From f0839d427bd168c8991c4b95857de657aba90801 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 5 Mar 2026 11:02:46 +1100 Subject: [PATCH 01/70] Initial reports portal --- src/apps/admin/src/admin-app.routes.tsx | 11 - src/apps/admin/src/config/routes.config.ts | 1 - .../Tab/config/system-admin-tabs-config.ts | 5 - .../admin/src/lib/services/groups.service.ts | 31 +- src/apps/admin/src/lib/services/index.ts | 1 - .../admin/src/lib/services/reports.service.ts | 64 ---- .../admin/src/lib/services/roles.service.ts | 26 +- .../PermissionGroupMembersPage.tsx | 47 ++- .../PermissionRoleMembersPage.tsx | 49 ++- src/apps/platform/src/platform.routes.tsx | 2 + src/apps/reports/index.ts | 1 + src/apps/reports/src/ReportsApp.tsx | 36 +++ src/apps/reports/src/config/routes.config.ts | 12 + src/apps/reports/src/index.ts | 2 + .../src/lib/assets/icons/chevron-down.svg | 3 + .../lib/components/Layout/Layout.module.scss | 29 ++ .../src/lib/components/Layout/Layout.tsx | 27 ++ .../components/NavTabs/NavTabs.module.scss | 121 ++++++++ .../src/lib/components/NavTabs/NavTabs.tsx | 111 +++++++ .../src/lib/components/NavTabs/index.ts | 1 + src/apps/reports/src/lib/components/index.ts | 3 + .../src/lib/contexts/ReportsAppContext.ts | 15 + .../contexts/ReportsAppContextProvider.tsx | 41 +++ .../src/lib/contexts/SWRConfigProvider.tsx | 19 ++ src/apps/reports/src/lib/contexts/index.ts | 3 + src/apps/reports/src/lib/index.ts | 4 + src/apps/reports/src/lib/services/index.ts | 16 + .../src/lib/services/reports.service.ts | 142 +++++++++ src/apps/reports/src/lib/styles/index.scss | 5 + src/apps/reports/src/lib/utils/index.ts | 22 ++ .../BulkMemberLookupPage.module.scss | 39 +++ .../BulkMemberLookupPage.tsx | 279 ++++++++++++++++++ .../src/pages/bulk-member-lookup/index.ts | 1 + .../pages}/reports/ReportsPage.module.scss | 16 + .../src/pages}/reports/ReportsPage.tsx | 271 +++++++++-------- src/apps/reports/src/pages/reports/index.ts | 1 + src/apps/reports/src/reports-app.routes.tsx | 63 ++++ src/config/constants.ts | 6 +- .../table/table-functions/table.functions.ts | 20 +- 39 files changed, 1333 insertions(+), 213 deletions(-) delete mode 100644 src/apps/admin/src/lib/services/reports.service.ts create mode 100644 src/apps/reports/index.ts create mode 100644 src/apps/reports/src/ReportsApp.tsx create mode 100644 src/apps/reports/src/config/routes.config.ts create mode 100644 src/apps/reports/src/index.ts create mode 100644 src/apps/reports/src/lib/assets/icons/chevron-down.svg create mode 100644 src/apps/reports/src/lib/components/Layout/Layout.module.scss create mode 100644 src/apps/reports/src/lib/components/Layout/Layout.tsx create mode 100644 src/apps/reports/src/lib/components/NavTabs/NavTabs.module.scss create mode 100644 src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx create mode 100644 src/apps/reports/src/lib/components/NavTabs/index.ts create mode 100644 src/apps/reports/src/lib/components/index.ts create mode 100644 src/apps/reports/src/lib/contexts/ReportsAppContext.ts create mode 100644 src/apps/reports/src/lib/contexts/ReportsAppContextProvider.tsx create mode 100644 src/apps/reports/src/lib/contexts/SWRConfigProvider.tsx create mode 100644 src/apps/reports/src/lib/contexts/index.ts create mode 100644 src/apps/reports/src/lib/index.ts create mode 100644 src/apps/reports/src/lib/services/index.ts create mode 100644 src/apps/reports/src/lib/services/reports.service.ts create mode 100644 src/apps/reports/src/lib/styles/index.scss create mode 100644 src/apps/reports/src/lib/utils/index.ts create mode 100644 src/apps/reports/src/pages/bulk-member-lookup/BulkMemberLookupPage.module.scss create mode 100644 src/apps/reports/src/pages/bulk-member-lookup/BulkMemberLookupPage.tsx create mode 100644 src/apps/reports/src/pages/bulk-member-lookup/index.ts rename src/apps/{admin/src => reports/src/pages}/reports/ReportsPage.module.scss (79%) rename src/apps/{admin/src => reports/src/pages}/reports/ReportsPage.tsx (59%) create mode 100644 src/apps/reports/src/pages/reports/index.ts create mode 100644 src/apps/reports/src/reports-app.routes.tsx diff --git a/src/apps/admin/src/admin-app.routes.tsx b/src/apps/admin/src/admin-app.routes.tsx index 606e40794..2f773d3f1 100644 --- a/src/apps/admin/src/admin-app.routes.tsx +++ b/src/apps/admin/src/admin-app.routes.tsx @@ -17,7 +17,6 @@ import { paymentsRouteId, permissionManagementRouteId, platformRouteId, - reportsRouteId, rootRoute, termsRouteId, userManagementRouteId, @@ -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 @@ -416,12 +411,6 @@ export const adminRoutes: ReadonlyArray = [ id: paymentsRouteId, route: paymentsRouteId, }, - // Reports Module - { - element: , - id: reportsRouteId, - route: reportsRouteId, - }, ], domain: AppSubdomain.admin, element: , 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/config/system-admin-tabs-config.ts b/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts index 55c448402..8c07f6f6c 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 @@ -10,7 +10,6 @@ import { paymentsRouteId, permissionManagementRouteId, platformRouteId, - reportsRouteId, termsRouteId, userManagementRouteId, } from '~/apps/admin/src/config/routes.config' @@ -83,10 +82,6 @@ export const SystemAdminTabsConfig: TabsNavItem[] = [ id: paymentsRouteId, title: 'Payments', }, - { - id: reportsRouteId, - title: 'Reports', - }, ] export function getTabIdFromPathName(pathname: string): string { 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..b47f23458 100644 --- a/src/apps/admin/src/lib/services/index.ts +++ b/src/apps/admin/src/lib/services/index.ts @@ -14,4 +14,3 @@ export * from './default-reviewers.service' export * from './timeline-templates.service' export * from './phases.service' export * from './scorecards.service' -export * 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 deleted file mode 100644 index f2802ff85..000000000 --- a/src/apps/admin/src/lib/services/reports.service.ts +++ /dev/null @@ -1,64 +0,0 @@ -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 -} - -export const downloadReportAsJson = (path: string): Promise => ( - downloadReportBlob(path, 'application/json') -) - -export const downloadReportAsCsv = (path: string): Promise => ( - downloadReportBlob(path, 'text/csv') -) diff --git a/src/apps/admin/src/lib/services/roles.service.ts b/src/apps/admin/src/lib/services/roles.service.ts index 83f37775a..89040bced 100644 --- a/src/apps/admin/src/lib/services/roles.service.ts +++ b/src/apps/admin/src/lib/services/roles.service.ts @@ -1,21 +1,24 @@ /** * Roles service */ +import type { AxiosInstance } from 'axios' import _ from 'lodash' import { EnvironmentConfig } from '~/config' import { + xhrCreateInstance, xhrDeleteAsync, xhrGetAsync, 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 @@ -231,3 +234,24 @@ export const fetchRoleMembersPaginated = async ( totalPages: computedTotalPages, } } + +/** + * 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/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}

+ = [ ...engagementsRoutes, ...homeRoutes, ...adminRoutes, + ...reportsRoutes, ...customerPortalRoutes, ] diff --git a/src/apps/reports/index.ts b/src/apps/reports/index.ts new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/reports/index.ts @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/reports/src/ReportsApp.tsx b/src/apps/reports/src/ReportsApp.tsx new file mode 100644 index 000000000..14f78194c --- /dev/null +++ b/src/apps/reports/src/ReportsApp.tsx @@ -0,0 +1,36 @@ +/** + * The reports app. + */ +import { FC, useContext, useEffect, useMemo } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' + +import { Layout, ReportsAppContextProvider, SWRConfigProvider } from './lib' +import { toolTitle } from './reports-app.routes' +import './lib/styles/index.scss' + +const ReportsApp: FC = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes]) + + useEffect(() => { + document.body.classList.add('reports-app') + return () => { + document.body.classList.remove('reports-app') + } + }, []) + + return ( + + + + + {childRoutes} + + + + ) +} + +export default ReportsApp diff --git a/src/apps/reports/src/config/routes.config.ts b/src/apps/reports/src/config/routes.config.ts new file mode 100644 index 000000000..fb7d07091 --- /dev/null +++ b/src/apps/reports/src/config/routes.config.ts @@ -0,0 +1,12 @@ +/** + * Common config for routes in reports app. + */ +import { AppSubdomain, EnvironmentConfig } from '~/config' + +export const rootRoute: string + = EnvironmentConfig.SUBDOMAIN === AppSubdomain.reports + ? '' + : `/${AppSubdomain.reports}` + +export const reportsPageRouteId = 'reports' +export const bulkMemberLookupRouteId = 'bulk-member-lookup' diff --git a/src/apps/reports/src/index.ts b/src/apps/reports/src/index.ts new file mode 100644 index 000000000..3aabe0c5e --- /dev/null +++ b/src/apps/reports/src/index.ts @@ -0,0 +1,2 @@ +export { reportsRoutes } from './reports-app.routes' +export { rootRoute as reportsRootRoute } from './config/routes.config' diff --git a/src/apps/reports/src/lib/assets/icons/chevron-down.svg b/src/apps/reports/src/lib/assets/icons/chevron-down.svg new file mode 100644 index 000000000..82b096f96 --- /dev/null +++ b/src/apps/reports/src/lib/assets/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/reports/src/lib/components/Layout/Layout.module.scss b/src/apps/reports/src/lib/components/Layout/Layout.module.scss new file mode 100644 index 000000000..d1b0d383d --- /dev/null +++ b/src/apps/reports/src/lib/components/Layout/Layout.module.scss @@ -0,0 +1,29 @@ +@import '@libs/ui/styles/includes'; + +.layout { + position: relative; + font-family: $font-roboto; + color: var(--Primary); + + .main { + @include ltelg { + padding: 36px 0; + } + } + + h1, + h2, + h3, + h4 { + font-family: $font-roboto; + } +} + +.contentLayoutOuter { + margin: $sp-6 auto !important; +} + +.contentLayoutInner { + box-sizing: border-box; + width: 100%; +} diff --git a/src/apps/reports/src/lib/components/Layout/Layout.tsx b/src/apps/reports/src/lib/components/Layout/Layout.tsx new file mode 100644 index 000000000..14e6ea42b --- /dev/null +++ b/src/apps/reports/src/lib/components/Layout/Layout.tsx @@ -0,0 +1,27 @@ +import { FC, PropsWithChildren } from 'react' + +import { ContentLayout } from '~/libs/ui' + +import { NavTabs } from '../NavTabs' + +import styles from './Layout.module.scss' + +export const NullLayout: FC = props => ( + <>{props.children} +) + +export const Layout: FC = props => ( + <> + + +
+
{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..f41edf8df --- /dev/null +++ b/src/apps/reports/src/lib/services/index.ts @@ -0,0 +1,16 @@ +export { + 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..f983da64a --- /dev/null +++ b/src/apps/reports/src/lib/services/reports.service.ts @@ -0,0 +1,142 @@ +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') +) 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/admin/src/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx similarity index 59% rename from src/apps/admin/src/reports/ReportsPage.tsx rename to src/apps/reports/src/pages/reports/ReportsPage.tsx index 98053d0dd..f7b7e89d7 100644 --- a/src/apps/admin/src/reports/ReportsPage.tsx +++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx @@ -1,9 +1,10 @@ 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 { PageContent, PageHeader } from '../lib' -import { handleError } from '../lib/utils' +import { bulkMemberLookupRouteId } from '../../config/routes.config' +import { handleError } from '../../lib/utils' import { downloadReportAsCsv, downloadReportAsJson, @@ -12,11 +13,12 @@ import { ReportGroup, ReportParameter, ReportsIndexResponse, -} from '../lib/services' +} from '../../lib/services' import styles from './ReportsPage.module.scss' const pageTitle = 'Reports' +const bulkMembersByHandlesPath = '/identity/users-by-handles' const buildDownloadName = (name: string, extension: 'json' | 'csv'): string => { const normalized = name @@ -33,6 +35,7 @@ const formatMethod = (method?: string): string => ( ) export const ReportsPage: FC = () => { + const navigate: NavigateFunction = useNavigate() const [reportsIndex, setReportsIndex] = useState({}) const [selectedBasePath, setSelectedBasePath] = useState('') const [selectedReportPath, setSelectedReportPath] = useState('') @@ -191,6 +194,10 @@ export const ReportsPage: FC = () => { } }, [buildReportPathWithParams, selectedReport]) + const handleOpenBulkMemberLookup = useCallback(() => { + navigate(bulkMemberLookupRouteId) + }, [navigate]) + const isDownloading = downloadingFormat !== undefined const requiredParamsMissing = useMemo(() => { @@ -204,7 +211,13 @@ export const ReportsPage: FC = () => { .some(param => !parameterValues[param.name]?.trim()) ), [parameterValues, selectedReport]) - const isDownloadDisabled = !selectedReport || isDownloading || requiredParamsMissing || hasUnresolvedPathParams + const isPostReport = selectedReport?.method?.toUpperCase() === 'POST' + const isHandleLookupPostReport = isPostReport && selectedReport.path === bulkMembersByHandlesPath + const isDownloadDisabled = !selectedReport + || isPostReport + || isDownloading + || requiredParamsMissing + || hasUnresolvedPathParams const handleJsonDownload = useCallback(() => { handleDownload('json') @@ -214,6 +227,54 @@ export const ReportsPage: FC = () => { handleDownload('csv') }, [handleDownload]) + const reportActions = useMemo(() => { + if (isPostReport) { + return ( +
+
+ This report uses a POST request body and cannot be downloaded from this + page. +
+ {isHandleLookupPostReport ? ( + + ) : ( +
+ Run this report from its dedicated workflow. +
+ )} +
+ ) + } + + return ( +
+ + +
+ ) + }, [ + handleCsvDownload, + handleJsonDownload, + handleOpenBulkMemberLookup, + isDownloadDisabled, + isHandleLookupPostReport, + isPostReport, + ]) + const renderParameterInput = useCallback((parameter: ReportParameter) => { const commonProps = { label: parameter.name, @@ -266,126 +327,106 @@ export const ReportsPage: FC = () => { 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 ? ( -
+

+ 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 && ( - - {selectedGroup && ( - - )} -
- ) : ( -
- No reports are currently available. -
- )} - - {selectedReport && ( - <> -
-
{selectedReport.name}
- {selectedReport.description && ( -
- {selectedReport.description} -
- )} -
- {formatMethod(selectedReport.method)} - {' '} - {selectedReport.path} + )} +
+ ) : ( +
+ 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)} + {(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)} +
+ ))}
- - )} - - )} -
- + )} + + {reportActions} + + )} + + )} +
) } 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/reports-app.routes.tsx b/src/apps/reports/src/reports-app.routes.tsx new file mode 100644 index 000000000..b0995e311 --- /dev/null +++ b/src/apps/reports/src/reports-app.routes.tsx @@ -0,0 +1,63 @@ +/** + * 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.projectManager, + UserRole.talentManager, + ], + route: rootRoute, + title: toolTitle, + }, +] 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/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) }) } From 78a9b480488be3153a891b27df310a1263ebd649 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 5 Mar 2026 11:03:22 +1100 Subject: [PATCH 02/70] Deploy reports --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) 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 From 1bd0f027964c3fe1d0485455bb87c61c8482ce4f Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 6 Mar 2026 08:04:48 +1100 Subject: [PATCH 03/70] Remove project manager rights from reports portal --- src/apps/reports/src/reports-app.routes.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/apps/reports/src/reports-app.routes.tsx b/src/apps/reports/src/reports-app.routes.tsx index b0995e311..df4b56ecd 100644 --- a/src/apps/reports/src/reports-app.routes.tsx +++ b/src/apps/reports/src/reports-app.routes.tsx @@ -54,7 +54,6 @@ export const reportsRoutes: ReadonlyArray = [ id: toolTitle, rolesRequired: [ UserRole.administrator, - UserRole.projectManager, UserRole.talentManager, ], route: rootRoute, From 61fdca1e7c65a3e88e39e76317801799543d5e5b Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Tue, 10 Mar 2026 21:33:04 +0530 Subject: [PATCH 04/70] PM-4075 Fix manager comment access --- .../components/ReviewViewer/ReviewViewer.tsx | 56 +++++++++++-------- 1 file changed, 33 insertions(+), 23 deletions(-) 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..e503f39f8 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(), From eee8edf8f07b7449708384710ec92938ecebc727 Mon Sep 17 00:00:00 2001 From: Hentry Martin Date: Tue, 10 Mar 2026 23:37:11 +0100 Subject: [PATCH 05/70] PM-3707 --- .../src/lib/components/ChallengeLinks/ChallengeLinks.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.tsx b/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.tsx index be146b2d6..d6641fb6b 100644 --- a/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.tsx +++ b/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.tsx @@ -40,7 +40,10 @@ export const ChallengeLinks: FC = (props: Props) => { // Payments button visibility: only copilots and admins const canShowPaymentsButton = useMemo( - () => [ADMIN, COPILOT].includes(actionChallengeRole as any), + () => ( + [ADMIN, COPILOT].includes(actionChallengeRole as any) || + myResources.some(resource => resource.roleName?.toLowerCase() === 'copilot') + ), [actionChallengeRole], ) From 4db3c1b3bd32506792a7073c6988083d3331b8fb Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 11 Mar 2026 11:27:58 +1100 Subject: [PATCH 06/70] Better RBAC for reports access for PM / TM roles --- src/apps/admin/src/AdminHomeRedirect.tsx | 21 +++++ src/apps/admin/src/admin-app.routes.tsx | 16 +++- .../ChallengeDetailsPage.module.scss | 15 ++++ .../ChallengeDetailsPage.tsx | 81 +++++++++++++++++++ .../components/common/Tab/SystemAdminTabs.tsx | 20 +++-- .../Tab/config/system-admin-tabs-config.ts | 27 ++++++- .../admin/src/lib/services/reports.service.ts | 19 +++++ src/apps/admin/src/lib/utils/access.ts | 25 ++++++ src/apps/admin/src/lib/utils/index.ts | 1 + src/apps/admin/src/reports/ReportsPage.tsx | 36 ++++++--- .../profile-factory/user-role.enum.ts | 1 + 11 files changed, 236 insertions(+), 26 deletions(-) create mode 100644 src/apps/admin/src/AdminHomeRedirect.tsx create mode 100644 src/apps/admin/src/lib/utils/access.ts diff --git a/src/apps/admin/src/AdminHomeRedirect.tsx b/src/apps/admin/src/AdminHomeRedirect.tsx new file mode 100644 index 000000000..ba7d4d9b8 --- /dev/null +++ b/src/apps/admin/src/AdminHomeRedirect.tsx @@ -0,0 +1,21 @@ +import { FC } from 'react' +import { Navigate } from 'react-router-dom' + +import { ProfileContextData, useProfileContext } from '~/libs/core' + +import { manageChallengeRouteId, reportsRouteId } 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 + : reportsRouteId + + 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..c0a4fd2c3 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 { @@ -22,7 +20,9 @@ import { 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')) @@ -186,7 +186,7 @@ export const adminRoutes: ReadonlyArray = [ authRequired: true, children: [ { - element: , + element: , route: '', }, // Challenge Management Module @@ -220,12 +220,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 +246,7 @@ export const adminRoutes: ReadonlyArray = [ ], element: , id: manageReviewRouteId, + rolesRequired: administratorOnlyRoles, route: manageReviewRouteId, }, // Billing Account Module @@ -297,6 +300,7 @@ export const adminRoutes: ReadonlyArray = [ ], element: , id: billingAccountRouteId, + rolesRequired: administratorOnlyRoles, route: billingAccountRouteId, }, // Permission Management Module @@ -335,6 +339,7 @@ export const adminRoutes: ReadonlyArray = [ ], element: , id: permissionManagementRouteId, + rolesRequired: administratorOnlyRoles, route: permissionManagementRouteId, }, @@ -408,25 +413,28 @@ 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, + rolesRequired: adminReportsAccessRoles, 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..b84c38c14 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, @@ -27,6 +28,8 @@ import { ChallengeWinner, } from '../../lib/models' import { + downloadBlobFile, + downloadReportAsCsv, getChallengeById, getChallengeResources, getResourceRoles, @@ -62,6 +65,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 +207,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 +349,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 +436,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/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..31ba5260a 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 { canAccessAdminReports, isAdministrator } from '~/apps/admin/src/lib/utils' import { billingAccountRouteId, defaultReviewersRouteId, @@ -89,16 +90,34 @@ export const SystemAdminTabsConfig: TabsNavItem[] = [ }, ] -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 + } + + if (canAccessAdminReports(roles)) { + return SystemAdminTabsConfig.filter(item => item.id === reportsRouteId) + } + + 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) || reportsRouteId } 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/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/reports/ReportsPage.tsx b/src/apps/admin/src/reports/ReportsPage.tsx index 98053d0dd..8037dd6be 100644 --- a/src/apps/admin/src/reports/ReportsPage.tsx +++ b/src/apps/admin/src/reports/ReportsPage.tsx @@ -5,6 +5,7 @@ import { Button, InputSelect, InputSelectOption, InputText, LoadingSpinner, Page import { PageContent, PageHeader } from '../lib' import { handleError } from '../lib/utils' import { + downloadBlobFile, downloadReportAsCsv, downloadReportAsJson, fetchReportsIndex, @@ -18,14 +19,26 @@ import styles from './ReportsPage.module.scss' const pageTitle = 'Reports' -const buildDownloadName = (name: string, extension: 'json' | 'csv'): string => { +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 `${base}.${extension}` + return normalizedSuffix + ? `${base}_${normalizedSuffix}.${extension}` + : `${base}.${extension}` } const formatMethod = (method?: string): string => ( @@ -174,22 +187,19 @@ export const ReportsPage: FC = () => { ? 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) + const challengeIdSuffix = parameterValues.challengeId?.trim() + const fileName = buildDownloadName( + selectedReport.name, + format, + challengeIdSuffix, + ) + downloadBlobFile(blob, fileName) } catch (error) { handleError(error) } finally { setDownloadingFormat(undefined) } - }, [buildReportPathWithParams, selectedReport]) + }, [buildReportPathWithParams, parameterValues.challengeId, selectedReport]) const isDownloading = downloadingFormat !== undefined 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', From 000b697a0b57b4cad77e05972931dc91ac84b902 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 11 Mar 2026 14:48:22 +1100 Subject: [PATCH 07/70] Fix up for copilot issue --- src/apps/copilots/src/models/CopilotRequest.ts | 2 +- .../src/pages/copilot-requests/index.tsx | 5 +++-- .../copilots/src/services/copilot-requests.ts | 18 ++++++++++++++++-- 3 files changed, 20 insertions(+), 5 deletions(-) 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), } } From 9a347c26d16305460a565b32802b1062593222c8 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 11 Mar 2026 08:57:22 +0200 Subject: [PATCH 08/70] PM-3926 - show locked state --- .../TableReview/TableReview.module.scss | 6 +++++ .../components/TableReview/TableReview.tsx | 9 ++++++++ .../models/BackendSubmissionStatus.enum.ts | 1 + .../src/lib/models/SubmissionInfo.model.ts | 23 +++++++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/src/apps/review/src/lib/components/TableReview/TableReview.module.scss b/src/apps/review/src/lib/components/TableReview/TableReview.module.scss index 2dd2bfe44..b4047dd32 100644 --- a/src/apps/review/src/lib/components/TableReview/TableReview.module.scss +++ b/src/apps/review/src/lib/components/TableReview/TableReview.module.scss @@ -36,6 +36,12 @@ display: inline-block; } +.statusLocked { + @extend .resultPill; + background-color: $black-5; + color: $red-100; +} + .submissionColumn { width: 240px; min-width: 220px; diff --git a/src/apps/review/src/lib/components/TableReview/TableReview.tsx b/src/apps/review/src/lib/components/TableReview/TableReview.tsx index ba30d6547..b87d15922 100644 --- a/src/apps/review/src/lib/components/TableReview/TableReview.tsx +++ b/src/apps/review/src/lib/components/TableReview/TableReview.tsx @@ -685,10 +685,19 @@ export const TableReview: FC = (props: TableReviewProps) => { columnId: 'review-result', label: 'Review Result', renderer: (row: SubmissionReviewerRow) => { + const isLocked = row.status === 'AI_FAILED_REVIEW' if (!row.isFirstReviewerRow) { return } + if (isLocked) { + return ( + + AI Locked + + ) + } + const result = resolveSubmissionReviewResult(row, { minimumPassingScoreByScorecardId, }) diff --git a/src/apps/review/src/lib/models/BackendSubmissionStatus.enum.ts b/src/apps/review/src/lib/models/BackendSubmissionStatus.enum.ts index dd7e7a85e..9770eeea2 100644 --- a/src/apps/review/src/lib/models/BackendSubmissionStatus.enum.ts +++ b/src/apps/review/src/lib/models/BackendSubmissionStatus.enum.ts @@ -9,4 +9,5 @@ export enum BackendSubmissionStatus { DELETED, FAILED_CHECKPOINT_SCREENING, FAILED_CHECKPOINT_REVIEW, + AI_FAILED_REVIEW, } diff --git a/src/apps/review/src/lib/models/SubmissionInfo.model.ts b/src/apps/review/src/lib/models/SubmissionInfo.model.ts index abebbf8d9..90a6bb765 100644 --- a/src/apps/review/src/lib/models/SubmissionInfo.model.ts +++ b/src/apps/review/src/lib/models/SubmissionInfo.model.ts @@ -4,6 +4,7 @@ import { TABLE_DATE_FORMAT } from '../../config/index.config' import { BackendResource } from './BackendResource.model' import { BackendSubmission } from './BackendSubmission.model' +import { BackendSubmissionStatus } from './BackendSubmissionStatus.enum' import { adjustReviewInfo, convertBackendReviewToReviewInfo, @@ -60,6 +61,27 @@ export interface SubmissionInfo { * Flag indicating whether the submission includes an uploaded file. */ isFileSubmission?: boolean + /** + * Submission status (e.g. 'ACTIVE', 'FAILED_REVIEW', 'AI_FAILED_REVIEW'). + */ + status?: string +} + +/** + * Normalize backend submission status to string representation + * @param status - The status value from backend (can be number or string) + * @returns Normalized status string + */ +function normalizeSubmissionStatus(status: BackendSubmissionStatus | string | undefined): string | undefined { + if (typeof status === 'number') { + return BackendSubmissionStatus[status] ?? String(status) + } + + if (typeof status === 'string') { + return status + } + + return undefined } /** @@ -140,6 +162,7 @@ export function convertBackendSubmissionToSubmissionInfo( reviewInfos, reviews: reviewResults, reviewTypeId: primaryReview?.typeId ?? undefined, + status: normalizeSubmissionStatus(data.status), submittedDate, submittedDateString, type: data.type, From dcca61b494194ca1692bdfda7e078b0a4bad949a Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Wed, 11 Mar 2026 12:29:35 +0530 Subject: [PATCH 09/70] PM-4075 Don't allow copilots to add/edit manager comments --- .../src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx | 1 - 1 file changed, 1 deletion(-) 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 e503f39f8..92aa9edfd 100644 --- a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx +++ b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx @@ -286,7 +286,6 @@ const ReviewViewer: FC = () => { hasChallengeAdminRole || hasTopcoderAdminRole || hasChallengeManagerRole - || hasChallengeCopilotRole } saveReviewInfo={saveReviewInfo} addAppeal={addAppeal} From 866d600c4103ef1b02b7c59999c0e1bc00d621bd Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 11 Mar 2026 09:04:35 +0200 Subject: [PATCH 10/70] PM-4182 #time 15min qa tweaks --- .../profile-header/OpenForGigs/OpenForGigs.module.scss | 1 + .../src/member-profile/profile-header/ProfileHeader.tsx | 6 +----- 2 files changed, 2 insertions(+), 5 deletions(-) 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 )} Date: Wed, 11 Mar 2026 09:08:55 +0200 Subject: [PATCH 11/70] PM-4198 #time 15m qa feedback --- .../ProfileCompletionPage/ProfileCompletionPage.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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..641851b99 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 @@ -18,6 +18,8 @@ import { 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) @@ -118,13 +120,13 @@ 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) return { ...profile, @@ -201,7 +203,7 @@ export const ProfileCompletionPage: FC = () => {
- + From 3acb31ef0067b0523f6e8e2b411dddd20426e4ce Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 11 Mar 2026 09:14:29 +0200 Subject: [PATCH 12/70] lint --- .../ProfileCompletionPage/ProfileCompletionPage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 641851b99..d33719692 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 @@ -18,7 +18,7 @@ import { import styles from './ProfileCompletionPage.module.scss' -const DISPLAY_SKILLS_COUNT = 5; +const DISPLAY_SKILLS_COUNT = 5 export const ProfileCompletionPage: FC = () => { const [selectedCountry, setSelectedCountry] = useState('all') @@ -124,7 +124,6 @@ export const ProfileCompletionPage: FC = () => { ...userSkills.filter(skill => skill.displayMode?.name === UserSkillDisplayModes.principal), ] - const displayedSkills = principalSkills.slice(0, DISPLAY_SKILLS_COUNT) const additionalSkillsCount = Math.max(0, principalSkills.length - DISPLAY_SKILLS_COUNT) From 47bdd9079a4cec7f9042b38461ffa504580501d2 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 11 Mar 2026 12:04:57 +0200 Subject: [PATCH 13/70] PM-3906 wip --- .../TableReview/TableReview.module.scss | 38 ++ .../components/TableReview/TableReview.tsx | 470 +++++++++++++++++- src/apps/review/src/lib/hooks/index.ts | 1 + .../lib/hooks/useFetchAiReviewEscalations.ts | 68 +++ .../services/aiReviewEscalation.service.ts | 84 ++++ src/apps/review/src/lib/services/index.ts | 1 + 6 files changed, 659 insertions(+), 3 deletions(-) create mode 100644 src/apps/review/src/lib/hooks/useFetchAiReviewEscalations.ts create mode 100644 src/apps/review/src/lib/services/aiReviewEscalation.service.ts diff --git a/src/apps/review/src/lib/components/TableReview/TableReview.module.scss b/src/apps/review/src/lib/components/TableReview/TableReview.module.scss index b4047dd32..1d50d02cc 100644 --- a/src/apps/review/src/lib/components/TableReview/TableReview.module.scss +++ b/src/apps/review/src/lib/components/TableReview/TableReview.module.scss @@ -236,3 +236,41 @@ } } } + +.escalationModal { + width: min(820px, 92vw); +} + +.escalationDescription { + margin-bottom: $sp-4; + line-height: 1.5; +} + +.verifySubmission { + margin-bottom: $sp-3; + font-weight: 700; +} + +.verifyDetails { + margin-bottom: $sp-4; + + > * + * { + margin-top: $sp-2; + } +} + +.escalationTextarea { + width: 100%; + min-height: 160px; + border: 1px solid $black-40; + border-radius: 4px; + padding: $sp-3; + resize: vertical; + margin-bottom: $sp-4; +} + +.escalationActions { + display: flex; + justify-content: flex-end; + gap: $sp-3; +} diff --git a/src/apps/review/src/lib/components/TableReview/TableReview.tsx b/src/apps/review/src/lib/components/TableReview/TableReview.tsx index b87d15922..f238c0294 100644 --- a/src/apps/review/src/lib/components/TableReview/TableReview.tsx +++ b/src/apps/review/src/lib/components/TableReview/TableReview.tsx @@ -10,15 +10,22 @@ import { Link } from 'react-router-dom' import { toast } from 'react-toastify' import _ from 'lodash' import classNames from 'classnames' +import { useSWRConfig } from 'swr' +import { FullConfiguration } from 'swr/dist/types' import { TableMobile } from '~/apps/admin/src/lib/components/common/TableMobile' import { IsRemovingType } from '~/apps/admin/src/lib/models' import { MobileTableColumn } from '~/apps/admin/src/lib/models/MobileTableColumn.model' import { handleError, useWindowSize, WindowSize } from '~/libs/shared' -import { IconOutline, Table, TableColumn } from '~/libs/ui' +import { BaseModal, IconOutline, Table, TableColumn } from '~/libs/ui' import { ChallengeDetailContext, ReviewAppContext } from '../../contexts' -import { useRole, useScorecardPassingScores, useSubmissionDownloadAccess } from '../../hooks' +import { + useFetchAiReviewEscalations, + useRole, + useScorecardPassingScores, + useSubmissionDownloadAccess, +} from '../../hooks' import type { useRoleProps } from '../../hooks/useRole' import { useSubmissionHistory } from '../../hooks/useSubmissionHistory' import type { UseSubmissionHistoryResult } from '../../hooks/useSubmissionHistory' @@ -45,7 +52,14 @@ import type { AggregatedSubmissionReviews, } from '../../utils' import { getSubmissionHistoryKey } from '../../utils/submissionHistory' -import { updateReview } from '../../services' +import { + AiReviewDecisionEscalation, + AiReviewEscalationDecision, + createAiReviewEscalation, + getAiReviewEscalationsCacheKey, + updateAiReviewEscalation, + updateReview, +} from '../../services' import { TableWrapper } from '../TableWrapper' import { SubmissionHistoryModal } from '../SubmissionHistoryModal' import { ConfirmModal } from '../ConfirmModal' @@ -105,6 +119,7 @@ export const TableReview: FC = (props: TableReviewProps) => { const { challengeInfo, reviewers, + resources, myResources, }: ChallengeDetailContextModel = useContext(ChallengeDetailContext) const { width: screenWidth }: WindowSize = useWindowSize() @@ -123,6 +138,7 @@ export const TableReview: FC = (props: TableReviewProps) => { restrictionMessage, }: UseSubmissionDownloadAccessResult = useSubmissionDownloadAccess() const { loginUserInfo }: ReviewAppContextModel = useContext(ReviewAppContext) + const { mutate }: FullConfiguration = useSWRConfig() const isTablet = useMemo(() => screenWidth <= 744, [screenWidth]) const reviewPhaseDatas = useMemo( @@ -302,8 +318,48 @@ export const TableReview: FC = (props: TableReviewProps) => { [aggregatedRows], ) + const { + decisions: escalationDecisions, + } = useFetchAiReviewEscalations({ + challengeId: challengeInfo?.id, + submissionLocked: true, + }) + + const escalationDecisionBySubmissionId = useMemo>( + () => new Map(escalationDecisions.map(decision => [decision.submissionId, decision])), + [escalationDecisions], + ) + + const handleByMemberId = useMemo>( + () => { + const map = new Map() + ;[ + ...resources, + ...reviewers, + ].forEach(resource => { + if (resource.memberId && resource.memberHandle) { + map.set(String(resource.memberId), resource.memberHandle) + } + }) + + return map + }, + [resources, reviewers], + ) + const [isReopening, setIsReopening] = useState(false) const [pendingReopen, setPendingReopen] = useState(undefined) + const [escalateTarget, setEscalateTarget] = useState(undefined) + const [unlockTarget, setUnlockTarget] = useState(undefined) + const [verifyTarget, setVerifyTarget] = useState<{ + submission: SubmissionReviewerRow + decision: AiReviewEscalationDecision + escalation: AiReviewDecisionEscalation + } | undefined>(undefined) + const [escalationNotes, setEscalationNotes] = useState('') + const [unlockNotes, setUnlockNotes] = useState('') + const [verifyNotes, setVerifyNotes] = useState('') + const [isEscalationSubmitting, setIsEscalationSubmitting] = useState(false) const openReopenDialog = useCallback((submission: SubmissionRow, review: AggregatedReviewDetail): void => { const resourceId = review.reviewInfo?.resourceId ?? review.resourceId @@ -320,6 +376,170 @@ export const TableReview: FC = (props: TableReviewProps) => { setPendingReopen(undefined) }, []) + const closeEscalateDialog = useCallback((): void => { + if (isEscalationSubmitting) { + return + } + setEscalateTarget(undefined) + setEscalationNotes('') + }, [isEscalationSubmitting]) + + const closeUnlockDialog = useCallback((): void => { + if (isEscalationSubmitting) { + return + } + setUnlockTarget(undefined) + setUnlockNotes('') + }, [isEscalationSubmitting]) + + const closeVerifyDialog = useCallback((): void => { + if (isEscalationSubmitting) { + return + } + setVerifyTarget(undefined) + setVerifyNotes('') + }, [isEscalationSubmitting]) + + const revalidateEscalationData = useCallback(async (): Promise => { + if (!challengeInfo?.id) { + return + } + + await mutate(getAiReviewEscalationsCacheKey({ + challengeId: challengeInfo.id, + submissionLocked: true, + })) + }, [challengeInfo?.id, mutate]) + + const handleSubmitEscalation = useCallback(async (): Promise => { + if (!escalateTarget?.id) { + return + } + + const decision = escalationDecisionBySubmissionId.get(escalateTarget.id) + if (!decision?.aiReviewDecisionId) { + toast.error('Unable to find AI review decision for this submission.') + return + } + + const notes = escalationNotes.trim() + if (!notes) { + toast.error('Escalation notes are required.') + return + } + + setIsEscalationSubmitting(true) + + try { + await createAiReviewEscalation(decision.aiReviewDecisionId, { + escalationNotes: notes, + }) + toast.success('Escalation request submitted.') + closeEscalateDialog() + if (challengeInfo?.id) { + await refreshChallengeReviewData(challengeInfo.id) + } + await revalidateEscalationData() + } catch (error) { + handleError(error) + } finally { + setIsEscalationSubmitting(false) + } + }, [ + escalateTarget?.id, + escalationDecisionBySubmissionId, + escalationNotes, + closeEscalateDialog, + challengeInfo?.id, + revalidateEscalationData, + ]) + + const handleSubmitUnlock = useCallback(async (): Promise => { + if (!unlockTarget?.id) { + return + } + + const decision = escalationDecisionBySubmissionId.get(unlockTarget.id) + if (!decision?.aiReviewDecisionId) { + toast.error('Unable to find AI review decision for this submission.') + return + } + + const notes = unlockNotes.trim() + if (!notes) { + toast.error('Reason is required to unlock this submission.') + return + } + + setIsEscalationSubmitting(true) + + try { + await createAiReviewEscalation(decision.aiReviewDecisionId, { + approverNotes: notes, + }) + toast.success('Submission unlocked successfully.') + closeUnlockDialog() + if (challengeInfo?.id) { + await refreshChallengeReviewData(challengeInfo.id) + } + await revalidateEscalationData() + } catch (error) { + handleError(error) + } finally { + setIsEscalationSubmitting(false) + } + }, [ + unlockTarget?.id, + escalationDecisionBySubmissionId, + unlockNotes, + closeUnlockDialog, + challengeInfo?.id, + revalidateEscalationData, + ]) + + const handleVerifyEscalation = useCallback(async ( + status: 'APPROVED' | 'REJECTED', + ): Promise => { + if (!verifyTarget?.decision.aiReviewDecisionId || !verifyTarget.escalation.id) { + return + } + + const notes = verifyNotes.trim() + if (!notes) { + toast.error('Reason is required to approve or reject this request.') + return + } + + setIsEscalationSubmitting(true) + + try { + await updateAiReviewEscalation( + verifyTarget.decision.aiReviewDecisionId, + verifyTarget.escalation.id, + { + approverNotes: notes, + status, + }, + ) + toast.success(status === 'APPROVED' ? 'Escalation approved.' : 'Escalation rejected.') + closeVerifyDialog() + if (challengeInfo?.id) { + await refreshChallengeReviewData(challengeInfo.id) + } + await revalidateEscalationData() + } catch (error) { + handleError(error) + } finally { + setIsEscalationSubmitting(false) + } + }, [ + verifyTarget, + verifyNotes, + closeVerifyDialog, + challengeInfo?.id, + revalidateEscalationData, + ]) + const handleConfirmReopen = useCallback(async (): Promise => { const reviewId = pendingReopen?.review?.reviewInfo?.id if (!reviewId) { @@ -562,7 +782,113 @@ export const TableReview: FC = (props: TableReviewProps) => { ) } + const buildEscalateAction = (): JSX.Element | undefined => { + const isLocked = submission.status === 'AI_FAILED_REVIEW' + if (!isLocked || !submission.id) { + return undefined + } + + const decision = escalationDecisionBySubmissionId.get(submission.id) + if (!decision?.submissionLocked) { + return undefined + } + + const isReviewerOnly = (hasReviewRole || isCopilotWithReviewerAssignments) + && !canManageCompletedReviews + if (!isReviewerOnly || !isReviewPhase(challengeInfo)) { + return undefined + } + + const hasOwnEscalation = decision.escalations.some(escalation => ( + String(escalation.createdBy ?? '') === String(loginUserInfo?.userId ?? '') + )) + if (hasOwnEscalation) { + return undefined + } + + return ( + + ) + } + + const buildVerifyAction = (): JSX.Element | undefined => { + const isLocked = submission.status === 'AI_FAILED_REVIEW' + if (!isLocked || !submission.id || !canManageCompletedReviews) { + return undefined + } + + const decision = escalationDecisionBySubmissionId.get(submission.id) + if (!decision?.submissionLocked) { + return undefined + } + + const pendingEscalation = decision.escalations.find(escalation => ( + escalation.status === 'PENDING_APPROVAL' + )) + + if (!pendingEscalation) { + return undefined + } + + return ( + + ) + } + + const buildUnlockAction = (): JSX.Element | undefined => { + const isLocked = submission.status === 'AI_FAILED_REVIEW' + if (!isLocked || !submission.id || !canManageCompletedReviews) { + return undefined + } + + const decision = escalationDecisionBySubmissionId.get(submission.id) + if (!decision?.submissionLocked) { + return undefined + } + + return ( + + ) + } + appendAction(buildPrimaryAction(), 'primary') + appendAction(buildEscalateAction(), 'escalate') + appendAction(buildVerifyAction(), 'verify') + appendAction(buildUnlockAction(), 'unlock') appendAction(buildHistoryAction(), 'history') appendAction(buildReopenAction(), 'reopen') @@ -596,11 +922,13 @@ export const TableReview: FC = (props: TableReviewProps) => { canManageCompletedReviews, canViewHistory, challengeInfo, + escalationDecisionBySubmissionId, handleHistoryButtonClick, hasReviewRole, historyByMember, isCopilotWithReviewerAssignments, isReopening, + loginUserInfo?.userId, myReviewerResourceIds, openReopenDialog, pendingReopen, @@ -853,6 +1181,142 @@ export const TableReview: FC = (props: TableReviewProps) => { /> )} + +
+ Escalate this submission to the copilot. Add your reason below why you think + the submission should pass the AI Review. +
+
Member Handle LocationSkillsPrincipal Skills {' '}