Fully Completed Profiles
@@ -201,7 +298,8 @@ export const ProfileCompletionPage: FC = () => {
Member |
Handle |
Location |
-
Skills |
+
Open to Work |
+
Principal Skills |
{' '} |
@@ -230,6 +328,23 @@ export const ProfileCompletionPage: FC = () => {
{profile.locationLabel || profile.countryLabel} |
+
+ {
+ profile.openToWorkLabel === 'Yes' ? (
+
+
+ {profile.openToWorkLabel}
+
+
+ ) : (
+
+ {profile.openToWorkLabel}
+
+ )
+ }
+ |
{profile.displayedSkills && profile.displayedSkills.length > 0 ? (
diff --git a/src/apps/engagements/src/components/assignment-card/AssignmentCard.spec.tsx b/src/apps/engagements/src/components/assignment-card/AssignmentCard.spec.tsx
new file mode 100644
index 000000000..0d057a96c
--- /dev/null
+++ b/src/apps/engagements/src/components/assignment-card/AssignmentCard.spec.tsx
@@ -0,0 +1,108 @@
+/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, sort-keys */
+import '@testing-library/jest-dom'
+
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+
+import type { Engagement, EngagementAssignment } from '../../lib/models'
+import { EngagementStatus } from '../../lib/models'
+
+import AssignmentCard from './AssignmentCard'
+
+jest.mock('react-markdown', () => ({
+ __esModule: true,
+ default: ({ children }: { children?: React.ReactNode }) => <>{children}>,
+}))
+
+jest.mock('remark-frontmatter', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}))
+
+jest.mock('remark-gfm', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}))
+
+jest.mock('~/config', () => ({
+ EnvironmentConfig: {
+ URLS: {
+ USER_PROFILE: 'https://topcoder.com/members',
+ },
+ },
+}), { virtual: true })
+
+jest.mock('~/libs/ui', () => ({
+ Button: (props: {
+ className?: string
+ disabled?: boolean
+ label: string
+ onClick?: () => void
+ }) => (
+
+ ),
+ IconSolid: {
+ CalendarIcon: () => ,
+ ClockIcon: () => ,
+ CurrencyDollarIcon: () => ,
+ GlobeAltIcon: () => ,
+ LocationMarkerIcon: () => ,
+ },
+}), { virtual: true })
+
+const engagement: Engagement = {
+ id: 'engagement-1',
+ nanoId: 'engagement-1-nano',
+ projectId: 'project-1',
+ title: 'QA Assignment',
+ description: 'Test description',
+ duration: {},
+ timeZones: ['America/New_York'],
+ countries: ['US'],
+ requiredSkills: ['Testing'],
+ status: EngagementStatus.OPEN,
+ createdAt: '2026-03-25T00:00:00.000Z',
+ updatedAt: '2026-03-25T00:00:00.000Z',
+ createdBy: 'manager',
+}
+
+const assignment: EngagementAssignment = {
+ id: 'assignment-1',
+ engagementId: 'engagement-1',
+ memberId: 'member-1',
+ memberHandle: 'member',
+ agreementRate: '761.25',
+ ratePerHour: '20.3',
+ standardHoursPerWeek: 37.5,
+ status: 'assigned',
+ createdAt: '2026-03-25T00:00:00.000Z',
+ updatedAt: '2026-03-25T00:00:00.000Z',
+}
+
+describe('AssignmentCard', () => {
+ it('formats assignment currency values with two decimal places', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Rate / hr: $20.30'))
+ .toBeInTheDocument()
+ expect(screen.getByText('Rate / week: $761.25'))
+ .toBeInTheDocument()
+ expect(screen.getByText('Std hrs / week: 37.5 hrs'))
+ .toBeInTheDocument()
+ })
+})
diff --git a/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx b/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx
index d85e92bd5..46be7f5a7 100644
--- a/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx
+++ b/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx
@@ -8,7 +8,13 @@ import { Button, IconSolid } from '~/libs/ui'
import { EnvironmentConfig } from '~/config'
import type { Engagement, EngagementAssignment } from '../../lib/models'
-import { formatDate, formatLocation, truncateText } from '../../lib/utils'
+import {
+ formatCurrencyAmount,
+ formatDate,
+ formatLocation,
+ formatStandardHoursPerWeek,
+ truncateText,
+} from '../../lib/utils'
import { StatusBadge } from '../status-badge'
import styles from './AssignmentCard.module.scss'
@@ -105,13 +111,12 @@ const formatAssignmentDate = (value?: string): string => {
return formatted === 'Date TBD' ? FALLBACK_VALUE_LABEL : formatted
}
-const formatAgreementRate = (value?: string | number): string => {
- if (value === null || value === undefined) {
+const formatDurationMonths = (value?: number): string => {
+ if (!value) {
return FALLBACK_VALUE_LABEL
}
- const normalized = typeof value === 'string' ? value.trim() : value.toString()
- return normalized || FALLBACK_VALUE_LABEL
+ return `${value} month${value === 1 ? '' : 's'}`
}
const AssignmentCard: FC = (props: AssignmentCardProps) => {
@@ -142,16 +147,24 @@ const AssignmentCard: FC = (props: AssignmentCardProps) =>
[assignment?.status],
)
const paymentLabel = useMemo(
- () => formatAgreementRate(assignment?.agreementRate),
+ () => formatCurrencyAmount(assignment?.agreementRate, FALLBACK_VALUE_LABEL),
[assignment?.agreementRate],
)
+ const ratePerHourLabel = useMemo(
+ () => formatCurrencyAmount(assignment?.ratePerHour, FALLBACK_VALUE_LABEL),
+ [assignment?.ratePerHour],
+ )
const startDateLabel = useMemo(
() => formatAssignmentDate(assignment?.startDate),
[assignment?.startDate],
)
- const endDateLabel = useMemo(
- () => formatAssignmentDate(assignment?.endDate),
- [assignment?.endDate],
+ const durationMonthsLabel = useMemo(
+ () => formatDurationMonths(assignment?.durationMonths),
+ [assignment?.durationMonths],
+ )
+ const standardHoursPerWeekLabel = useMemo(
+ () => formatStandardHoursPerWeek(assignment?.standardHoursPerWeek, FALLBACK_VALUE_LABEL),
+ [assignment?.standardHoursPerWeek],
)
const assignmentStatus = assignment?.status?.toLowerCase()
const showAssignedActions = assignmentStatus === 'assigned'
@@ -213,11 +226,11 @@ const AssignmentCard: FC = (props: AssignmentCardProps) =>
- {`Start: ${startDateLabel}`}
+ {`Billing start: ${startDateLabel}`}
- {`End: ${endDateLabel}`}
+ {`Duration: ${durationMonthsLabel}`}
@@ -229,7 +242,15 @@ const AssignmentCard: FC = (props: AssignmentCardProps) =>
- {`Payment: $${paymentLabel} per week`}
+ {`Rate / hr: ${ratePerHourLabel}`}
+
+
+
+ {`Std hrs / week: ${standardHoursPerWeekLabel}`}
+
+
+
+ {`Rate / week: ${paymentLabel}`}
diff --git a/src/apps/engagements/src/components/assignment-offer-modal/AssignmentOfferModal.tsx b/src/apps/engagements/src/components/assignment-offer-modal/AssignmentOfferModal.tsx
index ae376c623..8f5fbdc6a 100644
--- a/src/apps/engagements/src/components/assignment-offer-modal/AssignmentOfferModal.tsx
+++ b/src/apps/engagements/src/components/assignment-offer-modal/AssignmentOfferModal.tsx
@@ -3,7 +3,11 @@ import { FC, useMemo } from 'react'
import { BaseModal, Button } from '~/libs/ui'
import type { Engagement, EngagementAssignment } from '../../lib/models'
-import { formatDate } from '../../lib/utils'
+import {
+ formatCurrencyAmount,
+ formatDate,
+ formatStandardHoursPerWeek,
+} from '../../lib/utils'
import styles from './AssignmentOfferModal.module.scss'
@@ -29,14 +33,12 @@ const formatAssignmentDate = (value?: string): string => {
return formatted === 'Date TBD' ? FALLBACK_LABEL : formatted
}
-const formatAgreementRate = (value?: string): string => {
- if (value === null || value === undefined) {
+const formatDurationMonths = (value?: number): string => {
+ if (!value) {
return FALLBACK_LABEL
}
- const normalized = value.toString()
- .trim()
- return `$ ${normalized}` || FALLBACK_LABEL
+ return `${value} month${value === 1 ? '' : 's'}`
}
const formatRemarks = (value?: string): string => {
@@ -62,16 +64,24 @@ const AssignmentOfferModal: FC = (
: 'Review the details below before accepting this offer.'
const agreementRateLabel = useMemo(
- () => formatAgreementRate(assignment.agreementRate),
+ () => formatCurrencyAmount(assignment.agreementRate, FALLBACK_LABEL),
[assignment.agreementRate],
)
const startDateLabel = useMemo(
() => formatAssignmentDate(assignment.startDate),
[assignment.startDate],
)
- const endDateLabel = useMemo(
- () => formatAssignmentDate(assignment.endDate),
- [assignment.endDate],
+ const durationMonthsLabel = useMemo(
+ () => formatDurationMonths(assignment.durationMonths),
+ [assignment.durationMonths],
+ )
+ const ratePerHourLabel = useMemo(
+ () => formatCurrencyAmount(assignment.ratePerHour, FALLBACK_LABEL),
+ [assignment.ratePerHour],
+ )
+ const standardHoursPerWeekLabel = useMemo(
+ () => formatStandardHoursPerWeek(assignment.standardHoursPerWeek, FALLBACK_LABEL),
+ [assignment.standardHoursPerWeek],
)
const otherRemarksLabel = useMemo(
() => formatRemarks(assignment.otherRemarks),
@@ -113,16 +123,24 @@ const AssignmentOfferModal: FC = (
Assignment details
- Agreement rate (per week)
- {agreementRateLabel}
+ Billing start date
+ {startDateLabel}
- Tentative start date
- {startDateLabel}
+ Duration (in months)
+ {durationMonthsLabel}
- Tentative end date
- {endDateLabel}
+ Rate per hour
+ {ratePerHourLabel}
+
+
+ Standard hours per week
+ {standardHoursPerWeekLabel}
+
+
+ Assignment rate per week
+ {agreementRateLabel}
Other remarks
diff --git a/src/apps/engagements/src/components/index.ts b/src/apps/engagements/src/components/index.ts
index 3fbd5c5c4..59fe18061 100644
--- a/src/apps/engagements/src/components/index.ts
+++ b/src/apps/engagements/src/components/index.ts
@@ -10,3 +10,4 @@ export * from './member-experience-modal'
export * from './assignment-offer-modal'
export * from './assignment-card'
export * from './engagements-tabs'
+export * from './terms-agreement-modal'
diff --git a/src/apps/engagements/src/components/terms-agreement-modal/TermsAgreementModal.module.scss b/src/apps/engagements/src/components/terms-agreement-modal/TermsAgreementModal.module.scss
new file mode 100644
index 000000000..e5403f4f7
--- /dev/null
+++ b/src/apps/engagements/src/components/terms-agreement-modal/TermsAgreementModal.module.scss
@@ -0,0 +1,225 @@
+@import '@libs/ui/styles/includes';
+
+.termsModalDescription {
+ color: $black-80;
+ margin-bottom: 12px;
+ line-height: 1.5;
+}
+
+.termsModalLoading {
+ display: flex;
+ justify-content: center;
+ padding: 16px 0;
+}
+
+.termsModalSpinner {
+ width: 24px;
+ height: 24px;
+}
+
+.termsModalBody {
+ background: $tc-white;
+ border: 1px solid $black-10;
+ border-radius: 12px;
+ padding: 16px;
+ max-height: 60vh;
+ overflow-y: auto;
+}
+
+.termsContent {
+ color: $black-80;
+ line-height: 1.6;
+
+ :global(p) {
+ margin: 0 0 12px 0;
+ }
+
+ :global(p:last-child) {
+ margin-bottom: 0;
+ }
+
+ :global(ul),
+ :global(ol) {
+ margin: 0 0 12px 20px;
+ padding: 0;
+ list-style-position: outside;
+ }
+
+ :global(ul) {
+ list-style: disc;
+ }
+
+ :global(ol) {
+ list-style: decimal;
+ }
+
+ :global(li) {
+ margin-bottom: 6px;
+ }
+
+ :global(li:last-child) {
+ margin-bottom: 0;
+ }
+
+ :global(h1) {
+ margin: 20px 0 10px;
+ font-size: 1.4rem;
+ font-weight: 700;
+ color: $black-100;
+ }
+
+ :global(h2) {
+ margin: 18px 0 10px;
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: $black-100;
+ }
+
+ :global(h3) {
+ margin: 16px 0 8px;
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: $black-100;
+ }
+
+ :global(h4),
+ :global(h5),
+ :global(h6) {
+ margin: 14px 0 8px;
+ font-size: 1rem;
+ font-weight: 600;
+ color: $black-100;
+ }
+
+ :global(strong) {
+ color: $black-100;
+ }
+
+ :global(em) {
+ font-style: italic;
+ }
+
+ :global(a) {
+ color: $teal-100;
+ text-decoration: underline;
+ }
+
+ :global(blockquote) {
+ margin: 0 0 12px 0;
+ padding-left: 12px;
+ border-left: 3px solid $black-20;
+ color: $black-60;
+ }
+
+ :global(code) {
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+ font-size: 0.9em;
+ background: $black-5;
+ border: 1px solid $black-10;
+ border-radius: 6px;
+ padding: 1px 4px;
+ }
+
+ :global(pre) {
+ margin: 0 0 12px 0;
+ background: $black-5;
+ border: 1px solid $black-10;
+ border-radius: 12px;
+ padding: 12px;
+ overflow-x: auto;
+ }
+
+ :global(pre code) {
+ background: transparent;
+ border: none;
+ padding: 0;
+ font-size: 0.9rem;
+ }
+
+ :global(table) {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 0 0 12px 0;
+ }
+
+ :global(th),
+ :global(td) {
+ border: 1px solid $black-10;
+ padding: 8px 10px;
+ text-align: left;
+ vertical-align: top;
+ }
+
+ :global(thead th) {
+ background: $black-5;
+ color: $black-100;
+ }
+
+ :global(img) {
+ max-width: 100%;
+ height: auto;
+ margin: 8px 0;
+ border-radius: 12px;
+ }
+
+ :global(hr) {
+ border: none;
+ border-top: 1px solid $black-10;
+ margin: 16px 0;
+ }
+}
+
+.termsDocuSign {
+ position: relative;
+ background: $tc-white;
+ border: 1px solid $black-10;
+ border-radius: 12px;
+ padding: 16px;
+ max-height: 70vh;
+ overflow: hidden;
+}
+
+.termsDocuSignOverlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ background: rgba(255, 255, 255, 0.85);
+ z-index: 1;
+ text-align: center;
+ padding: 16px;
+}
+
+.termsDocuSignStatus {
+ color: $black-80;
+ font-size: 0.95rem;
+}
+
+.termsDocuSignFrame {
+ width: 100%;
+ height: 60vh;
+ border: 0;
+}
+
+.termsModalFallback {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ color: $black-80;
+}
+
+.termsModalLink {
+ color: $turq-160;
+ font-weight: 600;
+ text-decoration: underline;
+ width: fit-content;
+}
+
+.termsModalError {
+ margin-top: 12px;
+ color: $red-120;
+ font-size: 0.9rem;
+}
diff --git a/src/apps/engagements/src/components/terms-agreement-modal/TermsAgreementModal.tsx b/src/apps/engagements/src/components/terms-agreement-modal/TermsAgreementModal.tsx
new file mode 100644
index 000000000..ca179392e
--- /dev/null
+++ b/src/apps/engagements/src/components/terms-agreement-modal/TermsAgreementModal.tsx
@@ -0,0 +1,244 @@
+import type { FC, SyntheticEvent } from 'react'
+
+import { BaseModal, Button, LoadingSpinner } from '~/libs/ui'
+
+import styles from './TermsAgreementModal.module.scss'
+
+/**
+ * Defines the content and callbacks needed to present a required engagement terms agreement.
+ */
+export interface TermsAgreementModalProps {
+ open: boolean
+ onClose: () => void
+ contextDescription: string
+ termsLabel?: string
+ termsTitle: string
+ termsBody?: string
+ termsLoading: boolean
+ termsError?: string
+ termsAgreeing: boolean
+ isElectronicallyAgreeable: boolean
+ isDocuSignTerm: boolean
+ docuSignUrl?: string
+ docuSignLoading: boolean
+ termsUrl?: string
+ onAgree: () => void
+ onOpenTermsLink: () => void
+ onDocuSignFrameLoad?: (event: SyntheticEvent ) => void
+}
+
+type TermsModalButtonProps = Pick<
+ TermsAgreementModalProps,
+ | 'isDocuSignTerm'
+ | 'isElectronicallyAgreeable'
+ | 'onAgree'
+ | 'onClose'
+ | 'onOpenTermsLink'
+ | 'termsAgreeing'
+ | 'termsLoading'
+ | 'termsUrl'
+>
+
+type TermsModalDocuSignProps = Pick<
+ TermsAgreementModalProps,
+ | 'docuSignLoading'
+ | 'docuSignUrl'
+ | 'onDocuSignFrameLoad'
+ | 'termsAgreeing'
+ | 'termsTitle'
+ | 'termsUrl'
+>
+
+type TermsModalBodyProps = Pick
+
+type TermsModalContentProps = TermsModalBodyProps
+ & TermsModalDocuSignProps
+ & Pick
+
+const getTermsModalDescription = (
+ termsLabel: string | undefined,
+ contextDescription: string,
+): string => {
+ const subject = termsLabel ? `the ${termsLabel}` : 'these Terms & Conditions'
+ return `You are seeing ${subject} because ${contextDescription}. You must agree to continue.`
+}
+
+const renderTermsModalButtons = (props: TermsModalButtonProps): JSX.Element => {
+ if (props.isDocuSignTerm) {
+ return (
+
+ )
+ }
+
+ if (props.isElectronicallyAgreeable) {
+ return (
+ <>
+
+
+ >
+ )
+ }
+
+ return (
+ <>
+
+
+ >
+ )
+}
+
+const renderTermsDocuSignSection = (props: TermsModalDocuSignProps): JSX.Element => {
+ const isProcessing = props.docuSignLoading || props.termsAgreeing
+ const statusMessage = props.docuSignLoading
+ ? 'Loading agreement...'
+ : 'Finalizing your agreement...'
+
+ return (
+
+ {isProcessing && (
+
+
+ {statusMessage}
+
+ )}
+ {!isProcessing && props.docuSignUrl && (
+
+ )}
+ {!isProcessing && !props.docuSignUrl && (
+
+ )}
+
+ )
+}
+
+const renderTermsBodySection = (props: TermsModalBodyProps): JSX.Element => (
+
+ {props.termsBody ? (
+
+ ) : (
+
+ )}
+
+)
+
+const renderTermsModalContent = (props: TermsModalContentProps): JSX.Element => {
+ if (props.termsLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (props.isDocuSignTerm) {
+ return renderTermsDocuSignSection(props)
+ }
+
+ return renderTermsBodySection(props)
+}
+
+/**
+ * Renders the terms and NDA agreement modal shared by engagement apply and accept-offer flows.
+ */
+const TermsAgreementModal: FC = (
+ props: TermsAgreementModalProps,
+): JSX.Element => {
+ const buttonProps: TermsModalButtonProps = {
+ isDocuSignTerm: props.isDocuSignTerm,
+ isElectronicallyAgreeable: props.isElectronicallyAgreeable,
+ onAgree: props.onAgree,
+ onClose: props.onClose,
+ onOpenTermsLink: props.onOpenTermsLink,
+ termsAgreeing: props.termsAgreeing,
+ termsLoading: props.termsLoading,
+ termsUrl: props.termsUrl,
+ }
+ const contentProps: TermsModalContentProps = {
+ docuSignLoading: props.docuSignLoading,
+ docuSignUrl: props.docuSignUrl,
+ isDocuSignTerm: props.isDocuSignTerm,
+ onDocuSignFrameLoad: props.onDocuSignFrameLoad,
+ termsAgreeing: props.termsAgreeing,
+ termsBody: props.termsBody,
+ termsLoading: props.termsLoading,
+ termsTitle: props.termsTitle,
+ termsUrl: props.termsUrl,
+ }
+ const description = getTermsModalDescription(props.termsLabel, props.contextDescription)
+
+ return (
+
+ {description}
+ {renderTermsModalContent(contentProps)}
+ {props.termsError && props.open && (
+ {props.termsError}
+ )}
+
+ )
+}
+
+export default TermsAgreementModal
diff --git a/src/apps/engagements/src/components/terms-agreement-modal/index.ts b/src/apps/engagements/src/components/terms-agreement-modal/index.ts
new file mode 100644
index 000000000..cc31e7a83
--- /dev/null
+++ b/src/apps/engagements/src/components/terms-agreement-modal/index.ts
@@ -0,0 +1,2 @@
+export { default as TermsAgreementModal } from './TermsAgreementModal'
+export type { TermsAgreementModalProps } from './TermsAgreementModal'
diff --git a/src/apps/engagements/src/lib/hooks/index.ts b/src/apps/engagements/src/lib/hooks/index.ts
new file mode 100644
index 000000000..a3509d451
--- /dev/null
+++ b/src/apps/engagements/src/lib/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useTermsAgreementGate'
diff --git a/src/apps/engagements/src/lib/hooks/useTermsAgreementGate.ts b/src/apps/engagements/src/lib/hooks/useTermsAgreementGate.ts
new file mode 100644
index 000000000..c1cadd183
--- /dev/null
+++ b/src/apps/engagements/src/lib/hooks/useTermsAgreementGate.ts
@@ -0,0 +1,368 @@
+import { type SyntheticEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+
+import { EnvironmentConfig } from '~/config'
+
+import type { TermDetails } from '../models'
+import {
+ agreeToTerm,
+ getDocuSignUrl,
+ getTermDetails,
+} from '../services'
+import { extractTermId } from '../utils'
+
+type TermsConfig = {
+ id: string
+ label: string
+ url?: string
+}
+
+export type TermsAgreementCompletionHandler = () => void | Promise
+
+export interface TermsAgreementGateModalState {
+ open: boolean
+ onClose: () => void
+ contextDescription: string
+ termsLabel?: string
+ termsTitle: string
+ termsBody?: string
+ termsLoading: boolean
+ termsError?: string
+ termsAgreeing: boolean
+ isElectronicallyAgreeable: boolean
+ isDocuSignTerm: boolean
+ docuSignUrl?: string
+ docuSignLoading: boolean
+ termsUrl?: string
+ onAgree: () => void
+ onOpenTermsLink: () => void
+ onDocuSignFrameLoad?: (event: SyntheticEvent) => void
+}
+
+export interface UseTermsAgreementGateOptions {
+ contextDescription: string
+}
+
+export interface UseTermsAgreementGateResult {
+ modalState: TermsAgreementGateModalState
+ isCheckingTerms: boolean
+ isFinalizingAgreement: boolean
+ termsError?: string
+ startTermsAgreementFlow: (onComplete: TermsAgreementCompletionHandler) => Promise
+}
+
+type TermsViewData = {
+ termsTitle: string
+ termsBody?: string
+ isElectronicallyAgreeable: boolean
+}
+
+const TERMS_ID = extractTermId(EnvironmentConfig.TERMS_URL)
+const NDA_TERMS_ID = extractTermId(EnvironmentConfig.NDA_TERMS_URL)
+
+const TERMS_CONFIG: TermsConfig[] = [
+ { id: TERMS_ID ?? '', label: 'Standard Topcoder Terms', url: EnvironmentConfig.TERMS_URL },
+ { id: NDA_TERMS_ID ?? '', label: 'Topcoder NDA', url: EnvironmentConfig.NDA_TERMS_URL },
+].filter(term => term.id)
+
+const DOCUSIGN_POLL_DELAY_MS = 5000
+const DOCUSIGN_POLL_MAX_ATTEMPTS = 5
+const DOCUSIGN_RETURN_PARAM = 'docusignReturn'
+const DOCUSIGN_RETURN_VALUE = '1'
+
+const delay = (durationMs: number): Promise => (
+ new Promise(resolve => {
+ window.setTimeout(resolve, durationMs)
+ })
+)
+
+const buildDocuSignReturnUrl = (): string => {
+ const url = new URL(window.location.href)
+ url.searchParams.set(DOCUSIGN_RETURN_PARAM, DOCUSIGN_RETURN_VALUE)
+ return url.toString()
+}
+
+const isDocuSignReturnUrl = (url?: string): boolean => {
+ if (!url) {
+ return false
+ }
+
+ try {
+ const parsed = new URL(url)
+ return parsed.origin === window.location.origin
+ && parsed.searchParams.get(DOCUSIGN_RETURN_PARAM) === DOCUSIGN_RETURN_VALUE
+ } catch {
+ return false
+ }
+}
+
+const getTermsViewData = (termsDetails?: TermDetails): TermsViewData => {
+ const termsTitle = termsDetails?.title || 'Terms & Conditions of Use'
+ const termsBody = termsDetails?.text
+ ? termsDetails.text.replace(/topcoder/gi, 'Topcoder')
+ : undefined
+ const isElectronicallyAgreeable = termsDetails?.agreeabilityType
+ ? termsDetails.agreeabilityType === 'Electronically-agreeable'
+ : true
+
+ return {
+ isElectronicallyAgreeable,
+ termsBody,
+ termsTitle,
+ }
+}
+
+/**
+ * Manages the shared engagement terms gate, including sequential term checks and DocuSign-backed terms.
+ */
+export const useTermsAgreementGate = (
+ options: UseTermsAgreementGateOptions,
+): UseTermsAgreementGateResult => {
+ const [termsModalOpen, setTermsModalOpen] = useState(false)
+ const [activeTerm, setActiveTerm] = useState(undefined)
+ const [termsDetails, setTermsDetails] = useState(undefined)
+ const [termsLoading, setTermsLoading] = useState(false)
+ const [termsError, setTermsError] = useState(undefined)
+ const [termsAgreeing, setTermsAgreeing] = useState(false)
+ const [docuSignUrl, setDocuSignUrl] = useState(undefined)
+ const [docuSignLoading, setDocuSignLoading] = useState(false)
+
+ const completionHandlerRef = useRef(undefined)
+ const docuSignCallbackHandledRef = useRef(false)
+
+ const completeTermsFlow = useCallback(async (): Promise => {
+ const onComplete = completionHandlerRef.current
+ completionHandlerRef.current = undefined
+
+ if (onComplete) {
+ await onComplete()
+ }
+ }, [])
+
+ const closeTermsFlow = useCallback(() => {
+ setTermsModalOpen(false)
+ setTermsError(undefined)
+ setTermsAgreeing(false)
+ setDocuSignUrl(undefined)
+ setDocuSignLoading(false)
+ setActiveTerm(undefined)
+ setTermsDetails(undefined)
+ completionHandlerRef.current = undefined
+ docuSignCallbackHandledRef.current = false
+ }, [])
+
+ const openNextPendingTerm = useCallback(async (): Promise<'opened' | 'completed' | 'failed'> => {
+ if (!TERMS_ID || !NDA_TERMS_ID || TERMS_CONFIG.length < 2) {
+ setTermsError('Unable to verify terms and NDA. Please try again later.')
+ return 'failed'
+ }
+
+ setTermsError(undefined)
+ setTermsLoading(true)
+
+ try {
+ const results = await Promise.all(
+ TERMS_CONFIG.map(async term => ({
+ details: await getTermDetails(term.id),
+ term,
+ })),
+ )
+ const nextPending = results.find(entry => !entry.details?.agreed)
+
+ if (!nextPending) {
+ setActiveTerm(undefined)
+ setTermsDetails(undefined)
+ setTermsModalOpen(false)
+ await completeTermsFlow()
+ return 'completed'
+ }
+
+ setActiveTerm(nextPending.term)
+ setTermsDetails(nextPending.details)
+ setTermsModalOpen(true)
+ return 'opened'
+ } catch {
+ setTermsError('Unable to verify terms of use. Please try again.')
+ return 'failed'
+ } finally {
+ setTermsLoading(false)
+ }
+ }, [completeTermsFlow])
+
+ const handleAgreeTerms = useCallback(async () => {
+ if (!activeTerm?.id) {
+ setTermsError('Unable to verify terms of use. Please try again later.')
+ return
+ }
+
+ setTermsAgreeing(true)
+ setTermsError(undefined)
+
+ try {
+ const response = await agreeToTerm(activeTerm.id)
+ if (response?.success === false) {
+ throw new Error('Terms agreement failed')
+ }
+
+ await openNextPendingTerm()
+ } catch {
+ setTermsError('Unable to save your agreement. Please try again.')
+ } finally {
+ setTermsAgreeing(false)
+ }
+ }, [activeTerm?.id, openNextPendingTerm])
+
+ const handleOpenTermsLink = useCallback(() => {
+ const nextTermsUrl = activeTerm?.url || termsDetails?.url
+ if (!nextTermsUrl) {
+ return
+ }
+
+ window.open(nextTermsUrl, '_blank', 'noopener,noreferrer')
+ }, [activeTerm?.url, termsDetails?.url])
+
+ const handleDocuSignComplete = useCallback(async () => {
+ if (!activeTerm?.id) {
+ return
+ }
+
+ const termId = activeTerm.id
+ const checkAgreement = async (attempt: number): Promise => {
+ const details = await getTermDetails(termId)
+ setTermsDetails(details)
+
+ if (details.agreed || attempt >= DOCUSIGN_POLL_MAX_ATTEMPTS) {
+ return details
+ }
+
+ await delay(DOCUSIGN_POLL_DELAY_MS)
+ return checkAgreement(attempt + 1)
+ }
+
+ setTermsAgreeing(true)
+ setTermsError(undefined)
+
+ try {
+ const details = await checkAgreement(1)
+ if (!details.agreed) {
+ setTermsError('We could not confirm your signature yet. Please try again.')
+ return
+ }
+
+ await openNextPendingTerm()
+ } catch {
+ setTermsError('Unable to verify your agreement. Please try again.')
+ } finally {
+ setTermsAgreeing(false)
+ }
+ }, [activeTerm?.id, openNextPendingTerm])
+
+ const handleDocuSignCallback = useCallback(() => {
+ if (docuSignCallbackHandledRef.current) {
+ return
+ }
+
+ docuSignCallbackHandledRef.current = true
+ setTermsModalOpen(false)
+ handleDocuSignComplete()
+ }, [handleDocuSignComplete])
+
+ const handleDocuSignFrameLoad = useCallback((event: SyntheticEvent) => {
+ try {
+ const frameLocation = event.currentTarget.contentWindow?.location?.href
+ if (isDocuSignReturnUrl(frameLocation)) {
+ handleDocuSignCallback()
+ }
+ } catch {
+ // Ignore cross-origin iframe loads.
+ }
+ }, [handleDocuSignCallback])
+
+ const { isElectronicallyAgreeable, termsBody, termsTitle }: TermsViewData = useMemo(
+ () => getTermsViewData(termsDetails),
+ [termsDetails],
+ )
+ const docuSignTemplateId = termsDetails?.docusignTemplateId
+ const isDocuSignTerm = Boolean(
+ termsDetails?.agreeabilityType
+ && termsDetails.agreeabilityType !== 'Electronically-agreeable'
+ && docuSignTemplateId,
+ )
+ const termsUrl = activeTerm?.url || termsDetails?.url
+
+ useEffect(() => {
+ if (!termsModalOpen || !isDocuSignTerm || !docuSignTemplateId) {
+ setDocuSignUrl(undefined)
+ setDocuSignLoading(false)
+ return
+ }
+
+ docuSignCallbackHandledRef.current = false
+ const returnUrl = buildDocuSignReturnUrl()
+ setDocuSignLoading(true)
+ setDocuSignUrl(undefined)
+ getDocuSignUrl(docuSignTemplateId, returnUrl)
+ .then(url => setDocuSignUrl(url))
+ .catch(() => setTermsError('Unable to load the agreement. Please try again.'))
+ .finally(() => setDocuSignLoading(false))
+ }, [docuSignTemplateId, isDocuSignTerm, termsModalOpen])
+
+ useEffect(() => {
+ if (!termsModalOpen || !docuSignUrl) {
+ return undefined
+ }
+
+ const handler = (event: MessageEvent): void => {
+ if (!event?.data || event.data.type !== 'DocuSign') {
+ return
+ }
+
+ if (event.data.event === 'signing_complete' || event.data.event === 'viewing_complete') {
+ handleDocuSignCallback()
+ } else {
+ closeTermsFlow()
+ }
+ }
+
+ window.addEventListener('message', handler)
+ return () => window.removeEventListener('message', handler)
+ }, [closeTermsFlow, docuSignUrl, handleDocuSignCallback, termsModalOpen])
+
+ const startTermsAgreementFlow = useCallback(async (onComplete: TermsAgreementCompletionHandler) => {
+ if (termsLoading || termsAgreeing) {
+ return
+ }
+
+ completionHandlerRef.current = onComplete
+ const result = await openNextPendingTerm()
+
+ if (result === 'failed') {
+ completionHandlerRef.current = undefined
+ }
+ }, [openNextPendingTerm, termsAgreeing, termsLoading])
+
+ return {
+ isCheckingTerms: termsLoading && !termsModalOpen,
+ isFinalizingAgreement: termsAgreeing && !termsModalOpen,
+ modalState: {
+ contextDescription: options.contextDescription,
+ docuSignLoading,
+ docuSignUrl,
+ isDocuSignTerm,
+ isElectronicallyAgreeable,
+ onAgree: handleAgreeTerms,
+ onClose: closeTermsFlow,
+ onDocuSignFrameLoad: handleDocuSignFrameLoad,
+ onOpenTermsLink: handleOpenTermsLink,
+ open: termsModalOpen,
+ termsAgreeing,
+ termsBody,
+ termsError,
+ termsLabel: activeTerm?.label,
+ termsLoading,
+ termsTitle,
+ termsUrl,
+ },
+ startTermsAgreementFlow,
+ termsError,
+ }
+}
diff --git a/src/apps/engagements/src/lib/index.ts b/src/apps/engagements/src/lib/index.ts
index 28408d3de..3e71dd858 100644
--- a/src/apps/engagements/src/lib/index.ts
+++ b/src/apps/engagements/src/lib/index.ts
@@ -1,4 +1,5 @@
export * from './engagements-swr'
+export * from './hooks'
export * from './models'
export * from './services'
export * from './utils'
diff --git a/src/apps/engagements/src/lib/models/Engagement.model.ts b/src/apps/engagements/src/lib/models/Engagement.model.ts
index 580aefcd0..dc88a8103 100644
--- a/src/apps/engagements/src/lib/models/Engagement.model.ts
+++ b/src/apps/engagements/src/lib/models/Engagement.model.ts
@@ -15,6 +15,9 @@ export interface EngagementAssignment {
status?: string
termsAccepted?: boolean
agreementRate?: string
+ ratePerHour?: string
+ standardHoursPerWeek?: number
+ durationMonths?: number
otherRemarks?: string
startDate?: string
endDate?: string
diff --git a/src/apps/engagements/src/lib/services/engagements.service.ts b/src/apps/engagements/src/lib/services/engagements.service.ts
index aaf625383..20d9c924d 100644
--- a/src/apps/engagements/src/lib/services/engagements.service.ts
+++ b/src/apps/engagements/src/lib/services/engagements.service.ts
@@ -4,6 +4,7 @@ import { fetchSkillsByIds } from '~/libs/shared'
import { EngagementStatus } from '../models'
import type { Engagement, EngagementAssignment, EngagementListResponse } from '../models'
+import { normalizePositiveNumericValue } from '../utils'
const BASE_URL = `${EnvironmentConfig.API.V6}/engagements/engagements`
@@ -27,6 +28,9 @@ interface BackendEngagementAssignment {
status?: string | null
termsAccepted?: boolean | null
agreementRate?: string | number | null
+ ratePerHour?: string | number | null
+ standardHoursPerWeek?: number | string | null
+ durationMonths?: number | string | null
otherRemarks?: string | null
startDate?: string | null
endDate?: string | null
@@ -188,26 +192,52 @@ const normalizeEnumValue = (
return normalized || undefined
}
+const normalizeIntegerValue = (
+ value?: string | number | null,
+): number | undefined => {
+ if (value === null || value === undefined) {
+ return undefined
+ }
+
+ const parsed = typeof value === 'number' ? value : Number(value)
+ return Number.isInteger(parsed) ? parsed : undefined
+}
+
const normalizeAssignments = (assignments?: BackendEngagementAssignment[]): EngagementAssignment[] => {
if (!Array.isArray(assignments)) {
return []
}
- return assignments.map(assignment => ({
- agreementRate: normalizeEnumValue(assignment.agreementRate),
- createdAt: withDefault('', assignment.createdAt),
- endDate: normalizeEnumValue(assignment.endDate),
- engagementId: withDefault('', assignment.engagementId),
- id: withDefault('', assignment.id),
- memberHandle: withDefault('', assignment.memberHandle),
- memberId: withDefault('', assignment.memberId),
- otherRemarks: normalizeEnumValue(assignment.otherRemarks),
- startDate: normalizeEnumValue(assignment.startDate),
- status: normalizeAssignmentStatus(assignment.status),
- terminationReason: normalizeEnumValue(assignment.terminationReason),
- termsAccepted: assignment.termsAccepted ?? undefined,
- updatedAt: withDefault('', assignment.updatedAt),
- }))
+ return assignments.map(assignment => {
+ const ratePerHour = normalizeEnumValue(assignment.ratePerHour)
+ const parsedRatePerHour = normalizePositiveNumericValue(assignment.ratePerHour)
+ const standardHoursPerWeek = normalizePositiveNumericValue(assignment.standardHoursPerWeek)
+ const agreementRate = normalizeEnumValue(assignment.agreementRate)
+ ?? (
+ parsedRatePerHour !== undefined && standardHoursPerWeek !== undefined
+ ? (parsedRatePerHour * standardHoursPerWeek).toFixed(2)
+ : undefined
+ )
+
+ return {
+ agreementRate,
+ createdAt: withDefault('', assignment.createdAt),
+ durationMonths: normalizeIntegerValue(assignment.durationMonths),
+ endDate: normalizeEnumValue(assignment.endDate),
+ engagementId: withDefault('', assignment.engagementId),
+ id: withDefault('', assignment.id),
+ memberHandle: withDefault('', assignment.memberHandle),
+ memberId: withDefault('', assignment.memberId),
+ otherRemarks: normalizeEnumValue(assignment.otherRemarks),
+ ratePerHour,
+ standardHoursPerWeek,
+ startDate: normalizeEnumValue(assignment.startDate),
+ status: normalizeAssignmentStatus(assignment.status),
+ terminationReason: normalizeEnumValue(assignment.terminationReason),
+ termsAccepted: assignment.termsAccepted ?? undefined,
+ updatedAt: withDefault('', assignment.updatedAt),
+ }
+ })
}
const normalizeDuration = (data: BackendEngagement): Engagement['duration'] => {
diff --git a/src/apps/engagements/src/lib/utils/currency.utils.ts b/src/apps/engagements/src/lib/utils/currency.utils.ts
new file mode 100644
index 000000000..145607818
--- /dev/null
+++ b/src/apps/engagements/src/lib/utils/currency.utils.ts
@@ -0,0 +1,72 @@
+const USD_CURRENCY_FORMATTER = new Intl.NumberFormat('en-US', {
+ currency: 'USD',
+ maximumFractionDigits: 2,
+ minimumFractionDigits: 2,
+ style: 'currency',
+})
+
+const DECIMAL_FORMATTER = new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: 2,
+})
+
+/**
+ * Parses positive numeric values used by engagement assignment terms.
+ *
+ * @param value Numeric input received from the API payload.
+ * @returns Parsed positive number, or `undefined` when the value is absent or invalid.
+ */
+export const normalizePositiveNumericValue = (
+ value?: string | number | null,
+): number | undefined => {
+ if (value === null || value === undefined) {
+ return undefined
+ }
+
+ const normalized = typeof value === 'string' ? value.trim() : value.toString()
+ if (!normalized) {
+ return undefined
+ }
+
+ const parsed = Number(normalized)
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
+}
+
+/**
+ * Formats assignment currency values with a fixed two-decimal USD display.
+ *
+ * @param value Currency value received from the API payload.
+ * @param fallback Label shown when the value is absent or invalid.
+ * @returns Formatted USD currency string.
+ */
+export const formatCurrencyAmount = (
+ value?: string | number | null,
+ fallback = 'TBD',
+): string => {
+ const parsed = normalizePositiveNumericValue(value)
+
+ if (parsed === undefined) {
+ if (value === null || value === undefined) {
+ return fallback
+ }
+
+ const normalized = typeof value === 'string' ? value.trim() : value.toString()
+ return normalized ? `$${normalized}` : fallback
+ }
+
+ return USD_CURRENCY_FORMATTER.format(parsed)
+}
+
+/**
+ * Formats fractional standard hours while preserving up to two decimals.
+ *
+ * @param value Standard hours per week received from the API payload.
+ * @param fallback Label shown when the value is absent or invalid.
+ * @returns Human-readable weekly hours label.
+ */
+export const formatStandardHoursPerWeek = (
+ value?: string | number | null,
+ fallback = 'TBD',
+): string => {
+ const parsed = normalizePositiveNumericValue(value)
+ return parsed === undefined ? fallback : `${DECIMAL_FORMATTER.format(parsed)} hrs`
+}
diff --git a/src/apps/engagements/src/lib/utils/index.ts b/src/apps/engagements/src/lib/utils/index.ts
index 05a27e699..8f5f0a9b8 100644
--- a/src/apps/engagements/src/lib/utils/index.ts
+++ b/src/apps/engagements/src/lib/utils/index.ts
@@ -1,4 +1,5 @@
export * from './api.utils'
export * from './application.utils'
+export * from './currency.utils'
export * from './date.utils'
export * from './terms.utils'
diff --git a/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx b/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx
index 87daa4ce2..c2a406e64 100644
--- a/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx
+++ b/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx
@@ -1,4 +1,4 @@
-import { FC, SyntheticEvent, useCallback, useEffect, useRef, useState } from 'react'
+import { FC, useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import ReactMarkdown, { type Options as ReactMarkdownOptions } from 'react-markdown'
import remarkBreaks from 'remark-breaks'
@@ -7,24 +7,21 @@ import remarkGfm from 'remark-gfm'
import { EnvironmentConfig } from '~/config'
import { authUrlLogin, useProfileCompleteness, useProfileContext } from '~/libs/core'
-import { BaseModal, Button, ContentLayout, IconOutline, IconSolid, LoadingSpinner } from '~/libs/ui'
+import { Button, ContentLayout, IconOutline, IconSolid, LoadingSpinner } from '~/libs/ui'
-import type { Application, Engagement, TermDetails } from '../../lib/models'
+import type { Application, Engagement } from '../../lib/models'
+import { useTermsAgreementGate } from '../../lib'
import { ApplicationStatus, EngagementStatus } from '../../lib/models'
import {
- agreeToTerm,
checkExistingApplication,
- getDocuSignUrl,
getEngagementByNanoId,
- getTermDetails,
} from '../../lib/services'
import {
- extractTermId,
formatDate,
formatDuration,
formatLocation,
} from '../../lib/utils'
-import { StatusBadge } from '../../components'
+import { StatusBadge, TermsAgreementModal } from '../../components'
import { rootRoute } from '../../engagements.routes'
import styles from './EngagementDetailPage.module.scss'
@@ -58,51 +55,6 @@ const formatEnumLabel = (value?: string): string | undefined => {
.replace(/\b\w/g, character => character.toUpperCase())
}
-type TermsConfig = {
- id: string
- label: string
- url?: string
-}
-
-const TERMS_ID = extractTermId(EnvironmentConfig.TERMS_URL)
-const NDA_TERMS_ID = extractTermId(EnvironmentConfig.NDA_TERMS_URL)
-
-const TERMS_CONFIG: TermsConfig[] = [
- { id: TERMS_ID ?? '', label: 'Standard Topcoder Terms', url: EnvironmentConfig.TERMS_URL },
- { id: NDA_TERMS_ID ?? '', label: 'Topcoder NDA', url: EnvironmentConfig.NDA_TERMS_URL },
-].filter(term => term.id)
-
-const DOCUSIGN_POLL_DELAY_MS = 5000
-const DOCUSIGN_POLL_MAX_ATTEMPTS = 5
-const DOCUSIGN_RETURN_PARAM = 'docusignReturn'
-const DOCUSIGN_RETURN_VALUE = '1'
-
-const delay = (durationMs: number): Promise => (
- new Promise(resolve => {
- window.setTimeout(resolve, durationMs)
- })
-)
-
-const buildDocuSignReturnUrl = (): string => {
- const url = new URL(window.location.href)
- url.searchParams.set(DOCUSIGN_RETURN_PARAM, DOCUSIGN_RETURN_VALUE)
- return url.toString()
-}
-
-const isDocuSignReturnUrl = (url?: string): boolean => {
- if (!url) {
- return false
- }
-
- try {
- const parsed = new URL(url)
- return parsed.origin === window.location.origin
- && parsed.searchParams.get(DOCUSIGN_RETURN_PARAM) === DOCUSIGN_RETURN_VALUE
- } catch {
- return false
- }
-}
-
const normalizeRoleNames = (roles?: string[]): string[] => (
(roles ?? [])
.filter((role): role is string => typeof role === 'string')
@@ -232,28 +184,6 @@ const isPrivateEngagementAccessDeniedError = (error: any): boolean => {
return message === PRIVATE_ENGAGEMENT_ACCESS_DENIED_MESSAGE
}
-type TermsViewData = {
- termsTitle: string
- termsBody?: string
- isElectronicallyAgreeable: boolean
-}
-
-const getTermsViewData = (termsDetails?: TermDetails): TermsViewData => {
- const termsTitle = termsDetails?.title || 'Terms & Conditions of Use'
- const termsBody = termsDetails?.text
- ? termsDetails.text.replace(/topcoder/gi, 'Topcoder')
- : undefined
- const isElectronicallyAgreeable = termsDetails?.agreeabilityType
- ? termsDetails.agreeabilityType === 'Electronically-agreeable'
- : true
-
- return {
- isElectronicallyAgreeable,
- termsBody,
- termsTitle,
- }
-}
-
const getPageTitle = (
engagement?: Engagement,
canViewPrivateEngagement?: boolean,
@@ -277,240 +207,6 @@ const renderApplicationStatus = (label?: string): JSX.Element | undefined => {
)
}
-type TermsModalProps = {
- open: boolean
- onClose: () => void
- termsLabel?: string
- termsTitle: string
- termsBody?: string
- termsLoading: boolean
- termsError?: string
- termsAgreeing: boolean
- isElectronicallyAgreeable: boolean
- isDocuSignTerm: boolean
- docuSignUrl?: string
- docuSignLoading: boolean
- termsUrl?: string
- onAgree: () => void
- onOpenTermsLink: () => void
- onDocuSignFrameLoad?: (event: SyntheticEvent) => void
-}
-
-type TermsModalButtonProps = Pick<
- TermsModalProps,
- | 'isDocuSignTerm'
- | 'isElectronicallyAgreeable'
- | 'termsAgreeing'
- | 'termsLoading'
- | 'termsUrl'
- | 'onClose'
- | 'onAgree'
- | 'onOpenTermsLink'
->
-
-type TermsModalDocuSignProps = Pick<
- TermsModalProps,
- | 'docuSignLoading'
- | 'docuSignUrl'
- | 'termsAgreeing'
- | 'termsTitle'
- | 'termsUrl'
- | 'onDocuSignFrameLoad'
->
-
-type TermsModalBodyProps = Pick<
- TermsModalProps,
- | 'termsBody'
- | 'termsUrl'
->
-
-type TermsModalContentProps = TermsModalDocuSignProps
- & TermsModalBodyProps
- & Pick
-
-const getTermsModalDescription = (termsLabel?: string): string => (
- termsLabel
- ? `You are seeing the ${termsLabel} because you are applying to an engagement. `
- + 'You must agree to continue.'
- : 'You are seeing these Terms & Conditions because you are applying to an engagement. '
- + 'You must agree to continue.'
-)
-
-const renderTermsModalButtons = (props: TermsModalButtonProps): JSX.Element => {
- if (props.isDocuSignTerm) {
- return (
-
- )
- }
-
- if (props.isElectronicallyAgreeable) {
- return (
- <>
-
-
- >
- )
- }
-
- return (
- <>
-
-
- >
- )
-}
-
-const renderTermsDocuSignSection = (props: TermsModalDocuSignProps): JSX.Element => {
- const isProcessing = props.docuSignLoading || props.termsAgreeing
- const statusMessage = props.docuSignLoading
- ? 'Loading agreement...'
- : 'Finalizing your agreement...'
-
- return (
-
- {isProcessing && (
-
-
- {statusMessage}
-
- )}
- {!isProcessing && props.docuSignUrl && (
-
- )}
- {!isProcessing && !props.docuSignUrl && (
-
- )}
-
- )
-}
-
-const renderTermsBodySection = (props: TermsModalBodyProps): JSX.Element => (
-
- {props.termsBody ? (
-
- ) : (
-
- )}
-
-)
-
-const renderTermsModalContent = (props: TermsModalContentProps): JSX.Element => {
- if (props.termsLoading) {
- return (
-
-
-
- )
- }
-
- if (props.isDocuSignTerm) {
- return renderTermsDocuSignSection(props)
- }
-
- return renderTermsBodySection(props)
-}
-
-const TermsModal: FC = (props: TermsModalProps): JSX.Element => {
- const description = getTermsModalDescription(props.termsLabel)
- const buttonProps: TermsModalButtonProps = {
- isDocuSignTerm: props.isDocuSignTerm,
- isElectronicallyAgreeable: props.isElectronicallyAgreeable,
- onAgree: props.onAgree,
- onClose: props.onClose,
- onOpenTermsLink: props.onOpenTermsLink,
- termsAgreeing: props.termsAgreeing,
- termsLoading: props.termsLoading,
- termsUrl: props.termsUrl,
- }
- const contentProps: TermsModalContentProps = {
- docuSignLoading: props.docuSignLoading,
- docuSignUrl: props.docuSignUrl,
- isDocuSignTerm: props.isDocuSignTerm,
- onDocuSignFrameLoad: props.onDocuSignFrameLoad,
- termsAgreeing: props.termsAgreeing,
- termsBody: props.termsBody,
- termsLoading: props.termsLoading,
- termsTitle: props.termsTitle,
- termsUrl: props.termsUrl,
- }
-
- return (
-
-
- {description}
-
- {renderTermsModalContent(contentProps)}
- {props.termsError && props.open && (
- {props.termsError}
- )}
-
- )
-}
-
const EngagementDetailPage: FC = () => {
const params = useParams<{ nanoId: string }>()
const nanoId = params.nanoId
@@ -530,16 +226,6 @@ const EngagementDetailPage: FC = () => {
const [hasApplied, setHasApplied] = useState(false)
const [checkingApplication, setCheckingApplication] = useState(false)
const [applicationError, setApplicationError] = useState(undefined)
- const [termsModalOpen, setTermsModalOpen] = useState(false)
- const [activeTerm, setActiveTerm] = useState(undefined)
- const [termsDetails, setTermsDetails] = useState(undefined)
- const [termsLoading, setTermsLoading] = useState(false)
- const [termsError, setTermsError] = useState(undefined)
- const [termsAgreeing, setTermsAgreeing] = useState(false)
- const [docuSignUrl, setDocuSignUrl] = useState(undefined)
- const [docuSignLoading, setDocuSignLoading] = useState(false)
- const docuSignCallbackHandledRef = useRef(false)
- const termsUrl = activeTerm?.url
const normalizedUserId = normalizeUserId(userId)
const normalizedCreatedBy = engagement?.createdBy?.trim()
const normalizedCreatorEmail = engagement?.createdByEmail
@@ -555,6 +241,15 @@ const EngagementDetailPage: FC = () => {
normalizedUserId,
})
const [profileGateError, setProfileGateError] = useState()
+ const {
+ isCheckingTerms,
+ isFinalizingAgreement,
+ modalState: termsModalState,
+ startTermsAgreementFlow,
+ termsError,
+ }: ReturnType = useTermsAgreementGate({
+ contextDescription: 'you are applying to an engagement',
+ })
const isPrivateEngagement = Boolean(engagement?.isPrivate)
@@ -630,61 +325,6 @@ const EngagementDetailPage: FC = () => {
navigate(`${rootRoute}/${nanoId}/apply`)
}, [nanoId, navigate])
- const fetchPendingTerm = useCallback(async (): Promise<{
- pendingTerm?: TermsConfig
- details?: TermDetails
- } | undefined> => {
- if (!TERMS_ID || !NDA_TERMS_ID) {
- setTermsError('Unable to verify terms and NDA. Please try again later.')
- return undefined
- }
-
- setTermsError(undefined)
- setTermsLoading(true)
-
- try {
- const results = await Promise.all(
- TERMS_CONFIG.map(async term => ({
- details: await getTermDetails(term.id),
- term,
- })),
- )
- const nextPending = results.find(entry => !entry.details?.agreed)
- if (!nextPending) {
- return {}
- }
-
- return {
- details: nextPending.details,
- pendingTerm: nextPending.term,
- }
- } catch {
- setTermsError('Unable to verify terms of use. Please try again.')
- return undefined
- } finally {
- setTermsLoading(false)
- }
- }, [])
-
- const openNextPendingTerm = useCallback(async () => {
- const pending = await fetchPendingTerm()
- if (!pending) {
- return
- }
-
- if (!pending.pendingTerm || !pending.details) {
- setActiveTerm(undefined)
- setTermsDetails(undefined)
- setTermsModalOpen(false)
- navigateToApply()
- return
- }
-
- setActiveTerm(pending.pendingTerm)
- setTermsDetails(pending.details)
- setTermsModalOpen(true)
- }, [fetchPendingTerm, navigateToApply])
-
const handleApplyClick = useCallback(() => {
setProfileGateError(undefined)
@@ -703,8 +343,8 @@ const EngagementDetailPage: FC = () => {
return
}
- openNextPendingTerm()
- }, [openNextPendingTerm, profileCompleteness])
+ startTermsAgreementFlow(navigateToApply)
+ }, [navigateToApply, profileCompleteness, startTermsAgreementFlow])
const handleBackClick = useCallback(() => navigate(rootRoute || '/'), [navigate])
@@ -715,150 +355,6 @@ const EngagementDetailPage: FC = () => {
const handleRetry = useCallback(() => fetchEngagement(), [fetchEngagement])
- const handleTermsClose = useCallback(() => {
- setTermsModalOpen(false)
- setTermsError(undefined)
- setTermsAgreeing(false)
- setDocuSignUrl(undefined)
- setDocuSignLoading(false)
- setActiveTerm(undefined)
- setTermsDetails(undefined)
- docuSignCallbackHandledRef.current = false
- }, [])
-
- const handleAgreeTerms = useCallback(async () => {
- if (!activeTerm?.id) {
- setTermsError('Unable to verify terms of use. Please try again later.')
- return
- }
-
- setTermsAgreeing(true)
- setTermsError(undefined)
-
- try {
- const response = await agreeToTerm(activeTerm.id)
- if (response?.success === false) {
- throw new Error('Terms agreement failed')
- }
-
- await openNextPendingTerm()
- } catch {
- setTermsError('Unable to save your agreement. Please try again.')
- } finally {
- setTermsAgreeing(false)
- }
- }, [activeTerm?.id, openNextPendingTerm])
-
- const handleOpenTermsLink = useCallback(() => {
- if (!termsUrl) {
- return
- }
-
- window.open(termsUrl, '_blank', 'noopener,noreferrer')
- }, [termsUrl])
-
- const handleDocuSignComplete = useCallback(async () => {
- if (!activeTerm?.id) {
- return
- }
-
- const termId = activeTerm.id
- const checkAgreement = async (attempt: number): Promise => {
- const details = await getTermDetails(termId)
- setTermsDetails(details)
-
- if (details.agreed || attempt >= DOCUSIGN_POLL_MAX_ATTEMPTS) {
- return details
- }
-
- await delay(DOCUSIGN_POLL_DELAY_MS)
- return checkAgreement(attempt + 1)
- }
-
- setTermsAgreeing(true)
- setTermsError(undefined)
-
- try {
- const details = await checkAgreement(1)
- if (!details.agreed) {
- setTermsError('We could not confirm your signature yet. Please try again.')
- return
- }
-
- await openNextPendingTerm()
- } catch {
- setTermsError('Unable to verify your agreement. Please try again.')
- } finally {
- setTermsAgreeing(false)
- }
- }, [activeTerm?.id, openNextPendingTerm])
-
- const handleDocuSignCallback = useCallback(() => {
- if (docuSignCallbackHandledRef.current) {
- return
- }
-
- docuSignCallbackHandledRef.current = true
- setTermsModalOpen(false)
- handleDocuSignComplete()
- }, [handleDocuSignComplete])
-
- const handleDocuSignFrameLoad = useCallback((event: SyntheticEvent) => {
- try {
- const frameLocation = event.currentTarget.contentWindow?.location?.href
- if (isDocuSignReturnUrl(frameLocation)) {
- handleDocuSignCallback()
- }
- } catch {
- // Ignore cross-origin iframe loads.
- }
- }, [handleDocuSignCallback])
-
- const docuSignTemplateId = termsDetails?.docusignTemplateId
- const isDocuSignTerm = Boolean(
- termsDetails?.agreeabilityType
- && termsDetails.agreeabilityType !== 'Electronically-agreeable'
- && docuSignTemplateId,
- )
-
- useEffect(() => {
- if (!termsModalOpen || !isDocuSignTerm || !docuSignTemplateId) {
- setDocuSignUrl(undefined)
- setDocuSignLoading(false)
- return
- }
-
- docuSignCallbackHandledRef.current = false
- const returnUrl = buildDocuSignReturnUrl()
- setDocuSignLoading(true)
- setDocuSignUrl(undefined)
- getDocuSignUrl(docuSignTemplateId, returnUrl)
- .then(url => setDocuSignUrl(url))
- .catch(() => setTermsError('Unable to load the agreement. Please try again.'))
- .finally(() => setDocuSignLoading(false))
- }, [docuSignTemplateId, isDocuSignTerm, termsModalOpen])
-
- useEffect(() => {
- if (!termsModalOpen || !docuSignUrl) {
- return undefined
- }
-
- const handler = (event: MessageEvent): void => {
- if (!event?.data || event.data.type !== 'DocuSign') {
- return
- }
-
- if (event.data.event === 'signing_complete' || event.data.event === 'viewing_complete') {
- handleDocuSignCallback()
- } else {
- handleTermsClose()
- }
- }
-
- window.addEventListener('message', handler)
- return () => window.removeEventListener('message', handler)
- }, [docuSignUrl, handleDocuSignCallback, handleTermsClose, termsModalOpen])
-
const isEngagementOpen = engagement?.status === EngagementStatus.OPEN
const normalizedRoles = normalizeRoleNames(profileContext.profile?.roles)
@@ -877,11 +373,9 @@ const EngagementDetailPage: FC = () => {
})
const applicationStatusLabel = getApplicationStatusLabel(application)
- const termsLabel = activeTerm?.label
- const { termsTitle, termsBody, isElectronicallyAgreeable }: TermsViewData = getTermsViewData(termsDetails)
const renderTermsGate = (): JSX.Element | undefined => {
- if (termsLoading) {
+ if (isCheckingTerms) {
return (
@@ -890,7 +384,7 @@ const EngagementDetailPage: FC = () => {
)
}
- if (termsAgreeing && !termsModalOpen) {
+ if (isFinalizingAgreement) {
return (
@@ -899,7 +393,7 @@ const EngagementDetailPage: FC = () => {
)
}
- if (termsError && !termsModalOpen) {
+ if (termsError && !termsModalState.open) {
return (
{termsError}
@@ -1039,7 +533,7 @@ const EngagementDetailPage: FC = () => {
Private engagement
- Only task managers, project managers, administrators, and assigned members can view this engagement.
+ Only talent managers, administrators, and assigned members can view this engagement.
)
@@ -1211,24 +705,7 @@ const EngagementDetailPage: FC = () => {
}}
>
{renderContent()}
-
+
)
}
diff --git a/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.spec.tsx b/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.spec.tsx
new file mode 100644
index 000000000..7dc9ade8e
--- /dev/null
+++ b/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.spec.tsx
@@ -0,0 +1,202 @@
+/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, sort-keys */
+import '@testing-library/jest-dom'
+
+import React from 'react'
+import { render, screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+
+import type { Engagement } from '../../lib/models'
+import { EngagementStatus } from '../../lib/models'
+import { getMyAssignedEngagements } from '../../lib/services/engagements.service'
+
+import MyAssignmentsPage from './MyAssignmentsPage'
+
+const mockNavigate = jest.fn()
+const mockUseProfileContext = jest.fn()
+const mockUseProfileCompleteness = jest.fn()
+const mockGetMyAssignedEngagements = getMyAssignedEngagements as jest.MockedFunction<
+ typeof getMyAssignedEngagements
+>
+
+jest.mock('react-router-dom', () => ({
+ useNavigate: () => mockNavigate,
+}))
+
+jest.mock('react-toastify', () => ({
+ toast: {
+ error: jest.fn(),
+ success: jest.fn(),
+ },
+}))
+
+jest.mock('~/config', () => ({
+ EnvironmentConfig: {
+ TC_DOMAIN: 'topcoder.com',
+ URLS: {
+ USER_PROFILE: 'https://topcoder.com/members',
+ },
+ },
+}), { virtual: true })
+
+jest.mock('~/libs/core', () => ({
+ useProfileContext: () => mockUseProfileContext(),
+ useProfileCompleteness: () => mockUseProfileCompleteness(),
+}), { virtual: true })
+
+jest.mock('~/libs/ui', () => ({
+ Button: (props: {
+ disabled?: boolean
+ label: string
+ onClick?: () => void
+ }) => (
+
+ ),
+ ContentLayout: (props: {
+ children: React.ReactNode
+ title: string
+ }) => (
+
+ {props.title}
+ {props.children}
+
+ ),
+ IconOutline: {
+ ExclamationIcon: () => error-icon,
+ SearchIcon: () => search-icon,
+ },
+ LoadingSpinner: () => loading-spinner ,
+}), { virtual: true })
+
+jest.mock('~/apps/admin/src/lib/components/common/Pagination', () => ({
+ Pagination: () => pagination ,
+}), { virtual: true })
+
+jest.mock('../../lib', () => ({
+ useTermsAgreementGate: () => ({
+ modalState: {
+ open: false,
+ },
+ startTermsAgreementFlow: jest.fn(),
+ termsError: undefined,
+ }),
+}))
+
+jest.mock('../../lib/services/engagements.service', () => ({
+ acceptAssignmentOffer: jest.fn(),
+ getMyAssignedEngagements: jest.fn(),
+ rejectAssignmentOffer: jest.fn(),
+}))
+
+jest.mock('../../engagements.routes', () => ({
+ rootRoute: '/engagements',
+}))
+
+jest.mock('../../components', () => ({
+ AssignmentCard: (props: {
+ assignment?: { status?: string }
+ engagement: { id: string; title: string }
+ onAcceptOffer?: () => void
+ profileGateError?: string
+ }) => (
+
+ {props.engagement.title}
+ {props.assignment?.status?.toLowerCase() === 'selected' && (
+
+ )}
+ {props.profileGateError && {props.profileGateError} }
+
+ ),
+ AssignmentOfferModal: () => <>>,
+ EngagementsTabs: () => engagement-tabs ,
+ MemberExperienceModal: () => <>>,
+ TermsAgreementModal: () => <>>,
+}))
+
+const buildEngagement = (
+ id: string,
+ title: string,
+ assignmentStatus: string,
+): Engagement => ({
+ id,
+ nanoId: `${id}-nano`,
+ projectId: `${id}-project`,
+ title,
+ description: `${title} description`,
+ duration: {},
+ timeZones: [],
+ countries: [],
+ requiredSkills: [],
+ status: EngagementStatus.OPEN,
+ createdAt: '2026-03-25T00:00:00.000Z',
+ updatedAt: '2026-03-25T00:00:00.000Z',
+ createdBy: 'talent-manager',
+ assignments: [
+ {
+ id: `${id}-assignment`,
+ engagementId: id,
+ memberId: '123',
+ memberHandle: 'incomplete-member',
+ status: assignmentStatus,
+ createdAt: '2026-03-25T00:00:00.000Z',
+ updatedAt: '2026-03-25T00:00:00.000Z',
+ },
+ ],
+})
+
+describe('MyAssignmentsPage', () => {
+ beforeEach(() => {
+ mockNavigate.mockReset()
+ mockUseProfileContext.mockReturnValue({
+ isLoggedIn: true,
+ profile: {
+ handle: 'incomplete-member',
+ userId: 123,
+ },
+ })
+ mockUseProfileCompleteness.mockReturnValue({
+ isLoading: false,
+ percent: 80,
+ })
+ mockGetMyAssignedEngagements.mockResolvedValue({
+ data: [
+ buildEngagement('eng-1', 'Private Engagement 1', 'selected'),
+ buildEngagement('eng-2', 'Private Engagement 2', 'assigned'),
+ ],
+ page: 1,
+ perPage: 20,
+ total: 2,
+ totalPages: 1,
+ })
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('shows the incomplete profile message only on the selected card where accept was clicked', async () => {
+ const user = userEvent.setup()
+
+ render( )
+
+ const acceptOfferButton = await screen.findByRole('button', { name: 'Accept Offer' })
+
+ await user.click(acceptOfferButton)
+
+ const firstCard = screen.getByTestId('assignment-card-eng-1')
+ const secondCard = screen.getByTestId('assignment-card-eng-2')
+ const gateMessage = 'Your profile must be 100% complete before accepting this offer.'
+
+ expect(within(firstCard)
+ .getByText(gateMessage))
+ .toBeInTheDocument()
+ expect(within(secondCard)
+ .queryByText(gateMessage))
+ .not.toBeInTheDocument()
+ expect(screen.getAllByText(gateMessage))
+ .toHaveLength(1)
+ })
+})
diff --git a/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.tsx b/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.tsx
index 100d6dda6..93331f4fa 100644
--- a/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.tsx
+++ b/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.tsx
@@ -9,6 +9,7 @@ import { Pagination } from '~/apps/admin/src/lib/components/common/Pagination'
import { APPLICATIONS_PER_PAGE } from '../../config/constants'
import type { Engagement, EngagementAssignment } from '../../lib/models'
+import { useTermsAgreementGate } from '../../lib'
import {
acceptAssignmentOffer,
getMyAssignedEngagements,
@@ -19,6 +20,7 @@ import {
AssignmentOfferModal,
EngagementsTabs,
MemberExperienceModal,
+ TermsAgreementModal,
} from '../../components'
import { rootRoute } from '../../engagements.routes'
@@ -27,6 +29,12 @@ import styles from './MyAssignmentsPage.module.scss'
const PER_PAGE = APPLICATIONS_PER_PAGE
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const IP_ADDRESS_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/
+const PROFILE_GATE_ERROR_MESSAGE = 'Your profile must be 100% complete before accepting this offer.'
+
+type ProfileGateState = {
+ engagementId: string
+ message: string
+}
const getBaseDomainFromHostname = (hostname: string): string | undefined => {
const normalized = hostname.trim()
@@ -78,7 +86,14 @@ const MyAssignmentsPage: FC = () => {
const userId = profileContext.profile?.userId
const profileHandle = profileContext.profile?.handle
const profileCompleteness = useProfileCompleteness(profileHandle)
- const [profileGateError, setProfileGateError] = useState ()
+ const [profileGateState, setProfileGateState] = useState()
+ const {
+ modalState: termsModalState,
+ startTermsAgreementFlow,
+ termsError,
+ }: ReturnType = useTermsAgreementGate({
+ contextDescription: 'you are accepting a private engagement offer',
+ })
const [assignments, setAssignments] = useState([])
const [loading, setLoading] = useState(false)
@@ -122,6 +137,12 @@ const MyAssignmentsPage: FC = () => {
fetchAssignments()
}, [fetchAssignments])
+ useEffect(() => {
+ if (termsError && !termsModalState.open) {
+ toast.error(termsError)
+ }
+ }, [termsError, termsModalState.open])
+
useEffect(() => (
() => {
if (closeTimeoutRef.current !== undefined) {
@@ -336,7 +357,7 @@ const MyAssignmentsPage: FC = () => {
}
const handleAcceptOfferClick = function (): void {
- setProfileGateError(undefined)
+ setProfileGateState(undefined)
if (profileCompleteness?.isLoading) {
return
@@ -347,13 +368,16 @@ const MyAssignmentsPage: FC = () => {
&& typeof profileCompleteness.percent === 'number'
&& profileCompleteness.percent < 100
) {
- setProfileGateError(
- 'Your profile must be 100% complete before applying.',
- )
+ setProfileGateState({
+ engagementId: engagement.id,
+ message: PROFILE_GATE_ERROR_MESSAGE,
+ })
return
}
- handleOpenOfferModal(engagement, 'accept')
+ startTermsAgreementFlow(() => {
+ handleOpenOfferModal(engagement, 'accept')
+ })
}
const handleRejectOfferClick = function (): void {
@@ -372,7 +396,11 @@ const MyAssignmentsPage: FC = () => {
onRejectOffer={handleRejectOfferClick}
onContactTalentManager={handleContactTalentManager}
canContactTalentManager={Boolean(contactEmail)}
- profileGateError={profileGateError}
+ profileGateError={
+ profileGateState?.engagementId === engagement.id
+ ? profileGateState.message
+ : undefined
+ }
profileHandle={profileHandle}
/>
)
@@ -408,6 +436,7 @@ const MyAssignmentsPage: FC = () => {
loading={offerSaving}
/>
)}
+
)
}
diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx
index 259e02e82..3edc81d48 100644
--- a/src/apps/platform/src/platform.routes.tsx
+++ b/src/apps/platform/src/platform.routes.tsx
@@ -10,6 +10,7 @@ import { walletRoutes } from '~/apps/wallet'
import { walletAdminRoutes } from '~/apps/wallet-admin'
import { copilotsRoutes } from '~/apps/copilots'
import { adminRoutes } from '~/apps/admin'
+import { reportsRoutes } from '~/apps/reports'
import { reviewRoutes } from '~/apps/review'
import { calendarRoutes } from '~/apps/calendar'
import { engagementsRoutes } from '~/apps/engagements'
@@ -46,5 +47,6 @@ export const platformRoutes: Array = [
...engagementsRoutes,
...homeRoutes,
...adminRoutes,
+ ...reportsRoutes,
...customerPortalRoutes,
]
diff --git a/src/apps/profiles/src/components/tc-achievements/ChallengeHistoryView/ChallengeHistoryView.tsx b/src/apps/profiles/src/components/tc-achievements/ChallengeHistoryView/ChallengeHistoryView.tsx
index 913a209b5..60e522e2d 100644
--- a/src/apps/profiles/src/components/tc-achievements/ChallengeHistoryView/ChallengeHistoryView.tsx
+++ b/src/apps/profiles/src/components/tc-achievements/ChallengeHistoryView/ChallengeHistoryView.tsx
@@ -1,32 +1,25 @@
import { FC } from 'react'
-import { MemberStats, StatsHistory, UserProfile } from '~/libs/core'
-
-import { useTrackHistory } from '../../../hooks'
+import { StatsHistory } from '~/libs/core'
import { ChallengeHistoryCard } from './ChallengeHistoryCard'
import styles from './ChallengeHistoryView.module.scss'
interface ChallengeHistoryViewProps {
- profile: UserProfile
- trackData: MemberStats
+ trackHistory: StatsHistory[]
}
-const ChallengeHistoryView: FC = props => {
- const trackHistory: StatsHistory[] = useTrackHistory(props.profile?.handle, props.trackData)
-
- return (
-
-
- {trackHistory.map(challenge => (
-
- ))}
-
+const ChallengeHistoryView: FC = props => (
+
+
+ {props.trackHistory.map(challenge => (
+
+ ))}
- )
-}
+
+)
export default ChallengeHistoryView
diff --git a/src/apps/profiles/src/components/tc-achievements/DevelopTrackView/DevelopTrackView.tsx b/src/apps/profiles/src/components/tc-achievements/DevelopTrackView/DevelopTrackView.tsx
index 78376b000..5b266e3ed 100644
--- a/src/apps/profiles/src/components/tc-achievements/DevelopTrackView/DevelopTrackView.tsx
+++ b/src/apps/profiles/src/components/tc-achievements/DevelopTrackView/DevelopTrackView.tsx
@@ -3,42 +3,52 @@ import { isEmpty } from 'lodash'
import {
MemberStats,
- UserProfile,
+ StatsHistory,
UserStatsDistributionResponse,
useStatsDistribution,
} from '~/libs/core'
import { DetailedTrackView } from '../DetailedTrackView'
import { ChallengeHistoryView } from '../ChallengeHistoryView'
-import { useTrackHistory } from '../../../hooks'
interface DevelopTrackViewProps {
- profile: UserProfile
trackData: MemberStats
+ trackHistory: StatsHistory[]
}
const DevelopTrackView: FC = props => {
const trackName: string = (props.trackData as MemberStats).name ?? 'SRM'
- const trackHistory = useTrackHistory(props.profile?.handle, props.trackData)
-
- const ratingDistribution: UserStatsDistributionResponse | undefined = useStatsDistribution({
- subTrack: trackName,
- track: props.trackData.parentTrack,
- })
+ const isDesignTrack = useMemo(() => props.trackData.parentTrack === 'DESIGN', [props.trackData.parentTrack])
+ const isFirst2FinishTrack = useMemo(() => [
+ 'FIRST_2_FINISH',
+ 'First2Finish',
+ 'DESIGN_FIRST_2_FINISH',
+ ].includes(trackName), [trackName])
+
+ const ratingDistribution: UserStatsDistributionResponse | undefined = useStatsDistribution(
+ isDesignTrack ? undefined : {
+ subTrack: trackName,
+ track: props.trackData.parentTrack,
+ },
+ )
const showDetailsViewBtn = useMemo(() => (
- (!!ratingDistribution && !isEmpty(ratingDistribution))
- || (!!trackHistory && !isEmpty(trackHistory))
- ), [ratingDistribution, trackHistory])
+ !isDesignTrack
+ && !isFirst2FinishTrack
+ && (
+ (!!ratingDistribution && !isEmpty(ratingDistribution))
+ || (!!props.trackHistory && !isEmpty(props.trackHistory))
+ )
+ ), [isDesignTrack, isFirst2FinishTrack, props.trackHistory, ratingDistribution])
return (
+
)}
/>
)
diff --git a/src/apps/profiles/src/components/tc-achievements/StatsDetailsLayout/StatsDetailsLayout.tsx b/src/apps/profiles/src/components/tc-achievements/StatsDetailsLayout/StatsDetailsLayout.tsx
index fd9390b1d..1d89f258c 100644
--- a/src/apps/profiles/src/components/tc-achievements/StatsDetailsLayout/StatsDetailsLayout.tsx
+++ b/src/apps/profiles/src/components/tc-achievements/StatsDetailsLayout/StatsDetailsLayout.tsx
@@ -5,7 +5,7 @@ import { MemberStats } from '~/libs/core'
import { StatsNavHeader } from '../StatsNavHeader'
import { StatsSummaryBlock } from '../StatsSummaryBlock'
-import { MemberStatsTrack } from '../../../hooks/useFetchActiveTracks'
+import { getSubTrackSubmissionCount, MemberStatsTrack } from '../../../hooks/useFetchActiveTracks'
import styles from './StatsDetailsLayout.module.scss'
@@ -34,7 +34,10 @@ const StatsDetailsLayout: FC = props => (
trackTitle={props.title}
challenges={props.trackData.challenges}
wins={props.trackData.wins}
- submissions={(props.trackData as MemberStats).submissions?.submissions ?? props.trackData.submissions}
+ submissions={
+ getSubTrackSubmissionCount(props.trackData as MemberStats)
+ ?? (typeof props.trackData.submissions === 'number' ? props.trackData.submissions : undefined)
+ }
ranking={(props.trackData as MemberStats).rank?.rank}
rating={(props.trackData as MemberStats).rank?.rating ?? (props.trackData as MemberStatsTrack).rating}
volatility={(props.trackData as MemberStats).rank?.volatility}
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 692bc26a9..c389ce395 100644
--- a/src/apps/profiles/src/components/tc-achievements/StatsSummaryBlock/StatsSummaryBlock.tsx
+++ b/src/apps/profiles/src/components/tc-achievements/StatsSummaryBlock/StatsSummaryBlock.tsx
@@ -28,6 +28,7 @@ const StatsSummaryBlock: FC = props => {
...(props.trackId ? { id: props.trackId } : {}),
name: props.trackTitle,
}), 'fields')
+ const wins = props.wins ?? 0
const isFieldVisible = (field: string): boolean => (
!visibleFields || visibleFields[field]
@@ -60,11 +61,11 @@ const StatsSummaryBlock: FC = props => {
{isFieldVisible('wins') && (
- {props.wins}
+ {wins}
- {formatPlural(props.wins || 0, 'Win')}
+ {formatPlural(wins, 'Win')}
diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx
new file mode 100644
index 000000000..df3db9434
--- /dev/null
+++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx
@@ -0,0 +1,108 @@
+import { UserStats } from '~/libs/core'
+
+import { getActiveTracks, MemberStatsTrack } from './useFetchActiveTracks'
+
+describe('getActiveTracks', () => {
+ it('keeps unified design and development subtracks visible', () => {
+ const activeTracks: MemberStatsTrack[] = getActiveTracks({
+ DATA_SCIENCE: {
+ MARATHON_MATCH: {
+ challenges: 4,
+ rank: {
+ percentile: 20,
+ rating: 900,
+ },
+ wins: 4,
+ },
+ },
+ DESIGN: {
+ subTracks: [
+ {
+ challenges: 20,
+ name: 'Challenge',
+ wins: 16,
+ },
+ ],
+ },
+ DEVELOP: {
+ subTracks: [
+ {
+ challenges: 24,
+ name: 'Challenge',
+ submissions: {
+ submissions: 24,
+ },
+ wins: 23,
+ },
+ {
+ challenges: 2,
+ name: 'Task',
+ submissions: {
+ submissions: 2,
+ },
+ wins: 2,
+ },
+ ],
+ },
+ } as UserStats)
+ const trackNames: string[] = activeTracks.map(track => track.name)
+ const designTrackNames: string[] | undefined = activeTracks
+ .find(track => track.name === 'Design')
+ ?.subTracks
+ .map(track => track.name)
+ const developmentTrackNames: string[] | undefined = activeTracks
+ .find(track => track.name === 'Development')
+ ?.subTracks
+ .map(track => track.name)
+
+ expect(trackNames)
+ .toEqual(expect.arrayContaining([
+ 'Design',
+ 'Development',
+ 'Data Science',
+ ]))
+ expect(trackNames).not.toContain('Testing')
+ expect(designTrackNames)
+ .toEqual(['Challenge'])
+ expect(developmentTrackNames)
+ .toEqual(['Challenge', 'Task'])
+ })
+
+ it('keeps legacy testing subtracks in the testing track', () => {
+ const activeTracks: MemberStatsTrack[] = getActiveTracks({
+ DEVELOP: {
+ subTracks: [
+ {
+ challenges: 10,
+ name: 'DEVELOPMENT',
+ submissions: {
+ submissions: 10,
+ },
+ wins: 3,
+ },
+ {
+ challenges: 5,
+ name: 'BUG_HUNT',
+ submissions: {
+ submissions: 5,
+ },
+ wins: 1,
+ },
+ ],
+ },
+ } as UserStats)
+ const developmentTrackNames: string[] | undefined = activeTracks
+ .find(track => track.name === 'Development')
+ ?.subTracks
+ .map(track => track.name)
+ const testingTrackNames: string[] | undefined = activeTracks
+ .find(track => track.name === 'Testing')
+ ?.subTracks
+ .map(track => track.name)
+
+ expect(developmentTrackNames)
+ .toEqual(['DEVELOPMENT'])
+ expect(testingTrackNames)
+ .toEqual(['BUG_HUNT'])
+ })
+})
diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx
index 081b408f1..34628707b 100644
--- a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx
+++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx
@@ -5,6 +5,12 @@ import { MemberStats, SRMStats, useMemberStats, UserStats } from '~/libs/core'
import { calcProportionalAverage } from '../lib/math.utils'
+const testingSubTrackNames = new Set([
+ 'BUG_HUNT',
+ 'TEST_SCENARIOS',
+ 'TEST_SUITES',
+])
+
/**
* The structure of a track for a member.
*/
@@ -24,6 +30,72 @@ export interface MemberStatsTrack {
isCPTrack?: boolean
}
+/**
+ * Return the explicit submission count when the stats payload includes one.
+ *
+ * Legacy stats include submission counters, while unified stats may omit them.
+ *
+ * @param {MemberStats | undefined} subTrack - The subtrack to inspect.
+ * @returns {number | undefined} The submission count when available.
+ */
+export const getSubTrackSubmissionCount = (subTrack?: MemberStats): number | undefined => {
+ const submissionCount = subTrack?.submissions?.submissions ?? subTrack?.submissions
+
+ return typeof submissionCount === 'number' ? submissionCount : undefined
+}
+
+/**
+ * Determine whether the subtrack should be considered active.
+ *
+ * Unified member stats do not currently include legacy submission counters for
+ * development/design rows, so fall back to the challenge count when the
+ * submission count is unavailable.
+ *
+ * @param {MemberStats | undefined} subTrack - The subtrack to inspect.
+ * @returns {boolean} Whether the subtrack has activity worth rendering.
+ */
+const isActiveSubTrack = (subTrack?: MemberStats): boolean => {
+ const submissionCount = getSubTrackSubmissionCount(subTrack)
+
+ return (submissionCount ?? subTrack?.challenges ?? 0) > 0
+}
+
+/**
+ * Determine whether the subtrack belongs in the legacy Testing track.
+ *
+ * Unified stats return generic development subtrack names such as `Challenge`
+ * and `Task`, so anything outside the explicit legacy testing set should stay
+ * visible under Development instead of being dropped.
+ *
+ * @param {MemberStats | undefined} subTrack - The subtrack to inspect.
+ * @returns {boolean} Whether the subtrack should render in Testing.
+ */
+const isTestingSubTrack = (subTrack?: MemberStats): boolean => (
+ !!subTrack?.name && testingSubTrackNames.has(subTrack.name)
+)
+
+/**
+ * Attach parent track metadata to legacy design/develop subtracks and index them by name.
+ *
+ * @param {string} parentTrack - The top-level track that owns these subtracks.
+ * @param {MemberStats[] | undefined} subTracks - The raw subtrack list from member stats.
+ * @returns {{[key: string]: MemberStats}} Map of subtracks keyed by subtrack name.
+ */
+const mapSubTracksByName = (
+ parentTrack: 'DESIGN' | 'DEVELOP',
+ subTracks?: MemberStats[],
+): {[key: string]: MemberStats} => (
+ subTracks?.reduce((all, subTrack) => {
+ all[subTrack.name] = {
+ ...subTrack,
+ parentTrack,
+ path: `${parentTrack}.subTracks`,
+ }
+
+ return all
+ }, {} as {[key: string]: MemberStats}) ?? {}
+)
+
/**
* Helper function to build aggregated data for a track.
*
@@ -32,23 +104,22 @@ export interface MemberStatsTrack {
* @returns {MemberStatsTrack} - Aggregated data for the track.
*/
const buildTrackData = (trackName: string, allSubTracks: MemberStats[]): MemberStatsTrack => {
- const subTracks = allSubTracks.filter(s => (
- (s.submissions?.submissions ?? (s.submissions as unknown as number)) > 0
- ))
+ const subTracks = allSubTracks.filter(isActiveSubTrack)
// Calculate total wins, challenges, and submissions for the track
const totalWins = subTracks.reduce((sum, subTrack) => (sum + (subTrack?.wins || 0)), 0)
const challengesCount = subTracks.reduce((sum, subTrack) => (sum + (subTrack?.challenges || 0)), 0)
const submissionsCount = subTracks.reduce((sum, subTrack) => (
- sum + (subTrack?.submissions?.submissions ?? subTrack?.submissions ?? 0)
+ sum + (getSubTrackSubmissionCount(subTrack) ?? 0)
), 0)
+ const hasSubmissionCounts = subTracks.some(subTrack => getSubTrackSubmissionCount(subTrack) !== undefined)
// Return aggregated track data
return {
challenges: challengesCount,
- isActive: submissionsCount > 0,
+ isActive: subTracks.length > 0,
name: trackName,
order: 1,
- submissions: submissionsCount,
+ submissions: hasSubmissionCounts ? submissionsCount : undefined,
subTracks,
wins: totalWins,
}
@@ -81,14 +152,12 @@ const enhanceDesignTrackData = (trackData: MemberStatsTrack): MemberStatsTrack =
/**
* Custom hook to fetch active tracks for a user, sorted by wins & submissions.
*
- * @param {string} userHandle - The user's handle.
+ * @param {UserStats | undefined} memberStats - The raw stats payload for the user.
* @returns {MemberStatsTrack[]} - List of active tracks for the user.
*/
-export const useFetchActiveTracks = (userHandle: string): MemberStatsTrack[] => {
- const memberStats: UserStats | undefined = useMemberStats(userHandle)
-
+export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => {
// Create mappings for data science subtracks
- const dataScienceSubTracks: {[key: string]: MemberStats | SRMStats} = useMemo(() => ({
+ const dataScienceSubTracks: {[key: string]: MemberStats | SRMStats} = {
// Map MARATHON_MATCH subtrack
MARATHON_MATCH: (memberStats?.DATA_SCIENCE?.MARATHON_MATCH && ({
...memberStats.DATA_SCIENCE.MARATHON_MATCH,
@@ -104,119 +173,80 @@ export const useFetchActiveTracks = (userHandle: string): MemberStatsTrack[] =>
parentTrack: 'DATA_SCIENCE',
path: 'DATA_SCIENCE',
})) as SRMStats & {name: string},
- }), [memberStats])
+ }
// Create mappings for design subtracks
- const designSubTracks: {[key: string]: MemberStats} = useMemo(() => (
- memberStats?.DESIGN?.subTracks.reduce((all, subTrack) => {
- all[subTrack.name] = {
- ...subTrack,
- parentTrack: 'DESIGN',
- path: 'DESIGN.subTracks',
- }
- return all
- }, {} as {[key: string]: MemberStats}) ?? {}
- ), [memberStats])
+ const designSubTracks: {[key: string]: MemberStats} = mapSubTracksByName(
+ 'DESIGN',
+ memberStats?.DESIGN?.subTracks,
+ )
// Create mappings for develop subtracks
- const developSubTracks: {[key: string]: MemberStats} = useMemo(() => (
- memberStats?.DEVELOP?.subTracks.reduce((all, subTrack) => {
- all[subTrack.name] = {
- ...subTrack,
- parentTrack: 'DEVELOP',
- path: 'DEVELOP.subTracks',
- }
- return all
- }, {} as {[key: string]: MemberStats}) ?? {}
- ), [memberStats])
+ const developSubTracks: {[key: string]: MemberStats} = mapSubTracksByName(
+ 'DEVELOP',
+ memberStats?.DEVELOP?.subTracks,
+ )
// Build aggregated stats for Design, Development, Testing, and Competitive Programming tracks
- // Each track is constructed using the buildTrackData helper function
- // The useMemo hook is used to memoize the results for performance optimization
-
// Design
- const designTrackStats: MemberStatsTrack = useMemo(() => (
+ const designTrackStats: MemberStatsTrack = (
enhanceDesignTrackData(
- buildTrackData('Design', [
- designSubTracks.DESIGN_FIRST_2_FINISH,
- designSubTracks.WEB_DESIGNS,
- designSubTracks.LOGO_DESIGN,
- designSubTracks.WIREFRAMES,
- designSubTracks.IDEA_GENERATION,
- designSubTracks.FRONT_END_FLASH,
- designSubTracks.PRINT_OR_PRESENTATION,
- designSubTracks.STUDIO_OTHER,
- designSubTracks.APPLICATION_FRONT_END_DESIGN,
- designSubTracks.BANNERS_OR_ICONS,
- designSubTracks.WIDGET_OR_MOBILE_SCREEN_DESIGN,
- ].filter(Boolean)),
+ buildTrackData('Design', Object.values(designSubTracks)),
)
- ), [developSubTracks, designSubTracks])
+ )
// Development
- const developTrackStats: MemberStatsTrack = useMemo(() => (
- buildTrackData('Development', [
- developSubTracks.DESIGN,
- developSubTracks.DEVELOPMENT,
- developSubTracks.ARCHITECTURE,
- developSubTracks.FIRST_2_FINISH,
- developSubTracks.CODE,
- developSubTracks.ASSEMBLY_COMPETITION,
- developSubTracks.UI_PROTOTYPE_COMPETITION,
- developSubTracks.SPECIFICATION,
- developSubTracks.CONCEPTUALIZATION,
- ].filter(Boolean))
- ), [developSubTracks])
+ const developTrackStats: MemberStatsTrack = (
+ buildTrackData(
+ 'Development',
+ Object.values(developSubTracks)
+ .filter(subTrack => !isTestingSubTrack(subTrack)),
+ )
+ )
// Testing
- const testingTrackStats: MemberStatsTrack = useMemo(() => (
- buildTrackData('Testing', [
- developSubTracks.BUG_HUNT,
- developSubTracks.TEST_SCENARIOS,
- developSubTracks.TEST_SUITES,
- ].filter(Boolean))
- ), [developSubTracks])
+ const testingTrackStats: MemberStatsTrack = (
+ buildTrackData(
+ 'Testing',
+ Object.values(developSubTracks)
+ .filter(isTestingSubTrack),
+ )
+ )
// Data science
- const dsTrackStats: MemberStatsTrack = useMemo(() => {
- // Aggregate stats for DATA SCIENCE track
- const subTracks = [
- dataScienceSubTracks.MARATHON_MATCH,
- ].filter(d => d?.challenges > 0) as MemberStats[]
-
- return {
- challenges: dataScienceSubTracks.MARATHON_MATCH?.challenges ?? 0,
- isActive: (dataScienceSubTracks.MARATHON_MATCH?.challenges ?? 0) > 0,
- isDSTrack: true,
- name: 'Data Science',
- order: -1,
- percentile: dataScienceSubTracks.MARATHON_MATCH?.rank?.percentile ?? 0,
- rating: dataScienceSubTracks.MARATHON_MATCH?.rank?.rating ?? 0,
- subTracks,
- wins: dataScienceSubTracks.MARATHON_MATCH?.wins ?? 0,
- }
- }, [dataScienceSubTracks])
+ const dsSubTracks: MemberStats[] = [
+ dataScienceSubTracks.MARATHON_MATCH,
+ ].filter(d => d?.challenges > 0) as MemberStats[]
+
+ const dsTrackStats: MemberStatsTrack = {
+ challenges: dataScienceSubTracks.MARATHON_MATCH?.challenges ?? 0,
+ isActive: (dataScienceSubTracks.MARATHON_MATCH?.challenges ?? 0) > 0,
+ isDSTrack: true,
+ name: 'Data Science',
+ order: -1,
+ percentile: dataScienceSubTracks.MARATHON_MATCH?.rank?.percentile ?? 0,
+ rating: dataScienceSubTracks.MARATHON_MATCH?.rank?.rating ?? 0,
+ subTracks: dsSubTracks,
+ wins: dataScienceSubTracks.MARATHON_MATCH?.wins ?? 0,
+ }
// Competitive Programming
- const cpTrackStats: MemberStatsTrack = useMemo(() => {
- // Aggregate stats for Competitive Programming track
- const subTracks = [
- dataScienceSubTracks.SRM,
- ].filter(d => d?.challenges > 0) as MemberStats[]
-
- return {
- challenges: dataScienceSubTracks.SRM?.challenges ?? 0,
- isActive: (dataScienceSubTracks.SRM?.challenges ?? 0) > 0,
- isCPTrack: true,
- isDSTrack: true,
- name: 'Competitive Programming',
- order: -2,
- percentile: dataScienceSubTracks.SRM?.rank?.percentile ?? 0,
- rating: dataScienceSubTracks.SRM?.rank?.rating ?? 0,
- subTracks,
- wins: dataScienceSubTracks.SRM?.wins ?? 0,
- }
- }, [dataScienceSubTracks])
+ const cpSubTracks: MemberStats[] = [
+ dataScienceSubTracks.SRM,
+ ].filter(d => d?.challenges > 0) as MemberStats[]
+
+ const cpTrackStats: MemberStatsTrack = {
+ challenges: dataScienceSubTracks.SRM?.challenges ?? 0,
+ isActive: (dataScienceSubTracks.SRM?.challenges ?? 0) > 0,
+ isCPTrack: true,
+ isDSTrack: true,
+ name: 'Competitive Programming',
+ order: -2,
+ percentile: dataScienceSubTracks.SRM?.rank?.percentile ?? 0,
+ rating: dataScienceSubTracks.SRM?.rank?.rating ?? 0,
+ subTracks: cpSubTracks,
+ wins: dataScienceSubTracks.SRM?.wins ?? 0,
+ }
// Order and filter active tracks based on wins and submissions
return orderBy(filter([
@@ -228,6 +258,18 @@ export const useFetchActiveTracks = (userHandle: string): MemberStatsTrack[] =>
], { isActive: true }), ['order', 'wins', 'submissions'], ['desc', 'desc', 'desc'])
}
+/**
+ * Custom hook to fetch active tracks for a user, sorted by wins & submissions.
+ *
+ * @param {string} userHandle - The user's handle.
+ * @returns {MemberStatsTrack[]} - List of active tracks for the user.
+ */
+export const useFetchActiveTracks = (userHandle: string): MemberStatsTrack[] => {
+ const memberStats: UserStats | undefined = useMemberStats(userHandle)
+
+ return useMemo(() => getActiveTracks(memberStats), [memberStats])
+}
+
/**
* Custom hook to fetch data for a specific track.
*
diff --git a/src/apps/profiles/src/lib/helpers.ts b/src/apps/profiles/src/lib/helpers.ts
index a09cab00d..1a854dc0b 100644
--- a/src/apps/profiles/src/lib/helpers.ts
+++ b/src/apps/profiles/src/lib/helpers.ts
@@ -33,6 +33,7 @@ export function subTrackLabelToHumanName(label: string): string {
case 'CODE':
return 'Code'
case 'FIRST_2_FINISH':
+ case 'First2Finish':
return 'First2Finish'
case 'CONCEPTUALIZATION':
return 'Conceptualization'
diff --git a/src/apps/profiles/src/member-profile/profile-header/OpenForGigs/OpenForGigs.module.scss b/src/apps/profiles/src/member-profile/profile-header/OpenForGigs/OpenForGigs.module.scss
index 7f31ba279..e474cd4e2 100644
--- a/src/apps/profiles/src/member-profile/profile-header/OpenForGigs/OpenForGigs.module.scss
+++ b/src/apps/profiles/src/member-profile/profile-header/OpenForGigs/OpenForGigs.module.scss
@@ -12,6 +12,7 @@
padding-right: $sp-2;
}
+ .unknownOopenToWork,
.notOopenToWork {
color: $red-100;
}
diff --git a/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.tsx b/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.tsx
index 62b721b65..9ae99e3c9 100644
--- a/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.tsx
+++ b/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.tsx
@@ -158,11 +158,7 @@ const ProfileHeader: FC = (props: ProfileHeaderProps) => {
{showMyStatusLabel && Engagement status:}
{showAdminLabel && (
-
- {props.profile.firstName}
- {' '}
- is
-
+ Engagement status is
)}
= props => {
const { statsRoute }: MemberProfileContextValue = useMemberProfileContext()
const params = useParams()
- const { trackData, ...subTrackData }: any
- = useFetchSubTrackData(props.profile.handle, params.trackType, params.subTrack)
+ const subTrackResult = useFetchSubTrackData(props.profile.handle, params.trackType, params.subTrack)
+ const { trackData, ...subTrackData }: any = subTrackResult ?? {}
+ const trackHistory = useTrackHistory(props.profile.handle, subTrackData as MemberStats | undefined)
const [backRoute, prevTitle] = useMemo(() => {
- const trackName = trackData.subTracks?.length === 1 ? '' : trackData.name
+ const trackName = trackData?.subTracks?.length === 1 ? '' : trackData?.name ?? ''
return [
statsRoute(props.profile.handle, trackName),
trackName || 'Member Stats',
]
- }, [props.profile.handle, statsRoute, trackData.name, trackData.subTracks])
+ }, [props.profile.handle, statsRoute, trackData?.name, trackData?.subTracks])
+
+ const summaryTrackData = useMemo(() => {
+ const supportsHistoryBackedSummary = ['DATA_SCIENCE', 'DEVELOP'].includes(String(subTrackData?.parentTrack))
+
+ if (!supportsHistoryBackedSummary || trackHistory.length === 0) {
+ return subTrackData
+ }
+
+ return {
+ ...subTrackData,
+ challenges: trackHistory.length,
+ submissions: trackHistory.length,
+ wins: trackHistory.filter(challenge => challenge.placement === 1).length,
+ }
+ }, [subTrackData, trackHistory])
return (!trackData || isEmpty(subTrackData)) ? props.renderDefault() : (
@@ -40,12 +56,12 @@ const SubTrackView: FC = props => {
title={subTrackLabelToHumanName(subTrackData.name)}
backAction={backRoute}
closeAction={statsRoute(props.profile.handle)}
- trackData={subTrackData}
+ trackData={summaryTrackData}
>
{subTrackData.name === 'SRM' ? (
) : (
-
+
)}
diff --git a/src/apps/profiles/src/member-profile/tc-achievements/track-view/TrackView.tsx b/src/apps/profiles/src/member-profile/tc-achievements/track-view/TrackView.tsx
index 3e3ba5791..69ba5b061 100644
--- a/src/apps/profiles/src/member-profile/tc-achievements/track-view/TrackView.tsx
+++ b/src/apps/profiles/src/member-profile/tc-achievements/track-view/TrackView.tsx
@@ -4,7 +4,7 @@ import { orderBy } from 'lodash'
import { MemberStats, UserProfile } from '~/libs/core'
-import { useFetchTrackData } from '../../../hooks'
+import { getSubTrackSubmissionCount, useFetchTrackData } from '../../../hooks'
import { StatsDetailsLayout } from '../../../components/tc-achievements/StatsDetailsLayout'
import { SubTrackSummaryCard } from '../../../components/tc-achievements/SubTrackSummaryCard'
import { MemberProfileContextValue, useMemberProfileContext } from '../../MemberProfile.context'
@@ -47,7 +47,7 @@ const TrackView: FC = props => {
key={subTrack.name}
title={subTrack.name}
wins={subTrack.wins}
- submissions={subTrack.submissions?.submissions ?? subTrack.submissions ?? 0}
+ submissions={getSubTrackSubmissionCount(subTrack) ?? 0}
/>
))}
diff --git a/src/apps/reports/index.ts b/src/apps/reports/index.ts
new file mode 100644
index 000000000..6f39cd49b
--- /dev/null
+++ b/src/apps/reports/index.ts
@@ -0,0 +1 @@
+export * from './src'
diff --git a/src/apps/reports/src/ReportsApp.tsx b/src/apps/reports/src/ReportsApp.tsx
new file mode 100644
index 000000000..14f78194c
--- /dev/null
+++ b/src/apps/reports/src/ReportsApp.tsx
@@ -0,0 +1,36 @@
+/**
+ * The reports app.
+ */
+import { FC, useContext, useEffect, useMemo } from 'react'
+import { Outlet, Routes } from 'react-router-dom'
+
+import { routerContext, RouterContextData } from '~/libs/core'
+
+import { Layout, ReportsAppContextProvider, SWRConfigProvider } from './lib'
+import { toolTitle } from './reports-app.routes'
+import './lib/styles/index.scss'
+
+const ReportsApp: FC = () => {
+ const { getChildRoutes }: RouterContextData = useContext(routerContext)
+ const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes])
+
+ useEffect(() => {
+ document.body.classList.add('reports-app')
+ return () => {
+ document.body.classList.remove('reports-app')
+ }
+ }, [])
+
+ return (
+
+
+
+
+ {childRoutes}
+
+
+
+ )
+}
+
+export default ReportsApp
diff --git a/src/apps/reports/src/config/routes.config.ts b/src/apps/reports/src/config/routes.config.ts
new file mode 100644
index 000000000..fb7d07091
--- /dev/null
+++ b/src/apps/reports/src/config/routes.config.ts
@@ -0,0 +1,12 @@
+/**
+ * Common config for routes in reports app.
+ */
+import { AppSubdomain, EnvironmentConfig } from '~/config'
+
+export const rootRoute: string
+ = EnvironmentConfig.SUBDOMAIN === AppSubdomain.reports
+ ? ''
+ : `/${AppSubdomain.reports}`
+
+export const reportsPageRouteId = 'reports'
+export const bulkMemberLookupRouteId = 'bulk-member-lookup'
diff --git a/src/apps/reports/src/index.ts b/src/apps/reports/src/index.ts
new file mode 100644
index 000000000..3aabe0c5e
--- /dev/null
+++ b/src/apps/reports/src/index.ts
@@ -0,0 +1,2 @@
+export { reportsRoutes } from './reports-app.routes'
+export { rootRoute as reportsRootRoute } from './config/routes.config'
diff --git a/src/apps/reports/src/lib/assets/icons/chevron-down.svg b/src/apps/reports/src/lib/assets/icons/chevron-down.svg
new file mode 100644
index 000000000..82b096f96
--- /dev/null
+++ b/src/apps/reports/src/lib/assets/icons/chevron-down.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/apps/reports/src/lib/components/Layout/Layout.module.scss b/src/apps/reports/src/lib/components/Layout/Layout.module.scss
new file mode 100644
index 000000000..d1b0d383d
--- /dev/null
+++ b/src/apps/reports/src/lib/components/Layout/Layout.module.scss
@@ -0,0 +1,29 @@
+@import '@libs/ui/styles/includes';
+
+.layout {
+ position: relative;
+ font-family: $font-roboto;
+ color: var(--Primary);
+
+ .main {
+ @include ltelg {
+ padding: 36px 0;
+ }
+ }
+
+ h1,
+ h2,
+ h3,
+ h4 {
+ font-family: $font-roboto;
+ }
+}
+
+.contentLayoutOuter {
+ margin: $sp-6 auto !important;
+}
+
+.contentLayoutInner {
+ box-sizing: border-box;
+ width: 100%;
+}
diff --git a/src/apps/reports/src/lib/components/Layout/Layout.tsx b/src/apps/reports/src/lib/components/Layout/Layout.tsx
new file mode 100644
index 000000000..14e6ea42b
--- /dev/null
+++ b/src/apps/reports/src/lib/components/Layout/Layout.tsx
@@ -0,0 +1,27 @@
+import { FC, PropsWithChildren } from 'react'
+
+import { ContentLayout } from '~/libs/ui'
+
+import { NavTabs } from '../NavTabs'
+
+import styles from './Layout.module.scss'
+
+export const NullLayout: FC = props => (
+ <>{props.children}>
+)
+
+export const Layout: FC = props => (
+ <>
+
+
+
+
+ >
+)
+
+export default Layout
diff --git a/src/apps/reports/src/lib/components/NavTabs/NavTabs.module.scss b/src/apps/reports/src/lib/components/NavTabs/NavTabs.module.scss
new file mode 100644
index 000000000..8d0b35c5a
--- /dev/null
+++ b/src/apps/reports/src/lib/components/NavTabs/NavTabs.module.scss
@@ -0,0 +1,121 @@
+@import '@libs/ui/styles/includes';
+
+.nav-bar {
+ background-color: #f7f5f1;
+ position: relative;
+ @include ltemd {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ }
+ &::before {
+ content: '';
+ display: block;
+ height: 68px;
+ position: absolute;
+ inset: 0;
+ top: -68px;
+ pointer-events: none;
+ box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 12px;
+ }
+ .inner {
+ max-width: $xxl-min;
+ padding: $sp-3 0;
+ @include pagePaddings;
+ margin: 0 auto;
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ @include ltemd {
+ display: block;
+ position: relative;
+ }
+ .title {
+ font-family: 'Nunito Sans', sans-serif;
+ font-weight: 700;
+ color: var(--FontColor);
+ position: relative;
+ line-height: 22px;
+ @include ltemd {
+ cursor: pointer;
+ &::after {
+ background: url('../../assets/icons/chevron-down.svg')
+ no-repeat;
+ display: block;
+ height: 24px;
+ width: 24px;
+ position: absolute;
+ top: 0;
+ right: 0;
+ content: '';
+ }
+ }
+ }
+ .tab {
+ display: flex;
+ align-items: center;
+ font-size: 16px;
+ @include ltemd {
+ display: none;
+ }
+ li {
+ font-family: 'Nunito Sans', sans-serif;
+ margin-left: $sp-8;
+ cursor: pointer;
+ color: var(--FontColor);
+ line-height: 32px;
+ display: flex;
+ align-items: center;
+ gap: $sp-2;
+ &.active {
+ font-weight: 700;
+ }
+ @include ltelg {
+ margin-left: $sp-4;
+ }
+ }
+ }
+ }
+
+ @include ltemd {
+ &.open {
+ .inner {
+ .title {
+ &::after {
+ transform: rotate(-180deg);
+ }
+ }
+ .tab {
+ background-color: var(--Appeal);
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ margin-top: 56px;
+ z-index: 100;
+ li {
+ padding: $sp-2 $sp-6;
+ margin-left: 0;
+ width: 100%;
+ &.active {
+ background-color: var(--Actived);
+ color: var(--invertButtonColor);
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+.tabLabel {
+ display: inline-flex;
+ align-items: center;
+}
+
+.externalIcon {
+ width: 16px;
+ height: 16px;
+}
diff --git a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx
new file mode 100644
index 000000000..cb0c2e34a
--- /dev/null
+++ b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx
@@ -0,0 +1,111 @@
+import {
+ Dispatch,
+ FC,
+ MouseEvent,
+ SetStateAction,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import { NavigateFunction, useLocation, useNavigate } from 'react-router-dom'
+import classNames from 'classnames'
+
+import { useClickOutside } from '~/libs/shared/lib/hooks'
+import { TabsNavItem } from '~/libs/ui'
+
+import { bulkMemberLookupRouteId, reportsPageRouteId } from '../../../config/routes.config'
+
+import styles from './NavTabs.module.scss'
+
+const NavTabs: FC = () => {
+ const navigate: NavigateFunction = useNavigate()
+ const [isOpen, setIsOpen] = useState(false)
+ const triggerRef = useRef(null)
+ const { pathname }: { pathname: string } = useLocation()
+
+ const tabs = useMemo(() => [
+ {
+ id: reportsPageRouteId,
+ title: 'Reports',
+ },
+ {
+ id: bulkMemberLookupRouteId,
+ title: 'Bulk Member Lookup',
+ },
+ ], [])
+
+ const activeTabPathName: string = useMemo(() => {
+ const matchingTabs = tabs
+ .filter(tab => pathname.includes(`/${tab.id}`))
+ .sort((tabA, tabB) => tabB.id.length - tabA.id.length)
+
+ if (matchingTabs.length > 0) {
+ return matchingTabs[0].id as string
+ }
+
+ return reportsPageRouteId
+ }, [pathname, tabs])
+
+ const [activeTab, setActiveTab]: [
+ string,
+ Dispatch>
+ ] = useState(activeTabPathName)
+
+ useEffect(() => {
+ setActiveTab(activeTabPathName)
+ }, [activeTabPathName])
+
+ const triggerTab = useCallback(() => {
+ setIsOpen(!isOpen)
+ }, [isOpen])
+
+ const handleTabClick = useCallback((event: MouseEvent) => {
+ const { tabId }: { tabId?: string } = event.currentTarget.dataset
+
+ if (!tabId) {
+ return
+ }
+
+ setActiveTab(tabId)
+ setIsOpen(false)
+ navigate(tabId)
+ }, [navigate])
+
+ useClickOutside(triggerRef.current, () => setIsOpen(false))
+
+ return (
+
+
+
+ Reports
+
+
+ {tabs.map(tab => {
+ const isActive = tab.id === activeTab
+
+ return (
+ -
+ {tab.title}
+
+ )
+ })}
+
+
+
+ )
+}
+
+export default NavTabs
diff --git a/src/apps/reports/src/lib/components/NavTabs/index.ts b/src/apps/reports/src/lib/components/NavTabs/index.ts
new file mode 100644
index 000000000..26433a25d
--- /dev/null
+++ b/src/apps/reports/src/lib/components/NavTabs/index.ts
@@ -0,0 +1 @@
+export { default as NavTabs } from './NavTabs'
diff --git a/src/apps/reports/src/lib/components/index.ts b/src/apps/reports/src/lib/components/index.ts
new file mode 100644
index 000000000..40b99bd8a
--- /dev/null
+++ b/src/apps/reports/src/lib/components/index.ts
@@ -0,0 +1,3 @@
+export { default as Layout } from './Layout/Layout'
+export * from './Layout/Layout'
+export * from './NavTabs'
diff --git a/src/apps/reports/src/lib/contexts/ReportsAppContext.ts b/src/apps/reports/src/lib/contexts/ReportsAppContext.ts
new file mode 100644
index 000000000..36e45b097
--- /dev/null
+++ b/src/apps/reports/src/lib/contexts/ReportsAppContext.ts
@@ -0,0 +1,15 @@
+/**
+ * Reports app context definition.
+ */
+import { Context, createContext } from 'react'
+
+import { TokenModel } from '~/libs/core'
+
+export interface ReportsAppContextModel {
+ loginUserInfo: TokenModel | undefined
+}
+
+export const ReportsAppContext: Context
+ = createContext({
+ loginUserInfo: undefined,
+ })
diff --git a/src/apps/reports/src/lib/contexts/ReportsAppContextProvider.tsx b/src/apps/reports/src/lib/contexts/ReportsAppContextProvider.tsx
new file mode 100644
index 000000000..4db0fed30
--- /dev/null
+++ b/src/apps/reports/src/lib/contexts/ReportsAppContextProvider.tsx
@@ -0,0 +1,41 @@
+/**
+ * Context provider for reports app
+ */
+import {
+ FC,
+ PropsWithChildren,
+ useMemo,
+ useState,
+} from 'react'
+
+import { tokenGetAsync, TokenModel } from '~/libs/core'
+import { useOnComponentDidMount } from '~/apps/admin/src/lib/hooks'
+
+import { ReportsAppContext, ReportsAppContextModel } from './ReportsAppContext'
+
+export const ReportsAppContextProvider: FC = props => {
+ const [loginUserInfo, setLoginUserInfo] = useState(undefined)
+
+ const value = useMemo(
+ () => ({
+ loginUserInfo,
+ }),
+ [
+ loginUserInfo,
+ ],
+ )
+
+ useOnComponentDidMount(() => {
+ // get login user info on init
+ tokenGetAsync()
+ .then((token: TokenModel) => {
+ setLoginUserInfo(token)
+ })
+ })
+
+ return (
+
+ {props.children}
+
+ )
+}
diff --git a/src/apps/reports/src/lib/contexts/SWRConfigProvider.tsx b/src/apps/reports/src/lib/contexts/SWRConfigProvider.tsx
new file mode 100644
index 000000000..c9efac909
--- /dev/null
+++ b/src/apps/reports/src/lib/contexts/SWRConfigProvider.tsx
@@ -0,0 +1,19 @@
+import { FC, PropsWithChildren } from 'react'
+import { SWRConfig } from 'swr'
+
+import { xhrGetAsync } from '~/libs/core'
+
+export const SWRConfigProvider: FC = props => (
+ xhrGetAsync(resource),
+ refreshInterval: 0,
+ revalidateOnFocus: false,
+ revalidateOnMount: true,
+ }}
+ >
+ {props.children}
+
+)
+
+export default SWRConfigProvider
diff --git a/src/apps/reports/src/lib/contexts/index.ts b/src/apps/reports/src/lib/contexts/index.ts
new file mode 100644
index 000000000..ea1940576
--- /dev/null
+++ b/src/apps/reports/src/lib/contexts/index.ts
@@ -0,0 +1,3 @@
+export * from './ReportsAppContext'
+export * from './ReportsAppContextProvider'
+export * from './SWRConfigProvider'
diff --git a/src/apps/reports/src/lib/index.ts b/src/apps/reports/src/lib/index.ts
new file mode 100644
index 000000000..140745e2a
--- /dev/null
+++ b/src/apps/reports/src/lib/index.ts
@@ -0,0 +1,4 @@
+export * from './contexts'
+export * from './components'
+export * from './services/index'
+export * from './utils/index'
diff --git a/src/apps/reports/src/lib/services/index.ts b/src/apps/reports/src/lib/services/index.ts
new file mode 100644
index 000000000..b2c6dc18b
--- /dev/null
+++ b/src/apps/reports/src/lib/services/index.ts
@@ -0,0 +1,17 @@
+export {
+ downloadBlobFile,
+ downloadReportAsCsv,
+ downloadReportAsJson,
+ fetchReportsIndex,
+ postReportAsCsv,
+ postReportAsJson,
+ postReportFileAsCsv,
+ postReportFileAsJson,
+} from './reports.service'
+
+export type {
+ ReportDefinition,
+ ReportGroup,
+ ReportParameter,
+ ReportsIndexResponse,
+} from './reports.service'
diff --git a/src/apps/reports/src/lib/services/reports.service.ts b/src/apps/reports/src/lib/services/reports.service.ts
new file mode 100644
index 000000000..d752087c8
--- /dev/null
+++ b/src/apps/reports/src/lib/services/reports.service.ts
@@ -0,0 +1,161 @@
+import type { AxiosInstance } from 'axios'
+
+import { EnvironmentConfig } from '~/config'
+import { xhrCreateInstance, xhrGetAsync } from '~/libs/core/lib/xhr'
+
+export type ReportParameter = {
+ name: string
+ type: 'string' | 'string[]' | 'number' | 'number[]' | 'boolean' | 'date' | 'enum' | 'enum[]'
+ description?: string
+ required?: boolean
+ location?: 'query' | 'path'
+ options?: string[]
+}
+
+export type ReportDefinition = {
+ name: string
+ path: string
+ description?: string
+ method: string
+ parameters?: ReportParameter[]
+}
+
+export type ReportGroup = {
+ label: string
+ basePath: string
+ reports: ReportDefinition[]
+}
+
+export type ReportsIndexResponse = Record
+
+const reportsDownloadClient: AxiosInstance = xhrCreateInstance()
+
+const buildReportUrl = (path: string): string => {
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`
+ return `${EnvironmentConfig.API.V6}/reports${normalizedPath}`
+}
+
+export const fetchReportsIndex = async (): Promise => (
+ xhrGetAsync(`${EnvironmentConfig.API.V6}/reports/directory`)
+)
+
+const downloadReportBlob = async (path: string, accept: string): Promise => {
+ if (!path) {
+ throw new Error('Report path is required')
+ }
+
+ const url = buildReportUrl(path)
+ const response = await reportsDownloadClient.get(url, {
+ headers: {
+ Accept: accept,
+ },
+ responseType: 'blob',
+ })
+
+ return response.data
+}
+
+const postReportBlob = async (
+ path: string,
+ data: Record | FormData,
+ accept: string,
+ contentType: string,
+): Promise => {
+ if (!path) {
+ throw new Error('Report path is required')
+ }
+
+ const url = buildReportUrl(path)
+ const response = await reportsDownloadClient.post(url, data, {
+ headers: {
+ Accept: accept,
+ 'Content-Type': contentType,
+ },
+ responseType: 'blob',
+ })
+
+ return response.data
+}
+
+/**
+ * Posts JSON payload to a report endpoint and returns the response body as a blob.
+ * @param path Report path relative to `/reports`.
+ * @param body Request payload, typically `{ handles: string[] }` for identity lookup endpoints.
+ * @returns Blob response body.
+ * @throws Error when report path is empty.
+ */
+export const postReportAsJson = (
+ path: string,
+ body: Record,
+): Promise => (
+ postReportBlob(path, body, 'application/json', 'application/json')
+)
+
+/**
+ * Posts JSON payload to a report endpoint and requests CSV output.
+ * @param path Report path relative to `/reports`.
+ * @param body Request payload, typically `{ handles: string[] }` for identity lookup endpoints.
+ * @returns Blob response body encoded as CSV.
+ * @throws Error when report path is empty.
+ */
+export const postReportAsCsv = (
+ path: string,
+ body: Record,
+): Promise => (
+ postReportBlob(path, body, 'text/csv', 'application/json')
+)
+
+const createFileFormData = (file: File): FormData => {
+ const formData = new FormData()
+ formData.append('file', file)
+ return formData
+}
+
+/**
+ * Posts a text/csv file to a report endpoint and requests JSON output.
+ * @param path Report path relative to `/reports`.
+ * @param file Input file uploaded by the user.
+ * @returns Blob response body.
+ * @throws Error when report path is empty.
+ */
+export const postReportFileAsJson = (path: string, file: File): Promise => (
+ postReportBlob(path, createFileFormData(file), 'application/json', 'multipart/form-data')
+)
+
+/**
+ * Posts a text/csv file to a report endpoint and requests CSV output.
+ * @param path Report path relative to `/reports`.
+ * @param file Input file uploaded by the user.
+ * @returns Blob response body encoded as CSV.
+ * @throws Error when report path is empty.
+ */
+export const postReportFileAsCsv = (path: string, file: File): Promise => (
+ postReportBlob(path, createFileFormData(file), 'text/csv', 'multipart/form-data')
+)
+
+export const downloadReportAsJson = (path: string): Promise => (
+ downloadReportBlob(path, 'application/json')
+)
+
+export const downloadReportAsCsv = (path: string): Promise => (
+ downloadReportBlob(path, 'text/csv')
+)
+
+/**
+ * Triggers a browser download for a report blob.
+ * @param blob the report data returned from the reports API.
+ * @param fileName the file name to present in the browser download prompt.
+ * @returns nothing. The helper is used by the reports pages after a blob response is received.
+ * @throws Does not throw intentionally. Browser download failures surface from the underlying DOM APIs.
+ */
+export const downloadBlobFile = (blob: Blob, fileName: string): void => {
+ const link = document.createElement('a')
+ const url = window.URL.createObjectURL(blob)
+
+ link.href = url
+ link.setAttribute('download', fileName)
+ document.body.appendChild(link)
+ link.click()
+ link.parentNode?.removeChild(link)
+ window.URL.revokeObjectURL(url)
+}
diff --git a/src/apps/reports/src/lib/styles/index.scss b/src/apps/reports/src/lib/styles/index.scss
new file mode 100644
index 000000000..1ad1d7753
--- /dev/null
+++ b/src/apps/reports/src/lib/styles/index.scss
@@ -0,0 +1,5 @@
+@import '@libs/ui/styles/includes';
+
+.reports-app {
+ --Primary: #545f71;
+}
diff --git a/src/apps/reports/src/lib/utils/index.ts b/src/apps/reports/src/lib/utils/index.ts
new file mode 100644
index 000000000..83bf5f8f3
--- /dev/null
+++ b/src/apps/reports/src/lib/utils/index.ts
@@ -0,0 +1,22 @@
+import { toast } from 'react-toastify'
+
+/**
+ * Handles API errors by extracting the most useful message and showing a toast.
+ * @param error Axios error-like object.
+ */
+export const handleError = (error: any): void => {
+ let errMessage = error?.data?.message
+
+ if (!errMessage) {
+ const errors = error?.response?.data?.errors
+ if (Array.isArray(errors)) {
+ errMessage = errors.join(',')
+ }
+ }
+
+ if (!errMessage) {
+ errMessage = error?.message
+ }
+
+ toast.error(errMessage)
+}
diff --git a/src/apps/reports/src/pages/bulk-member-lookup/BulkMemberLookupPage.module.scss b/src/apps/reports/src/pages/bulk-member-lookup/BulkMemberLookupPage.module.scss
new file mode 100644
index 000000000..8bf6bb896
--- /dev/null
+++ b/src/apps/reports/src/pages/bulk-member-lookup/BulkMemberLookupPage.module.scss
@@ -0,0 +1,39 @@
+.page {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.instructions {
+ color: #565a5f;
+ max-width: 720px;
+}
+
+.uploadSection {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+
+.resultsHeader {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.tableWrapper {
+ width: 100%;
+}
+
+.emptyState {
+ color: #6b6f75;
+ font-style: italic;
+}
diff --git a/src/apps/reports/src/pages/bulk-member-lookup/BulkMemberLookupPage.tsx b/src/apps/reports/src/pages/bulk-member-lookup/BulkMemberLookupPage.tsx
new file mode 100644
index 000000000..b0b15a375
--- /dev/null
+++ b/src/apps/reports/src/pages/bulk-member-lookup/BulkMemberLookupPage.tsx
@@ -0,0 +1,279 @@
+import { Dispatch, FC, SetStateAction, useCallback, useMemo, useState } from 'react'
+
+import {
+ Button,
+ InputFilePicker,
+ LoadingSpinner,
+ PageTitle,
+ Table,
+ TableColumn,
+} from '~/libs/ui'
+
+import { postReportAsCsv, postReportAsJson } from '../../lib/services'
+import { handleError } from '../../lib/utils'
+
+import styles from './BulkMemberLookupPage.module.scss'
+
+const pageTitle = 'Bulk Member Lookup'
+const bulkMembersByHandlesPath = '/identity/users-by-handles'
+const emptyValue = '—'
+
+/**
+ * Represents a resolved user row returned by bulk handle lookup.
+ *
+ * The table uses this shape directly to show both found and unresolved handles.
+ */
+type BulkMemberRow = {
+ userId: number | null
+ handle: string
+ email: string | null
+ country: string | null
+}
+
+const buildDownloadName = (extension: 'json' | 'csv'): string => (
+ `bulk-member-lookup.${extension}`
+)
+
+/**
+ * Parses the blob response from the reports API into lookup rows.
+ * @param blob JSON blob returned from `/identity/users-by-handles`.
+ * @returns Parsed list of bulk lookup rows.
+ * @throws Error when the payload is not valid JSON.
+ */
+const parseLookupResults = async (blob: Blob): Promise => {
+ const payload = await blob.text()
+
+ if (!payload) {
+ return []
+ }
+
+ const parsed = JSON.parse(payload)
+
+ if (Array.isArray(parsed)) {
+ return parsed as BulkMemberRow[]
+ }
+
+ return []
+}
+
+/**
+ * Parses uploaded text/CSV content into a normalized handle list.
+ * @param file Uploaded `.txt` or `.csv` file.
+ * @returns Ordered non-empty handles from the file.
+ * @throws Error when no handles are found in the uploaded content.
+ */
+const parseHandlesFromFile = async (file: File): Promise => {
+ const content = (await file.text())
+ .replace(/^\uFEFF/, '')
+
+ const handles = content
+ .split(/\r?\n/)
+ .flatMap(line => line.split(','))
+ .map(value => value.trim()
+ .replace(/^"(.*)"$/, '$1')
+ .trim())
+ .filter(value => value.length > 0)
+
+ if (handles.length > 1 && /^handles?$/i.test(handles[0])) {
+ handles.shift()
+ }
+
+ if (!handles.length) {
+ throw new Error('Uploaded file does not contain any handles.')
+ }
+
+ return handles
+}
+
+/**
+ * Triggers a browser file download from a blob.
+ * @param blob File content to download.
+ * @param fileName Name to use for the downloaded file.
+ */
+const downloadBlob = (blob: Blob, fileName: string): void => {
+ const link = document.createElement('a')
+ const url = window.URL.createObjectURL(blob)
+
+ link.href = url
+ link.setAttribute('download', fileName)
+ document.body.appendChild(link)
+ link.click()
+ link.parentNode?.removeChild(link)
+ window.URL.revokeObjectURL(url)
+}
+
+/**
+ * Bulk Member Lookup page for uploading handles and resolving account details.
+ *
+ * Users upload a `.txt` or `.csv` file of handles, submit for lookup,
+ * review results in a table, and optionally download JSON/CSV output.
+ */
+export const BulkMemberLookupPage: FC = () => {
+ const [file, setFile]: [File | undefined, Dispatch>]
+ = useState(undefined)
+ const [isSubmitting, setIsSubmitting]: [boolean, Dispatch>]
+ = useState(false)
+ const [results, setResults]: [BulkMemberRow[], Dispatch>]
+ = useState([])
+ const [hasSubmitted, setHasSubmitted]: [boolean, Dispatch>]
+ = useState(false)
+ const [isDownloading, setIsDownloading]: [
+ 'json' | 'csv' | undefined,
+ Dispatch>
+ ] = useState<'json' | 'csv' | undefined>(undefined)
+
+ const tableColumns = useMemo[]>(() => ([
+ {
+ label: 'User ID',
+ propertyName: 'userId',
+ renderer: data => <>{data.userId ?? emptyValue}>,
+ type: 'element',
+ },
+ {
+ label: 'Handle',
+ propertyName: 'handle',
+ type: 'text',
+ },
+ {
+ label: 'Email',
+ propertyName: 'email',
+ renderer: data => <>{data.email ?? emptyValue}>,
+ type: 'element',
+ },
+ {
+ label: 'Country',
+ propertyName: 'country',
+ renderer: data => <>{data.country ?? emptyValue}>,
+ type: 'element',
+ },
+ ]), [])
+
+ const handleFileChange = useCallback((fileList: FileList | undefined): void => {
+ setFile(fileList?.item(0) ?? undefined)
+ setHasSubmitted(false)
+ setResults([])
+ }, [])
+
+ const handleLookupMembers = useCallback(async (): Promise => {
+ if (!file) {
+ return
+ }
+
+ try {
+ setIsSubmitting(true)
+ const handles = await parseHandlesFromFile(file)
+ const responseBlob = await postReportAsJson(bulkMembersByHandlesPath, { handles })
+ const lookupResults = await parseLookupResults(responseBlob)
+
+ setResults(lookupResults)
+ setHasSubmitted(true)
+ } catch (error) {
+ handleError(error)
+ } finally {
+ setIsSubmitting(false)
+ }
+ }, [file])
+
+ const handleDownload = useCallback(async (format: 'json' | 'csv'): Promise => {
+ if (!file) {
+ return
+ }
+
+ try {
+ setIsDownloading(format)
+ const handles = await parseHandlesFromFile(file)
+
+ const blob = format === 'json'
+ ? await postReportAsJson(bulkMembersByHandlesPath, { handles })
+ : await postReportAsCsv(bulkMembersByHandlesPath, { handles })
+
+ downloadBlob(blob, buildDownloadName(format))
+ } catch (error) {
+ handleError(error)
+ } finally {
+ setIsDownloading(undefined)
+ }
+ }, [file])
+
+ const handleJsonDownload = useCallback(() => {
+ handleDownload('json')
+ }, [handleDownload])
+
+ const handleCsvDownload = useCallback(() => {
+ handleDownload('csv')
+ }, [handleDownload])
+
+ const isDownloadDisabled = !file || isSubmitting || isDownloading !== undefined
+
+ return (
+ <>
+ {isSubmitting && }
+ {isDownloading && }
+
+
+ {pageTitle}
+
+
+ Upload a TXT or CSV file that contains one member handle per line,
+ then submit to resolve user details.
+
+
+
+
+
+
+
+
+
+
+ {hasSubmitted && (
+ <>
+
+ Results
+
+
+
+
+
+
+
+ {results.length ? (
+
+ ) : (
+
+ No members were returned for the uploaded handles.
+
+ )}
+
+ >
+ )}
+
+ >
+ )
+}
+
+export default BulkMemberLookupPage
diff --git a/src/apps/reports/src/pages/bulk-member-lookup/index.ts b/src/apps/reports/src/pages/bulk-member-lookup/index.ts
new file mode 100644
index 000000000..2a57db02a
--- /dev/null
+++ b/src/apps/reports/src/pages/bulk-member-lookup/index.ts
@@ -0,0 +1 @@
+export { BulkMemberLookupPage } from './BulkMemberLookupPage'
diff --git a/src/apps/admin/src/reports/ReportsPage.module.scss b/src/apps/reports/src/pages/reports/ReportsPage.module.scss
similarity index 79%
rename from src/apps/admin/src/reports/ReportsPage.module.scss
rename to src/apps/reports/src/pages/reports/ReportsPage.module.scss
index 35872b51a..e804f2221 100644
--- a/src/apps/admin/src/reports/ReportsPage.module.scss
+++ b/src/apps/reports/src/pages/reports/ReportsPage.module.scss
@@ -68,6 +68,22 @@
gap: 12px;
}
+.postReportNotice {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ max-width: 720px;
+ padding: 12px;
+ border: 1px solid #e0b458;
+ border-radius: 4px;
+ background: #fff8e6;
+ color: #5f4a00;
+}
+
+.postReportHint {
+ font-size: 12px;
+}
+
.spinnerWrapper {
padding: 40px 0;
display: flex;
diff --git a/src/apps/reports/src/pages/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx
new file mode 100644
index 000000000..b00f5cf2f
--- /dev/null
+++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx
@@ -0,0 +1,497 @@
+import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react'
+import { NavigateFunction, useNavigate } from 'react-router-dom'
+
+import { Button, InputSelect, InputSelectOption, InputText, LoadingSpinner, PageTitle } from '~/libs/ui'
+
+import { bulkMemberLookupRouteId } from '../../config/routes.config'
+import { handleError } from '../../lib/utils'
+import {
+ downloadBlobFile,
+ downloadReportAsCsv,
+ downloadReportAsJson,
+ fetchReportsIndex,
+ ReportDefinition,
+ ReportGroup,
+ ReportParameter,
+ ReportsIndexResponse,
+} from '../../lib/services'
+
+import { getReportParameterValidationError } from './reports-page.validation'
+import styles from './ReportsPage.module.scss'
+
+const pageTitle = 'Reports'
+const bulkMembersByHandlesPath = '/identity/users-by-handles'
+
+const buildDownloadName = (
+ name: string,
+ extension: 'json' | 'csv',
+ suffix?: string,
+): string => {
+ const normalized = name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/(^-|-$)+/g, '')
+ const normalizedSuffix = suffix
+ ? suffix
+ .toLowerCase()
+ .replace(/[^a-z0-9-]+/g, '-')
+ .replace(/(^-|-$)+/g, '')
+ : ''
+
+ const base = normalized || 'report'
+ return normalizedSuffix
+ ? `${base}_${normalizedSuffix}.${extension}`
+ : `${base}.${extension}`
+}
+
+const formatMethod = (method?: string): string => (
+ method ? method.toUpperCase() : 'GET'
+)
+
+type ReportActionsProps = {
+ handleCsvDownload: () => void
+ handleJsonDownload: () => void
+ handleOpenBulkMemberLookup: () => void
+ isDownloadDisabled: boolean
+ isHandleLookupPostReport: boolean
+ isPostReport: boolean
+}
+
+const ReportActions = (props: ReportActionsProps): JSX.Element => {
+ if (props.isPostReport) {
+ return (
+
+
+ This report uses a POST request body and cannot be downloaded from this
+ page.
+
+ {props.isHandleLookupPostReport ? (
+
+ ) : (
+
+ Run this report from its dedicated workflow.
+
+ )}
+
+ )
+ }
+
+ return (
+
+
+
+
+ )
+}
+
+type SelectedReportSectionProps = {
+ renderParameterInput: (parameter: ReportParameter) => JSX.Element
+ reportActions: JSX.Element
+ selectedReport?: ReportDefinition
+}
+
+const SelectedReportSection = (props: SelectedReportSectionProps): JSX.Element => {
+ if (!props.selectedReport) {
+ return <>>
+ }
+
+ return (
+ <>
+
+ {props.selectedReport.name}
+ {props.selectedReport.description && (
+
+ {props.selectedReport.description}
+
+ )}
+
+ {formatMethod(props.selectedReport.method)}
+ {' '}
+ {props.selectedReport.path}
+
+
+
+ {(props.selectedReport.parameters?.length ?? 0) > 0 && (
+
+ {props.selectedReport.parameters?.map(parameter => (
+
+
+ {parameter.name}
+ {parameter.required ? ' *' : ''}
+
+ {parameter.description && (
+ {parameter.description}
+ )}
+
+ Location:
+ {' '}
+ {parameter.location || 'query'}
+ {' '}
+ • Type:
+ {' '}
+ {parameter.type}
+
+ {parameter.type.endsWith('[]') && (
+
+ Use comma-separated values for lists.
+
+ )}
+ {props.renderParameterInput(parameter)}
+
+ ))}
+
+ )}
+
+ {props.reportActions}
+ >
+ )
+}
+
+export const ReportsPage: FC = () => {
+ const navigate: NavigateFunction = useNavigate()
+ const [reportsIndex, setReportsIndex] = useState({})
+ const [selectedBasePath, setSelectedBasePath] = useState('')
+ const [selectedReportPath, setSelectedReportPath] = useState('')
+ const [isLoading, setIsLoading] = useState(false)
+ const [downloadingFormat, setDownloadingFormat] = useState<'json' | 'csv' | undefined>(undefined)
+ const [parameterValues, setParameterValues] = useState>({})
+
+ useEffect(() => {
+ let isMounted = true
+ setIsLoading(true)
+
+ fetchReportsIndex()
+ .then(data => {
+ if (!isMounted) return
+ setReportsIndex(data ?? {})
+ })
+ .catch(error => {
+ if (!isMounted) return
+ handleError(error)
+ })
+ .finally(() => {
+ if (isMounted) {
+ setIsLoading(false)
+ }
+ })
+
+ return () => {
+ isMounted = false
+ }
+ }, [])
+
+ const basePathOptions = useMemo(() => {
+ const groups: ReportGroup[] = Object.values(reportsIndex ?? {})
+ const options = groups.map(group => ({
+ label: group.label || group.basePath,
+ value: group.basePath,
+ }))
+
+ options.sort((a, b) => a.label.localeCompare(b.label))
+ return options
+ }, [reportsIndex])
+
+ const selectedGroup = useMemo(() => (
+ selectedBasePath
+ ? Object.values(reportsIndex)
+ .find(group => group.basePath === selectedBasePath)
+ : undefined
+ ), [reportsIndex, selectedBasePath])
+
+ const reportOptions = useMemo(() => {
+ if (!selectedGroup?.reports?.length) {
+ return []
+ }
+
+ const options = selectedGroup.reports.map(report => ({
+ label: report.name,
+ value: report.path,
+ }))
+
+ options.sort((a, b) => a.label.localeCompare(b.label))
+ return options
+ }, [selectedGroup])
+
+ const selectedReport = useMemo(() => (
+ selectedGroup?.reports?.find(report => report.path === selectedReportPath)
+ ), [selectedGroup, selectedReportPath])
+
+ const handleBasePathChange = useCallback((event: ChangeEvent) => {
+ setSelectedBasePath(event.target.value)
+ setSelectedReportPath('')
+ setParameterValues({})
+ }, [])
+
+ const handleReportChange = useCallback((event: ChangeEvent) => {
+ setSelectedReportPath(event.target.value)
+ setParameterValues({})
+ }, [])
+
+ const handleParameterChange = useCallback((event: ChangeEvent) => {
+ if (!event.target?.name) return
+
+ setParameterValues(previous => ({
+ ...previous,
+ [event.target.name]: event.target.value,
+ }))
+ }, [])
+
+ const createSelectParamChange = useCallback((name: string) => (
+ event: ChangeEvent,
+ ) => {
+ setParameterValues(previous => ({
+ ...previous,
+ [name]: event.target.value,
+ }))
+ }, [])
+
+ const buildReportPathWithParams = useCallback((report: ReportDefinition): string => {
+ let path = report.path
+ const query = new URLSearchParams()
+ const params: ReportParameter[] = report.parameters ?? []
+
+ params.forEach(param => {
+ const rawValue = parameterValues[param.name]
+ if (rawValue === undefined || rawValue.trim() === '') {
+ return
+ }
+
+ const isArray = param.type.endsWith('[]')
+ const values = isArray
+ ? rawValue.split(',')
+ .map(v => v.trim())
+ .filter(Boolean)
+ : [rawValue.trim()]
+
+ if (!values.length) return
+
+ if (param.location === 'path') {
+ path = path.replace(`:${param.name}`, encodeURIComponent(values[0]))
+ } else {
+ values.forEach(value => query.append(param.name, value))
+ }
+ })
+
+ const queryString = query.toString()
+ return queryString ? `${path}?${queryString}` : path
+ }, [parameterValues])
+
+ const parameterErrors = useMemo>(() => (
+ (selectedReport?.parameters ?? []).reduce>((errors, parameter) => {
+ const error = getReportParameterValidationError(parameter, parameterValues[parameter.name])
+
+ if (error) {
+ errors[parameter.name] = error
+ }
+
+ return errors
+ }, {})
+ ), [parameterValues, selectedReport])
+
+ const hasInvalidParameterValues = useMemo(() => (
+ Object.keys(parameterErrors).length > 0
+ ), [parameterErrors])
+
+ const handleDownload = useCallback(async (format: 'json' | 'csv') => {
+ if (!selectedReport || hasInvalidParameterValues) {
+ return
+ }
+
+ try {
+ setDownloadingFormat(format)
+
+ const requestPath = buildReportPathWithParams(selectedReport)
+
+ const blob = format === 'json'
+ ? await downloadReportAsJson(requestPath)
+ : await downloadReportAsCsv(requestPath)
+
+ const challengeIdSuffix = parameterValues.challengeId?.trim()
+ const fileName = buildDownloadName(
+ selectedReport.name,
+ format,
+ challengeIdSuffix,
+ )
+ downloadBlobFile(blob, fileName)
+ } catch (error) {
+ handleError(error)
+ } finally {
+ setDownloadingFormat(undefined)
+ }
+ }, [buildReportPathWithParams, hasInvalidParameterValues, parameterValues.challengeId, selectedReport])
+
+ const handleOpenBulkMemberLookup = useCallback(() => {
+ navigate(bulkMemberLookupRouteId)
+ }, [navigate])
+
+ const isDownloading = downloadingFormat !== undefined
+
+ const requiredParamsMissing = useMemo(() => {
+ const params = selectedReport?.parameters ?? []
+ return params.some(param => param.required && !(parameterValues[param.name]?.trim()))
+ }, [parameterValues, selectedReport])
+
+ const hasUnresolvedPathParams = useMemo(() => (
+ (selectedReport?.parameters ?? [])
+ .filter(param => param.location === 'path')
+ .some(param => !parameterValues[param.name]?.trim())
+ ), [parameterValues, selectedReport])
+
+ const isPostReport = selectedReport?.method?.toUpperCase() === 'POST'
+ const isHandleLookupPostReport = isPostReport && selectedReport.path === bulkMembersByHandlesPath
+ const isDownloadDisabled = !selectedReport
+ || isPostReport
+ || isDownloading
+ || requiredParamsMissing
+ || hasInvalidParameterValues
+ || hasUnresolvedPathParams
+
+ const handleJsonDownload = useCallback(() => {
+ handleDownload('json')
+ }, [handleDownload])
+
+ const handleCsvDownload = useCallback(() => {
+ handleDownload('csv')
+ }, [handleDownload])
+
+ const reportActions = (
+
+ )
+
+ const renderParameterInput = useCallback((parameter: ReportParameter) => {
+ const commonProps = {
+ label: parameter.name,
+ name: parameter.name,
+ placeholder: parameter.type === 'date'
+ ? 'YYYY-MM-DD'
+ : (parameter.type.endsWith('[]') ? 'Comma-separated values' : 'Enter value'),
+ }
+
+ if (parameter.type === 'boolean') {
+ const options: InputSelectOption[] = [
+ { label: 'True', value: 'true' },
+ { label: 'False', value: 'false' },
+ ]
+
+ return (
+
+ )
+ }
+
+ if (parameter.type === 'enum') {
+ const options: InputSelectOption[] = (parameter.options ?? []).map(option => ({
+ label: option,
+ value: option,
+ }))
+
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ }, [createSelectParamChange, handleParameterChange, parameterErrors, parameterValues])
+
+ return (
+ <>
+ {isDownloading && (
+
+ )}
+
+ {pageTitle}
+
+ Select a base path to view the available reports. After choosing a report, provide any
+ required parameters and download the data as JSON or CSV directly from the reports API.
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+ <>
+ {basePathOptions.length ? (
+
+
+
+ {selectedGroup && (
+
+ )}
+
+ ) : (
+
+ No reports are currently available.
+
+ )}
+
+
+ >
+ )}
+
+ >
+ )
+}
+
+export default ReportsPage
diff --git a/src/apps/reports/src/pages/reports/index.ts b/src/apps/reports/src/pages/reports/index.ts
new file mode 100644
index 000000000..ba9294998
--- /dev/null
+++ b/src/apps/reports/src/pages/reports/index.ts
@@ -0,0 +1 @@
+export { ReportsPage } from './ReportsPage'
diff --git a/src/apps/reports/src/pages/reports/reports-page.validation.spec.ts b/src/apps/reports/src/pages/reports/reports-page.validation.spec.ts
new file mode 100644
index 000000000..613a3b2f6
--- /dev/null
+++ b/src/apps/reports/src/pages/reports/reports-page.validation.spec.ts
@@ -0,0 +1,38 @@
+import {
+ getReportParameterValidationError,
+ invalidReportDateMessage,
+ isValidReportDateValue,
+} from './reports-page.validation'
+
+describe('reports page date validation', () => {
+ it('accepts a real calendar date', () => {
+ expect(isValidReportDateValue('2026-02-28'))
+ .toBe(true)
+ })
+
+ it('rejects impossible dates for shorter months', () => {
+ expect(isValidReportDateValue('2026-02-29'))
+ .toBe(false)
+ expect(isValidReportDateValue('2026-04-31'))
+ .toBe(false)
+ })
+
+ it('rejects dotted invalid dates entered into the reports fields', () => {
+ expect(isValidReportDateValue('2026.02.29'))
+ .toBe(false)
+ expect(isValidReportDateValue('2026.04.31'))
+ .toBe(false)
+ })
+
+ it('returns the shared validation message for invalid date parameters', () => {
+ expect(getReportParameterValidationError({ type: 'date' }, '2026-02-29'))
+ .toBe(invalidReportDateMessage)
+ })
+
+ it('does not flag empty or non-date parameters', () => {
+ expect(getReportParameterValidationError({ type: 'date' }, ''))
+ .toBeUndefined()
+ expect(getReportParameterValidationError({ type: 'string' }, '2026-02-29'))
+ .toBeUndefined()
+ })
+})
diff --git a/src/apps/reports/src/pages/reports/reports-page.validation.ts b/src/apps/reports/src/pages/reports/reports-page.validation.ts
new file mode 100644
index 000000000..126a05e69
--- /dev/null
+++ b/src/apps/reports/src/pages/reports/reports-page.validation.ts
@@ -0,0 +1,45 @@
+import { isValid, parseISO } from 'date-fns'
+
+import type { ReportParameter } from '../../lib/services'
+
+export const invalidReportDateMessage = 'Enter a valid ISO date such as 2024-01-31.'
+
+/**
+ * Validates report date inputs without allowing calendar rollover.
+ *
+ * `parseISO` rejects impossible dates, so values like 2026-02-29 and
+ * 2026.04.31 fail validation instead of being sent to the API.
+ *
+ * @param value user-provided date input
+ * @returns `true` when the value is a valid ISO-style date accepted by the reports UI
+ */
+export const isValidReportDateValue = (value: string): boolean => (
+ isValid(parseISO(value.trim()))
+)
+
+/**
+ * Returns the reports-page validation error for a parameter, if any.
+ *
+ * Empty values are treated as valid here because required-field checks run
+ * separately in `ReportsPage` before download is enabled.
+ *
+ * @param parameter report parameter metadata from the reports directory
+ * @param rawValue raw field value entered by the user
+ * @returns an error message when the value is invalid; otherwise `undefined`
+ */
+export const getReportParameterValidationError = (
+ parameter: Pick,
+ rawValue?: string,
+): string | undefined => {
+ const trimmedValue = rawValue?.trim()
+
+ if (!trimmedValue) {
+ return undefined
+ }
+
+ if (parameter.type === 'date' && !isValidReportDateValue(trimmedValue)) {
+ return invalidReportDateMessage
+ }
+
+ return undefined
+}
diff --git a/src/apps/reports/src/reports-app.routes.tsx b/src/apps/reports/src/reports-app.routes.tsx
new file mode 100644
index 000000000..df4b56ecd
--- /dev/null
+++ b/src/apps/reports/src/reports-app.routes.tsx
@@ -0,0 +1,62 @@
+/**
+ * App routes
+ */
+import { AppSubdomain, ToolTitle } from '~/config'
+import {
+ lazyLoad,
+ LazyLoadedComponent,
+ PlatformRoute,
+ Rewrite,
+ UserRole,
+} from '~/libs/core'
+
+import {
+ bulkMemberLookupRouteId,
+ reportsPageRouteId,
+ rootRoute,
+} from './config/routes.config'
+
+const ReportsApp: LazyLoadedComponent = lazyLoad(() => import('./ReportsApp'))
+const ReportsPage: LazyLoadedComponent = lazyLoad(
+ () => import('./pages/reports/ReportsPage'),
+ 'ReportsPage',
+)
+const BulkMemberLookupPage: LazyLoadedComponent = lazyLoad(
+ () => import('./pages/bulk-member-lookup/BulkMemberLookupPage'),
+ 'BulkMemberLookupPage',
+)
+
+export const toolTitle: string = ToolTitle.reports
+
+export const reportsRoutes: ReadonlyArray = [
+ // Reports App Root
+ {
+ authRequired: true,
+ children: [
+ {
+ authRequired: true,
+ element: ,
+ route: '',
+ },
+ {
+ authRequired: true,
+ element: ,
+ route: reportsPageRouteId,
+ },
+ {
+ authRequired: true,
+ element: ,
+ route: bulkMemberLookupRouteId,
+ },
+ ],
+ domain: AppSubdomain.reports,
+ element: ,
+ id: toolTitle,
+ rolesRequired: [
+ UserRole.administrator,
+ UserRole.talentManager,
+ ],
+ route: rootRoute,
+ title: toolTitle,
+ },
+]
diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss
index 7938835ac..e09b31d55 100644
--- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss
+++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss
@@ -6,6 +6,37 @@
overflow: hidden;
}
+.lockedBanner {
+ background-color: $black-5;
+ color: #C1294F;
+ border-radius: $sp-1;
+ padding: $sp-4;
+ margin: $sp-2 $sp-3;
+
+ display: flex;
+ gap: $sp-2;
+}
+
+.lockedTitle {
+ display: flex;
+ align-items: center;
+ gap: $sp-1;
+ font-weight: 700;
+ font-size: 14px;
+}
+
+.lockedMessage {
+ margin-top: $sp-1;
+ font-size: 14px;
+ max-width: 720px;
+ white-space: normal;
+}
+
+.reRunIcon {
+ color: $black-80;
+ cursor: pointer;
+}
+
.reviewsTable {
width: 100%;
border-collapse: collapse;
@@ -33,6 +64,18 @@
.scoreCol {
text-align: left;
+ .flex {
+ display: flex;
+ align-items: center;
+ gap: $sp-1;
+ }
+ }
+
+ .infoIcon {
+ display: inline-flex;
+ align-items: center;
+ margin-left: $sp-1;
+ cursor: pointer;
}
}
@@ -57,6 +100,25 @@
}
}
+.gatingMarker {
+ color: $red-160;
+ font-size: 12px;
+ cursor: pointer;
+}
+
+.row-passed {
+ .resultCol {
+ color: $green-140;
+ }
+}
+
+.row-failed,
+.row-failed-score {
+ .resultCol {
+ color: $red-140;
+ }
+}
+
.mobileCard {
border-top: 1px solid #A8A8A8;
margin-top: $sp-2;
diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
index 9d306a65e..e91f51241 100644
--- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
+++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
@@ -1,33 +1,126 @@
-import { FC, MouseEvent as ReactMouseEvent, useMemo } from 'react'
+import { FC, MouseEvent as ReactMouseEvent, 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 classNames from 'classnames'
import moment from 'moment'
+import { handleError } from '~/libs/shared/lib/utils/handle-error'
import { useWindowSize, WindowSize } from '~/libs/shared'
-import { Tooltip } from '~/libs/ui'
+import { IconOutline, Tooltip } from '~/libs/ui'
import {
+ aiRunFailed,
+ aiRunInProgress,
AiWorkflowRun,
AiWorkflowRunsResponse,
AiWorkflowRunStatusEnum,
+ getAiWorkflowRunsCacheKey,
+ retriggerAiWorkflowRun,
useFetchAiWorkflowsRuns,
+ useRolePermissions,
+ UseRolePermissionsResult,
} from '../../hooks'
import { IconAiReview } from '../../assets/icons'
import { TABLE_DATE_FORMAT } from '../../../config/index.config'
-import { BackendSubmission } from '../../models'
+import {
+ AiReviewConfigWorkflow,
+ AiReviewDecision,
+ AiReviewDecisionBreakdownWorkflow,
+ BackendSubmission,
+ ChallengeDetailContextModel,
+} from '../../models'
+import { ChallengeDetailContext } from '../../contexts'
import { AiWorkflowRunStatus } from './AiWorkflowRunStatus'
import styles from './AiReviewsTable.module.scss'
interface AiReviewsTableProps {
submission: Pick
+ aiReviewers?: { aiWorkflowId: string }[]
+}
+
+interface AiReviewerRow {
+ id: string
+ isGating?: boolean
+ minScore?: number
+ reviewDate?: string
+ run?: Pick
+ score?: number
+ status?: 'failed' | 'failed-score' | 'passed' | 'pending'
+ title: string
+ weight?: number
+ workflowId?: string
}
const stopPropagation = (ev: ReactMouseEvent): void => {
ev.stopPropagation()
}
+function normalizeStatus(
+ runStatus?: string | null,
+ score?: number | null,
+ minScore?: number,
+): 'failed' | 'failed-score' | 'passed' | 'pending' {
+ if (!runStatus) {
+ return 'pending'
+ }
+
+ if (aiRunInProgress({ status: runStatus as AiWorkflowRunStatusEnum })) {
+ return 'pending'
+ }
+
+ if (aiRunFailed({ status: runStatus as AiWorkflowRunStatusEnum })) {
+ return 'failed'
+ }
+
+ if (typeof score !== 'number') {
+ return 'pending'
+ }
+
+ return score >= (minScore ?? 0) ? 'passed' : 'failed-score'
+}
+
+function formatScore(value?: number | null): string {
+ if (typeof value !== 'number' || Number.isNaN(value)) {
+ return '-'
+ }
+
+ return value.toFixed(2)
+}
+
+function formatWeight(value?: number): string {
+ if (typeof value !== 'number' || Number.isNaN(value)) {
+ return '-'
+ }
+
+ return `${value.toFixed(0)}%`
+}
+
+function getConfiguredWorkflowName(workflow?: AiReviewConfigWorkflow['workflow']): string | undefined {
+ const configuredName = workflow?.name?.trim()
+ return configuredName || undefined
+}
+
+function getDecisionBySubmission(
+ decisions: Record,
+ submissionId: string,
+): AiReviewDecision | undefined {
+ return decisions[submissionId]
+}
+
+// eslint-disable-next-line complexity
const AiReviewsTable: FC = props => {
const { runs, isLoading }: AiWorkflowRunsResponse = useFetchAiWorkflowsRuns(props.submission.id)
+ const challengeDetailContext: ChallengeDetailContextModel = useContext(ChallengeDetailContext)
+ const aiReviewConfig: ChallengeDetailContextModel['aiReviewConfig'] = challengeDetailContext.aiReviewConfig
+ const aiReviewDecisionsBySubmissionId: ChallengeDetailContextModel['aiReviewDecisionsBySubmissionId']
+ = challengeDetailContext.aiReviewDecisionsBySubmissionId
+ const isLoadingAiReviewConfig: ChallengeDetailContextModel['isLoadingAiReviewConfig']
+ = challengeDetailContext.isLoadingAiReviewConfig
+ const isLoadingAiReviewDecisions: ChallengeDetailContextModel['isLoadingAiReviewDecisions']
+ = challengeDetailContext.isLoadingAiReviewDecisions
const windowSize: WindowSize = useWindowSize()
const isTablet = useMemo(
@@ -35,49 +128,239 @@ const AiReviewsTable: FC = props => {
[windowSize.width],
)
- const aiRuns = useMemo(() => [
- ...runs,
- {
- completedAt: (props.submission as BackendSubmission).submittedDate,
- id: '-1',
- score: props.submission.virusScan === true ? 100 : 0,
- status: AiWorkflowRunStatusEnum.SUCCESS,
- workflow: {
- description: '',
- name: 'Virus Scan',
- scorecard: {
- minimumPassingScore: 1,
+ const currentDecision = useMemo(
+ () => getDecisionBySubmission(aiReviewDecisionsBySubmissionId, props.submission.id),
+ [aiReviewDecisionsBySubmissionId, props.submission.id],
+ )
+
+ const configuredWorkflows = useMemo(
+ () => aiReviewConfig?.workflows ?? [],
+ [aiReviewConfig],
+ )
+
+ const hasConfig = useMemo(
+ () => configuredWorkflows.length > 0,
+ [configuredWorkflows.length],
+ )
+
+ const decisionWorkflowRows = useMemo(
+ () => currentDecision?.breakdown?.workflows ?? [],
+ [currentDecision],
+ )
+
+ const runsByWorkflowId = useMemo(
+ () => new Map(
+ runs
+ .filter(run => Boolean(run.workflow?.id))
+ .map(run => [run.workflow.id, run]),
+ ),
+ [runs],
+ )
+
+ const reviewerRows = useMemo(() => {
+ const configuredIds = configuredWorkflows.map(workflow => workflow.workflowId)
+ const reviewerIds = (props.aiReviewers ?? []).map(reviewer => reviewer.aiWorkflowId)
+ const decisionIds = decisionWorkflowRows.map(workflow => workflow.workflowId)
+ const runsIds = runs.map(run => run.workflow?.id)
+ .filter((id): id is string => Boolean(id))
+
+ const orderedWorkflowIds = hasConfig
+ ? [...configuredIds, ...decisionIds, ...runsIds]
+ : [...reviewerIds, ...decisionIds, ...runsIds]
+
+ const uniqueWorkflowIds = Array.from(new Set(orderedWorkflowIds.filter(Boolean)))
+
+ const rows: AiReviewerRow[] = uniqueWorkflowIds.map(workflowId => {
+ const configured = configuredWorkflows.find(item => item.workflowId === workflowId)
+ const fromDecision = decisionWorkflowRows.find(item => item.workflowId === workflowId)
+ const run = runsByWorkflowId.get(workflowId)
+ const minScore = fromDecision?.minimumPassingScore
+ ?? configured?.workflow?.scorecard?.minimumPassingScore
+
+ const status = fromDecision
+ ? normalizeStatus(run && aiRunInProgress(run)
+ ? undefined
+ : fromDecision.runStatus, fromDecision.runScore, minScore)
+ : undefined
+
+ return {
+ id: workflowId,
+ isGating: fromDecision?.isGating ?? configured?.isGating,
+ minScore,
+ reviewDate: run?.completedAt,
+ run,
+ score: fromDecision?.runScore ?? run?.score,
+ status,
+ title: getConfiguredWorkflowName(configured?.workflow) ?? run?.workflow?.name ?? 'AI Review',
+ weight: fromDecision?.weightPercent ?? configured?.weightPercent,
+ workflowId,
+ }
+ })
+
+ const hasVirusScan = rows.some(row => row.title.toLowerCase() === 'virus scan')
+
+ if (!hasVirusScan) {
+ rows.push({
+ id: 'virus-scan-fallback',
+ minScore: hasConfig ? 100 : undefined,
+ reviewDate: (props.submission as BackendSubmission).submittedDate,
+ run: {
+ id: '-1',
+ score: props.submission.virusScan === true ? 100 : 0,
+ status: AiWorkflowRunStatusEnum.SUCCESS,
+ workflow: {
+ description: '',
+ name: 'Virus Scan',
+ scorecard: {
+ minimumPassingScore: 100,
+ },
+ } as AiWorkflowRun['workflow'],
},
- },
- } as AiWorkflowRun,
- ], [runs, props.submission])
+ score: props.submission.virusScan === undefined
+ ? undefined
+ : (props.submission.virusScan ? 100 : 0),
+ status: props.submission.virusScan === undefined ? 'pending' : (
+ props.submission.virusScan ? 'passed' : 'failed-score'
+ ),
+ title: 'Virus Scan',
+ weight: hasConfig ? 0 : undefined,
+ })
+ }
+
+ return rows
+ }, [
+ configuredWorkflows,
+ decisionWorkflowRows,
+ hasConfig,
+ props.aiReviewers,
+ props.submission,
+ runs,
+ runsByWorkflowId,
+ ])
+
+ const loading = isLoading || isLoadingAiReviewConfig || isLoadingAiReviewDecisions
+
+ const rolePermissions: UseRolePermissionsResult = useRolePermissions()
+ const { isAdmin, hasSubmitterRole }: UseRolePermissionsResult = rolePermissions
+ const { mutate }: FullConfiguration = useSWRConfig()
+ const [, setRerunningRunId] = useState(undefined)
+
+ const handleRerun = useCallback(async (runId?: string): Promise => {
+ if (!runId || runId === '-1') return
+
+ setRerunningRunId(runId)
+ try {
+ await retriggerAiWorkflowRun(runId)
+ await mutate(getAiWorkflowRunsCacheKey(props.submission.id))
+ toast.success('Workflow re-run triggered successfully.')
+ } catch (error) {
+ handleError(error as Error)
+ toast.error('Failed to trigger workflow re-run.')
+ } finally {
+ setRerunningRunId(undefined)
+ }
+ }, [mutate, props.submission.id])
+
+ const failedGatingReviewers = useMemo(
+ () => reviewerRows
+ .filter(row => row.isGating && (row.status === 'failed' || row.status === 'failed-score'))
+ .map(row => row.title),
+ [reviewerRows],
+ )
+
+ const lockMessage = useMemo(() => {
+ if (!currentDecision?.submissionLocked) {
+ return undefined
+ }
+
+ const failedReviewersText = failedGatingReviewers.length
+ ? `Gating Reviewers failed: ${failedGatingReviewers.join(', ')}.
+ This submission is automatically failed regardless of Overall Score.`
+ : `This submission is failed because ${hasSubmitterRole ? 'your' : 'the'}
+ Overall Score is bellow minimum threshold.`
+
+ // Message text varies by role
+ const roleBasedText = hasSubmitterRole
+ ? 'Improve your submission and resubmit.'
+ : ''
+
+ return `${failedReviewersText} ${roleBasedText}`
+ }, [currentDecision?.submissionLocked, failedGatingReviewers, hasSubmitterRole])
+
+ const lockedBannerTitle = useMemo(() => {
+ if (!currentDecision?.submissionLocked) {
+ return ''
+ }
+
+ if (hasSubmitterRole) {
+ return 'Submission Locked - Your submission will not be reviewed in the Review Phase.'
+ }
+
+ return 'Submission Locked - This submission doesn\'t have to be reviewed in Review Phase.'
+ }, [currentDecision?.submissionLocked, hasSubmitterRole])
if (isTablet) {
return (
- {!runs.length && isLoading && (
+ {currentDecision?.submissionLocked && lockMessage && (
+
+
+
+ {lockedBannerTitle}
+
+ {lockMessage}
+
+ )}
+
+ {!reviewerRows.length && loading && (
Loading...
)}
- {aiRuns.map(run => (
-
+ {reviewerRows.map(row => (
+
Reviewer
-
- {run.workflow.name}
+
+ {row.title}
+ {row.isGating && (
+
+
+
+ )}
+ {hasConfig && (
+ <>
+
+ Weight
+ {formatWeight(row.weight)}
+
+
+ Min Score
+ {formatScore(row.minScore)}
+
+ >
+ )}
+
Review Date
- {run.status === 'SUCCESS'
- ? moment(run.completedAt)
+ {row.reviewDate
+ ? moment(row.reviewDate)
.local()
.format(TABLE_DATE_FORMAT)
: '-'}
@@ -87,14 +370,14 @@ const AiReviewsTable: FC = props => {
Score
- {run.status === 'SUCCESS' ? (
- (run.workflow.scorecard && run.workflow.id) ? (
+ {typeof row.score === 'number' ? (
+ row.workflowId ? (
- {run.score}
+ {formatScore(row.score)}
- ) : run.score
+ ) : formatScore(row.score)
) : '-'}
@@ -102,7 +385,24 @@ const AiReviewsTable: FC = props => {
@@ -113,10 +413,24 @@ const AiReviewsTable: FC = props => {
return (
+ {currentDecision?.submissionLocked && lockMessage && (
+
+
+
+
+ {lockedBannerTitle}
+
+ {lockMessage}
+
+
+ )}
+
| AI Reviewer |
+ {hasConfig && Weight | }
+ {hasConfig && Min Score | }
Review Date |
Score |
Result |
@@ -124,46 +438,75 @@ const AiReviewsTable: FC = props => {
- {!runs.length && isLoading && (
+ {!reviewerRows.length && loading && (
- | Loading... |
+ Loading... |
)}
- {aiRuns.map(run => (
-
+ {reviewerRows.map(row => (
+
|
-
- {run.workflow.name}
+
+ {row.title}
+ {row.isGating && (
+
+
+
+ )}
|
+ {hasConfig && {formatWeight(row.weight)} | }
+ {hasConfig && {formatScore(row.minScore)} | }
- {run.status === 'SUCCESS' && (
- moment(run.completedAt)
+ {row.reviewDate && (
+ moment(row.reviewDate)
.local()
.format(TABLE_DATE_FORMAT)
)}
|
- {run.status === 'SUCCESS' ? (
- run.workflow.id ? (
+ {typeof row.score === 'number' ? (
+ row.workflowId ? (
- {run.score}
+ {formatScore(row.score)}
- ) : run.score
+ ) : formatScore(row.score)
) : '-'}
|
-
+
+
+
+ )
+ }
+ />
|
))}
diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx
index 2a1744cfc..078d0ec29 100644
--- a/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx
+++ b/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx
@@ -1,31 +1,18 @@
-import { FC, PropsWithChildren, useCallback, useMemo, useState } from 'react'
-import { toast } from 'react-toastify'
-import { useSWRConfig } from 'swr'
-import { FullConfiguration } from 'swr/dist/types'
+import { FC, ReactNode, useMemo } from 'react'
-import { IconOutline, Tooltip } from '~/libs/ui'
-import { handleError } from '~/libs/shared/lib/utils/handle-error'
+import { IconOutline } from '~/libs/ui'
-import {
- aiRunFailed,
- aiRunInProgress,
- AiWorkflowRun,
- getAiWorkflowRunsCacheKey,
- retriggerAiWorkflowRun,
- useRolePermissions,
- UseRolePermissionsResult,
-} from '../../hooks'
+import { aiRunFailed, aiRunInProgress, AiWorkflowRun } from '../../hooks'
import StatusLabel from './StatusLabel'
-import styles from './AiWorkflowRunStatus.module.scss'
interface AiWorkflowRunStatusProps {
run?: Pick
- status?: 'passed' | 'pending' | 'failed-score'
+ status?: 'passed' | 'pending' | 'failed-score' | 'failed'
score?: number
hideLabel?: boolean
showScore?: boolean
- submissionId?: string
+ action?: ReactNode
}
const aiRunStatus = (run: Pick): string => {
@@ -41,10 +28,6 @@ const aiRunStatus = (run: Pick): str
}
export const AiWorkflowRunStatus: FC = props => {
- const [isRerunning, setIsRerunning] = useState(false)
- const { isAdmin }: UseRolePermissionsResult = useRolePermissions()
- const { mutate }: FullConfiguration = useSWRConfig()
-
const status = useMemo(() => {
if (props.status) {
return props.status
@@ -58,64 +41,10 @@ export const AiWorkflowRunStatus: FC = props => {
}, [props.status, props.run])
const displayStatus = status
-
- const handleRerun = useCallback(async (): Promise => {
- const runId = props.run?.id
- if (!runId || runId === '-1') {
- return
- }
-
- setIsRerunning(true)
-
- try {
- await retriggerAiWorkflowRun(runId)
-
- if (props.submissionId) {
- await mutate(getAiWorkflowRunsCacheKey(props.submissionId))
- } else {
- await mutate(
- (key: unknown) => typeof key === 'string' && key.includes('/workflows/runs?submissionId='),
- )
- }
-
- toast.success('Workflow re-run triggered successfully.')
- } catch (error) {
- handleError(error as Error)
- toast.error('Failed to trigger workflow re-run.')
- } finally {
- setIsRerunning(false)
- }
- }, [mutate, props.run, props.submissionId])
-
const score: number | undefined = props.showScore ? (props.score ?? props.run?.score) : undefined
- const Wrapper: FC = useCallback(({ children }: PropsWithChildren) => {
- if (!isAdmin || displayStatus === 'pending' || !props.run?.id || props.run?.id === '-1') {
- return <>{children}>
- }
-
- return (
-
- |