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/pages/engagement-detail/EngagementDetailPage.tsx b/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx
index 87daa4ce2..39d6b77c3 100644
--- a/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx
+++ b/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx
@@ -1039,7 +1039,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.
)
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/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
)}
{
+ 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..b1f891016 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,220 @@ 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 { isAdmin }: UseRolePermissionsResult = useRolePermissions()
+ 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(', ')}.`
+ : ''
+
+ return `${failedReviewersText} This submission is automatically failed regardless of Overall Score. `
+ + 'Improve your submission and resubmit.'
+ }, [currentDecision?.submissionLocked, failedGatingReviewers])
if (isTablet) {
return (
- {!runs.length && isLoading && (
+ {currentDecision?.submissionLocked && lockMessage && (
+
+
+
+ Submission Locked - Your submission will not be reviewed in the Review Phase.
+
+ {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 +351,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 +366,24 @@ const AiReviewsTable: FC = props => {
@@ -113,10 +394,24 @@ const AiReviewsTable: FC = props => {
return (
+ {currentDecision?.submissionLocked && lockMessage && (
+
+
+
+
+ Submission Locked - Your submission will not be reviewed in the Review Phase.
+
+ {lockMessage}
+
+
+ )}
+
| AI Reviewer |
+ {hasConfig && Weight | }
+ {hasConfig && Min Score | }
Review Date |
Score |
Result |
@@ -124,46 +419,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 (
-
-
- {isRerunning ? 'Re-running...' : 'Re-run'}
-
- )}
- >
- {children}
-
- )
- }, [isAdmin, displayStatus, props.run, isRerunning, handleRerun])
-
return (
-
+ <>
{displayStatus === 'passed' && (
}
@@ -123,6 +52,7 @@ export const AiWorkflowRunStatus: FC = props => {
label='Passed'
status={displayStatus}
score={score}
+ action={props.action}
/>
)}
{displayStatus === 'failed-score' && (
@@ -132,6 +62,7 @@ export const AiWorkflowRunStatus: FC = props => {
label='Failed'
status={displayStatus}
score={score}
+ action={props.action}
/>
)}
{displayStatus === 'pending' && (
@@ -141,6 +72,7 @@ export const AiWorkflowRunStatus: FC = props => {
label='To be filled'
status={displayStatus}
score={score}
+ action={props.action}
/>
)}
{displayStatus === 'failed' && (
@@ -150,8 +82,9 @@ export const AiWorkflowRunStatus: FC = props => {
status={displayStatus}
label='Failure'
score={score}
+ action={props.action}
/>
)}
-
+ >
)
}
diff --git a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss
index 1857d285f..b416ee905 100644
--- a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss
+++ b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss
@@ -31,6 +31,15 @@
}
}
+.aiIcon {
+ display: flex;
+ width: 20px;
+ height: 20px;
+ background: #fff;
+ align-items: center;
+ justify-content: center;
+}
+
.score {
font-size: 14px;
&.failed-score {
diff --git a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx
index 011649ebd..0458c02c9 100644
--- a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx
+++ b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx
@@ -9,6 +9,8 @@ interface StatusLabelProps {
label?: string
score?: number
status: 'pending' | 'failed' | 'passed' | 'failed-score'
+ action?: ReactNode
+ isAiIcon?: boolean
}
const StatusLabel: FC = props => (
@@ -17,11 +19,17 @@ const StatusLabel: FC = props => (
{props.score}
)}
{props.icon && (
-
+
{props.icon}
)}
{!props.hideLabel && props.label}
+ {props.action}
)
diff --git a/src/apps/review/src/lib/components/AiScoreFormulaTooltip/AiScoreFormulaTooltip.module.scss b/src/apps/review/src/lib/components/AiScoreFormulaTooltip/AiScoreFormulaTooltip.module.scss
new file mode 100644
index 000000000..355666c43
--- /dev/null
+++ b/src/apps/review/src/lib/components/AiScoreFormulaTooltip/AiScoreFormulaTooltip.module.scss
@@ -0,0 +1,28 @@
+@import '@libs/ui/styles/includes';
+
+.infoTooltipContent {
+ min-width: 220px;
+ font-size: 12px;
+ color: $tc-white;
+}
+
+.infoTooltipRow {
+ display: flex;
+ justify-content: space-between;
+ gap: $sp-2;
+ margin-bottom: $sp-1;
+ border-bottom: 1px solid $black-10;
+}
+
+.infoTooltipTitle {
+ font-weight: 700;
+ margin-top: $sp-1;
+ margin-bottom: $sp-1;
+}
+
+.infoTooltipLine {
+ line-height: 1.4;
+ &.indent {
+ text-indent: 85px;
+ }
+}
\ No newline at end of file
diff --git a/src/apps/review/src/lib/components/AiScoreFormulaTooltip/AiScoreFormulaTooltip.tsx b/src/apps/review/src/lib/components/AiScoreFormulaTooltip/AiScoreFormulaTooltip.tsx
new file mode 100644
index 000000000..b34306ccc
--- /dev/null
+++ b/src/apps/review/src/lib/components/AiScoreFormulaTooltip/AiScoreFormulaTooltip.tsx
@@ -0,0 +1,72 @@
+import { FC } from 'react'
+import classNames from 'classnames'
+
+import { AiReviewConfig, AiReviewConfigWorkflow } from '../../models'
+
+import styles from './AiScoreFormulaTooltip.module.scss'
+
+interface AiScoreFormulaTooltipProps {
+ aiReviewConfig: AiReviewConfig | undefined,
+}
+
+export function formatScore(value?: number | null): string {
+ if (typeof value !== 'number' || Number.isNaN(value)) {
+ return '-'
+ }
+
+ return value.toFixed(2)
+}
+
+export function formatWeight(value?: number): string {
+ if (typeof value !== 'number' || Number.isNaN(value)) {
+ return '-'
+ }
+
+ return `${value.toFixed(0)}%`
+}
+
+const AiScoreFormulaTooltip: FC = props => {
+
+ const configuredWorkflows: AiReviewConfigWorkflow[] = props.aiReviewConfig?.workflows ?? []
+
+ if (!props.aiReviewConfig || !configuredWorkflows.length) {
+ return <>>
+ }
+
+ const formulaLines = configuredWorkflows.map((workflow, i) => {
+ const label = workflow.workflow?.name ?? 'AI Reviewer'
+ return `${!i ? '' : '+ '}${formatWeight(workflow.weightPercent)} * ${label}`
+ })
+
+ return (
+
+
+ Min Passing Score
+ {formatScore(props.aiReviewConfig.minPassingThreshold)}
+
+
+
+ AI Score Formula
+
+
+
+ Overall Score =
+ {
+ formulaLines[0]
+ }
+
+
+ {formulaLines.slice(1)
+ .map(line => (
+
+ {line}
+
+ ))}
+
+ )
+}
+
+export default AiScoreFormulaTooltip
diff --git a/src/apps/review/src/lib/components/AiScoreFormulaTooltip/index.ts b/src/apps/review/src/lib/components/AiScoreFormulaTooltip/index.ts
new file mode 100644
index 000000000..422df6a92
--- /dev/null
+++ b/src/apps/review/src/lib/components/AiScoreFormulaTooltip/index.ts
@@ -0,0 +1 @@
+export { default as AiScoreFormulaTooltip } from './AiScoreFormulaTooltip'
diff --git a/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.tsx b/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.tsx
index be146b2d6..118c76c3e 100644
--- a/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.tsx
+++ b/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.tsx
@@ -40,8 +40,11 @@ export const ChallengeLinks: FC = (props: Props) => {
// Payments button visibility: only copilots and admins
const canShowPaymentsButton = useMemo(
- () => [ADMIN, COPILOT].includes(actionChallengeRole as any),
- [actionChallengeRole],
+ () => (
+ [ADMIN, COPILOT].includes(actionChallengeRole as any)
+ || myResources.some(resource => resource.roleName?.toLowerCase() === 'copilot')
+ ),
+ [actionChallengeRole, myResources],
)
return (
diff --git a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.module.scss b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.module.scss
index 95a40e950..862bdf09f 100644
--- a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.module.scss
+++ b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.module.scss
@@ -5,31 +5,70 @@
text-align: left;
}
-.reviewersDropown {
+.header {
display: flex;
align-items: center;
- gap: $sp-2;
+ justify-content: space-between;
cursor: pointer;
+ gap: $sp-4;
+}
- @include ltelg {
- justify-content: space-between;
- font-weight: 600;
- }
+.reviewersDropdown {
+ display: flex;
+ align-items: center;
+ gap: $sp-2;
+ cursor: pointer;
+ flex: 1;
svg {
color: #767676;
}
}
+.statusContainer {
+ display: flex;
+ align-items: center;
+ gap: $sp-6;
+ flex-shrink: 0;
+}
+
+.score {
+ display: flex;
+ align-items: center;
+ gap: $sp-1;
+ font-weight: 700;
+ font-size: 14px;
+}
+
+.scorePassed {
+ color: $link-blue-dark;
+}
+
+.scoreFailed {
+ color: $red-100;
+}
+
+.infoIcon {
+ display: inline-flex;
+ align-items: center;
+ cursor: pointer;
+ color: $black-60;
+}
+
+.runStatus {
+ min-width: 105px;
+}
+
.table {
- margin-top: $sp-2;
- margin-left: -1 * $sp-4;
- @include ltelg {
- margin-top: 0;
- margin-left: -1 * $sp-4;
- margin-right: -1 * $sp-4;
- }
+ margin-top: -1px;
}
+// margin-left: -1 * $sp-4;
+// @include ltelg {
+// margin-top: 0;
+// margin-left: -1 * $sp-4;
+// margin-right: -1 * $sp-4;
+// }
+// }
.rotated {
transform: rotate(180deg);
diff --git a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx
index 98ccc3b57..326e86db1 100644
--- a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx
+++ b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx
@@ -1,10 +1,19 @@
-import { FC, useCallback, useState } from 'react'
+import { FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
import classNames from 'classnames'
-import { IconOutline } from '~/libs/ui'
+import { IconOutline, Tooltip } from '~/libs/ui'
-import { AiReviewsTable } from '../AiReviewsTable'
-import { BackendSubmission } from '../../models'
+import { AiReviewsTable, AiWorkflowRunStatus } from '../AiReviewsTable'
+import {
+ AiReviewDecision,
+ AiReviewDecisionStatus,
+ BackendSubmission,
+ ChallengeDetailContextModel,
+} from '../../models'
+import { ChallengeDetailContext } from '../../contexts'
+import { AiScoreFormulaTooltip } from '../AiScoreFormulaTooltip'
+import { formatScore } from '../AiScoreFormulaTooltip/AiScoreFormulaTooltip'
import styles from './CollapsibleAiReviewsRow.module.scss'
@@ -15,28 +24,133 @@ interface CollapsibleAiReviewsRowProps {
submission: Pick
}
+export function normalizeDecisionStatus(
+ status?: AiReviewDecisionStatus,
+): 'passed' | 'failed-score' | 'pending' | 'failed' {
+ if (!status || status === 'PENDING') {
+ return 'pending'
+ }
+
+ if (status === 'PASSED') {
+ return 'passed'
+ }
+
+ if (status === 'FAILED') {
+ return 'failed-score'
+ }
+
+ if (status === 'ERROR') {
+ return 'failed'
+ }
+
+ return 'pending'
+}
+
const CollapsibleAiReviewsRow: FC = props => {
- const aiReviewersCount = props.aiReviewers.length + 1
+ const challengeDetailContext: ChallengeDetailContextModel = useContext(ChallengeDetailContext)
+ const aiReviewConfig: ChallengeDetailContextModel['aiReviewConfig'] = challengeDetailContext.aiReviewConfig
+ const aiReviewDecisionsBySubmissionId: ChallengeDetailContextModel['aiReviewDecisionsBySubmissionId']
+ = challengeDetailContext.aiReviewDecisionsBySubmissionId
+
+ const aiReviewersCount = useMemo(() => {
+ const reviewersCount = props.aiReviewers.length || aiReviewConfig?.workflows?.length || 0
+ return reviewersCount + 1
+ }, [aiReviewConfig?.workflows?.length, props.aiReviewers.length])
+
+ const currentDecision = useMemo(
+ () => aiReviewDecisionsBySubmissionId[props.submission.id],
+ [aiReviewDecisionsBySubmissionId, props.submission.id],
+ )
+
+ const normalizedStatus = useMemo(
+ () => normalizeDecisionStatus(currentDecision?.status),
+ [currentDecision?.status],
+ )
const [isOpen, setIsOpen] = useState(props.defaultOpen ?? false)
+ const [portalContainer, setPortalContainer] = useState(undefined)
+ const wrapperRef = useRef(null)
+ const createdRowRef = useRef(undefined)
const toggleOpen = useCallback(() => {
setIsOpen(wasOpen => !wasOpen)
}, [])
+ useEffect(() => {
+ // create portal row when opened
+ if (isOpen && wrapperRef.current) {
+ const parentTr = wrapperRef.current.closest('tr') as HTMLTableRowElement | null
+ const parentTd = wrapperRef.current.closest('td') as HTMLTableCellElement | null
+ const tbody = parentTr?.parentElement as HTMLTableSectionElement | null
+ if (parentTr && tbody) {
+ const createdTr = document.createElement('tr')
+ const createdTd = document.createElement('td')
+ const colCount = parentTd?.getAttribute('colSpan') ? parentTd.colSpan : parentTr.children.length || 1
+ createdTd.colSpan = colCount
+ createdTr.appendChild(createdTd)
+ parentTr.insertAdjacentElement('afterend', createdTr)
+ createdRowRef.current = createdTr
+ setPortalContainer(createdTd)
+ }
+ }
+
+ return () => {
+ // cleanup created row
+ const createdTr = createdRowRef.current
+ if (createdTr && createdTr.parentElement) {
+ createdTr.parentElement.removeChild(createdTr)
+ createdRowRef.current = undefined
+ }
+
+ setPortalContainer(undefined)
+ }
+ }, [isOpen])
+
+ const hasScore = currentDecision?.totalScore !== null && currentDecision?.totalScore !== undefined
+
return (
-
-
- {aiReviewersCount}
- {' '}
- AI Reviewer
- {aiReviewersCount === 1 ? '' : 's'}
-
-
- {isOpen && (
-
-
+
+
+
+ {aiReviewersCount}
+ {' '}
+ AI Reviewer
+ {aiReviewersCount === 1 ? '' : 's'}
+
+
+
+ {hasScore && (
+
+ }
+ triggerOn='hover'
+ >
+
+
+
+
+ {formatScore(currentDecision!.totalScore)}
+
+ )}
+ {currentDecision && (
+
+ )}
+
+ {isOpen && portalContainer && createPortal(
+ ,
+ portalContainer,
)}
)
diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/utils.spec.ts b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/utils.spec.ts
new file mode 100644
index 000000000..34cf46f24
--- /dev/null
+++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/utils.spec.ts
@@ -0,0 +1,81 @@
+import type { ScorecardInfo } from '../../../models'
+
+import { calculateProgressAndScore } from './utils'
+
+jest.mock('../../../utils', () => ({
+ roundWith2DecimalPlaces: (value: number): number => Math.round(value * 100) / 100,
+}))
+
+const buildScorecard = (): ScorecardInfo => ({
+ id: 'scorecard-1',
+ minimumPassingScore: 98,
+ name: 'Topgear - Standard Task Review Scorecard',
+ scorecardGroups: [
+ {
+ id: 'group-1',
+ name: 'Review',
+ sections: [
+ {
+ id: 'section-1',
+ name: 'Review',
+ questions: [
+ {
+ description: 'Question 1',
+ guidelines: 'Question 1',
+ id: 'question-1',
+ requiresUpload: false,
+ scaleMax: 0,
+ scaleMin: 0,
+ sortOrder: 1,
+ type: 'YES_NO',
+ weight: 50,
+ },
+ {
+ description: 'Question 2',
+ guidelines: 'Question 2',
+ id: 'question-2',
+ requiresUpload: false,
+ scaleMax: 0,
+ scaleMin: 0,
+ sortOrder: 2,
+ type: 'YES_NO',
+ weight: 50,
+ },
+ ],
+ sortOrder: 1,
+ weight: 100,
+ },
+ ],
+ sortOrder: 1,
+ weight: 98,
+ },
+ ],
+})
+
+describe('calculateProgressAndScore', () => {
+ it('scores uppercase YES answers from persisted reviews as full marks', () => {
+ const result = calculateProgressAndScore([
+ {
+ initialAnswer: 'YES',
+ scorecardQuestionId: 'question-1',
+ },
+ {
+ initialAnswer: 'YES',
+ scorecardQuestionId: 'question-2',
+ },
+ ], buildScorecard())
+
+ expect(result.reviewProgress)
+ .toBe(100)
+ expect(result.scoreMap.get('question-1'))
+ .toBe(50)
+ expect(result.scoreMap.get('question-2'))
+ .toBe(50)
+ expect(result.scoreMap.get('section-1'))
+ .toBe(100)
+ expect(result.scoreMap.get('group-1'))
+ .toBe(98)
+ expect(result.totalScore)
+ .toBe(98)
+ })
+})
diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/utils.ts b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/utils.ts
index fccc968c9..a2d87f1d1 100644
--- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/utils.ts
+++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/utils.ts
@@ -63,7 +63,30 @@ export interface ProgressAndScore {
}
/**
- * Calculate progress and score from review form data
+ * Review answers come back as `Yes`/`No` from form controls and `YES`/`NO`
+ * from persisted API data. Normalize both formats before applying score logic.
+ */
+const isAffirmativeYesNoAnswer = (
+ answer?: string | number | null,
+): boolean => {
+ if (answer === undefined || answer === null) {
+ return false
+ }
+
+ if (typeof answer === 'number') {
+ return answer === 1
+ }
+
+ const normalizedAnswer = `${answer}`.trim()
+ .toUpperCase()
+
+ return normalizedAnswer === 'YES' || normalizedAnswer === '1'
+}
+
+/**
+ * Calculate progress and score from review form data.
+ * YES/NO answers are normalized so the viewer scores both UI (`Yes`) and
+ * API (`YES`) representations consistently.
*/
export const calculateProgressAndScore = (
reviewFormDatas: {scorecardQuestionId: string; initialAnswer: string;}[],
@@ -113,7 +136,7 @@ export const calculateProgressAndScore = (
if (
question.type === 'YES_NO'
- && (initialAnswer === 'Yes' || initialAnswer === 1)
+ && isAffirmativeYesNoAnswer(initialAnswer)
) {
questionPoint = 100
} else if (
diff --git a/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.module.scss b/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.module.scss
index 6fc566bec..f048bcce2 100644
--- a/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.module.scss
+++ b/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.module.scss
@@ -57,6 +57,7 @@
.cellVirusScan {
width: 25%;
white-space: nowrap;
+ vertical-align: middle;
}
.submissionCell {
diff --git a/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx b/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx
index 25ef9bb15..4f0607625 100644
--- a/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx
+++ b/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx
@@ -1,4 +1,5 @@
-import { FC, Fragment, MouseEvent, useCallback, useMemo, useState } from 'react'
+/* eslint-disable complexity */
+import { FC, Fragment, MouseEvent, useCallback, useContext, useMemo, useState } from 'react'
import { toast } from 'react-toastify'
import classNames from 'classnames'
import moment from 'moment'
@@ -7,9 +8,10 @@ import { IsRemovingType } from '~/apps/admin/src/lib/models'
import { copyTextToClipboard } from '~/libs/shared'
import { BaseModal, IconOutline, Tooltip } from '~/libs/ui'
-import { SubmissionInfo } from '../../models'
+import { ChallengeDetailContextModel, SubmissionInfo } from '../../models'
import { TABLE_DATE_FORMAT } from '../../../config/index.config'
-import { AiReviewsTable } from '../AiReviewsTable'
+import { AiReviewsTable, AiWorkflowRunStatus } from '../AiReviewsTable'
+import { ChallengeDetailContext } from '../../contexts'
import styles from './SubmissionHistoryModal.module.scss'
@@ -92,6 +94,36 @@ function formatSubmissionDate(
return '—'
}
+function formatScore(value?: number | null): string {
+ if (typeof value !== 'number' || Number.isNaN(value)) {
+ return '-'
+ }
+
+ return value.toFixed(2)
+}
+
+function normalizeDecisionStatus(
+ status?: string | null,
+): 'passed' | 'failed-score' | 'pending' | 'failed' {
+ if (!status || status === 'PENDING') {
+ return 'pending'
+ }
+
+ if (status === 'PASSED') {
+ return 'passed'
+ }
+
+ if (status === 'FAILED') {
+ return 'failed-score'
+ }
+
+ if (status === 'ERROR') {
+ return 'failed'
+ }
+
+ return 'pending'
+}
+
export const SubmissionHistoryModal: FC = (props: SubmissionHistoryModalProps) => {
const sortedSubmissions = useMemo(
() => props.submissions
@@ -99,9 +131,14 @@ export const SubmissionHistoryModal: FC = (props: S
.sort((a, b) => getTimestamp(b) - getTimestamp(a)),
[props.submissions],
)
+ const challengeDetailContext: ChallengeDetailContextModel = useContext(ChallengeDetailContext)
+ const aiReviewConfig: ChallengeDetailContextModel['aiReviewConfig'] = challengeDetailContext.aiReviewConfig
const aiReviewers = useMemo(() => props.aiReviewers ?? [], [props.aiReviewers])
- const aiReviewersCount = useMemo(() => (aiReviewers.length ?? 0) + 1, [aiReviewers])
+ const aiReviewersCount = useMemo(
+ () => ((aiReviewers.length || aiReviewConfig?.workflows?.length || 0) + 1),
+ [aiReviewConfig?.workflows?.length, aiReviewers.length],
+ )
const [toggledRows, setToggledRows] = useState(new Set())
@@ -249,6 +286,12 @@ export const SubmissionHistoryModal: FC = (props: S
toggleRow(submission.id)
}
+ const aiReviewDecisionsBySubmissionId: ChallengeDetailContextModel['aiReviewDecisionsBySubmissionId']
+ = challengeDetailContext.aiReviewDecisionsBySubmissionId
+ const currentDecision = aiReviewDecisionsBySubmissionId[submission.id]
+ const hasDecisionScore = currentDecision?.totalScore !== null && currentDecision?.totalScore !== undefined
+ const normalizedStatus = normalizeDecisionStatus(currentDecision?.status ?? undefined)
+
return (
@@ -274,12 +317,24 @@ export const SubmissionHistoryModal: FC = (props: S
)}
+ {aiReviewersCount > 0 && (
+ <>
+ |
+ {hasDecisionScore ? formatScore(currentDecision!.totalScore) : '-'}
+ |
+
+ {currentDecision ? (
+
+ ) : '-'}
+ |
+ >
+ )}
{toggledRows.has(submission.id) && (
- |
+ |
|
@@ -300,7 +355,7 @@ export const SubmissionHistoryModal: FC = (props: S
open={props.open}
onClose={props.onClose}
title={modalTitle}
- size='lg'
+ size='body'
classNames={{ modal: styles.modal }}
>
{sortedSubmissions.length === 0 ? (
@@ -313,6 +368,12 @@ export const SubmissionHistoryModal: FC = (props: S
Submission ID |
Submitted |
Reviewer |
+ {aiReviewersCount > 0 && (
+ <>
+ Score |
+ Status |
+ >
+ )}
diff --git a/src/apps/review/src/lib/components/TableReview/TableReview.tsx b/src/apps/review/src/lib/components/TableReview/TableReview.tsx
index ba30d6547..69af31d37 100644
--- a/src/apps/review/src/lib/components/TableReview/TableReview.tsx
+++ b/src/apps/review/src/lib/components/TableReview/TableReview.tsx
@@ -852,6 +852,7 @@ export const TableReview: FC = (props: TableReviewProps) => {
isDownloading={isDownloading}
getRestriction={getHistoryRestriction}
getSubmissionMeta={resolveSubmissionMeta}
+ aiReviewers={props.aiReviewers}
/>
= (props:
isDownloading={isDownloading}
getRestriction={getHistoryRestriction}
getSubmissionMeta={resolveSubmissionMeta}
+ aiReviewers={props.aiReviewers}
/>
)
diff --git a/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx b/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx
index 027d204e6..495590897 100644
--- a/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx
+++ b/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx
@@ -1271,6 +1271,7 @@ export const TableSubmissionScreening: FC = (props: Props) => {
isDownloading={props.isDownloading}
getRestriction={getHistoryRestriction}
getSubmissionMeta={resolveSubmissionMeta}
+ aiReviewers={props.aiReviewers}
/>
= createContext({
+ aiReviewDecisionsBySubmissionId: {},
challengeId: undefined,
challengeInfo: undefined,
challengeSubmissions: [],
+ isLoadingAiReviewConfig: false,
+ isLoadingAiReviewDecisions: false,
isLoadingChallengeInfo: false,
isLoadingChallengeResources: false,
isLoadingChallengeSubmissions: false,
diff --git a/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx b/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx
index 98080c350..8a64fb033 100644
--- a/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx
+++ b/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx
@@ -11,6 +11,10 @@ import type {
SubmissionInfo,
} from '../models'
import {
+ useFetchAiReviewConfig,
+ UseFetchAiReviewConfigResult,
+ useFetchAiReviewDecisions,
+ UseFetchAiReviewDecisionsResult,
useFetchChallengeInfo,
useFetchChallengeInfoProps,
useFetchChallengeResources,
@@ -96,6 +100,27 @@ export const ChallengeDetailContextProvider: FC = props => {
[challengeSubmissions, registrants],
)
+ const {
+ aiReviewConfig,
+ isLoading: isLoadingAiReviewConfig,
+ }: UseFetchAiReviewConfigResult = useFetchAiReviewConfig(challengeId)
+
+ const {
+ decisions: aiReviewDecisions,
+ isLoading: isLoadingAiReviewDecisions,
+ }: UseFetchAiReviewDecisionsResult = useFetchAiReviewDecisions(aiReviewConfig?.id)
+
+ const aiReviewDecisionsBySubmissionId = useMemo(
+ () => aiReviewDecisions.reduce>((result, decision) => {
+ if (decision.submissionId) {
+ result[decision.submissionId] = decision
+ }
+
+ return result
+ }, {}),
+ [aiReviewDecisions],
+ )
+
const enrichedChallengeInfo = useMemo(
() => (challengeInfo
? {
@@ -113,9 +138,13 @@ export const ChallengeDetailContextProvider: FC = props => {
const value = useMemo(
() => ({
+ aiReviewConfig,
+ aiReviewDecisionsBySubmissionId,
challengeId,
challengeInfo: enrichedChallengeInfo,
challengeSubmissions,
+ isLoadingAiReviewConfig,
+ isLoadingAiReviewDecisions,
isLoadingChallengeInfo: isLoadingChallengeInfoCombined,
isLoadingChallengeResources,
isLoadingChallengeSubmissions,
@@ -133,6 +162,10 @@ export const ChallengeDetailContextProvider: FC = props => {
isLoadingChallengeInfoCombined,
isLoadingChallengeResources,
isLoadingChallengeSubmissions,
+ aiReviewConfig,
+ aiReviewDecisionsBySubmissionId,
+ isLoadingAiReviewConfig,
+ isLoadingAiReviewDecisions,
myResources,
myRoles,
registrants,
diff --git a/src/apps/review/src/lib/hooks/index.ts b/src/apps/review/src/lib/hooks/index.ts
index 2e069dd47..5ca1e9514 100644
--- a/src/apps/review/src/lib/hooks/index.ts
+++ b/src/apps/review/src/lib/hooks/index.ts
@@ -19,5 +19,6 @@ export * from './useSubmissionDownloadAccess'
export * from './useSubmissionHistory'
export * from './useScorecardPassingScores'
export * from './useFetchAiWorkflowRuns'
+export * from './useFetchAiReviewData'
export * from './useFetchSubmissionInfo'
export * from './useReviewEditAccess'
diff --git a/src/apps/review/src/lib/hooks/useFetchAiReviewData.ts b/src/apps/review/src/lib/hooks/useFetchAiReviewData.ts
new file mode 100644
index 000000000..9f21bed9f
--- /dev/null
+++ b/src/apps/review/src/lib/hooks/useFetchAiReviewData.ts
@@ -0,0 +1,104 @@
+import { useEffect } from 'react'
+import useSWR, { SWRResponse } from 'swr'
+
+import { handleError } from '~/libs/shared/lib/utils/handle-error'
+
+import { AiReviewConfig, AiReviewDecision } from '../models'
+import {
+ fetchAiReviewConfig,
+ fetchAiReviewDecisions,
+ getAiReviewConfigCacheKey,
+ getAiReviewDecisionsCacheKey,
+} from '../services'
+
+interface ErrorWithStatus {
+ status?: number
+ response?: {
+ status?: number
+ }
+}
+
+function isNotFoundError(error: unknown): boolean {
+ const knownError = error as ErrorWithStatus | undefined
+ return knownError?.status === 404 || knownError?.response?.status === 404
+}
+
+export interface UseFetchAiReviewConfigResult {
+ aiReviewConfig?: AiReviewConfig
+ isLoading: boolean
+}
+
+export function useFetchAiReviewConfig(challengeId?: string): UseFetchAiReviewConfigResult {
+ const {
+ data: aiReviewConfig,
+ error,
+ isValidating: isLoading,
+ }: SWRResponse = useSWR(
+ getAiReviewConfigCacheKey(challengeId),
+ {
+ fetcher: async (): Promise => {
+ if (!challengeId) {
+ return undefined
+ }
+
+ try {
+ return await fetchAiReviewConfig(challengeId)
+ } catch (fetchError) {
+ if (isNotFoundError(fetchError)) {
+ return undefined
+ }
+
+ throw fetchError
+ }
+ },
+ isPaused: () => !challengeId,
+ },
+ )
+
+ useEffect(() => {
+ if (error) {
+ handleError(error)
+ }
+ }, [error])
+
+ return {
+ aiReviewConfig,
+ isLoading,
+ }
+}
+
+export interface UseFetchAiReviewDecisionsResult {
+ decisions: AiReviewDecision[]
+ isLoading: boolean
+}
+
+export function useFetchAiReviewDecisions(configId?: string): UseFetchAiReviewDecisionsResult {
+ const {
+ data: decisions = [],
+ error,
+ isValidating: isLoading,
+ }: SWRResponse = useSWR(
+ getAiReviewDecisionsCacheKey(configId),
+ {
+ fetcher: async (): Promise => {
+ if (!configId) {
+ return []
+ }
+
+ return fetchAiReviewDecisions(configId)
+ },
+ isPaused: () => !configId,
+ },
+ )
+
+ useEffect(() => {
+ if (error) {
+ handleError(error)
+ }
+ }, [error])
+
+ return {
+ decisions,
+ isLoading,
+ }
+}
diff --git a/src/apps/review/src/lib/models/AiReview.model.ts b/src/apps/review/src/lib/models/AiReview.model.ts
new file mode 100644
index 000000000..4178d31c3
--- /dev/null
+++ b/src/apps/review/src/lib/models/AiReview.model.ts
@@ -0,0 +1,64 @@
+export type AiReviewDecisionStatus = 'PENDING' | 'PASSED' | 'FAILED' | 'ERROR' | 'HUMAN_OVERRIDE'
+
+export interface AiReviewConfigWorkflow {
+ id: string
+ workflowId: string
+ weightPercent: number
+ isGating: boolean
+ workflow?: {
+ id?: string
+ name?: string
+ description?: string
+ scorecard?: {
+ minimumPassingScore?: number
+ }
+ }
+}
+
+export interface AiReviewConfig {
+ id: string
+ challengeId: string
+ version: number
+ minPassingThreshold: number
+ mode: string
+ autoFinalize: boolean
+ formula?: Record
+ templateId?: string | null
+ createdAt: string
+ updatedAt: string
+ workflows: AiReviewConfigWorkflow[]
+}
+
+export interface AiReviewDecisionBreakdownWorkflow {
+ workflowId: string
+ weightPercent: number
+ isGating: boolean
+ minimumPassingScore: number
+ runId: string | null
+ runStatus: string | null
+ runScore: number | null
+}
+
+export interface AiReviewDecisionBreakdown {
+ evaluatedAt?: string
+ mode?: string
+ weightedTotal?: number
+ minPassingThreshold?: number
+ hasBlockingGatingFailure?: boolean
+ workflows?: AiReviewDecisionBreakdownWorkflow[]
+}
+
+export interface AiReviewDecision {
+ id: string
+ submissionId: string
+ configId: string
+ status: AiReviewDecisionStatus
+ totalScore: number | null
+ submissionLocked: boolean
+ reason: string | null
+ breakdown: AiReviewDecisionBreakdown | null
+ isFinal: boolean
+ finalizedAt: string | null
+ createdAt: string
+ updatedAt: string
+}
diff --git a/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts b/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts
index 2a38f0758..5f5b3491d 100644
--- a/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts
+++ b/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts
@@ -1,6 +1,7 @@
import { BackendResource } from './BackendResource.model'
import { BackendSubmission } from './BackendSubmission.model'
import { ChallengeInfo } from './ChallengeInfo.model'
+import { AiReviewConfig, AiReviewDecision } from './AiReview.model'
/**
* Model for challenge detail context
@@ -17,6 +18,10 @@ export interface ChallengeDetailContextModel {
resources: BackendResource[]
registrants: BackendResource[]
reviewers: BackendResource[]
+ aiReviewConfig?: AiReviewConfig
+ aiReviewDecisionsBySubmissionId: Record
+ isLoadingAiReviewConfig: boolean
+ isLoadingAiReviewDecisions: boolean
resourceMemberIdMapping: {
[memberId: string]: BackendResource
}
diff --git a/src/apps/review/src/lib/models/index.ts b/src/apps/review/src/lib/models/index.ts
index 38e77c79a..ea54c5535 100644
--- a/src/apps/review/src/lib/models/index.ts
+++ b/src/apps/review/src/lib/models/index.ts
@@ -1,5 +1,6 @@
export * from './AiScorecardContext.model'
export * from './AiFeedbackItem.model'
+export * from './AiReview.model'
export * from './ChallengeInfo.model'
export * from './SubmissionInfo.model'
export * from './ReviewInfo.model'
diff --git a/src/apps/review/src/lib/services/aiReview.service.ts b/src/apps/review/src/lib/services/aiReview.service.ts
new file mode 100644
index 000000000..efb41ac51
--- /dev/null
+++ b/src/apps/review/src/lib/services/aiReview.service.ts
@@ -0,0 +1,22 @@
+import { EnvironmentConfig } from '~/config'
+import { xhrGetAsync } from '~/libs/core'
+
+import { AiReviewConfig, AiReviewDecision } from '../models'
+
+const v6BaseUrl = `${EnvironmentConfig.API.V6}`
+
+export const getAiReviewConfigCacheKey = (challengeId?: string): string => (
+ `${v6BaseUrl}/ai-review/configs/${challengeId ?? ''}`
+)
+
+export const fetchAiReviewConfig = async (challengeId: string): Promise => (
+ xhrGetAsync(getAiReviewConfigCacheKey(challengeId))
+)
+
+export const getAiReviewDecisionsCacheKey = (configId?: string): string => (
+ `${v6BaseUrl}/ai-review/decisions?configId=${configId ?? ''}`
+)
+
+export const fetchAiReviewDecisions = async (configId: string): Promise => (
+ xhrGetAsync(getAiReviewDecisionsCacheKey(configId))
+)
diff --git a/src/apps/review/src/lib/services/index.ts b/src/apps/review/src/lib/services/index.ts
index 097c180b6..df7c4398a 100644
--- a/src/apps/review/src/lib/services/index.ts
+++ b/src/apps/review/src/lib/services/index.ts
@@ -5,3 +5,4 @@ export * from './file-upload.service'
export * from './resources.service'
export * from './payments.service'
export * from './challenge-phases.service'
+export * from './aiReview.service'
diff --git a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx
index fc573e2ee..92aa9edfd 100644
--- a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx
+++ b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx
@@ -43,7 +43,39 @@ const ReviewViewer: FC = () => {
const [showCloseConfirmation, setShowCloseConfirmation] = useState(false)
const [isChanged, setIsChanged] = useState(false)
const respondToAppeals = searchParams.get('respondToAppeals') === 'true'
- const [isManagerEdit, setIsManagerEdit] = useState(respondToAppeals)
+ const hasChallengeAdminRole = useMemo(
+ () => myChallengeResources.some(
+ resource => resource.roleName?.toLowerCase() === ADMIN.toLowerCase(),
+ ),
+ [myChallengeResources],
+ )
+
+ const hasTopcoderAdminRole = useMemo(
+ () => myChallengeRoles.some(
+ role => role?.toLowerCase()
+ .includes('admin'),
+ ),
+ [myChallengeRoles],
+ )
+
+ const hasChallengeManagerRole = useMemo(
+ () => myChallengeResources.some(
+ resource => resource.roleName?.toLowerCase() === MANAGER.toLowerCase(),
+ ),
+ [myChallengeResources],
+ )
+
+ const canManagerEdit = useMemo(
+ () => hasChallengeAdminRole
+ || hasTopcoderAdminRole
+ || hasChallengeManagerRole,
+ [
+ hasChallengeAdminRole,
+ hasTopcoderAdminRole,
+ hasChallengeManagerRole,
+ ],
+ )
+ const [isManagerEdit, setIsManagerEdit] = useState(respondToAppeals && canManagerEdit)
const {
challengeInfo,
@@ -148,28 +180,6 @@ const ReviewViewer: FC = () => {
})
}, [challengeInfo?.id, mutate, navigate])
- const hasChallengeAdminRole = useMemo(
- () => myChallengeResources.some(
- resource => resource.roleName?.toLowerCase() === ADMIN.toLowerCase(),
- ),
- [myChallengeResources],
- )
-
- const hasTopcoderAdminRole = useMemo(
- () => myChallengeRoles.some(
- role => role?.toLowerCase()
- .includes('admin'),
- ),
- [myChallengeRoles],
- )
-
- const hasChallengeManagerRole = useMemo(
- () => myChallengeResources.some(
- resource => resource.roleName?.toLowerCase() === MANAGER.toLowerCase(),
- ),
- [myChallengeResources],
- )
-
const hasChallengeCopilotRole = useMemo(
() => myChallengeResources.some(
resource => resource.roleName?.toLowerCase() === COPILOT.toLowerCase(),
@@ -276,7 +286,6 @@ const ReviewViewer: FC = () => {
hasChallengeAdminRole
|| hasTopcoderAdminRole
|| hasChallengeManagerRole
- || hasChallengeCopilotRole
}
saveReviewInfo={saveReviewInfo}
addAppeal={addAppeal}
diff --git a/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.module.scss b/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.module.scss
index 54197a0e6..5185c462a 100644
--- a/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.module.scss
+++ b/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.module.scss
@@ -203,3 +203,24 @@
display: none;
}
}
+
+.scoreInfoRow {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: $sp-2;
+
+ &:first-of-type {
+ margin-top: $sp-3;
+ }
+}
+
+.infoIcon {
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+}
+
+.gatingIcon {
+ color: $red-160;
+}
diff --git a/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx b/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx
index 9f095919b..c3ce57b3f 100644
--- a/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx
+++ b/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable complexity */
import { FC, useCallback, useState } from 'react'
import { Link } from 'react-router-dom'
import classNames from 'classnames'
@@ -6,6 +7,11 @@ import { ReviewsContextModel } from '~/apps/review/src/lib/models'
import { AiWorkflowRunStatus } from '~/apps/review/src/lib/components/AiReviewsTable'
import { IconAiReview, IconPhaseReview } from '~/apps/review/src/lib/assets/icons'
import { IconOutline, IconSolid, Tooltip } from '~/libs/ui'
+import { AiScoreFormulaTooltip } from '~/apps/review/src/lib/components/AiScoreFormulaTooltip'
+import { formatScore } from '~/apps/review/src/lib/components/AiScoreFormulaTooltip/AiScoreFormulaTooltip'
+import {
+ normalizeDecisionStatus,
+} from '~/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow'
import StatusLabel from '~/apps/review/src/lib/components/AiReviewsTable/StatusLabel'
import { useReviewsContext } from '../../ReviewsContext'
@@ -26,6 +32,8 @@ const ReviewsSidebar: FC = props => {
submissionId,
reviewId,
reviewStatus,
+ aiReviewConfig,
+ aiReviewDecisionsBySubmissionId,
}: ReviewsContextModel = useReviewsContext()
const isReviewActive = !workflowRun
@@ -41,6 +49,19 @@ const ReviewsSidebar: FC = props => {
`../reviews/${submissionId}?workflowId=${runWorkflowId}&reviewId=${reviewId}`
), [reviewId, submissionId])
+ const hasAiReviewConfig = Boolean(aiReviewConfig?.workflows?.length)
+
+ const currentDecision = submissionId
+ ? aiReviewDecisionsBySubmissionId?.[submissionId]
+ : undefined
+
+ const hasScore
+ = currentDecision?.totalScore !== null
+ && currentDecision?.totalScore !== undefined
+
+ const overallStatus = normalizeDecisionStatus(currentDecision?.status)
+ const overallScore = currentDecision?.totalScore
+
return (
{((workflow && workflowRun) || reviewId) && (
@@ -65,7 +86,6 @@ const ReviewsSidebar: FC = props => {
run={workflowRun}
showScore
hideLabel
- submissionId={submissionId}
/>
)}
@@ -81,38 +101,77 @@ const ReviewsSidebar: FC = props => {
- {workflowRuns.map(run => (
+ {workflowRuns.map(run => {
+ const isGating = aiReviewConfig?.workflows?.find(
+ w => w.workflowId === run.workflow.id,
+ )?.isGating
+
+ return (
+
+ -
+
+
+
+
+ {run.workflow.name}
+ {isGating && (
+
+ )}
+
+
+
+
+ )
+ })}
+ {hasAiReviewConfig && (
-
-
-
+
+ Overall Score
+
+
+ {hasScore ? (
+
-
-
- {run.workflow.name}
-
-
-
+ ) : (
+
+ )}
- ))}
+ )}
{submissionId && reviewId && (
- = props => {
)}
)}
+
+ {hasAiReviewConfig
+ && (
+
+
+ Score Info
+
+
+ Min Passing Score
+ {formatScore(aiReviewConfig?.minPassingThreshold)}
+
+
+
+ AI Score Formula
+ }
+ triggerOn='hover'
+ >
+
+
+
+
+
+
+ )}
@@ -168,6 +252,18 @@ const ReviewsSidebar: FC = props => {
status='pending'
/>
+
+
+ )}
+ label='Gating Indicator'
+ status='pending'
+ isAiIcon
+ />
+
diff --git a/src/config/constants.ts b/src/config/constants.ts
index 5d38262a2..19fde95d5 100644
--- a/src/config/constants.ts
+++ b/src/config/constants.ts
@@ -14,7 +14,8 @@ export enum AppSubdomain {
review = 'review',
calendar = 'calendar',
engagements = 'engagements',
- customer = 'customer'
+ customer = 'customer',
+ reports = 'reports'
}
export enum ToolTitle {
@@ -33,7 +34,8 @@ export enum ToolTitle {
review = 'Review',
calendar = 'Calendar',
engagements = 'Engagements',
- customer = 'Customer'
+ customer = 'Customer',
+ reports = 'Reports'
}
export const PageSubheaderPortalId: string = 'page-subheader-portal-el'
diff --git a/src/libs/core/lib/profile/profile-functions/profile-factory/user-role.enum.ts b/src/libs/core/lib/profile/profile-functions/profile-factory/user-role.enum.ts
index 228aa2212..a7d291906 100644
--- a/src/libs/core/lib/profile/profile-functions/profile-factory/user-role.enum.ts
+++ b/src/libs/core/lib/profile/profile-functions/profile-factory/user-role.enum.ts
@@ -9,6 +9,7 @@ export enum UserRole {
paymentViewer = 'Payment Viewer',
paymentProviderAdmin = 'PaymentProvider Admin',
paymentProviderViewer = 'PaymentProvider Viewer',
+ productManager = 'Product Manager',
projectManager = 'Project Manager',
taxFormAdmin = 'TaxForm Admin',
taxFormViewer = 'TaxForm Viewer',
diff --git a/src/libs/shared/lib/constants/index.ts b/src/libs/shared/lib/constants/index.ts
index 270aaf371..79e3ed96e 100644
--- a/src/libs/shared/lib/constants/index.ts
+++ b/src/libs/shared/lib/constants/index.ts
@@ -1,3 +1,5 @@
+import { InputMultiselectOption } from '~/libs/ui'
+
export const INDUSTRIES_OPTIONS: string[] = [
'Banking',
'Consumer goods',
@@ -11,3 +13,18 @@ export const INDUSTRIES_OPTIONS: string[] = [
'Travel & hospitality',
'Others',
]
+
+export const preferredRoleOptions: InputMultiselectOption[] = [
+ { label: 'AI / ML Engineer', value: 'AI_ML_ENGINEER' },
+ { label: 'Data Scientist / Data Engineer', value: 'DATA_SCIENTIST_ENGINEER' },
+ { label: 'Cybersecurity Analyst / Security Engineer', value: 'CYBERSECURITY_ENGINEER' },
+ { label: 'Cloud Engineer / Solutions Architect', value: 'CLOUD_ENGINEER' },
+ { label: 'DevOps Engineer / SRE', value: 'DEVOPS_SRE' },
+ { label: 'Full-Stack Developer', value: 'FULL_STACK_DEVELOPER' },
+ { label: 'QA Lead / Automation Engineer', value: 'QA_AUTOMATION_ENGINEER' },
+ { label: 'UX Designer', value: 'UX_DESIGNER' },
+ { label: 'Technical Project Manager', value: 'TECHNICAL_PM' },
+ { label: 'Database Administrator', value: 'DB_ADMIN' },
+ { label: 'AI Prompt Engineer', value: 'AI_PROMPT_ENGINEER' },
+ { label: 'Enterprise Architect', value: 'ENTERPRISE_ARCHITECT' },
+]
diff --git a/src/libs/shared/lib/utils/roles.ts b/src/libs/shared/lib/utils/roles.ts
new file mode 100644
index 000000000..2137faa46
--- /dev/null
+++ b/src/libs/shared/lib/utils/roles.ts
@@ -0,0 +1,6 @@
+import { preferredRoleOptions } from '../constants'
+
+export function getPreferredRoleLabelByValue(value: string): string {
+ return preferredRoleOptions
+ .find(each => each.value === value)?.label as string
+}
diff --git a/src/libs/ui/lib/components/table/table-functions/table.functions.ts b/src/libs/ui/lib/components/table/table-functions/table.functions.ts
index 736b58eff..2bb6f161c 100644
--- a/src/libs/ui/lib/components/table/table-functions/table.functions.ts
+++ b/src/libs/ui/lib/components/table/table-functions/table.functions.ts
@@ -63,15 +63,17 @@ export function getSorted(
return sortedData
.sort((a: T, b: T) => {
- const aField: string = a[sort.fieldName]
- const bField: string = b[sort.fieldName]
-
- // Handle undefined/null values safely
- if (aField === undefined && bField === undefined) return 0
- if (aField === undefined) return 1
- if (bField === undefined) return -1
+ const aField: unknown = a[sort.fieldName]
+ const bField: unknown = b[sort.fieldName]
+
+ // Keep nullish values at the bottom for both sort directions.
+ const aValue = String(aField ?? '')
+ const bValue = String(bField ?? '')
+ if (aValue === '' && bValue === '') return 0
+ if (aValue === '') return 1
+ if (bValue === '') return -1
return sort.direction === 'asc'
- ? aField.localeCompare(bField)
- : bField.localeCompare(aField)
+ ? aValue.localeCompare(bValue)
+ : bValue.localeCompare(aValue)
})
}
diff --git a/src/libs/ui/lib/styles/variables/_palette.scss b/src/libs/ui/lib/styles/variables/_palette.scss
index 887a38277..bd8e7a789 100644
--- a/src/libs/ui/lib/styles/variables/_palette.scss
+++ b/src/libs/ui/lib/styles/variables/_palette.scss
@@ -51,9 +51,11 @@ $red-100: #EF3A3A;
$red-75: #F37593;
$red-50: #F7A3B7;
$red-25: #FBD1DB;
+
// dark
$red-120: #BE405E;
$red-140: #8C384C;
+$red-160: #C1294F;
/* ORANGE */
|