diff --git a/.circleci/config.yml b/.circleci/config.yml index f77046979..2a4a03350 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -229,6 +229,7 @@ workflows: - mm-final-2025-reveal - engagements - HOTFIX-PM-3269 + - reports - deployQa: context: org-global diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml index 7b9fa4839..9cbcf5209 100644 --- a/.github/workflows/trivy.yaml +++ b/.github/workflows/trivy.yaml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@v4 - name: Run Trivy scanner in repo mode - uses: aquasecurity/trivy-action@0.33.1 + uses: aquasecurity/trivy-action@0.35.0 with: scan-type: "fs" ignore-unfixed: true diff --git a/src/apps/admin/src/AdminHomeRedirect.tsx b/src/apps/admin/src/AdminHomeRedirect.tsx new file mode 100644 index 000000000..a96869288 --- /dev/null +++ b/src/apps/admin/src/AdminHomeRedirect.tsx @@ -0,0 +1,22 @@ +import { FC } from 'react' +import { Navigate } from 'react-router-dom' + +import { reportsRootRoute } from '~/apps/reports' +import { ProfileContextData, useProfileContext } from '~/libs/core' + +import { manageChallengeRouteId } from './config/routes.config' +import { isAdministrator } from './lib/utils' + +/** + * Redirects authenticated admin-app users to the first route they can access. + */ +const AdminHomeRedirect: FC = () => { + const { profile }: ProfileContextData = useProfileContext() + const defaultRoute: string = isAdministrator(profile?.roles) + ? manageChallengeRouteId + : reportsRootRoute + + return +} + +export default AdminHomeRedirect diff --git a/src/apps/admin/src/admin-app.routes.tsx b/src/apps/admin/src/admin-app.routes.tsx index 606e40794..de637b87f 100644 --- a/src/apps/admin/src/admin-app.routes.tsx +++ b/src/apps/admin/src/admin-app.routes.tsx @@ -4,8 +4,6 @@ import { lazyLoad, LazyLoadedComponent, PlatformRoute, - Rewrite, - UserRole, } from '~/libs/core' import { @@ -17,12 +15,13 @@ import { paymentsRouteId, permissionManagementRouteId, platformRouteId, - reportsRouteId, rootRoute, termsRouteId, userManagementRouteId, } from './config/routes.config' +import { administratorOnlyRoles, adminReportsAccessRoles } from './lib/utils' import { platformSkillRouteId } from './platform/routes.config' +import AdminHomeRedirect from './AdminHomeRedirect' const AdminApp: LazyLoadedComponent = lazyLoad(() => import('./AdminApp')) @@ -173,10 +172,6 @@ const PaymentsPage: LazyLoadedComponent = lazyLoad( () => import('./payments/PaymentsPage'), 'PaymentsPage', ) -const ReportsPage: LazyLoadedComponent = lazyLoad( - () => import('./reports/ReportsPage'), - 'ReportsPage', -) export const toolTitle: string = ToolTitle.admin @@ -186,7 +181,7 @@ export const adminRoutes: ReadonlyArray = [ authRequired: true, children: [ { - element: , + element: , route: '', }, // Challenge Management Module @@ -220,12 +215,14 @@ export const adminRoutes: ReadonlyArray = [ ], element: , id: manageChallengeRouteId, + rolesRequired: administratorOnlyRoles, route: manageChallengeRouteId, }, // User Management Module { element: , id: userManagementRouteId, + rolesRequired: administratorOnlyRoles, route: userManagementRouteId, }, // Reviewer Management Module @@ -244,6 +241,7 @@ export const adminRoutes: ReadonlyArray = [ ], element: , id: manageReviewRouteId, + rolesRequired: administratorOnlyRoles, route: manageReviewRouteId, }, // Billing Account Module @@ -297,6 +295,7 @@ export const adminRoutes: ReadonlyArray = [ ], element: , id: billingAccountRouteId, + rolesRequired: administratorOnlyRoles, route: billingAccountRouteId, }, // Permission Management Module @@ -335,6 +334,7 @@ export const adminRoutes: ReadonlyArray = [ ], element: , id: permissionManagementRouteId, + rolesRequired: administratorOnlyRoles, route: permissionManagementRouteId, }, @@ -408,25 +408,21 @@ export const adminRoutes: ReadonlyArray = [ ], element: , id: platformRouteId, + rolesRequired: administratorOnlyRoles, route: platformRouteId, }, // Payments Module { element: , id: paymentsRouteId, + rolesRequired: administratorOnlyRoles, route: paymentsRouteId, }, - // Reports Module - { - element: , - id: reportsRouteId, - route: reportsRouteId, - }, ], domain: AppSubdomain.admin, element: , id: toolTitle, - rolesRequired: [UserRole.administrator], + rolesRequired: adminReportsAccessRoles, route: rootRoute, title: toolTitle, }, diff --git a/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.module.scss b/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.module.scss index a94e0ce92..ce85b57b4 100644 --- a/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.module.scss +++ b/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.module.scss @@ -19,6 +19,21 @@ gap: $sp-3; } +.exportDescription { + margin: 0; + color: #555; +} + +.exportActions { + display: flex; + flex-wrap: wrap; + gap: $sp-3; +} + +.exportButton { + min-width: 220px; +} + .sectionTitle { margin: 0; font-size: 20px; diff --git a/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.tsx b/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.tsx index e5f5c2c17..c6243edfe 100644 --- a/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.tsx +++ b/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.tsx @@ -1,6 +1,7 @@ import { ChangeEvent, FC, + MouseEvent, useEffect, useMemo, useState, @@ -32,6 +33,10 @@ import { getResourceRoles, updateChallengeById, } from '../../lib/services' +import { + downloadBlobFile, + downloadReportAsCsv, +} from '../../lib/services/reports.service' import { createChallengeQueryString, handleError } from '../../lib/utils' import styles from './ChallengeDetailsPage.module.scss' @@ -62,6 +67,22 @@ type RouteState = { type WinnerUpdate = Pick +type ChallengeExportReportKey = + | 'registered-users' + | 'submitters' + | 'valid-submitters' + | 'winners' + +const CHALLENGE_EXPORT_REPORTS: Array<{ + key: ChallengeExportReportKey + label: string +}> = [ + { key: 'registered-users', label: 'Registered Users' }, + { key: 'submitters', label: 'Submitters' }, + { key: 'valid-submitters', label: 'Valid Submitters' }, + { key: 'winners', label: 'Winners' }, +] + function formatStatusLabel(rawStatus: string): string { const normalized = rawStatus .trim() @@ -188,6 +209,8 @@ export const ChallengeDetailsPage: FC = () => { const [isLoading, setIsLoading] = useState(false) const [isSavingStatus, setIsSavingStatus] = useState(false) const [isSavingWinners, setIsSavingWinners] = useState(false) + const [downloadingReportKey, setDownloadingReportKey] + = useState() const [isLoadingSubmitters, setIsLoadingSubmitters] = useState(false) const [submitterOptions, setSubmitterOptions] = useState([ { label: 'Select submitter', value: '' }, @@ -328,6 +351,7 @@ export const ChallengeDetailsPage: FC = () => { }, [routeState.previousChallengeListFilter]) const pageTitle = challengeInfo?.name || 'Challenge Details' + const isMarathonMatch = challengeInfo?.type?.name === 'Marathon Match' const currentWinnerHandleByUserId = useMemo( () => Object.fromEntries( (challengeInfo?.winners ?? []).map(winner => [`${winner.userId}`, winner.handle]), @@ -414,6 +438,37 @@ export const ChallengeDetailsPage: FC = () => { } }) + const handleExportReport = useEventCallback(async (reportKey: ChallengeExportReportKey) => { + if (!challengeId) { + return + } + + setDownloadingReportKey(reportKey) + + try { + const path = `/challenges/${encodeURIComponent(challengeId)}/${reportKey}` + const blob = await downloadReportAsCsv(path) + const fileName = `challenge-${reportKey}_${challengeId}.csv` + + downloadBlobFile(blob, fileName) + } catch (error) { + handleError(error) + } finally { + setDownloadingReportKey(undefined) + } + }) + + const handleExportButtonClick = useEventCallback( + (event: MouseEvent) => { + const reportKey = event.currentTarget.value as ChallengeExportReportKey + if (!reportKey) { + return + } + + handleExportReport(reportKey) + }, + ) + return ( { )} {!isLoading && challengeInfo && ( <> +
+

Exports

+

+ Download challenge detail reports as CSV. + {isMarathonMatch && ( + ' Marathon Match submission-based exports include provisional ' + + 'score and final rank.' + )} +

+
+ {CHALLENGE_EXPORT_REPORTS.map(report => ( + + ))} +
+
+

Status

diff --git a/src/apps/admin/src/config/routes.config.ts b/src/apps/admin/src/config/routes.config.ts index b1b523086..a2ebf2790 100644 --- a/src/apps/admin/src/config/routes.config.ts +++ b/src/apps/admin/src/config/routes.config.ts @@ -18,4 +18,3 @@ export const termsRouteId = 'terms' export const defaultReviewersRouteId = 'default-reviewers' export const platformRouteId = 'platform' export const paymentsRouteId = 'payments' -export const reportsRouteId = 'reports' diff --git a/src/apps/admin/src/lib/components/common/Tab/SystemAdminTabs.tsx b/src/apps/admin/src/lib/components/common/Tab/SystemAdminTabs.tsx index d6a4a0553..f70b82493 100644 --- a/src/apps/admin/src/lib/components/common/Tab/SystemAdminTabs.tsx +++ b/src/apps/admin/src/lib/components/common/Tab/SystemAdminTabs.tsx @@ -1,16 +1,22 @@ import { Dispatch, FC, SetStateAction, useEffect, useMemo, useState } from 'react' import { NavigateFunction, useLocation, useNavigate } from 'react-router-dom' +import { ProfileContextData, useProfileContext } from '~/libs/core' import { TabsNavbar } from '~/libs/ui' -import { getTabIdFromPathName, SystemAdminTabsConfig } from './config' +import { getSystemAdminTabs, getTabIdFromPathName } from './config' import styles from './SystemAdminTabs.module.scss' const SystemAdminTabs: FC = () => { const navigate: NavigateFunction = useNavigate() + const { profile }: ProfileContextData = useProfileContext() const { pathname }: { pathname: string } = useLocation() - const activeTabPathName: string = useMemo(() => getTabIdFromPathName(pathname), [pathname]) + const tabs = useMemo(() => getSystemAdminTabs(profile?.roles), [profile?.roles]) + const activeTabPathName: string = useMemo( + () => getTabIdFromPathName(pathname, tabs), + [pathname, tabs], + ) const [activeTab, setActiveTab]: [string, Dispatch>] = useState(activeTabPathName) @@ -26,17 +32,21 @@ const SystemAdminTabs: FC = () => { // If url is changed by navigator on different tabs, we need set activeTab useEffect(() => { - const pathTabId = getTabIdFromPathName(pathname) + const pathTabId = getTabIdFromPathName(pathname, tabs) if (pathTabId !== activeTab) { setActiveTab(pathTabId) } - }, [pathname]) // eslint-disable-line react-hooks/exhaustive-deps + }, [activeTab, pathname, tabs]) + + if (!tabs.length) { + return <> + } return (
diff --git a/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts b/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts index 55c448402..1154c6cbf 100644 --- a/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts +++ b/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts @@ -1,6 +1,7 @@ import _ from 'lodash' import { TabsNavItem } from '~/libs/ui' +import { isAdministrator } from '~/apps/admin/src/lib/utils' import { billingAccountRouteId, defaultReviewersRouteId, @@ -10,7 +11,6 @@ import { paymentsRouteId, permissionManagementRouteId, platformRouteId, - reportsRouteId, termsRouteId, userManagementRouteId, } from '~/apps/admin/src/config/routes.config' @@ -83,22 +83,32 @@ export const SystemAdminTabsConfig: TabsNavItem[] = [ id: paymentsRouteId, title: 'Payments', }, - { - id: reportsRouteId, - title: 'Reports', - }, ] -export function getTabIdFromPathName(pathname: string): string { - const matchItem = _.find(SystemAdminTabsConfig, item => pathname.includes(`/${item.id}`)) +/** + * Returns the visible system-admin tabs for the current user. + */ +export function getSystemAdminTabs(roles?: string[]): TabsNavItem[] { + if (isAdministrator(roles)) { + return SystemAdminTabsConfig + } + + return [] +} + +/** + * Resolves the active tab id for the current location and visible tab set. + */ +export function getTabIdFromPathName(pathname: string, tabs: TabsNavItem[] = SystemAdminTabsConfig): string { + const matchItem = _.find(tabs, item => pathname.includes(`/${item.id}`)) if (matchItem) { return matchItem.id } - if (pathname.includes(`/${manageReviewRouteId}`)) { + if (tabs.some(item => item.id === manageReviewRouteId) && pathname.includes(`/${manageReviewRouteId}`)) { return manageReviewRouteId } - return manageChallengeRouteId + return (tabs[0]?.id as string) || manageChallengeRouteId } diff --git a/src/apps/admin/src/lib/services/groups.service.ts b/src/apps/admin/src/lib/services/groups.service.ts index 9d67160d1..0bb2ac5f4 100644 --- a/src/apps/admin/src/lib/services/groups.service.ts +++ b/src/apps/admin/src/lib/services/groups.service.ts @@ -1,11 +1,17 @@ /** * Groups service */ +import type { AxiosInstance } from 'axios' import _ from 'lodash' import qs from 'qs' import { EnvironmentConfig } from '~/config' -import { xhrDeleteAsync, xhrGetAsync, xhrPostAsync } from '~/libs/core' +import { + xhrCreateInstance, + xhrDeleteAsync, + xhrGetAsync, + xhrPostAsync, +} from '~/libs/core/lib/xhr' import { adjustUserGroupMemberResponse, @@ -15,6 +21,8 @@ import { UserGroupMember, } from '../models' +const reportsDownloadClient: AxiosInstance = xhrCreateInstance() + /** * Get a groups of the particular member * @param params query params. @@ -146,3 +154,24 @@ export const removeGroupMember = async ( ) return result } + +/** + * Exports users assigned to a group in CSV format. + * @param groupId group id. + * @returns resolves to CSV blob. + */ +export const exportGroupUsersCsv = async ( + groupId: string, +): Promise => { + const response = await reportsDownloadClient.get( + `${EnvironmentConfig.REPORTS_API}/identity/users-by-group?groupId=${encodeURIComponent(groupId)}`, + { + headers: { + Accept: 'text/csv', + }, + responseType: 'blob', + }, + ) + + return response.data +} diff --git a/src/apps/admin/src/lib/services/index.ts b/src/apps/admin/src/lib/services/index.ts index bf260a1b9..4441676f9 100644 --- a/src/apps/admin/src/lib/services/index.ts +++ b/src/apps/admin/src/lib/services/index.ts @@ -14,4 +14,9 @@ export * from './default-reviewers.service' export * from './timeline-templates.service' export * from './phases.service' export * from './scorecards.service' -export * from './reports.service' +export { + downloadBlobFile, + downloadReportAsCsv, + downloadReportAsJson, + fetchReportsIndex, +} from './reports.service' diff --git a/src/apps/admin/src/lib/services/reports.service.ts b/src/apps/admin/src/lib/services/reports.service.ts index f2802ff85..5d19536ce 100644 --- a/src/apps/admin/src/lib/services/reports.service.ts +++ b/src/apps/admin/src/lib/services/reports.service.ts @@ -62,3 +62,22 @@ export const downloadReportAsJson = (path: string): Promise => ( 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 admin 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/admin/src/lib/services/roles.service.ts b/src/apps/admin/src/lib/services/roles.service.ts index 83f37775a..f06f91b19 100644 --- a/src/apps/admin/src/lib/services/roles.service.ts +++ b/src/apps/admin/src/lib/services/roles.service.ts @@ -1,21 +1,25 @@ /** * Roles service */ +import type { AxiosInstance } from 'axios' import _ from 'lodash' import { EnvironmentConfig } from '~/config' import { + xhrCreateInstance, xhrDeleteAsync, xhrGetAsync, + xhrGetPaginatedAsync, xhrPatchAsync, xhrPostAsync, -} from '~/libs/core' +} from '~/libs/core/lib/xhr' import { adjustUserRoleResponse, UserRole } from '../models' import { PaginatedResponseV6 } from '../models/PaginatedResponseV6.model' import { RoleMemberInfo } from '../models/RoleMemberInfo.model' type RoleMemberRaw = { userId?: number; handle?: string | null; email?: string | null } +const reportsDownloadClient: AxiosInstance = xhrCreateInstance() /** * Fetchs roles of the specified subject @@ -191,43 +195,40 @@ export const fetchRoleMembersPaginated = async ( .map(([k, v]) => `${k}=${encodeURIComponent(String(v as string | number))}`) const url = params.length ? `${baseUrl}?${params.join('&')}` : baseUrl - const raw = await xhrGetAsync(url) - - // Support both array (non-paginated) and object (paginated) responses - if (Array.isArray(raw)) { - const mappedArr: RoleMemberInfo[] = (raw || []).map( - (m: RoleMemberRaw) => ({ - email: m?.email ?? undefined, - handle: m?.handle ?? undefined, - id: String(m?.userId ?? ''), - }), - ) - return { - data: mappedArr, - page: 1, - perPage: mappedArr.length, - total: mappedArr.length, - totalPages: 1, - } - } - - const dataArray = (raw?.data ?? []) as Array - const mapped: RoleMemberInfo[] = dataArray.map(m => ({ - email: m.email ?? undefined, - handle: m.handle ?? undefined, - id: String(m.userId ?? ''), + const result = await xhrGetPaginatedAsync(url) + const dataArray = Array.isArray(result.data) ? result.data : [] + const mapped: RoleMemberInfo[] = dataArray.map((m: RoleMemberRaw) => ({ + email: m?.email ?? undefined, + handle: m?.handle ?? undefined, + id: String(m?.userId ?? ''), })) - const safeTotal = Number(raw?.total ?? mapped.length) - const safePerPage = Number(raw?.perPage ?? mapped.length) - const computedTotalPages = raw?.totalPages - || (safePerPage ? Math.ceil(safeTotal / safePerPage) : 1) - return { data: mapped, - page: raw?.page || 1, - perPage: safePerPage, - total: safeTotal, - totalPages: computedTotalPages, + page: result.page || 1, + perPage: result.perPage || mapped.length, + total: result.total || mapped.length, + totalPages: result.totalPages || 1, } } + +/** + * Exports users assigned to a role in CSV format. + * @param roleId role id. + * @returns resolves to CSV blob. + */ +export const exportRoleUsersCsv = async ( + roleId: string, +): Promise => { + const response = await reportsDownloadClient.get( + `${EnvironmentConfig.REPORTS_API}/identity/users-by-role?roleId=${encodeURIComponent(roleId)}`, + { + headers: { + Accept: 'text/csv', + }, + responseType: 'blob', + }, + ) + + return response.data +} diff --git a/src/apps/admin/src/lib/utils/access.ts b/src/apps/admin/src/lib/utils/access.ts new file mode 100644 index 000000000..76d8f96c7 --- /dev/null +++ b/src/apps/admin/src/lib/utils/access.ts @@ -0,0 +1,25 @@ +import { UserRole } from '~/libs/core' + +export const administratorOnlyRoles: UserRole[] = [ + UserRole.administrator, +] + +export const adminReportsAccessRoles: UserRole[] = [ + UserRole.administrator, + UserRole.productManager, + UserRole.talentManager, +] + +/** + * Returns true when the current user is an administrator. + */ +export function isAdministrator(roles?: string[]): boolean { + return !!roles?.includes(UserRole.administrator) +} + +/** + * Returns true when the current user should be able to enter the admin reports module. + */ +export function canAccessAdminReports(roles?: string[]): boolean { + return !!roles?.some(role => adminReportsAccessRoles.includes(role as UserRole)) +} diff --git a/src/apps/admin/src/lib/utils/index.ts b/src/apps/admin/src/lib/utils/index.ts index ab6460257..5e827a00b 100644 --- a/src/apps/admin/src/lib/utils/index.ts +++ b/src/apps/admin/src/lib/utils/index.ts @@ -5,3 +5,4 @@ export * from './challenge' export * from './number' export * from './string' export * from './others' +export * from './access' diff --git a/src/apps/admin/src/permission-management/PermissionGroupMembersPage/PermissionGroupMembersPage.tsx b/src/apps/admin/src/permission-management/PermissionGroupMembersPage/PermissionGroupMembersPage.tsx index ea85c8010..e2a5ba63f 100644 --- a/src/apps/admin/src/permission-management/PermissionGroupMembersPage/PermissionGroupMembersPage.tsx +++ b/src/apps/admin/src/permission-management/PermissionGroupMembersPage/PermissionGroupMembersPage.tsx @@ -1,7 +1,7 @@ /** * Permission group members page. */ -import { FC, useContext, useEffect, useMemo, useState } from 'react' +import { FC, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useParams } from 'react-router-dom' import _ from 'lodash' import classNames from 'classnames' @@ -13,6 +13,7 @@ import { PageDivider, PageTitle, } from '~/libs/ui' +import { downloadBlob } from '~/libs/shared/lib/utils/files' import { PlusIcon } from '@heroicons/react/solid' import { GroupMembersFilters } from '../../lib/components/GroupMembersFilters' @@ -21,6 +22,8 @@ import { useManagePermissionGroupMembers, useManagePermissionGroupMembersProps } import { AdminAppContext, PageContent, PageHeader } from '../../lib' import { AdminAppContextType, FormGroupMembersFilters, UserGroupMember } from '../../lib/models' import { useTableSelection, useTableSelectionProps } from '../../lib/hooks/useTableSelection' +import { exportGroupUsersCsv } from '../../lib/services' +import { handleError } from '../../lib/utils' import styles from './PermissionGroupMembersPage.module.scss' @@ -29,6 +32,19 @@ interface Props { } const pageTitle = 'Group Members' +const normalizeFileNameSegment = (value: string): string => ( + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)+/g, '') +) + +const buildGroupExportFileName = (groupName: string | undefined, groupId: string): string => { + const normalizedName = normalizeFileNameSegment(groupName || groupId) + return `group-users-${normalizedName || groupId}.csv` +} + export const PermissionGroupMembersPage: FC = (props: Props) => { const memberTypes = useMemo(() => ['group', 'user'], []) const { groupId = '' }: { groupId?: string } = useParams<{ @@ -60,6 +76,7 @@ export const PermissionGroupMembersPage: FC = (props: Props) => { cancelLoadGroup, groupsMapping, ) + const [isExporting, setIsExporting] = useState(false) const [datasIdsMapping, setDatasIdsMapping] = useState<{ [memberType: string]: number[] }>({ @@ -92,6 +109,26 @@ export const PermissionGroupMembersPage: FC = (props: Props) => { () => !groupsMapping[groupId], [groupsMapping, groupId], ) + const handleExport = useCallback(() => { + if (!groupId || isExporting) { + return + } + + setIsExporting(true) + exportGroupUsersCsv(groupId) + .then(blob => { + downloadBlob( + blob, + buildGroupExportFileName(groupsMapping[groupId], groupId), + ) + }) + .catch(error => { + handleError(error) + }) + .finally(() => { + setIsExporting(false) + }) + }, [groupId, groupsMapping, isExporting]) return (
@@ -99,6 +136,14 @@ export const PermissionGroupMembersPage: FC = (props: Props) => {

{pageTitle}

+ ( + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)+/g, '') +) + +const buildRoleExportFileName = (roleName: string | undefined, roleId: string): string => { + const normalizedName = normalizeFileNameSegment(roleName || roleId) + return `role-users-${normalizedName || roleId}.csv` +} + export const PermissionRoleMembersPage: FC = (props: Props) => { const { roleId = '' }: { roleId?: string } = useParams<{ roleId: string }>() + const [isExporting, setIsExporting] = useState(false) const { isLoading, roleInfo, @@ -40,6 +57,26 @@ export const PermissionRoleMembersPage: FC = (props: Props) => { }: useManagePermissionRoleMembersProps = useManagePermissionRoleMembers(roleId) const pageTitleWithRole = roleInfo?.roleName ? `${pageTitle}: ${roleInfo.roleName}` : pageTitle + const handleExport = useCallback(() => { + if (!roleId || isExporting) { + return + } + + setIsExporting(true) + exportRoleUsersCsv(roleId) + .then(blob => { + downloadBlob( + blob, + buildRoleExportFileName(roleInfo?.roleName, roleId), + ) + }) + .catch(error => { + handleError(error) + }) + .finally(() => { + setIsExporting(false) + }) + }, [isExporting, roleId, roleInfo?.roleName]) return (
@@ -47,6 +84,14 @@ export const PermissionRoleMembersPage: FC = (props: Props) => {

{pageTitleWithRole}

+ { - const normalized = name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/(^-|-$)+/g, '') - - const base = normalized || 'report' - return `${base}.${extension}` -} - -const formatMethod = (method?: string): string => ( - method ? method.toUpperCase() : 'GET' -) - -export const ReportsPage: FC = () => { - 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 handleDownload = useCallback(async (format: 'json' | 'csv') => { - if (!selectedReport) { - return - } - - try { - setDownloadingFormat(format) - - const requestPath = buildReportPathWithParams(selectedReport) - - const blob = format === 'json' - ? await downloadReportAsJson(requestPath) - : await downloadReportAsCsv(requestPath) - - const link = document.createElement('a') - const fileName = buildDownloadName(selectedReport.name, format) - 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) - } catch (error) { - handleError(error) - } finally { - setDownloadingFormat(undefined) - } - }, [buildReportPathWithParams, selectedReport]) - - 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 isDownloadDisabled = !selectedReport || isDownloading || requiredParamsMissing || hasUnresolvedPathParams - - const handleJsonDownload = useCallback(() => { - handleDownload('json') - }, [handleDownload]) - - const handleCsvDownload = useCallback(() => { - handleDownload('csv') - }, [handleDownload]) - - const renderParameterInput = useCallback((parameter: ReportParameter) => { - const commonProps = { - label: parameter.name, - name: parameter.name, - placeholder: 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, parameterValues]) - - return ( - <> - - {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. -
- )} - - {selectedReport && ( - <> -
-
{selectedReport.name}
- {selectedReport.description && ( -
- {selectedReport.description} -
- )} -
- {formatMethod(selectedReport.method)} - {' '} - {selectedReport.path} -
-
- - {(selectedReport.parameters?.length ?? 0) > 0 && ( -
- {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. -
- )} - {renderParameterInput(parameter)} -
- ))} -
- )} - -
- - -
- - )} - - )} -
-
- - ) -} - -export default ReportsPage diff --git a/src/apps/copilots/src/models/CopilotRequest.ts b/src/apps/copilots/src/models/CopilotRequest.ts index cd8122d86..c92e028cc 100644 --- a/src/apps/copilots/src/models/CopilotRequest.ts +++ b/src/apps/copilots/src/models/CopilotRequest.ts @@ -5,7 +5,7 @@ import { ProjectType } from '../constants' import { CopilotOpportunity } from './CopilotOpportunity' export interface CopilotRequest { - id: number, + id: string, projectId: string, projectType: ProjectType, complexity: 'high' | 'medium' | 'low', diff --git a/src/apps/copilots/src/pages/copilot-requests/index.tsx b/src/apps/copilots/src/pages/copilot-requests/index.tsx index 876018d37..6cf8f28e6 100644 --- a/src/apps/copilots/src/pages/copilot-requests/index.tsx +++ b/src/apps/copilots/src/pages/copilot-requests/index.tsx @@ -1,5 +1,4 @@ import { FC, useCallback, useContext, useMemo, useState } from 'react' -import { find } from 'lodash' import { NavigateFunction, Params, useNavigate, useParams } from 'react-router-dom' import classNames from 'classnames' @@ -158,7 +157,9 @@ const CopilotRequestsPage: FC = () => { }: CopilotRequestsResponse = useCopilotRequests(sort) const viewRequestDetails = useMemo(() => ( - routeParams.requestId && find(requests, { id: +routeParams.requestId }) as CopilotRequest + routeParams.requestId + ? requests.find(request => request.id === routeParams.requestId) + : undefined ), [requests, routeParams.requestId]) const hideRequestDetails = useCallback(() => { diff --git a/src/apps/copilots/src/services/copilot-requests.ts b/src/apps/copilots/src/services/copilot-requests.ts index 270452f23..ccfedd39c 100644 --- a/src/apps/copilots/src/services/copilot-requests.ts +++ b/src/apps/copilots/src/services/copilot-requests.ts @@ -12,6 +12,16 @@ import { CopilotRequest } from '../models/CopilotRequest' const baseUrl = `${EnvironmentConfig.API.V6}/projects` const PAGE_SIZE = 20 +/** + * Normalizes ids returned by the API so the app can use a consistent string shape. + * + * @param value - The raw id value from the API response. + * @returns The normalized string id, or an empty string when the id is missing. + */ +function normalizeId(value: string | number | undefined): string { + return value === undefined ? '' : String(value) +} + /** * Creates a CopilotRequest object by merging the provided data and its nested data, * setting specific properties, and formatting the createdAt date. @@ -20,14 +30,18 @@ const PAGE_SIZE = 20 * @returns A new CopilotRequest object with the transformed properties. */ function copilotRequestFactory(data: any): CopilotRequest { + const requestData = data.data ?? {} + return { ...data, - ...data.data, + ...requestData, copilotOpportunity: undefined, createdAt: new Date(data.createdAt), data: undefined, + id: normalizeId(data.id ?? requestData.id), opportunity: data.copilotOpportunity?.[0], - startDate: new Date(data.data?.startDate), + projectId: normalizeId(data.projectId ?? requestData.projectId), + startDate: new Date(requestData.startDate), } } diff --git a/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts b/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts index 4c1041ac4..f0674b639 100644 --- a/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts +++ b/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts @@ -11,6 +11,11 @@ export type CompletedProfile = { photoURL?: string skillCount?: number userId?: number | string + isOpenToWork?: boolean | null + openToWork?: { + availability?: string + preferredRoles?: string[] + } | null } export type CompletedProfilesResponse = { @@ -79,10 +84,14 @@ function normalizeCompletedProfilesResponse( } } +export type OpenToWorkFilter = 'all' | 'yes' | 'no' + export async function fetchCompletedProfiles( countryCode: string | undefined, page: number, perPage: number, + openToWorkFilter?: OpenToWorkFilter, + skillIds?: string[], ): Promise { const queryParams = new URLSearchParams({ page: String(page), @@ -93,6 +102,22 @@ export async function fetchCompletedProfiles( queryParams.set('countryCode', countryCode) } + if (openToWorkFilter === 'yes') { + queryParams.set('openToWork', 'true') + } + + if (openToWorkFilter === 'no') { + queryParams.set('openToWork', 'false') + } + + if (Array.isArray(skillIds) && skillIds.length > 0) { + skillIds.forEach(id => { + if (id) { + queryParams.append('skillId', String(id)) + } + }) + } + const response = await xhrGetAsync( `${EnvironmentConfig.REPORTS_API}/topcoder/completed-profiles?${queryParams.toString()}`, ) diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss index f0a0e396d..ec7051428 100644 --- a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss +++ b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss @@ -18,14 +18,28 @@ } } -.filterWrap { - min-width: 280px; - max-width: 360px; +.filterWrapper { + display: flex; + gap: $sp-4; + + :global([class*='__value-container']) { + min-height: 18px; + } @include ltemd { - max-width: unset; - min-width: unset; - width: 100%; + flex-direction: column; + align-items: stretch; + } + + .filterWrap { + min-width: 280px; + max-width: 360px; + + @include ltemd { + max-width: unset; + min-width: unset; + width: 100%; + } } } @@ -192,3 +206,13 @@ color: $link-blue; cursor: pointer; } + +.openToWorkYes { + color: $green-100; + font-weight: 600; +} + +.openToWorkNo { + color: $red-100; + font-weight: 600; +} diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx index 34ee7785b..00689bb5d 100644 --- a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx +++ b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx @@ -1,12 +1,21 @@ /* eslint-disable react/jsx-no-bind */ /* eslint-disable no-await-in-loop */ /* eslint-disable complexity */ -import { ChangeEvent, FC, useEffect, useMemo, useState } from 'react' +import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' import useSWR, { SWRResponse } from 'swr' import { EnvironmentConfig } from '~/config' -import { CountryLookup, useCountryLookup, UserSkill, UserSkillDisplayModes } from '~/libs/core' -import { Button, InputSelect, InputSelectOption, LoadingSpinner } from '~/libs/ui' +import { CountryLookup, useCountryLookup, UserSkill, UserSkillDisplayModes, xhrGetAsync } from '~/libs/core' +import { + Button, + InputMultiselect, + InputMultiselectOption, + InputSelect, + InputSelectOption, + LoadingSpinner, + Tooltip, +} from '~/libs/ui' +import { getPreferredRoleLabelByValue } from '~/libs/shared/lib/utils/roles' import { PageWrapper } from '../../../lib' import { @@ -14,21 +23,65 @@ import { DEFAULT_PAGE_SIZE, fetchCompletedProfiles, fetchMemberSkillsData, + type OpenToWorkFilter, } from '../../../lib/services/profileCompletion.service' import styles from './ProfileCompletionPage.module.scss' +const DISPLAY_SKILLS_COUNT = 5 + export const ProfileCompletionPage: FC = () => { const [selectedCountry, setSelectedCountry] = useState('all') const [currentPage, setCurrentPage] = useState(1) + const [selectedOpenToWork, setSelectedOpenToWork] = useState('all') + const [selectedSkills, setSelectedSkills] = useState([]) const [memberSkills, setMemberSkills] = useState>(new Map()) + const [skillOptionsLoading, setSkillOptionsLoading] = useState(false) const countryLookup: CountryLookup[] | undefined = useCountryLookup() const countryCodeFilter = selectedCountry === 'all' ? undefined : selectedCountry + const loadSkillOptions = useCallback(async (query: string): Promise => { + setSkillOptionsLoading(true) + try { + const baseUrl = `${EnvironmentConfig.API.V5}/standardized-skills` + const params = new URLSearchParams({ + size: '25', + }) + if (query && query.trim().length > 0) { + params.append('term', query.trim()) + } + + const url = `${baseUrl}/skills/autocomplete?${params.toString()}` + const response: any = await xhrGetAsync(url) + + const skills = Array.isArray(response) ? response : [] + + return skills + .map((skill: any) => ({ + label: skill.name, + value: String(skill.id), + })) + .filter((option: InputMultiselectOption) => !!option.value) + } catch { + return [] + } finally { + setSkillOptionsLoading(false) + } + }, []) + const { data, error, isValidating }: SWRResponse = useSWR( - `customer-portal-completed-profiles:${countryCodeFilter || 'all'}:${currentPage}:${DEFAULT_PAGE_SIZE}`, - () => fetchCompletedProfiles(countryCodeFilter, currentPage, DEFAULT_PAGE_SIZE), + // eslint-disable-next-line max-len + `customer-portal-completed-profiles:${countryCodeFilter || 'all'}:${selectedOpenToWork}:${currentPage}:${DEFAULT_PAGE_SIZE}:${selectedSkills.map(skill => skill.value) + .sort() + .join(',')}`, + () => fetchCompletedProfiles( + countryCodeFilter, + currentPage, + DEFAULT_PAGE_SIZE, + selectedOpenToWork, + selectedSkills.map(skill => skill.value), + ), { revalidateOnFocus: false, }, @@ -118,13 +171,20 @@ export const ProfileCompletionPage: FC = () => { const userSkills = profile.userId ? (memberSkills.get(profile.userId) || []) : [] // Prioritize principal skills, then add additional skills - const allSkillsByPriority = [ + const principalSkills = [ ...userSkills.filter(skill => skill.displayMode?.name === UserSkillDisplayModes.principal), - ...userSkills.filter(skill => skill.displayMode?.name !== UserSkillDisplayModes.principal), ] - const displayedSkills = allSkillsByPriority.slice(0, 5) - const additionalSkillsCount = Math.max(0, allSkillsByPriority.length - 5) + const displayedSkills = principalSkills.slice(0, DISPLAY_SKILLS_COUNT) + const additionalSkillsCount = Math.max(0, principalSkills.length - DISPLAY_SKILLS_COUNT) + + const isOpenToWork = profile.isOpenToWork === true + const openToWorkLabel = isOpenToWork ? 'Yes' : 'No' + const openToWorkRolesText = profile.openToWork?.preferredRoles && profile.openToWork.preferredRoles.length + ? profile.openToWork.preferredRoles.map(getPreferredRoleLabelByValue) + .filter(Boolean) + .join(', ') + : 'No role preferences set' return { ...profile, @@ -136,11 +196,14 @@ export const ProfileCompletionPage: FC = () => { fullName: [profile.firstName, profile.lastName].filter(Boolean) .join(' ') .trim(), + isOpenToWork, locationLabel: [profile.city, profile.countryCode ? countryMap.get(profile.countryCode) || profile.countryName || profile.countryCode : profile.countryName] .filter(Boolean) .join(', '), + openToWorkLabel, + openToWorkRolesText, } }) .sort((a, b) => a.handle.localeCompare(b.handle)), [profiles, countryMap, memberSkills]) @@ -155,18 +218,52 @@ export const ProfileCompletionPage: FC = () => { className={styles.container} >
-
- ) => { - setSelectedCountry(event.target.value || 'all') - setCurrentPage(1) - }} - placeholder='Select country' - /> +
+
+ ) => { + setSelectedCountry(event.target.value || 'all') + setCurrentPage(1) + }} + placeholder='Select country' + /> +
+
+ ) => { + setSelectedOpenToWork((event.target.value || 'all') as OpenToWorkFilter) + setCurrentPage(1) + }} + placeholder='Select' + /> +
+
+ ) => { + const value = (event.target.value || []) as InputMultiselectOption[] + setSelectedSkills(value) + setCurrentPage(1) + }} + /> +
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 => ( + <> + + +
+
{props.children}
+
+
+ +) + +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 => {
Result
- + + + + ) + } + />
@@ -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}
+
+
+ )} +
+ {hasConfig && } + {hasConfig && } @@ -124,46 +419,75 @@ const AiReviewsTable: FC = props => { - {!runs.length && isLoading && ( + {!reviewerRows.length && loading && ( - + )} - {aiRuns.map(run => ( - + {reviewerRows.map(row => ( + + {hasConfig && } + {hasConfig && } ))} 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 && ( + <> + + + + )} {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 + {aiReviewersCount > 0 && ( + <> + + + + )} 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 */
    AI ReviewerWeightMin ScoreReview Date Score Result
    Loading...Loading...
    - - {run.workflow.name} + + {row.title} + {row.isGating && ( + + + + )}
    {formatWeight(row.weight)}{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) ) : '-'} - + + + + ) + } + />
    + {hasDecisionScore ? formatScore(currentDecision!.totalScore) : '-'} + + {currentDecision ? ( + + ) : '-'} +
    +
    - +
    Submission ID Submitted ReviewerScoreStatus