diff --git a/.env.development b/.env.development new file mode 100644 index 000000000..66244ad78 --- /dev/null +++ b/.env.development @@ -0,0 +1,5 @@ +REACT_APP_GROUPS_API_URL=https://api.topcoder-dev.com/v6/groups +REACT_APP_TERMS_API_URL=https://api.topcoder-dev.com/v5/terms +REACT_APP_RESOURCES_API_URL=https://api.topcoder-dev.com/v6/resources +REACT_APP_MEMBER_API_URL=https://api.topcoder-dev.com/v6/members +REACT_APP_RESOURCE_ROLES_API_URL=https://api.topcoder-dev.com/v6/resource-roles diff --git a/.env.production b/.env.production new file mode 100644 index 000000000..d2b109d06 --- /dev/null +++ b/.env.production @@ -0,0 +1,5 @@ +REACT_APP_GROUPS_API_URL=https://api.topcoder.com/v6/groups +REACT_APP_TERMS_API_URL=https://api.topcoder.com/v5/terms +REACT_APP_RESOURCES_API_URL=https://api.topcoder.com/v6/resources +REACT_APP_MEMBER_API_URL=https://api.topcoder.com/v6/members +REACT_APP_RESOURCE_ROLES_API_URL=https://api.topcoder.com/v6/resource-roles diff --git a/package.json b/package.json index 9bc448381..215e645de 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,14 @@ "sb:build": "storybook build -o build/storybook" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/language": "^6.12.2", + "@codemirror/lint": "^6.9.5", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.6.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.40.0", "@datadog/browser-logs": "^4.50.1", "@hello-pangea/dnd": "^18.0.1", "@heroicons/react": "^1.0.6", @@ -31,6 +39,7 @@ "@stripe/stripe-js": "1.54.2", "@tinymce/tinymce-react": "^6.3.0", "@types/codemirror": "5.60.17", + "@uiw/react-codemirror": "^4.25.8", "amazon-s3-uri": "^0.1.1", "apexcharts": "^3.54.1", "axios": "^1.13.2", @@ -51,6 +60,7 @@ "express": "^4.22.1", "express-fileupload": "^1.5.2", "express-interceptor": "^1.2.0", + "fflate": "^0.8.2", "filestack-js": "^3.44.2", "highcharts": "^10.3.3", "highcharts-react-official": "^3.2.3", diff --git a/src/apps/accounts/src/settings/tabs/tools/devices/Devices.tsx b/src/apps/accounts/src/settings/tabs/tools/devices/Devices.tsx index 314c87953..f471f983f 100644 --- a/src/apps/accounts/src/settings/tabs/tools/devices/Devices.tsx +++ b/src/apps/accounts/src/settings/tabs/tools/devices/Devices.tsx @@ -24,6 +24,8 @@ import { WearableIcon, } from '~/apps/accounts/src/lib' +import { shouldUseUpdateTraitAction } from '../trait-action.utils' + import styles from './Devices.module.scss' interface DevicesProps { @@ -315,7 +317,9 @@ const Devices: FC = (props: DevicesProps) => { }, }] - const action = props.devicesTrait ? updateMemberTraitsAsync : createMemberTraitsAsync + const action = shouldUseUpdateTraitAction(props.devicesTrait, deviceTypesData) + ? updateMemberTraitsAsync + : createMemberTraitsAsync action( props.profile.handle, diff --git a/src/apps/accounts/src/settings/tabs/tools/service-provider/ServiceProvider.tsx b/src/apps/accounts/src/settings/tabs/tools/service-provider/ServiceProvider.tsx index 4de9e7c0d..8a8a51e6a 100644 --- a/src/apps/accounts/src/settings/tabs/tools/service-provider/ServiceProvider.tsx +++ b/src/apps/accounts/src/settings/tabs/tools/service-provider/ServiceProvider.tsx @@ -14,6 +14,8 @@ import { TelevisionServiceProviderIcon, } from '~/apps/accounts/src/lib' +import { shouldUseUpdateTraitAction } from '../trait-action.utils' + import { serviceProviderTypes } from './service-provider-types.config' import styles from './ServiceProvider.module.scss' @@ -216,7 +218,9 @@ const ServiceProvider: FC = (props: ServiceProviderProps) }, }] - const action = props.serviceProviderTrait ? updateMemberTraitsAsync : createMemberTraitsAsync + const action = shouldUseUpdateTraitAction(props.serviceProviderTrait, serviceProviderTypesData) + ? updateMemberTraitsAsync + : createMemberTraitsAsync action( props.profile.handle, diff --git a/src/apps/accounts/src/settings/tabs/tools/software/Software.tsx b/src/apps/accounts/src/settings/tabs/tools/software/Software.tsx index 340b48bfd..2e3d116c5 100644 --- a/src/apps/accounts/src/settings/tabs/tools/software/Software.tsx +++ b/src/apps/accounts/src/settings/tabs/tools/software/Software.tsx @@ -7,6 +7,8 @@ import { createMemberTraitsAsync, updateMemberTraitsAsync, UserProfile, UserTrai import { Button, Collapsible, ConfirmModal, IconOutline, InputSelect, InputText } from '~/libs/ui' import { SettingSection, SoftwareIcon } from '~/apps/accounts/src/lib' +import { shouldUseUpdateTraitAction } from '../trait-action.utils' + import { softwareTypes } from './software-types.config' import styles from './Software.module.scss' @@ -170,7 +172,9 @@ const Software: FC = (props: SoftwareProps) => { }, }] - const action = props.softwareTrait ? updateMemberTraitsAsync : createMemberTraitsAsync + const action = shouldUseUpdateTraitAction(props.softwareTrait, softwareTypesData) + ? updateMemberTraitsAsync + : createMemberTraitsAsync action( props.profile.handle, diff --git a/src/apps/accounts/src/settings/tabs/tools/subscriptions/Subscriptions.tsx b/src/apps/accounts/src/settings/tabs/tools/subscriptions/Subscriptions.tsx index 491018f9b..041b45da9 100644 --- a/src/apps/accounts/src/settings/tabs/tools/subscriptions/Subscriptions.tsx +++ b/src/apps/accounts/src/settings/tabs/tools/subscriptions/Subscriptions.tsx @@ -7,6 +7,8 @@ import { createMemberTraitsAsync, updateMemberTraitsAsync, UserProfile, UserTrai import { Button, Collapsible, ConfirmModal, IconOutline, InputText } from '~/libs/ui' import { SettingSection, SubscriptionsIcon } from '~/apps/accounts/src/lib' +import { shouldUseUpdateTraitAction } from '../trait-action.utils' + import styles from './Subscriptions.module.scss' interface SubscriptionsProps { @@ -150,7 +152,9 @@ const Subscriptions: FC = (props: SubscriptionsProps) => { setIsSaving(false) }) } else { - const action = props.subscriptionsTrait ? updateMemberTraitsAsync : createMemberTraitsAsync + const action = shouldUseUpdateTraitAction(props.subscriptionsTrait, subscriptionsTypesData) + ? updateMemberTraitsAsync + : createMemberTraitsAsync action( props.profile.handle, [{ diff --git a/src/apps/accounts/src/settings/tabs/tools/trait-action.utils.spec.ts b/src/apps/accounts/src/settings/tabs/tools/trait-action.utils.spec.ts new file mode 100644 index 000000000..8eafa870a --- /dev/null +++ b/src/apps/accounts/src/settings/tabs/tools/trait-action.utils.spec.ts @@ -0,0 +1,18 @@ +import { shouldUseUpdateTraitAction } from './trait-action.utils' + +describe('shouldUseUpdateTraitAction', () => { + it('returns true when the initial trait exists', () => { + expect(shouldUseUpdateTraitAction({ traitId: 'software' }, undefined)) + .toBe(true) + }) + + it('returns true when local traits exist even without the initial trait', () => { + expect(shouldUseUpdateTraitAction(undefined, [{ name: 'Chrome' }])) + .toBe(true) + }) + + it('returns false when both initial and local traits are missing', () => { + expect(shouldUseUpdateTraitAction(undefined, undefined)) + .toBe(false) + }) +}) diff --git a/src/apps/accounts/src/settings/tabs/tools/trait-action.utils.ts b/src/apps/accounts/src/settings/tabs/tools/trait-action.utils.ts new file mode 100644 index 000000000..f2e5e7420 --- /dev/null +++ b/src/apps/accounts/src/settings/tabs/tools/trait-action.utils.ts @@ -0,0 +1,13 @@ +import { UserTrait } from '~/libs/core' + +/** + * Determine whether tool traits should use update or create action. + * The initial trait prop can be stale while the user stays on the tab, + * so local list state is also considered to avoid duplicate create calls. + */ +export function shouldUseUpdateTraitAction( + initialTrait: UserTrait | undefined, + localTraitsData: UserTrait[] | undefined, +): boolean { + return Boolean(initialTrait || localTraitsData?.length) +} diff --git a/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.tsx b/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.tsx index c6243edfe..79c5cb656 100644 --- a/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.tsx +++ b/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.tsx @@ -24,13 +24,11 @@ import { Challenge, ChallengeFilterCriteria, ChallengePrizeSet, - ChallengeResource, ChallengeWinner, } from '../../lib/models' import { getChallengeById, - getChallengeResources, - getResourceRoles, + getChallengeSubmitterResources, updateChallengeById, } from '../../lib/services' import { @@ -247,54 +245,7 @@ export const ChallengeDetailsPage: FC = () => { setIsLoadingSubmitters(true) try { - const roles = await getResourceRoles() - const submitterRoleIds = roles - .filter(role => role.name.toLowerCase() - .includes('submitter')) - .map(role => role.id) - - if (submitterRoleIds.length === 0) { - setSubmitterOptions([{ label: 'Select submitter', value: '' }]) - setSubmitterHandleByUserId({}) - return - } - - const resourcesByRole = await Promise.all( - submitterRoleIds.map(async roleId => { - const resources: ChallengeResource[] = [] - let page = 1 - const perPage = 200 - let totalPages = 1 - - do { - // eslint-disable-next-line no-await-in-loop - const response = await getChallengeResources(challengeId, { - page, - perPage, - roleId, - }) - resources.push(...response.data) - totalPages = response.totalPages - page += 1 - } while (page <= totalPages) - - return resources - }), - ) - - const deduplicatedByMemberId = new Map() - resourcesByRole.flat() - .forEach(resource => { - if (!deduplicatedByMemberId.has(resource.memberId)) { - deduplicatedByMemberId.set(resource.memberId, resource) - } - }) - - const submitters = Array.from(deduplicatedByMemberId.values()) - .sort((left, right) => ( - left.memberHandle.localeCompare(right.memberHandle) - )) - + const submitters = await getChallengeSubmitterResources(challengeId) const handleMap: Record = {} const options: InputSelectOption[] = [ { label: 'Select submitter', value: '' }, diff --git a/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.module.scss b/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.module.scss index 06ebfb150..ce05d3661 100644 --- a/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.module.scss +++ b/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.module.scss @@ -1,3 +1,5 @@ +@import '@libs/ui/styles/includes'; + .container { display: flex; flex-direction: column; @@ -6,3 +8,63 @@ .blockTableContainer { position: relative; } + +.headerButtons { + display: flex; + align-items: center; + gap: $sp-4; +} + +.uploadForm { + position: relative; + display: flex; + flex-direction: column; + gap: $sp-6; + width: 100%; + max-width: 620px; +} + +.uploadFormFields { + display: flex; + flex-direction: column; + gap: $sp-4; +} + +.fileInputContainer { + display: flex; + flex-direction: column; + gap: $sp-1; +} + +.inputLabel { + font-family: $font-barlow; + font-weight: $font-weight-semibold; + font-size: 15px; + color: $black-100; +} + +.fileInput { + width: 100%; +} + +.selectedFile { + font-family: $font-roboto; + font-size: 14px; + color: $black-80; +} + +.actionButtons { + display: flex; + justify-content: flex-end; + gap: $sp-4; +} + +.dialogLoadingSpinnerContainer { + position: absolute; + left: $sp-4; + bottom: $sp-2; + + .spinner { + background: none; + } +} diff --git a/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx b/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx index 41542a7e8..2d9e172b9 100644 --- a/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx +++ b/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx @@ -1,11 +1,24 @@ /** * Manage Submission Page. */ -import { FC, useMemo } from 'react' +import { + ChangeEvent, + FC, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' import { useParams } from 'react-router-dom' +import { toast } from 'react-toastify' import classNames from 'classnames' -import { LinkButton } from '~/libs/ui' +import { + BaseModal, + Button, + LinkButton, + LoadingSpinner, +} from '~/libs/ui' import { useDownloadSubmission, @@ -23,12 +36,22 @@ import { } from '../../lib/hooks' import { ActionLoading, + FieldSingleSelect, PageWrapper, SubmissionTable, TableLoading, TableNoRecord, } from '../../lib' -import { checkIsMM, getSubmissionReprocessTopic } from '../../lib/utils' +import { SelectOption } from '../../lib/models' +import { + getChallengeSubmitterResources, + uploadManualSubmission, +} from '../../lib/services' +import { + checkIsMM, + getSubmissionReprocessTopic, + handleError, +} from '../../lib/utils' import styles from './ManageSubmissionPage.module.scss' @@ -36,6 +59,191 @@ interface Props { className?: string } +interface SubmissionsContentProps { + isLoading: boolean + submissions: useManageChallengeSubmissionsProps['submissions'] + isDoingAvScan: useManageAVScanProps['isLoading'] + doPostBusEventAvScan: useManageAVScanProps['doPostBusEvent'] + isDownloadingSubmission: useDownloadSubmissionProps['isLoading'] + downloadSubmission: useDownloadSubmissionProps['downloadSubmission'] + isRemovingSubmission: useManageChallengeSubmissionsProps['isRemovingSubmission'] + doRemoveSubmission: useManageChallengeSubmissionsProps['doRemoveSubmission'] + isRemovingReviewSummations: useManageChallengeSubmissionsProps['isRemovingReviewSummations'] + doRemoveReviewSummations: useManageChallengeSubmissionsProps['doRemoveReviewSummations'] + isRunningTest: useManageBusEventProps['isRunningTest'] + doPostBusEvent: useManageBusEventProps['doPostBusEvent'] + showSubmissionHistory: useManageChallengeSubmissionsProps['showSubmissionHistory'] + setShowSubmissionHistory: useManageChallengeSubmissionsProps['setShowSubmissionHistory'] + isMM: boolean + isReprocessingSubmission: useManageSubmissionReprocessProps['isLoading'] + doReprocessSubmission: useManageSubmissionReprocessProps['doReprocessSubmission'] + canReprocessSubmission: boolean + isDoingAvScanBool: useManageAVScanProps['isLoadingBool'] + isDownloadingSubmissionBool: useDownloadSubmissionProps['isLoadingBool'] + isRemovingSubmissionBool: useManageChallengeSubmissionsProps['isRemovingSubmissionBool'] + isRunningTestBool: useManageBusEventProps['isRunningTestBool'] + isRemovingReviewSummationsBool: useManageChallengeSubmissionsProps['isRemovingReviewSummationsBool'] + isReprocessingSubmissionBool: useManageSubmissionReprocessProps['isLoadingBool'] +} + +interface ManualSubmissionUploadModalProps { + open: boolean + onClose: () => void + selectedHandle?: SelectOption + setSelectedHandle: (value: SelectOption) => void + isUploading: boolean + isLoadingSubmitters: boolean + submitterOptions: SelectOption[] + handleFileChange: (event: ChangeEvent) => void + selectedFile?: File + handleUploadSubmission: () => void +} + +/** + * Renders the submission table area, including loading and empty states, for + * the submission management page. + * @param {SubmissionsContentProps} props submission data and action state. + * @returns {JSX.Element} the submission management content for the page body. + */ +const SubmissionsContent: FC = ( + props: SubmissionsContentProps, +) => { + const shouldShowActionLoading = props.isDoingAvScanBool + || props.isDownloadingSubmissionBool + || props.isRemovingSubmissionBool + || props.isRunningTestBool + || props.isRemovingReviewSummationsBool + || props.isReprocessingSubmissionBool + + if (props.isLoading) { + return + } + + if (props.submissions.length === 0) { + return + } + + return ( +
+ + + {shouldShowActionLoading && } +
+ ) +} + +/** + * Renders the manual submission upload dialog with challenge-scoped submitter + * options so admins can only select registered submitter resources. + * @param {ManualSubmissionUploadModalProps} props upload form state and handlers. + * @returns {JSX.Element} the upload dialog for manual submission imports. + */ +const ManualSubmissionUploadModal: FC = ( + props: ManualSubmissionUploadModalProps, +) => { + const isHandleSelectDisabled = props.isUploading + || props.isLoadingSubmitters + || props.submitterOptions.length === 0 + const memberHandleHint = !props.isLoadingSubmitters + && props.submitterOptions.length === 0 + ? 'No submitter resources are registered for this challenge.' + : undefined + const memberHandlePlaceholder = props.isLoadingSubmitters + ? 'Loading submitter handles...' + : 'Start typing a handle' + + return ( + +
+
+ +
+ + + {props.selectedFile && ( + + {props.selectedFile.name} + + )} +
+
+
+ + +
+ + {props.isUploading && ( +
+ +
+ )} +
+
+ ) +} + export const ManageSubmissionPage: FC = (props: Props) => { const { challengeId = '' }: { challengeId?: string } = useParams<{ challengeId: string @@ -64,6 +272,7 @@ export const ManageSubmissionPage: FC = (props: Props) => { doRemoveReviewSummations, showSubmissionHistory, setShowSubmissionHistory, + refresh, }: useManageChallengeSubmissionsProps = useManageChallengeSubmissions(challengeId) @@ -85,65 +294,177 @@ export const ManageSubmissionPage: FC = (props: Props) => { = useManageSubmissionReprocess(submissionReprocessTopic) const isLoading = isLoadingSubmission || isLoadingChallenge + const [isUploadModalOpen, setIsUploadModalOpen] = useState(false) + const [selectedHandle, setSelectedHandle] + = useState() + const [selectedFile, setSelectedFile] = useState() + const [isUploading, setIsUploading] = useState(false) + const [submitterOptions, setSubmitterOptions] = useState([]) + const [isLoadingSubmitters, setIsLoadingSubmitters] = useState(false) + + const resetUploadForm = useCallback(() => { + setSelectedHandle(undefined) + setSelectedFile(undefined) + }, []) + + const openUploadModal = useCallback(() => { + setIsUploadModalOpen(true) + }, []) + + const closeUploadModal = useCallback(() => { + if (isUploading) { + return + } + + setIsUploadModalOpen(false) + resetUploadForm() + }, [isUploading, resetUploadForm]) + + const handleFileChange = useCallback( + (event: ChangeEvent) => { + const nextFile = event.target.files?.[0] + setSelectedFile(nextFile ?? undefined) + }, + [], + ) + + const handleUploadSubmission = useCallback(async () => { + if (!challengeId || !selectedFile || !selectedHandle?.value) { + return + } + + try { + setIsUploading(true) + await uploadManualSubmission({ + challengeId, + file: selectedFile, + fileName: selectedFile.name, + memberHandle: String(selectedHandle.label), + memberId: selectedHandle.value, + }) + + toast.success('Submission uploaded successfully', { + toastId: 'Manual submission upload', + }) + setIsUploadModalOpen(false) + resetUploadForm() + refresh() + } catch (error) { + handleError(error) + } finally { + setIsUploading(false) + } + }, [challengeId, selectedFile, selectedHandle, resetUploadForm, refresh]) + + useEffect(() => { + let isCancelled = false + + if (!challengeId) { + setSubmitterOptions([]) + setSelectedHandle(undefined) + return undefined + } + + setIsLoadingSubmitters(true) + + const loadSubmitters = async (): Promise => { + try { + const submitters = await getChallengeSubmitterResources(challengeId) + if (isCancelled) { + return + } + + const nextOptions = submitters.map(submitter => ({ + label: submitter.memberHandle, + value: submitter.memberId, + })) + setSubmitterOptions(nextOptions) + setSelectedHandle(currentValue => ( + currentValue && nextOptions.some( + option => option.value === currentValue.value, + ) + ? currentValue + : undefined + )) + } catch (error) { + if (!isCancelled) { + setSubmitterOptions([]) + setSelectedHandle(undefined) + handleError(error) + } + } finally { + if (!isCancelled) { + setIsLoadingSubmitters(false) + } + } + } + + loadSubmitters() + + return () => { + isCancelled = true + } + }, [challengeId]) return ( - Back - +
+ + + Back + +
)} > - {isLoading ? ( - - ) : ( - <> - {submissions.length === 0 ? ( - - ) : ( -
- - - {(isDoingAvScanBool - || isDownloadingSubmissionBool - || isRemovingSubmissionBool - || isRunningTestBool - || isRemovingReviewSummationsBool - || isReprocessingSubmissionBool) && ( - - )} -
- )} - - )} + + +
) } diff --git a/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.spec.ts b/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.spec.ts new file mode 100644 index 000000000..884b4bb7e --- /dev/null +++ b/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.spec.ts @@ -0,0 +1,30 @@ +import { + canOpenReviewUi, + getReviewUiChallengeUrl, +} from './reviewUiLink' + +describe('ChallengeList review UI helpers', () => { + describe('canOpenReviewUi', () => { + it('returns true when challenge has a uuid id', () => { + expect(canOpenReviewUi('challenge-uuid')) + .toBe(true) + }) + + it('returns false when challenge id is empty', () => { + expect(canOpenReviewUi('')) + .toBe(false) + }) + + it('returns false when challenge id is only whitespace', () => { + expect(canOpenReviewUi(' ')) + .toBe(false) + }) + }) + + describe('getReviewUiChallengeUrl', () => { + it('builds review ui url using challenge id path', () => { + expect(getReviewUiChallengeUrl('https://review.topcoder-dev.com', 'challenge-uuid')) + .toBe('https://review.topcoder-dev.com/challenge-uuid') + }) + }) +}) diff --git a/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx b/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx index 55df372c2..988a7f01d 100644 --- a/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx +++ b/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx @@ -24,6 +24,10 @@ import { Paging } from '../../models/challenge-management/Pagination' import { checkIsMM } from '../../utils/challenge' import { MobileListView } from './MobileListView' +import { + canOpenReviewUi, + getReviewUiChallengeUrl, +} from './reviewUiLink' import styles from './ChallengeList.module.scss' export interface ChallengeListProps { @@ -180,11 +184,12 @@ const Actions: FC<{ }, ) + const hasChallengeDetailsAccess = canOpenReviewUi(props.challenge.id) const hasProjectId - = 'projectId' in props.challenge - && props.challenge.projectId !== undefined - const hasLegacyId - = 'legacyId' in props.challenge && props.challenge.legacyId !== undefined + = typeof props.challenge.projectId === 'number' + && props.challenge.projectId > 0 + const hasWorkManagerAccess = hasProjectId && hasChallengeDetailsAccess + const hasReviewUiAccess = canOpenReviewUi(props.challenge.id) return (
@@ -234,17 +239,20 @@ const Actions: FC<{ classNames={{ menu: 'challenge-list-actions-dropdown-menu' }} > diff --git a/src/apps/admin/src/lib/components/ChallengeList/reviewUiLink.ts b/src/apps/admin/src/lib/components/ChallengeList/reviewUiLink.ts new file mode 100644 index 000000000..7a825ebfa --- /dev/null +++ b/src/apps/admin/src/lib/components/ChallengeList/reviewUiLink.ts @@ -0,0 +1,16 @@ +/** + * Returns whether the Review UI link can be opened for a challenge. + */ +export function canOpenReviewUi(challengeId?: string): boolean { + return Boolean(challengeId?.trim()) +} + +/** + * Builds the Review UI URL for a challenge id. + */ +export function getReviewUiChallengeUrl( + reviewUiBaseUrl: string, + challengeId: string, +): string { + return `${reviewUiBaseUrl}/${challengeId}` +} diff --git a/src/apps/admin/src/lib/components/DialogUserEmails/DialogUserEmails.module.scss b/src/apps/admin/src/lib/components/DialogUserEmails/DialogUserEmails.module.scss new file mode 100644 index 000000000..76bd5ca02 --- /dev/null +++ b/src/apps/admin/src/lib/components/DialogUserEmails/DialogUserEmails.module.scss @@ -0,0 +1,89 @@ +@import '@libs/ui/styles/includes'; + +.modal { + width: 1240px !important; + max-width: calc(100vw - 32px) !important; +} + +.container { + display: flex; + flex-direction: column; + gap: 20px; + + th:first-child { + padding-left: 16px !important; + } +} + +.actionButtons { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.tableCellNoWrap { + white-space: nowrap; + text-align: left !important; +} + +.statusCell { + text-align: center !important; + width: 90px; +} + +.emailStatus { + align-items: center; + display: inline-flex; + justify-content: center; + min-height: 24px; + min-width: 24px; + + svg { + width: 20px; + height: 20px; + } +} + +.emailStatusDelivered { + color: $green-120; +} + +.emailStatusFailed { + color: $red-110; +} + +.tableCell { + min-width: 220px; + white-space: break-spaces !important; + text-align: left !important; +} + +.loadingSpinnerContainer { + position: relative; + height: 100px; + + .spinner { + background: none; + } +} + +.noRecordFound { + padding: 16px 16px 32px; + text-align: center; +} + +.desktopTable { + overflow-x: auto; + overflow-y: visible; + + thead th { + position: sticky; + top: 0; + z-index: 2; + background: $tc-white; + } + + td { + vertical-align: middle; + } +} diff --git a/src/apps/admin/src/lib/components/DialogUserEmails/DialogUserEmails.tsx b/src/apps/admin/src/lib/components/DialogUserEmails/DialogUserEmails.tsx new file mode 100644 index 000000000..2412dc32d --- /dev/null +++ b/src/apps/admin/src/lib/components/DialogUserEmails/DialogUserEmails.tsx @@ -0,0 +1,187 @@ +/** + * Dialog with SendGrid emails sent to a member in the last 30 days. + */ +import { FC, useCallback, useEffect, useMemo, useState } from 'react' +import _ from 'lodash' +import classNames from 'classnames' +import moment from 'moment' + +import { + BaseModal, + Button, + IconOutline, + LoadingSpinner, + Table, + TableColumn, +} from '~/libs/ui' + +import { + MSG_NO_RECORD_FOUND, + TABLE_DATE_FORMAT, +} from '../../../config/index.config' +import { MemberSendgridEmail, UserInfo } from '../../models' +import { fetchMemberSendgridEmails } from '../../services' +import { handleError } from '../../utils' + +import styles from './DialogUserEmails.module.scss' + +interface Props { + className?: string + open: boolean + setOpen: (isOpen: boolean) => void + userInfo: UserInfo +} + +export const DialogUserEmails: FC = (props: Props) => { + const [isLoading, setIsLoading] = useState(false) + const [emails, setEmails] = useState([]) + + const handleClose = useCallback(() => { + props.setOpen(false) + }, [props.setOpen]) + + useEffect(() => { + if (!props.open) { + return undefined + } + + let active = true + setIsLoading(true) + fetchMemberSendgridEmails(props.userInfo.handle) + .then(result => { + if (active) { + setEmails(result) + } + }) + .catch(error => { + if (active) { + setEmails([]) + } + + handleError(error) + }) + .finally(() => { + if (active) { + setIsLoading(false) + } + }) + + return () => { + active = false + } + }, [props.open, props.userInfo.handle]) + + const columns = useMemo[]>( + () => [ + { + className: styles.tableCell, + columnId: 'subject', + label: 'Subject', + propertyName: 'subject', + type: 'text', + }, + { + className: styles.tableCellNoWrap, + columnId: 'toEmail', + label: 'To Email', + propertyName: 'toEmail', + type: 'text', + }, + { + className: styles.statusCell, + columnId: 'status', + label: 'Status', + propertyName: 'status', + renderer: (data: MemberSendgridEmail) => { + const status = data.status || '-' + const isDelivered = status.toLowerCase() === 'delivered' + + return ( + + {isDelivered + ? + : } + + ) + }, + type: 'element', + }, + { + className: styles.tableCellNoWrap, + columnId: 'timestamp', + label: 'Timestamp', + propertyName: 'timestamp', + renderer: (data: MemberSendgridEmail) => { + if (data.timestamp === '-') { + return
-
+ } + + const timestamp = moment(data.timestamp) + const timestampDisplay = timestamp.isValid() + ? timestamp + .local() + .format(TABLE_DATE_FORMAT) + : data.timestamp + + return
{timestampDisplay}
+ }, + type: 'element', + }, + ], + [], + ) + + return ( + +
+ {isLoading ? ( +
+ +
+ ) : ( + <> + {emails.length === 0 ? ( +

+ {MSG_NO_RECORD_FOUND} +

+ ) : ( + + )} + + )} +
+ +
+ + + ) +} + +export default DialogUserEmails diff --git a/src/apps/admin/src/lib/components/DialogUserEmails/index.ts b/src/apps/admin/src/lib/components/DialogUserEmails/index.ts new file mode 100644 index 000000000..51373a8c1 --- /dev/null +++ b/src/apps/admin/src/lib/components/DialogUserEmails/index.ts @@ -0,0 +1,2 @@ +export * from './DialogUserEmails' +export { default as DialogUserEmails } from './DialogUserEmails' diff --git a/src/apps/admin/src/lib/components/UsersTable/UsersTable.module.scss b/src/apps/admin/src/lib/components/UsersTable/UsersTable.module.scss index 0fd56a9bb..a57f11bcd 100644 --- a/src/apps/admin/src/lib/components/UsersTable/UsersTable.module.scss +++ b/src/apps/admin/src/lib/components/UsersTable/UsersTable.module.scss @@ -57,7 +57,7 @@ } .blockColumnAction { - width: 320px; + width: 430px; @include ltelg { width: 60px; diff --git a/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx b/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx index 4316e4de7..69ac43b84 100644 --- a/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx +++ b/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx @@ -27,6 +27,7 @@ import { DialogEditUserGroups } from '../DialogEditUserGroups' import { DialogEditUserSSOLogin } from '../DialogEditUserSSOLogin' import { DialogEditUserTerms } from '../DialogEditUserTerms' import { DialogEditUserStatus } from '../DialogEditUserStatus' +import { DialogUserEmails } from '../DialogUserEmails' import { DialogUserStatusHistory } from '../DialogUserStatusHistory' import { DialogDeleteUser } from '../DialogDeleteUser' import { DropdownMenuButton } from '../common/DropdownMenuButton' @@ -224,6 +225,9 @@ export const UsersTable: FC = props => { const [showDialogStatusHistory, setShowDialogStatusHistory] = useState< UserInfo | undefined >() + const [showDialogUserEmails, setShowDialogUserEmails] = useState< + UserInfo | undefined + >() const [showDialogDeleteUser, setShowDialogDeleteUser] = useState< UserInfo | undefined >() @@ -448,6 +452,8 @@ export const UsersTable: FC = props => { setShowDialogEditUserTerms(data) } else if (item === 'SSO Logins') { setShowDialogEditSSOLogin(data) + } else if (item === 'View Emails') { + setShowDialogUserEmails(data) } else if (item === 'Deactivate') { setShowDialogEditUserStatus(data) } else if (item === 'Activate') { @@ -480,6 +486,7 @@ export const UsersTable: FC = props => { 'Groups', 'Terms', 'SSO Logins', + 'View Emails', ...(data.active ? ['Deactivate', 'Delete'] : ['Activate', 'Delete']), @@ -513,6 +520,13 @@ export const UsersTable: FC = props => { Edit + + + ) => { + setJobDescription(event.target.value) + }} + /> +
+ + +
+ {errorMessage && ( +

{errorMessage}

+ )} + + +
+

Filter

+
+ ) => { + const value = (event.target.value || []) as InputMultiselectOption[] + setSelectedSkills(value) + setHasSearched(value.length > 0) + }} + /> +
+
+ ) => { + setSelectedCountry(event.target.value || 'all') + }} + placeholder='Select country' + /> +
+ + +
+ +
+
+ + +
+ {!hasSearched && ( +
+ Person search +

Find the right talent

+

+ Paste a job description on the left and hit  + Search +  - Our AI will match you with the + best candidates from our network. +

+
+ )} + + {hasSearched && ( +
+
+

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

+
+ Sort by + undefined} + /> +
+
+ {isSearchingMembers && ( +
+

Searching talent...

+
+ )} + {!isSearchingMembers && filteredResults.length === 0 && ( +
+

No matching talent found

+

Try changing filters or using a different job description.

+
+ )} + {!isSearchingMembers && filteredResults.length > 0 && ( + <> +
+ {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 new file mode 100644 index 000000000..83b5982ba --- /dev/null +++ b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss @@ -0,0 +1,294 @@ +@import '@libs/ui/styles/includes'; + +.talentCard { + background: $tc-white; + border: 0; + border-radius: 12px; + padding: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.cardMain { + padding: 24px; +} + +.topRow { + display: flex; + gap: 16px; + align-items: flex-start; + width: 100%; +} + +.avatarWrap { + position: relative; + flex-shrink: 0; +} + +.profilePic { + width: 80px; + height: 80px; + + :global(span) { + font-size: 30px !important; + letter-spacing: 0.04em; + text-transform: uppercase; + } +} + +.verifiedBadge { + position: absolute; + right: 2px; + top: 2px; + width: 20px; + height: 20px; + background: $tc-white; + color: $turq-120; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + z-index: 2; + + :global(svg) { + width: 18px; + height: 18px; + display: block; + } +} + +.headContent { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-width: 0; +} + +.cardHeader { + display: flex; + justify-content: space-between; + gap: $sp-2; + align-items: center; + min-width: 0; +} + +.handleText { + margin: 0; + color: $black-100; + font-size: 20px; + line-height: 26px; + font-weight: 700; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + @include ltemd { + font-size: 16px; + line-height: 22px; + } +} + +.nameText { + margin: 0; + color: $black-100; + font-size: 14px; + line-height: 20px; + font-weight: 500; +} + +.matchPill { + background: $teal-120; + color: $tc-white; + border-radius: 999px; + padding: 3px 10px; + font-size: 14px; + line-height: 22px; + font-weight: 500; + white-space: nowrap; + align-self: flex-start; + flex-shrink: 0; +} + +.locationRow { + display: flex; + align-items: flex-start; + gap: 6px; +} + +.locationIcon { + flex-shrink: 0; + width: 20px; + height: 20px; + margin-top: 1px; + color: $turq-160; +} + +.locationText { + margin: 0; + color: $black-100; + font-size: 14px; + line-height: 22px; + font-weight: 400; +} + +.statusRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; +} + +.statusPill { + border-radius: 999px; + padding: 2px 10px; + font-size: 14px; + line-height: 22px; + font-weight: 500; +} + +.statusPillActive { + background: $green-25; + color: $turq-180; +} + +.statusPillInactive { + background: $black-10; + color: $black-80; +} + +.availability { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 14px; + line-height: 22px; + font-weight: 500; +} + +.availabilityYes { + color: $turq-160; +} + +.availabilityNo { + color: $black-80; +} + +.availabilityIcon { + flex-shrink: 0; + width: 18px; + height: 18px; +} + +.cardFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-2; + padding: 16px 24px 20px; + border-top: 1px solid $black-10; + font-size: 14px; + line-height: 22px; + font-weight: 400; +} + +.footerMatched { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.experienceLink { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + color: $black-100; + text-decoration: none; + + &:hover { + color: $turq-160; + } + + &:focus-visible { + outline: 2px solid $link-blue; + outline-offset: 2px; + border-radius: 2px; + } +} + +.experienceLinkIcon { + width: 16px; + height: 16px; + color: $turq-160; +} + +.matchedSkillsText { + margin: 0; + color: $black-100; + font-weight: 500; +} + +.infoButton { + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + color: $black-60; + border-radius: 50%; + + &:hover { + color: $black-80; + } + + &:focus-visible { + outline: 2px solid $link-blue; + outline-offset: 2px; + } +} + +.infoIcon { + width: 18px; + height: 18px; + display: block; +} + +.tooltipBody { + text-align: left; +} + +.tooltipTitle { + margin: 0 0 8px; + font-weight: 700; + font-size: 13px; + line-height: 18px; +} + +.tooltipLines { + margin: 0; + padding-left: 0; + list-style-type: disc; + list-style-position: inside; + font-size: 12px; + line-height: 16px; + font-weight: 400; +} + +.tooltipSkillLine { + display: list-item; + + & + & { + margin-top: 4px; + } +} + +.tooltipSkillName { + font-weight: 700; +} 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 new file mode 100644 index 000000000..7d636e228 --- /dev/null +++ b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx @@ -0,0 +1,186 @@ +/* eslint-disable complexity */ +import { FC, ReactElement, useMemo } from 'react' +import classNames from 'classnames' + +import { EnvironmentConfig } from '~/config' +import { ProfilePicture } from '~/libs/shared' +import { IconOutline, IconSolid, Tooltip } from '~/libs/ui' + +import styles from './TalentResultCard.module.scss' + +interface MatchedSkill { + id: string + name: string + wins: number + submitted: number +} + +interface TalentResultCardTalent { + id: string + handle: string + isVerified: boolean + isRecentlyActive: boolean + location: string + matchIndex: number + matchedSkills: MatchedSkill[] + name: string + openToWork?: boolean + photoUrl?: string +} + +interface TalentResultCardProps { + talent: TalentResultCardTalent +} + +function getUniqueMatchedSkills(talent: TalentResultCardTalent): TalentResultCardTalent['matchedSkills'] { + const seen = new Set() + return talent.matchedSkills.filter((skill: MatchedSkill) => { + const key = `${skill.id}-${skill.name}` + if (seen.has(key)) { + return false + } + + seen.add(key) + return true + }) +} + +function buildMatchedSkillsTooltipContent( + count: number, + skills: MatchedSkill[], +): ReactElement { + return ( +
+

+ {`${count} Matched Skills:`} +

+
    + {skills.map((skill: MatchedSkill) => ( +
  • + {skill.name} + {`: ${skill.wins} wins, ${skill.submitted} submissions`} +
  • + ))} +
+
+ ) +} + +export const TalentResultCard: FC = (props: TalentResultCardProps) => { + const talent: TalentResultCardTalent = props.talent + const uniqueSkills = useMemo(() => getUniqueMatchedSkills(talent), [talent]) + const isVerifiedProfile = talent.isVerified === true + const displayName = String(talent.name || '') + .trim() + const [firstName, ...lastNameParts] = displayName.split(/\s+/) + const lastName = lastNameParts.join(' ') + .trim() + + const isActive = talent.isRecentlyActive === true + const openToWork = talent.openToWork + const profileUrl = `${EnvironmentConfig.USER_PROFILE_URL}/${encodeURIComponent(talent.handle)}` + const displayHandle = String(talent.handle || '') + .trim() + const matchedSkillLabel = uniqueSkills.length === 1 ? 'matched skill' : 'matched skills' + + return ( +
+
+
+
+ + {isVerifiedProfile && ( + + + + )} +
+
+
+ {displayHandle} + + {`${talent.matchIndex}% Match`} + +
+

{talent.name}

+
+ + {talent.location} +
+
+ + {isActive ? 'Active' : 'Inactive'} + + {openToWork !== undefined && ( + + {openToWork ? ( + + ) : ( + + )} + {openToWork ? 'Available' : 'Unavailable'} + + )} +
+
+
+
+
+
+ + {`${uniqueSkills.length} ${matchedSkillLabel}`} + + {uniqueSkills.length > 0 && ( + + + + )} +
+ + Experience Match + + +
+
+ ) +} + +export default TalentResultCard diff --git a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/index.ts b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/index.ts new file mode 100644 index 000000000..4dd223e55 --- /dev/null +++ b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/index.ts @@ -0,0 +1 @@ +export { default as TalentResultCard } from './TalentResultCard' diff --git a/src/apps/engagements/src/components/assignment-card/AssignmentCard.spec.tsx b/src/apps/engagements/src/components/assignment-card/AssignmentCard.spec.tsx index 0d057a96c..3e541b3d7 100644 --- a/src/apps/engagements/src/components/assignment-card/AssignmentCard.spec.tsx +++ b/src/apps/engagements/src/components/assignment-card/AssignmentCard.spec.tsx @@ -24,6 +24,11 @@ jest.mock('remark-gfm', () => ({ default: jest.fn(), })) +jest.mock('rehype-raw', () => ({ + __esModule: true, + default: jest.fn(), +})) + jest.mock('~/config', () => ({ EnvironmentConfig: { URLS: { diff --git a/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx b/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx index 46be7f5a7..517eeb818 100644 --- a/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx +++ b/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx @@ -1,12 +1,17 @@ import type { FC, ReactNode } from 'react' import { useCallback, useMemo } from 'react' import ReactMarkdown, { type Components, type Options as ReactMarkdownOptions } from 'react-markdown' +import rehypeRaw from 'rehype-raw' import remarkFrontmatter from 'remark-frontmatter' import remarkGfm from 'remark-gfm' import { Button, IconSolid } from '~/libs/ui' import { EnvironmentConfig } from '~/config' +import { + renderRichTextToPlainText, + sanitizeRichTextSource, +} from '../../../../../libs/shared/lib/utils/rich-text' import type { Engagement, EngagementAssignment } from '../../lib/models' import { formatCurrencyAmount, @@ -123,6 +128,7 @@ const AssignmentCard: FC = (props: AssignmentCardProps) => const engagement = props.engagement const assignment = props.assignment const canContactTalentManager = props.canContactTalentManager ?? true + const contactEmail = props.contactEmail const skills = engagement.requiredSkills ?? [] const visibleSkills = skills.slice(0, 6) const extraSkillsCount = Math.max(0, skills.length - 6) @@ -131,11 +137,11 @@ const AssignmentCard: FC = (props: AssignmentCardProps) => engagement.timeZones ?? [], ) const handleContactTalentManagerClick = useCallback(() => { - props.onContactTalentManager(props.contactEmail) - }, [props.contactEmail, props.onContactTalentManager]) + props.onContactTalentManager(contactEmail) + }, [contactEmail, props.onContactTalentManager]) const descriptionSnippet = useMemo(() => ( - truncateText(engagement.description, DESCRIPTION_MAX_LENGTH) + truncateText(renderRichTextToPlainText(engagement.description), DESCRIPTION_MAX_LENGTH) ), [engagement.description]) const assignmentStatusLabel = useMemo( @@ -214,13 +220,14 @@ const AssignmentCard: FC = (props: AssignmentCardProps) =>
- {descriptionSnippet || 'Description not available.'} + {sanitizeRichTextSource(descriptionSnippet || 'Description not available.')}
diff --git a/src/apps/engagements/src/components/engagement-card/EngagementCard.tsx b/src/apps/engagements/src/components/engagement-card/EngagementCard.tsx index a504c5719..bd6c07033 100644 --- a/src/apps/engagements/src/components/engagement-card/EngagementCard.tsx +++ b/src/apps/engagements/src/components/engagement-card/EngagementCard.tsx @@ -1,10 +1,12 @@ import type { FC, ReactNode } from 'react' import ReactMarkdown, { type Components, type Options as ReactMarkdownOptions } from 'react-markdown' +import rehypeRaw from 'rehype-raw' import remarkFrontmatter from 'remark-frontmatter' import remarkGfm from 'remark-gfm' import { IconSolid } from '~/libs/ui' +import { sanitizeRichTextSource } from '../../../../../libs/shared/lib/utils/rich-text' import type { Engagement } from '../../lib/models' import { formatDuration, formatLocation } from '../../lib/utils' import { StatusBadge } from '../status-badge' @@ -99,13 +101,14 @@ const EngagementCard: FC = (props: EngagementCardProps) =>
- {engagement.description} + {sanitizeRichTextSource(engagement.description)}
diff --git a/src/apps/engagements/src/components/feedback-form/FeedbackForm.tsx b/src/apps/engagements/src/components/feedback-form/FeedbackForm.tsx index 4c0aa9884..f9a396872 100644 --- a/src/apps/engagements/src/components/feedback-form/FeedbackForm.tsx +++ b/src/apps/engagements/src/components/feedback-form/FeedbackForm.tsx @@ -180,7 +180,7 @@ const FeedbackForm: FC = (props: FeedbackFormProps) => {
({ + __esModule: true, + default: ({ children }: { children?: React.ReactNode }) => <>{children}, +})) + +jest.mock('remark-breaks', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('remark-frontmatter', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('remark-gfm', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('~/libs/ui', () => ({ + Button: (props: { + className?: string + disabled?: boolean + label: string + onClick?: () => void + }) => ( + + ), + IconOutline: { + ChatAltIcon: () => , + ExclamationIcon: () => , + }, + LoadingSpinner: () => Loading, +}), { virtual: true }) + +describe('MemberExperienceList', () => { + it('renders the newest experiences first', () => { + const experiences: MemberExperience[] = [ + { + createdAt: '2026-04-01T18:21:00.000Z', + engagementAssignmentId: 'assignment-1', + experienceText: 'This is my exp', + id: 'exp-1', + memberHandle: 'liuliquan', + memberId: '22655076', + updatedAt: '2026-04-01T18:21:00.000Z', + }, + { + createdAt: '2026-04-01T18:30:00.000Z', + engagementAssignmentId: 'assignment-1', + experienceText: 'this is my second exp', + id: 'exp-2', + memberHandle: 'liuliquan', + memberId: '22655076', + updatedAt: '2026-04-01T18:30:00.000Z', + }, + ] + + render() + + const newestExperience = screen.getByText('this is my second exp') + const oldestExperience = screen.getByText('This is my exp') + + expect( + newestExperience.compareDocumentPosition(oldestExperience), + ) + .toBe(Node.DOCUMENT_POSITION_FOLLOWING) + }) +}) diff --git a/src/apps/engagements/src/components/member-experience-list/MemberExperienceList.tsx b/src/apps/engagements/src/components/member-experience-list/MemberExperienceList.tsx index 4095a8fe1..b6b4756ae 100644 --- a/src/apps/engagements/src/components/member-experience-list/MemberExperienceList.tsx +++ b/src/apps/engagements/src/components/member-experience-list/MemberExperienceList.tsx @@ -38,7 +38,7 @@ const MemberExperienceList: FC = (props: MemberExperi } return [...experiences].sort( - (a, b) => parseDate(a.createdAt) - parseDate(b.createdAt), + (a, b) => parseDate(b.createdAt) - parseDate(a.createdAt), ) }, [experiences]) diff --git a/src/apps/engagements/src/lib/services/engagements.service.ts b/src/apps/engagements/src/lib/services/engagements.service.ts index 20d9c924d..e86af1e35 100644 --- a/src/apps/engagements/src/lib/services/engagements.service.ts +++ b/src/apps/engagements/src/lib/services/engagements.service.ts @@ -118,6 +118,7 @@ export interface GetEngagementsParams { countries?: string[] timeZones?: string[] search?: string + includePrivate?: boolean } const normalizePaginatedResponse = ( @@ -347,6 +348,7 @@ export const getEngagements = async ( } if (params.search) queryParams.append('search', params.search) + if (params.includePrivate) queryParams.append('includePrivate', 'true') if (params.skills?.length) { params.skills.forEach(skill => queryParams.append('requiredSkills', skill)) } diff --git a/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx b/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx index c2a406e64..d06386feb 100644 --- a/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx +++ b/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx @@ -1,6 +1,7 @@ import { FC, useCallback, useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import ReactMarkdown, { type Options as ReactMarkdownOptions } from 'react-markdown' +import rehypeRaw from 'rehype-raw' import remarkBreaks from 'remark-breaks' import remarkFrontmatter from 'remark-frontmatter' import remarkGfm from 'remark-gfm' @@ -9,6 +10,7 @@ import { EnvironmentConfig } from '~/config' import { authUrlLogin, useProfileCompleteness, useProfileContext } from '~/libs/core' import { Button, ContentLayout, IconOutline, IconSolid, LoadingSpinner } from '~/libs/ui' +import { sanitizeRichTextSource } from '../../../../../libs/shared/lib/utils/rich-text' import type { Application, Engagement } from '../../lib/models' import { useTermsAgreementGate } from '../../lib' import { ApplicationStatus, EngagementStatus } from '../../lib/models' @@ -577,13 +579,14 @@ const EngagementDetailPage: FC = () => {

Overview

- {engagement.description} + {sanitizeRichTextSource(engagement.description)}
diff --git a/src/apps/engagements/src/pages/engagement-list/EngagementListPage.spec.tsx b/src/apps/engagements/src/pages/engagement-list/EngagementListPage.spec.tsx new file mode 100644 index 000000000..27b7ff065 --- /dev/null +++ b/src/apps/engagements/src/pages/engagement-list/EngagementListPage.spec.tsx @@ -0,0 +1,125 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, sort-keys */ +import '@testing-library/jest-dom' + +import React from 'react' +import { render, waitFor } from '@testing-library/react' + +import { getEngagements } from '../../lib/services' + +import EngagementListPage from './EngagementListPage' + +const mockNavigate = jest.fn() +const mockUseProfileContext = jest.fn() +const mockUseCountryLookup = jest.fn() +const mockGetEngagements = getEngagements as jest.MockedFunction + +jest.mock('react-router-dom', () => ({ + useLocation: () => ({ + pathname: '/engagements', + state: undefined, + }), + useNavigate: () => mockNavigate, +})) + +jest.mock('~/libs/core', () => ({ + useCountryLookup: () => mockUseCountryLookup(), + useProfileContext: () => mockUseProfileContext(), +}), { virtual: true }) + +jest.mock('~/libs/ui', () => ({ + Button: (props: { + label: string + onClick?: () => void + }) => ( + + ), + ContentLayout: (props: { + children: React.ReactNode + title: string + }) => ( +
+

{props.title}

+ {props.children} +
+ ), + IconOutline: { + ExclamationIcon: () => , + InformationCircleIcon: () => , + SearchIcon: () => , + }, + LoadingSpinner: () =>
loading-spinner
, +}), { virtual: true }) + +jest.mock('~/apps/admin/src/lib/components/common/Pagination', () => ({ + Pagination: () =>
pagination
, +}), { virtual: true }) + +jest.mock('../../components', () => ({ + EngagementCard: () =>
engagement-card
, + EngagementFilters: () =>
engagement-filters
, + EngagementsTabs: () =>
engagement-tabs
, +})) + +jest.mock('../../engagements.routes', () => ({ + rootRoute: '/engagements', +})) + +jest.mock('../../lib/services', () => ({ + getEngagements: jest.fn(), +})) + +describe('EngagementListPage', () => { + beforeEach(() => { + mockNavigate.mockReset() + mockUseCountryLookup.mockReturnValue([]) + mockGetEngagements.mockResolvedValue({ + data: [], + page: 1, + perPage: 12, + total: 0, + totalPages: 0, + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('includes private engagements for privileged users', async () => { + mockUseProfileContext.mockReturnValue({ + isLoggedIn: true, + profile: { + roles: ['Talent Manager'], + }, + }) + + render() + + await waitFor(() => { + expect(mockGetEngagements) + .toHaveBeenCalledWith(expect.objectContaining({ + includePrivate: true, + })) + }) + }) + + it('keeps private engagements excluded for standard members', async () => { + mockUseProfileContext.mockReturnValue({ + isLoggedIn: true, + profile: { + roles: ['Member'], + }, + }) + + render() + + await waitFor(() => { + expect(mockGetEngagements) + .toHaveBeenCalledWith(expect.not.objectContaining({ + includePrivate: true, + })) + }) + }) +}) diff --git a/src/apps/engagements/src/pages/engagement-list/EngagementListPage.tsx b/src/apps/engagements/src/pages/engagement-list/EngagementListPage.tsx index 43a01b7aa..2a375b5ca 100644 --- a/src/apps/engagements/src/pages/engagement-list/EngagementListPage.tsx +++ b/src/apps/engagements/src/pages/engagement-list/EngagementListPage.tsx @@ -28,6 +28,7 @@ const DEFAULT_FILTERS: FilterState = { const PER_PAGE = 12 const ANY_LOCATION = 'Any' +const PRIVATE_ENGAGEMENT_ROLE_KEYWORDS = ['project manager', 'task manager', 'talent manager', 'admin'] type CountryMatch = { name?: string @@ -113,12 +114,22 @@ const buildLocationFilters = ( } } +const canIncludePrivateEngagements = (roles?: string[]): boolean => ( + (roles ?? []) + .filter((role): role is string => typeof role === 'string') + .map(role => role + .trim() + .toLowerCase()) + .some(role => PRIVATE_ENGAGEMENT_ROLE_KEYWORDS.some(keyword => role.includes(keyword))) +) + const EngagementListPage: FC = () => { const navigate = useNavigate() const location = useLocation() const profileContext = useProfileContext() const countryLookup = useCountryLookup() const isLoggedIn = profileContext.isLoggedIn + const canViewPrivateEngagements = canIncludePrivateEngagements(profileContext.profile?.roles) const [engagements, setEngagements] = useState([]) const [loading, setLoading] = useState(false) @@ -150,6 +161,7 @@ const EngagementListPage: FC = () => { try { const response = await getEngagements({ countries: locationFilters.countries, + includePrivate: canViewPrivateEngagements, page, perPage: PER_PAGE, search: filters.search || undefined, @@ -174,7 +186,7 @@ const EngagementListPage: FC = () => { setLoading(false) } } - }, [filters, locationFilters.countries, locationFilters.timeZones, page]) + }, [canViewPrivateEngagements, filters, locationFilters.countries, locationFilters.timeZones, page]) useEffect(() => { fetchEngagements() diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx index 3edc81d48..0bc933be7 100644 --- a/src/apps/platform/src/platform.routes.tsx +++ b/src/apps/platform/src/platform.routes.tsx @@ -12,6 +12,7 @@ import { copilotsRoutes } from '~/apps/copilots' import { adminRoutes } from '~/apps/admin' import { reportsRoutes } from '~/apps/reports' import { reviewRoutes } from '~/apps/review' +import { workRoutes } from '~/apps/work' import { calendarRoutes } from '~/apps/calendar' import { engagementsRoutes } from '~/apps/engagements' import { customerPortalRoutes } from '~/apps/customer-portal' @@ -43,6 +44,7 @@ export const platformRoutes: Array = [ ...walletAdminRoutes, ...accountsRoutes, ...reviewRoutes, + ...workRoutes, ...calendarRoutes, ...engagementsRoutes, ...homeRoutes, diff --git a/src/apps/profiles/src/components/tc-achievements/StatsSummaryBlock/StatsSummaryBlock.spec.tsx b/src/apps/profiles/src/components/tc-achievements/StatsSummaryBlock/StatsSummaryBlock.spec.tsx new file mode 100644 index 000000000..ccac9df8c --- /dev/null +++ b/src/apps/profiles/src/components/tc-achievements/StatsSummaryBlock/StatsSummaryBlock.spec.tsx @@ -0,0 +1,73 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' + +import StatsSummaryBlock from './StatsSummaryBlock' + +jest.mock('~/libs/core', () => ({ + getRatingColor: jest.fn(() => '#000000'), + UserRole: { + administrator: 'administrator', + talentManager: 'talentManager', + }, +}), { + virtual: true, +}) + +jest.mock('../../../lib', () => ({ + formatPlural: (count: number, label: string) => `${label}${count === 1 ? '' : 's'}`, + numberToFixed: (value: number) => value.toString(), +})) + +describe('StatsSummaryBlock', () => { + it('renders zero wins when the wins count is missing', () => { + render( + , + ) + + expect( + screen.getByText('Wins') + .closest('div'), + ) + .toHaveTextContent('0') + }) + + it.each(['First2Finish', 'Bug Hunt'])( + 'hides volatility for %s stats even when a volatility value exists', + trackTitle => { + render( + , + ) + + expect(screen.queryByText(/volatility/i)).not.toBeInTheDocument() + expect(screen.queryByText('123')).not.toBeInTheDocument() + }, + ) + + it('keeps volatility visible for tracks that support it', () => { + render( + , + ) + + expect(screen.getByText(/volatility/i)) + .toBeInTheDocument() + expect(screen.getByText('123')) + .toBeInTheDocument() + }) +}) diff --git a/src/apps/profiles/src/components/tc-achievements/StatsSummaryBlock/StatsSummaryBlock.tsx b/src/apps/profiles/src/components/tc-achievements/StatsSummaryBlock/StatsSummaryBlock.tsx index c389ce395..dbf50e26e 100644 --- a/src/apps/profiles/src/components/tc-achievements/StatsSummaryBlock/StatsSummaryBlock.tsx +++ b/src/apps/profiles/src/components/tc-achievements/StatsSummaryBlock/StatsSummaryBlock.tsx @@ -23,6 +23,8 @@ interface StatsSummaryBlockProps { volatility?: number } +const VOLATILITY_HIDDEN_TRACKS = new Set(['First2Finish', 'Bug Hunt']) + const StatsSummaryBlock: FC = props => { const visibleFields = get(find(TracksSummaryStats, { ...(props.trackId ? { id: props.trackId } : {}), @@ -33,6 +35,9 @@ const StatsSummaryBlock: FC = props => { const isFieldVisible = (field: string): boolean => ( !visibleFields || visibleFields[field] ) + const shouldShowVolatility = isFieldVisible('volatility') + && Number.isFinite(props.volatility) + && !VOLATILITY_HIDDEN_TRACKS.has(props.trackTitle) return (
@@ -107,7 +112,7 @@ const StatsSummaryBlock: FC = props => {
)} - {isFieldVisible('volatility') && props.volatility !== undefined && ( + {shouldShowVolatility && (
{props.volatility} diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx index df3db9434..1023542d2 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx @@ -1,7 +1,13 @@ -import { UserStats } from '~/libs/core' +import type { UserStats } from '~/libs/core' import { getActiveTracks, MemberStatsTrack } from './useFetchActiveTracks' +jest.mock('~/libs/core', () => ({ + useMemberStats: jest.fn(), +}), { + virtual: true, +}) + describe('getActiveTracks', () => { it('keeps unified design and development subtracks visible', () => { const activeTracks: MemberStatsTrack[] = getActiveTracks({ diff --git a/src/apps/reports/src/pages/bulk-member-lookup/BulkMemberLookupPage.tsx b/src/apps/reports/src/pages/bulk-member-lookup/BulkMemberLookupPage.tsx index b0b15a375..5dece3164 100644 --- a/src/apps/reports/src/pages/bulk-member-lookup/BulkMemberLookupPage.tsx +++ b/src/apps/reports/src/pages/bulk-member-lookup/BulkMemberLookupPage.tsx @@ -26,6 +26,9 @@ const emptyValue = '—' type BulkMemberRow = { userId: number | null handle: string + firstName: string | null + lastName: string | null + contactNumber: string | null email: string | null country: string | null } @@ -134,6 +137,24 @@ export const BulkMemberLookupPage: FC = () => { propertyName: 'handle', type: 'text', }, + { + label: 'First Name', + propertyName: 'firstName', + renderer: data => <>{data.firstName ?? emptyValue}, + type: 'element', + }, + { + label: 'Last Name', + propertyName: 'lastName', + renderer: data => <>{data.lastName ?? emptyValue}, + type: 'element', + }, + { + label: 'Contact Number', + propertyName: 'contactNumber', + renderer: data => <>{data.contactNumber ?? emptyValue}, + type: 'element', + }, { label: 'Email', propertyName: 'email', diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx index baaa79adf..74824f97f 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx @@ -29,7 +29,10 @@ import { import { ITERATIVE_REVIEW, SUBMITTER } from '../../../config/index.config' import { TableNoRecord } from '../TableNoRecord' import { hasIsLatestFlag } from '../../utils' -import { shouldIncludeInReviewPhase } from '../../utils/reviewPhaseGuards' +import { + isContestReviewPhaseSubmission, + shouldIncludeInReviewPhase, +} from '../../utils/reviewPhaseGuards' import TabContentApproval from './TabContentApproval' import TabContentCheckpoint from './TabContentCheckpoint' @@ -82,13 +85,16 @@ const TabContentPlaceholder = (props: { message: string }): JSX.Element => ( const SUBMISSION_TAB_KEYS = new Set([ normalizeType('submission'), + normalizeType('specification submission'), normalizeType('screening'), + normalizeType('ai screening'), normalizeType('submission / screening'), normalizeType('topgear submission'), ]) const CHECKPOINT_REVIEW_KEY = normalizeType('checkpoint review') const CHECKPOINT_SCREENING_KEY = normalizeType('checkpoint screening') +const AI_SCREENING_KEY = normalizeType('ai screening') const CHECKPOINT_TAB_KEYS = new Set([ normalizeType('checkpoint'), normalizeType('checkpoint submission'), @@ -144,9 +150,12 @@ const renderSubmissionTab = ({ aiReviewers, }: SubmissionTabParams): JSX.Element => { const isSubmissionTab = selectedTabNormalized === 'submission' + const isSpecificationSubmissionTab = selectedTabNormalized === 'specificationsubmission' const isTopgearSubmissionTab = selectedTabNormalized === 'topgearsubmission' const shouldRestrictToContestSubmissions = selectedTabNormalized .startsWith('submission') + || isSpecificationSubmissionTab + || selectedTabNormalized === AI_SCREENING_KEY || isTopgearSubmissionTab const visibleSubmissions = shouldRestrictToContestSubmissions ? submissions.filter( @@ -239,6 +248,24 @@ export const ChallengeDetailsContent: FC = (props: Props) => { isLoading: isLoadingProjectResult, projectResults, }: useFetchChallengeResultsProps = useFetchChallengeResults(props.review) + const selectedTabNormalized = useMemo( + () => normalizeType(props.selectedTab), + [props.selectedTab], + ) + const selectedReviewPhaseName = useMemo( + () => { + if (selectedTabNormalized === 'specificationreview') { + return 'Specification Review' + } + + if (selectedTabNormalized === 'review') { + return 'Review' + } + + return undefined + }, + [selectedTabNormalized], + ) // Determine if the selected tab corresponds to a phase that hasn't opened yet const selectedPhase = useMemo( @@ -313,11 +340,21 @@ export const ChallengeDetailsContent: FC = (props: Props) => { [props.screening], ) const passesReviewTabGuards: (submission: SubmissionInfo) => boolean = useMemo( - () => (submission: SubmissionInfo): boolean => shouldIncludeInReviewPhase( - submission, - challengeInfo?.phases, - ), - [challengeInfo?.phases], + () => (submission: SubmissionInfo): boolean => { + if (selectedReviewPhaseName) { + return isContestReviewPhaseSubmission( + submission, + challengeInfo?.phases, + selectedReviewPhaseName, + ) + } + + return shouldIncludeInReviewPhase( + submission, + challengeInfo?.phases, + ) + }, + [challengeInfo?.phases, selectedReviewPhaseName], ) const { reviews: reviewTabReviews, @@ -392,7 +429,6 @@ export const ChallengeDetailsContent: FC = (props: Props) => { const renderSelectedTab = (): JSX.Element => { const selectedTabLower = (props.selectedTab || '').toLowerCase() - const selectedTabNormalized = normalizeType(props.selectedTab) const aiReviewers = ( challengeInfo?.reviewers?.filter(r => !!r.aiWorkflowId) as { aiWorkflowId: string }[] ) ?? [] @@ -456,12 +492,13 @@ export const ChallengeDetailsContent: FC = (props: Props) => { ) } - if (selectedTabLower === 'approval') { + if (selectedTabNormalized === 'approval') { return ( void @@ -36,6 +38,7 @@ export const TabContentApproval: FC = (props: Props) => { approverResourceIds, isPrivilegedRole, }: useRoleProps = useRole() + const isSubmitterView = actionChallengeRole === SUBMITTER const hideHandleColumn = props.isActiveChallenge && actionChallengeRole === REVIEWER @@ -45,12 +48,13 @@ export const TabContentApproval: FC = (props: Props) => { ) const hasPassedApprovalThreshold = useMemo( - () => hasSubmitterPassedThreshold( + () => hasRoleBasedThresholdAccess( + isSubmitterView, props.submitterReviews ?? [], myMemberIds, props.approvalMinimumPassingScore, ), - [props.submitterReviews, myMemberIds, props.approvalMinimumPassingScore], + [isSubmitterView, props.submitterReviews, myMemberIds, props.approvalMinimumPassingScore], ) const isChallengeCompleted = useMemo( @@ -65,29 +69,79 @@ export const TabContentApproval: FC = (props: Props) => { // Only show Approval-phase reviews on the Approval tab const approvalPhaseIds = useMemo>( - () => new Set( - (challengeInfo?.phases ?? []) - .filter(p => (p.name || '').toLowerCase() === 'approval') - .map(p => p.id), - ), + () => (challengeInfo?.phases ?? []) + .reduce((ids, phase) => { + if ((phase.name || '').toLowerCase() !== 'approval') { + return ids + } + + const id = `${phase.id ?? ''}`.trim() + const phaseId = `${phase.phaseId ?? ''}`.trim() + + if (id) { + ids.add(id) + } + + if (phaseId) { + ids.add(phaseId) + } + + return ids + }, new Set()), [challengeInfo?.phases], ) + + const filteredPhaseIds = useMemo>( + () => { + const normalizedPhaseId = `${props.phaseIdFilter ?? ''}`.trim() + if (!normalizedPhaseId) { + return new Set() + } + + const matchingPhase = (challengeInfo?.phases ?? []).find( + phase => phase.id === normalizedPhaseId || phase.phaseId === normalizedPhaseId, + ) + + const identifiers = new Set([normalizedPhaseId]) + const matchingPhaseId = `${matchingPhase?.id ?? ''}`.trim() + const matchingPhasePhaseId = `${matchingPhase?.phaseId ?? ''}`.trim() + + if (matchingPhaseId) { + identifiers.add(matchingPhaseId) + } + + if (matchingPhasePhaseId) { + identifiers.add(matchingPhasePhaseId) + } + + return identifiers + }, + [challengeInfo?.phases, props.phaseIdFilter], + ) + const approvalRows: SubmissionInfo[] = useMemo( () => { if (!props.reviews.length) { return [] } + if (filteredPhaseIds.size) { + return props.reviews.filter(row => { + const phaseId = `${row.review?.phaseId ?? ''}`.trim() + return phaseId ? filteredPhaseIds.has(phaseId) : false + }) + } + if (approvalPhaseIds.size === 0) { return props.reviews } return props.reviews.filter(row => { - const phaseId = row.review?.phaseId + const phaseId = `${row.review?.phaseId ?? ''}`.trim() return phaseId ? approvalPhaseIds.has(phaseId) : false }) }, - [props.reviews, approvalPhaseIds], + [props.reviews, approvalPhaseIds, filteredPhaseIds], ) const filteredApprovalRows = useMemo( diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentIterativeReview.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentIterativeReview.tsx index 5777f869e..6d8225395 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentIterativeReview.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentIterativeReview.tsx @@ -1,12 +1,21 @@ /** * Content of iterative review tab. */ -import { FC, useContext, useMemo } from 'react' +import { + FC, + useContext, + useMemo, +} from 'react' import { TableLoading } from '~/apps/admin/src/lib' import { IsRemovingType } from '~/apps/admin/src/lib/models' import { ChallengeDetailContextModel, SubmissionInfo } from '../../models' +import { isContestSubmissionType } from '../../constants' +import { + isFirst2FinishChallenge as detectFirst2FinishChallenge, + resolveFirst2FinishIterativeSubmissionIds, +} from '../../utils/challenge' import { TableNoRecord } from '../TableNoRecord' import { TableIterativeReview } from '../TableIterativeReview' import { useRole, useRoleProps } from '../../hooks' @@ -16,7 +25,11 @@ import { } from '../../../config/index.config' import { ChallengeDetailContext } from '../../contexts' import { hasSubmitterPassedThreshold } from '../../utils/reviewScoring' -import { shouldIncludeInReviewPhase } from '../../utils/reviewPhaseGuards' + +import { + filterIterativeReviewRows, + limitFirst2FinishIterativeRows, +} from './iterativeReviewFiltering' interface Props { reviews: SubmissionInfo[] @@ -55,19 +68,13 @@ const getSubmissionPriority = (submission: SubmissionInfo): number => { return 1 } -const normalizePhaseId = (value: unknown): string | undefined => { - if (value === undefined || value === null) { - return undefined - } - - const normalized = `${value}`.trim() - return normalized.length ? normalized : undefined -} - export const TabContentIterativeReview: FC = (props: Props) => { const { + aiReviewDecisionsBySubmissionId, challengeInfo, + challengeSubmissions, myResources = [], + resources, }: ChallengeDetailContextModel = useContext(ChallengeDetailContext) const { actionChallengeRole, @@ -102,6 +109,31 @@ export const TabContentIterativeReview: FC = (props: Props) => { () => normalizedColumnLabel === 'postmortem', [normalizedColumnLabel], ) + const isFirst2FinishChallenge = useMemo( + () => detectFirst2FinishChallenge(challengeInfo), + [challengeInfo?.track?.name, challengeInfo?.type?.name], + ) + const first2FinishSubmissionIds = useMemo(() => { + if (isPostMortemPhase || !isFirst2FinishChallenge) { + return [] + } + + const contestSubmissions = (challengeSubmissions ?? []).filter(submission => isContestSubmissionType( + submission.type, + { defaultToContest: true }, + )) + if (!contestSubmissions.length) { + return [] + } + + return resolveFirst2FinishIterativeSubmissionIds( + contestSubmissions, + ) + }, [ + challengeSubmissions, + isFirst2FinishChallenge, + isPostMortemPhase, + ]) const isSubmitterOnly = actionChallengeRole === SUBMITTER && postMortemReviewerResourceIds.size === 0 @@ -118,55 +150,27 @@ export const TabContentIterativeReview: FC = (props: Props) => { [sourceRows, myMemberIds, props.postMortemMinimumPassingScore], ) - const phaseIdFilterSet = useMemo(() => { - const normalizedFilter = normalizePhaseId(props.phaseIdFilter) - if (!normalizedFilter) { - return undefined - } - - const ids = new Set([normalizedFilter]) - const phases = challengeInfo?.phases ?? [] - const matchingPhase = phases.find(phase => { - const phaseId = normalizePhaseId(phase.id) - const phaseTypeId = normalizePhaseId(phase.phaseId) - return phaseId === normalizedFilter || phaseTypeId === normalizedFilter - }) - - if (matchingPhase) { - const phaseId = normalizePhaseId(matchingPhase.id) - if (phaseId) { - ids.add(phaseId) - } - - const phaseTypeId = normalizePhaseId(matchingPhase.phaseId) - if (phaseTypeId) { - ids.add(phaseTypeId) - } - } - - return ids - }, [challengeInfo?.phases, props.phaseIdFilter]) - const filteredRows = useMemo(() => { - if (phaseIdFilterSet?.size) { - return sourceRows.filter(submission => { - const reviewPhaseId = normalizePhaseId(submission.review?.phaseId) - return reviewPhaseId ? phaseIdFilterSet.has(reviewPhaseId) : false - }) - } - - if (!isPostMortemPhase) { - const iterativeOnly = sourceRows.filter(submission => !shouldIncludeInReviewPhase( - submission, - challengeInfo?.phases, - )) - if (iterativeOnly.length) { - return iterativeOnly - } - } + const rows = filterIterativeReviewRows({ + aiReviewDecisionsBySubmissionId, + challengePhases: challengeInfo?.phases, + isPostMortemPhase, + limitToSubmissionIds: first2FinishSubmissionIds, + phaseIdFilter: props.phaseIdFilter, + reviewerResources: resources, + sourceRows, + }) - return sourceRows - }, [sourceRows, phaseIdFilterSet, isPostMortemPhase, challengeInfo?.phases]) + return rows + }, [ + aiReviewDecisionsBySubmissionId, + sourceRows, + isPostMortemPhase, + challengeInfo?.phases, + props.phaseIdFilter, + resources, + first2FinishSubmissionIds, + ]) const reviewRows = useMemo(() => { const map = new Map() @@ -186,18 +190,38 @@ export const TabContentIterativeReview: FC = (props: Props) => { return Array.from(map.values()) }, [filteredRows]) + const first2FinishReviewRows = useMemo( + () => { + if (isPostMortemPhase || !isFirst2FinishChallenge) { + return reviewRows + } + + return limitFirst2FinishIterativeRows(reviewRows, first2FinishSubmissionIds, { + forceSingleRow: true, + }) + }, + [ + first2FinishSubmissionIds, + isFirst2FinishChallenge, + isPostMortemPhase, + reviewRows, + ], + ) const filteredReviewRows = useMemo( () => { if (!isPostMortemPhase) { - return reviewRows + return first2FinishReviewRows } - if (isPrivilegedRole || (isChallengeCompleted && (!isPostMortemPhase || hasPassedPostMortemThreshold))) { - return reviewRows + if ( + isPrivilegedRole + || (isChallengeCompleted && (!isPostMortemPhase || hasPassedPostMortemThreshold)) + ) { + return first2FinishReviewRows } - return reviewRows.filter(row => { + return first2FinishReviewRows.filter(row => { if (row.review?.resourceId && postMortemReviewerResourceIds.has(row.review.resourceId)) { return true @@ -211,7 +235,7 @@ export const TabContentIterativeReview: FC = (props: Props) => { }) }, [ - reviewRows, + first2FinishReviewRows, isPostMortemPhase, isPrivilegedRole, isChallengeCompleted, diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx index 357c99b7f..2d6e7c532 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx @@ -197,6 +197,20 @@ export const TabContentReview: FC = (props: Props) => { () => normalizeTabLabel(selectedTab), [selectedTab], ) + const selectedReviewPhaseName = useMemo( + () => { + if (normalizedSelectedTab === 'specificationreview') { + return 'Specification Review' + } + + if (normalizedSelectedTab === 'review') { + return 'Review' + } + + return undefined + }, + [normalizedSelectedTab], + ) const shouldSortReviewTabByScore = useMemo( () => !props.isActiveChallenge && normalizedSelectedTab === 'review', [normalizedSelectedTab, props.isActiveChallenge], @@ -499,16 +513,28 @@ export const TabContentReview: FC = (props: Props) => { isContestReviewPhaseSubmission( submission, challengeInfo?.phases, + selectedReviewPhaseName ?? 'Review', ) ), - [challengeInfo?.phases], + [challengeInfo?.phases, selectedReviewPhaseName], ) const shouldIncludeReviewRow = useCallback( - (submission: SubmissionInfo): boolean => ( - shouldIncludeInReviewPhase(submission, challengeInfo?.phases) - || isAiFailedReviewSubmission(submission) - ), - [challengeInfo?.phases], + (submission: SubmissionInfo): boolean => { + if (selectedReviewPhaseName) { + return isContestReviewPhaseSubmission( + submission, + challengeInfo?.phases, + selectedReviewPhaseName, + ) || ( + selectedReviewPhaseName === 'Review' + && isAiFailedReviewSubmission(submission) + ) + } + + return shouldIncludeInReviewPhase(submission, challengeInfo?.phases) + || isAiFailedReviewSubmission(submission) + }, + [challengeInfo?.phases, selectedReviewPhaseName], ) const resolvedReviews = useMemo( () => { @@ -733,9 +759,12 @@ export const TabContentReview: FC = (props: Props) => { } if (selectedTab === 'Appeals Response') { + const appealsResponseDatas = isSubmitterView + ? filteredSubmitterReviews + : resolvedReviewsWithSubmitter return ( = (props: Props) => { .trim() return normalizedPhaseName === 'screening' + || normalizedPhaseName === 'ai screening' }) const canSeeAll = isPrivilegedRole || hasReviewerRole diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts new file mode 100644 index 000000000..338c4cfe0 --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts @@ -0,0 +1,228 @@ +import { + BackendPhase, + BackendResource, + SubmissionInfo, +} from '../../models' + +import { + filterIterativeReviewRows, + limitFirst2FinishIterativeRows, +} from './iterativeReviewFiltering' + +const createPhase = ( + id: string, + name: string, + phaseId = id, +): BackendPhase => ({ + constraints: [], + description: '', + duration: 0, + id, + isOpen: false, + name, + phaseId, + scheduledEndDate: '2026-03-30T00:00:00Z', + scheduledStartDate: '2026-03-30T00:00:00Z', +}) + +const createResource = ( + id: string, + roleName: string, +): BackendResource => ({ + challengeId: 'challenge-1', + created: '2026-03-30T00:00:00Z', + createdBy: 'system', + id, + memberHandle: `${id}-handle`, + memberId: `${id}-member`, + roleId: `${roleName}-role`, + roleName, +}) + +const createSubmission = ( + resourceId: string, + phaseId?: string, +): SubmissionInfo => ({ + id: `submission-${resourceId}`, + memberId: 'submitter-1', + review: { + committed: false, + createdAt: '', + finalScore: 0, + id: '', + initialScore: 0, + metadata: {}, + phaseId: phaseId ?? '', + phaseName: '', + resourceId, + reviewDate: '', + reviewItems: [], + scorecardId: '', + status: '', + submissionId: `submission-${resourceId}`, + submitterHandle: '', + submitterMaxRating: undefined, + updatedAt: '', + }, +}) + +describe('filterIterativeReviewRows', () => { + const challengePhases: BackendPhase[] = [ + createPhase('submission-1', 'Submission'), + createPhase('iterative-1', 'Iterative Review', 'iterative-phase-1'), + createPhase('review-1', 'Review', 'review-phase-1'), + ] + + it('keeps a phase-less iterative placeholder when a single iterative phase is selected', () => { + const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer') + const results = filterIterativeReviewRows({ + challengePhases, + isPostMortemPhase: false, + phaseIdFilter: 'iterative-1', + reviewerResources: [iterativeReviewer], + sourceRows: [createSubmission(iterativeReviewer.id)], + }) + + expect(results) + .toHaveLength(1) + expect(results[0].id) + .toBe(`submission-${iterativeReviewer.id}`) + }) + + it('does not show a phase-less standard reviewer placeholder on the iterative tab', () => { + const standardReviewer = createResource('review-resource-1', 'Reviewer') + const results = filterIterativeReviewRows({ + challengePhases, + isPostMortemPhase: false, + phaseIdFilter: 'iterative-1', + reviewerResources: [standardReviewer], + sourceRows: [createSubmission(standardReviewer.id)], + }) + + expect(results) + .toEqual([]) + }) + + it('still keeps explicit phase-id matches when the review exists', () => { + const standardReviewer = createResource('review-resource-1', 'Reviewer') + const results = filterIterativeReviewRows({ + challengePhases, + isPostMortemPhase: false, + phaseIdFilter: 'iterative-1', + reviewerResources: [standardReviewer], + sourceRows: [createSubmission(standardReviewer.id, 'iterative-phase-1')], + }) + + expect(results) + .toHaveLength(1) + expect(results[0].review?.phaseId) + .toBe('iterative-phase-1') + }) + + 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') + const results = filterIterativeReviewRows({ + challengePhases, + isPostMortemPhase: false, + limitToSubmissionIds: [`submission-${iterativeReviewer.id}`], + reviewerResources: [iterativeReviewer, losingReviewer], + sourceRows: [ + { + ...createSubmission(iterativeReviewer.id), + memberId: 'winner-member', + }, + { + ...createSubmission(losingReviewer.id), + memberId: 'losing-member', + }, + ], + }) + + expect(results) + .toHaveLength(1) + expect(results[0].id) + .toBe(`submission-${iterativeReviewer.id}`) + }) + + it('falls back to the earliest row when F2F limiter ids do not match rendered row ids', () => { + const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer') + const losingReviewer = createResource('iterative-resource-2', 'Iterative Reviewer') + const laterRow = createSubmission(iterativeReviewer.id) + const earlierRow = createSubmission(losingReviewer.id) + const results = filterIterativeReviewRows({ + challengePhases, + isPostMortemPhase: false, + limitToSubmissionIds: ['actual-submission-id'], + reviewerResources: [iterativeReviewer, losingReviewer], + sourceRows: [ + { + ...laterRow, + id: 'synthetic-row-2', + review: { + ...laterRow.review!, + createdAt: '2026-04-01T04:57:38.244Z', + submissionId: 'later-submission-id', + updatedAt: '', + }, + submittedDate: '2026-04-01T04:57:36.849Z', + }, + { + ...earlierRow, + id: 'synthetic-row-1', + review: { + ...earlierRow.review!, + createdAt: '2026-04-01T04:56:15.294Z', + submissionId: 'earlier-submission-id', + updatedAt: '', + }, + submittedDate: '2026-04-01T04:56:13.405Z', + }, + ], + }) + + expect(results) + .toHaveLength(1) + expect(results[0].id) + .toBe('synthetic-row-1') + }) +}) + +describe('limitFirst2FinishIterativeRows', () => { + it('keeps the earliest row when no preferred submission ids are available', () => { + const laterRow = createSubmission('iterative-resource-1') + const earlierRow = createSubmission('iterative-resource-2') + + const results = limitFirst2FinishIterativeRows([ + { + ...laterRow, + id: 'submission-later', + review: { + ...laterRow.review!, + createdAt: '2026-04-01T04:57:38.244Z', + submissionId: 'submission-later', + updatedAt: '', + }, + submittedDate: '2026-04-01T04:57:36.849Z', + }, + { + ...earlierRow, + id: 'submission-earlier', + review: { + ...earlierRow.review!, + createdAt: '2026-04-01T04:56:15.294Z', + submissionId: 'submission-earlier', + updatedAt: '', + }, + submittedDate: '2026-04-01T04:56:13.405Z', + }, + ], undefined, { + forceSingleRow: true, + }) + + expect(results) + .toHaveLength(1) + expect(results[0].id) + .toBe('submission-earlier') + }) +}) diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts new file mode 100644 index 000000000..64940ae67 --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts @@ -0,0 +1,328 @@ +import { + AiReviewDecision, + BackendPhase, + BackendResource, + SubmissionInfo, +} from '../../models' +import { shouldIncludeInReviewPhase } from '../../utils/reviewPhaseGuards' + +interface FilterIterativeReviewRowsArgs { + aiReviewDecisionsBySubmissionId?: Record + challengePhases?: BackendPhase[] + isPostMortemPhase: boolean + limitToSubmissionIds?: string[] + phaseIdFilter?: string + reviewerResources?: BackendResource[] + sourceRows: SubmissionInfo[] +} + +interface IterativePlaceholderRowArgs { + challengePhases?: BackendPhase[] + isPostMortemPhase: boolean + iterativeReviewPhaseCount: number + iterativeReviewerResourceIds: Set + submission: SubmissionInfo +} + +interface LimitFirst2FinishIterativeRowsOptions { + forceSingleRow?: boolean +} + +function isAiFailedReviewSubmission(submission: SubmissionInfo): boolean { + return (submission.status ?? '').toUpperCase() === 'AI_FAILED_REVIEW' +} + +function isAiLockedByDecision( + submission: SubmissionInfo, + aiReviewDecisionsBySubmissionId?: Record, +): boolean { + const submissionId = normalizeIdentifier(submission.id) + if (!submissionId || !aiReviewDecisionsBySubmissionId) { + return false + } + + const decision = aiReviewDecisionsBySubmissionId[submissionId] + if (!decision) { + return false + } + + const decisionStatus = (decision.status ?? '').toUpperCase() + const isDecisionFailed = decisionStatus === 'FAILED' || decisionStatus === 'ERROR' + + return Boolean(decision.submissionLocked && isDecisionFailed) +} + +function shouldTreatAsAiFailedSubmission( + submission: SubmissionInfo, + aiReviewDecisionsBySubmissionId?: Record, +): boolean { + if (isAiLockedByDecision(submission, aiReviewDecisionsBySubmissionId)) { + return true + } + + return isAiFailedReviewSubmission(submission) +} + +/** + * Normalize a phase or resource identifier for set-based comparisons. + * + * @param value - Raw identifier value from phase or review payloads. + * @returns Trimmed string identifier when present; otherwise undefined. + */ +function normalizeIdentifier(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined + } + + const normalized = `${value}`.trim() + return normalized.length ? normalized : undefined +} + +/** + * Parse sortable date inputs from submission and review payloads. + * + * @param value - Raw date-like value supplied by the UI model. + * @returns Parsed timestamp, or NaN when no valid date is available. + */ +function parseSortableDate(value: string | Date | undefined): number { + if (value instanceof Date) { + return value.getTime() + } + + return Date.parse(value ?? '') +} + +/** + * Resolve the effective phase-id filter set for the selected phase. + * + * @param challengePhases - Challenge phases used to expand id and phaseId aliases. + * @param phaseIdFilter - Selected phase identifier from the UI tab. + * @returns Set of acceptable phase identifiers, or undefined when no filter applies. + */ +function buildPhaseIdFilterSet( + challengePhases: BackendPhase[] | undefined, + phaseIdFilter: string | undefined, +): Set | undefined { + const normalizedFilter = normalizeIdentifier(phaseIdFilter) + if (!normalizedFilter) { + return undefined + } + + const ids = new Set([normalizedFilter]) + const matchingPhase = (challengePhases ?? []).find(phase => { + const phaseId = normalizeIdentifier(phase.id) + const phaseTypeId = normalizeIdentifier(phase.phaseId) + return phaseId === normalizedFilter || phaseTypeId === normalizedFilter + }) + + if (matchingPhase) { + const phaseId = normalizeIdentifier(matchingPhase.id) + if (phaseId) { + ids.add(phaseId) + } + + const phaseTypeId = normalizeIdentifier(matchingPhase.phaseId) + if (phaseTypeId) { + ids.add(phaseTypeId) + } + } + + return ids +} + +/** + * Count iterative-review phases so fallback matching only applies to single-phase flows. + * + * @param challengePhases - Challenge phases associated with the current challenge. + * @returns Number of iterative-review phases configured on the challenge. + */ +function countIterativeReviewPhases(challengePhases: BackendPhase[] | undefined): number { + return (challengePhases ?? []).filter(phase => (phase.name ?? '') + .toLowerCase() + .includes('iterative review')).length +} + +/** + * Collect resource ids assigned to iterative-review roles. + * + * @param reviewerResources - Challenge resources available on the details page. + * @returns Set of resource ids belonging to iterative reviewers. + */ +function collectIterativeReviewerResourceIds( + reviewerResources: BackendResource[] | undefined, +): Set { + return new Set( + (reviewerResources ?? []) + .filter(resource => { + const normalizedRoleName = (resource.roleName ?? '') + .toLowerCase() + .replace(/[^a-z]/g, '') + return normalizedRoleName === 'iterativereviewer' + }) + .map(resource => normalizeIdentifier(resource.id)) + .filter((id): id is string => Boolean(id)), + ) +} + +/** + * Determine whether a row is an iterative placeholder created before the backend review exists. + * + * @param args - Current filter context and candidate submission row. + * @returns True when the row should stay visible on a single iterative-review tab. + */ +function isIterativePlaceholderRow(args: IterativePlaceholderRowArgs): boolean { + const { + challengePhases, + isPostMortemPhase, + iterativeReviewPhaseCount, + iterativeReviewerResourceIds, + submission, + }: IterativePlaceholderRowArgs = args + + if (isPostMortemPhase || iterativeReviewPhaseCount !== 1) { + return false + } + + const resourceId = normalizeIdentifier(submission.review?.resourceId) + if (!resourceId || !iterativeReviewerResourceIds.has(resourceId)) { + return false + } + + return !shouldIncludeInReviewPhase(submission, challengePhases) +} + +/** + * Restrict First2Finish iterative rows to the single submission that should stay + * visible on the tab. + * + * @param rows - Candidate rows for the iterative-review tab. + * @param preferredSubmissionIds - Optional submission ids supplied by the caller. + * @returns A single surviving iterative-review row whenever a First2Finish limit applies. + */ +export function limitFirst2FinishIterativeRows( + rows: SubmissionInfo[], + preferredSubmissionIds?: string[], + options?: LimitFirst2FinishIterativeRowsOptions, +): SubmissionInfo[] { + const submissionIds = new Set( + (preferredSubmissionIds ?? []) + .map(submissionId => normalizeIdentifier(submissionId)) + .filter((submissionId): submissionId is string => Boolean(submissionId)), + ) + + if (!submissionIds.size && !options?.forceSingleRow) { + return rows + } + + const matchingRows = rows.filter(submission => { + const submissionId = normalizeIdentifier(submission.id) + const reviewSubmissionId = normalizeIdentifier(submission.review?.submissionId) + + return (submissionId ? submissionIds.has(submissionId) : false) + || (reviewSubmissionId ? submissionIds.has(reviewSubmissionId) : false) + }) + + if (matchingRows.length) { + return matchingRows + } + + if (rows.length <= 1) { + return rows + } + + const withOrdering = rows + .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 + }) + + return withOrdering[0] ? [withOrdering[0].submission] : rows +} + +/** + * Filter iterative-review rows for the selected tab while preserving reviewer placeholders. + * + * @param args - Iterative-review rows, selected phase context, and challenge resources. + * @returns Rows that belong on the currently selected iterative-review tab. + */ +export function filterIterativeReviewRows(args: FilterIterativeReviewRowsArgs): SubmissionInfo[] { + const { + aiReviewDecisionsBySubmissionId, + challengePhases, + isPostMortemPhase, + limitToSubmissionIds, + phaseIdFilter, + reviewerResources, + sourceRows, + }: FilterIterativeReviewRowsArgs = args + + const phaseIdFilterSet = buildPhaseIdFilterSet(challengePhases, phaseIdFilter) + const iterativeReviewPhaseCount = countIterativeReviewPhases(challengePhases) + const iterativeReviewerResourceIds = collectIterativeReviewerResourceIds(reviewerResources) + + if (phaseIdFilterSet?.size) { + const filteredRows = sourceRows.filter(submission => { + const reviewPhaseId = normalizeIdentifier(submission.review?.phaseId) + if (reviewPhaseId) { + return phaseIdFilterSet.has(reviewPhaseId) + } + + if (shouldTreatAsAiFailedSubmission(submission, aiReviewDecisionsBySubmissionId)) { + return true + } + + // New WM F2F flows can surface assigned submissions before the review row has a phase id. + return isIterativePlaceholderRow({ + challengePhases, + isPostMortemPhase, + iterativeReviewerResourceIds, + iterativeReviewPhaseCount, + submission, + }) + }) + + return limitFirst2FinishIterativeRows(filteredRows, limitToSubmissionIds) + } + + if (!isPostMortemPhase) { + const iterativeOnly = sourceRows.filter(submission => ( + shouldTreatAsAiFailedSubmission(submission, aiReviewDecisionsBySubmissionId) + || !shouldIncludeInReviewPhase( + submission, + challengePhases, + ) + )) + if (iterativeOnly.length) { + return limitFirst2FinishIterativeRows(iterativeOnly, limitToSubmissionIds) + } + } + + return limitFirst2FinishIterativeRows(sourceRows, limitToSubmissionIds) +} 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 43793e3fc..544b430b9 100644 --- a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss +++ b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss @@ -4,6 +4,13 @@ $error-line-height: 14px; .container { + --field-markdown-editor-default-border-color: var(--EditorBorder, rgba(168, 168, 168, 0.5)); + --field-markdown-editor-error-color: var(--RedError, #D34E3B); + --field-markdown-editor-muted-color: var(--GrayFontColor, #767676); + --field-markdown-editor-link-color: var(--Link, #0d61bf); + --field-markdown-editor-border-color: white; + --field-markdown-editor-top-border-color: var(--field-markdown-editor-default-border-color); + display: flex; flex-direction: column; height: 280px; @@ -13,19 +20,13 @@ $error-line-height: 14px; gap: 0; } + &.showBorder { + --field-markdown-editor-border-color: var(--field-markdown-editor-default-border-color); + } + &.isError { - :global { - .CodeMirror.CodeMirror-wrap { - border-right: 1px solid var(--RedError); - border-left: 1px solid var(--RedError); - border-bottom: 1px solid var(--RedError); - } - .editor-toolbar { - border-top: 1px solid var(--RedError); - border-left: 1px solid var(--RedError); - border-right: 1px solid var(--RedError); - } - } + --field-markdown-editor-border-color: var(--field-markdown-editor-error-color); + --field-markdown-editor-top-border-color: var(--field-markdown-editor-error-color); } &.disabled { @@ -46,10 +47,10 @@ $error-line-height: 14px; flex: 1; box-sizing: border-box; height: auto; - border-right: 1px solid white; - border-left: 1px solid white; - border-bottom: 1px solid white; - border-top: 1px solid var(--EditorBorder); + border-right: 1px solid var(--field-markdown-editor-border-color); + border-left: 1px solid var(--field-markdown-editor-border-color); + border-bottom: 1px solid var(--field-markdown-editor-border-color); + border-top: 1px solid var(--field-markdown-editor-top-border-color); border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; font-family: "Nunito Sans", sans-serif; @@ -66,9 +67,9 @@ $error-line-height: 14px; opacity: 1; border-top-left-radius: 8px; border-top-right-radius: 8px; - border-top: 1px solid white; - border-left: 1px solid white; - border-right: 1px solid white; + border-top: 1px solid var(--field-markdown-editor-border-color); + border-left: 1px solid var(--field-markdown-editor-border-color); + border-right: 1px solid var(--field-markdown-editor-border-color); display: flex; flex-wrap: wrap; gap: 8px; @@ -107,7 +108,7 @@ $error-line-height: 14px; } i.separator { - border-left: 1px solid var(--EditorBorder); + border-left: 1px solid var(--field-markdown-editor-default-border-color); @include ltemd { margin: 0 $sp-1; } @@ -115,7 +116,7 @@ $error-line-height: 14px; } .editor-statusbar { - color: var(--GrayFontColor); + color: var(--field-markdown-editor-muted-color); font-family: "Nunito Sans", sans-serif; font-size: 14px; line-height: 19px; @@ -132,7 +133,7 @@ $error-line-height: 14px; margin-right: auto; display: flex; font-family: "Nunito Sans", sans-serif; - color: var(--GrayFontColor); + color: var(--field-markdown-editor-muted-color); font-size: 14px; line-height: 20px; } @@ -151,7 +152,7 @@ $error-line-height: 14px; .cm-s-easymde { .cm-link, .cm-url { - color: var(--Link); + color: var(--field-markdown-editor-link-color); } } } @@ -159,7 +160,7 @@ $error-line-height: 14px; .remainingCharacters { font-family: "Nunito Sans", sans-serif; - color: var(--GrayFontColor); + color: var(--field-markdown-editor-muted-color); font-size: 14px; line-height: 20px; @include ltemd { diff --git a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx index 7412fd800..37dea39b8 100644 --- a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx +++ b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx @@ -43,6 +43,7 @@ interface Props { onChange?: (value: string) => void onBlur?: () => void error?: string + hideErrorMessage?: boolean showBorder?: boolean disabled?: boolean uploadCategory?: string @@ -902,7 +903,7 @@ export const FieldMarkdownEditor: FC = (props: Props) => { characters remaining
)} - {props.error && ( + {props.error && !props.hideErrorMessage && (
{props.error}
diff --git a/src/apps/review/src/lib/components/PageWrapper/PageWrapper.module.scss b/src/apps/review/src/lib/components/PageWrapper/PageWrapper.module.scss index 20f85d04f..1bd763251 100644 --- a/src/apps/review/src/lib/components/PageWrapper/PageWrapper.module.scss +++ b/src/apps/review/src/lib/components/PageWrapper/PageWrapper.module.scss @@ -35,6 +35,12 @@ gap: 4px; } +.blockTitleAction { + display: inline-flex; + align-items: center; + margin-bottom: 6px; +} + .blockExternalLink { margin-left: $sp-2; margin-bottom: 6px; diff --git a/src/apps/review/src/lib/components/PageWrapper/PageWrapper.tsx b/src/apps/review/src/lib/components/PageWrapper/PageWrapper.tsx index 904f3901c..718561ba8 100644 --- a/src/apps/review/src/lib/components/PageWrapper/PageWrapper.tsx +++ b/src/apps/review/src/lib/components/PageWrapper/PageWrapper.tsx @@ -20,13 +20,16 @@ interface Props { backUrl?: string backAction?: () => void titleUrl?: string | 'emptyLink' + titleAction?: ReactNode rightHeader?: ReactNode, breadCrumb: BreadCrumbData[] } export const PageWrapper: FC> = props => (
- + {props.breadCrumb.length > 0 && ( + + )} {props.pageTitle}
@@ -46,6 +49,13 @@ export const PageWrapper: FC> = props => ( {props.pageTitle} + {props.titleAction + ? ( +
+ {props.titleAction} +
+ ) + : undefined} {props.titleUrl && props.titleUrl !== 'emptyLink' && ( = props => { useEffect(() => { if (props.setReviewStatus && props.scorecard) { const isCompleted = props.reviewInfo?.status === 'COMPLETED' - const score = isCompleted ? props.reviewInfo!.finalScore! : totalScore + const score = isCompleted + ? (props.reviewInfo?.finalScore ?? totalScore) + : totalScore let status: 'passed' |'failed-score' |'pending' = ( score >= (props.scorecard.minimumPassingScore ?? 50) ? 'passed' : 'failed-score' ) @@ -165,7 +167,14 @@ const ScorecardViewerContent: FC = props => { status, }) } - }, [totalScore, reviewProgress, props.scorecard]) + }, [ + totalScore, + reviewProgress, + props.scorecard, + props.reviewInfo?.finalScore, + props.reviewInfo?.status, + props.setReviewStatus, + ]) const actionButtons = useMemo(() => (
diff --git a/src/apps/review/src/lib/components/TableAppealsForSubmitter/TableAppealsForSubmitter.tsx b/src/apps/review/src/lib/components/TableAppealsForSubmitter/TableAppealsForSubmitter.tsx index 1c7e935cc..0123f81a1 100644 --- a/src/apps/review/src/lib/components/TableAppealsForSubmitter/TableAppealsForSubmitter.tsx +++ b/src/apps/review/src/lib/components/TableAppealsForSubmitter/TableAppealsForSubmitter.tsx @@ -33,6 +33,7 @@ import { import type { DownloadButtonConfig, ScoreVisibilityConfig, + SubmissionReviewerRow, SubmissionRow, } from '../common/types' import { @@ -60,6 +61,7 @@ import { WITHOUT_APPEAL, } from '../../../config/index.config' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' +import { buildSubmissionReviewerRows } from '../common/reviewResult' import styles from './TableAppealsForSubmitter.module.scss' @@ -244,12 +246,9 @@ export const TableAppealsForSubmitter: FC = (prop [aggregatedRows], ) - const maxReviewCount = useMemo( - () => aggregatedRows.reduce( - (count, row) => Math.max(count, row.reviews.length), - 0, - ), - [aggregatedRows], + const reviewerRows = useMemo( + () => buildSubmissionReviewerRows(submissionRows), + [submissionRows], ) const { @@ -292,26 +291,63 @@ export const TableAppealsForSubmitter: FC = (prop [ownedMemberIds], ) - const columns = useMemo[]>(() => { - const baseColumns: TableColumn[] = [] + const columns = useMemo[]>(() => { + const baseColumns: TableColumn[] = [] baseColumns.push({ - className: styles.submissionColumn, + className: classNames(styles.submissionColumn, 'no-row-border'), columnId: 'submission-id', label: 'Submission ID', - renderer: submission => renderSubmissionIdCell(submission, downloadConfigBase), + renderer: submission => ( + submission.isFirstReviewerRow + ? renderSubmissionIdCell(submission, downloadConfigBase) + : + ), type: 'element', }) if (isChallengeCompleted) { baseColumns.push({ + className: 'no-row-border', columnId: 'submitter', label: 'Submitter', - renderer: submission => renderSubmitterHandleCell(submission), + renderer: submission => ( + submission.isFirstReviewerRow + ? renderSubmitterHandleCell(submission) + : + ), type: 'element', }) } + baseColumns.push({ + className: 'no-row-border', + columnId: 'review-score', + label: 'Review Score', + renderer: submission => { + if (!submission.isFirstReviewerRow) { + return + } + + const isOwnedSubmission = isOwned(submission) + const scoreConfig: ScoreVisibilityConfig = { + canDisplayScores, + canViewScorecard: isChallengeCompleted || isOwnedSubmission, + isAppealsTab: true, + } + + return renderReviewScoreCell(submission, scoreConfig) + }, + type: 'element', + }) + + baseColumns.push({ + columnId: 'reviewer', + label: 'Reviewer', + renderer: submission => renderReviewerCell(submission, submission.reviewerIndex), + type: 'element', + }) + baseColumns.push({ columnId: 'review-date', label: 'Review Date', @@ -320,8 +356,8 @@ export const TableAppealsForSubmitter: FC = (prop }) baseColumns.push({ - columnId: 'review-score', - label: 'Review Score', + columnId: 'score', + label: 'Score', renderer: submission => { const isOwnedSubmission = isOwned(submission) const scoreConfig: ScoreVisibilityConfig = { @@ -330,22 +366,16 @@ export const TableAppealsForSubmitter: FC = (prop isAppealsTab: true, } - return renderReviewScoreCell(submission, scoreConfig) + return renderScoreCell(submission, submission.reviewerIndex, scoreConfig) }, type: 'element', }) - for (let index = 0; index < maxReviewCount; index += 1) { - baseColumns.push({ - columnId: `reviewer-${index}`, - label: `Reviewer ${index + 1}`, - renderer: submission => renderReviewerCell(submission, index), - type: 'element', - }) - + if (allowsAppeals) { baseColumns.push({ - columnId: `score-${index}`, - label: `Score ${index + 1}`, + className: styles.tableCellNoWrap, + columnId: 'appeals', + label: 'Appeals', renderer: submission => { const isOwnedSubmission = isOwned(submission) const scoreConfig: ScoreVisibilityConfig = { @@ -354,29 +384,10 @@ export const TableAppealsForSubmitter: FC = (prop isAppealsTab: true, } - return renderScoreCell(submission, index, scoreConfig) + return renderAppealsCell(submission, submission.reviewerIndex, scoreConfig) }, type: 'element', }) - - if (allowsAppeals) { - baseColumns.push({ - className: styles.tableCellNoWrap, - columnId: `appeals-${index}`, - label: `Appeals ${index + 1}`, - renderer: submission => { - const isOwnedSubmission = isOwned(submission) - const scoreConfig: ScoreVisibilityConfig = { - canDisplayScores, - canViewScorecard: isChallengeCompleted || isOwnedSubmission, - isAppealsTab: true, - } - - return renderAppealsCell(submission, index, scoreConfig) - }, - type: 'element', - }) - } } if (props.aiReviewers) { @@ -384,16 +395,25 @@ export const TableAppealsForSubmitter: FC = (prop columnId: 'ai-reviews-table', isExpand: true, label: '', - renderer: (submission: SubmissionRow, allRows: SubmissionRow[]) => ( - props.aiReviewers && ( + renderer: (submission: SubmissionReviewerRow, allRows: SubmissionReviewerRow[]) => { + if (!submission.isLastReviewerRow || !props.aiReviewers) { + return + } + + const firstIndexForSubmission = allRows.findIndex(candidate => ( + candidate.id === submission.id && candidate.isFirstReviewerRow + )) + const defaultOpen = firstIndexForSubmission === 0 + + return ( ) - ), + }, type: 'element', }) } @@ -405,10 +425,9 @@ export const TableAppealsForSubmitter: FC = (prop downloadConfigBase, isChallengeCompleted, isOwned, - maxReviewCount, ]) - const columnsMobile = useMemo[][]>( + const columnsMobile = useMemo[][]>( () => columns.map(column => ( [ column.label && { @@ -429,7 +448,7 @@ export const TableAppealsForSubmitter: FC = (prop colSpan: column.label ? 1 : 2, mobileType: 'last-value', }, - ].filter(Boolean) as MobileTableColumn[] + ].filter(Boolean) as MobileTableColumn[] )), [columns], ) @@ -445,11 +464,11 @@ export const TableAppealsForSubmitter: FC = (prop )} > {isTablet ? ( - + ) : (
= (props: Props) => { }, ] - const hasAnyMyAssignment = rows.some(row => Boolean(row.myReviewResourceId)) + const hasAnyMyAssignment = rows.some( + row => isViewerAssignedToScreening(row, myResourceIds), + ) const canShowReopenActions = rows.some(row => computeReopenEligibility(row).canReopen) if (!hasAnyMyAssignment && !canShowReopenActions) { return appendAiColumn(screeningColumns) @@ -489,10 +496,12 @@ export const TableCheckpointSubmissions: FC = (props: Props) => { propertyName: 'action', renderer: (data: Screening) => { const actions: Array<{ key: string; render: (isLast: boolean) => JSX.Element }> = [] - const status = (data.myReviewStatus || '').toUpperCase() + const isOwnAssignment = isViewerAssignedToScreening(data, myResourceIds) + const status = resolveViewerReviewStatus(data) + const actionReviewId = resolveViewerReviewId(data) if ( - data.myReviewResourceId + isOwnAssignment && ['COMPLETED', 'SUBMITTED'].includes(status) ) { actions.push({ @@ -510,12 +519,12 @@ export const TableCheckpointSubmissions: FC = (props: Props) => { ), }) - } else if (data.myReviewId) { + } else if (isOwnAssignment && actionReviewId) { actions.push({ - key: `complete-${data.myReviewId}`, + key: `complete-${actionReviewId}`, render: isLast => ( @@ -642,7 +651,9 @@ export const TableCheckpointSubmissions: FC = (props: Props) => { }, ] - const hasAnyMyAssignment = rows.some(row => Boolean(row.myReviewResourceId)) + const hasAnyMyAssignment = rows.some( + row => isViewerAssignedToScreening(row, myResourceIds), + ) const canShowReopenActions = rows.some(row => computeReopenEligibility(row).canReopen) if (!hasAnyMyAssignment && !canShowReopenActions) { return appendAiColumn(reviewColumns) @@ -653,10 +664,12 @@ export const TableCheckpointSubmissions: FC = (props: Props) => { propertyName: 'action', renderer: (data: Screening) => { const actions: Array<{ key: string; render: (isLast: boolean) => JSX.Element }> = [] - const status = (data.myReviewStatus || '').toUpperCase() + const isOwnAssignment = isViewerAssignedToScreening(data, myResourceIds) + const status = resolveViewerReviewStatus(data) + const actionReviewId = resolveViewerReviewId(data) if ( - data.myReviewResourceId + isOwnAssignment && ['COMPLETED', 'SUBMITTED'].includes(status) ) { actions.push({ @@ -674,12 +687,12 @@ export const TableCheckpointSubmissions: FC = (props: Props) => { ), }) - } else if (data.myReviewId) { + } else if (isOwnAssignment && actionReviewId) { actions.push({ - key: `complete-${data.myReviewId}`, + key: `complete-${actionReviewId}`, render: isLast => ( diff --git a/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.module.scss b/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.module.scss index f3f9b4369..b2b19091a 100644 --- a/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.module.scss +++ b/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.module.scss @@ -173,6 +173,16 @@ } } +.actionsCell { + display: inline-flex; + flex-wrap: wrap; + gap: $sp-2; +} + +.actionItem { + display: inline-flex; +} + .completedAction { align-items: center; display: flex; diff --git a/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.tsx b/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.tsx index 2208a9a1c..e0cadd634 100644 --- a/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.tsx +++ b/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.tsx @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ /** * Table Iterative Review. */ @@ -7,9 +8,12 @@ import { useCallback, useContext, useMemo, + useState, } from 'react' import { Link } from 'react-router-dom' import { toast } from 'react-toastify' +import { useSWRConfig } from 'swr' +import { FullConfiguration } from 'swr/dist/types' import _ from 'lodash' import classNames from 'classnames' @@ -33,18 +37,24 @@ import type { UseRolePermissionsResult } from '../../hooks/useRolePermissions' import { useRolePermissions } from '../../hooks/useRolePermissions' import type { UseSubmissionDownloadAccessResult } from '../../hooks/useSubmissionDownloadAccess' import { + AiReviewDecisionEscalation, BackendResource, ChallengeDetailContextModel, ReviewAppContextModel, ReviewInfo, SubmissionInfo, } from '../../models' -import { getHandleUrl, isReviewPhase } from '../../utils' -import type { SubmissionRow } from '../common/types' +import { getHandleUrl, isReviewPhase, refreshChallengeReviewData } from '../../utils' +import { + AiReviewEscalationDecision, + getAiReviewDecisionsCacheKey, +} from '../../services' +import type { SubmissionReviewerRow, SubmissionRow } from '../common/types' import { resolveSubmissionReviewResult } from '../common/reviewResult' import { ProgressBar } from '../ProgressBar' import { TableWrapper } from '../TableWrapper' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' +import { EscalationModals } from '../TableReview/EscalationModals' import { SUBMISSION_DOWNLOAD_RESTRICTION_MESSAGE } from '../../constants' import styles from './TableIterativeReview.module.scss' @@ -492,9 +502,17 @@ export const TableIterativeReview: FC = (props: Props) => { const hideSubmissionColumn = props.hideSubmissionColumn ?? false const isDownloading = props.isDownloading const columnLabel = props.columnLabel || 'Iterative Review' - const { challengeInfo, myRoles, resources }: ChallengeDetailContextModel = useContext( + const { + aiReviewConfig, + aiReviewDecisionsBySubmissionId, + challengeInfo, + myRoles, + resources, + reviewers, + }: ChallengeDetailContextModel = useContext( ChallengeDetailContext, ) + const { mutate }: FullConfiguration = useSWRConfig() const { isSubmissionDownloadRestricted, restrictionMessage, @@ -506,7 +524,11 @@ export const TableIterativeReview: FC = (props: Props) => { const isTablet = useMemo(() => screenWidth <= 744, [screenWidth]) const { loginUserInfo }: ReviewAppContextModel = useContext(ReviewAppContext) const { actionChallengeRole, myChallengeResources }: useRoleProps = useRole() - const { isCopilotWithReviewerAssignments, canViewAllSubmissions }: UseRolePermissionsResult = useRolePermissions() + const { + canManageCompletedReviews, + isCopilotWithReviewerAssignments, + canViewAllSubmissions, + }: UseRolePermissionsResult = useRolePermissions() const isSubmitterView = actionChallengeRole === SUBMITTER const ownedMemberIds: Set = useMemo( (): Set => new Set( @@ -551,6 +573,88 @@ export const TableIterativeReview: FC = (props: Props) => { return mapping }, [resources]) + const escalationDecisionBySubmissionId = useMemo>( + () => (datas ?? []).reduce((result, submission) => { + const submissionId = submission.id?.trim() + const aiDecision = submissionId + ? aiReviewDecisionsBySubmissionId[submissionId] + : undefined + + if (!submissionId || !aiDecision?.id) { + return result + } + + result.set(submissionId, { + aiReviewDecisionId: aiDecision.id, + challengeId: challengeInfo?.id ?? undefined, + decisionStatus: aiDecision.status, + escalations: aiDecision.escalations ?? [], + submissionId, + submissionLocked: aiDecision.submissionLocked, + }) + + return result + }, new Map()), + [aiReviewDecisionsBySubmissionId, challengeInfo?.id, datas], + ) + + const handleByMemberId = useMemo( + (): Map => { + const map = new Map(); + [ + ...(resources ?? []), + ...(reviewers ?? []), + ].forEach(resource => { + if (resource.memberId) { + map.set(String(resource.memberId), { + color: (resource as any).handleColor ?? undefined, + handle: resource.memberHandle, + }) + } + }) + + return map + }, + [resources, reviewers], + ) + + const [escalateTarget, setEscalateTarget] = useState(undefined) + const [unlockTarget, setUnlockTarget] = useState(undefined) + const [verifyTarget, setVerifyTarget] = useState<{ + submission: SubmissionReviewerRow + decision: AiReviewEscalationDecision + escalations: AiReviewDecisionEscalation[] + } | undefined>(undefined) + + const revalidateEscalationData = useCallback(async (): Promise => { + if (aiReviewConfig?.id) { + await mutate(getAiReviewDecisionsCacheKey(aiReviewConfig.id)) + } + + if (challengeInfo?.id) { + await refreshChallengeReviewData(challengeInfo.id) + } + }, [aiReviewConfig?.id, challengeInfo?.id, mutate]) + + const isSubmissionAiLocked = useCallback(( + submission: SubmissionInfo, + decision?: AiReviewEscalationDecision, + ): boolean => { + const hasApprovedEscalation = Boolean(decision?.escalations?.some(escalation => ( + (escalation.status ?? '').toUpperCase() === 'APPROVED' + ))) + + if (decision && decision.submissionLocked === false && hasApprovedEscalation) { + return false + } + + if (submission.review?.id) { + return false + } + + return true + }, []) + const isAdmin = useMemo( () => loginUserInfo?.roles?.some( role => typeof role === 'string' @@ -614,13 +718,16 @@ export const TableIterativeReview: FC = (props: Props) => { }, [challengeInfo?.status]) const isCurrentPhaseApproval = useMemo( - () => ( - (challengeInfo?.currentPhaseObject?.name + () => normaliseAlphaKey( + ( + challengeInfo?.currentPhaseObject?.name ?? challengeInfo?.currentPhase - ?? '') + ?? '' + ) + .toString() + .toLowerCase(), ) - .toString() - .toLowerCase() === 'approval', + .startsWith('approval'), [challengeInfo?.currentPhase, challengeInfo?.currentPhaseObject?.name], ) @@ -1216,50 +1323,176 @@ export const TableIterativeReview: FC = (props: Props) => { const status = (review?.status ?? '').toUpperCase() const hasReview = Boolean(reviewId) const resourceId = review?.resourceId + const actionEntries: JSX.Element[] = [] + + const decision = data.id + ? escalationDecisionBySubmissionId.get(data.id) + : undefined + const isAiLocked = isFirst2Finish + && isSubmissionAiLocked(data, decision) + && Boolean(decision?.submissionLocked) + + if (isAiLocked && decision && data.id) { + const isReviewerOnly = (hasIterativeReviewerRole || isCopilotWithReviewerAssignments) + && !canManageCompletedReviews + + if (isReviewerOnly && isReviewPhase(challengeInfo)) { + const hasOwnEscalation = decision.escalations.some(escalation => ( + String(escalation.createdBy ?? '') === String(loginUserInfo?.userId ?? '') + )) + + if (!hasOwnEscalation) { + actionEntries.push( + , + ) + } else { + const latest = decision.escalations.slice() + .sort((a, b) => ( + new Date(b.createdAt) + .getTime() - new Date(a.createdAt) + .getTime() + ))[0] + + const escalationStatus = (latest?.status ?? '').toUpperCase() + if (escalationStatus === 'PENDING_APPROVAL') { + actionEntries.push( + + Escalation Pending + , + ) + } else if (escalationStatus === 'APPROVED') { + actionEntries.push( + + Escalation Approved + , + ) + } else if (escalationStatus === 'REJECTED') { + actionEntries.push( + + Escalation Rejected + , + ) + } + } + } - if (!resourceId || !myResourceIds.has(resourceId)) { - return undefined - } + if (canManageCompletedReviews) { + const pendingEscalations = decision.escalations.filter(escalation => ( + escalation.status === 'PENDING_APPROVAL' + )) - if (['COMPLETED', 'SUBMITTED'].includes(status)) { - const normalized = (columnLabel || 'Iterative Review').trim() - const pillText = `${normalized} Complete` - return ( -
- - {pillText} -
- ) + if (pendingEscalations.length) { + actionEntries.push( + , + ) + } + + actionEntries.push( + , + ) + } } - if ( - ['PENDING', 'IN_PROGRESS'].includes(status) - || (!status && hasReview) - || review?.reviewProgress - ) { - if (!reviewId) { - return undefined + if (resourceId && myResourceIds.has(resourceId)) { + if (['COMPLETED', 'SUBMITTED'].includes(status)) { + const normalized = (columnLabel || 'Iterative Review').trim() + const pillText = `${normalized} Complete` + actionEntries.push( +
+ + {pillText} +
, + ) + } else if ( + ['PENDING', 'IN_PROGRESS'].includes(status) + || (!status && hasReview) + || review?.reviewProgress + ) { + if (reviewId) { + actionEntries.push( + + + Complete Review + , + ) + } } + } - return ( - - - Complete Review - - ) + if (!actionEntries.length) { + return undefined } - return undefined - }, [columnLabel, myResourceIds]) + if (actionEntries.length === 1) { + return actionEntries[0] + } + + return ( + + {actionEntries.map(entry => ( + + {entry} + + ))} + + ) + }, [ + canManageCompletedReviews, + challengeInfo, + columnLabel, + escalationDecisionBySubmissionId, + hasIterativeReviewerRole, + isCopilotWithReviewerAssignments, + isFirst2Finish, + isSubmissionAiLocked, + loginUserInfo?.userId, + myResourceIds, + ]) // eslint-disable-next-line complexity const actionColumn: TableColumn | undefined = useMemo(() => { @@ -1295,7 +1528,9 @@ export const TableIterativeReview: FC = (props: Props) => { // Show Action column to Iterative Reviewers during active review phase, // and for First2Finish also when iterative reviews are completed (past phase) - if (!hasIterativeReviewerRole && !isCopilotWithReviewerAssignments) { + if (!hasIterativeReviewerRole + && !isCopilotWithReviewerAssignments + && !canManageCompletedReviews) { return undefined } @@ -1309,9 +1544,17 @@ export const TableIterativeReview: FC = (props: Props) => { .toUpperCase()) )) + const hasAiLockedFirst2FinishSubmission = (datas || []).some(d => { + const decision = d.id + ? escalationDecisionBySubmissionId.get(d.id) + : undefined + return isSubmissionAiLocked(d, decision) && Boolean(decision?.submissionLocked) + }) + const allowColumn = isReviewPhase(challengeInfo) || hasMyIterativeReviewAssignments || (isFirst2Finish && hasCompletedIterativeReviews) + || (isFirst2Finish && hasAiLockedFirst2FinishSubmission) if (!allowColumn) { return undefined } @@ -1324,7 +1567,9 @@ export const TableIterativeReview: FC = (props: Props) => { } }, [ challengeInfo, + canManageCompletedReviews, datas, + escalationDecisionBySubmissionId, hasApproverRole, hasIterativeReviewerRole, hasPostMortemReviewerRole, @@ -1335,6 +1580,7 @@ export const TableIterativeReview: FC = (props: Props) => { isCurrentPhaseApproval, isFirst2Finish, isPostMortemColumn, + isSubmissionAiLocked, renderApprovalAction, renderIterativeAction, renderPostMortemAction, @@ -1447,6 +1693,18 @@ export const TableIterativeReview: FC = (props: Props) => { removeDefaultSort /> )} + + ) } diff --git a/src/apps/review/src/lib/components/TableReviewForSubmitter/TableReviewForSubmitter.tsx b/src/apps/review/src/lib/components/TableReviewForSubmitter/TableReviewForSubmitter.tsx index 72412e4c3..bc30121d3 100644 --- a/src/apps/review/src/lib/components/TableReviewForSubmitter/TableReviewForSubmitter.tsx +++ b/src/apps/review/src/lib/components/TableReviewForSubmitter/TableReviewForSubmitter.tsx @@ -35,6 +35,7 @@ import { import type { DownloadButtonConfig, ScoreVisibilityConfig, + SubmissionReviewerRow, SubmissionRow, } from '../common/types' import { @@ -61,7 +62,7 @@ import { TRACK_CHALLENGE, WITHOUT_APPEAL, } from '../../../config/index.config' -import { resolveSubmissionReviewResult } from '../common/reviewResult' +import { buildSubmissionReviewerRows, resolveSubmissionReviewResult } from '../common/reviewResult' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' import styles from './TableReviewForSubmitter.module.scss' @@ -322,6 +323,11 @@ export const TableReviewForSubmitter: FC = (props: [aggregatedRows], ) + const reviewerRows = useMemo( + () => buildSubmissionReviewerRows(aggregatedSubmissionRows), + [aggregatedSubmissionRows], + ) + const scorecardIds = useMemo>(() => { const ids = new Set() @@ -344,14 +350,6 @@ export const TableReviewForSubmitter: FC = (props: const minimumPassingScoreByScorecardId = useScorecardPassingScores(scorecardIds) - const maxReviewCount = useMemo( - () => aggregatedRows.reduce( - (max, row) => Math.max(max, row.reviews.length), - 0, - ), - [aggregatedRows], - ) - const isOwned = useCallback<( submission: SubmissionInfo | SubmissionRow) => boolean >( @@ -385,37 +383,44 @@ export const TableReviewForSubmitter: FC = (props: canViewSubmissions, ]) - const columns = useMemo[]>(() => { - const columnsList: TableColumn[] = [] + const columns = useMemo[]>(() => { + const columnsList: TableColumn[] = [] columnsList.push({ - className: styles.submissionColumn, + className: classNames(styles.submissionColumn, 'no-row-border'), columnId: 'submission-id', label: 'Submission ID', - renderer: submission => renderSubmissionIdCell(submission, downloadConfigBase), + renderer: submission => ( + submission.isFirstReviewerRow + ? renderSubmissionIdCell(submission, downloadConfigBase) + : + ), type: 'element', }) if (isChallengeCompleted) { columnsList.push({ + className: 'no-row-border', columnId: 'submitter', label: 'Submitter', - renderer: submission => renderSubmitterHandleCell(submission), + renderer: submission => ( + submission.isFirstReviewerRow + ? renderSubmitterHandleCell(submission) + : + ), type: 'element', }) } columnsList.push({ - columnId: 'review-date', - label: 'Review Date', - renderer: submission => renderReviewDateCell(submission), - type: 'element', - }) - - columnsList.push({ + className: 'no-row-border', columnId: 'review-score', label: 'Review Score', renderer: submission => { + if (!submission.isFirstReviewerRow) { + return + } + const isOwnedSubmission = isOwned(submission) const scoreConfig: ScoreVisibilityConfig = { canDisplayScores, @@ -429,9 +434,14 @@ export const TableReviewForSubmitter: FC = (props: }) columnsList.push({ + className: 'no-row-border', columnId: 'review-result', label: 'Review Result', renderer: submission => { + if (!submission.isFirstReviewerRow) { + return + } + const result = resolveSubmissionReviewResult(submission, { minimumPassingScoreByScorecardId, }) @@ -456,30 +466,35 @@ export const TableReviewForSubmitter: FC = (props: type: 'element', }) - for (let index = 0; index < maxReviewCount; index += 1) { - columnsList.push({ - columnId: `reviewer-${index}`, - label: `Reviewer ${index + 1}`, - renderer: submission => renderReviewerCell(submission, index), - type: 'element', - }) + columnsList.push({ + columnId: 'reviewer', + label: 'Reviewer', + renderer: submission => renderReviewerCell(submission, submission.reviewerIndex), + type: 'element', + }) - columnsList.push({ - columnId: `score-${index}`, - label: `Score ${index + 1}`, - renderer: submission => { - const isOwnedSubmission = isOwned(submission) - const scoreConfig: ScoreVisibilityConfig = { - canDisplayScores, - canViewScorecard: isChallengeCompleted || isOwnedSubmission, - isAppealsTab: false, - } + columnsList.push({ + columnId: 'review-date', + label: 'Review Date', + renderer: submission => renderReviewDateCell(submission), + type: 'element', + }) - return renderScoreCell(submission, index, scoreConfig) - }, - type: 'element', - }) - } + columnsList.push({ + columnId: 'score', + label: 'Score', + renderer: submission => { + const isOwnedSubmission = isOwned(submission) + const scoreConfig: ScoreVisibilityConfig = { + canDisplayScores, + canViewScorecard: isChallengeCompleted || isOwnedSubmission, + isAppealsTab: false, + } + + return renderScoreCell(submission, submission.reviewerIndex, scoreConfig) + }, + type: 'element', + }) const showActionsColumn = shouldShowHistoryActions && !shouldRestrictSubmitterToOwnSubmission @@ -488,8 +503,8 @@ export const TableReviewForSubmitter: FC = (props: columnId: 'actions', label: 'Actions', renderer: submission => { - if (!submission.id) { - return -- + if (!submission.isFirstReviewerRow || !submission.id) { + return } const isOwnedSubmission = isOwned(submission) @@ -534,16 +549,25 @@ export const TableReviewForSubmitter: FC = (props: columnId: 'ai-reviews-table', isExpand: true, label: '', - renderer: (submission: SubmissionRow, allRows: SubmissionRow[]) => ( - props.aiReviewers && ( + renderer: (submission: SubmissionReviewerRow, allRows: SubmissionReviewerRow[]) => { + if (!submission.isLastReviewerRow || !props.aiReviewers) { + return + } + + const firstIndexForSubmission = allRows.findIndex(candidate => ( + candidate.id === submission.id && candidate.isFirstReviewerRow + )) + const defaultOpen = firstIndexForSubmission === 0 + + return ( ) - ), + }, type: 'element', }) } @@ -556,7 +580,6 @@ export const TableReviewForSubmitter: FC = (props: historyByMember, isChallengeCompleted, latestSubmissionIds, - maxReviewCount, minimumPassingScoreByScorecardId, isOwned, restrictToLatest, @@ -564,29 +587,50 @@ export const TableReviewForSubmitter: FC = (props: shouldRestrictSubmitterToOwnSubmission, ]) - const columnsMobile = useMemo[][]>( - () => columns.map(column => ( - [ - column.label && { - ...column, - className: '', - label: `${column.label as string} label`, - mobileType: 'label', - renderer: () => ( -
- {column.label as string} - : -
- ), - type: 'element', - }, + const columnsMobile = useMemo[][]>( + () => columns.map(column => { + const resolvedLabel = typeof column.label === 'function' + ? column.label() ?? '' + : (column.label ?? '') + const labelForAction = typeof column.label === 'string' + ? column.label + : resolvedLabel + + if (labelForAction === 'Action' || labelForAction === 'Actions') { + return [ + { + ...column, + colSpan: 2, + mobileType: 'last-value', + }, + ] + } + + const labelText = resolvedLabel || '' + + return [ + (labelText && ( + { + ...column, + className: '', + label: labelText ? `${labelText} label` : 'label', + mobileType: 'label', + renderer: () => ( +
+ {labelText} + : +
+ ), + type: 'element', + } + )), { ...column, - colSpan: column.label ? 1 : 2, + colSpan: labelText ? 1 : 2, mobileType: 'last-value', }, - ].filter(Boolean) as MobileTableColumn[] - )), + ].filter(Boolean) as MobileTableColumn[] + }), [columns], ) @@ -601,11 +645,11 @@ export const TableReviewForSubmitter: FC = (props: )} > {isTablet ? ( - + ) : (
boolean - shouldMaskScore: (entry: Screening) => boolean + canViewScorecard: (entry: Screening, detail?: ScreeningReviewDetail) => boolean + shouldMaskScore: (entry: Screening, detail?: ScreeningReviewDetail) => boolean + hasMultipleScreeners: boolean + maxScreenerCount: number } interface ActionRenderer { @@ -265,13 +273,14 @@ const createVirusScanColumn = (): TableColumn => ({ const createMyReviewActions = ( data: Screening, - options: { allowCompleteScreeningAction: boolean }, + options: { allowCompleteScreeningAction: boolean; myResourceIds: Set }, ): ActionRenderer[] => { - if (!data.myReviewResourceId) { + const isOwnAssignment = isViewerAssignedToScreening(data, options.myResourceIds) + if (!isOwnAssignment) { return [] } - const status = (data.myReviewStatus ?? '').toUpperCase() + const status = resolveViewerReviewStatus(data) if (['COMPLETED', 'SUBMITTED'].includes(status)) { return [ { @@ -296,16 +305,17 @@ const createMyReviewActions = ( return [] } - if (!data.myReviewId) { + const reviewId = resolveViewerReviewId(data) + if (!reviewId) { return [] } return [ { - key: `complete-${data.myReviewId}`, + key: `complete-${reviewId}`, render: isLast => ( ( || isInProgressStatus(entry.myReviewStatus) ) +const COMPLETED_REVIEW_STATUSES = new Set(['COMPLETED', 'SUBMITTED']) + +const isCompletedReviewStatus = (value: string | undefined): boolean => ( + COMPLETED_REVIEW_STATUSES.has((value ?? '').toUpperCase()) +) + +const resolveScreeningReviewDetails = (entry: Screening): ScreeningReviewDetail[] => { + if (entry.screeningReviews?.length) { + return entry.screeningReviews + } + + if (!entry.reviewId && !entry.screenerId && !entry.screener?.memberHandle) { + return [] + } + + return [ + { + result: entry.result, + reviewId: entry.reviewId, + reviewPhaseId: entry.reviewPhaseId, + reviewStatus: entry.reviewStatus, + score: entry.score, + screener: entry.screener, + screenerId: entry.screenerId, + }, + ] +} + +const hasIncompleteScreeningReviews = (entry: Screening): boolean => { + const screeningReviews = resolveScreeningReviewDetails(entry) + if (screeningReviews.length <= 1) { + return false + } + + return screeningReviews.some(reviewDetail => !isCompletedReviewStatus(reviewDetail.reviewStatus)) +} + /** * Creates columns for displaying screening review data. * @@ -449,119 +496,149 @@ const isScreeningReviewInProgress = (entry: Screening): boolean => ( const createScreeningColumns = ({ canViewScorecard, shouldMaskScore, -}: ScreeningColumnConfig): TableColumn[] => [ - { - label: 'Screener', - propertyName: 'screener', - renderer: (data: Screening) => { - const normalizedPhaseName = data.phaseName - ?.toLowerCase() - .trim() + hasMultipleScreeners, + maxScreenerCount, +}: ScreeningColumnConfig): TableColumn[] => { + const screenerColumnCount = Math.max(1, maxScreenerCount) + + const renderScreener = ( + screener?: ScreeningReviewDetail['screener'], + ): JSX.Element => { + if (!screener?.memberHandle) { + return -- + } - if (normalizedPhaseName && normalizedPhaseName !== 'screening') { - return - } + return ( + + {screener.memberHandle} + + ) + } - // Display the screener handle from the Screening phase review - // (Review phase data is filtered out in TabContentScreening) - return data.screener?.memberHandle ? ( - - {data.screener?.memberHandle ?? ''} - - ) : ( - - {data.screener?.memberHandle ?? ''} - + const renderResult = (result: Screening['result']): JSX.Element => { + const normalizedValue = (result || '').toUpperCase() + if (normalizedValue === 'PASS') { + return ( + Pass ) - }, - type: 'element', - }, - { - label: 'Screening Score', - propertyName: 'score', - renderer: (data: Screening) => { - const normalizedPhaseName = data.phaseName - ?.toLowerCase() - .trim() - // Link to the Screening phase scorecard - // (Review phase scorecards are filtered out upstream) - const maskScore = shouldMaskScore(data) - const scoreValue = maskScore ? '--' : (data.score ?? '-') - - if (normalizedPhaseName && normalizedPhaseName !== 'screening') { - return {scoreValue} - } + } - if (!data.reviewId || maskScore) { - return {scoreValue} - } + if (normalizedValue === 'NO PASS' || normalizedValue === 'FAIL') { + return ( + Fail + ) + } - const canAccessScorecard = canViewScorecard(data) + return - + } - if (!canAccessScorecard) { - return ( - - - {scoreValue} - - - ) - } + const columns: TableColumn[] = [] - return ( - - {scoreValue} - - ) - }, - type: 'element', - }, - { + for (let index = 0; index < screenerColumnCount; index += 1) { + const isSingleScreenerLayout = !hasMultipleScreeners && screenerColumnCount === 1 + const screenerLabel = isSingleScreenerLayout + ? 'Screener' + : `Screener ${index + 1}` + const scoreLabel = isSingleScreenerLayout + ? 'Screening Score' + : `Screening Score ${index + 1}` + + columns.push( + { + label: screenerLabel, + propertyName: `screener-${index}`, + renderer: (data: Screening) => { + const detail = resolveScreeningReviewDetails(data)[index] + return renderScreener(detail?.screener) + }, + type: 'element', + }, + { + label: scoreLabel, + propertyName: `screening-score-${index}`, + renderer: (data: Screening) => { + const detail = resolveScreeningReviewDetails(data)[index] + if (!detail) { + return -- + } + + const maskScore = shouldMaskScore(data, detail) + const scoreValue = maskScore ? '--' : (detail.score ?? '-') + + if (!detail.reviewId || maskScore) { + return {scoreValue} + } + + const canAccessScorecard = canViewScorecard(data, detail) + + if (!canAccessScorecard) { + return ( + + + {scoreValue} + + + ) + } + + return ( + + {scoreValue} + + ) + }, + type: 'element', + }, + ) + } + + if (hasMultipleScreeners) { + columns.push({ + label: 'Screening Score', + propertyName: 'screening-aggregate-score', + renderer: (data: Screening) => { + if (hasIncompleteScreeningReviews(data)) { + return -- + } + + const maskScore = shouldMaskScore(data) + return {maskScore ? '--' : (data.score ?? '-')} + }, + type: 'element', + }) + } + + columns.push({ label: 'Screening Result', propertyName: 'result', renderer: (data: Screening) => { - if (isScreeningReviewInProgress(data)) { + if (hasIncompleteScreeningReviews(data) || isScreeningReviewInProgress(data)) { return - } - const val = (data.result || '').toUpperCase() - if (val === 'PASS') { - return ( - Pass - ) - } - - if (val === 'NO PASS' || val === 'FAIL') { - return ( - Fail - ) - } - - return - + return renderResult(data.result) }, type: 'element', - }, -] + }) + + return columns +} const createActionColumn = ({ allowCompleteScreeningAction, @@ -590,7 +667,10 @@ const createActionColumn = ({ actionRenderers.push(...createMyReviewActions( data, - { allowCompleteScreeningAction }, + { + allowCompleteScreeningAction, + myResourceIds, + }, )) const reopenAction = createReopenAction({ @@ -833,10 +913,14 @@ export const TableSubmissionScreening: FC = (props: Props) => { return <> } + if (!props.aiReviewers?.length) { + return <> + } + return ( } defaultOpen={allRows ? !allRows.indexOf(data) : false} /> @@ -876,9 +960,28 @@ export const TableSubmissionScreening: FC = (props: Props) => { .filter(screening => latestSubmissionIds.has(screening.submissionId)) ), [props.screenings, latestSubmissionIds]) + const maxScreenerCount = useMemo( + () => filteredScreenings.reduce( + (maxCount, screening) => Math.max( + maxCount, + resolveScreeningReviewDetails(screening).length, + 1, + ), + 1, + ), + [filteredScreenings], + ) + + const hasMultipleScreeners = useMemo( + () => maxScreenerCount > 1, + [maxScreenerCount], + ) + const hasAnyScreeningAssignment = useMemo( - () => props.screenings.some(screening => Boolean(screening.myReviewResourceId)), - [props.screenings], + () => props.screenings.some( + screening => isViewerAssignedToScreening(screening, myResourceIds), + ), + [props.screenings, myResourceIds], ) const canShowReopenActions = useMemo( @@ -1028,8 +1131,11 @@ export const TableSubmissionScreening: FC = (props: Props) => { [submissionMetaById], ) - const isSubmissionNotViewable = (submission: Screening): boolean => ( - !canViewSubmissions && String(submission.memberId) !== String(loginUserInfo?.userId) + const isSubmissionNotViewable = useCallback( + (submission: Screening): boolean => ( + !canViewSubmissions && String(submission.memberId) !== String(loginUserInfo?.userId) + ), + [canViewSubmissions, loginUserInfo?.userId], ) const submissionColumn = useMemo( () => createSubmissionColumn({ @@ -1080,8 +1186,9 @@ export const TableSubmissionScreening: FC = (props: Props) => { ) const canViewScorecardForRow = useCallback( - (entry: Screening): boolean => { - if (!entry.reviewId) { + (entry: Screening, detail?: ScreeningReviewDetail): boolean => { + const detailReviewId = detail?.reviewId ?? entry.reviewId + if (!detailReviewId) { return false } @@ -1097,13 +1204,13 @@ export const TableSubmissionScreening: FC = (props: Props) => { return true } - if (entry.myReviewId && entry.reviewId === entry.myReviewId) { + if (entry.myReviewId && detailReviewId === entry.myReviewId) { return true } const reviewerResourceIds = [ entry.myReviewResourceId, - entry.screenerId, + detail?.screenerId ?? entry.screenerId, ].filter((id): id is string => Boolean(id)) return reviewerResourceIds.some(id => myResourceIds.has(id)) @@ -1117,7 +1224,7 @@ export const TableSubmissionScreening: FC = (props: Props) => { ) const shouldMaskScoreForRow = useCallback( - (entry: Screening): boolean => { + (entry: Screening, detail?: ScreeningReviewDetail): boolean => { if (!hasSubmitterRole) { return false } @@ -1134,7 +1241,7 @@ export const TableSubmissionScreening: FC = (props: Props) => { return false } - if (canViewScorecardForRow(entry)) { + if (canViewScorecardForRow(entry, detail)) { return false } @@ -1156,10 +1263,14 @@ export const TableSubmissionScreening: FC = (props: Props) => { const screeningColumns = useMemo[]>( () => createScreeningColumns({ canViewScorecard: canViewScorecardForRow, + hasMultipleScreeners, + maxScreenerCount, shouldMaskScore: shouldMaskScoreForRow, }), [ canViewScorecardForRow, + hasMultipleScreeners, + maxScreenerCount, shouldMaskScoreForRow, ], ) @@ -1190,6 +1301,7 @@ export const TableSubmissionScreening: FC = (props: Props) => { openReopenDialog, pendingReopen?.reviewId, isReopening, + showScreeningColumns, shouldShowHistoryActions, ], ) diff --git a/src/apps/review/src/lib/constants.ts b/src/apps/review/src/lib/constants.ts index 4d04d03ce..de5ecb563 100644 --- a/src/apps/review/src/lib/constants.ts +++ b/src/apps/review/src/lib/constants.ts @@ -3,3 +3,33 @@ export const SUBMISSION_TYPE_CHECKPOINT = 'CHECKPOINT_SUBMISSION' export const TABLE_DATE_FORMAT = 'MMM DD, HH:mm A' export const SUBMISSION_DOWNLOAD_RESTRICTION_MESSAGE = 'This challenge is a private challenge. You do not have permission to download submissions.' + +const normalizeSubmissionType = (value?: string | null): string => ( + (value ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z]/g, '') +) + +const NORMALIZED_CONTEST_SUBMISSION_TYPE = normalizeSubmissionType(SUBMISSION_TYPE_CONTEST) +const NORMALIZED_CHECKPOINT_SUBMISSION_TYPE = normalizeSubmissionType(SUBMISSION_TYPE_CHECKPOINT) + +interface SubmissionTypeMatchOptions { + defaultToContest?: boolean +} + +export const isContestSubmissionType = ( + value?: string | null, + options?: SubmissionTypeMatchOptions, +): boolean => { + const normalizedType = normalizeSubmissionType(value) + if (!normalizedType) { + return Boolean(options?.defaultToContest) + } + + return normalizedType === NORMALIZED_CONTEST_SUBMISSION_TYPE +} + +export const isCheckpointSubmissionType = ( + value?: string | null, +): boolean => normalizeSubmissionType(value) === NORMALIZED_CHECKPOINT_SUBMISSION_TYPE diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeResults.spec.ts b/src/apps/review/src/lib/hooks/useFetchChallengeResults.spec.ts new file mode 100644 index 000000000..5b8941eed --- /dev/null +++ b/src/apps/review/src/lib/hooks/useFetchChallengeResults.spec.ts @@ -0,0 +1,57 @@ +import type { ChallengeWinner, SubmissionInfo } from '../models' +import { submissionMatchesWinner } from '../utils/winnerMatching' + +const buildWinner = (overrides: Partial = {}): ChallengeWinner => ({ + handle: 'winner-handle', + placement: 1, + userId: 1001, + ...overrides, +}) + +const buildSubmission = (overrides: Partial = {}): SubmissionInfo => ({ + id: 'submission-1', + memberId: '1001', + placement: 1, + reviews: [], + submitterHandle: 'winner-handle', + ...overrides, +}) + +describe('submissionMatchesWinner', () => { + it('matches submissions by member id when the ids agree', () => { + expect(submissionMatchesWinner( + buildSubmission(), + buildWinner(), + )) + .toBe(true) + }) + + it('falls back to the submitter handle for legacy winner records', () => { + expect(submissionMatchesWinner( + buildSubmission({ + memberId: '9999', + placement: 4, + }), + buildWinner({ + userId: 2002, + }), + )) + .toBe(true) + }) + + it('falls back to placement when member ids and handles do not line up', () => { + expect(submissionMatchesWinner( + buildSubmission({ + memberId: '9999', + placement: 2, + submitterHandle: 'captain-handle', + }), + buildWinner({ + handle: 'group-member', + placement: 2, + userId: 3003, + }), + )) + .toBe(true) + }) +}) diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeResults.ts b/src/apps/review/src/lib/hooks/useFetchChallengeResults.ts index 534d486ca..4a2799cc1 100644 --- a/src/apps/review/src/lib/hooks/useFetchChallengeResults.ts +++ b/src/apps/review/src/lib/hooks/useFetchChallengeResults.ts @@ -16,16 +16,19 @@ import { ChallengeDetailContextModel, ChallengeWinner, convertBackendReviewToReviewResult, + convertBackendSubmissionToSubmissionInfo, ProjectResult, ReviewResult, SubmissionInfo, } from '../models' -import { fetchAllChallengeReviews } from '../services' +import { fetchAllChallengeReviews, fetchAllSubmissions } from '../services' import { ChallengeDetailContext } from '../contexts' import { PAST_CHALLENGE_STATUSES } from '../utils/challengeStatus' import { - SUBMISSION_TYPE_CONTEST, + isContestSubmissionType, } from '../constants' +import { buildChallengeResultSubmissionSource } from '../utils/challengeResultSubmissions' +import { submissionMatchesWinner } from '../utils/winnerMatching' type ResourceMemberMapping = ChallengeDetailContextModel['resourceMemberIdMapping'] @@ -71,6 +74,15 @@ const orderReviewsByCreatedDate = (reviews: ReviewResult[]): ReviewResult[] => o ['asc'], ) +const normalizeIdentifier = (value: unknown): string | undefined => { + if (value === undefined || value === null) { + return undefined + } + + const normalized = `${value}`.trim() + return normalized.length ? normalized : undefined +} + const resolveUserInfo = ({ challengeUuid, memberId, @@ -136,16 +148,22 @@ const buildProjectResult = ({ }: BuildProjectResultParams): ProjectResult | undefined => { const memberId = `${winner.userId}` - // Find all submissions for this member - const memberSubmissions = submissions.filter(s => s.memberId === memberId) - const contestSubmissions = memberSubmissions.filter( - submission => (submission.type ?? SUBMISSION_TYPE_CONTEST) === SUBMISSION_TYPE_CONTEST, + // Prefer exact member matches, then fall back to legacy winner identifiers. + const exactMemberSubmissions = submissions.filter(s => s.memberId === memberId) + const matchingSubmissions = exactMemberSubmissions.length + ? exactMemberSubmissions + : submissions.filter(submission => submissionMatchesWinner(submission, winner)) + const contestSubmissions = matchingSubmissions.filter( + submission => isContestSubmissionType( + submission.type, + { defaultToContest: true }, + ), ) // Prefer contest submissions; fall back to everything so we still display something if data is inconsistent const submissionsToEvaluate = contestSubmissions.length ? contestSubmissions - : memberSubmissions + : matchingSubmissions if (!submissionsToEvaluate.length) { return undefined @@ -274,16 +292,40 @@ export function useFetchChallengeResults( : false), [normalizedStatus], ) - const submissionSource = useMemo(() => { - if (isPastChallengeStatus && (challengeInfo?.submissions?.length ?? 0) > 0) { - return challengeInfo?.submissions ?? submissions - } + const { + data: winnerSubmissions, + error: winnerSubmissionsError, + isValidating: isLoadingWinnerSubmissions, + }: SWRResponse = useSWR< + SubmissionInfo[], + Error + >( + shouldFetchReviews + ? `reviewBaseUrl/challengeWinnerSubmissions/${challengeUuid}` + : undefined, + async () => { + const allSubmissions = await fetchAllSubmissions(challengeUuid, 100) + return allSubmissions.map(item => convertBackendSubmissionToSubmissionInfo(item)) + }, + ) - return submissions + const submissionSource = useMemo(() => { + const challengeSubmissions = isPastChallengeStatus + ? (challengeInfo?.submissions ?? submissions) + : submissions + + return buildChallengeResultSubmissionSource({ + challengeSubmissions, + memberMapping: resourceMemberIdMapping, + reviewSubmissions: submissions, + winnerSubmissions, + }) }, [ challengeInfo?.submissions, isPastChallengeStatus, + resourceMemberIdMapping, submissions, + winnerSubmissions, ]) // Use swr hooks for challenge reviews fetching when winners are available @@ -307,18 +349,54 @@ export function useFetchChallengeResults( } }, [error]) + useEffect(() => { + if (winnerSubmissionsError) { + handleError(winnerSubmissionsError) + } + }, [winnerSubmissionsError]) + const reviewsBySubmissionId = useMemo(() => { const result = new Map() const reviewList = challengeReviews ?? [] + const submissionIdAliases = new Map() + + submissionSource.forEach(submission => { + const canonicalId = normalizeIdentifier(submission.id) + if (!canonicalId) { + return + } + + submissionIdAliases.set(canonicalId, canonicalId) + + const legacySubmissionId = normalizeIdentifier(submission.legacySubmissionId) + if (legacySubmissionId) { + submissionIdAliases.set(legacySubmissionId, canonicalId) + } + }) reviewList.forEach(review => { + const canonicalSubmissionId = [ + normalizeIdentifier(review.submissionId), + normalizeIdentifier(review.legacySubmissionId), + ] + .map(identifier => ( + identifier + ? (submissionIdAliases.get(identifier) ?? identifier) + : undefined + )) + .find((identifier): identifier is string => Boolean(identifier)) + + if (!canonicalSubmissionId) { + return + } + const transformedReview = convertBackendReviewToReviewResult(review) - const existing = result.get(review.submissionId) ?? [] - result.set(review.submissionId, [...existing, transformedReview]) + const existing = result.get(canonicalSubmissionId) ?? [] + result.set(canonicalSubmissionId, [...existing, transformedReview]) }) return result - }, [challengeReviews]) + }, [challengeReviews, submissionSource]) const sortedWinners = useMemo( () => orderBy(winners, ['placement'], ['asc']), @@ -355,7 +433,7 @@ export function useFetchChallengeResults( ]) return { - isLoading: shouldFetchReviews ? isLoadingReviews : false, + isLoading: shouldFetchReviews ? (isLoadingReviews || isLoadingWinnerSubmissions) : false, projectResults, } } diff --git a/src/apps/review/src/lib/hooks/useFetchScorecards.ts b/src/apps/review/src/lib/hooks/useFetchScorecards.ts index f04df5b83..fe1237a54 100644 --- a/src/apps/review/src/lib/hooks/useFetchScorecards.ts +++ b/src/apps/review/src/lib/hooks/useFetchScorecards.ts @@ -1,35 +1,22 @@ import useSWR, { SWRResponse } from 'swr' -import { EnvironmentConfig } from '~/config' -import { xhrGetAsync } from '~/libs/core' +import { + fetchScorecards, + FetchScorecardsParams, + ScorecardsResponse as FetchScorecardsResponse, +} from '../services' -import { Scorecard } from '../models' +type UseFetchScorecardsParams = FetchScorecardsParams -interface UseFetchScorecardsParams { - page: number - perPage: number - name?: string - challengeTrack?: string - scorecardType?: string - challengeType?: string - status?: string +export interface ScorecardsResponse extends FetchScorecardsResponse { + error?: any + isValidating: boolean } -export interface ScorecardsResponse { - scoreCards: Scorecard[] - metadata: any - error?: any - isValidating: boolean -} - -const baseUrl = `${EnvironmentConfig.API.V6}` - -const PAGE_SIZE = 20 - export function useFetchScorecards( { page, - perPage = PAGE_SIZE, + perPage, name = '', challengeTrack = '', challengeType = '', @@ -37,30 +24,25 @@ export function useFetchScorecards( status = '', }: UseFetchScorecardsParams, ): ScorecardsResponse { - const query = new URLSearchParams({ - page: String(page), - perPage: String(perPage), - ...(name ? { name } : {}), - ...(scorecardType ? { scorecardType } : {}), - ...(challengeTrack ? { challengeTrack } : {}), - ...(challengeType ? { challengeType } : {}), - ...(status ? { status } : {}), - }) - - const fetcher = (url: string): Promise => xhrGetAsync(url) + const params: FetchScorecardsParams = { + challengeTrack, + challengeType, + name, + page, + perPage, + scorecardType, + status, + } - const { data, error, isValidating }: SWRResponse = useSWR( - `${baseUrl}/scorecards?${query.toString()}`, - fetcher, + const { data, error, isValidating }: SWRResponse = useSWR( + ['scorecards', params], + () => fetchScorecards(params), ) return { error, isValidating, metadata: data?.metadata, - scoreCards: data?.scoreCards?.map(scorecard => ({ - ...scorecard, - minimumPassingScore: scorecard.minimumPassingScore ?? 50, - })) || [], + scoreCards: data?.scoreCards || [], } } diff --git a/src/apps/review/src/lib/hooks/useFetchScreeningReview.ts b/src/apps/review/src/lib/hooks/useFetchScreeningReview.ts index f08cf057f..0589d5b1e 100644 --- a/src/apps/review/src/lib/hooks/useFetchScreeningReview.ts +++ b/src/apps/review/src/lib/hooks/useFetchScreeningReview.ts @@ -1,4 +1,4 @@ -import { every, filter, forEach, orderBy } from 'lodash' +import { filter, forEach } from 'lodash' import { useContext, useEffect, useMemo } from 'react' import useSWR, { type SWRResponse } from 'swr' @@ -7,10 +7,7 @@ import { xhrGetAsync } from '~/libs/core' import { handleError } from '~/libs/shared' import { - ADMIN, - COPILOT, DESIGN, - MANAGER, REVIEWER, SUBMITTER, } from '../../config/index.config' @@ -23,6 +20,7 @@ import type { MappingReviewAppeal, ReviewAppContextModel, Screening, + ScreeningReviewDetail, SubmissionInfo, } from '../models' import { @@ -32,14 +30,17 @@ import { convertBackendSubmissionToSubmissionInfo, } from '../models' import { fetchAllChallengeReviews } from '../services' -import { SUBMISSION_TYPE_CHECKPOINT, SUBMISSION_TYPE_CONTEST } from '../constants' +import { isCheckpointSubmissionType, isContestSubmissionType } from '../constants' import { debugLog, DEBUG_CHECKPOINT_PHASES, isPhaseAllowedForReview, truncateForLog, warnLog } from '../utils' import { registerChallengeReviewKey } from '../utils/reviewCacheRegistry' import { normalizeReviewMetadata } from '../utils/metadataMatching' +import { buildApprovalReviewRows } from '../utils/approvalReviewRows' import { resolvePhaseMeta } from '../utils/phaseResolution' +import { shouldForceChallengeReviewFetch } from '../utils/reviewFetchPolicy' import { buildReviewForResource } from '../utils/reviewBuilding' +import { collectMatchingReviews, selectBestReview } from '../utils/reviewSelection' import { resolveReviewPhaseId, reviewMatchesPhase } from '../utils/reviewMatching' -import { shouldIncludeInReviewPhase } from '../utils/reviewPhaseGuards' +import { calculateReviewProgress } from '../utils/reviewProgress' import { buildResourceFromReviewHandle, determinePassFail, @@ -47,16 +48,9 @@ import { parseSubmissionScore, scoreToDisplay, } from '../utils/reviewScoring' -import type { - SubmissionIdResolutionArgs, - SubmissionLookupArgs, - SubmitterMemberIdResolutionArgs, -} from '../utils/submissionResolution' -import { - resolveFallbackSubmissionId, - resolveSubmissionForReview, - resolveSubmitterMemberId, -} from '../utils/submissionResolution' +import type { SubmissionLookupArgs } from '../utils/submissionResolution' +import { buildSubmitterReviewSubmission } from '../utils/submitterReviewResolution' +import { resolveSubmissionForReview } from '../utils/submissionResolution' import type { useFetchAppealQueueProps } from './useFetchAppealQueue' import { useFetchAppealQueue } from './useFetchAppealQueue' @@ -65,15 +59,12 @@ import { useFetchChallengeSubmissions } from './useFetchChallengeSubmissions' import type { useRoleProps } from './useRole' import { useRole } from './useRole' -const normalizeSubmissionType = (type?: string | null): string => type?.trim() - .toUpperCase() ?? '' - const isContestSubmission = (submission: BackendSubmission): boolean => ( - normalizeSubmissionType(submission?.type) === SUBMISSION_TYPE_CONTEST + isContestSubmissionType(submission?.type) ) const isCheckpointSubmission = (submission: BackendSubmission): boolean => ( - normalizeSubmissionType(submission?.type) === SUBMISSION_TYPE_CHECKPOINT + isCheckpointSubmissionType(submission?.type) ) const resolveCheckpointSubmissionScore = ( @@ -88,158 +79,9 @@ const resolveCheckpointSubmissionScore = ( return parseSubmissionScore(submission.screeningScore) } -const phaseNameEquals = ( - value: string | null | undefined, - target: string, -): boolean => { - if (typeof value !== 'string') { - return false - } - - const normalizedValue = value.trim() - .toLowerCase() - const normalizedTarget = target.trim() - .toLowerCase() - - return normalizedValue === normalizedTarget -} - -const matchesReviewPhaseCandidate = ( - review: BackendReview | undefined, - phaseLabel: string, - scorecardId: string | undefined, - phaseIds: Set, -): boolean => { - if (!review) { - return false - } - - if (reviewMatchesPhase(review, scorecardId, phaseIds, phaseLabel)) { - return true - } - - if (phaseNameEquals((review as { phaseName?: string | null }).phaseName, phaseLabel)) { - return true - } - - const reviewType = (review as { reviewType?: string | null }).reviewType - if (phaseNameEquals(reviewType, phaseLabel)) { - return true - } - - return false -} - -const collectMatchingReviews = ( - submission: BackendSubmission, - phaseLabel: string, - scorecardId: string | undefined, - phaseIds: Set, - submissionReviewMap?: Map, - globalReviews?: BackendReview[], -): BackendReview[] => { - const seen = new Map() - const pushReview = (review: BackendReview | undefined): void => { - if (!review?.id) { - return - } - - if (!matchesReviewPhaseCandidate(review, phaseLabel, scorecardId, phaseIds)) { - return - } - - if (!seen.has(review.id)) { - seen.set(review.id, review) - } - } - - if (submissionReviewMap?.size) { - pushReview(submissionReviewMap.get(submission.id)) - } - - if (Array.isArray(globalReviews) && globalReviews.length) { - globalReviews.forEach(review => { - if (review?.submissionId === submission.id) { - pushReview(review) - } - }) - } - - if (submission.reviewResourceMapping) { - Object.values(submission.reviewResourceMapping) - .forEach(review => { - pushReview(review) - }) - } - - if (Array.isArray(submission.review)) { - submission.review.forEach(review => { - pushReview(review) - }) - } - - return Array.from(seen.values()) -} - -const findFallbackReview = ( - submission: BackendSubmission, - phaseLabel: string, - scorecardId: string | undefined, - phaseIds: Set, -): BackendReview | undefined => { - if (!Array.isArray(submission.review)) { - return undefined - } - - const phaseLabelLower = phaseLabel.toLowerCase() - - return submission.review.find(review => { - if (!review) { - return false - } - - if (matchesReviewPhaseCandidate(review, phaseLabel, scorecardId, phaseIds)) { - return true - } - - const typeMatches = typeof review.typeId === 'string' - && review.typeId.toLowerCase() - .includes(phaseLabelLower) - - return typeMatches - }) -} - -const selectBestReview = ( - reviews: BackendReview[], - phaseLabel: string, - scorecardId: string | undefined, - phaseIds: Set, - submission: BackendSubmission, -): BackendReview | undefined => { - if (!reviews.length) { - return findFallbackReview(submission, phaseLabel, scorecardId, phaseIds) - } - - const sorted = orderBy( - reviews, - [ - (review: BackendReview) => Boolean(review.committed), - (review: BackendReview) => (review.status || '').toUpperCase() === 'COMPLETED', - (review: BackendReview) => { - const score = getNumericScore(review) - return typeof score === 'number' ? score : -Infinity - }, - (review: BackendReview) => { - const updatedAt = review.updatedAt || review.reviewDate || review.createdAt - const parsed = updatedAt ? Date.parse(updatedAt) : NaN - return Number.isFinite(parsed) ? parsed : 0 - }, - ], - ['desc', 'desc', 'desc', 'desc'], - ) - - return sorted[0] +const isCompletedReviewStatus = (status?: string): boolean => { + const normalizedStatus = (status ?? '').toUpperCase() + return normalizedStatus === 'COMPLETED' || normalizedStatus === 'SUBMITTED' } type CheckpointReviewDebugArgs = { @@ -679,34 +521,12 @@ export function useFetchScreeningReview(): useFetchScreeningReviewProps { ) const shouldForceReviewFetch = useMemo( - () => { - const normalizedActionRole = actionChallengeRole ?? '' - - if ( - normalizedActionRole === SUBMITTER - || normalizedActionRole === REVIEWER - || normalizedActionRole === COPILOT - || normalizedActionRole === ADMIN - || normalizedActionRole === MANAGER - ) { - return true - } - - return (myResources ?? []).some(resource => { - const normalizedRoleName = (resource.roleName ?? '').toLowerCase() - - if (!normalizedRoleName) { - return false - } - - return normalizedRoleName.includes('screener') - || normalizedRoleName.includes('reviewer') - || normalizedRoleName.includes('copilot') - || normalizedRoleName.includes('admin') - || normalizedRoleName.includes('manager') - }) - }, - [actionChallengeRole, myResources], + () => shouldForceChallengeReviewFetch( + actionChallengeRole, + challengeInfo?.status, + myResources, + ), + [actionChallengeRole, challengeInfo?.status, myResources], ) const { @@ -848,6 +668,24 @@ export function useFetchScreeningReview(): useFetchScreeningReviewProps { const reviewScorecardId = reviewPhaseMeta.scorecardId const reviewPhaseIds = reviewPhaseMeta.phaseIds + const specificationReviewPhaseMeta = useMemo( + () => resolvePhaseMeta( + 'Specification Review', + challengeInfo?.phases, + challengeInfo?.reviewers, + challengeReviews, + challengeLegacy?.reviewScorecardId, + ), + [ + challengeInfo?.phases, + challengeInfo?.reviewers, + challengeReviews, + challengeLegacy?.reviewScorecardId, + ], + ) + const specificationReviewScorecardId = specificationReviewPhaseMeta.scorecardId + const specificationReviewPhaseIds = specificationReviewPhaseMeta.phaseIds + const iterativeReviewPhaseMeta = useMemo( () => resolvePhaseMeta( 'Iterative Review', @@ -1026,7 +864,14 @@ export function useFetchScreeningReview(): useFetchScreeningReviewProps { } const resourceId = reviewItem.resourceId - const submissionId = reviewItem.submissionId + const resolvedSubmission = resolveSubmissionForReview({ + review: reviewItem, + submissionsById: visibleSubmissionsById, + submissionsByLegacyId: visibleSubmissionsByLegacyId, + } satisfies SubmissionLookupArgs) + const submissionId = resolvedSubmission?.id + ?? reviewItem.submissionId + ?? reviewItem.legacySubmissionId if (!resourceId || !submissionId) { return } @@ -1040,21 +885,12 @@ export function useFetchScreeningReview(): useFetchScreeningReviewProps { return mapping }, - [challengeReviews, reviewerIds], + [challengeReviews, visibleSubmissionsById, visibleSubmissionsByLegacyId], ) // get screening data from challenge submissions const screening = useMemo( () => { - const screeningReviewsBySubmission = new Map() - if (challengeReviews && challengeReviews.length) { - forEach(challengeReviews, rv => { - if (reviewMatchesPhase(rv, screeningScorecardId, screeningPhaseIds, 'Screening')) { - screeningReviewsBySubmission.set(rv.submissionId, rv) - } - }) - } - const minPass = screeningScorecardBase?.minimumPassingScore ?? undefined // Current viewer's resource ids that grant Screening review access (Screener or Reviewer) const myScreeningReviewerResourceIds = new Set(); @@ -1085,35 +921,21 @@ export function useFetchScreeningReview(): useFetchScreeningReviewProps { // eslint-disable-next-line complexity return contestSubmissions.map(item => { const base = convertBackendSubmissionToScreening(item) - let matchedReview = screeningReviewsBySubmission.get(item.id) - if (!matchedReview && item.reviewResourceMapping) { - matchedReview = Object.values(item.reviewResourceMapping) - .find(review => reviewMatchesPhase( - review, - screeningScorecardId, - screeningPhaseIds, - 'Screening', - )) - } - - let numericScore = getNumericScore(matchedReview) - let scoreDisplay = scoreToDisplay(numericScore, base.score) - - if ( - numericScore === undefined - && matchedReview - && ['COMPLETED', 'SUBMITTED'].includes((matchedReview.status || '').toUpperCase()) - ) { - const submissionScore = parseSubmissionScore(item.screeningScore) - if (submissionScore !== undefined) { - numericScore = submissionScore - scoreDisplay = scoreToDisplay(numericScore, base.score) - } - } - - const reviewForHandle = matchedReview - const resolvedScreenerId = reviewForHandle?.resourceId ?? base.screenerId - const result = determinePassFail(numericScore, minPass, base.result, matchedReview?.metadata) + const candidateReviews = collectMatchingReviews( + item, + 'Screening', + screeningScorecardId, + screeningPhaseIds, + undefined, + challengeReviews, + ) + const matchedReview = selectBestReview( + candidateReviews, + 'Screening', + screeningScorecardId, + screeningPhaseIds, + item, + ) const normalizeResourceId = (resourceIdValue: BackendReview['resourceId']): string => { if (typeof resourceIdValue === 'string') { @@ -1164,7 +986,10 @@ export function useFetchScreeningReview(): useFetchScreeningReviewProps { memberHandle: 'Not assigned', } as BackendResource - const screenerDisplay = (() => { + const resolveScreenerDisplay = ( + reviewForHandle: BackendReview | undefined, + resolvedScreenerId: string | undefined, + ): BackendResource => { if (resolvedScreenerId) { const resourceMatch = (resources ?? []).find(resource => resource.id === resolvedScreenerId) if (resourceMatch) { @@ -1184,21 +1009,118 @@ export function useFetchScreeningReview(): useFetchScreeningReviewProps { } return defaultScreener - })() + } + + const reviewEntries = candidateReviews.length + ? candidateReviews + : (matchedReview ? [matchedReview] : []) + + const screeningReviewsWithScore = reviewEntries.map(reviewEntry => { + let reviewNumericScore = getNumericScore(reviewEntry) + + if ( + reviewNumericScore === undefined + && reviewEntries.length <= 1 + && reviewEntry + && isCompletedReviewStatus(reviewEntry.status ?? undefined) + ) { + const submissionScore = parseSubmissionScore(item.screeningScore) + if (submissionScore !== undefined) { + reviewNumericScore = submissionScore + } + } + + const resolvedScreenerId = normalizeResourceId(reviewEntry?.resourceId) || base.screenerId + const screenerDisplay = resolveScreenerDisplay(reviewEntry, resolvedScreenerId) + + const reviewDetail: ScreeningReviewDetail = { + result: determinePassFail( + reviewNumericScore, + minPass, + base.result, + reviewEntry?.metadata, + ), + reviewId: reviewEntry?.id, + reviewPhaseId: resolveReviewPhaseId(reviewEntry), + reviewStatus: reviewEntry?.status ?? undefined, + score: scoreToDisplay(reviewNumericScore, base.score), + screener: screenerDisplay, + screenerId: screenerDisplay?.id ?? resolvedScreenerId, + } + + return { + numericScore: reviewNumericScore, + reviewDetail, + } + }) + + const screeningReviews = screeningReviewsWithScore + .map(({ reviewDetail }: { reviewDetail: ScreeningReviewDetail }) => reviewDetail) + .sort((first, second) => ( + (first.screener?.memberHandle ?? '') + .localeCompare(second.screener?.memberHandle ?? '', undefined, { sensitivity: 'base' }) + || (first.screenerId ?? '') + .localeCompare(second.screenerId ?? '') + )) + + const hasMultipleScreeners = screeningReviews.length > 1 + const hasIncompleteMultipleScreening = hasMultipleScreeners + && screeningReviews.some(reviewDetail => !isCompletedReviewStatus(reviewDetail.reviewStatus)) + const numericScores = screeningReviewsWithScore + .map(({ numericScore }: { numericScore: number | undefined }) => numericScore) + .filter((value): value is number => typeof value === 'number' && Number.isFinite(value)) + const averageNumericScore = numericScores.length + ? numericScores.reduce((sum, value) => sum + value, 0) / numericScores.length + : undefined + const hasFailingResult = screeningReviews.some(reviewDetail => { + const normalizedResult = (reviewDetail.result ?? '').toUpperCase() + return normalizedResult === 'NO PASS' || normalizedResult === 'FAIL' + }) + const hasPassingResult = screeningReviews.some(reviewDetail => ( + (reviewDetail.result ?? '').toUpperCase() === 'PASS' + )) + const aggregatedScoreFallback = hasMultipleScreeners + ? base.score + : (screeningReviews[0]?.score ?? base.score) + const aggregatedScore = hasIncompleteMultipleScreening + ? '--' + : scoreToDisplay(averageNumericScore, aggregatedScoreFallback) + + let aggregatedResult: Screening['result'] = base.result + if (hasIncompleteMultipleScreening) { + aggregatedResult = '-' + } else if (typeof averageNumericScore === 'number' && typeof minPass === 'number') { + aggregatedResult = averageNumericScore >= minPass ? 'PASS' : 'NO PASS' + } else if (hasFailingResult) { + aggregatedResult = 'NO PASS' + } else if (hasPassingResult) { + aggregatedResult = 'PASS' + } + + const primaryReview = myAssignment ?? matchedReview ?? reviewEntries[0] + const primaryScreener = screeningReviews.find( + detail => detail.reviewId && detail.reviewId === primaryReview?.id, + ) ?? screeningReviews[0] + const primaryScreenerId = normalizeResourceId(primaryReview?.resourceId) + || primaryScreener?.screenerId + || base.screenerId + const primaryScreenerDisplay = primaryScreener?.screener + ?? resolveScreenerDisplay(primaryReview, primaryScreenerId) return { ...base, myReviewId: myAssignment?.id, myReviewResourceId: myAssignment?.resourceId, myReviewStatus: myAssignment?.status ?? undefined, - phaseName: matchedReview?.phaseName ?? undefined, - result, - reviewId: matchedReview?.id, - reviewPhaseId: resolveReviewPhaseId(matchedReview), - reviewStatus: matchedReview?.status ?? undefined, - score: scoreDisplay, - screener: screenerDisplay, - screenerId: screenerDisplay?.id ?? resolvedScreenerId, + phaseName: primaryReview?.phaseName ?? matchedReview?.phaseName ?? undefined, + result: aggregatedResult, + reviewId: primaryReview?.id, + reviewPhaseId: resolveReviewPhaseId(primaryReview), + reviewStatus: primaryReview?.status ?? undefined, + score: aggregatedScore, + screener: primaryScreenerDisplay, + screenerId: primaryScreenerDisplay?.id ?? primaryScreenerId, + screeningReviews, userInfo: resourceMemberIdMapping[base.memberId], } }) @@ -1826,58 +1748,12 @@ export function useFetchScreeningReview(): useFetchScreeningReviewProps { submissionsByLegacyId: visibleSubmissionsByLegacyId, } satisfies SubmissionLookupArgs) - const submissionType = matchingSubmission?.type?.trim() - if (submissionType?.toUpperCase() !== 'CONTEST_SUBMISSION') { - return undefined - } - - const submissionWithReview: BackendSubmission | undefined = matchingSubmission - ? { - ...matchingSubmission, - review: [reviewItem], - } - : undefined - - const baseSubmissionInfo = submissionWithReview - ? convertBackendSubmissionToSubmissionInfo(submissionWithReview) - : undefined - - const fallbackId = resolveFallbackSubmissionId({ - baseSubmissionInfo, + return buildSubmitterReviewSubmission({ defaultId: `${memberId || 'submission'}-${index}`, matchingSubmission, + resourceMemberIdMapping, review: reviewItem, - } satisfies SubmissionIdResolutionArgs) - - if (!fallbackId) { - return undefined - } - - const resolvedMemberId = resolveSubmitterMemberId({ - baseSubmissionInfo, - matchingSubmission, - } satisfies SubmitterMemberIdResolutionArgs) - - const reviewInfo = convertBackendReviewToReviewInfo(reviewItem) - const reviewResult = convertBackendReviewToReviewResult(reviewItem) - - return { - ...baseSubmissionInfo, - id: fallbackId, - isLatest: baseSubmissionInfo?.isLatest - ?? matchingSubmission?.isLatest - ?? true, - memberId: resolvedMemberId, - review: reviewInfo, - reviews: [reviewResult], - reviewTypeId: reviewItem.typeId ?? baseSubmissionInfo?.reviewTypeId, - submittedDate: baseSubmissionInfo?.submittedDate, - submittedDateString: baseSubmissionInfo?.submittedDateString, - userInfo: resolvedMemberId - ? resourceMemberIdMapping[resolvedMemberId] - : undefined, - virusScan: baseSubmissionInfo?.virusScan, - } as SubmissionInfo + }) }) .filter((entry): entry is SubmissionInfo => Boolean(entry)) @@ -1918,6 +1794,12 @@ export function useFetchScreeningReview(): useFetchScreeningReviewProps { reviewPhaseIds, 'Review', ) + || reviewMatchesPhase( + candidate, + specificationReviewScorecardId, + specificationReviewPhaseIds, + 'Specification Review', + ) || reviewMatchesPhase( candidate, iterativeReviewScorecardId, @@ -1927,6 +1809,8 @@ export function useFetchScreeningReview(): useFetchScreeningReviewProps { ) reviewerIds.forEach(appendReviewerId) + Object.keys(reviewAssignmentsBySubmission[challengeSubmission.id] ?? {}) + .forEach(appendReviewerId) forEach(challengeSubmission.review, reviewEntry => { if (matchesReviewPhase(reviewEntry)) { appendReviewerId(reviewEntry?.resourceId) @@ -1979,55 +1863,24 @@ export function useFetchScreeningReview(): useFetchScreeningReviewProps { reviewAssignmentsBySubmission, reviewPhaseIds, reviewScorecardId, + specificationReviewPhaseIds, + specificationReviewScorecardId, ]) // Build approval reviews list (one entry per approval review instance) - const approvalReviews = useMemo(() => { - if (!challengeReviews?.length || approvalPhaseIds.size === 0) { - return [] - } - - const result: SubmissionInfo[] = [] - - forEach(challengeReviews, reviewEntry => { - if (!reviewEntry) { - return - } - - if (!reviewMatchesPhase(reviewEntry, approvalScorecardId, approvalPhaseIds, 'Approval')) { - return - } - - const matchingSubmission = resolveSubmissionForReview({ - review: reviewEntry, - submissionsById: visibleSubmissionsById, - submissionsByLegacyId: visibleSubmissionsByLegacyId, - } satisfies SubmissionLookupArgs) - - if (!matchingSubmission) { - return - } - - const submissionWithReview: BackendSubmission = { - ...matchingSubmission, - review: [reviewEntry], - } - - const submissionInfo = convertBackendSubmissionToSubmissionInfo(submissionWithReview) - - result.push({ - ...submissionInfo, - review: submissionInfo.review ?? convertBackendReviewToReviewInfo(reviewEntry), - reviews: [convertBackendReviewToReviewResult(reviewEntry)], - userInfo: resourceMemberIdMapping[submissionInfo.memberId], - }) - }) - - return result - }, [ + const approvalReviews = useMemo(() => buildApprovalReviewRows({ + approvalPhaseIds, + approvalScorecardId, + challengeReviews, + contestSubmissions, + resourceMemberIdMapping, + submissionsById: visibleSubmissionsById, + submissionsByLegacyId: visibleSubmissionsByLegacyId, + }), [ approvalPhaseIds, approvalScorecardId, challengeReviews, + contestSubmissions, resourceMemberIdMapping, visibleSubmissionsById, visibleSubmissionsByLegacyId, @@ -2117,56 +1970,17 @@ export function useFetchScreeningReview(): useFetchScreeningReviewProps { }, [actionChallengeRole, loadResourceAppeal, review, submitterReviews]) // get review progress from challenge review - const reviewProgress = useMemo(() => { - if (!review.length) { - return 0 - } - - const eligibleReviews = review.filter(submission => shouldIncludeInReviewPhase( - submission, - challengeInfo?.phases, - )) - if (!eligibleReviews.length) { - return 0 - } - - const isDesignChallenge = challengeInfo?.track?.name === DESIGN - - const filteredReviews = isDesignChallenge - ? eligibleReviews - : eligibleReviews.filter(item => item.isLatest) - - if (!filteredReviews.length) { - return 0 - } - - const completedReviews = filteredReviews.filter(item => { - const committed = item.review?.committed - if (typeof committed === 'boolean') { - return committed - } - - const status = item.review?.status - if (typeof status === 'string' && status.trim()) { - return status.trim() - .toUpperCase() === 'COMPLETED' - } - - if (!item.reviews?.length) { - return false - } - - return every( - item.reviews, - reviewResult => typeof reviewResult.score === 'number' - && Number.isFinite(reviewResult.score), - ) - }) - - return Math.round( - (completedReviews.length * 100) / filteredReviews.length, - ) - }, [review, challengeInfo?.phases, challengeInfo?.track?.name]) + const reviewProgress = useMemo(() => calculateReviewProgress({ + challengePhases: challengeInfo?.phases, + isDesignChallenge: challengeInfo?.track?.name === DESIGN, + reviewRows: review, + screeningRows: screening, + }), [ + challengeInfo?.phases, + challengeInfo?.track?.name, + review, + screening, + ]) useEffect(() => () => { cancelLoadResourceAppeal() diff --git a/src/apps/review/src/lib/hooks/useFetchSubmissionReviews.ts b/src/apps/review/src/lib/hooks/useFetchSubmissionReviews.ts index 4368f36d9..a89e0e930 100644 --- a/src/apps/review/src/lib/hooks/useFetchSubmissionReviews.ts +++ b/src/apps/review/src/lib/hooks/useFetchSubmissionReviews.ts @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ /** * Fetch reviews of submission */ @@ -57,6 +58,7 @@ import { ReviewItemComment } from '../models/ReviewItemComment.model' import { SUBMITTER } from '../../config/index.config' import { useRole, useRoleProps } from './useRole' +import { applyAppealResponseScoreUpdate } from './useFetchSubmissionReviews.utils' const hasSubmitterReviewDetails = (review?: BackendReview): boolean => { if (!review) { @@ -628,16 +630,26 @@ export function useFetchSubmissionReviews(reviewId: string = ''): useFetchSubmis ? buildReviewItemsPayload(updatedReview) : undefined - const payload = { - committed, - reviewDate, - status, - ...(scorecardId ? { scorecardId } : {}), - ...(challengeInfo?.typeId ? { typeId: challengeInfo.typeId } : {}), - ...(currentPhase?.id ? { phaseId: currentPhase.id } : {}), - ...(reviewItemsPayload - ? { reviewItems: reviewItemsPayload } - : {}), + let payload + + if (!committed && !updatedReview && !fullReview) { + // REOPEN CASE → send only status payload + payload = { + committed: false, + status: 'IN_PROGRESS', + } + } else { + payload = { + committed, + reviewDate, + status, + ...(scorecardId ? { scorecardId } : {}), + ...(challengeInfo?.typeId ? { typeId: challengeInfo.typeId } : {}), + ...(currentPhase?.id ? { phaseId: currentPhase.id } : {}), + ...(reviewItemsPayload + ? { reviewItems: reviewItemsPayload } + : {}), + } } setIsSavingReview(true) @@ -796,25 +808,12 @@ export function useFetchSubmissionReviews(reviewId: string = ''): useFetchSubmis scorecardQuestionId: reviewItem.scorecardQuestionId, }) .then(rs => { - const result = map( - reviewInfo?.reviewItems ?? [], - existingReview => { - if (existingReview.id === reviewItem.id) { - return { - ...existingReview, - finalAnswer: updatedResponse, - } - } - - return existingReview - }, - ) - if (updatedReviewInfo) { - setUpdatedReviewInfo({ - ...updatedReviewInfo, - reviewItems: result, - }) - } + setUpdatedReviewInfo(previousReviewInfo => applyAppealResponseScoreUpdate( + previousReviewInfo ?? reviewInfo, + reviewItem.id, + updatedResponse, + scorecardInfo, + )) resolve(rs) }) @@ -878,7 +877,7 @@ export function useFetchSubmissionReviews(reviewId: string = ''): useFetchSubmis handleError(e) }) }, - [resourceId, reviewInfo, setUpdatedReviewInfo, updatedReviewInfo, reviewId], + [resourceId, reviewInfo, reviewId, scorecardInfo], ) /** diff --git a/src/apps/review/src/lib/hooks/useFetchSubmissionReviews.utils.spec.ts b/src/apps/review/src/lib/hooks/useFetchSubmissionReviews.utils.spec.ts new file mode 100644 index 000000000..943f6f607 --- /dev/null +++ b/src/apps/review/src/lib/hooks/useFetchSubmissionReviews.utils.spec.ts @@ -0,0 +1,123 @@ +import { ReviewInfo, ScorecardInfo } from '../models' + +import { applyAppealResponseScoreUpdate } from './useFetchSubmissionReviews.utils' + +jest.mock('~/config', () => ({ + EnvironmentConfig: {}, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + getRatingColor: jest.fn() + .mockReturnValue('#000000'), +}), { virtual: true }) + +const scorecardInfo: ScorecardInfo = { + id: 'scorecard-1', + minimumPassingScore: 70, + name: 'Appeals Review Scorecard', + scorecardGroups: [ + { + id: 'group-1', + name: 'Group 1', + sections: [ + { + id: 'section-1', + name: 'Section 1', + questions: [ + { + description: 'Question 1', + guidelines: '', + id: 'question-1', + requiresUpload: false, + scaleMax: 5, + scaleMin: 1, + sortOrder: 1, + type: 'SCALE', + weight: 100, + }, + ], + sortOrder: 1, + weight: 100, + }, + ], + sortOrder: 1, + weight: 100, + }, + ], +} + +const createReviewInfo = (): ReviewInfo => ({ + committed: true, + createdAt: '2025-10-15T09:51:00.000Z', + finalScore: 50, + id: 'review-1', + initialScore: 50, + resourceId: 'resource-1', + reviewItems: [ + { + createdAt: '2025-10-15T09:51:00.000Z', + finalAnswer: '3', + id: 'review-item-1', + initialAnswer: '3', + reviewItemComments: [ + { + content: 'comment', + id: 'comment-1', + sortOrder: 1, + type: 'COMMENT', + }, + ], + scorecardQuestionId: 'question-1', + }, + ], + scorecardId: scorecardInfo.id, + updatedAt: '2025-10-15T09:51:00.000Z', +}) + +describe('applyAppealResponseScoreUpdate', () => { + it('updates the review answer and recalculates local score metadata', () => { + const updatedReview = applyAppealResponseScoreUpdate( + createReviewInfo(), + 'review-item-1', + '4', + scorecardInfo, + ) + + expect(updatedReview?.reviewItems[0].finalAnswer) + .toBe('4') + expect(updatedReview?.finalScore) + .toBe(75) + expect(updatedReview?.initialScore) + .toBe(75) + expect(updatedReview?.reviewProgress) + .toBe(100) + }) + + it('still updates the review answer when scorecard metadata is unavailable', () => { + const reviewInfo = createReviewInfo() + const updatedReview = applyAppealResponseScoreUpdate( + reviewInfo, + 'review-item-1', + '4', + ) + + expect(updatedReview?.reviewItems[0].finalAnswer) + .toBe('4') + expect(updatedReview?.finalScore) + .toBe(reviewInfo.finalScore) + expect(updatedReview?.initialScore) + .toBe(reviewInfo.initialScore) + }) + + it('returns undefined when there is no review to update', () => { + expect( + applyAppealResponseScoreUpdate( + undefined, + 'review-item-1', + '4', + scorecardInfo, + ), + ) + .toBeUndefined() + }) +}) diff --git a/src/apps/review/src/lib/hooks/useFetchSubmissionReviews.utils.ts b/src/apps/review/src/lib/hooks/useFetchSubmissionReviews.utils.ts new file mode 100644 index 000000000..4e223fb19 --- /dev/null +++ b/src/apps/review/src/lib/hooks/useFetchSubmissionReviews.utils.ts @@ -0,0 +1,58 @@ +import { ReviewInfo, ReviewItemInfo, ScorecardInfo } from '../models' +import { calculateProgressAndScore } from '../components/Scorecard/ScorecardViewer/utils' + +const getReviewItemAnswer = ( + reviewItem: ReviewItemInfo, +): string => reviewItem.finalAnswer || reviewItem.initialAnswer || '' + +/** + * Apply an appeal-response answer update to the review model and recompute score/progress locally. + * + * @param reviewInfo current review info state. + * @param reviewItemId id of the review item to update. + * @param updatedResponse new answer selected while responding to an appeal. + * @param scorecardInfo scorecard definition used to recompute progress and total score. + * @returns updated review info with the new answer and recalculated score metadata. + */ +export const applyAppealResponseScoreUpdate = ( + reviewInfo: ReviewInfo | undefined, + reviewItemId: string, + updatedResponse: string, + scorecardInfo?: ScorecardInfo, +): ReviewInfo | undefined => { + if (!reviewInfo) { + return reviewInfo + } + + const reviewItems = reviewInfo.reviewItems.map(reviewItem => ( + reviewItem.id === reviewItemId + ? { + ...reviewItem, + finalAnswer: updatedResponse, + } + : reviewItem + )) + + if (!scorecardInfo) { + return { + ...reviewInfo, + reviewItems, + } + } + + const recalculatedScore: ReturnType = calculateProgressAndScore( + reviewItems.map(reviewItem => ({ + initialAnswer: getReviewItemAnswer(reviewItem), + scorecardQuestionId: reviewItem.scorecardQuestionId, + })), + scorecardInfo, + ) + + return { + ...reviewInfo, + finalScore: recalculatedScore.totalScore, + initialScore: recalculatedScore.totalScore, + reviewItems, + reviewProgress: recalculatedScore.reviewProgress, + } +} diff --git a/src/apps/review/src/lib/models/BackendChallengeInfo.model.spec.ts b/src/apps/review/src/lib/models/BackendChallengeInfo.model.spec.ts new file mode 100644 index 000000000..5b25dd097 --- /dev/null +++ b/src/apps/review/src/lib/models/BackendChallengeInfo.model.spec.ts @@ -0,0 +1,96 @@ +import { BackendChallengeInfo, convertBackendChallengeInfo } from './BackendChallengeInfo.model' + +jest.mock('~/config', () => ({ + EnvironmentConfig: {}, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + getRatingColor: jest.fn() + .mockReturnValue('#000000'), +}), { virtual: true }) + +const buildChallengeInfo = ( + winners: BackendChallengeInfo['winners'], +): BackendChallengeInfo => ({ + created: '2026-01-01T00:00:00.000Z', + createdBy: 'tester', + currentPhaseNames: [], + description: '', + descriptionFormat: 'markdown', + discussions: [], + endDate: '2026-01-03T00:00:00.000Z', + groups: [], + id: 'challenge-id', + legacy: {} as BackendChallengeInfo['legacy'], + metadata: [], + name: 'Legacy Challenge', + numOfCheckpointSubmissions: 0, + numOfRegistrants: 0, + numOfSubmissions: 0, + overview: {} as BackendChallengeInfo['overview'], + phases: [], + prizeSets: [], + projectId: 1, + registrationEndDate: '2026-01-02T00:00:00.000Z', + registrationStartDate: '2026-01-01T00:00:00.000Z', + skills: [], + startDate: '2026-01-01T00:00:00.000Z', + status: 'COMPLETED', + submissionEndDate: '2026-01-02T00:00:00.000Z', + submissionStartDate: '2026-01-01T00:00:00.000Z', + tags: [], + task: {} as BackendChallengeInfo['task'], + terms: [], + timelineTemplateId: 'timeline-id', + track: { + id: 'track-id', + name: 'Quality Assurance', + }, + trackId: 'track-id', + type: { + id: 'type-id', + name: 'Challenge', + }, + typeId: 'type-id', + updated: '2026-01-01T00:00:00.000Z', + updatedBy: 'tester', + winners, +}) + +describe('convertBackendChallengeInfo winners mapping', () => { + it('keeps contest winners when legacy winner type uses spaces', () => { + const result = convertBackendChallengeInfo(buildChallengeInfo([ + { + handle: 'winnerHandle', + placement: 1, + type: 'Contest Submission', + userId: 1234, + }, + ])) + + expect(result?.winners) + .toEqual([ + { + handle: 'winnerHandle', + maxRating: undefined, + placement: 1, + type: 'Contest Submission', + userId: 1234, + }, + ]) + }) + + it('filters out checkpoint winners', () => { + const result = convertBackendChallengeInfo(buildChallengeInfo([ + { + handle: 'checkpointHandle', + placement: 1, + type: 'Checkpoint Submission', + userId: 9999, + }, + ])) + + expect(result?.winners) + .toEqual([]) + }) +}) diff --git a/src/apps/review/src/lib/models/BackendChallengeInfo.model.ts b/src/apps/review/src/lib/models/BackendChallengeInfo.model.ts index 70f3f7b9b..fab25041b 100644 --- a/src/apps/review/src/lib/models/BackendChallengeInfo.model.ts +++ b/src/apps/review/src/lib/models/BackendChallengeInfo.model.ts @@ -1,7 +1,7 @@ import moment from 'moment' import { formatDurationDate } from '../utils' -import { SUBMISSION_TYPE_CONTEST } from '../constants' +import { isContestSubmissionType } from '../constants' import { TABLE_DATE_FORMAT } from '../../config/index.config' import { BackendMetadata } from './BackendMetadata.model' @@ -114,8 +114,9 @@ function mapWinners( } // Only expose contest submissions in the winners list - const contestWinners = winners.filter(winner => ( - (winner.type ?? SUBMISSION_TYPE_CONTEST) === SUBMISSION_TYPE_CONTEST + const contestWinners = winners.filter(winner => isContestSubmissionType( + winner.type, + { defaultToContest: true }, )) return contestWinners.map(winner => ({ diff --git a/src/apps/review/src/lib/models/Screening.model.ts b/src/apps/review/src/lib/models/Screening.model.ts index e28de9b02..d9d103c1b 100644 --- a/src/apps/review/src/lib/models/Screening.model.ts +++ b/src/apps/review/src/lib/models/Screening.model.ts @@ -7,6 +7,16 @@ import { BackendResource } from './BackendResource.model' type ScreeningResult = 'PASS' | 'NO PASS' | '' | '-' +export interface ScreeningReviewDetail { + reviewId?: string + reviewPhaseId?: string + reviewStatus?: string + screenerId?: string + screener?: BackendResource + score: string + result: ScreeningResult +} + export interface Screening { challengeId: string submissionId: string @@ -66,6 +76,11 @@ export interface Screening { * Used for defensive filtering to ensure phase data isolation. */ phaseName?: string + /** + * Individual screening reviews associated with the submission. + * Used to render multi-screener columns in the Screening table. + */ + screeningReviews?: ScreeningReviewDetail[] } /** diff --git a/src/apps/review/src/lib/models/SubmissionInfo.model.ts b/src/apps/review/src/lib/models/SubmissionInfo.model.ts index 90a6bb765..4f4db52b9 100644 --- a/src/apps/review/src/lib/models/SubmissionInfo.model.ts +++ b/src/apps/review/src/lib/models/SubmissionInfo.model.ts @@ -20,7 +20,19 @@ import { */ export interface SubmissionInfo { id: string + /** + * Legacy submission identifier used by older review payloads and result lookups. + */ + legacySubmissionId?: string memberId: string + /** + * Placement assigned to the submission when available from the backend. + */ + placement?: number | null + /** + * Submitter handle returned by the submissions API for legacy winner matching. + */ + submitterHandle?: string userInfo?: BackendResource // this field is calculated at frontend review?: ReviewInfo reviewInfos?: ReviewInfo[] @@ -157,7 +169,9 @@ export function convertBackendSubmissionToSubmissionInfo( isFileSubmission: data.isFileSubmission, isLatest: data.isLatest, isPassingReview, + legacySubmissionId: data.legacySubmissionId, memberId: data.memberId, + placement: data.placement, review: primaryReviewInfo, reviewInfos, reviews: reviewResults, @@ -165,6 +179,9 @@ export function convertBackendSubmissionToSubmissionInfo( status: normalizeSubmissionStatus(data.status), submittedDate, submittedDateString, + submitterHandle: (data as BackendSubmission & { + submitterHandle?: string | null + }).submitterHandle?.trim() || undefined, type: data.type, userInfo: registrantMap.get(data.memberId), virusScan: data.virusScan, diff --git a/src/apps/review/src/lib/services/scorecards.service.ts b/src/apps/review/src/lib/services/scorecards.service.ts index 9fb4fe234..896a0ba89 100644 --- a/src/apps/review/src/lib/services/scorecards.service.ts +++ b/src/apps/review/src/lib/services/scorecards.service.ts @@ -1,12 +1,71 @@ /** * Scorecards service */ -import { xhrPatchAsync, xhrPostAsync, xhrPutAsync } from '~/libs/core' +import { + xhrGetAsync, + xhrPatchAsync, + xhrPostAsync, + xhrPutAsync, +} from '~/libs/core' import { EnvironmentConfig } from '~/config' import { Scorecard } from '../models' const baseUrl = `${EnvironmentConfig.API.V6}/scorecards` +const PAGE_SIZE = 20 + +export interface FetchScorecardsParams { + page: number + perPage?: number + name?: string + challengeTrack?: string + scorecardType?: string + challengeType?: string + status?: string +} + +export interface ScorecardsResponse { + scoreCards: Scorecard[] + metadata: any +} + +/** + * Fetches scorecards using the shared review lookup contract. + * + * @param params Scorecard filter and pagination inputs. + * @returns Normalized scorecard list with metadata. + */ +export const fetchScorecards = async ( + { + page, + perPage = PAGE_SIZE, + name = '', + challengeTrack = '', + challengeType = '', + scorecardType = '', + status = '', + }: FetchScorecardsParams, +): Promise => { + const query = new URLSearchParams({ + page: String(page), + perPage: String(perPage), + ...(name ? { name } : {}), + ...(scorecardType ? { scorecardType } : {}), + ...(challengeTrack ? { challengeTrack } : {}), + ...(challengeType ? { challengeType } : {}), + ...(status ? { status } : {}), + }) + + const data = await xhrGetAsync(`${baseUrl}?${query.toString()}`) + + return { + metadata: data?.metadata, + scoreCards: data?.scoreCards?.map(scorecard => ({ + ...scorecard, + minimumPassingScore: scorecard.minimumPassingScore ?? 50, + })) || [], + } +} /** * Clone scorecard diff --git a/src/apps/review/src/lib/utils/aggregateSubmissionReviews.spec.ts b/src/apps/review/src/lib/utils/aggregateSubmissionReviews.spec.ts new file mode 100644 index 000000000..eff4c16ab --- /dev/null +++ b/src/apps/review/src/lib/utils/aggregateSubmissionReviews.spec.ts @@ -0,0 +1,201 @@ +import type { + BackendResource, + MappingReviewAppeal, + ReviewResult, + SubmissionInfo, +} from '../models' + +import { aggregateSubmissionReviews } from './aggregateSubmissionReviews' + +jest.mock('~/libs/core', () => ({ + getRatingColor: jest.fn() + .mockReturnValue('#2a2a2a'), +}), { virtual: true }) + +const createReviewer = ( + id: string, + memberId: string, + memberHandle: string, +): BackendResource => ({ + challengeId: 'challenge-1', + created: '2026-01-01T00:00:00.000Z', + createdBy: 'tester', + id, + memberHandle, + memberId, + roleId: 'reviewer-role', +}) + +const createReviewResult = ( + id: string, + resourceId: string, + reviewerHandle: string, + score: number, +): ReviewResult => ({ + appeals: [], + createdAt: '2026-01-01T00:00:00.000Z', + id, + resourceId, + reviewerHandle, + reviewerHandleColor: '#2a2a2a', + score, +}) + +const createSubmission = ( + id: string, + reviews: ReviewResult[], +): SubmissionInfo => ({ + id, + memberId: 'submitter-1', + reviews, +}) + +describe('aggregateSubmissionReviews', () => { + it('collapses duplicate reviewer identities that use different resource ids', () => { + const reviewers: BackendResource[] = [ + createReviewer('alice-resource-1', 'alice-member', 'alice'), + createReviewer('bob-resource-1', 'bob-member', 'bob'), + createReviewer('alice-resource-2', 'alice-member', 'alice'), + createReviewer('bob-resource-2', 'bob-member', 'bob'), + ] + + const submissions: SubmissionInfo[] = [ + createSubmission('submission-1', [ + createReviewResult('review-1-alice', 'alice-resource-1', 'alice', 90), + createReviewResult('review-1-bob', 'bob-resource-1', 'bob', 80), + ]), + createSubmission('submission-2', [ + createReviewResult('review-2-alice', 'alice-resource-2', 'alice', 70), + createReviewResult('review-2-bob', 'bob-resource-2', 'bob', 60), + ]), + ] + + const rows = aggregateSubmissionReviews({ + mappingReviewAppeal: {} as MappingReviewAppeal, + reviewers, + submissions, + }) + + expect(rows) + .toHaveLength(2) + expect(rows[0].reviews) + .toHaveLength(2) + expect(rows[1].reviews) + .toHaveLength(2) + + const firstRowScores = new Map( + rows[0].reviews.map(review => [review.reviewerHandle, review.finalScore]), + ) + expect(firstRowScores.get('alice')) + .toBe(90) + expect(firstRowScores.get('bob')) + .toBe(80) + + const secondRowScores = new Map( + rows[1].reviews.map(review => [review.reviewerHandle, review.finalScore]), + ) + expect(secondRowScores.get('alice')) + .toBe(70) + expect(secondRowScores.get('bob')) + .toBe(60) + }) + + it('collapses duplicates when one source identifies reviewers by member and another by handle', () => { + const reviewers: BackendResource[] = [ + createReviewer('assigned-alice', 'alice-member', 'alice'), + createReviewer('assigned-bob', 'bob-member', 'bob'), + ] + + const submissions: SubmissionInfo[] = [ + createSubmission('submission-1', [ + createReviewResult('review-1-assigned-alice', 'assigned-alice', 'alice', Number.NaN), + createReviewResult('review-1-assigned-bob', 'assigned-bob', 'bob', Number.NaN), + createReviewResult('review-1-scored-alice', 'scored-alice-1', 'alice', 95), + createReviewResult('review-1-scored-bob', 'scored-bob-1', 'bob', 85), + ]), + createSubmission('submission-2', [ + createReviewResult('review-2-assigned-alice', 'assigned-alice', 'alice', Number.NaN), + createReviewResult('review-2-assigned-bob', 'assigned-bob', 'bob', Number.NaN), + createReviewResult('review-2-scored-alice', 'scored-alice-2', 'alice', 75), + createReviewResult('review-2-scored-bob', 'scored-bob-2', 'bob', 65), + ]), + ] + + const rows = aggregateSubmissionReviews({ + mappingReviewAppeal: {} as MappingReviewAppeal, + reviewers, + submissions, + }) + + expect(rows) + .toHaveLength(2) + expect(rows[0].reviews) + .toHaveLength(2) + expect(rows[1].reviews) + .toHaveLength(2) + + const firstRowScores = new Map( + rows[0].reviews.map(review => [review.reviewerHandle, review.finalScore]), + ) + expect(firstRowScores.get('alice')) + .toBe(95) + expect(firstRowScores.get('bob')) + .toBe(85) + + const secondRowScores = new Map( + rows[1].reviews.map(review => [review.reviewerHandle, review.finalScore]), + ) + expect(secondRowScores.get('alice')) + .toBe(75) + expect(secondRowScores.get('bob')) + .toBe(65) + }) + + it('collapses duplicate reviewers when one reviewer resource is member-keyed and another is handle-keyed', () => { + const reviewers: BackendResource[] = [ + createReviewer('alice-member-resource', 'alice-member', ''), + createReviewer('alice-handle-resource', 'alice-member', 'alice'), + createReviewer('bob-resource', 'bob-member', 'bob'), + ] + + const submissions: SubmissionInfo[] = [ + createSubmission('submission-1', [ + createReviewResult('review-1-alice', 'alice-member-resource', '', 91), + createReviewResult('review-1-bob', 'bob-resource', 'bob', 81), + ]), + createSubmission('submission-2', [ + createReviewResult('review-2-alice', 'alice-handle-resource', 'alice', 71), + createReviewResult('review-2-bob', 'bob-resource', 'bob', 61), + ]), + ] + + const rows = aggregateSubmissionReviews({ + mappingReviewAppeal: {} as MappingReviewAppeal, + reviewers, + submissions, + }) + + expect(rows) + .toHaveLength(2) + expect(rows[0].reviews) + .toHaveLength(2) + expect(rows[1].reviews) + .toHaveLength(2) + + const firstRowScores = new Map( + rows[0].reviews.map(review => [review.reviewerHandle, review.finalScore]), + ) + expect(firstRowScores.get('alice')) + .toBe(91) + expect(firstRowScores.get('bob')) + .toBe(81) + + const secondRowScores = new Map( + rows[1].reviews.map(review => [review.reviewerHandle, review.finalScore]), + ) + expect(secondRowScores.get('alice')) + .toBe(71) + expect(secondRowScores.get('bob')) + .toBe(61) + }) +}) diff --git a/src/apps/review/src/lib/utils/aggregateSubmissionReviews.ts b/src/apps/review/src/lib/utils/aggregateSubmissionReviews.ts index a12aeb404..dee1b5a09 100644 --- a/src/apps/review/src/lib/utils/aggregateSubmissionReviews.ts +++ b/src/apps/review/src/lib/utils/aggregateSubmissionReviews.ts @@ -19,6 +19,7 @@ export interface AggregatedReviewDetail { reviewInfo?: ReviewInfo reviewId?: string resourceId?: string + reviewerKey?: string finalScore?: number reviewProgress?: number status?: string | null @@ -85,6 +86,54 @@ function resolveHandleColor( : undefined) } +type ReviewerIdentityArgs = { + memberId?: string | null + resourceId?: string | null + reviewerHandle?: string | null +} + +/** + * Normalizes optional string values by trimming whitespace and treating empty strings as undefined. + * + * @param value - Optional string candidate from review/resource payloads. + * @returns Trimmed string when present; otherwise undefined. + */ +const normalizeStringValue = (value?: string | null): string | undefined => { + const trimmed = value?.trim() + return trimmed?.length ? trimmed : undefined +} + +/** + * Builds ordered reviewer identity keys from the available payload fields. + * + * @param args - Reviewer identity candidates from resource and review payloads. + * @returns Ordered keys: handle, member, then resource (when present). + */ +const buildReviewerIdentityKeys = ({ + memberId, + resourceId, + reviewerHandle, +}: ReviewerIdentityArgs): string[] => { + const keys: string[] = [] + const normalizedHandle = normalizeStringValue(reviewerHandle) + const normalizedMemberId = normalizeStringValue(memberId) + const normalizedResourceId = normalizeStringValue(resourceId) + + if (normalizedHandle) { + keys.push(`handle:${normalizedHandle.toLowerCase()}`) + } + + if (normalizedMemberId) { + keys.push(`member:${normalizedMemberId}`) + } + + if (normalizedResourceId) { + keys.push(`resource:${normalizedResourceId}`) + } + + return keys +} + const deriveReviewResultFromReviewInfo = (reviewInfo: ReviewInfo): ReviewResult => { const reviewerHandle = reviewInfo.reviewerHandle?.trim() || undefined const reviewerMaxRating = normalizeRatingValue(reviewInfo.reviewerMaxRating) @@ -129,6 +178,55 @@ export function aggregateSubmissionReviews({ acc[r.id] = r.memberHandle return acc }, {}) + const canonicalResourceIdByReviewerKey: Record = {} + const canonicalReviewerKeyByAlias: Record = {} + const reviewerKeyByResourceId: Record = {} + + const resolveCanonicalReviewerKey = (reviewerKey?: string): string | undefined => { + if (!reviewerKey) { + return undefined + } + + return canonicalReviewerKeyByAlias[reviewerKey] ?? reviewerKey + } + + const registerReviewerKeyAliases = (identityKeys: Array): string | undefined => { + const normalizedKeys = identityKeys.filter( + (identityKey): identityKey is string => Boolean(identityKey), + ) + if (!normalizedKeys.length) { + return undefined + } + + const existingCanonical = normalizedKeys + .map(identityKey => canonicalReviewerKeyByAlias[identityKey]) + .find((candidate): candidate is string => Boolean(candidate)) + const canonicalKey = existingCanonical ?? normalizedKeys[0] + + normalizedKeys.forEach(identityKey => { + canonicalReviewerKeyByAlias[identityKey] = canonicalKey + }) + + return canonicalKey + } + + reviewers.forEach(reviewer => { + const reviewerIdentityKeys = buildReviewerIdentityKeys({ + memberId: reviewer.memberId, + resourceId: reviewer.id, + reviewerHandle: reviewer.memberHandle, + }) + const reviewerKey = registerReviewerKeyAliases(reviewerIdentityKeys) + if (!reviewerKey) { + return + } + + if (!canonicalResourceIdByReviewerKey[reviewerKey]) { + canonicalResourceIdByReviewerKey[reviewerKey] = reviewer.id + } + + reviewerKeyByResourceId[reviewer.id] = reviewerKey + }) forEach(submissions, submission => { if (!grouped.has(submission.id)) { @@ -202,28 +300,19 @@ export function aggregateSubmissionReviews({ } forEach(reviewsToProcess, reviewResult => { - const resourceId = reviewResult.resourceId + const rawResourceId = reviewResult.resourceId const reviewResultId = reviewResult.id const reviewInfoFromId = reviewResultId ? reviewInfoById.get(reviewResultId) : undefined const reviewInfo = reviewInfoFromId - ?? (resourceId - ? reviewInfoByResourceId.get(resourceId) + ?? (rawResourceId + ? reviewInfoByResourceId.get(rawResourceId) : submission.review && !submission.review.resourceId ? submission.review : undefined) const reviewId = reviewInfo?.id ?? reviewResultId - const reviewKey = reviewId ?? (resourceId ? `resource:${resourceId}` : undefined) - - if (reviewKey && seenReviewIds.has(reviewKey)) { - return - } - - const reviewerInfo = resourceId ? reviewerByResourceId[resourceId] : undefined - if (resourceId) { - discoveredResourceIds.add(resourceId) - } + const reviewerInfo = rawResourceId ? reviewerByResourceId[rawResourceId] : undefined const reviewDate = reviewInfo?.reviewDate ? new Date(reviewInfo.reviewDate) @@ -241,17 +330,61 @@ export function aggregateSubmissionReviews({ const reviewHandle = reviewInfo?.reviewerHandle?.trim() || undefined const resultHandle = reviewResult.reviewerHandle?.trim() || undefined const resourceHandle = reviewerInfo?.memberHandle?.trim() || undefined - const fallbackMappedHandle = resourceId ? reviewerHandleByResourceId[resourceId]?.trim() : undefined + const fallbackMappedHandle = rawResourceId + ? reviewerHandleByResourceId[rawResourceId]?.trim() + : undefined const candidateReviewerHandle = reviewHandle ?? resultHandle ?? resourceHandle const resolvedReviewerHandle = candidateReviewerHandle ?? fallbackMappedHandle + const reviewerIdentityKeys = buildReviewerIdentityKeys({ + memberId: reviewerInfo?.memberId, + resourceId: rawResourceId, + reviewerHandle: resolvedReviewerHandle ?? fallbackMappedHandle, + }) + const reviewerKey = registerReviewerKeyAliases(reviewerIdentityKeys) + const canonicalReviewerKey = resolveCanonicalReviewerKey(reviewerKey) + + const canonicalResourceId = canonicalReviewerKey + ? ( + canonicalResourceIdByReviewerKey[canonicalReviewerKey] + ?? rawResourceId + ) + : rawResourceId + const resourceId = canonicalResourceId + + if (canonicalReviewerKey && resourceId && !canonicalResourceIdByReviewerKey[canonicalReviewerKey]) { + canonicalResourceIdByReviewerKey[canonicalReviewerKey] = resourceId + } + + if (resourceId && canonicalReviewerKey) { + reviewerKeyByResourceId[resourceId] = canonicalReviewerKey + } + + if (rawResourceId && canonicalReviewerKey) { + reviewerKeyByResourceId[rawResourceId] = canonicalReviewerKey + } + if (resourceId && resolvedReviewerHandle) { reviewerHandleByResourceId[resourceId] = resolvedReviewerHandle } + if (rawResourceId && resolvedReviewerHandle) { + reviewerHandleByResourceId[rawResourceId] = resolvedReviewerHandle + } + + const reviewKey = reviewId ?? (resourceId ? `resource:${resourceId}` : undefined) + + if (reviewKey && seenReviewIds.has(reviewKey)) { + return + } + + if (resourceId) { + discoveredResourceIds.add(resourceId) + } + const finalReviewerMaxRating = normalizeRatingValue( reviewInfo?.reviewerMaxRating ?? reviewResult.reviewerMaxRating @@ -367,6 +500,10 @@ export function aggregateSubmissionReviews({ const finishedAppeals = appealInfo?.finishAppeals ?? 0 const totalAppeals = appealInfo?.totalAppeals ?? 0 const unresolvedAppeals = totalAppeals - finishedAppeals + const reviewerHandleForDetail = resolvedReviewerHandle ?? fallbackMappedHandle + const normalizedReviewerHandleForDetail = normalizeStringValue(reviewerHandleForDetail) + ?.toLowerCase() + const reviewerCanonicalKey = resolveCanonicalReviewerKey(reviewerKey) const existingDetail = group.reviews.find(detail => { const detailReviewId = detail.reviewInfo?.id ?? detail.reviewId @@ -374,6 +511,39 @@ export function aggregateSubmissionReviews({ return detailReviewId === reviewId } + if (reviewerCanonicalKey) { + const detailResourceId = detail.resourceId ?? detail.reviewInfo?.resourceId + const detailIdentityKeys = buildReviewerIdentityKeys({ + memberId: detailResourceId + ? reviewerByResourceId[detailResourceId]?.memberId + : undefined, + resourceId: detailResourceId, + reviewerHandle: detail.reviewerHandle + ?? detail.reviewInfo?.reviewerHandle + ?? (detailResourceId ? reviewerHandleByResourceId[detailResourceId] : undefined), + }) + const detailReviewerKey = resolveCanonicalReviewerKey(registerReviewerKeyAliases([ + detail.reviewerKey, + detailResourceId ? reviewerKeyByResourceId[detailResourceId] : undefined, + ...detailIdentityKeys, + ])) + if (detailReviewerKey) { + return detailReviewerKey === reviewerCanonicalKey + } + } + + if (normalizedReviewerHandleForDetail) { + const detailResourceId = detail.resourceId ?? detail.reviewInfo?.resourceId + const detailReviewerHandle = normalizeStringValue( + detail.reviewerHandle + ?? detail.reviewInfo?.reviewerHandle + ?? (detailResourceId ? reviewerHandleByResourceId[detailResourceId] : undefined), + ) + if (detailReviewerHandle?.toLowerCase() === normalizedReviewerHandleForDetail) { + return true + } + } + if (!detailReviewId && !reviewId && resourceId) { const detailResourceId = detail.resourceId ?? detail.reviewInfo?.resourceId return detailResourceId === resourceId @@ -382,7 +552,6 @@ export function aggregateSubmissionReviews({ return false }) - const reviewerHandleForDetail = resolvedReviewerHandle ?? fallbackMappedHandle const resolvedStatus = normalizedReviewInfo?.status ?? ((finalScore !== undefined && reviewDate) ? 'COMPLETED' @@ -396,6 +565,7 @@ export function aggregateSubmissionReviews({ reviewDateString, reviewerHandle: reviewerHandleForDetail, reviewerHandleColor: finalReviewerHandleColor, + reviewerKey, reviewerMaxRating: finalReviewerMaxRating, reviewId, reviewInfo: normalizedReviewInfo, @@ -441,6 +611,13 @@ export function aggregateSubmissionReviews({ existingDetail.reviewerMaxRating = finalReviewerMaxRating } + if (reviewerKey) { + const existingReviewerKey = resolveCanonicalReviewerKey(existingDetail.reviewerKey) + if (existingReviewerKey !== reviewerKey) { + existingDetail.reviewerKey = reviewerKey + } + } + if (normalizedReviewInfo) { existingDetail.reviewInfo = existingDetail.reviewInfo ? { @@ -490,6 +667,11 @@ export function aggregateSubmissionReviews({ // Establish a deterministic reviewer order across all submissions. // Prefer the explicit reviewers list; otherwise fall back to discovered resourceIds. const orderedResourceIds: string[] = (() => { + const compareReviewerIds = (a: string, b: string): number => ( + (reviewerHandleByResourceId[a] || a) + .localeCompare(reviewerHandleByResourceId[b] || b, undefined, { sensitivity: 'base' }) + ) + const base: string[] = reviewers.length ? reviewers .slice() @@ -502,49 +684,149 @@ export function aggregateSubmissionReviews({ ) || a.id.localeCompare(b.id) )) - .map(r => r.id) + .map(r => { + const reviewerIdentityKeys = buildReviewerIdentityKeys({ + memberId: r.memberId, + resourceId: r.id, + reviewerHandle: r.memberHandle ?? reviewerHandleByResourceId[r.id], + }) + const reviewerKey = resolveCanonicalReviewerKey( + reviewerKeyByResourceId[r.id] + ?? registerReviewerKeyAliases(reviewerIdentityKeys), + ) + const canonicalResourceId = reviewerKey + ? (canonicalResourceIdByReviewerKey[reviewerKey] ?? r.id) + : r.id + + if (reviewerKey) { + reviewerKeyByResourceId[canonicalResourceId] = reviewerKey + reviewerKeyByResourceId[r.id] = reviewerKey + if (!canonicalResourceIdByReviewerKey[reviewerKey]) { + canonicalResourceIdByReviewerKey[reviewerKey] = canonicalResourceId + } + } + + return canonicalResourceId + }) : Array.from(discoveredResourceIds) .slice() - .sort((a, b) => (reviewerHandleByResourceId[a] || a) - .localeCompare(reviewerHandleByResourceId[b] || b, undefined, { sensitivity: 'base' })) + .sort(compareReviewerIds) + + const uniqueBase = Array.from(new Set(base)) // Ensure any discovered ids that aren't in base are appended deterministically - const baseSet = new Set(base) + const baseSet = new Set(uniqueBase) const extras = Array.from(discoveredResourceIds) .filter(id => !baseSet.has(id)) - .sort((a, b) => (reviewerHandleByResourceId[a] || a) - .localeCompare(reviewerHandleByResourceId[b] || b, undefined, { sensitivity: 'base' })) + .sort(compareReviewerIds) - return [...base, ...extras] + return [...uniqueBase, ...extras] })() grouped.forEach(group => { // Reorder reviews to match the deterministic reviewer order and // insert placeholders for missing reviewers so columns align. const byResourceId: Record = {} + const byReviewerKey: Record = {} group.reviews.forEach(r => { if (r.resourceId) { byResourceId[r.resourceId] = r } + + const reviewerIdentityKeys = buildReviewerIdentityKeys({ + memberId: r.resourceId ? reviewerByResourceId[r.resourceId]?.memberId : undefined, + resourceId: r.resourceId, + reviewerHandle: r.reviewerHandle ?? r.reviewInfo?.reviewerHandle, + }) + const reviewerKey = resolveCanonicalReviewerKey(registerReviewerKeyAliases([ + r.reviewerKey, + r.resourceId ? reviewerKeyByResourceId[r.resourceId] : undefined, + ...reviewerIdentityKeys, + ])) + if (reviewerKey) { + r.reviewerKey = reviewerKey + if (r.resourceId) { + reviewerKeyByResourceId[r.resourceId] = reviewerKey + } + + if (!byReviewerKey[reviewerKey]) { + byReviewerKey[reviewerKey] = r + } + } }) const ordered: AggregatedReviewDetail[] = [] orderedResourceIds.forEach(id => { - if (byResourceId[id]) { - ordered.push(byResourceId[id]) - } else { - ordered.push({ - finishedAppeals: 0, - resourceId: id, - totalAppeals: 0, - unresolvedAppeals: 0, - }) + const reviewerKey = resolveCanonicalReviewerKey( + reviewerKeyByResourceId[id] + ?? registerReviewerKeyAliases(buildReviewerIdentityKeys({ + memberId: reviewerByResourceId[id]?.memberId, + resourceId: id, + reviewerHandle: reviewerHandleByResourceId[id], + })), + ) + const directMatch = byResourceId[id] + if (directMatch) { + ordered.push(directMatch) + return } + + const reviewerKeyMatch = reviewerKey + ? byReviewerKey[reviewerKey] + : undefined + if (reviewerKeyMatch) { + ordered.push(reviewerKeyMatch) + return + } + + ordered.push({ + finishedAppeals: 0, + resourceId: id, + reviewerHandle: reviewerHandleByResourceId[id], + reviewerKey, + totalAppeals: 0, + unresolvedAppeals: 0, + }) }) - // Append any reviews without a resourceId (rare) in a deterministic way + const orderedReviewsSet = new Set(ordered) + const orderedReviewerKeys = new Set( + ordered + .map(review => review.reviewerKey) + .filter((value): value is string => Boolean(value)), + ) + const orderedReviewerHandles = new Set( + ordered + .map(review => normalizeStringValue( + review.reviewerHandle + ?? review.reviewInfo?.reviewerHandle + ?? (review.resourceId ? reviewerHandleByResourceId[review.resourceId] : undefined), + )) + .filter((value): value is string => Boolean(value)) + .map(handle => handle.toLowerCase()), + ) + // Append any reviews that were not captured in the ordered reviewer list. const unmatched = group.reviews - .filter(r => !r.resourceId) + .filter(r => { + if (orderedReviewsSet.has(r)) { + return false + } + + if (r.reviewerKey && orderedReviewerKeys.has(r.reviewerKey)) { + return false + } + + const unmatchedHandle = normalizeStringValue( + r.reviewerHandle + ?? r.reviewInfo?.reviewerHandle + ?? (r.resourceId ? reviewerHandleByResourceId[r.resourceId] : undefined), + ) + if (unmatchedHandle && orderedReviewerHandles.has(unmatchedHandle.toLowerCase())) { + return false + } + + return true + }) .slice() .sort((a, b) => ( (a.reviewerHandle || '') diff --git a/src/apps/review/src/lib/utils/approvalReviewRows.spec.ts b/src/apps/review/src/lib/utils/approvalReviewRows.spec.ts new file mode 100644 index 000000000..44570df44 --- /dev/null +++ b/src/apps/review/src/lib/utils/approvalReviewRows.spec.ts @@ -0,0 +1,167 @@ +import type { + BackendReview, + BackendSubmission, +} from '../models' + +import { buildApprovalReviewRows } from './approvalReviewRows' + +jest.mock('~/libs/core', () => ({ + getRatingColor: () => '#2a2a2a', +}), { virtual: true }) +jest.mock('~/config', () => ({ + EnvironmentConfig: {}, +}), { virtual: true }) + +const createReview = ( + overrides: Partial = {}, +): BackendReview => ({ + committed: false, + createdAt: '2026-02-23T08:33:44.000Z', + createdBy: 'system', + finalScore: 0, + id: 'review-approval-2', + initialScore: 0, + legacyId: 'legacy-review-approval-2', + legacySubmissionId: 'legacy-submission-2', + metadata: {}, + phaseId: 'phase-approval-2', + phaseName: 'Approval', + resourceId: 'resource-approver-1', + reviewDate: '', + scorecardId: 'scorecard-approval', + status: 'PENDING', + submissionId: 'submission-2', + typeId: '', + updatedAt: '2026-02-23T08:33:44.000Z', + updatedBy: 'system', + ...overrides, +}) + +const createSubmission = ( + overrides: Partial = {}, +): BackendSubmission => ({ + challengeId: 'challenge-1', + createdAt: '2026-02-23T08:33:44.000Z', + createdBy: 'system', + esId: 'es-submission-2', + fileSize: 0, + fileType: 'zip', + finalScore: '0', + id: 'submission-2', + initialScore: '0', + isFileSubmission: true, + isLatest: true, + legacyChallengeId: 1, + legacySubmissionId: 'legacy-submission-2', + legacyUploadId: 'legacy-upload-2', + markForPurchase: false, + memberId: 'member-2', + placement: 0, + prizeId: 0, + review: [], + reviewSummation: [], + screeningScore: '', + status: 1, + submissionPhaseId: 'phase-submission', + submittedDate: '2026-02-23T08:33:44.000Z', + systemFileName: 'submission-2.zip', + thurgoodJobId: '', + type: 'CONTEST_SUBMISSION', + updatedAt: '2026-02-23T08:33:44.000Z', + updatedBy: 'system', + uploadId: 'upload-2', + url: 'https://example.com/submission-2.zip', + userRank: 0, + viewCount: 0, + virusScan: true, + ...overrides, +}) + +describe('buildApprovalReviewRows', () => { + const approvalPhaseIds = new Set(['phase-approval-1', 'phase-approval-2']) + const resourceMemberIdMapping = { + 'member-2': { + challengeId: 'challenge-1', + created: '2026-02-23T08:33:44.000Z', + createdBy: 'system', + id: 'resource-submitter-2', + memberHandle: 'submitter-two', + memberId: 'member-2', + roleId: 'submitter-role', + roleName: 'Submitter', + }, + } + + it('falls back to submission-local approval reviews when challenge reviews are empty', () => { + const submission = createSubmission({ + review: [createReview()], + }) + + const results = buildApprovalReviewRows({ + approvalPhaseIds, + approvalScorecardId: 'scorecard-approval', + challengeReviews: [], + contestSubmissions: [submission], + resourceMemberIdMapping, + submissionsById: new Map([[submission.id, submission]]), + submissionsByLegacyId: new Map([[submission.legacySubmissionId, submission]]), + }) + + expect(results) + .toHaveLength(1) + expect(results[0].id) + .toBe('submission-2') + expect(results[0].review?.id) + .toBe('review-approval-2') + expect(results[0].review?.phaseId) + .toBe('phase-approval-2') + }) + + it('does not duplicate approval rows when the same review is present globally and on the submission', () => { + const review = createReview() + const submission = createSubmission({ + review: [review], + }) + + const results = buildApprovalReviewRows({ + approvalPhaseIds, + approvalScorecardId: 'scorecard-approval', + challengeReviews: [review], + contestSubmissions: [submission], + resourceMemberIdMapping, + submissionsById: new Map([[submission.id, submission]]), + submissionsByLegacyId: new Map([[submission.legacySubmissionId, submission]]), + }) + + expect(results) + .toHaveLength(1) + expect(results[0].review?.id) + .toBe(review.id) + }) + + it('keeps continuation approval rows when the review phase name includes the round suffix', () => { + const review = createReview({ + phaseName: 'Approval 2', + }) + const submission = createSubmission({ + review: [review], + }) + + const results = buildApprovalReviewRows({ + approvalPhaseIds, + approvalScorecardId: 'scorecard-approval', + challengeReviews: [], + contestSubmissions: [submission], + resourceMemberIdMapping, + submissionsById: new Map([[submission.id, submission]]), + submissionsByLegacyId: new Map([[submission.legacySubmissionId, submission]]), + }) + + expect(results) + .toHaveLength(1) + expect(results[0].review?.id) + .toBe(review.id) + expect(results[0].review?.phaseName) + .toBe('Approval 2') + }) +}) diff --git a/src/apps/review/src/lib/utils/approvalReviewRows.ts b/src/apps/review/src/lib/utils/approvalReviewRows.ts new file mode 100644 index 000000000..aa0b487e9 --- /dev/null +++ b/src/apps/review/src/lib/utils/approvalReviewRows.ts @@ -0,0 +1,109 @@ +import type { BackendResource } from '../models/BackendResource.model' +import type { BackendReview } from '../models/BackendReview.model' +import type { BackendSubmission } from '../models/BackendSubmission.model' +import type { SubmissionInfo } from '../models/SubmissionInfo.model' +import { convertBackendReviewToReviewInfo } from '../models/ReviewInfo.model' +import { convertBackendReviewToReviewResult } from '../models/ReviewResult.model' +import { convertBackendSubmissionToSubmissionInfo } from '../models/SubmissionInfo.model' + +import type { SubmissionLookupArgs } from './submissionResolution' +import { resolveSubmissionForReview } from './submissionResolution' +import { collectMatchingReviews } from './reviewSelection' +import { reviewMatchesPhase } from './reviewMatching' + +export interface BuildApprovalReviewRowsArgs { + approvalPhaseIds: Set + approvalScorecardId?: string + challengeReviews?: BackendReview[] + contestSubmissions: BackendSubmission[] + resourceMemberIdMapping: Record + submissionsById: Map + submissionsByLegacyId: Map +} + +const buildSubmissionInfoWithReview = ( + submission: BackendSubmission, + review: BackendReview, + resourceMemberIdMapping: Record, +): SubmissionInfo => { + const submissionWithReview: BackendSubmission = { + ...submission, + review: [review], + } + const submissionInfo = convertBackendSubmissionToSubmissionInfo(submissionWithReview) + + return { + ...submissionInfo, + review: submissionInfo.review ?? convertBackendReviewToReviewInfo(review), + reviews: [convertBackendReviewToReviewResult(review)], + userInfo: resourceMemberIdMapping[submissionInfo.memberId], + } +} + +/** + * Builds Approval tab rows from both challenge-level review queries and submission-local review + * data so live continuation approvals still render even when the challenge review list has not + * caught up yet. + * + * @param args - Approval phase metadata, submissions, reviews, and resource mapping context. + * @returns One row per unique approval review instance. + */ +export function buildApprovalReviewRows({ + approvalPhaseIds, + approvalScorecardId, + challengeReviews, + contestSubmissions, + resourceMemberIdMapping, + submissionsById, + submissionsByLegacyId, +}: BuildApprovalReviewRowsArgs): SubmissionInfo[] { + if (approvalPhaseIds.size === 0) { + return [] + } + + const rowsByReviewId = new Map() + const addRow = ( + review: BackendReview | undefined, + submission: BackendSubmission | undefined, + ): void => { + if (!review?.id || !submission) { + return + } + + if (!reviewMatchesPhase(review, approvalScorecardId, approvalPhaseIds, 'Approval')) { + return + } + + if (!rowsByReviewId.has(review.id)) { + rowsByReviewId.set( + review.id, + buildSubmissionInfoWithReview(submission, review, resourceMemberIdMapping), + ) + } + } + + challengeReviews?.forEach(review => { + const submission = resolveSubmissionForReview({ + review, + submissionsById, + submissionsByLegacyId, + } satisfies SubmissionLookupArgs) + + addRow(review, submission) + }) + + contestSubmissions.forEach(submission => { + const submissionReviews = collectMatchingReviews( + submission, + 'Approval', + approvalScorecardId, + approvalPhaseIds, + undefined, + challengeReviews, + ) + + submissionReviews.forEach(review => addRow(review, submission)) + }) + + return Array.from(rowsByReviewId.values()) +} diff --git a/src/apps/review/src/lib/utils/challenge.spec.ts b/src/apps/review/src/lib/utils/challenge.spec.ts index 8a0b6b25c..8b4366a94 100644 --- a/src/apps/review/src/lib/utils/challenge.spec.ts +++ b/src/apps/review/src/lib/utils/challenge.spec.ts @@ -1,4 +1,16 @@ -import { buildPhaseTabs, findPhaseByTabLabel, type PhaseLike } from './challenge' +import type { BackendPhase } from '../models' + +import { + buildPhaseTabs, + collectReopenEligiblePhaseIds, + findPhaseByTabLabel, + hasPendingApprovalReview, + isFirst2FinishChallenge, + type PhaseLike, + resolveFirst2FinishIterativeSubmissionIds, + shouldAllowWinnersTabForPastChallenge, + shouldForceWinnersTabForPastChallenge, +} from './challenge' const createPhase = ( id: string, @@ -17,6 +29,26 @@ const createPhase = ( scheduledStartDate, }) +const createBackendPhase = ( + id: string, + name: string, + scheduledStartDate: string, + overrides: Partial = {}, +): BackendPhase => ({ + actualEndDate: overrides.actualEndDate ?? overrides.scheduledEndDate ?? scheduledStartDate, + actualStartDate: overrides.actualStartDate ?? scheduledStartDate, + constraints: overrides.constraints ?? [], + description: overrides.description ?? '', + duration: overrides.duration ?? 0, + id, + isOpen: overrides.isOpen ?? false, + name, + phaseId: overrides.phaseId ?? id, + predecessor: overrides.predecessor, + scheduledEndDate: overrides.scheduledEndDate ?? scheduledStartDate, + scheduledStartDate, +}) + describe('challenge phase tab helpers', () => { it('preserves the incoming phase order', () => { const phases: PhaseLike[] = [ @@ -153,4 +185,175 @@ describe('challenge phase tab helpers', () => { expect(checkpointPhase?.id) .toBe('a') }) + + it('adds winners tab for completed challenges when all phases are closed', () => { + const phases: PhaseLike[] = [ + createPhase('1', 'Registration', '2025-01-01T00:00:00Z'), + createPhase('2', 'Approval', '2025-01-02T00:00:00Z'), + ] + + const tabs = buildPhaseTabs(phases, 'COMPLETED') + expect(tabs.map(tab => tab.value)) + .toEqual([ + 'Registration', + 'Approval', + 'Winners', + ]) + }) + + it('does not add winners tab for completed challenges that still have an open phase', () => { + const phases: PhaseLike[] = [ + createPhase('1', 'Registration', '2025-01-01T00:00:00Z'), + createPhase('2', 'Approval', '2025-01-02T00:00:00Z'), + createPhase('3', 'Approval', '2025-01-03T00:00:00Z', { isOpen: true }), + ] + + const tabs = buildPhaseTabs(phases, 'COMPLETED') + expect(tabs.map(tab => tab.value)) + .toEqual([ + 'Registration', + 'Approval', + 'Approval 2', + ]) + }) + + it('allows past challenges with winner data to force-show the winners tab', () => { + expect(shouldForceWinnersTabForPastChallenge({ + status: 'COMPLETED', + winners: [{ handle: 'winner-one', placement: 1, userId: 1 }], + })) + .toBe(true) + expect(shouldForceWinnersTabForPastChallenge({ + status: 'COMPLETED', + winners: [], + })) + .toBe(false) + }) + + it('keeps winners hidden when a follow-up approval review is still pending', () => { + const challengeInfo = { + phases: [ + createBackendPhase('approval-1', 'Approval', '2025-01-02T00:00:00Z'), + createBackendPhase('approval-2', 'Approval', '2025-01-03T00:00:00Z'), + ], + status: 'COMPLETED', + winners: [{ handle: 'winner-one', placement: 1, userId: 1 }], + } + const approvalReviews = [ + { + review: { + status: 'PENDING', + }, + }, + ] + + expect(hasPendingApprovalReview(approvalReviews)) + .toBe(true) + expect(shouldAllowWinnersTabForPastChallenge(challengeInfo, approvalReviews)) + .toBe(false) + expect(shouldForceWinnersTabForPastChallenge(challengeInfo, approvalReviews)) + .toBe(false) + }) + + it('recognizes First2Finish challenges when the type name contains digits', () => { + expect(isFirst2FinishChallenge({ + track: { + name: 'Development', + } as never, + type: { + name: 'First2Finish', + } as never, + })) + .toBe(true) + }) + + it('keeps only the first dated contest submission for F2F iterative review', () => { + expect(resolveFirst2FinishIterativeSubmissionIds([ + { + id: 'submission-2', + placement: undefined as unknown as number, + submittedDate: '2026-04-01T04:57:36.849Z', + }, + { + id: 'submission-1', + placement: undefined as unknown as number, + submittedDate: '2026-04-01T04:56:13.405Z', + }, + ])) + .toEqual(['submission-1']) + }) + + it('falls back to placement when F2F submission dates are unavailable', () => { + expect(resolveFirst2FinishIterativeSubmissionIds([ + { + id: 'submission-2', + placement: 2, + submittedDate: '', + }, + { + id: 'submission-1', + placement: 1, + submittedDate: '', + }, + ])) + .toEqual(['submission-1']) + }) +}) + +describe('collectReopenEligiblePhaseIds', () => { + it('does not mark Registration as reopen-eligible in a two-round flow when Submission is open', () => { + const phases: BackendPhase[] = [ + createBackendPhase('registration-id', 'Registration', '2025-12-05T10:55:00Z', { + phaseId: 'registration-phase-id', + }), + createBackendPhase('checkpoint-submission-id', 'Checkpoint Submission', '2025-12-05T10:57:00Z', { + phaseId: 'checkpoint-submission-phase-id', + predecessor: 'registration-phase-id', + }), + createBackendPhase('checkpoint-screening-id', 'Checkpoint Screening', '2025-12-05T11:03:00Z', { + phaseId: 'checkpoint-screening-phase-id', + predecessor: 'checkpoint-submission-phase-id', + }), + createBackendPhase('checkpoint-review-id', 'Checkpoint Review', '2025-12-05T11:05:00Z', { + phaseId: 'checkpoint-review-phase-id', + predecessor: 'checkpoint-screening-phase-id', + }), + createBackendPhase('submission-id', 'Submission', '2025-12-05T11:09:00Z', { + isOpen: true, + phaseId: 'submission-phase-id', + predecessor: 'checkpoint-review-phase-id', + }), + ] + + const eligible = collectReopenEligiblePhaseIds(phases) + + expect(eligible.has('checkpoint-review-id')) + .toBe(true) + expect(eligible.has('checkpoint-review-phase-id')) + .toBe(true) + expect(eligible.has('registration-id')) + .toBe(false) + expect(eligible.has('registration-phase-id')) + .toBe(false) + }) + + it('marks Registration as reopen-eligible when it is the direct predecessor of an open phase', () => { + const phases: BackendPhase[] = [ + createBackendPhase('registration-id', 'Registration', '2025-12-05T10:55:00Z', { + phaseId: 'registration-phase-id', + }), + createBackendPhase('submission-id', 'Submission', '2025-12-05T11:09:00Z', { + isOpen: true, + phaseId: 'submission-phase-id', + predecessor: 'registration-phase-id', + }), + ] + + const eligible = collectReopenEligiblePhaseIds(phases) + + expect(eligible.has('registration-id')) + .toBe(true) + expect(eligible.has('registration-phase-id')) + .toBe(true) + }) }) diff --git a/src/apps/review/src/lib/utils/challenge.ts b/src/apps/review/src/lib/utils/challenge.ts index 7d14533dc..44aa240e8 100644 --- a/src/apps/review/src/lib/utils/challenge.ts +++ b/src/apps/review/src/lib/utils/challenge.ts @@ -2,13 +2,16 @@ * Util for challenge */ -import type { +import { BackendMetadata, BackendPhase, + BackendSubmission, ChallengeInfo, SelectOption, } from '../models' +import { PAST_CHALLENGE_STATUSES } from './challengeStatus' + /** * Check if challenge is in the review phase * @param challengeInfo challenge info @@ -120,7 +123,6 @@ function normalizeLimitMetadataValue(rawValue: unknown): unknown { } function evaluateStringLimit(value: string): boolean { - console.log('evaluateStringLimit', value) const trimmed = value.trim() if (!trimmed) { return true @@ -216,6 +218,159 @@ export type PhaseOrderingOptions = { isTopgearTask?: boolean } +/** + * Approval review shape needed for winners-tab gating decisions. + */ +export interface ApprovalReviewStatusLike { + review?: { + status?: string | null + } +} + +type WinnersTabVisibilityChallengeInfo = Pick + & Partial> + +type WinnersTabFallbackChallengeInfo = WinnersTabVisibilityChallengeInfo + & Partial> + +/** + * Determine whether the challenge is in a past/completed-style status. + * + * @param status - Challenge status returned by the backend. + * @returns True when the challenge is completed or cancelled. + */ +function isPastChallengeStatus(status?: string): boolean { + const normalizedStatus = (status ?? '') + .trim() + .toUpperCase() + + if (!normalizedStatus) { + return false + } + + return PAST_CHALLENGE_STATUSES.some(pastStatus => normalizedStatus.startsWith(pastStatus)) +} + +/** + * Determine whether any approval round is still pending. + * + * @param approvalReviews - Approval reviews currently associated with the challenge. + * @returns True when an approval review has not been completed or submitted yet. + */ +export function hasPendingApprovalReview( + approvalReviews?: ApprovalReviewStatusLike[] | null, +): boolean { + return (approvalReviews ?? []).some(entry => { + const normalizedStatus = (entry.review?.status ?? '') + .trim() + .toUpperCase() + + return normalizedStatus !== 'COMPLETED' && normalizedStatus !== 'SUBMITTED' + }) +} + +function normalizeChallengeKey(value?: string): string { + return (value ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]/g, '') +} + +/** + * Check whether a challenge should follow First2Finish-specific UI rules. + * + * @param challengeInfo - Challenge metadata containing type and track names. + * @returns True when the type or track identifies the challenge as First2Finish. + */ +export function isFirst2FinishChallenge( + challengeInfo?: Pick, +): boolean { + const typeName = normalizeChallengeKey(challengeInfo?.type?.name) + const trackName = normalizeChallengeKey(challengeInfo?.track?.name) + + return typeName === 'first2finish' || trackName === 'first2finish' +} + +function normalizeEntityId(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined + } + + const normalized = `${value}`.trim() + return normalized.length ? normalized : undefined +} + +function normalizeSubmissionId(submission: Pick): string | undefined { + return normalizeEntityId(submission.id) +} + +/** + * Parse a date-like value into a timestamp in milliseconds. + * + * @param value - Raw string or `Date` value from challenge-api models. + * @returns Milliseconds since epoch, or `undefined` when the value is empty or invalid. + */ +function parseTimestamp(value?: string | Date | null): number | undefined { + if (!value) { + return undefined + } + + const parsed = value instanceof Date + ? value.getTime() + : Date.parse(value) + + return Number.isNaN(parsed) ? undefined : parsed +} + +/** + * Resolve the submission ids that should remain visible on a First2Finish iterative + * tab. Legacy F2F flows should surface only the first contest submission; when the + * submission timestamp is unavailable, placement and original list order are used as + * stable fallbacks. + * + * @param submissions - Contest submissions associated with the challenge. + * @returns Ordered submission ids to keep visible on the iterative tab. + */ +export function resolveFirst2FinishIterativeSubmissionIds( + submissions?: Array> | null, +): string[] { + const contestSubmissions = submissions ?? [] + if (!contestSubmissions.length) { + return [] + } + + const withParsedDates = contestSubmissions + .map(submission => ({ + parsedDate: parseTimestamp(submission.submittedDate), + submission, + })) + .filter((item): item is { + parsedDate: number + submission: Pick + } => typeof item.parsedDate === 'number') + .sort((left, right) => left.parsedDate - right.parsedDate) + + if (withParsedDates.length) { + const earliestSubmissionId = normalizeSubmissionId(withParsedDates[0].submission) + return earliestSubmissionId ? [earliestSubmissionId] : [] + } + + const placements = contestSubmissions + .map(submission => Number(submission.placement)) + .filter(placement => Number.isFinite(placement) && placement > 0) + + if (placements.length) { + const topPlacement = Math.min(...placements) + return contestSubmissions + .filter(submission => Number(submission.placement) === topPlacement) + .map(submission => normalizeSubmissionId(submission)) + .filter((submissionId): submissionId is string => Boolean(submissionId)) + } + + const fallbackSubmissionId = normalizeSubmissionId(contestSubmissions[0]) + return fallbackSubmissionId ? [fallbackSubmissionId] : [] +} + const TAB_INSERTION_HELPERS = { insertIfMissing( tabs: SelectOption[], @@ -245,8 +400,8 @@ const getPhaseStartTimestamp = (phase: PhaseLike | undefined): number | undefine if (!phase) return undefined const startSource = phase.actualStartDate || phase.scheduledStartDate if (!startSource) return undefined - const parsed = Date.parse(startSource) - if (Number.isNaN(parsed)) return undefined + const parsed = parseTimestamp(startSource) + if (parsed === undefined) return undefined const minutes = Math.floor(parsed / 60000) return Number.isNaN(minutes) ? undefined : minutes } @@ -374,6 +529,7 @@ const orderPhasesForTabs = ( /** * Build tabs for challenge phases using a consistent ordering. + * For completed challenges, add the Winners tab only when no phase is still open. */ export function buildPhaseTabs( phases: PhaseLike[], @@ -404,7 +560,8 @@ export function buildPhaseTabs( }) const normalizedStatus = (status || '').toUpperCase() - if (normalizedStatus.startsWith('COMPLETED')) { + const hasOpenPhase = orderedPhases.some(phase => phase?.isOpen === true) + if (normalizedStatus.startsWith('COMPLETED') && !hasOpenPhase) { TAB_INSERTION_HELPERS.insertIfMissing(tabs, 'Winners', 'Winners', tabs.length) } @@ -455,8 +612,71 @@ const normalizePhaseIdentifier = ( return normalized.length ? normalized : undefined } +/** + * Collect phase identifiers that are eligible for timeline reopen actions. + * A phase is reopen-eligible only when it is the direct predecessor of an open phase. + * + * @param phases - Challenge phases from the backend. + * @returns Set of eligible phase identifiers including both `id` and `phaseId` values. + */ +export function collectReopenEligiblePhaseIds( + phases?: BackendPhase[], +): Set { + const identifiers = new Set() + if (!Array.isArray(phases) || !phases.length) { + return identifiers + } + + const phaseLookup = new Map() + phases.forEach(phase => { + const id = normalizePhaseIdentifier(phase?.id) + const phaseId = normalizePhaseIdentifier(phase?.phaseId) + + if (id) { + phaseLookup.set(id, phase) + } + + if (phaseId) { + phaseLookup.set(phaseId, phase) + } + }) + + const addPhaseIdentifiers = (phase?: BackendPhase): void => { + if (!phase) { + return + } + + const id = normalizePhaseIdentifier(phase.id) + const phaseId = normalizePhaseIdentifier(phase.phaseId) + + if (id) { + identifiers.add(id) + } + + if (phaseId) { + identifiers.add(phaseId) + } + } + + phases.forEach(phase => { + if (!phase?.isOpen) { + return + } + + const predecessorId = normalizePhaseIdentifier(phase.predecessor) + if (!predecessorId) { + return + } + + identifiers.add(predecessorId) + addPhaseIdentifiers(phaseLookup.get(predecessorId)) + }) + + return identifiers +} + const collectOpenPhaseIdentifiers = ( - challengeInfo?: ChallengeInfo, + challengeInfo?: Partial>, ): Set => { const identifiers = new Set() @@ -490,6 +710,46 @@ const collectOpenPhaseIdentifiers = ( return identifiers } +/** + * Determine whether a past challenge is allowed to show the Winners tab. + * + * @param challengeInfo - Challenge status and phase metadata returned by the backend. + * @param approvalReviews - Approval reviews currently associated with the challenge. + * @returns True when the challenge is past and no follow-up approval round remains active. + */ +export function shouldAllowWinnersTabForPastChallenge( + challengeInfo?: WinnersTabVisibilityChallengeInfo, + approvalReviews?: ApprovalReviewStatusLike[] | null, +): boolean { + if (!isPastChallengeStatus(challengeInfo?.status)) { + return false + } + + if (hasPendingApprovalReview(approvalReviews)) { + return false + } + + return collectOpenPhaseIdentifiers(challengeInfo).size === 0 +} + +/** + * Determine whether the UI should force-show the Winners tab for a past challenge. + * + * @param challengeInfo - Challenge status, winners, and phase metadata returned by the backend. + * @param approvalReviews - Approval reviews currently associated with the challenge. + * @returns True when the challenge is past/completed and winners are already available. + */ +export function shouldForceWinnersTabForPastChallenge( + challengeInfo?: WinnersTabFallbackChallengeInfo, + approvalReviews?: ApprovalReviewStatusLike[] | null, +): boolean { + if (!(challengeInfo?.winners?.length)) { + return false + } + + return shouldAllowWinnersTabForPastChallenge(challengeInfo, approvalReviews) +} + export function isReviewPhaseCurrentlyOpen( challengeInfo?: ChallengeInfo, phaseId?: string | number | null, diff --git a/src/apps/review/src/lib/utils/challengeResultSubmissions.spec.ts b/src/apps/review/src/lib/utils/challengeResultSubmissions.spec.ts new file mode 100644 index 000000000..0f60caef7 --- /dev/null +++ b/src/apps/review/src/lib/utils/challengeResultSubmissions.spec.ts @@ -0,0 +1,75 @@ +import type { BackendResource, SubmissionInfo } from '../models' + +import { buildChallengeResultSubmissionSource } from './challengeResultSubmissions' + +const buildResource = (memberId: string, handle: string): BackendResource => ({ + id: `resource-${memberId}`, + memberHandle: handle, + memberId, + roleId: 'submitter-role', +} as BackendResource) + +const buildSubmission = (overrides: Partial = {}): SubmissionInfo => ({ + id: 'submission-1', + legacySubmissionId: 'legacy-submission-1', + memberId: '1001', + reviews: [], + ...overrides, +}) + +describe('buildChallengeResultSubmissionSource', () => { + it('backfills winner submissions with user info and handles from challenge submissions', () => { + const result = buildChallengeResultSubmissionSource({ + challengeSubmissions: [ + buildSubmission({ + submitterHandle: 'winner-handle', + userInfo: buildResource('1001', 'winner-handle'), + }), + ], + memberMapping: { + 1001: buildResource('1001', 'winner-handle'), + }, + winnerSubmissions: [ + buildSubmission({ + submitterHandle: undefined, + userInfo: undefined, + }), + ], + }) + + expect(result) + .toHaveLength(1) + expect(result[0]) + .toMatchObject({ + id: 'submission-1', + submitterHandle: 'winner-handle', + userInfo: { + memberHandle: 'winner-handle', + }, + }) + }) + + it('deduplicates legacy-equivalent submissions across sources', () => { + const result = buildChallengeResultSubmissionSource({ + challengeSubmissions: [ + buildSubmission({ + id: 'submission-legacy-alias', + legacySubmissionId: 'legacy-submission-1', + submitterHandle: 'winner-handle', + }), + ], + memberMapping: {}, + winnerSubmissions: [ + buildSubmission({ + id: 'submission-1', + legacySubmissionId: 'legacy-submission-1', + }), + ], + }) + + expect(result) + .toHaveLength(1) + expect(result[0].submitterHandle) + .toBe('winner-handle') + }) +}) diff --git a/src/apps/review/src/lib/utils/challengeResultSubmissions.ts b/src/apps/review/src/lib/utils/challengeResultSubmissions.ts new file mode 100644 index 000000000..8a1a81a50 --- /dev/null +++ b/src/apps/review/src/lib/utils/challengeResultSubmissions.ts @@ -0,0 +1,186 @@ +import type { BackendResource, SubmissionInfo } from '../models' + +export interface BuildChallengeResultSubmissionSourceArgs { + challengeSubmissions?: SubmissionInfo[] + memberMapping: Record + reviewSubmissions?: SubmissionInfo[] + winnerSubmissions?: SubmissionInfo[] +} + +/** + * Normalizes identifier-like values for submission-source matching. + * + * @param value - Raw identifier value. + * @returns Trimmed string identifier or undefined when empty. + */ +function normalizeIdentifier(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined + } + + const normalized = `${value}`.trim() + return normalized.length ? normalized : undefined +} + +/** + * Enriches a submission with member-mapping user info when it is missing. + * + * @param submission - Submission to normalize. + * @param memberMapping - Member mapping from the challenge context. + * @returns Submission with the best available user info attached. + */ +function enrichSubmissionInfo( + submission: SubmissionInfo, + memberMapping: Record, +): SubmissionInfo { + const memberId = normalizeIdentifier(submission.memberId) + + if (!memberId || submission.userInfo) { + return submission + } + + const userInfo = memberMapping[memberId] + return userInfo + ? { + ...submission, + userInfo, + } + : submission +} + +/** + * Builds the ordered identity keys that can represent a submission across legacy and current data. + * + * @param submission - Submission to inspect. + * @returns Ordered identity keys for matching submissions across data sources. + */ +function getSubmissionIdentityKeys(submission: SubmissionInfo): string[] { + return [ + normalizeIdentifier(submission.id), + normalizeIdentifier(submission.legacySubmissionId), + ] + .filter((key): key is string => Boolean(key)) +} + +/** + * Prefers the first defined scalar value across the preferred and fallback submissions. + * + * @param preferredValue - Value from the higher-priority submission. + * @param fallbackValue - Value from the lower-priority submission. + * @returns Preferred value when defined; otherwise the fallback value. + */ +function preferDefinedValue( + preferredValue: T | undefined, + fallbackValue: T | undefined, +): T | undefined { + return preferredValue ?? fallbackValue +} + +/** + * Prefers a non-empty array from the higher-priority submission before falling back. + * + * @param preferredValue - Array from the higher-priority submission. + * @param fallbackValue - Array from the lower-priority submission. + * @returns Preferred non-empty array when present; otherwise the fallback array. + */ +function preferArrayValue( + preferredValue: T[] | undefined, + fallbackValue: T[] | undefined, +): T[] | undefined { + return preferredValue?.length ? preferredValue : fallbackValue +} + +/** + * Merges a preferred submission with a fallback record that may include richer legacy details. + * + * @param preferred - Submission from the higher-priority source. + * @param fallback - Submission from a lower-priority source used to fill gaps. + * @returns A merged submission preserving the preferred source while backfilling missing fields. + */ +function mergeSubmissionInfo( + preferred: SubmissionInfo, + fallback: SubmissionInfo, +): SubmissionInfo { + return { + ...fallback, + ...preferred, + aggregateScore: preferDefinedValue(preferred.aggregateScore, fallback.aggregateScore), + id: preferred.id || fallback.id, + isFileSubmission: preferDefinedValue(preferred.isFileSubmission, fallback.isFileSubmission), + isLatest: preferDefinedValue(preferred.isLatest, fallback.isLatest), + isPassingReview: preferDefinedValue(preferred.isPassingReview, fallback.isPassingReview), + legacySubmissionId: preferDefinedValue( + preferred.legacySubmissionId, + fallback.legacySubmissionId, + ), + memberId: preferred.memberId || fallback.memberId, + placement: preferDefinedValue(preferred.placement, fallback.placement), + review: preferDefinedValue(preferred.review, fallback.review), + reviewInfos: preferArrayValue(preferred.reviewInfos, fallback.reviewInfos), + reviews: preferArrayValue(preferred.reviews, fallback.reviews), + reviewTypeId: preferDefinedValue(preferred.reviewTypeId, fallback.reviewTypeId), + status: preferDefinedValue(preferred.status, fallback.status), + submittedDate: preferDefinedValue(preferred.submittedDate, fallback.submittedDate), + submittedDateString: preferDefinedValue( + preferred.submittedDateString, + fallback.submittedDateString, + ), + submitterHandle: preferDefinedValue(preferred.submitterHandle, fallback.submitterHandle), + type: preferDefinedValue(preferred.type, fallback.type), + userInfo: preferDefinedValue(preferred.userInfo, fallback.userInfo), + virusScan: preferDefinedValue(preferred.virusScan, fallback.virusScan), + } +} + +/** + * Builds the submission source used by past-challenge winner rows. Winner submissions remain the + * primary source so all placements are available to submitters, while challenge and review + * submissions backfill handles, user info, and other legacy-safe details. + * + * @param args - Submission sources ordered by preference. + * @returns Deduplicated, enriched submission source for winner/result resolution. + */ +export function buildChallengeResultSubmissionSource({ + challengeSubmissions = [], + memberMapping, + reviewSubmissions = [], + winnerSubmissions = [], +}: BuildChallengeResultSubmissionSourceArgs): SubmissionInfo[] { + const orderedSources = [ + winnerSubmissions, + challengeSubmissions, + reviewSubmissions, + ] + + const mergedSubmissions: SubmissionInfo[] = [] + const submissionIndexByKey = new Map() + + orderedSources.forEach(source => { + source.forEach(rawSubmission => { + const submission = enrichSubmissionInfo(rawSubmission, memberMapping) + const identityKeys = getSubmissionIdentityKeys(submission) + + const existingIndex = identityKeys + .map(key => submissionIndexByKey.get(key)) + .find((index): index is number => index !== undefined) + + if (existingIndex === undefined) { + const nextIndex = mergedSubmissions.length + mergedSubmissions.push(submission) + identityKeys.forEach(key => submissionIndexByKey.set(key, nextIndex)) + return + } + + const merged = mergeSubmissionInfo( + mergedSubmissions[existingIndex], + submission, + ) + + mergedSubmissions[existingIndex] = merged + getSubmissionIdentityKeys(merged) + .forEach(key => submissionIndexByKey.set(key, existingIndex)) + }) + }) + + return mergedSubmissions +} diff --git a/src/apps/review/src/lib/utils/metadataMatching.spec.ts b/src/apps/review/src/lib/utils/metadataMatching.spec.ts new file mode 100644 index 000000000..0e140ddc6 --- /dev/null +++ b/src/apps/review/src/lib/utils/metadataMatching.spec.ts @@ -0,0 +1,13 @@ +import { isPhaseAllowedForReview } from './metadataMatching' + +describe('isPhaseAllowedForReview', () => { + it('accepts specification review as a review-bearing legacy phase', () => { + expect(isPhaseAllowedForReview('Specification Review')) + .toBe(true) + }) + + it('keeps screening phases excluded from review matching', () => { + expect(isPhaseAllowedForReview('Screening')) + .toBe(false) + }) +}) diff --git a/src/apps/review/src/lib/utils/metadataMatching.ts b/src/apps/review/src/lib/utils/metadataMatching.ts index 2279bfe00..3daa06e2a 100644 --- a/src/apps/review/src/lib/utils/metadataMatching.ts +++ b/src/apps/review/src/lib/utils/metadataMatching.ts @@ -186,6 +186,7 @@ export function isPhaseAllowedForReview(phaseName?: string | null): boolean { } return normalizedAlpha === 'review' + || normalizedAlpha === 'specificationreview' || normalizedAlpha === 'iterativereview' || normalizedAlpha === 'postmortem' || normalizedAlpha === 'approval' diff --git a/src/apps/review/src/lib/utils/reviewFetchPolicy.spec.ts b/src/apps/review/src/lib/utils/reviewFetchPolicy.spec.ts new file mode 100644 index 000000000..a52cb64b0 --- /dev/null +++ b/src/apps/review/src/lib/utils/reviewFetchPolicy.spec.ts @@ -0,0 +1,27 @@ +import type { BackendResource } from '../models' + +import { shouldForceChallengeReviewFetch } from './reviewFetchPolicy' + +const createResource = (roleName: string): BackendResource => ({ + id: `${roleName}-resource`, + memberId: '1001', + roleId: `${roleName}-role`, + roleName, +} as BackendResource) + +describe('shouldForceChallengeReviewFetch', () => { + it('forces review fetching for past challenges even when the viewer is only an observer', () => { + expect(shouldForceChallengeReviewFetch(undefined, 'COMPLETED')) + .toBe(true) + }) + + it('does not force review fetching for active observer views without privileged resources', () => { + expect(shouldForceChallengeReviewFetch(undefined, 'ACTIVE', [])) + .toBe(false) + }) + + it('keeps forcing review fetching for submitter views', () => { + expect(shouldForceChallengeReviewFetch('Submitter', 'ACTIVE', [createResource('Submitter')])) + .toBe(true) + }) +}) diff --git a/src/apps/review/src/lib/utils/reviewFetchPolicy.ts b/src/apps/review/src/lib/utils/reviewFetchPolicy.ts new file mode 100644 index 000000000..492418eed --- /dev/null +++ b/src/apps/review/src/lib/utils/reviewFetchPolicy.ts @@ -0,0 +1,62 @@ +import type { BackendResource } from '../models' + +import { PAST_CHALLENGE_STATUSES } from './challengeStatus' + +const ADMIN_ROLE = 'Admin' +const COPILOT_ROLE = 'Copilot' +const MANAGER_ROLE = 'Manager' +const REVIEWER_ROLE = 'Reviewer' +const SUBMITTER_ROLE = 'Submitter' + +/** + * Determines whether challenge reviews should be fetched regardless of reviewer assignments. + * Past challenges need the full review list even for observer-style views because legacy + * submissions often do not embed their review rows locally. + * + * @param actionChallengeRole - Current challenge action role. + * @param challengeStatus - Challenge status from challenge info. + * @param myResources - Current member resources for the challenge. + * @returns True when the UI should force the full challenge-review fetch. + */ +export function shouldForceChallengeReviewFetch( + actionChallengeRole: string | undefined, + challengeStatus: string | undefined, + myResources?: BackendResource[], +): boolean { + const normalizedStatus = (challengeStatus ?? '') + .trim() + .toUpperCase() + + if ( + normalizedStatus + && PAST_CHALLENGE_STATUSES.some(status => normalizedStatus.startsWith(status)) + ) { + return true + } + + const normalizedActionRole = actionChallengeRole ?? '' + + if ( + normalizedActionRole === SUBMITTER_ROLE + || normalizedActionRole === REVIEWER_ROLE + || normalizedActionRole === COPILOT_ROLE + || normalizedActionRole === ADMIN_ROLE + || normalizedActionRole === MANAGER_ROLE + ) { + return true + } + + return (myResources ?? []).some(resource => { + const normalizedRoleName = (resource.roleName ?? '').toLowerCase() + + if (!normalizedRoleName) { + return false + } + + return normalizedRoleName.includes('screener') + || normalizedRoleName.includes('reviewer') + || normalizedRoleName.includes('copilot') + || normalizedRoleName.includes('admin') + || normalizedRoleName.includes('manager') + }) +} diff --git a/src/apps/review/src/lib/utils/reviewMatching.ts b/src/apps/review/src/lib/utils/reviewMatching.ts index 21d1322d2..744ad134a 100644 --- a/src/apps/review/src/lib/utils/reviewMatching.ts +++ b/src/apps/review/src/lib/utils/reviewMatching.ts @@ -83,8 +83,10 @@ interface ResolvePhaseMatchArguments { matchesPhase: boolean matchesScorecard: boolean normalizedPhaseName?: string + normalizedPhaseNameAlpha?: string normalizedPhaseNameForReviewType?: string normalizedReviewPhaseName?: string + normalizedReviewPhaseNameAlpha?: string normalizedReviewTypeAlpha?: string review: BackendReview reviewPhaseName?: string | null @@ -95,8 +97,10 @@ function resolvePhaseOrTypeMatch({ matchesPhase, matchesScorecard, normalizedPhaseName, + normalizedPhaseNameAlpha, normalizedPhaseNameForReviewType, normalizedReviewPhaseName, + normalizedReviewPhaseNameAlpha, normalizedReviewTypeAlpha, review, reviewPhaseName, @@ -109,7 +113,9 @@ function resolvePhaseOrTypeMatch({ if (normalizedReviewPhaseName) { const matches = enforceExactPhaseNameMatch({ normalizedPhaseName, + normalizedPhaseNameAlpha, normalizedReviewPhaseName, + normalizedReviewPhaseNameAlpha, review, reviewPhaseName: reviewPhaseName ?? '', }) @@ -208,29 +214,43 @@ export function handleNoPhaseMatch( * Enforces an exact match requirement when the review contains an explicit phase name. * * @param normalizedPhaseName - Target phase name normalized to lowercase. + * @param normalizedPhaseNameAlpha - Target phase name normalized to letters only. * @param normalizedReviewPhaseName - Review phase name normalized to lowercase. + * @param normalizedReviewPhaseNameAlpha - Review phase name normalized to letters only. * @param review - Review being evaluated. * @param reviewPhaseName - Original review phase name for logging purposes. - * @returns True when the normalized phase names match exactly. + * @returns True when the phase names match exactly after normalization, including + * continuation suffixes such as "Approval 2". */ export function enforceExactPhaseNameMatch({ normalizedPhaseName, + normalizedPhaseNameAlpha, normalizedReviewPhaseName, + normalizedReviewPhaseNameAlpha, review, reviewPhaseName, }: { normalizedPhaseName: string + normalizedPhaseNameAlpha?: string normalizedReviewPhaseName: string + normalizedReviewPhaseNameAlpha?: string review: BackendReview reviewPhaseName: string }): boolean { const matchesPhaseName = normalizedReviewPhaseName === normalizedPhaseName + || ( + Boolean(normalizedPhaseNameAlpha) + && Boolean(normalizedReviewPhaseNameAlpha) + && normalizedReviewPhaseNameAlpha === normalizedPhaseNameAlpha + ) const matchedCriteria = matchesPhaseName ? ['phaseName'] : [] debugLog('reviewMatchesPhase.phaseNameExactMatchCheck', { matchesPhaseName, normalizedPhaseName, + normalizedPhaseNameAlpha, normalizedReviewPhaseName, + normalizedReviewPhaseNameAlpha, reviewId: review.id, reviewPhaseName: truncateForLog(reviewPhaseName), }) @@ -289,12 +309,23 @@ export function enforceExactReviewTypeMatch({ const hasMatchingPhaseName = ( normalizedPhaseName?: string, normalizedReviewPhaseName?: string, + normalizedPhaseNameAlpha?: string, + normalizedReviewPhaseNameAlpha?: string, ): boolean => { if (!normalizedPhaseName || !normalizedReviewPhaseName) { - return false + return Boolean( + normalizedPhaseNameAlpha + && normalizedReviewPhaseNameAlpha + && normalizedReviewPhaseNameAlpha === normalizedPhaseNameAlpha, + ) } return normalizedReviewPhaseName === normalizedPhaseName + || ( + Boolean(normalizedPhaseNameAlpha) + && Boolean(normalizedReviewPhaseNameAlpha) + && normalizedReviewPhaseNameAlpha === normalizedPhaseNameAlpha + ) } const hasMatchingReviewTypeName = ( @@ -354,8 +385,9 @@ const doesReviewMatchScorecard = ( /** * Determines whether a review matches the supplied phase context. Reviews that include an explicit - * phase name must match the target phase name exactly; otherwise scorecard, phase identifier, type, - * and metadata criteria are evaluated in order. + * phase name must match the target phase name after normalization (including continuation + * suffixes like "Approval 2"); otherwise scorecard, phase identifier, type, and metadata criteria + * are evaluated in order. * * @param review - Review to evaluate. * @param scorecardId - Scorecard identifier associated with the target phase. @@ -395,10 +427,12 @@ export function reviewMatchesPhase( }) const normalizedPhaseName = getNormalizedLowerCase(phaseName) + const normalizedPhaseNameAlpha = getNormalizedAlphaLowerCase(phaseName) const normalizedPhaseNameForReviewType = getNormalizedAlphaLowerCase(phaseName) const reviewPhaseName = (review as { phaseName?: string | null }).phaseName ?? undefined const normalizedReviewPhaseName = getNormalizedLowerCase(reviewPhaseName) + const normalizedReviewPhaseNameAlpha = getNormalizedAlphaLowerCase(reviewPhaseName) const reviewType = (review as { reviewType?: string | null }).reviewType ?? undefined const normalizedReviewType = getNormalizedLowerCase(reviewType) const normalizedReviewTypeAlpha = getNormalizedAlphaLowerCase(reviewType) @@ -406,14 +440,18 @@ export function reviewMatchesPhase( const matchesPhaseName = hasMatchingPhaseName( normalizedPhaseName, normalizedReviewPhaseName, + normalizedPhaseNameAlpha, + normalizedReviewPhaseNameAlpha, ) const phaseBasedMatch = resolvePhaseOrTypeMatch({ matchesPhase, matchesScorecard, normalizedPhaseName, + normalizedPhaseNameAlpha, normalizedPhaseNameForReviewType, normalizedReviewPhaseName, + normalizedReviewPhaseNameAlpha, normalizedReviewTypeAlpha, review, reviewPhaseName, diff --git a/src/apps/review/src/lib/utils/reviewPhaseGuards.spec.ts b/src/apps/review/src/lib/utils/reviewPhaseGuards.spec.ts new file mode 100644 index 000000000..6071363ec --- /dev/null +++ b/src/apps/review/src/lib/utils/reviewPhaseGuards.spec.ts @@ -0,0 +1,95 @@ +import type { BackendPhase, SubmissionInfo } from '../models' + +import { isContestReviewPhaseSubmission } from './reviewPhaseGuards' + +const reviewPhase: BackendPhase = { + constraints: [], + description: '', + duration: 0, + id: 'phase-review', + isOpen: false, + name: 'Review', + phaseId: 'phase-review', + scheduledEndDate: '2026-01-02T00:00:00.000Z', + scheduledStartDate: '2026-01-01T00:00:00.000Z', +} + +const specificationReviewPhase: BackendPhase = { + ...reviewPhase, + id: 'phase-spec-review', + name: 'Specification Review', + phaseId: 'phase-spec-review', +} + +const buildSubmission = (type: string): SubmissionInfo => ({ + id: 'submission-1', + memberId: '1001', + review: { + committed: true, + createdAt: '2026-01-01T00:00:00.000Z', + id: 'review-1', + phaseId: 'phase-review', + phaseName: 'Review', + resourceId: 'reviewer-1', + reviewItems: [], + scorecardId: 'scorecard-1', + status: 'COMPLETED', + submissionId: 'submission-1', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + type, +}) + +describe('isContestReviewPhaseSubmission', () => { + it('accepts contest submission type values using legacy spacing/casing', () => { + expect(isContestReviewPhaseSubmission( + buildSubmission('Contest Submission'), + [reviewPhase], + )) + .toBe(true) + }) + + it('rejects non-contest submission types', () => { + expect(isContestReviewPhaseSubmission( + buildSubmission('Checkpoint Submission'), + [reviewPhase], + )) + .toBe(false) + }) + + it('matches legacy specification review tabs when the phase name is requested explicitly', () => { + const baseSubmission = buildSubmission('Contest Submission') + + expect(isContestReviewPhaseSubmission( + { + ...baseSubmission, + review: { + ...(baseSubmission.review as NonNullable), + phaseId: 'phase-spec-review', + phaseName: 'Specification Review', + }, + }, + [reviewPhase, specificationReviewPhase], + 'Specification Review', + )) + .toBe(true) + }) + + it('does not mix specification review rows into the standard review tab', () => { + const baseSubmission = buildSubmission('Contest Submission') + + expect(isContestReviewPhaseSubmission( + { + ...baseSubmission, + review: { + ...(baseSubmission.review as NonNullable), + phaseId: 'phase-spec-review', + phaseName: 'Specification Review', + }, + }, + [reviewPhase, specificationReviewPhase], + 'Review', + )) + .toBe(false) + }) +}) diff --git a/src/apps/review/src/lib/utils/reviewPhaseGuards.ts b/src/apps/review/src/lib/utils/reviewPhaseGuards.ts index 0d7ccb7f8..0e93dcf14 100644 --- a/src/apps/review/src/lib/utils/reviewPhaseGuards.ts +++ b/src/apps/review/src/lib/utils/reviewPhaseGuards.ts @@ -2,6 +2,7 @@ import { BackendPhase, SubmissionInfo, } from '../models' +import { isContestSubmissionType } from '../constants' const EXCLUDED_REVIEW_TYPE_FRAGMENTS = [ 'approval', @@ -92,15 +93,17 @@ const collectReviewHints = ( return normalizedCandidates } -const normalizeReviewPhaseName = (value?: string | null): string => ( +const normalizeReviewPhaseKey = (value?: string | null): string => ( (value ?? '') .trim() .toLowerCase() + .replace(/[^a-z]/g, '') ) const hasReviewPhaseName = ( phaseName?: string | null, -): boolean => normalizeReviewPhaseName(phaseName) === 'review' + targetPhaseName = 'Review', +): boolean => normalizeReviewPhaseKey(phaseName) === normalizeReviewPhaseKey(targetPhaseName) type ReviewPhaseCandidate = { phaseId?: string | null @@ -111,47 +114,49 @@ type ReviewPhaseCandidate = { const hasReviewPhase = ( review: ReviewPhaseCandidate | undefined, phases?: BackendPhase[], + targetPhaseName = 'Review', ): boolean => { if (!review) { return false } - if (hasReviewPhaseName(review.phaseName)) { + if (hasReviewPhaseName(review.phaseName, targetPhaseName)) { return true } - if (hasReviewPhaseName(review.reviewType)) { + if (hasReviewPhaseName(review.reviewType, targetPhaseName)) { return true } const resolvedPhaseName = resolvePhaseNameFromId(review.phaseId, phases) - return hasReviewPhaseName(resolvedPhaseName) + return hasReviewPhaseName(resolvedPhaseName, targetPhaseName) } export const isContestReviewPhaseSubmission = ( submission?: SubmissionInfo, phases?: BackendPhase[], + targetPhaseName = 'Review', ): boolean => { if (!submission) { return false } - const submissionType = (submission.type ?? '') - .trim() - .toUpperCase() - if (submissionType !== 'CONTEST_SUBMISSION') { + if (!isContestSubmissionType(submission.type)) { return false } - if (hasReviewPhase(submission.review, phases)) { + if (hasReviewPhase(submission.review, phases, targetPhaseName)) { return true } if (Array.isArray(submission.reviews)) { - return submission.reviews.some(review => hasReviewPhase(review, phases)) + if (submission.reviews.some(review => hasReviewPhase(review, phases, targetPhaseName))) { + return true + } } - return false + const normalizedCandidates = collectReviewHints(submission, phases) + return normalizedCandidates.has(normalizeReviewPhaseKey(targetPhaseName)) } export const shouldIncludeInReviewPhase = ( diff --git a/src/apps/review/src/lib/utils/reviewProgress.spec.ts b/src/apps/review/src/lib/utils/reviewProgress.spec.ts new file mode 100644 index 000000000..e696400ca --- /dev/null +++ b/src/apps/review/src/lib/utils/reviewProgress.spec.ts @@ -0,0 +1,133 @@ +import type { + BackendPhase, + Screening, + SubmissionInfo, +} from '../models' + +import { calculateReviewProgress } from './reviewProgress' + +jest.mock('~/config', () => ({ + EnvironmentConfig: {}, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + getRatingColor: jest.fn() + .mockReturnValue('#000000'), +}), { virtual: true }) + +const createPhase = (name: string): BackendPhase => ({ + constraints: [], + description: '', + duration: 0, + id: `${name.toLowerCase()}-id`, + isOpen: true, + name, + phaseId: `${name.toLowerCase()}-id`, + scheduledEndDate: '2025-01-01T01:00:00.000Z', + scheduledStartDate: '2025-01-01T00:00:00.000Z', +}) + +const createReviewSubmission = ( + submissionId: string, + status: string, + overrides: Partial = {}, +): SubmissionInfo => ({ + id: submissionId, + isLatest: true, + memberId: `member-${submissionId}`, + review: { + committed: status.toUpperCase() === 'COMPLETED', + createdAt: '2025-01-01T00:00:00.000Z', + id: `review-${submissionId}`, + resourceId: 'reviewer-resource-id', + reviewItems: [], + scorecardId: 'scorecard-id', + status, + submissionId, + updatedAt: '2025-01-01T00:00:00.000Z', + }, + reviewTypeId: 'Review', + ...overrides, +}) + +const createScreeningRow = ( + submissionId: string, + result: Screening['result'], +): Screening => ({ + challengeId: 'challenge-id', + createdAt: '2025-01-01T00:00:00.000Z', + memberId: `member-${submissionId}`, + result, + score: '100', + submissionId, +}) + +describe('calculateReviewProgress', () => { + const reviewPhases = [ + createPhase('Screening'), + createPhase('Review'), + ] + + it('ignores screening-failed submissions when computing review phase progress', () => { + const reviewRows: SubmissionInfo[] = [ + createReviewSubmission('failed-submission', 'PENDING'), + createReviewSubmission('passed-submission', 'COMPLETED'), + ] + const screeningRows: Screening[] = [ + createScreeningRow('failed-submission', 'NO PASS'), + createScreeningRow('passed-submission', 'PASS'), + ] + + const progress = calculateReviewProgress({ + challengePhases: reviewPhases, + isDesignChallenge: false, + reviewRows, + screeningRows, + }) + + expect(progress) + .toBe(100) + }) + + it('uses only latest submissions for non-design challenges', () => { + const reviewRows: SubmissionInfo[] = [ + createReviewSubmission('older-submission', 'COMPLETED', { isLatest: false }), + createReviewSubmission('latest-submission', 'PENDING', { isLatest: true }), + ] + const screeningRows: Screening[] = [ + createScreeningRow('older-submission', 'PASS'), + createScreeningRow('latest-submission', 'PASS'), + ] + + const progress = calculateReviewProgress({ + challengePhases: reviewPhases, + isDesignChallenge: false, + reviewRows, + screeningRows, + }) + + expect(progress) + .toBe(0) + }) + + it('counts all submissions for design challenges', () => { + const reviewRows: SubmissionInfo[] = [ + createReviewSubmission('older-submission', 'COMPLETED', { isLatest: false }), + createReviewSubmission('latest-submission', 'PENDING', { isLatest: true }), + ] + const screeningRows: Screening[] = [ + createScreeningRow('older-submission', 'PASS'), + createScreeningRow('latest-submission', 'PASS'), + ] + + const progress = calculateReviewProgress({ + challengePhases: reviewPhases, + isDesignChallenge: true, + reviewRows, + screeningRows, + }) + + expect(progress) + .toBe(50) + }) +}) diff --git a/src/apps/review/src/lib/utils/reviewProgress.ts b/src/apps/review/src/lib/utils/reviewProgress.ts new file mode 100644 index 000000000..07db797e5 --- /dev/null +++ b/src/apps/review/src/lib/utils/reviewProgress.ts @@ -0,0 +1,159 @@ +import { every } from 'lodash' + +import type { + BackendPhase, + Screening, + SubmissionInfo, +} from '../models' + +import { shouldIncludeInReviewPhase } from './reviewPhaseGuards' + +const normalizeScreeningResult = (result?: string | null): string => (result ?? '') + .trim() + .toUpperCase() + +const resolveReviewSubmissionIds = (submission: SubmissionInfo): string[] => { + const candidateIds = new Set() + const submissionId = submission.id?.trim() + if (submissionId) { + candidateIds.add(submissionId) + } + + const reviewSubmissionId = submission.review?.submissionId?.trim() + if (reviewSubmissionId) { + candidateIds.add(reviewSubmissionId) + } + + return Array.from(candidateIds) +} + +const isSubmissionIncludedByScreening = ( + submission: SubmissionInfo, + passingSubmissionIds: Set, + failingSubmissionIds: Set, + shouldFilter: boolean, +): boolean => { + if (!shouldFilter) { + return true + } + + const candidateIds = resolveReviewSubmissionIds(submission) + if (!candidateIds.length) { + return true + } + + if (passingSubmissionIds.size > 0) { + return candidateIds.some(candidateId => passingSubmissionIds.has(candidateId)) + } + + if (failingSubmissionIds.size > 0) { + return !candidateIds.some(candidateId => failingSubmissionIds.has(candidateId)) + } + + return true +} + +const isCompletedReviewSubmission = (submission: SubmissionInfo): boolean => { + const committed = submission.review?.committed + if (typeof committed === 'boolean') { + return committed + } + + const status = submission.review?.status + if (typeof status === 'string' && status.trim()) { + return status.trim() + .toUpperCase() === 'COMPLETED' + } + + if (!submission.reviews?.length) { + return false + } + + return every( + submission.reviews, + reviewResult => typeof reviewResult.score === 'number' + && Number.isFinite(reviewResult.score), + ) +} + +type CalculateReviewProgressArgs = { + challengePhases?: BackendPhase[] + isDesignChallenge: boolean + reviewRows: SubmissionInfo[] + screeningRows: Screening[] +} + +/** + * Calculates review phase completion progress as a percentage. + * Screening-failed submissions are excluded whenever screening outcomes are available. + * + * @param args - Inputs needed to evaluate review progress. + * @returns Rounded completion percentage in the inclusive range [0, 100]. + */ +export const calculateReviewProgress = ({ + challengePhases, + isDesignChallenge, + reviewRows, + screeningRows, +}: CalculateReviewProgressArgs): number => { + if (!reviewRows.length) { + return 0 + } + + const reviewPhaseRows = reviewRows.filter(submission => shouldIncludeInReviewPhase( + submission, + challengePhases, + )) + if (!reviewPhaseRows.length) { + return 0 + } + + const passingSubmissionIds = new Set() + const failingSubmissionIds = new Set() + + screeningRows.forEach(screeningEntry => { + const submissionId = screeningEntry?.submissionId?.trim() + if (!submissionId) { + return + } + + const normalizedResult = normalizeScreeningResult(screeningEntry.result) + if (normalizedResult === 'PASS') { + passingSubmissionIds.add(submissionId) + return + } + + if (normalizedResult === 'NO PASS') { + failingSubmissionIds.add(submissionId) + } + }) + + const hasScreeningPhase = (challengePhases ?? []).some( + phase => (phase.name ?? '').trim() + .toLowerCase() === 'screening', + ) + const shouldFilterByScreening = ( + (hasScreeningPhase || screeningRows.length > 0) + && (passingSubmissionIds.size > 0 || failingSubmissionIds.size > 0) + ) + + const filteredByScreening = reviewPhaseRows.filter(submission => isSubmissionIncludedByScreening( + submission, + passingSubmissionIds, + failingSubmissionIds, + shouldFilterByScreening, + )) + if (!filteredByScreening.length) { + return 0 + } + + const progressRows = isDesignChallenge + ? filteredByScreening + : filteredByScreening.filter(submission => submission.isLatest) + if (!progressRows.length) { + return 0 + } + + const completedReviews = progressRows.filter(isCompletedReviewSubmission) + return Math.round((completedReviews.length * 100) / progressRows.length) +} diff --git a/src/apps/review/src/lib/utils/reviewScoring.spec.ts b/src/apps/review/src/lib/utils/reviewScoring.spec.ts new file mode 100644 index 000000000..a2820fb25 --- /dev/null +++ b/src/apps/review/src/lib/utils/reviewScoring.spec.ts @@ -0,0 +1,54 @@ +import type { SubmissionInfo } from '../models' + +import { hasRoleBasedThresholdAccess } from './reviewScoring' + +jest.mock('~/libs/core', () => ({ + getRatingColor: jest.fn() + .mockReturnValue('#000000'), +}), { virtual: true }) + +describe('hasRoleBasedThresholdAccess', () => { + it('returns true for non-submitter views even when submitter review data is missing', () => { + const canAccess = hasRoleBasedThresholdAccess( + false, + [], + new Set(['reviewer-member-id']), + 75, + ) + + expect(canAccess) + .toBe(true) + }) + + it('returns false for submitter views when the submitter has not passed the threshold', () => { + const canAccess = hasRoleBasedThresholdAccess( + true, + [], + new Set(['submitter-member-id']), + 75, + ) + + expect(canAccess) + .toBe(false) + }) + + it('returns true for submitter views when an owned submission passes the threshold', () => { + const submitterRows: SubmissionInfo[] = [ + { + aggregateScore: 88, + id: 'submission-id', + memberId: 'submitter-member-id', + }, + ] + + const canAccess = hasRoleBasedThresholdAccess( + true, + submitterRows, + new Set(['submitter-member-id']), + 75, + ) + + expect(canAccess) + .toBe(true) + }) +}) diff --git a/src/apps/review/src/lib/utils/reviewScoring.ts b/src/apps/review/src/lib/utils/reviewScoring.ts index 66e92dd7e..694555226 100644 --- a/src/apps/review/src/lib/utils/reviewScoring.ts +++ b/src/apps/review/src/lib/utils/reviewScoring.ts @@ -183,3 +183,25 @@ export function hasSubmitterPassedThreshold( return false } + +/** + * Resolves threshold visibility based on whether the current view belongs to the submitter. + * + * @param isSubmitterView - Indicates if the current role context is submitter. + * @param submissions - Submission rows used for threshold evaluation. + * @param myMemberIds - Current member ids to identify owned submissions. + * @param minimumPassingScore - Minimum passing score for the current review phase. + * @returns True for non-submitter views, otherwise the submitter threshold result. + */ +export function hasRoleBasedThresholdAccess( + isSubmitterView: boolean, + submissions: Array, + myMemberIds: Set, + minimumPassingScore: number | null | undefined, +): boolean { + if (!isSubmitterView) { + return true + } + + return hasSubmitterPassedThreshold(submissions, myMemberIds, minimumPassingScore) +} diff --git a/src/apps/review/src/lib/utils/reviewSelection.spec.ts b/src/apps/review/src/lib/utils/reviewSelection.spec.ts new file mode 100644 index 000000000..9a5545a33 --- /dev/null +++ b/src/apps/review/src/lib/utils/reviewSelection.spec.ts @@ -0,0 +1,100 @@ +import type { BackendReview, BackendSubmission } from '../models' + +import { collectMatchingReviews, selectBestReview } from './reviewSelection' + +jest.mock('~/config', () => ({ + EnvironmentConfig: {}, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + getRatingColor: jest.fn() + .mockReturnValue('#000000'), +}), { virtual: true }) + +const createReview = ( + id: string, + overrides: Partial = {}, +): BackendReview => { + const baseReview: BackendReview = { + committed: false, + createdAt: '2025-10-24T15:00:00.000Z', + createdBy: 'tester', + finalScore: Number.NaN, + id, + initialScore: Number.NaN, + legacyId: `${id}-legacy`, + legacySubmissionId: 'legacy-submission-1', + metadata: '', + phaseId: 'phase-screening', + resourceId: `${id}-resource`, + reviewDate: '', + scorecardId: 'scorecard-screening', + status: 'PENDING', + submissionId: 'submission-1', + typeId: '', + updatedAt: '2025-10-24T15:00:00.000Z', + updatedBy: 'tester', + } + + return { + ...baseReview, + ...overrides, + } +} + +describe('selectBestReview', () => { + it('prefers completed scored screening review over pending or in-progress alternatives', () => { + const candidateReviews: BackendReview[] = [ + createReview('pending-no-score', { + status: 'PENDING', + updatedAt: '2025-10-24T15:10:00.000Z', + }), + createReview('in-progress-no-score', { + status: 'IN_PROGRESS', + updatedAt: '2025-10-24T15:20:00.000Z', + }), + createReview('completed-scored', { + committed: true, + finalScore: 87, + status: 'COMPLETED', + updatedAt: '2025-10-24T15:05:00.000Z', + }), + ] + + const result = selectBestReview( + candidateReviews, + 'Screening', + 'scorecard-screening', + new Set(['phase-screening']), + {} as BackendSubmission, + ) + + expect(result?.id) + .toBe('completed-scored') + }) + + it('collects challenge reviews when legacy submission ids do not match the modern submission id', () => { + const submission = { + id: 'submission-1', + legacySubmissionId: 'legacy-submission-1', + review: [], + } as unknown as BackendSubmission + + const result = collectMatchingReviews( + submission, + 'Screening', + 'scorecard-screening', + new Set(['phase-screening']), + undefined, + [ + createReview('legacy-match', { + legacySubmissionId: '', + submissionId: 'legacy-submission-1', + }), + ], + ) + + expect(result.map(review => review.id)) + .toEqual(['legacy-match']) + }) +}) diff --git a/src/apps/review/src/lib/utils/reviewSelection.ts b/src/apps/review/src/lib/utils/reviewSelection.ts new file mode 100644 index 000000000..0dbedfff2 --- /dev/null +++ b/src/apps/review/src/lib/utils/reviewSelection.ts @@ -0,0 +1,213 @@ +import { orderBy } from 'lodash' + +import type { BackendReview, BackendSubmission } from '../models' + +import { reviewMatchesPhase } from './reviewMatching' +import { getNumericScore } from './reviewScoring' +import { reviewMatchesSubmission } from './submissionResolution' + +/** + * Compares two phase labels after normalizing case and whitespace. + * + * @param value - Candidate phase label. + * @param target - Expected phase label. + * @returns True when both labels resolve to the same normalized value. + */ +const phaseNameEquals = ( + value: string | null | undefined, + target: string, +): boolean => { + if (typeof value !== 'string') { + return false + } + + const normalizedValue = value.trim() + .toLowerCase() + const normalizedTarget = target.trim() + .toLowerCase() + + return normalizedValue === normalizedTarget +} + +/** + * Determines whether a review belongs to the target phase. + * + * @param review - Review to evaluate. + * @param phaseLabel - Human-readable phase label. + * @param scorecardId - Resolved scorecard id for the target phase. + * @param phaseIds - Resolved phase ids that map to the target phase. + * @returns True when the review can be treated as a candidate for the phase. + */ +const matchesReviewPhaseCandidate = ( + review: BackendReview | undefined, + phaseLabel: string, + scorecardId: string | undefined, + phaseIds: Set, +): boolean => { + if (!review) { + return false + } + + if (reviewMatchesPhase(review, scorecardId, phaseIds, phaseLabel)) { + return true + } + + if (phaseNameEquals((review as { phaseName?: string | null }).phaseName, phaseLabel)) { + return true + } + + const reviewType = (review as { reviewType?: string | null }).reviewType + if (phaseNameEquals(reviewType, phaseLabel)) { + return true + } + + return false +} + +/** + * Collects distinct reviews for a submission that match the requested phase. + * + * @param submission - Submission to gather reviews for. + * @param phaseLabel - Human-readable phase label. + * @param scorecardId - Resolved scorecard id for the target phase. + * @param phaseIds - Resolved phase ids that map to the target phase. + * @param submissionReviewMap - Optional map keyed by submission id. + * @param globalReviews - Optional challenge-level review list. + * @returns Array of unique matching reviews for the submission. + */ +export const collectMatchingReviews = ( + submission: BackendSubmission, + phaseLabel: string, + scorecardId: string | undefined, + phaseIds: Set, + submissionReviewMap?: Map, + globalReviews?: BackendReview[], +): BackendReview[] => { + const seen = new Map() + const pushReview = (review: BackendReview | undefined): void => { + if (!review?.id) { + return + } + + if (!matchesReviewPhaseCandidate(review, phaseLabel, scorecardId, phaseIds)) { + return + } + + if (!seen.has(review.id)) { + seen.set(review.id, review) + } + } + + if (submissionReviewMap?.size) { + pushReview(submissionReviewMap.get(submission.id)) + } + + if (Array.isArray(globalReviews) && globalReviews.length) { + globalReviews.forEach(review => { + if (reviewMatchesSubmission({ + review, + submission, + })) { + pushReview(review) + } + }) + } + + if (submission.reviewResourceMapping) { + Object.values(submission.reviewResourceMapping) + .forEach(review => { + pushReview(review) + }) + } + + if (Array.isArray(submission.review)) { + submission.review.forEach(review => { + pushReview(review) + }) + } + + return Array.from(seen.values()) +} + +/** + * Finds a best-effort phase review in submission-local review data. + * + * @param submission - Submission to inspect. + * @param phaseLabel - Human-readable phase label. + * @param scorecardId - Resolved scorecard id for the target phase. + * @param phaseIds - Resolved phase ids that map to the target phase. + * @returns Matching fallback review when one exists. + */ +const findFallbackReview = ( + submission: BackendSubmission, + phaseLabel: string, + scorecardId: string | undefined, + phaseIds: Set, +): BackendReview | undefined => { + if (!Array.isArray(submission.review)) { + return undefined + } + + const phaseLabelLower = phaseLabel.toLowerCase() + + return submission.review.find(review => { + if (!review) { + return false + } + + if (matchesReviewPhaseCandidate(review, phaseLabel, scorecardId, phaseIds)) { + return true + } + + const typeMatches = typeof review.typeId === 'string' + && review.typeId.toLowerCase() + .includes(phaseLabelLower) + + return typeMatches + }) +} + +/** + * Selects the single best review to represent a submission in table rows. + * + * Selection priority favors committed/completed reviews with concrete scores, + * then the most recently updated candidate. + * + * @param reviews - Candidate reviews already matched to the target phase. + * @param phaseLabel - Human-readable phase label. + * @param scorecardId - Resolved scorecard id for the target phase. + * @param phaseIds - Resolved phase ids that map to the target phase. + * @param submission - Submission context used for fallback lookup. + * @returns The best review candidate or undefined. + */ +export const selectBestReview = ( + reviews: BackendReview[], + phaseLabel: string, + scorecardId: string | undefined, + phaseIds: Set, + submission: BackendSubmission, +): BackendReview | undefined => { + if (!reviews.length) { + return findFallbackReview(submission, phaseLabel, scorecardId, phaseIds) + } + + const sorted = orderBy( + reviews, + [ + (review: BackendReview) => Boolean(review.committed), + (review: BackendReview) => (review.status || '').toUpperCase() === 'COMPLETED', + (review: BackendReview) => { + const score = getNumericScore(review) + return typeof score === 'number' ? score : -Infinity + }, + (review: BackendReview) => { + const updatedAt = review.updatedAt || review.reviewDate || review.createdAt + const parsed = updatedAt ? Date.parse(updatedAt) : NaN + return Number.isFinite(parsed) ? parsed : 0 + }, + ], + ['desc', 'desc', 'desc', 'desc'], + ) + + return sorted[0] +} diff --git a/src/apps/review/src/lib/utils/screeningAssignments.spec.ts b/src/apps/review/src/lib/utils/screeningAssignments.spec.ts new file mode 100644 index 000000000..929a2e6a7 --- /dev/null +++ b/src/apps/review/src/lib/utils/screeningAssignments.spec.ts @@ -0,0 +1,96 @@ +import { Screening } from '../models' + +import { + isViewerAssignedToScreening, + resolveViewerReviewId, + resolveViewerReviewStatus, +} from './screeningAssignments' + +const createScreeningRow = (overrides: Partial = {}): Screening => ({ + challengeId: 'challenge-id', + createdAt: '2025-12-08T18:00:00.000Z', + memberId: 'member-id', + result: '-', + score: 'Pending', + submissionId: 'submission-id', + ...overrides, +}) + +describe('screeningAssignments', () => { + describe('isViewerAssignedToScreening', () => { + it('returns true when myReviewResourceId matches one of my resources', () => { + const row = createScreeningRow({ + myReviewResourceId: 'resource-1', + screenerId: 'resource-2', + }) + const myResourceIds = new Set(['resource-1']) + + expect(isViewerAssignedToScreening(row, myResourceIds)) + .toBe(true) + }) + + it('falls back to screenerId when myReviewResourceId is missing', () => { + const row = createScreeningRow({ + reviewId: 'review-1', + screenerId: 'resource-2', + }) + const myResourceIds = new Set(['resource-2']) + + expect(isViewerAssignedToScreening(row, myResourceIds)) + .toBe(true) + }) + + it('returns false when no assignment ids match my resources', () => { + const row = createScreeningRow({ + myReviewResourceId: 'resource-1', + screenerId: 'resource-2', + }) + const myResourceIds = new Set(['resource-3']) + + expect(isViewerAssignedToScreening(row, myResourceIds)) + .toBe(false) + }) + }) + + describe('resolveViewerReviewId', () => { + it('prefers myReviewId over reviewId', () => { + const row = createScreeningRow({ + myReviewId: 'my-review-id', + reviewId: 'review-id', + }) + + expect(resolveViewerReviewId(row)) + .toBe('my-review-id') + }) + + it('falls back to reviewId when myReviewId is unavailable', () => { + const row = createScreeningRow({ + reviewId: 'review-id', + }) + + expect(resolveViewerReviewId(row)) + .toBe('review-id') + }) + }) + + describe('resolveViewerReviewStatus', () => { + it('prefers myReviewStatus over reviewStatus and normalizes case', () => { + const row = createScreeningRow({ + myReviewStatus: 'completed', + reviewStatus: 'pending', + }) + + expect(resolveViewerReviewStatus(row)) + .toBe('COMPLETED') + }) + + it('falls back to reviewStatus when myReviewStatus is missing', () => { + const row = createScreeningRow({ + reviewStatus: 'submitted', + }) + + expect(resolveViewerReviewStatus(row)) + .toBe('SUBMITTED') + }) + }) +}) diff --git a/src/apps/review/src/lib/utils/screeningAssignments.ts b/src/apps/review/src/lib/utils/screeningAssignments.ts new file mode 100644 index 000000000..3d605e18f --- /dev/null +++ b/src/apps/review/src/lib/utils/screeningAssignments.ts @@ -0,0 +1,64 @@ +import type { Screening } from '../models' + +/** + * Determine whether the current viewer is assigned to the provided screening row. + * + * Usage: + * This helper is used by screening/checkpoint table renderers to decide whether + * action controls should be shown immediately, even when `myReviewResourceId` + * has not been hydrated yet. + * + * @param entry - Screening row candidate containing assignment resource identifiers. + * @param myResourceIds - Resource ids that belong to the current viewer. + * @returns `true` when any assignment resource id in the row matches the viewer. + */ +export const isViewerAssignedToScreening = ( + entry: Pick, + myResourceIds: Set, +): boolean => { + const candidateResourceIds = [ + entry.myReviewResourceId, + entry.screenerId, + ] + .map(id => id?.trim()) + .filter((id): id is string => Boolean(id)) + + return candidateResourceIds.some(id => myResourceIds.has(id)) +} + +/** + * Resolve the review id for row actions and scorecard navigation. + * + * Usage: + * Screening/checkpoint tables prefer `myReviewId` for viewer-specific actions and + * fall back to `reviewId` so the action column remains usable during initial load. + * + * @param entry - Screening row candidate containing review identifiers. + * @returns The first non-empty review id, or `undefined` if none are available. + */ +export const resolveViewerReviewId = ( + entry: Pick, +): string | undefined => { + const candidateReviewIds = [ + entry.myReviewId, + entry.reviewId, + ] + .map(id => id?.trim()) + .filter((id): id is string => Boolean(id)) + + return candidateReviewIds[0] +} + +/** + * Resolve an assignment status string for the current viewer from a screening row. + * + * Usage: + * This normalizes mixed status sources (`myReviewStatus` and `reviewStatus`) so + * table actions can consistently detect completed/submitted assignments. + * + * @param entry - Screening row candidate containing review status fields. + * @returns Upper-cased status string, or an empty string when no status exists. + */ +export const resolveViewerReviewStatus = ( + entry: Pick, +): string => (entry.myReviewStatus ?? entry.reviewStatus ?? '').toUpperCase() diff --git a/src/apps/review/src/lib/utils/submissionResolution.spec.ts b/src/apps/review/src/lib/utils/submissionResolution.spec.ts new file mode 100644 index 000000000..00b205798 --- /dev/null +++ b/src/apps/review/src/lib/utils/submissionResolution.spec.ts @@ -0,0 +1,50 @@ +import type { BackendReview, BackendSubmission } from '../models' + +import { + resolveFallbackSubmissionId, + resolveSubmissionForReview, + reviewMatchesSubmission, +} from './submissionResolution' + +const submission = { + id: 'submission-1', + legacySubmissionId: 'legacy-submission-1', +} as unknown as BackendSubmission + +describe('submissionResolution', () => { + it('resolves submissions when legacy ids are returned through submissionId', () => { + expect(resolveSubmissionForReview({ + review: { + legacySubmissionId: '', + submissionId: 'legacy-submission-1', + } as unknown as BackendReview, + submissionsById: new Map([[submission.id, submission]]), + submissionsByLegacyId: new Map([[submission.legacySubmissionId, submission]]), + })) + .toBe(submission) + }) + + it('matches reviews and submissions across modern and legacy identifiers', () => { + expect(reviewMatchesSubmission({ + review: { + legacySubmissionId: '', + submissionId: 'legacy-submission-1', + } as unknown as BackendReview, + submission, + })) + .toBe(true) + }) + + it('ignores empty submission ids when resolving fallback submission identifiers', () => { + expect(resolveFallbackSubmissionId({ + defaultId: 'default-submission-id', + matchingSubmission: submission, + review: { + id: 'review-1', + legacySubmissionId: 'legacy-submission-1', + submissionId: '', + } as unknown as BackendReview, + })) + .toBe('legacy-submission-1') + }) +}) diff --git a/src/apps/review/src/lib/utils/submissionResolution.ts b/src/apps/review/src/lib/utils/submissionResolution.ts index 2a0ac9746..1e53ba666 100644 --- a/src/apps/review/src/lib/utils/submissionResolution.ts +++ b/src/apps/review/src/lib/utils/submissionResolution.ts @@ -3,6 +3,40 @@ */ import { BackendReview, BackendSubmission, SubmissionInfo } from '../models' +/** + * Normalizes an identifier-like value into a trimmed string. + * + * @param value - Raw identifier value from review or submission payloads. + * @returns Trimmed string identifier or undefined when the value is empty. + */ +function normalizeIdentifier(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined + } + + const normalized = `${value}`.trim() + return normalized.length ? normalized : undefined +} + +/** + * Collects distinct normalized identifiers from a list of raw values. + * + * @param values - Identifier candidates that may be empty or duplicated. + * @returns Unique set of normalized identifiers for downstream matching. + */ +function collectIdentifiers(values: unknown[]): Set { + const identifiers = new Set() + + values.forEach(value => { + const normalized = normalizeIdentifier(value) + if (normalized) { + identifiers.add(normalized) + } + }) + + return identifiers +} + export interface SubmissionLookupArgs { review: BackendReview submissionsById: Map @@ -20,16 +54,20 @@ export function resolveSubmissionForReview({ submissionsById, submissionsByLegacyId, }: SubmissionLookupArgs): BackendSubmission | undefined { - if (review.submissionId) { - const submissionById = submissionsById.get(review.submissionId) + const candidateIds = [ + review.submissionId, + review.legacySubmissionId, + ] + .map(normalizeIdentifier) + .filter((candidate): candidate is string => Boolean(candidate)) + + for (const candidateId of candidateIds) { + const submissionById = submissionsById.get(candidateId) if (submissionById) { return submissionById } - } - if (review.legacySubmissionId) { - const legacyKey = `${review.legacySubmissionId}` - const submissionByLegacyId = submissionsByLegacyId.get(legacyKey) + const submissionByLegacyId = submissionsByLegacyId.get(candidateId) if (submissionByLegacyId) { return submissionByLegacyId } @@ -38,6 +76,44 @@ export function resolveSubmissionForReview({ return undefined } +export interface ReviewSubmissionMatchArgs { + review: Pick + submission: Pick +} + +/** + * Determines whether a review references the supplied submission using either + * modern or legacy identifiers. + * + * @param args - Review and submission identifier fields to compare. + * @returns True when any normalized review identifier matches a submission identifier. + */ +export function reviewMatchesSubmission({ + review, + submission, +}: ReviewSubmissionMatchArgs): boolean { + const reviewIdentifiers = collectIdentifiers([ + review.submissionId, + review.legacySubmissionId, + ]) + + if (!reviewIdentifiers.size) { + return false + } + + const submissionIdentifiers = collectIdentifiers([ + submission.id, + submission.legacySubmissionId, + ]) + + if (!submissionIdentifiers.size) { + return false + } + + return Array.from(reviewIdentifiers) + .some(identifier => submissionIdentifiers.has(identifier)) +} + export interface SubmissionIdResolutionArgs { baseSubmissionInfo?: SubmissionInfo defaultId: string @@ -57,12 +133,12 @@ export function resolveFallbackSubmissionId({ matchingSubmission, review, }: SubmissionIdResolutionArgs): string | undefined { - return review.submissionId - ?? baseSubmissionInfo?.id - ?? (review.legacySubmissionId ? `${review.legacySubmissionId}` : undefined) - ?? review.id - ?? matchingSubmission?.id - ?? defaultId + return normalizeIdentifier(review.submissionId) + ?? normalizeIdentifier(baseSubmissionInfo?.id) + ?? normalizeIdentifier(review.legacySubmissionId) + ?? normalizeIdentifier(review.id) + ?? normalizeIdentifier(matchingSubmission?.id) + ?? normalizeIdentifier(defaultId) } export interface SubmitterMemberIdResolutionArgs { diff --git a/src/apps/review/src/lib/utils/submitterReviewResolution.spec.ts b/src/apps/review/src/lib/utils/submitterReviewResolution.spec.ts new file mode 100644 index 000000000..cb8f24a7f --- /dev/null +++ b/src/apps/review/src/lib/utils/submitterReviewResolution.spec.ts @@ -0,0 +1,104 @@ +import type { BackendReview, BackendSubmission } from '../models' +import { BackendSubmissionStatus } from '../models/BackendSubmissionStatus.enum' + +import { buildSubmitterReviewSubmission } from './submitterReviewResolution' + +jest.mock('~/libs/core', () => ({ + getRatingColor: jest.fn() + .mockReturnValue('#000000'), +}), { virtual: true }) + +const buildReview = (overrides: Partial = {}): BackendReview => ({ + createdAt: '2026-01-01T00:00:00.000Z', + createdBy: 'tester', + finalScore: 90, + id: 'review-1', + initialScore: 90, + legacyId: 'legacy-review-1', + legacySubmissionId: 'legacy-submission-1', + metadata: '', + phaseId: 'phase-review', + phaseName: 'Review', + resourceId: 'reviewer-resource-1', + reviewDate: '2026-01-01T00:00:00.000Z', + scorecardId: 'scorecard-review', + status: 'COMPLETED', + submissionId: 'submission-1', + typeId: 'Review', + updatedAt: '2026-01-01T00:00:00.000Z', + updatedBy: 'tester', + ...overrides, +} as BackendReview) + +const buildSubmission = (overrides: Partial = {}): BackendSubmission => ({ + challengeId: 'challenge-1', + createdAt: '2026-01-01T00:00:00.000Z', + createdBy: 'tester', + esId: 'es-submission-1', + fileSize: undefined, + fileType: 'zip', + finalScore: '90', + id: 'submission-1', + initialScore: '90', + isFileSubmission: true, + isLatest: true, + legacyChallengeId: 1, + legacySubmissionId: 'legacy-submission-1', + legacyUploadId: 'legacy-upload-1', + markForPurchase: false, + memberId: '1001', + placement: 1, + prizeId: 1, + review: [], + reviewSummation: [], + screeningScore: undefined, + status: BackendSubmissionStatus.ACTIVE, + submissionPhaseId: 'phase-submission', + submittedDate: '2026-01-01T00:00:00.000Z', + systemFileName: 'submission.zip', + thurgoodJobId: undefined, + type: 'CONTEST_SUBMISSION', + updatedAt: '2026-01-01T00:00:00.000Z', + updatedBy: 'tester', + uploadId: 'upload-1', + url: 'https://example.com/submission.zip', + userRank: 1, + viewCount: undefined, + virusScan: true, + ...overrides, +} as BackendSubmission) + +describe('buildSubmitterReviewSubmission', () => { + it('builds a fallback submitter review row when legacy reviews cannot be resolved to a submission', () => { + const result = buildSubmitterReviewSubmission({ + defaultId: 'fallback-review-row', + resourceMemberIdMapping: {}, + review: buildReview({ + submissionId: '', + }), + }) + + expect(result) + .toMatchObject({ + id: 'legacy-submission-1', + memberId: '', + reviewTypeId: 'Review', + }) + expect(result?.reviews) + .toHaveLength(1) + }) + + it('still rejects checkpoint submissions when a resolved submission is not contest-based', () => { + const result = buildSubmitterReviewSubmission({ + defaultId: 'fallback-review-row', + matchingSubmission: buildSubmission({ + type: 'CHECKPOINT_SUBMISSION', + }), + resourceMemberIdMapping: {}, + review: buildReview(), + }) + + expect(result) + .toBeUndefined() + }) +}) diff --git a/src/apps/review/src/lib/utils/submitterReviewResolution.ts b/src/apps/review/src/lib/utils/submitterReviewResolution.ts new file mode 100644 index 000000000..165cadaad --- /dev/null +++ b/src/apps/review/src/lib/utils/submitterReviewResolution.ts @@ -0,0 +1,95 @@ +import type { BackendResource } from '../models/BackendResource.model' +import type { BackendReview } from '../models/BackendReview.model' +import type { BackendSubmission } from '../models/BackendSubmission.model' +import { + convertBackendReviewToReviewInfo, +} from '../models/ReviewInfo.model' +import { + convertBackendReviewToReviewResult, +} from '../models/ReviewResult.model' +import { + convertBackendSubmissionToSubmissionInfo, + type SubmissionInfo, +} from '../models/SubmissionInfo.model' +import { isContestSubmissionType } from '../constants' + +import { + resolveFallbackSubmissionId, + resolveSubmitterMemberId, + type SubmissionIdResolutionArgs, + type SubmitterMemberIdResolutionArgs, +} from './submissionResolution' + +export interface BuildSubmitterReviewSubmissionArgs { + defaultId: string + matchingSubmission?: BackendSubmission + resourceMemberIdMapping: Record + review: BackendReview +} + +/** + * Builds a submitter-facing review row from the resolved submission when present, while + * allowing legacy review-only fallbacks when older challenges cannot be mapped back to a + * modern submission record. + * + * @param args - Review row inputs including the matched submission when available. + * @returns Submitter-facing review row or undefined when the matched submission is not contest-based. + */ +export function buildSubmitterReviewSubmission({ + defaultId, + matchingSubmission, + resourceMemberIdMapping, + review, +}: BuildSubmitterReviewSubmissionArgs): SubmissionInfo | undefined { + if (matchingSubmission && !isContestSubmissionType(matchingSubmission.type)) { + return undefined + } + + const submissionWithReview: BackendSubmission | undefined = matchingSubmission + ? { + ...matchingSubmission, + review: [review], + } + : undefined + + const baseSubmissionInfo = submissionWithReview + ? convertBackendSubmissionToSubmissionInfo(submissionWithReview) + : undefined + + const fallbackId = resolveFallbackSubmissionId({ + baseSubmissionInfo, + defaultId, + matchingSubmission, + review, + } satisfies SubmissionIdResolutionArgs) + + if (!fallbackId) { + return undefined + } + + const resolvedMemberId = resolveSubmitterMemberId({ + baseSubmissionInfo, + matchingSubmission, + } satisfies SubmitterMemberIdResolutionArgs) + + const reviewInfo = convertBackendReviewToReviewInfo(review) + const reviewResult = convertBackendReviewToReviewResult(review) + + return { + ...baseSubmissionInfo, + id: fallbackId, + isLatest: baseSubmissionInfo?.isLatest + ?? matchingSubmission?.isLatest + ?? true, + memberId: resolvedMemberId, + review: reviewInfo, + reviews: [reviewResult], + reviewTypeId: review.typeId ?? baseSubmissionInfo?.reviewTypeId, + submittedDate: baseSubmissionInfo?.submittedDate, + submittedDateString: baseSubmissionInfo?.submittedDateString, + userInfo: resolvedMemberId + ? resourceMemberIdMapping[resolvedMemberId] + : undefined, + virusScan: baseSubmissionInfo?.virusScan, + } as SubmissionInfo +} diff --git a/src/apps/review/src/lib/utils/winnerMatching.ts b/src/apps/review/src/lib/utils/winnerMatching.ts new file mode 100644 index 000000000..13a87a513 --- /dev/null +++ b/src/apps/review/src/lib/utils/winnerMatching.ts @@ -0,0 +1,61 @@ +import type { ChallengeWinner, SubmissionInfo } from '../models' + +const toFiniteNumber = (value?: number | null): number | undefined => ( + typeof value === 'number' && Number.isFinite(value) ? value : undefined +) + +const normalizeIdentifier = (value?: string | number | null): string | undefined => { + if (value === undefined || value === null) { + return undefined + } + + const normalized = `${value}`.trim() + return normalized.length ? normalized : undefined +} + +const normalizeHandle = (value?: string | null): string | undefined => { + const normalized = value?.trim() + .toLowerCase() + return normalized?.length ? normalized : undefined +} + +/** + * Determine whether a submission belongs to a winner using progressively looser + * legacy-safe matching. Old challenges can disagree on the member id, but + * placement or submitter handle still identify the winning submission reliably. + * + * @param submission - Submission candidate to evaluate. + * @param winner - Winner row from challenge info. + * @returns True when the submission can be treated as the winner's submission. + */ +export const submissionMatchesWinner = ( + submission: SubmissionInfo, + winner: ChallengeWinner, +): boolean => { + const winnerUserId = normalizeIdentifier(winner.userId) + const submissionMemberId = normalizeIdentifier(submission.memberId) + + if (winnerUserId && submissionMemberId && winnerUserId === submissionMemberId) { + return true + } + + const winnerHandle = normalizeHandle(winner.handle) + const submissionHandles = [ + submission.submitterHandle, + submission.review?.submitterHandle, + submission.userInfo?.memberHandle, + ] + .map(normalizeHandle) + .filter((handle): handle is string => Boolean(handle)) + + if (winnerHandle && submissionHandles.some(handle => handle === winnerHandle)) { + return true + } + + const submissionPlacement = toFiniteNumber(submission.placement ?? undefined) + return Boolean( + typeof winner.placement === 'number' + && Number.isFinite(winner.placement) + && submissionPlacement === winner.placement, + ) +} 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 66992f25d..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 @@ -13,7 +13,6 @@ import { TableLoading } from '~/apps/admin/src/lib' import { handleError } from '~/apps/admin/src/lib/utils' import { EnvironmentConfig } from '~/config' import { BaseModal, Button, InputCheckbox, InputDatePicker, InputText } from '~/libs/ui' -import { NotificationContextType, useNotification } from '~/libs/shared' import { useFetchScreeningReview, @@ -50,9 +49,12 @@ import { import { REVIEWER, SUBMITTER, TAB, TABLE_DATE_FORMAT } from '../../../config/index.config' import { buildPhaseTabs, + collectReopenEligiblePhaseIds, findPhaseByTabLabel, isAppealsPhase, isAppealsResponsePhase, + shouldAllowWinnersTabForPastChallenge, + shouldForceWinnersTabForPastChallenge, } from '../../../lib/utils' import type { PhaseLike, PhaseOrderingOptions } from '../../../lib/utils' import { @@ -69,8 +71,6 @@ interface Props { const normalizePhaseName = (name?: string): string => (name ? name.trim() .toLowerCase() : '') -const SUBMISSION_PHASE_NAMES = new Set(['submission', 'topgear submission']) -const REGISTRATION_PHASE_NAME = 'registration' const POST_MORTEM_PHASE_KEY = 'post-mortem' const isSubmissionDataTab = (label?: string): boolean => { @@ -263,7 +263,6 @@ const isIterativeReviewPhaseName = (name?: string): boolean => (name || '') // eslint-disable-next-line complexity export const ChallengeDetailsPage: FC = (props: Props) => { - const { showBannerNotification, removeNotification }: NotificationContextType = useNotification() const [searchParams, setSearchParams] = useSearchParams() const location = useLocation() const navigate = useNavigate() @@ -770,16 +769,35 @@ export const ChallengeDetailsPage: FC = (props: Props) => { challengeInfo.status, phaseOrderingOptions, ) + const itemsWithoutBlockedWinners = shouldAllowWinnersTabForPastChallenge( + challengeInfo, + approvalReviews, + ) + ? items + : items.filter(item => item.value !== 'Winners') + const itemsWithWinnerFallback = shouldForceWinnersTabForPastChallenge( + challengeInfo, + approvalReviews, + ) + && !itemsWithoutBlockedWinners.some(item => item.value === 'Winners') + ? [ + ...itemsWithoutBlockedWinners, + { + label: 'Winners', + value: 'Winners', + }, + ] + : itemsWithoutBlockedWinners // Only add indicators on active-challenges view if (isPastReviewDetail) { - setTabItems(items) + setTabItems(itemsWithWinnerFallback) return } // Map tab labels to the corresponding phase so we can check whether it is currently open const tabPhaseMap = new Map() - items.forEach(tab => { + itemsWithWinnerFallback.forEach(tab => { tabPhaseMap.set( tab.value, findPhaseByTabLabel(challengePhases, tab.value, phaseOrderingOptions), @@ -852,7 +870,7 @@ export const ChallengeDetailsPage: FC = (props: Props) => { })() // Start with base items; add warnings per label if the viewer has obligations pending - const flagged = items.map(it => { + const flagged = itemsWithWinnerFallback.map(it => { const label = it.value.trim() .toLowerCase() const phaseForTab = tabPhaseMap.get(it.value) @@ -949,6 +967,7 @@ export const ChallengeDetailsPage: FC = (props: Props) => { setTabItems(finalItems) }, [ challengeInfo, + approvalReviews, actionChallengeRole, review, submitterReviews, @@ -1143,49 +1162,10 @@ export const ChallengeDetailsPage: FC = (props: Props) => { return map }, [challengePhases]) - const reopenEligiblePhaseIds = useMemo(() => { - const allowed = new Set() - if (!challengePhases?.length) { - return allowed - } - - const addPhaseIdentifiers = (phase?: BackendPhase): void => { - if (!phase) { - return - } - - if (phase.id) { - allowed.add(phase.id) - } - - if (phase.phaseId) { - allowed.add(phase.phaseId) - } - } - - challengePhases.forEach(phase => { - if (!phase?.isOpen || !phase.predecessor) { - return - } - - allowed.add(phase.predecessor) - addPhaseIdentifiers(phaseLookup.get(phase.predecessor)) - }) - - const hasSubmissionVariantOpen = challengePhases.some(phase => ( - phase?.isOpen && SUBMISSION_PHASE_NAMES.has(normalizePhaseName(phase.name)) - )) - - if (hasSubmissionVariantOpen) { - challengePhases.forEach(phase => { - if (normalizePhaseName(phase?.name) === REGISTRATION_PHASE_NAME) { - addPhaseIdentifiers(phase) - } - }) - } - - return allowed - }, [challengePhases, phaseLookup]) + const reopenEligiblePhaseIds = useMemo( + () => collectReopenEligiblePhaseIds(challengePhases), + [challengePhases], + ) const timelineRows = useMemo(() => { if (phaseOrderingOptions.isTask && !phaseOrderingOptions.isTopgearTask) { @@ -1201,6 +1181,12 @@ export const ChallengeDetailsPage: FC = (props: Props) => { challengeInfo?.status, phaseOrderingOptions, ) + const timelineItems = shouldAllowWinnersTabForPastChallenge( + challengeInfo, + approvalReviews, + ) + ? baseItems + : baseItems.filter(item => item.value !== 'Winners') const seen = new Set() const nowMs = Date.now() @@ -1214,7 +1200,7 @@ export const ChallengeDetailsPage: FC = (props: Props) => { } const rows: ChallengeTimelineRow[] = [] - baseItems.forEach(item => { + timelineItems.forEach(item => { const phase = findPhaseByTabLabel( visibleChallengePhases, item.value, @@ -1259,7 +1245,7 @@ export const ChallengeDetailsPage: FC = (props: Props) => { }) return rows - }, [challengeInfo, phaseOrderingOptions, visibleChallengePhases]) + }, [approvalReviews, challengeInfo, phaseOrderingOptions, visibleChallengePhases]) const setPhaseActionLoading = useCallback((phaseId: string, loading: boolean) => { setPhaseActionLoadingMap(prev => ({ @@ -1800,16 +1786,6 @@ export const ChallengeDetailsPage: FC = (props: Props) => { : undefined const shouldShowChallengeMetaRow = Boolean(statusLabel) || trackTypePills.length > 0 - useEffect(() => { - const notification = showBannerNotification({ - id: 'ai-review-scores-warning', - message: `AI Review Scores are advisory only to provide immediate, - educational, and actionable feedback to members. - AI Review Scores do not influence winner selection.`, - }) - return () => notification && removeNotification(notification.id) - }, [showBannerNotification]) - return ( { }) }, [challengeInfo?.id, mutate, navigate]) - const hasChallengeCopilotRole = useMemo( - () => myChallengeResources.some( - resource => resource.roleName?.toLowerCase() === COPILOT.toLowerCase(), - ), - [myChallengeResources], - ) - const canEditScorecard = useMemo(() => { const challengeStatus = (challengeInfo?.status ?? '') .toString() @@ -203,13 +196,11 @@ const ReviewViewer: FC = () => { reviewInfo?.committed && (hasChallengeAdminRole || hasTopcoderAdminRole - || hasChallengeManagerRole - || hasChallengeCopilotRole), + || hasChallengeManagerRole), ) }, [ challengeInfo?.status, hasChallengeAdminRole, - hasChallengeCopilotRole, hasChallengeManagerRole, hasTopcoderAdminRole, reviewInfo?.committed, diff --git a/src/apps/talent-search/src/components/search-input/SearchInput.tsx b/src/apps/talent-search/src/components/search-input/SearchInput.tsx index a44747def..d8f17c4e2 100644 --- a/src/apps/talent-search/src/components/search-input/SearchInput.tsx +++ b/src/apps/talent-search/src/components/search-input/SearchInput.tsx @@ -5,7 +5,10 @@ import { IconOutline, InputMultiselectOption } from '~/libs/ui' import { InputSkillSelector } from '~/libs/shared' import { UserSkill } from '~/libs/core' -import { SKILL_SEARCH_LIMIT } from '../../config' +import { + SKILL_SEARCH_LIMIT, + SKILL_SEARCH_MINIMUM, +} from '../../config' import styles from './SearchInput.module.scss' @@ -25,6 +28,7 @@ const SearchInput: FC = props => { levels: [], name: s.name, })), [props.skills]) + const canSearch = skills.length >= SKILL_SEARCH_MINIMUM function onChange(ev: any): void { const options = (ev.target.value as unknown) as InputMultiselectOption[] @@ -34,22 +38,30 @@ const SearchInput: FC = props => { }))) } + function handleSearchSubmit(): void { + if (!canSearch) { + return + } + + props.onSearch?.() + } + function handleSearchClick(ev: MouseEvent): void { ev.preventDefault() ev.stopPropagation() - props.onSearch?.() + handleSearchSubmit() } - const searchIcon = useMemo(() => ( + const searchIcon = (
- ), [props.onSearch, skills]) + ) return (
@@ -62,10 +74,15 @@ const SearchInput: FC = props => { dropdownIcon={searchIcon} value={skills} onChange={onChange} - onSubmit={props.onSearch} + onSubmit={handleSearchSubmit} inputRef={props.inputRef} limit={SKILL_SEARCH_LIMIT} /> + {skills.length > 0 && skills.length < SKILL_SEARCH_MINIMUM && ( +
+ {`Please select at least ${SKILL_SEARCH_MINIMUM} skills to search`} +
+ )} {skills.length >= SKILL_SEARCH_LIMIT && (
{`You can only search up to ${SKILL_SEARCH_LIMIT} skills at one time`} diff --git a/src/apps/talent-search/src/config/constants.ts b/src/apps/talent-search/src/config/constants.ts index f97991f4c..b2716a613 100644 --- a/src/apps/talent-search/src/config/constants.ts +++ b/src/apps/talent-search/src/config/constants.ts @@ -1 +1,2 @@ export const SKILL_SEARCH_LIMIT = 7 +export const SKILL_SEARCH_MINIMUM = 2 diff --git a/src/apps/talent-search/src/lib/services/use-fetch-talent-matches.spec.ts b/src/apps/talent-search/src/lib/services/use-fetch-talent-matches.spec.ts new file mode 100644 index 000000000..1f4f9f9a0 --- /dev/null +++ b/src/apps/talent-search/src/lib/services/use-fetch-talent-matches.spec.ts @@ -0,0 +1,50 @@ +import { SKILL_SEARCH_MINIMUM } from '../../config' + +import { + canSearchTalentMatches, + isTalentSearchLoading, +} from './use-fetch-talent-matches' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + API: { + V6: 'https://api.topcoder.com/v6', + }, + }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + xhrGetPaginatedAsync: jest.fn(), +}), { virtual: true }) + +jest.mock('swr', () => jest.fn()) + +const createSkills = (count: number): Array<{ id: string; name: string }> => ( + Array.from({ length: count }, (_, index) => ({ + id: `skill-${index + 1}`, + name: `Skill ${index + 1}`, + })) +) + +describe('useFetchTalentMatches', () => { + it('requires at least the configured minimum number of skills to search', () => { + expect(canSearchTalentMatches(createSkills(SKILL_SEARCH_MINIMUM - 1) as any)) + .toBe(false) + expect(canSearchTalentMatches(createSkills(SKILL_SEARCH_MINIMUM) as any)) + .toBe(true) + }) + + it('shows loading only when search can run and no data or error exists', () => { + expect(isTalentSearchLoading(true, undefined, undefined)) + .toBe(true) + expect(isTalentSearchLoading(false, undefined, undefined)) + .toBe(false) + expect(isTalentSearchLoading(true, { data: [] } as any, undefined)) + .toBe(false) + }) + + it('exits loading state when the search request fails', () => { + expect(isTalentSearchLoading(true, undefined, new Error('request failed'))) + .toBe(false) + }) +}) diff --git a/src/apps/talent-search/src/lib/services/use-fetch-talent-matches.ts b/src/apps/talent-search/src/lib/services/use-fetch-talent-matches.ts index d571538fd..5583abc85 100644 --- a/src/apps/talent-search/src/lib/services/use-fetch-talent-matches.ts +++ b/src/apps/talent-search/src/lib/services/use-fetch-talent-matches.ts @@ -2,9 +2,12 @@ import { uniqBy } from 'lodash' import { useCallback, useEffect, useMemo, useState } from 'react' import useSWR, { SWRResponse } from 'swr' +import type { PaginatedResponse, UserSkill } from '~/libs/core' import { EnvironmentConfig } from '~/config' -import { PaginatedResponse, UserSkill, xhrGetPaginatedAsync } from '~/libs/core' -import Member from '@talentSearch/lib/models/Member' +import { xhrGetPaginatedAsync } from '~/libs/core' +import type Member from '@talentSearch/lib/models/Member' + +import { SKILL_SEARCH_MINIMUM } from '../../config' export interface TalentMatchesResponse { error: boolean, @@ -16,11 +19,24 @@ export interface TalentMatchesResponse { totalPages: number, } +export function canSearchTalentMatches(skills: ReadonlyArray): boolean { + return skills.length >= SKILL_SEARCH_MINIMUM +} + +export function isTalentSearchLoading( + canSearch: boolean, + data: PaginatedResponse | undefined, + error: unknown, +): boolean { + return canSearch && !error && !data?.data +} + export function useFetchTalentMatches( skills: ReadonlyArray, page: number, pageSize: number, ): TalentMatchesResponse { + const canSearch = canSearchTalentMatches(skills) const searchParams = [ ...skills.map(s => `id=${s.id}`), 'sortBy=skillScore', @@ -31,17 +47,21 @@ export function useFetchTalentMatches( const url = `${EnvironmentConfig.API.V6}/members/searchBySkills?${searchParams}` - const { data, error }: SWRResponse> = useSWR(url, xhrGetPaginatedAsync, { - isPaused: () => !skills?.length, - refreshInterval: 0, - revalidateOnFocus: false, - }) + const { data, error }: SWRResponse, unknown> = useSWR( + url, + xhrGetPaginatedAsync, + { + isPaused: () => !canSearch, + refreshInterval: 0, + revalidateOnFocus: false, + }, + ) const matches = useMemo(() => data?.data ?? [], [data]) return { error: !!error, - loading: !data?.data, + loading: isTalentSearchLoading(canSearch, data, error), matches: matches ?? [], page: data?.page ?? 0, ready: !!data?.data, diff --git a/src/apps/talent-search/src/routes/search-page/SearchPage.tsx b/src/apps/talent-search/src/routes/search-page/SearchPage.tsx index 9db748e30..d129fcc80 100644 --- a/src/apps/talent-search/src/routes/search-page/SearchPage.tsx +++ b/src/apps/talent-search/src/routes/search-page/SearchPage.tsx @@ -7,6 +7,7 @@ import { ContentLayout, IconOutline } from '~/libs/ui' import { SearchInput } from '../../components/search-input' import { PopularSkills } from '../../components/popular-skills' import { TALENT_SEARCH_PATHS } from '../../talent-search.routes' +import { SKILL_SEARCH_MINIMUM } from '../../config' import { encodeUrlQuerySearch } from '../../lib/utils/search-query' import styles from './SearchPage.module.scss' @@ -20,6 +21,10 @@ export const SearchPage: FC = () => { const [skillsFilter, setSkillsFilter] = useState([]) function navigateToResults(): void { + if (skillsFilter.length < SKILL_SEARCH_MINIMUM) { + return + } + const searchParams = encodeUrlQuerySearch(skillsFilter) navigate(`${TALENT_SEARCH_PATHS.results}?${searchParams}`) } diff --git a/src/apps/talent-search/src/routes/search-results-page/SearchResultsPage.tsx b/src/apps/talent-search/src/routes/search-results-page/SearchResultsPage.tsx index 501f15b86..e96405d10 100644 --- a/src/apps/talent-search/src/routes/search-results-page/SearchResultsPage.tsx +++ b/src/apps/talent-search/src/routes/search-results-page/SearchResultsPage.tsx @@ -1,17 +1,20 @@ import { FC, useCallback, useEffect, useState } from 'react' import classNames from 'classnames' +import { EnvironmentConfig } from '~/config' import { Button, ContentLayout, LinkButton, LoadingCircles } from '~/libs/ui' import { HowSkillsWorkModal } from '~/libs/shared' import { TalentCard } from '../../components/talent-card' import { SearchInput } from '../../components/search-input' -import { useUrlQuerySearchParms } from '../../lib/utils/search-query' import { InfiniteTalentMatchesResposne, useInfiniteTalentMatches, } from '../../lib/services' +import { useUrlQuerySearchParms } from '../../lib/utils/search-query' +import { SKILL_SEARCH_MINIMUM } from '../../config' +import { getLetsTalkUrl } from './letsTalkUrl' import styles from './SearchResultsPage.module.scss' const SearchResultsPage: FC = () => { @@ -91,6 +94,10 @@ const SearchResultsPage: FC = () => { Search thousands of skills to match with our global experts. + ) : skills.length < SKILL_SEARCH_MINIMUM ? ( + + {`Please select at least ${SKILL_SEARCH_MINIMUM} skills to search`} + ) : !total ? (
@@ -104,7 +111,7 @@ const SearchResultsPage: FC = () => { primary size='lg' label='TALK TO AN EXPERT' - to='https://go.topcoder.com/lets-talk/' + to={getLetsTalkUrl(EnvironmentConfig.TOPCODER_URL)} />
diff --git a/src/apps/talent-search/src/routes/search-results-page/letsTalkUrl.spec.ts b/src/apps/talent-search/src/routes/search-results-page/letsTalkUrl.spec.ts new file mode 100644 index 000000000..76e6110a3 --- /dev/null +++ b/src/apps/talent-search/src/routes/search-results-page/letsTalkUrl.spec.ts @@ -0,0 +1,13 @@ +import { getLetsTalkUrl } from './letsTalkUrl' + +describe('getLetsTalkUrl', () => { + it('returns the lets-talk url for dev domain', () => { + expect(getLetsTalkUrl('https://www.topcoder-dev.com')) + .toBe('https://www.topcoder-dev.com/lets-talk') + }) + + it('returns the lets-talk url for prod domain', () => { + expect(getLetsTalkUrl('https://www.topcoder.com')) + .toBe('https://www.topcoder.com/lets-talk') + }) +}) diff --git a/src/apps/talent-search/src/routes/search-results-page/letsTalkUrl.ts b/src/apps/talent-search/src/routes/search-results-page/letsTalkUrl.ts new file mode 100644 index 000000000..381043919 --- /dev/null +++ b/src/apps/talent-search/src/routes/search-results-page/letsTalkUrl.ts @@ -0,0 +1 @@ +export const getLetsTalkUrl = (topcoderUrl: string): string => `${topcoderUrl}/lets-talk` diff --git a/src/apps/wallet-admin/src/home/tabs/WalletAdminTabs.module.scss b/src/apps/wallet-admin/src/home/tabs/WalletAdminTabs.module.scss index 30e3c9b0c..29d5112be 100644 --- a/src/apps/wallet-admin/src/home/tabs/WalletAdminTabs.module.scss +++ b/src/apps/wallet-admin/src/home/tabs/WalletAdminTabs.module.scss @@ -1,6 +1,8 @@ @import '@libs/ui/styles/includes'; .container { + width: 100%; + form { @include ltelg { :global(.input-el) { diff --git a/src/apps/wallet-admin/src/home/tabs/home/Home.module.scss b/src/apps/wallet-admin/src/home/tabs/home/Home.module.scss index b58cd7ece..75cce850c 100644 --- a/src/apps/wallet-admin/src/home/tabs/home/Home.module.scss +++ b/src/apps/wallet-admin/src/home/tabs/home/Home.module.scss @@ -16,17 +16,30 @@ justify-content: center; align-items: center; flex-wrap: wrap; + & > * { flex: 1 1 auto; display: flex; justify-content: center; align-items: center; + min-width: 0; } } @media (max-width: 768px) { + .container { + padding: $sp-4; + gap: $sp-6; + } + .banner { flex-direction: column; + gap: $sp-4; + } + + .info-row-container { + padding-left: 0; + padding-right: 0; } } diff --git a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx new file mode 100644 index 000000000..de61dffad --- /dev/null +++ b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx @@ -0,0 +1,451 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, react/jsx-no-bind */ +import React from 'react' +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' + +import { editPayment, getMemberHandle, getPayments } from '../../../lib/services/wallet' +import PaymentsListView from './PaymentsListView' + +const mockFilterBar = jest.fn((props: any) => ( +
+ {props.selectedValueOverrides?.status ?? ''} + {(props.selectionActions ?? []).map((action: any) => ( + + ))} +
+)) + +jest.mock('../../../lib/services/wallet', () => ({ + editPayment: jest.fn(), + exportSearchResults: jest.fn(), + getMemberHandle: jest.fn(), + getPayments: jest.fn(), +})) + +jest.mock('../../../lib', () => ({ + FilterBar: (props: any) => mockFilterBar(props), + formatIOSDateString: (value: string) => value, + PaymentView: () =>
Payment View
, +})) + +jest.mock('../../../lib/components/payment-edit/PaymentEdit', () => ({ + __esModule: true, + default: function PaymentEdit() { + return
Payment Edit
+ }, +})) + +jest.mock('../../../lib/components/payments-table/PaymentTable', () => ({ + __esModule: true, + default: function PaymentTable(props: any) { + return ( +
+
Payment Table
+ {props.payments.map((payment: any) => ( + + ))} + {props.payments.length > 1 && ( + + )} +
+ ) + }, +})) + +jest.mock('~/libs/ui', () => ({ + Collapsible: (props: { + children: React.ReactNode + header: React.ReactNode + }) => ( +
+ {props.header} + {props.children} +
+ ), + ConfirmModal: (props: any) => ( + props.open + ? ( +
+

{props.title}

+ {props.children} + {props.showButtons !== false && ( + + )} +
+ ) + : undefined + ), + InputText: (props: any) => ( + + ), + LoadingCircles: () =>
Loading
, +}), { virtual: true }) + +jest.mock('~/libs/shared', () => ({ + downloadBlob: jest.fn(), +}), { virtual: true }) + +jest.mock('axios', () => ({ + AxiosError: class AxiosError extends Error {}, +})) + +const mockedGetPayments = getPayments as jest.MockedFunction +const mockedGetMemberHandle = getMemberHandle as jest.MockedFunction +const mockedEditPayment = editPayment as jest.MockedFunction + +const emptyPaymentsResponse = { + pagination: { + currentPage: 1, + pageSize: 10, + totalItems: 0, + totalPages: 0, + }, + winnings: [], +} + +const paymentsResponse = { + pagination: { + currentPage: 1, + pageSize: 10, + totalItems: 2, + totalPages: 1, + }, + winnings: [ + { + attributes: { + assignmentId: 'assignment-1', + }, + category: 'ENGAGEMENT_PAYMENT', + createdAt: '2026-04-02T00:00:00.000Z', + datePaid: '', + description: 'V6 project - test eng prj ch - Week Ending: Apr 04, 2026', + details: [{ + currency: 'USD', + datePaid: '', + grossAmount: '2400', + id: 'detail-1', + installmentNumber: 1, + status: 'ON_HOLD_ADMIN', + totalAmount: '2400', + }], + externalId: 'engagement-1', + handle: '', + id: 'winning-1', + origin: 'MANUAL', + paymentStatus: { + payoutSetupComplete: true, + taxFormSetupComplete: true, + }, + releaseDate: '2026-04-17T00:00:00.000Z', + title: 'Winning 1', + type: 'PAYMENT', + winnerId: '111', + }, + { + attributes: { + assignmentId: 'assignment-2', + }, + category: 'ENGAGEMENT_PAYMENT', + createdAt: '2026-04-01T00:00:00.000Z', + datePaid: '', + description: 'SK project1 - Test engagement tm - Week Ending: Apr 04, 2026', + details: [{ + currency: 'USD', + datePaid: '', + grossAmount: '9600', + id: 'detail-2', + installmentNumber: 1, + status: 'ON_HOLD_ADMIN', + totalAmount: '9600', + }], + externalId: 'engagement-2', + handle: '', + id: 'winning-2', + origin: 'MANUAL', + paymentStatus: { + payoutSetupComplete: true, + taxFormSetupComplete: true, + }, + releaseDate: '2026-04-17T00:00:00.000Z', + title: 'Winning 2', + type: 'PAYMENT', + winnerId: '222', + }, + ], +} + +describe('PaymentsListView', () => { + beforeEach(() => { + mockFilterBar.mockClear() + mockedGetPayments.mockResolvedValue(emptyPaymentsResponse) + mockedGetMemberHandle.mockResolvedValue(new Map()) + mockedEditPayment.mockResolvedValue('Successfully updated winnings') + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('defaults the engagement approver view to the On Hold (Admin) status filter', async () => { + render( + , + ) + + await screen.findByText('No payments match your filters.') + + expect(mockedGetPayments) + .toHaveBeenLastCalledWith(10, 0, { + 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', + })) + }) + + it('defaults the wipro taas admin view to TAAS scoped category filters', async () => { + render( + , + ) + + await screen.findByText('Member earnings will appear here.') + + expect(mockedGetPayments) + .toHaveBeenLastCalledWith(10, 0, { + category: ['TAAS_PAYMENT'], + }) + expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) + .toEqual(expect.objectContaining({ + category: 'TAAS_PAYMENT', + })) + }) + + it('applies the default approver status after switching from admin view', async () => { + render( + , + ) + + await screen.findByText('Member earnings will appear here.') + + expect(mockedGetPayments) + .toHaveBeenLastCalledWith(10, 0, {}) + + fireEvent.click(screen.getByRole('button', { name: 'Engagement Approver View' })) + + await screen.findByText('No payments match your filters.') + + expect(mockedGetPayments) + .toHaveBeenLastCalledWith(10, 0, { + 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', + })) + }) + + it('keeps payment admins with Wipro TaaS Admin access on the unrestricted admin view', async () => { + render( + , + ) + + await screen.findByText('Member earnings will appear here.') + + expect(mockedGetPayments) + .toHaveBeenLastCalledWith(10, 0, {}) + expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) + .toEqual({ + category: 'all', + date: 'all', + status: 'all', + }) + }) + + it('lets an explicit status filter override the default approver status', async () => { + render( + , + ) + + await screen.findByText('No payments match your filters.') + + await act(async () => { + mockFilterBar.mock.calls.at(-1)?.[0].onFilterChange('status', ['PAID']) + }) + + await waitFor(() => { + expect(mockedGetPayments) + .toHaveBeenLastCalledWith(10, 0, { + category: ['ENGAGEMENT_PAYMENT'], + status: ['PAID'], + }) + }) + + expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) + .toEqual(expect.objectContaining({ + category: 'ENGAGEMENT_PAYMENT', + status: 'PAID', + })) + }) + + it('lets an explicit status filter apply within the wipro taas admin view', async () => { + render( + , + ) + + await screen.findByText('Member earnings will appear here.') + + await act(async () => { + mockFilterBar.mock.calls.at(-1)?.[0].onFilterChange('status', ['PAID']) + }) + + await waitFor(() => { + expect(mockedGetPayments) + .toHaveBeenLastCalledWith(10, 0, { + category: ['TAAS_PAYMENT'], + status: ['PAID'], + }) + }) + + expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides) + .toEqual(expect.objectContaining({ + category: 'TAAS_PAYMENT', + status: 'PAID', + })) + }) + + 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'], + [222, 'liuliquan'], + ])) + + render( + , + ) + + await screen.findByText('Payment Table') + + fireEvent.click(screen.getByRole('button', { name: 'Select All Payments' })) + + await waitFor(() => { + expect(mockFilterBar.mock.calls.at(-1)?.[0].selectionActions.map((action: any) => action.label)) + .toEqual([ + 'Approve (2)', + 'Reject (2)', + ]) + }) + + act(() => { + mockFilterBar.mock.calls.at(-1)?.[0].selectionActions[1].onClick() + }) + + expect(screen.queryByText('Reject Payments')) + .not.toBeNull() + + const confirmButton = screen.getByRole('button', { name: 'Reject' }) + expect(confirmButton.hasAttribute('disabled')) + .toBe(true) + + fireEvent.change(screen.getByRole('textbox', { name: 'Audit Note' }), { + target: { value: 'Reject these invalid engagement winnings.' }, + }) + + expect(confirmButton.hasAttribute('disabled')) + .toBe(false) + + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(mockedEditPayment) + .toHaveBeenCalledTimes(2) + }) + + expect(mockedEditPayment) + .toHaveBeenNthCalledWith(1, { + auditNote: 'Reject these invalid engagement winnings.', + paymentStatus: 'CANCELLED', + winningsId: 'winning-1', + }) + expect(mockedEditPayment) + .toHaveBeenNthCalledWith(2, { + auditNote: 'Reject these invalid engagement winnings.', + paymentStatus: 'CANCELLED', + winningsId: 'winning-2', + }) + }) + + it('includes the topgear payment type in the category filter options', async () => { + render( + , + ) + + await screen.findByText('Member earnings will appear here.') + + const filterProps = mockFilterBar.mock.calls.at(-1)?.[0] + const typeFilter = filterProps.filters.find((filter: any) => filter.key === 'category') + + expect(typeFilter.options.some((option: any) => ( + option.value === 'TOPGEAR_PAYMENT' && option.label === 'Topgear Payment' + ))) + .toBe(true) + }) +}) 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 6fd4a5fe9..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,9 +19,13 @@ import PaymentsTable from '../../../lib/components/payments-table/PaymentTable' import styles from './Payments.module.scss' -type PaymentRoleView = 'admin' | 'engagementApprover' +type PaymentRoleView = 'admin' | 'engagementApprover' | 'wiproTaasAdmin' +type SelectedPaymentAction = 'approve' | 'reject' const engagementPaymentCategory = 'ENGAGEMENT_PAYMENT' +const restrictedRoleDefaultStatus = 'ON_HOLD_ADMIN' +const taasPaymentCategory = 'TAAS_PAYMENT' +const topgearPaymentCategory = 'TOPGEAR_PAYMENT' const defaultPageSize = 10 interface PaymentsListViewProps { @@ -70,11 +74,106 @@ const formatCurrency = (amountStr: string, currency: string): string => { .format(amount) } +/** + * Extracts the assignment identifier captured on a finance winning row. + * + * @param payment raw finance winning row from the wallet-admin listing API. + * @returns the normalized assignment identifier when present. + * @remarks Wallet-admin reuses this identifier to recover engagement details + * directly from the engagements API when finance omits them. + */ +function getWinningAssignmentId(payment: WinningDetail): string | undefined { + const assignmentId = payment.attributes?.assignmentId + + return assignmentId !== undefined && assignmentId !== null + ? String(assignmentId) + : undefined +} + +/** + * Converts a raw finance winning row into the wallet-admin view model. + * + * @param payment raw finance winning row returned by the payouts API. + * @param handleMap member-handle lookup keyed by winner identifier. + * @returns the normalized winning record rendered by the payments table. + * @remarks The release date and hold status are derived here to keep the + * component-level mapping callback trivial. + */ +// eslint-disable-next-line complexity +function convertPaymentToWinning(payment: WinningDetail, handleMap: Map): Winning { + const now = new Date() + const releaseDate = new Date(payment.releaseDate) + const diffMs = releaseDate.getTime() - now.getTime() + const diffHours = diffMs / (1000 * 60 * 60) + + let formattedReleaseDate + if (diffHours > 0 && diffHours <= 24) { + const diffMinutes = diffMs / (1000 * 60) + const hours = Math.floor(diffHours) + const minutes = Math.round(diffMinutes - hours * 60) + formattedReleaseDate = `${minutes} minute${minutes !== 1 ? 's' : ''}` + if (hours > 0) { + formattedReleaseDate = `In ${hours} hour${hours !== 1 ? 's' : ''} ${formattedReleaseDate}` + } else if (minutes > 0) { + formattedReleaseDate = `In ${minutes} minute${minutes !== 1 ? 's' : ''}` + } + } else { + formattedReleaseDate = formatIOSDateString(payment.releaseDate) + } + + let status = formatStatus(payment.details[0].status) + if (status === 'Cancel') { + status = 'Cancelled' + } + + if (status === 'ON_HOLD') { + if (!payment.paymentStatus?.payoutSetupComplete) { + status = 'On Hold (Payment Provider)' + } else if (!payment.paymentStatus?.taxFormSetupComplete) { + status = 'On Hold (Tax Form)' + } else { + status = 'On Hold (Member)' + } + } + + return { + assignmentId: getWinningAssignmentId(payment), + createDate: formatIOSDateString(payment.createdAt), + currency: payment.details[0].currency, + datePaid: payment.details[0].datePaid ? formatIOSDateString(payment.details[0].datePaid) : '-', + description: payment.description, + details: payment.details, + externalId: payment.externalId, + grossAmount: formatCurrency(payment.details[0].grossAmount, payment.details[0].currency), + grossAmountNumber: parseFloat(payment.details[0].grossAmount), + handle: handleMap.get(parseInt(payment.winnerId, 10)) ?? payment.winnerId, + id: payment.id, + releaseDate: formattedReleaseDate, + releaseDateObj: releaseDate, + status, + type: payment.category.replaceAll('_', ' ') + .toLowerCase(), + winnerId: payment.winnerId, + } +} + // eslint-disable-next-line complexity const PaymentsListView: FC = (props: PaymentsListViewProps) => { - const isPaymentAdmin = props.profile.roles.includes('Payment Admin') - const isEngagementPaymentApprover = props.profile.roles.includes('Engagement Payment Approver') - const canToggleRoleView = isPaymentAdmin && isEngagementPaymentApprover + const normalizedRoles = React.useMemo( + () => new Set((props.profile.roles || []).map(role => role.trim() + .toLowerCase())), + [props.profile.roles], + ) + const hasRole = useCallback( + (...roles: string[]) => roles.some(role => normalizedRoles.has(role.trim() + .toLowerCase())), + [normalizedRoles], + ) + const isWiproTaasAdmin = hasRole('Wipro TaaS Admin') + const hasPaymentAdminRole = hasRole('Payment Admin') + const isPaymentAdmin = hasPaymentAdminRole || isWiproTaasAdmin + 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>([]) @@ -87,17 +186,57 @@ const PaymentsListView: FC = (props: PaymentsListViewProp const isEngagementApproverView = isEngagementPaymentApprover && ( !isPaymentAdmin || paymentRoleView === 'engagementApprover' ) + + 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>(() => { - if (!isEngagementApproverView) { + if (!restrictedCategory) { return filters } return { ...filters, - category: [engagementPaymentCategory], + category: [restrictedCategory], + ...(hasSelectedStatusFilter + ? { status: filters.status } + : (restrictedDefaultStatus ? { status: [restrictedDefaultStatus] } : {})), + } + }, [filters, hasSelectedStatusFilter, restrictedCategory, restrictedDefaultStatus]) + const hasActiveFilters = React.useMemo( + () => Object.entries(appliedFilters) + .some(([key, value]) => key !== 'category' && value.length > 0), + [appliedFilters], + ) + const selectedValueOverrides = React.useMemo>(() => { + if (!restrictedCategory) { + return {} as Record + } + + const statusOverride = filters.status?.[0] ?? restrictedDefaultStatus + + return { + category: restrictedCategory, + ...(statusOverride ? { status: statusOverride } : {}), + } + }, [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' } - }, [filters, isEngagementApproverView]) + + defaults.date = filters.date?.[0] ?? 'all' + + return defaults + }, [filters.category, filters.date, filters.status, restrictedCategory]) const [pagination, setPagination] = React.useState({ currentPage: 1, pageSize: defaultPageSize, @@ -133,60 +272,8 @@ const PaymentsListView: FC = (props: PaymentsListViewProp }, []) const convertToWinnings = useCallback( - (payments: WinningDetail[], handleMap: Map): ReadonlyArray => payments.map(payment => { - const now = new Date() - const releaseDate = new Date(payment.releaseDate) - const diffMs = releaseDate.getTime() - now.getTime() - const diffHours = diffMs / (1000 * 60 * 60) - - let formattedReleaseDate - if (diffHours > 0 && diffHours <= 24) { - const diffMinutes = diffMs / (1000 * 60) - const hours = Math.floor(diffHours) - const minutes = Math.round(diffMinutes - hours * 60) - formattedReleaseDate = `${minutes} minute${minutes !== 1 ? 's' : ''}` - if (hours > 0) { - formattedReleaseDate = `In ${hours} hour${hours !== 1 ? 's' : ''} ${formattedReleaseDate}` - } else if (minutes > 0) { - formattedReleaseDate = `In ${minutes} minute${minutes !== 1 ? 's' : ''}` - } - } else { - formattedReleaseDate = formatIOSDateString(payment.releaseDate) - } - - let status = formatStatus(payment.details[0].status) - if (status === 'Cancel') { - status = 'Cancelled' - } - - if (status === 'ON_HOLD') { - if (!payment.paymentStatus?.payoutSetupComplete) { - status = 'On Hold (Payment Provider)' - } else if (!payment.paymentStatus?.taxFormSetupComplete) { - status = 'On Hold (Tax Form)' - } else { - status = 'On Hold (Member)' - } - } - - return { - createDate: formatIOSDateString(payment.createdAt), - currency: payment.details[0].currency, - datePaid: payment.details[0].datePaid ? formatIOSDateString(payment.details[0].datePaid) : '-', - description: payment.description, - details: payment.details, - externalId: payment.externalId, - grossAmount: formatCurrency(payment.details[0].grossAmount, payment.details[0].currency), - grossAmountNumber: parseFloat(payment.details[0].grossAmount), - handle: handleMap.get(parseInt(payment.winnerId, 10)) ?? payment.winnerId, - id: payment.id, - releaseDate: formattedReleaseDate, - releaseDateObj: releaseDate, - status, - type: payment.category.replaceAll('_', ' ') - .toLowerCase(), - } - }), + (payments: WinningDetail[], handleMap: Map): ReadonlyArray => payments + .map(payment => convertPaymentToWinning(payment, handleMap)), [], ) @@ -319,14 +406,15 @@ const PaymentsListView: FC = (props: PaymentsListViewProp props.profile.roles.includes('Payment Admin') || props.profile.roles.includes('Payment BA Admin') || props.profile.roles.includes('Payment Editor') + || props.profile.roles.includes('Wipro TaaS Admin') ) - const [bulkOpen, setBulkOpen] = React.useState(false) + const [selectedPaymentAction, setSelectedPaymentAction] = React.useState(undefined) const [bulkAuditNote, setBulkAuditNote] = React.useState('') /** - * Switches the payments page between the full admin view and the engagement-only approver view. - * The engagement category is derived from the selected role view, so shared filters stay reusable + * Switches the payments page between the full admin view and scoped approver views. + * The role-scoped category is derived from the selected role view, so shared filters stay reusable * and hidden type restrictions do not leak across view changes. */ const onRoleViewChange = useCallback((nextView: PaymentRoleView) => { @@ -336,7 +424,7 @@ const PaymentsListView: FC = (props: PaymentsListViewProp setPaymentRoleView(nextView) setBulkAuditNote('') - setBulkOpen(false) + setSelectedPaymentAction(undefined) setSelectedPayments({}) setPagination(prev => ({ ...prev, @@ -350,17 +438,35 @@ const PaymentsListView: FC = (props: PaymentsListViewProp }) }, [paymentRoleView]) - const onBulkApprove = useCallback(async (auditNote: string) => { + /** + * Applies the selected approver action to the current payment selection. + * + * @param action whether the selection should be approved or rejected. + * @param auditNote approver note captured in the confirmation modal. + * @returns a promise that resolves after the updates and list refresh finish. + * @remarks Engagement approvers can only update On Hold (Admin) rows from + * the scoped view, so this handler maps the UI action directly to finance + * status transitions without exposing extra edit fields. + */ + const onSelectedPaymentsUpdate = useCallback(async ( + action: SelectedPaymentAction, + auditNote: string, + ) => { const ids = Object.keys(selectedPayments) if (ids.length === 0) return - toast.success('Starting bulk approve', { position: toast.POSITION.BOTTOM_RIGHT }) + const isRejectAction = action === 'reject' + const nextPaymentStatus = isRejectAction ? 'CANCELLED' : 'OWED' + const actionVerb = isRejectAction ? 'reject' : 'approve' + const actionResult = isRejectAction ? 'rejected' : 'approved' + + toast.success(`Starting ${ids.length > 1 ? 'bulk ' : ''}${actionVerb}`, { position: toast.POSITION.BOTTOM_RIGHT }) let successfullyUpdated = 0 for (const id of ids) { const updates: any = { auditNote, - paymentStatus: 'OWED', + paymentStatus: nextPaymentStatus, winningsId: id, } @@ -373,23 +479,44 @@ const PaymentsListView: FC = (props: PaymentsListViewProp } catch (err:any) { const paymentName = selectedPayments[id]?.handle || id if (err?.message) { - toast.error(`Failed to update payment ${paymentName} (${id}): ${err.message}`, { position: toast.POSITION.BOTTOM_RIGHT }) + toast.error(`Failed to ${actionVerb} payment ${paymentName} (${id}): ${err.message}`, { position: toast.POSITION.BOTTOM_RIGHT }) } else { - toast.error(`Failed to update payment ${paymentName} (${id})`, { position: toast.POSITION.BOTTOM_RIGHT }) + toast.error(`Failed to ${actionVerb} payment ${paymentName} (${id})`, { position: toast.POSITION.BOTTOM_RIGHT }) } } } - if (successfullyUpdated === ids.length) { - toast.success(`Successfully updated ${successfullyUpdated} winnings`, { position: toast.POSITION.BOTTOM_RIGHT }) + if (successfullyUpdated > 0) { + toast.success( + `Successfully ${actionResult} ${successfullyUpdated} winning${successfullyUpdated === 1 ? '' : 's'}`, + { position: toast.POSITION.BOTTOM_RIGHT }, + ) } setBulkAuditNote('') - setBulkOpen(false) + setSelectedPaymentAction(undefined) setSelectedPayments({}) await fetchWinnings() }, [selectedPayments, fetchWinnings]) + const selectedPaymentActions = selectedPaymentsCount > 0 + ? [ + { + appearance: 'primary' as const, + key: 'approve-selected-payments', + label: `Approve (${selectedPaymentsCount})`, + onClick: () => setSelectedPaymentAction('approve'), + }, + { + appearance: 'secondary' as const, + key: 'reject-selected-payments', + label: `Reject (${selectedPaymentsCount})`, + onClick: () => setSelectedPaymentAction('reject'), + variant: 'danger' as const, + }, + ] + : [] + return ( <> = (props: PaymentsListViewProp
- + {isEngagementPaymentApprover && ( + + )}
)} setBulkOpen(true)} - hasActiveFilters={Object.values(filters) - .some(value => value.length > 0)} + selectionActions={selectedPaymentActions} + hasActiveFilters={hasActiveFilters} onExport={async () => { toast.success('Downloading payments report. This may take a few moments.', { position: toast.POSITION.BOTTOM_RIGHT }) downloadBlob( @@ -435,9 +563,7 @@ const PaymentsListView: FC = (props: PaymentsListViewProp ) toast.success('Download complete', { position: toast.POSITION.BOTTOM_RIGHT }) }} - selectedValueOverrides={{ - category: isEngagementApproverView ? engagementPaymentCategory : filters.category?.[0] ?? '', - }} + selectedValueOverrides={{ ...defaultDropdownValues, ...selectedValueOverrides }} filters={[ { key: 'winnerIds', @@ -448,6 +574,10 @@ const PaymentsListView: FC = (props: PaymentsListViewProp key: 'status', label: 'Status', options: [ + { + label: 'All', + value: 'all', + }, { label: 'Owed', value: 'OWED', @@ -483,11 +613,15 @@ const PaymentsListView: FC = (props: PaymentsListViewProp ], type: 'dropdown', }, - ...(isEngagementApproverView ? [] : [ + ...(isRestrictedApproverView || (isWiproTaasAdmin && !hasPaymentAdminRole) ? [] : [ { key: 'category', label: 'Type', options: [ + { + label: 'All', + value: 'all', + }, { label: 'Task Payment', value: 'TASK_PAYMENT', @@ -508,6 +642,14 @@ const PaymentsListView: FC = (props: PaymentsListViewProp label: 'Engagement Payment', value: 'ENGAGEMENT_PAYMENT', }, + { + label: 'TaaS Payment', + value: 'TAAS_PAYMENT', + }, + { + label: 'Topgear Payment', + value: topgearPaymentCategory, + }, ], type: 'dropdown', }, @@ -561,9 +703,19 @@ const PaymentsListView: FC = (props: PaymentsListViewProp } setPagination(newPagination) - setFilters({ + /* setFilters({ ...filters, [key]: value, + }) */ + setFilters(prev => { + const newFilters = { ...prev } + if (value[0] === 'all') { + delete newFilters[key] + } else { + newFilters[key] = value + } + + return newFilters }) setSelectedPayments({}) }} @@ -580,8 +732,8 @@ const PaymentsListView: FC = (props: PaymentsListViewProp {isLoading && } {!isLoading && winnings.length > 0 && ( = (props: PaymentsListViewProp {!isLoading && winnings.length === 0 && (

- {Object.keys(filters).length === 0 + {!hasActiveFilters ? apiErrorMsg : 'No payments match your filters.'}

)} - {bulkOpen && ( + {selectedPaymentAction && ( 1 ? 'Bulk ' : ''}Approve Payment${selectedPaymentsCount > 1 ? 's' : ''}`} - action='Approve' + title={`${selectedPaymentAction === 'reject' ? 'Reject' : 'Approve'} Payment${selectedPaymentsCount > 1 ? 's' : ''}`} + action={selectedPaymentAction === 'reject' ? 'Reject' : 'Approve'} onClose={function onClose() { setBulkAuditNote('') - setBulkOpen(false) + setSelectedPaymentAction(undefined) }} onConfirm={function onConfirm() { - onBulkApprove(bulkAuditNote) + onSelectedPaymentsUpdate(selectedPaymentAction, bulkAuditNote) }} canSave={bulkAuditNote.trim().length > 0} - open={bulkOpen} + open={selectedPaymentAction !== undefined} >

- You are about to approve + You are about to + {' '} + {selectedPaymentAction} {' '} {selectedPaymentsCount} {' '} 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 5474cd9e9..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 @@ -3,10 +3,12 @@ align-items: center; justify-content: space-between; flex-wrap: wrap; + gap: 10px; .firstFilter { display: flex; align-items: center; + min-width: 0; } .flexStretch { @@ -14,10 +16,12 @@ } .remainingFilters { - display: flex;; + display: flex; flex-direction: row; align-items: center; gap: 10px; + flex-wrap: wrap; + min-width: 0; } .filterContainer { @@ -26,11 +30,13 @@ .filter { width: 165px; + max-width: 100%; height: 47px; } .firstFilterElement { width: 200px; + max-width: 100%; height: 47px; } @@ -38,8 +44,41 @@ margin-bottom: 0; } + .selectionActionButton { + flex-shrink: 0; + } + .resetButton { flex-shrink: 0; margin-left: 30px; } } + +@media (max-width: 768px) { + .FilterBar { + align-items: stretch; + + .firstFilter, + .remainingFilters { + width: 100%; + } + + .flexStretch { + display: none; + } + + .firstFilterElement, + .filter { + width: 100%; + height: auto; + } + + .selectionActionButton, + .resetButton, + .exportButton, + .bulkButton { + width: 100%; + margin-left: 0; + } + } +} 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 0d50355d0..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 @@ -21,6 +21,20 @@ export type Filter = { options?: FilterOptions[]; }; +/** + * Describes a selection-scoped action rendered beside the wallet-admin filters. + * + * @remarks Wallet-admin uses these actions for bulk payment approval and + * rejection flows that only appear when table rows are selected. + */ +interface FilterBarSelectionAction { + appearance?: 'primary' | 'secondary' + key: string + label: string + onClick: () => void + variant?: 'danger' | 'warning' | 'linkblue' | 'round' | 'tc-green' +} + interface FilterBarProps { filters: Filter[]; showExportButton?: boolean; @@ -29,6 +43,7 @@ interface FilterBarProps { onExport?: () => void; selectedCount?: number; onBulkClick?: () => void; + selectionActions?: FilterBarSelectionAction[]; selectedValueOverrides?: Record; hasActiveFilters?: boolean; } @@ -36,6 +51,16 @@ interface FilterBarProps { const FilterBar: React.FC = (props: FilterBarProps) => { const [selectedValue, setSelectedValue] = React.useState>(new Map()) const selectedMembers = useRef([]) + const selectedCount = props.selectedCount ?? 0 + const selectionActions = props.selectionActions + ?? (selectedCount > 0 && props.onBulkClick + ? [{ + appearance: 'primary' as const, + key: 'bulk-approve', + label: `Approve (${selectedCount})`, + onClick: props.onBulkClick, + }] + : []) const renderDropdown = (index: number, filter: Filter): JSX.Element => ( = (props: FilterBarProps) => { size='lg' /> )} - {!!props.selectedCount && props.selectedCount > 0 && ( + {selectionActions.length > 0 && ( <> - + ), + Collapsible: (props: { + children: React.ReactNode + header: React.ReactNode + }) => ( +

+ {props.header} + {props.children} +
+ ), +}), { virtual: true }) + +jest.mock('~/config/environments/default.env', () => ({ + TOPCODER_URL: 'https://www.example.com', +}), { virtual: true }) + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + ADMIN: { + WORK_MANAGER_URL: 'https://challenges.example.com', + }, + }, +}), { virtual: true }) + +const mockedFetchWinningPaymentDetails = ( + fetchWinningPaymentDetails as jest.MockedFunction +) +const expectedWorkManagerLink + = 'https://challenges.example.com/projects/project-789/engagements/engagement-456/assignments' + + '?assignmentId=assignment-123' +const expectedProjectLink + = 'https://challenges.example.com/projects/project-789' + +describe('PaymentView', () => { + const payment: Winning = { + createDate: 'Mar 21, 2026', + currency: 'USD', + datePaid: '-', + description: 'Wipro - US Foods - Week Ending: Mar 21, 2026', + details: [{ + currency: 'USD', + datePaid: '', + grossAmount: '463.75', + id: 'payment-1', + installmentNumber: 1, + status: 'OWED', + totalAmount: '463.75', + }], + externalId: 'assignment-123', + grossAmount: '$463.75', + grossAmountNumber: 463.75, + handle: 'vikashchaudhary26', + id: 'winning-1', + releaseDate: 'Apr 11, 2026', + releaseDateObj: new Date('2026-04-11T00:00:00.000Z'), + status: 'Owed', + type: 'engagement payment', + } + + beforeEach(() => { + mockedFetchWinningPaymentDetails.mockResolvedValue({ + engagementDetails: { + assignmentId: 'assignment-123', + billingStartDate: '2026-02-16T00:00:00.000Z', + durationMonths: 3, + engagementId: 'engagement-456', + engagementTitle: 'Snowflake Developer - Vikash', + otherRemarks: 'Complete onboarding within the first week. Docs: https://google.com', + projectId: 'project-789', + projectName: 'Wipro - US Foods', + ratePerHour: '10.60', + standardHoursPerWeek: 43.75, + }, + paymentCreatorHandle: 'copilot-manager', + workLog: { + hoursWorked: 43.75, + remarks: 'Completed sprint support and bug triage. Reference: https://example.com/worklog', + }, + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('renders engagement details and a work-manager assignee link for engagement payments', async () => { + render() + + await waitFor(() => { + expect(mockedFetchWinningPaymentDetails) + .toHaveBeenCalledWith(payment) + }) + + expect(await screen.findByRole('heading', { name: 'Engagement Details' })) + .toBeTruthy() + await waitFor(() => { + expect(screen.getAllByText('43.75')) + .toHaveLength(2) + }) + expect(await screen.findByText(/Completed sprint support and bug triage\./)) + .toBeTruthy() + + const workLogHeading = await screen.findByRole('heading', { + name: 'Work Log / Manager Inputs', + }) + const workLogSection = workLogHeading.parentElement + + if (!workLogSection) { + throw new Error('Expected work log section to be rendered.') + } + + expect(screen.getAllByText('Payment Creator')) + .toHaveLength(1) + expect(within(workLogSection) + .getByText('Payment Creator')) + .toBeTruthy() + expect(within(workLogSection) + .getByText('copilot-manager')) + .toBeTruthy() + + const descriptionLink = await screen.findByRole('link', { + name: 'Wipro - US Foods - Week Ending: Mar 21, 2026', + }) + + expect(descriptionLink.getAttribute('href')) + .toBe(expectedWorkManagerLink) + + const projectLink = await screen.findByRole('link', { + name: 'Wipro - US Foods', + }) + + expect(projectLink.getAttribute('href')) + .toBe(expectedProjectLink) + expect(projectLink.getAttribute('target')) + .toBe('_blank') + + const remarksLink = await screen.findByRole('link', { + name: 'https://google.com', + }) + + expect(remarksLink.getAttribute('href')) + .toBe('https://google.com') + expect(remarksLink.getAttribute('target')) + .toBe('_blank') + + const workLogRemarksLink = await screen.findByRole('link', { + name: 'https://example.com/worklog', + }) + + expect(workLogRemarksLink.getAttribute('href')) + .toBe('https://example.com/worklog') + expect(workLogRemarksLink.getAttribute('target')) + .toBe('_blank') + }) +}) diff --git a/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.tsx b/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.tsx index fb6954c40..02db5e10c 100644 --- a/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.tsx +++ b/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.tsx @@ -3,15 +3,28 @@ /* eslint-disable max-len */ /* eslint-disable react/jsx-no-bind */ /* eslint-disable complexity */ +/* eslint-disable ordered-imports/ordered-imports */ import React from 'react' import { Button, Collapsible } from '~/libs/ui' -import { ENGAGEMENTS_URL, TOPCODER_URL } from '~/config/environments/default.env' +import { TOPCODER_URL } from '~/config/environments/default.env' import { WinningsAudit } from '../../models/WinningsAudit' -import { Winning } from '../../models/WinningDetail' +import { Winning, WinningPaymentDetails } from '../../models/WinningDetail' import { PayoutAudit } from '../../models/PayoutAudit' -import { fetchAuditLogs, fetchPayoutAuditLogs, getMemberHandle } from '../../services/wallet' +import { + fetchAuditLogs, + fetchPayoutAuditLogs, + fetchWinningPaymentDetails, + getMemberHandle, +} from '../../services/wallet' +import { + buildWorkManagerAssignmentUrl, + buildWorkManagerProjectUrl, + formatOptionalDate, + formatOptionalText, + renderOptionalLinkedText, +} from './payment-view.utils' import styles from './PaymentView.module.scss' @@ -24,11 +37,53 @@ const PaymentView: React.FC = (props: PaymentViewProps) => { const [view, setView] = React.useState<'details' | 'audit' | 'external_transaction'>('details') const [auditLines, setAuditLines] = React.useState([]) const [externalTransactionAudit, setExternalTransactionAudit] = React.useState([]) + const [paymentDetails, setPaymentDetails] = React.useState() + const [isPaymentDetailsLoading, setIsPaymentDetailsLoading] = React.useState(false) + const [paymentDetailsError, setPaymentDetailsError] = React.useState() + + const isEngagementPayment = props.payment.type.toLowerCase() === 'engagement payment' + const hasEngagementDetails = Boolean(paymentDetails?.engagementDetails) const handleToggleView = (newView: 'audit' | 'details' | 'external_transaction'): void => { setView(newView) } + React.useEffect(() => { + if (!isEngagementPayment) { + setPaymentDetails(undefined) + setIsPaymentDetailsLoading(false) + setPaymentDetailsError(undefined) + return undefined + } + + let ignore = false + + setIsPaymentDetailsLoading(true) + setPaymentDetailsError(undefined) + + fetchWinningPaymentDetails(props.payment) + .then(details => { + if (!ignore) { + setPaymentDetails(details) + } + }) + .catch(() => { + if (!ignore) { + setPaymentDetails(undefined) + setPaymentDetailsError('Unable to load engagement details.') + } + }) + .finally(() => { + if (!ignore) { + setIsPaymentDetailsLoading(false) + } + }) + + return () => { + ignore = true + } + }, [isEngagementPayment, props.payment]) + React.useEffect(() => { if (view === 'audit') { fetchAuditLogs(props.payment.id) @@ -40,9 +95,7 @@ const PaymentView: React.FC = (props: PaymentViewProps) => { log.userId = handles.get(parseInt(log.userId, 10)) ?? log.userId }) }) - .catch(() => { - console.error('Error fetching member handles') - }) + .catch(() => undefined) .finally(() => { setAuditLines(auditLogs) }) @@ -83,13 +136,10 @@ const PaymentView: React.FC = (props: PaymentViewProps) => { return action } - const getLink = (payment: Winning): string => { - if (payment.type.toLowerCase() === 'engagement payment') { - return `${ENGAGEMENTS_URL}/${payment.externalId}` - } - - return `${TOPCODER_URL}/challenges/${payment.externalId}` - } + const descriptionLink = isEngagementPayment + ? buildWorkManagerAssignmentUrl(paymentDetails?.engagementDetails) + : `${TOPCODER_URL}/challenges/${props.payment.externalId}` + const projectLink = buildWorkManagerProjectUrl(paymentDetails?.engagementDetails) return (
@@ -98,9 +148,13 @@ const PaymentView: React.FC = (props: PaymentViewProps) => { <>
Description - - {props.payment.description} - + {descriptionLink + ? ( + + {props.payment.description} + + ) + :

{props.payment.description}

}
Payment ID @@ -110,7 +164,6 @@ const PaymentView: React.FC = (props: PaymentViewProps) => { Handle

{props.payment.handle}

-
Type

{props.payment.type}

@@ -143,6 +196,116 @@ const PaymentView: React.FC = (props: PaymentViewProps) => {
)} + {isEngagementPayment && ( +
+

Engagement Details

+ {isPaymentDetailsLoading + ?

Loading engagement details...

+ : undefined} + {!isPaymentDetailsLoading && paymentDetailsError + ?

{paymentDetailsError}

+ : undefined} + {!isPaymentDetailsLoading && !paymentDetailsError && !hasEngagementDetails + ? ( +

+ Engagement details are unavailable for this payment. +

+ ) + : undefined} + {!isPaymentDetailsLoading && !paymentDetailsError && hasEngagementDetails && ( +
+
+ Project Name + {projectLink && paymentDetails?.engagementDetails?.projectName + ? ( + + {paymentDetails.engagementDetails.projectName} + + ) + : ( +

+ {formatOptionalText(paymentDetails?.engagementDetails?.projectName)} +

+ )} +
+
+ Billing Start Date +

+ {formatOptionalDate(paymentDetails?.engagementDetails?.billingStartDate)} +

+
+
+ Duration +

+ {paymentDetails?.engagementDetails?.durationMonths + ? `${paymentDetails.engagementDetails.durationMonths} month${paymentDetails.engagementDetails.durationMonths === 1 ? '' : 's'}` + : '-'} +

+
+
+ Rate per Hour +

+ {paymentDetails?.engagementDetails?.ratePerHour + ? Number(paymentDetails.engagementDetails.ratePerHour) + .toLocaleString(undefined, { + currency: 'USD', + style: 'currency', + }) + : '-'} +

+
+
+ Standard Hours per Week +

+ {formatOptionalText(paymentDetails?.engagementDetails?.standardHoursPerWeek)} +

+
+
+ Other Remarks +

+ {renderOptionalLinkedText(paymentDetails?.engagementDetails?.otherRemarks)} +

+
+
+ )} +
+ )} + + {isEngagementPayment && ( +
+

Work Log / Manager Inputs

+ {isPaymentDetailsLoading + ?

Loading work log...

+ : ( +
+
+ Hours Worked +

+ {formatOptionalText(paymentDetails?.workLog?.hoursWorked)} +

+
+
+ Remarks +

+ {renderOptionalLinkedText(paymentDetails?.workLog?.remarks)} +

+
+
+ Payment Creator +

+ {formatOptionalText(paymentDetails?.paymentCreatorHandle)} +

+
+
+ )} +
+ )} +
+ )} + > +
+
+ Applicant + + {`${props.application?.handle || '-'} / ${props.application?.name || '-'}`} + +
+ +
+

+ Timezone: + {' '} + {timezone} +

+ + { + setStartDate(nextValue || undefined) + setErrors(previous => ({ + ...previous, + startDate: undefined, + })) + }} + /> + {errors.startDate + ?

{errors.startDate}

+ : undefined} +
+ +
+ + { + setDurationMonths(sanitizePositiveNumericInput(event.target.value)) + setErrors(previous => ({ + ...previous, + durationMonths: undefined, + })) + }} + pattern='[0-9.]*' + type='text' + value={durationMonths} + /> + {errors.durationMonths + ?

{errors.durationMonths}

+ : undefined} +
+ +
+ + { + setRatePerHour(sanitizePositiveNumericInput(event.target.value)) + setErrors(previous => ({ + ...previous, + ratePerHour: undefined, + })) + }} + inputMode='decimal' + pattern='[0-9.]*' + type='text' + value={ratePerHour} + /> + {errors.ratePerHour + ?

{errors.ratePerHour}

+ : undefined} +
+ +
+ + { + setStandardHoursPerWeek( + sanitizePositiveNumericInput(event.target.value, 2), + ) + setErrors(previous => ({ + ...previous, + standardHoursPerWeek: undefined, + })) + }} + pattern='[0-9.]*' + type='text' + value={standardHoursPerWeek} + /> + {errors.standardHoursPerWeek + ?

{errors.standardHoursPerWeek}

+ : undefined} +
+ +
+ + + {!agreementRate + ?

Assignment rate is calculated after entering hourly details.

+ : undefined} +
+ +
+ +