diff --git a/.circleci/config.yml b/.circleci/config.yml index 717a8915d..d52fc3db0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -203,6 +203,8 @@ workflows: ignore: - master - qa + tags: + only: /^dev-.*/ - build-qa: context: org-global @@ -226,6 +228,9 @@ workflows: branches: only: - dev + - hide_ba_details + tags: + only: /^dev-.*/ - deployQa: context: org-global diff --git a/.github/workflows/code_reviewer.yml b/.github/workflows/code_reviewer.yml deleted file mode 100644 index 02f198a18..000000000 --- a/.github/workflows/code_reviewer.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: AI PR Reviewer - -on: - pull_request: - types: - - opened - - synchronize -permissions: - pull-requests: write -jobs: - tc-ai-pr-review: - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v3 - - - name: TC AI PR Reviewer - uses: topcoder-platform/tc-ai-pr-reviewer@master - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The GITHUB_TOKEN is there by default so you just need to keep it like it is and not necessarily need to add it as secret as it will throw an error. [More Details](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#about-the-github_token-secret) - LAB45_API_KEY: ${{ secrets.LAB45_API_KEY }} - exclude: '**/*.json, **/*.md, **/*.jpg, **/*.png, **/*.jpeg, **/*.bmp, **/*.webp' # Optional: exclude patterns separated by commas diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/craco.config.js b/craco.config.js index bdc7fa39f..2472daed2 100644 --- a/craco.config.js +++ b/craco.config.js @@ -4,6 +4,59 @@ const CracoEnvPlugin = require('craco-plugin-env') const BabelRcPlugin = require('@jackwilsdon/craco-use-babelrc'); const isProd = process.env.APPMODE === "production"; +const nodeModulesWatchIgnore = '**/node_modules/**'; +const watchPollInterval = 1000; + +/** + * Appends the node_modules ignore pattern to an existing watch ignore config. + * + * @param {RegExp|string|Array|undefined} ignored - Existing ignore config from webpack or dev server watches. + * @returns {Array} Watch ignore config with node_modules excluded. Used by CRACO watch overrides. + * @throws This function does not throw. + */ +function withNodeModulesWatchIgnore(ignored) { + return [ + ...(Array.isArray(ignored) ? ignored : ignored ? [ignored] : []), + nodeModulesWatchIgnore, + ]; +} + +/** + * Preserves CRA's dev-server static config while disabling public asset watches. + * + * @param {object} devServerConfig - CRA webpack-dev-server config passed through CRACO for yarn start. + * @returns {object} Dev-server config that serves public assets without consuming file watchers for them. + * @throws This function does not throw. + */ +function configureDevServer(devServerConfig) { + const staticConfig = devServerConfig.static || {}; + + return { + ...devServerConfig, + static: { + ...staticConfig, + watch: false, + }, + }; +} + +/** + * Preserves CRA's webpack config while polling watch mode and ignoring node_modules. + * + * @param {object} webpackConfig - CRA webpack config passed through CRACO. + * @returns {object} Webpack config with polling enabled and node_modules excluded from watchOptions. + * @throws This function does not throw. + */ +function configureWebpack(webpackConfig) { + return { + ...webpackConfig, + watchOptions: { + ...webpackConfig.watchOptions, + ignored: withNodeModulesWatchIgnore(webpackConfig.watchOptions?.ignored), + poll: watchPollInterval, + }, + }; +} function getModeName() { const index = process.argv.indexOf('--mode'); @@ -33,6 +86,8 @@ module.exports = { } }, ], + devServer: configureDevServer, + webpack: { alias: { // aliases used in JS/TS @@ -41,7 +96,6 @@ module.exports = { '@learn': resolve('src/apps/learn/src'), '@devCenter': resolve('src/apps/dev-center/src'), '@gamificationAdmin': resolve('src/apps/gamification-admin/src'), - '@talentSearch': resolve('src/apps/talent-search/src'), '@profiles': resolve('src/apps/profiles/src'), '@wallet': resolve('src/apps/wallet/src'), '@walletAdmin': resolve('src/apps/wallet-admin/src'), @@ -51,5 +105,6 @@ module.exports = { // aliases used in SCSS files '@libs/ui/styles': resolve('src/libs/ui/lib/styles'), }, + configure: configureWebpack, } } diff --git a/package.json b/package.json index 8824556b9..30a3d3a35 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "report:coverage": "nyc report --reporter=html", "report:coverage:text": "nyc report --reporter=text", "sb": "storybook dev -p 6006", - "sb:build": "storybook build -o build/storybook" + "sb:build": "storybook build -o build/storybook", + "deploy:dev": "BRANCH=$(git rev-parse --abbrev-ref HEAD) && TAG=\"dev-${BRANCH}\" && git tag -d \"$TAG\" 2>/dev/null; git push origin \":refs/tags/$TAG\" 2>/dev/null; git tag \"$TAG\" && git push origin \"$TAG\"" }, "dependencies": { "@codemirror/autocomplete": "^6.20.1", @@ -259,5 +260,6 @@ "volta": { "node": "22.13.0", "yarn": "1.22.22" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/apps/admin/src/admin-app.routes.tsx b/src/apps/admin/src/admin-app.routes.tsx index de637b87f..1d6da0bd2 100644 --- a/src/apps/admin/src/admin-app.routes.tsx +++ b/src/apps/admin/src/admin-app.routes.tsx @@ -7,6 +7,9 @@ import { } from '~/libs/core' import { + aiReviewTemplatesRouteId, + aiReviewWorkflowsRouteId, + aiRouteId, billingAccountRouteId, defaultReviewersRouteId, gamificationAdminRouteId, @@ -173,6 +176,16 @@ const PaymentsPage: LazyLoadedComponent = lazyLoad( 'PaymentsPage', ) +const Ai: LazyLoadedComponent = lazyLoad(() => import('./ai/Ai')) +const AiReviewWorkflowsPage: LazyLoadedComponent = lazyLoad( + () => import('./ai/review-workflows/AiReviewWorkflowsPage'), + 'AiReviewWorkflowsPage', +) +const AiReviewTemplatesPage: LazyLoadedComponent = lazyLoad( + () => import('./ai/review-templates/AiReviewTemplatesPage'), + 'AiReviewTemplatesPage', +) + export const toolTitle: string = ToolTitle.admin export const adminRoutes: ReadonlyArray = [ @@ -418,6 +431,25 @@ export const adminRoutes: ReadonlyArray = [ rolesRequired: administratorOnlyRoles, route: paymentsRouteId, }, + // AI Module + { + children: [ + { + element: , + id: 'ai-review-workflows-page', + route: aiReviewWorkflowsRouteId, + }, + { + element: , + id: 'ai-review-templates-page', + route: aiReviewTemplatesRouteId, + }, + ], + element: , + id: aiRouteId, + rolesRequired: administratorOnlyRoles, + route: aiRouteId, + }, ], domain: AppSubdomain.admin, element: , diff --git a/src/apps/admin/src/ai/Ai.tsx b/src/apps/admin/src/ai/Ai.tsx new file mode 100644 index 000000000..572523285 --- /dev/null +++ b/src/apps/admin/src/ai/Ai.tsx @@ -0,0 +1,31 @@ +import { FC, useContext, useMemo } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' + +import { adminRoutes } from '../admin-app.routes' +import { aiRouteId } from '../config/routes.config' + +export const Ai: FC = () => { + const childRoutes = useChildRoutes() + + return ( + <> + + {childRoutes} + + ) +} + +function useChildRoutes(): Array | undefined { + const { getRouteElement }: RouterContextData = useContext(routerContext) + const childRoutes = useMemo( + () => adminRoutes[0].children + ?.find(r => r.id === aiRouteId) + ?.children?.map(getRouteElement), + [], // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: getRouteElement + ) + return childRoutes +} + +export default Ai diff --git a/src/apps/admin/src/ai/index.ts b/src/apps/admin/src/ai/index.ts new file mode 100644 index 000000000..7f855005e --- /dev/null +++ b/src/apps/admin/src/ai/index.ts @@ -0,0 +1 @@ +export { Ai } from './Ai' diff --git a/src/apps/admin/src/ai/review-templates/AiReviewTemplatesPage.module.scss b/src/apps/admin/src/ai/review-templates/AiReviewTemplatesPage.module.scss new file mode 100644 index 000000000..286cb8362 --- /dev/null +++ b/src/apps/admin/src/ai/review-templates/AiReviewTemplatesPage.module.scss @@ -0,0 +1,342 @@ +@import '@libs/ui/styles/includes'; +@import '../../lib/styles/includes'; + +.filtersWrapper { + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-4; + padding: $sp-4; + background-color: $black-5; + border-bottom: 1px solid $black-20; +} + +.filters { + display: flex; + align-items: center; + gap: $sp-4; + flex-wrap: nowrap; + + > *:not(button) { + min-width: 200px; + } +} + +@media (max-width: #{$mobile-max}) { + .filtersWrapper { + flex-direction: column; + align-items: stretch; + } + + .filters { + flex-direction: column; + align-items: stretch; + + > * { + min-width: auto; + } + } +} + +.templatesList { + display: flex; + flex-direction: column; + gap: $sp-3; + margin-bottom: $sp-4; +} + +.templateItem { + border: 1px solid $black-20; + border-radius: $sp-2; + overflow: hidden; +} + +.templateHeader { + display: flex; + flex-direction: column; + gap: $sp-2; + padding: $sp-3 $sp-4; + background-color: $black-5; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: $black-10; + } +} + +.templateHeaderTop { + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-3; +} + +.templateHeaderLeft { + display: flex; + align-items: center; + gap: $sp-2; + flex: 1; + min-width: 0; +} + +.templateHeaderRight { + display: flex; + align-items: center; + gap: $sp-2; + flex-shrink: 0; +} + +.editButton, +.deleteButton { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 50%; + background-color: transparent; + color: $black-40; + cursor: pointer; + transition: all 0.2s ease; + + svg { + width: 16px; + height: 16px; + } +} + +.editButton:hover { + background-color: rgba($blue-110, 0.1); + color: $blue-110; +} + +.deleteButton:hover { + background-color: rgba($red-100, 0.1); + color: $red-100; +} + +.templateDescription { + font-size: 13px; + color: $black-60; + padding-left: 20px; + line-height: 1.4; +} + +.templateMetas { + display: flex; + align-items: center; + gap: $sp-2; + padding-left: 20px; +} + +.expandIcon { + font-size: 10px; + color: $black-60; + width: 12px; + flex-shrink: 0; +} + +.templateName { + font-weight: 600; + font-size: 15px; + color: $black-100; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.templateMeta { + display: inline-flex; + align-items: center; + gap: $sp-1; + font-size: 12px; + font-weight: 500; + color: $purple-120; + background: linear-gradient(135deg, rgba($purple-25, 0.6), rgba($purple-25, 0.3)); + border: 1px solid rgba($purple-100, 0.2); + padding: 4px $sp-3; + border-radius: 16px; + white-space: nowrap; + + &::before { + content: '📂'; + font-size: 11px; + } +} + +.modeBadge { + display: inline-flex; + align-items: center; + gap: $sp-1; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: $tc-white; + background: linear-gradient(135deg, $turq-140, $turq-160); + padding: 4px $sp-3; + border-radius: 16px; + white-space: nowrap; + box-shadow: 0 1px 3px rgba($turq-160, 0.3); + + &::before { + content: '⚙️'; + font-size: 10px; + } +} + +.thresholdBadge { + display: inline-flex; + align-items: center; + gap: $sp-1; + font-size: 12px; + font-weight: 600; + color: $green-140; + background: linear-gradient(135deg, rgba($green-25, 0.8), rgba($green-25, 0.4)); + border: 1px solid rgba($green-100, 0.25); + padding: 4px $sp-3; + border-radius: 16px; + white-space: nowrap; + + &::before { + content: '✓'; + font-size: 11px; + font-weight: 700; + } +} + +.workflowCount { + font-size: 12px; + color: $black-60; + white-space: nowrap; +} + +.disabledBadge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + color: $tc-white; + background-color: $red-100; + padding: 4px $sp-3; + border-radius: 16px; +} + +.toggleWrapper { + display: flex; + align-items: center; + gap: $sp-2; + cursor: pointer; + padding: 4px $sp-2; + border-radius: $sp-1; + transition: background-color 0.2s ease; + + &:hover { + background-color: rgba($black-100, 0.05); + } +} + +.toggleLabel { + font-size: 12px; + font-weight: 500; + color: $black-60; +} + +.workflowsTree { + padding: $sp-3 $sp-4; + background-color: $tc-white; + border-top: 1px solid $black-10; +} + +.workflowItem { + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-3; + padding: $sp-3; + margin-left: $sp-4; + cursor: pointer; + border-radius: $sp-1; + border: 1px solid transparent; + transition: all 0.2s ease; + + &:hover { + background-color: $black-5; + border-color: $black-20; + } + + &:not(:last-child) { + margin-bottom: $sp-2; + } +} + +.workflowLeft { + display: flex; + align-items: center; + gap: $sp-2; + flex: 1; + min-width: 0; +} + +.workflowRight { + display: flex; + align-items: center; + gap: $sp-3; + flex-shrink: 0; +} + +.treeLine { + font-family: monospace; + color: $black-40; + font-size: 14px; + flex-shrink: 0; +} + +.workflowNameWrap { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.workflowName { + font-size: 14px; + color: $link-blue-dark; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + text-decoration: underline; + } +} + +.workflowNameDisabled { + font-size: 14px; + color: $black-40; + text-decoration: line-through; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.workflowHint { + font-size: 11px; + color: $black-40; + font-style: italic; +} + +.workflowWeight { + font-size: 13px; + font-weight: 600; + color: $black-80; +} + +.workflowGating { + font-size: 12px; + color: $orange-140; +} + + diff --git a/src/apps/admin/src/ai/review-templates/AiReviewTemplatesPage.tsx b/src/apps/admin/src/ai/review-templates/AiReviewTemplatesPage.tsx new file mode 100644 index 000000000..a6e73fc83 --- /dev/null +++ b/src/apps/admin/src/ai/review-templates/AiReviewTemplatesPage.tsx @@ -0,0 +1,519 @@ +import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' +import { toast } from 'react-toastify' +import _ from 'lodash' + +import { BaseModal, Button, FormToggleSwitch, IconOutline, InputSelect, InputSelectOption } from '~/libs/ui' + +import { ConfirmModal, PageWrapper, TableLoading, TableNoRecord } from '../../lib' +import { TableWrapper } from '../../lib/components/common/TableWrapper' +import { ChallengeTrack, ChallengeType } from '../../lib/models' +import { getChallengeTracks, getChallengeTypes } from '../../lib/services/challenge-management.service' +import { AiWorkflow } from '../../lib/services/ai-workflows.service' +import { + AiReviewTemplate, + AiReviewTemplatesFilter, + deleteAiReviewTemplate, + getAiReviewTemplates, + TemplateWorkflowItem, + updateAiReviewTemplate, +} from '../../lib/services/ai-templates.service' +import { WorkflowDetailsModal } from '../review-workflows/WorkflowDetailsModal' + +import { CreateTemplateModal } from './CreateTemplateModal' +import styles from './AiReviewTemplatesPage.module.scss' + +interface WorkflowItemProps { + item: TemplateWorkflowItem + onClick: (workflow: AiWorkflow) => void +} + +const WorkflowItem: FC = (props: WorkflowItemProps) => { + const workflow: AiWorkflow = props.item.workflow + + const handleClick = useCallback(() => { + props.onClick(workflow) + }, [props, workflow]) + + return ( +
+
+ └─ +
+ + {workflow.name} + + {workflow.disabled && ( + This workflow is disabled + )} +
+
+
+ {props.item.isGating && ( + Gating + )} + + {props.item.weightPercent} + % + +
+
+ ) +} + +interface TemplateItemProps { + template: AiReviewTemplate + onWorkflowClick: (workflow: AiWorkflow) => void + onEdit: (template: AiReviewTemplate) => void + onDelete: (template: AiReviewTemplate) => void + onToggleDisabled: (template: AiReviewTemplate) => void +} + +const TemplateItem: FC = (props: TemplateItemProps) => { + const [expanded, setExpanded] = useState(true) + const workflows = props.template.workflows || [] + + const handleToggleExpand = useCallback(() => { + setExpanded(prev => !prev) + }, []) + + const handleEditClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + props.onEdit(props.template) + }, [props]) + + const handleDeleteClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + props.onDelete(props.template) + }, [props]) + + const handleToggleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + props.onToggleDisabled(props.template) + }, [props]) + + return ( +
+
+
+
+ {expanded ? '▼' : '▶'} + + {props.template.title || props.template.id} + +
+
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, + jsx-a11y/no-static-element-interactions */} +
+ Active + +
+ + +
+
+ {props.template.description && ( +
+ {props.template.description} +
+ )} +
+ + {props.template.challengeTrack} + {' / '} + {props.template.challengeType} + + {props.template.mode} + + Pass: + {' '} + {props.template.minPassingThreshold} + % + +
+
+ {expanded && workflows.length > 0 && ( +
+ {workflows.map(item => ( + + ))} +
+ )} +
+ ) +} + +export const AiReviewTemplatesPage: FC = () => { + const [isLoading, setIsLoading] = useState(true) + const [templates, setTemplates] = useState([]) + const [tracks, setTracks] = useState([]) + const [types, setTypes] = useState([]) + const [filter, setFilter] = useState({}) + const [detailModal, setDetailModal] = useState<{ open: boolean; workflow?: AiWorkflow }>({ + open: false, + }) + const [createModalOpen, setCreateModalOpen] = useState(false) + const [editTemplate, setEditTemplate] = useState(undefined) + const [deleteModal, setDeleteModal] = useState<{ open: boolean; template?: AiReviewTemplate }>({ + open: false, + }) + const [isDeleting, setIsDeleting] = useState(false) + const [toggleModal, setToggleModal] = useState<{ open: boolean; template?: AiReviewTemplate }>({ + open: false, + }) + const [isToggling, setIsToggling] = useState(false) + const [disabledWorkflowsModal, setDisabledWorkflowsModal] = useState<{ + open: boolean; + workflowNames: string[]; + }>({ open: false, workflowNames: [] }) + + const trackOptions: InputSelectOption[] = useMemo(() => { + const seen = new Set() + const options: InputSelectOption[] = [{ label: 'All Tracks', value: '' }] + for (const t of tracks) { + const trackValue: string = (t as ChallengeTrack & { track?: string }).track || t.name.toUpperCase() + if (!seen.has(trackValue)) { + seen.add(trackValue) + options.push({ label: t.name, value: trackValue }) + } + } + + return options + }, [tracks]) + + const typeOptions: InputSelectOption[] = useMemo(() => { + const seen = new Set() + const options: InputSelectOption[] = [{ label: 'All Types', value: '' }] + for (const t of types) { + if (!seen.has(t.name)) { + seen.add(t.name) + options.push({ label: t.name, value: t.name }) + } + } + + return options + }, [types]) + + useEffect(() => { + getChallengeTracks() + .then(setTracks) + .catch(() => setTracks([])) + getChallengeTypes() + .then(setTypes) + .catch(() => setTypes([])) + }, []) + + const loadTemplates = useCallback((filterParams: AiReviewTemplatesFilter) => { + setIsLoading(true) + getAiReviewTemplates(filterParams) + .then(data => { + setTemplates(data || []) + }) + .catch(() => { + setTemplates([]) + }) + .finally(() => setIsLoading(false)) + }, []) + + useEffect(() => { + loadTemplates(filter) + }, [filter, loadTemplates]) + + const handleTrackChange = useCallback((event: ChangeEvent) => { + setFilter(prev => ({ ...prev, challengeTrack: event.target.value || undefined })) + }, []) + + const handleTypeChange = useCallback((event: ChangeEvent) => { + setFilter(prev => ({ ...prev, challengeType: event.target.value || undefined })) + }, []) + + const handleReset = useCallback(() => { + setFilter({}) + }, []) + + const handleWorkflowClick = useCallback((workflow: AiWorkflow) => { + setDetailModal({ open: true, workflow }) + }, []) + + const handleCloseDetail = useCallback(() => { + setDetailModal({ open: false }) + }, []) + + const handleOpenCreateModal = useCallback(() => { + setEditTemplate(undefined) + setCreateModalOpen(true) + }, []) + + const handleCloseCreateModal = useCallback(() => { + setCreateModalOpen(false) + setEditTemplate(undefined) + }, []) + + const handleEditClick = useCallback((template: AiReviewTemplate) => { + setEditTemplate(template) + setCreateModalOpen(true) + }, []) + + const handleTemplateCreated = useCallback(() => { + loadTemplates(filter) + }, [filter, loadTemplates]) + + const handleDeleteClick = useCallback((template: AiReviewTemplate) => { + setDeleteModal({ open: true, template }) + }, []) + + const handleCloseDeleteModal = useCallback(() => { + setDeleteModal({ open: false }) + }, []) + + const handleConfirmDelete = useCallback(async () => { + if (!deleteModal.template) return + setIsDeleting(true) + try { + await deleteAiReviewTemplate(deleteModal.template.id) + setDeleteModal({ open: false }) + loadTemplates(filter) + } catch { + // Error handling can be added here + } finally { + setIsDeleting(false) + } + }, [deleteModal.template, filter, loadTemplates]) + + const handleToggleClick = useCallback((template: AiReviewTemplate) => { + // When trying to activate a disabled template, check for disabled workflows + if (template.disabled) { + const disabledWorkflows = (template.workflows || []) + .filter(item => item.workflow.disabled) + .map(item => item.workflow.name) + + if (disabledWorkflows.length > 0) { + setDisabledWorkflowsModal({ open: true, workflowNames: disabledWorkflows }) + + return + } + } + + setToggleModal({ open: true, template }) + }, []) + + const handleCloseToggleModal = useCallback(() => { + setToggleModal({ open: false }) + }, []) + + const handleCloseDisabledWorkflowsModal = useCallback(() => { + setDisabledWorkflowsModal({ open: false, workflowNames: [] }) + }, []) + + const handleConfirmToggle = useCallback(async () => { + if (!toggleModal.template) return + setIsToggling(true) + const newDisabledState = !toggleModal.template.disabled + try { + await updateAiReviewTemplate(toggleModal.template.id, { + disabled: newDisabledState, + }) + setTemplates(prev => prev.map(t => ( + t.id === toggleModal.template?.id + ? { ...t, disabled: newDisabledState } + : t + ))) + toast.success(`Template ${newDisabledState ? 'deactivated' : 'activated'} successfully`) + setToggleModal({ open: false }) + } catch (error) { + toast.error('Failed to update template') + } finally { + setIsToggling(false) + } + }, [toggleModal.template]) + + const hasFilters: boolean = !!filter.challengeTrack || !!filter.challengeType + + return ( + +
+
+ + + {hasFilters && ( +
+
+ + + + {isLoading && } + + {!isLoading && templates.length === 0 && ( + + )} + + {!isLoading && templates.length > 0 && ( +
+ {templates.map(template => ( + + ))} +
+ )} +
+ + + + + + + + + + {(errors.workflows?.message || errors.workflows?.root?.message) && ( +

+ {(errors.workflows?.message || errors.workflows?.root?.message) as string} +

+ )} + + {fields.map((field, index) => ( +
+ + }) { + const currentValue: string = controlProps.field.value + const currentOption: InputSelectOption | undefined = workflowOptions + .find(o => o.value === currentValue) + const options: InputSelectOption[] = currentOption + ? [currentOption, ...availableWorkflowOptions] + : availableWorkflowOptions + + return ( + + ) + }} + /> + + + + + }) { + return ( +
+ + Gating +
+ ) + }} + /> + + +
+ ))} + + {fields.length === 0 && ( +

No workflows added yet.

+ )} + + +
+ + +
+ + {isSubmitting && ( +
+ +
+ )} + + )} +
+ ) +} + +export default CreateTemplateModal diff --git a/src/apps/admin/src/ai/review-templates/index.ts b/src/apps/admin/src/ai/review-templates/index.ts new file mode 100644 index 000000000..7a9332eb1 --- /dev/null +++ b/src/apps/admin/src/ai/review-templates/index.ts @@ -0,0 +1 @@ +export { AiReviewTemplatesPage } from './AiReviewTemplatesPage' diff --git a/src/apps/admin/src/ai/review-workflows/AiReviewWorkflowsPage.module.scss b/src/apps/admin/src/ai/review-workflows/AiReviewWorkflowsPage.module.scss new file mode 100644 index 000000000..10418ab0e --- /dev/null +++ b/src/apps/admin/src/ai/review-workflows/AiReviewWorkflowsPage.module.scss @@ -0,0 +1,123 @@ +@import '@libs/ui/styles/includes'; + +.table { + margin-bottom: $sp-4; + + tbody tr:nth-child(odd) { + background-color: $black-5; + } +} + +.mobileTable { + width: 100%; + border-collapse: collapse; + margin-bottom: $sp-4; + + tbody tr { + td { + padding: $sp-2 $sp-3 !important; + border-radius: 0 !important; + background-color: $tc-white !important; + vertical-align: middle; + + &:first-child { + font-weight: 600; + font-size: 11px; + text-transform: uppercase; + color: $black-60; + white-space: nowrap; + width: 100px; + min-width: 100px; + max-width: 100px; + } + + &:last-child { + word-break: break-all; + overflow-wrap: anywhere; + text-align: right; + } + } + + // 5 fields per workflow, alternate every other workflow + &:nth-child(10n+1) td, + &:nth-child(10n+2) td, + &:nth-child(10n+3) td, + &:nth-child(10n+4) td, + &:nth-child(10n+5) td { + background-color: $black-5 !important; + } + } + + @media (max-width: 400px) { + tbody tr td { + padding: $sp-1 $sp-2 !important; + font-size: 13px; + + &:first-child { + font-size: 10px; + width: 85px; + min-width: 85px; + max-width: 85px; + } + } + } +} + +.link { + color: $link-blue-dark; + font-size: 14px; + font-weight: 400; + text-decoration: none; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} + +.cellText { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 250px; + + @media (max-width: 1050px) { + white-space: normal; + word-break: break-all; + overflow-wrap: anywhere; + max-width: none; + } +} + +.nameCell { + word-break: break-word; + white-space: normal; +} + +.nameLink { + background: none; + border: none; + padding: 0; + color: $link-blue-dark; + font-size: 14px; + font-weight: 400; + text-decoration: none; + cursor: pointer; + text-align: left; + word-break: break-word; + white-space: normal; + + &:hover { + text-decoration: underline; + } + + @media (max-width: 1050px) { + text-align: right; + display: block; + width: 100%; + } +} + +.toggle { + cursor: pointer; +} diff --git a/src/apps/admin/src/ai/review-workflows/AiReviewWorkflowsPage.tsx b/src/apps/admin/src/ai/review-workflows/AiReviewWorkflowsPage.tsx new file mode 100644 index 000000000..4e50753f1 --- /dev/null +++ b/src/apps/admin/src/ai/review-workflows/AiReviewWorkflowsPage.tsx @@ -0,0 +1,273 @@ +import { FC, useCallback, useEffect, useMemo, useState } from 'react' +import { toast } from 'react-toastify' + +import { EnvironmentConfig } from '~/config' +import { useWindowSize, WindowSize } from '~/libs/shared' +import { Table, TableColumn } from '~/libs/ui' +import FormToggleSwitch from '~/libs/ui/lib/components/form/form-groups/form-toggle-switch' + +import { ConfirmModal, PageWrapper, TableLoading, TableNoRecord } from '../../lib' +import { TableMobile } from '../../lib/components/common/TableMobile' +import { TableWrapper } from '../../lib/components/common/TableWrapper' +import { MobileTableColumn } from '../../lib/models' +import { AiWorkflow, getAiWorkflows, updateAiWorkflow } from '../../lib/services/ai-workflows.service' + +import { WorkflowDetailsModal } from './WorkflowDetailsModal' +import styles from './AiReviewWorkflowsPage.module.scss' + +function stopPropagation(e: React.MouseEvent): void { + e.stopPropagation() +} + +export const AiReviewWorkflowsPage: FC = () => { + const [isLoading, setIsLoading] = useState(true) + const [isUpdating, setIsUpdating] = useState(false) + const [workflows, setWorkflows] = useState([]) + const [confirmModal, setConfirmModal] = useState<{ open: boolean; workflow?: AiWorkflow }>({ + open: false, + }) + const [detailModal, setDetailModal] = useState<{ open: boolean; workflow?: AiWorkflow }>({ + open: false, + }) + const { width: screenWidth }: WindowSize = useWindowSize() + const isMobile = useMemo(() => screenWidth <= 1050, [screenWidth]) + + useEffect(() => { + setIsLoading(true) + getAiWorkflows() + .then(setWorkflows) + .finally(() => setIsLoading(false)) + }, []) + + const handleToggleClick = useCallback((workflow: AiWorkflow) => { + setConfirmModal({ open: true, workflow }) + }, []) + + const handleCloseConfirm = useCallback(() => { + setConfirmModal({ open: false }) + }, []) + + const handleNameClick = useCallback((workflow: AiWorkflow) => { + setDetailModal({ open: true, workflow }) + }, []) + + const handleCloseDetail = useCallback(() => { + setDetailModal({ open: false }) + }, []) + + const handleConfirmToggle = useCallback(async () => { + if (!confirmModal.workflow) { + return + } + + const newDisabledState = !confirmModal.workflow.disabled + + setIsUpdating(true) + try { + await updateAiWorkflow(confirmModal.workflow.id, { + disabled: newDisabledState, + }) + setWorkflows(prev => prev.map(w => ( + w.id === confirmModal.workflow?.id + ? { ...w, disabled: newDisabledState } + : w + ))) + toast.success(`Workflow ${newDisabledState ? 'deactivated' : 'activated'} successfully`) + } catch (error) { + toast.error('Failed to update workflow') + } finally { + setIsUpdating(false) + setConfirmModal({ open: false }) + } + }, [confirmModal.workflow]) + + const columns = useMemo[]>(() => [ + { + label: 'ID', + propertyName: 'id', + renderer: (data: AiWorkflow) => ( +
{data.id}
+ ), + type: 'element', + }, + { + defaultSortDirection: 'asc', + label: 'Active', + propertyName: 'disabled', + renderer: (data: AiWorkflow) => { + function onToggleChange(): void { + handleToggleClick(data) + } + + return ( +
+ +
+ ) + }, + type: 'element', + }, + { + defaultSortDirection: 'asc', + isDefaultSort: true, + label: 'Name', + propertyName: 'name', + renderer: (data: AiWorkflow) => { + function onNameClick(e: React.MouseEvent): void { + e.stopPropagation() + handleNameClick(data) + } + + return ( + + ) + }, + type: 'element', + }, + { + defaultSortDirection: 'asc', + label: 'Provider/LLM', + propertyName: 'llm.provider.name', + renderer: (data: AiWorkflow) => ( +
+ {data.llm?.provider?.name} + / + {data.llm?.name} +
+ ), + type: 'element', + }, + { + defaultSortDirection: 'asc', + label: 'Scorecard', + propertyName: 'scorecard.name', + renderer: (data: AiWorkflow) => { + if (!data.scorecard?.id) { + return {data.scorecard?.name || 'N/A'} + } + + return ( + + {data.scorecard.name} + + ) + }, + type: 'element', + }, + ], []) + + const columnsMobile = useMemo[][]>( + () => columns.map(column => { + if (column.label === 'Active') { + return [ + { + ...column, + className: '', + label: 'Active label', + mobileType: 'label', + renderer: () =>
Active:
, + type: 'element', + }, + { + ...column, + mobileType: 'last-value', + }, + ] + } + + return [ + { + ...column, + className: '', + label: `${column.label as string} label`, + mobileType: 'label', + renderer: () => ( +
+ {column.label as string} + : +
+ ), + type: 'element', + }, + { + ...column, + mobileType: 'last-value', + }, + ] + }), + [columns], + ) + + return ( + + {isLoading ? ( + + ) : ( + <> + {workflows.length === 0 ? ( + + ) : ( + + {isMobile ? ( + + ) : ( + + )} + + )} + + )} + +

+ Are you sure you want to + {' '} + {confirmModal.workflow?.disabled ? 'activate' : 'deactivate'} + {' '} + the workflow + {' '} + {confirmModal.workflow?.name} + ? +

+
+ + + ) +} + +export default AiReviewWorkflowsPage diff --git a/src/apps/admin/src/ai/review-workflows/WorkflowDetailsModal.module.scss b/src/apps/admin/src/ai/review-workflows/WorkflowDetailsModal.module.scss new file mode 100644 index 000000000..e984792c5 --- /dev/null +++ b/src/apps/admin/src/ai/review-workflows/WorkflowDetailsModal.module.scss @@ -0,0 +1,180 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + gap: $sp-6; +} + +.section { + display: flex; + flex-direction: column; + gap: $sp-3; +} + +.sectionTitle { + font-size: 14px; + font-weight: 700; + color: $black-100; + margin: 0; + padding-bottom: $sp-2; + border-bottom: 1px solid $black-20; +} + +.grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: $sp-3; + + @include ltemd { + grid-template-columns: 1fr; + } +} + +.field { + display: flex; + flex-direction: column; + gap: $sp-1; +} + +.label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: $black-60; +} + +.value { + font-size: 14px; + color: $black-100; + word-break: break-word; +} + +.link { + color: $link-blue-dark; + text-decoration: none; + font-size: 14px; + + &:hover { + text-decoration: underline; + } +} + +.descriptionField { + display: flex; + flex-direction: column; + gap: $sp-2; + margin-top: $sp-3; +} + +.descriptionValue { + font-size: 14px; + color: $black-100; + background-color: $black-5; + border-radius: $sp-2; + padding: $sp-4; + max-height: 300px; + overflow-y: auto; + + a { + color: $link-blue-dark; + text-decoration: none; + font-size: 14px; + + &:hover { + text-decoration: underline; + } + } + + // Markdown normalization + h1, h2, h3, h4, h5, h6 { + margin: $sp-3 0 $sp-2; + font-weight: 700; + + &:first-child { + margin-top: 0; + } + } + + h1 { font-size: 18px; } + h2 { font-size: 16px; } + h3 { font-size: 15px; } + h4, h5, h6 { font-size: 14px; } + + p { + margin: 0 0 $sp-2; + + &:last-child { + margin-bottom: 0; + } + } + + ul, ol { + margin: 0 0 $sp-2; + padding-left: $sp-5; + } + + li { + margin-bottom: $sp-1; + } + + table { + width: 100%; + border-collapse: collapse; + margin: $sp-2 0; + font-size: 13px; + + th, td { + border: 1px solid $black-20; + padding: $sp-2; + text-align: left; + } + + th { + background-color: $black-10; + font-weight: 600; + } + } + + code { + background-color: $black-10; + padding: 2px $sp-1; + border-radius: 3px; + font-family: monospace; + font-size: 13px; + } + + pre { + background-color: $black-10; + padding: $sp-3; + border-radius: $sp-1; + overflow-x: auto; + margin: $sp-2 0; + + code { + background: none; + padding: 0; + } + } + + blockquote { + border-left: 3px solid $black-40; + margin: $sp-2 0; + padding-left: $sp-3; + color: $black-80; + } + + hr { + border: none; + border-top: 1px solid $black-20; + margin: $sp-3 0; + } + + strong { + font-weight: 700; + } + + em { + font-style: italic; + } +} diff --git a/src/apps/admin/src/ai/review-workflows/WorkflowDetailsModal.tsx b/src/apps/admin/src/ai/review-workflows/WorkflowDetailsModal.tsx new file mode 100644 index 000000000..bc0bba7f8 --- /dev/null +++ b/src/apps/admin/src/ai/review-workflows/WorkflowDetailsModal.tsx @@ -0,0 +1,217 @@ +import { FC } from 'react' + +import { EnvironmentConfig } from '~/config' +import { BaseModal, Button } from '~/libs/ui' +import { MarkdownReview } from '~/apps/review/src/lib/components/MarkdownReview' + +import { AiWorkflow } from '../../lib/services/ai-workflows.service' + +import styles from './WorkflowDetailsModal.module.scss' + +interface SectionProps { + workflow: AiWorkflow +} + +const GeneralSection: FC = (props: SectionProps) => ( +
+

General

+
+
+ ID + {props.workflow.id} +
+
+ Name + {props.workflow.name} +
+
+ Status + + {props.workflow.disabled ? 'Inactive' : 'Active'} + +
+
+ Definition URL + + {props.workflow.defUrl ? ( + + {props.workflow.defUrl} + + ) : 'N/A'} + +
+
+ Git Workflow ID + {props.workflow.gitWorkflowId || 'N/A'} +
+
+ Git Owner/Repo + {props.workflow.gitOwnerRepo || 'N/A'} +
+
+
+ Description +
+ {props.workflow.description ? ( + + ) : 'N/A'} +
+
+
+) + +const LlmSection: FC = (props: SectionProps) => ( +
+

LLM Configuration

+
+
+ Provider + {props.workflow.llm?.provider?.name || 'N/A'} +
+
+ Model + {props.workflow.llm?.name || 'N/A'} +
+
+ LLM URL + + {props.workflow.llm?.url ? ( + + {props.workflow.llm.url} + + ) : 'N/A'} + +
+
+
+) + +const ScorecardSection: FC = (props: SectionProps) => ( +
+

Scorecard

+
+
+ Name + + {props.workflow.scorecard?.id ? ( + + {props.workflow.scorecard.name} + + ) : (props.workflow.scorecard?.name || 'N/A')} + +
+
+ Type + {props.workflow.scorecard?.type || 'N/A'} +
+
+ Status + {props.workflow.scorecard?.status || 'N/A'} +
+
+
+) + +interface MetadataSectionProps extends SectionProps { + createdAt: string + updatedAt: string +} + +const MetadataSection: FC = (props: MetadataSectionProps) => ( +
+

Metadata

+
+
+ Created At + {props.createdAt} +
+
+ Created By + {props.workflow.createdBy || 'N/A'} +
+
+ Updated At + {props.updatedAt} +
+
+ Updated By + {props.workflow.updatedBy || 'N/A'} +
+
+
+) + +interface WorkflowDetailsContentProps { + workflow: AiWorkflow + open: boolean + onClose: () => void +} + +const WorkflowDetailsContent: FC = ( + props: WorkflowDetailsContentProps, +) => { + const createdAtFormatted: string = props.workflow.createdAt + ? new Date(props.workflow.createdAt) + .toLocaleString() + : 'N/A' + + const updatedAtFormatted: string = props.workflow.updatedAt + ? new Date(props.workflow.updatedAt) + .toLocaleString() + : 'N/A' + + return ( + + )} + > +
+ + + + +
+
+ ) +} + +interface WorkflowDetailsModalProps { + workflow?: AiWorkflow + open: boolean + onClose: () => void +} + +export const WorkflowDetailsModal: FC = ( + props: WorkflowDetailsModalProps, +) => { + if (!props.workflow) { + return <> + } + + return ( + + ) +} diff --git a/src/apps/admin/src/ai/review-workflows/index.ts b/src/apps/admin/src/ai/review-workflows/index.ts new file mode 100644 index 000000000..7a149d4ff --- /dev/null +++ b/src/apps/admin/src/ai/review-workflows/index.ts @@ -0,0 +1 @@ +export { AiReviewWorkflowsPage } from './AiReviewWorkflowsPage' diff --git a/src/apps/admin/src/config/routes.config.ts b/src/apps/admin/src/config/routes.config.ts index a2ebf2790..2940f32cb 100644 --- a/src/apps/admin/src/config/routes.config.ts +++ b/src/apps/admin/src/config/routes.config.ts @@ -18,3 +18,6 @@ export const termsRouteId = 'terms' export const defaultReviewersRouteId = 'default-reviewers' export const platformRouteId = 'platform' export const paymentsRouteId = 'payments' +export const aiRouteId = 'ai' +export const aiReviewWorkflowsRouteId = 'review-workflows' +export const aiReviewTemplatesRouteId = 'review-templates' diff --git a/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.module.scss b/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.module.scss index 3291b7c1d..548a0d707 100644 --- a/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.module.scss +++ b/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.module.scss @@ -11,11 +11,13 @@ border-radius: 0 0 $sp-1 $sp-1; padding: $sp-2 0; max-height: 320px; - overflow: auto; + overflow-y: auto; + overflow-x: hidden; + min-width: max-content; :global { ul > li { text-align: left; - + white-space: nowrap; font-weight: normal; color: $black-100; padding: $sp-2 $sp-4; 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 1aa72a376..dff10303a 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 @@ -3,6 +3,9 @@ import _ from 'lodash' import { TabsNavItem } from '~/libs/ui' import { isAdministrator } from '~/apps/admin/src/lib/utils/access' import { + aiReviewTemplatesRouteId, + aiReviewWorkflowsRouteId, + aiRouteId, billingAccountRouteId, defaultReviewersRouteId, gamificationAdminRouteId, @@ -83,6 +86,20 @@ export const SystemAdminTabsConfig: TabsNavItem[] = [ id: paymentsRouteId, title: 'Payments', }, + { + children: [ + { + id: `${aiRouteId}/${aiReviewWorkflowsRouteId}`, + title: 'AI Review Workflows', + }, + { + id: `${aiRouteId}/${aiReviewTemplatesRouteId}`, + title: 'AI Review Templates', + }, + ], + id: aiRouteId, + title: 'AI', + }, ] /** diff --git a/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx b/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx index ee63a4a90..e032bfd96 100644 --- a/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx +++ b/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx @@ -15,6 +15,7 @@ interface Props { readonly columns: ReadonlyArray[]> readonly data: ReadonlyArray className?: string + readonly rowClassName?: (data: T) => string | undefined } function getKey(key: (string | number)[]): string { @@ -33,10 +34,13 @@ export const TableMobile: ( {props.columns.map((itemColumns, indexColumns) => (
{itemColumns.map( (itemItemColumns, indexItemColumns) => ( diff --git a/src/apps/admin/src/lib/services/ai-templates.service.ts b/src/apps/admin/src/lib/services/ai-templates.service.ts new file mode 100644 index 000000000..7c19e24af --- /dev/null +++ b/src/apps/admin/src/lib/services/ai-templates.service.ts @@ -0,0 +1,100 @@ +import { EnvironmentConfig } from '~/config' +import { xhrDeleteAsync, xhrGetAsync, xhrPostAsync, xhrPutAsync } from '~/libs/core' + +import { AiWorkflow } from './ai-workflows.service' + +export interface TemplateWorkflowItem { + id: string + workflowId: string + weightPercent: number + isGating: boolean + workflow: AiWorkflow +} + +export interface AiReviewTemplate { + id: string + title: string + description: string + challengeTrack: string + challengeType: string + version: number + minPassingThreshold: number + mode: string + autoFinalize: boolean + disabled: boolean + createdAt: string + updatedAt: string + workflows: TemplateWorkflowItem[] +} + +export interface AiReviewTemplatesFilter { + challengeTrack?: string + challengeType?: string +} + +export async function getAiReviewTemplates( + filter?: AiReviewTemplatesFilter, +): Promise { + const params = new URLSearchParams() + + if (filter?.challengeTrack) { + params.append('challengeTrack', filter.challengeTrack) + } + + if (filter?.challengeType) { + params.append('challengeType', filter.challengeType) + } + + const query: string = params.toString() + const url: string = `${EnvironmentConfig.API.V6}/ai-review/templates${query ? `?${query}` : ''}` + const response = await xhrGetAsync(url) + return response +} + +export interface CreateTemplateWorkflow { + workflowId: string + weightPercent: number + isGating: boolean +} + +export interface CreateAiReviewTemplateRequest { + challengeTrack: string + challengeType: string + title: string + description: string + minPassingThreshold: number + mode: string + autoFinalize: boolean + formula?: Record + disabled: boolean + workflows: CreateTemplateWorkflow[] +} + +export async function createAiReviewTemplate( + data: CreateAiReviewTemplateRequest, +): Promise { + const response = await xhrPostAsync( + `${EnvironmentConfig.API.V6}/ai-review/templates`, + data, + ) + return response +} + +export async function deleteAiReviewTemplate(id: string): Promise { + await xhrDeleteAsync(`${EnvironmentConfig.API.V6}/ai-review/templates/${id}`) +} + +export type UpdateAiReviewTemplateRequest = Partial< + Omit +> + +export async function updateAiReviewTemplate( + id: string, + data: UpdateAiReviewTemplateRequest, +): Promise { + const response = await xhrPutAsync( + `${EnvironmentConfig.API.V6}/ai-review/templates/${id}`, + data, + ) + return response +} diff --git a/src/apps/admin/src/lib/services/ai-workflows.service.ts b/src/apps/admin/src/lib/services/ai-workflows.service.ts index b960cf7cb..200006844 100644 --- a/src/apps/admin/src/lib/services/ai-workflows.service.ts +++ b/src/apps/admin/src/lib/services/ai-workflows.service.ts @@ -1,12 +1,73 @@ import { EnvironmentConfig } from '~/config' -import { xhrGetAsync } from '~/libs/core' +import { xhrGetAsync, xhrPatchAsync } from '~/libs/core' + +export interface LlmProvider { + id: string; + name: string; + createdAt: string; + createdBy: string; +} + +export interface Llm { + id: string; + providerId: string; + name: string; + description: string; + icon: string; + url: string; + createdAt: string; + createdBy: string; + provider: LlmProvider; +} + +export interface Scorecard { + id: string; + legacyId: string; + status: string; + type: string; + challengeTrack: string; + challengeType: string; + name: string; + version: string; + minScore: number; + minimumPassingScore: number; + maxScore: number; + createdAt: string; + createdBy: string; + updatedAt: string; + updatedBy: string; +} export interface AiWorkflow { id: string; name: string; + llmId: string; + description: string; + defUrl: string; + gitWorkflowId: string; + gitOwnerRepo: string; + scorecardId: string; + disabled: boolean; + createdAt: string; + createdBy: string; + updatedAt: string; + updatedBy: string; + llm: Llm; + scorecard: Scorecard; } export async function getAiWorkflows(): Promise { const response = await xhrGetAsync(`${EnvironmentConfig.API.V6}/workflows`) return response } + +export async function updateAiWorkflow( + id: string, + data: Partial>, +): Promise { + const response = await xhrPatchAsync>, AiWorkflow>( + `${EnvironmentConfig.API.V6}/workflows/${id}`, + data, + ) + return response +} diff --git a/src/apps/admin/src/platform/gamification-admin/src/game-lib/member-autocomplete/InputHandleAutocomplete.tsx b/src/apps/admin/src/platform/gamification-admin/src/game-lib/member-autocomplete/InputHandleAutocomplete.tsx index f0182f640..8ec042b09 100644 --- a/src/apps/admin/src/platform/gamification-admin/src/game-lib/member-autocomplete/InputHandleAutocomplete.tsx +++ b/src/apps/admin/src/platform/gamification-admin/src/game-lib/member-autocomplete/InputHandleAutocomplete.tsx @@ -81,7 +81,7 @@ const InputHandleAutocomplete: FC = (props: InputH hideInlineErrors={props.hideInlineErrors} type='text' > - className={styles.memberSelect} cacheOptions getOptionLabel={getUserProp('handle')} diff --git a/src/apps/admin/src/platform/skill-management/lib/components/skill-modals/SkillModal.tsx b/src/apps/admin/src/platform/skill-management/lib/components/skill-modals/SkillModal.tsx index 816d37863..271a4dca0 100644 --- a/src/apps/admin/src/platform/skill-management/lib/components/skill-modals/SkillModal.tsx +++ b/src/apps/admin/src/platform/skill-management/lib/components/skill-modals/SkillModal.tsx @@ -91,7 +91,7 @@ const SkillModal: FC = props => { setIsLoading(true) // eslint-disable-next-line unicorn/no-null - return restoreArchivedStandardizedSkill({ ...props.skill, deleted_at: null }) + return restoreArchivedStandardizedSkill({ ...props.skill, deletedAt: null }) .then(() => { refetchSkills() setEditSkill() diff --git a/src/apps/admin/src/platform/skill-management/lib/context/skills-manager.context.tsx b/src/apps/admin/src/platform/skill-management/lib/context/skills-manager.context.tsx index 180262d3d..d47e9a620 100644 --- a/src/apps/admin/src/platform/skill-management/lib/context/skills-manager.context.tsx +++ b/src/apps/admin/src/platform/skill-management/lib/context/skills-manager.context.tsx @@ -48,7 +48,7 @@ export const SkillsManagerContext: FC = props => { }: SWRResponse = useFetchCategories() const filteredSkills = useMemo(() => ( - showArchivedSkills ? allSkills : allSkills.filter(s => !s.deleted_at) + showArchivedSkills ? allSkills : allSkills.filter(s => !s.deletedAt) ), [allSkills, showArchivedSkills]) const skills = useMemo(() => findSkillsMatches(filteredSkills, skillsFilter), [filteredSkills, skillsFilter]) diff --git a/src/apps/admin/src/platform/skill-management/lib/lib/skills.utils.ts b/src/apps/admin/src/platform/skill-management/lib/lib/skills.utils.ts index be0f2a43f..ed0b7fe7a 100644 --- a/src/apps/admin/src/platform/skill-management/lib/lib/skills.utils.ts +++ b/src/apps/admin/src/platform/skill-management/lib/lib/skills.utils.ts @@ -9,7 +9,7 @@ export interface GroupedSkills { } export const isSkillArchived = (skill: StandardizedSkill): boolean => ( - !!skill.deleted_at + !!skill.deletedAt ) export const groupSkillsByCategory = (skills: StandardizedSkill[]): GroupedSkills => { diff --git a/src/apps/admin/src/platform/skill-management/lib/services/skills.service.ts b/src/apps/admin/src/platform/skill-management/lib/services/skills.service.ts index f506942a8..78089f763 100644 --- a/src/apps/admin/src/platform/skill-management/lib/services/skills.service.ts +++ b/src/apps/admin/src/platform/skill-management/lib/services/skills.service.ts @@ -8,7 +8,7 @@ import { UserSkill, xhrDeleteAsync, xhrGetAsync, xhrPostAsync, xhrPutAsync } fro const baseUrl = `${EnvironmentConfig.STANDARDIZED_SKILLS_API}/skills` export interface StandardizedSkill extends UserSkill { - deleted_at: string | null + deletedAt: string | null categoryId?: string } diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss b/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss index 1e82fa9ca..456661485 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/styles.module.scss @@ -15,11 +15,17 @@ margin-top: $sp-2; padding: $sp-6 0; color: $teal-100; + text-align: left; + word-break: break-word; + line-height: 1.2; + } @media (max-width: 767px) { .header { - line-height: 48px; + font-size: 32px; + line-height: 38px; + } } @@ -89,4 +95,5 @@ .textCaps { text-transform: capitalize; -} \ No newline at end of file +} + diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/OpportunityDetails.tsx b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/OpportunityDetails.tsx index b3e3e4013..d65edc7f1 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/OpportunityDetails.tsx +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/OpportunityDetails.tsx @@ -15,9 +15,9 @@ const OpportunityDetails: FC<{

Description

{props.opportunity?.overview && ( -
'), - }} +
)}
diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/styles.module.scss b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/styles.module.scss index aad91a31d..2cdf48c40 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/styles.module.scss +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/styles.module.scss @@ -40,4 +40,68 @@ border-radius: 10px; white-space: nowrap; font-size: 14px; +} + +.overviewContent { + font-size: 14px; + line-height: 22px; + font-family: 'Roboto', Arial, Helvetica, sans-serif; + + p { + margin: 0 0 8px 0; + } + + strong, b { + font-weight: 700; + } + + em, i { + font-style: italic; + } + + u { + text-decoration: underline; + } + + s { + text-decoration: line-through; + } + + a { + color: #0d61bf; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } + + ul, ol { + margin: 0 0 8px 0; + padding-left: 24px; + } + + ul { + list-style-type: disc; + } + + ol { + list-style-type: decimal; + } + + table { + border-collapse: collapse; + width: 100%; + margin: 0 0 8px 0; + + td, th { + border: 1px solid #d4d4d4; + padding: 8px 12px; + } + + th { + background-color: #f5f5f5; + font-weight: 700; + } + } } \ No newline at end of file diff --git a/src/apps/copilots/src/pages/copilot-request-form/index.tsx b/src/apps/copilots/src/pages/copilot-request-form/index.tsx index 5ca6cb9cf..0e71834f4 100644 --- a/src/apps/copilots/src/pages/copilot-request-form/index.tsx +++ b/src/apps/copilots/src/pages/copilot-request-form/index.tsx @@ -1,13 +1,15 @@ -import { FC, useContext, useEffect, useMemo, useState } from 'react' +import { FC, useContext, useEffect, useMemo, useRef, useState } from 'react' import { bind, debounce, isEmpty, pick } from 'lodash' +import { mutate } from 'swr' import { toast } from 'react-toastify' import { Params, useNavigate, useParams, useSearchParams } from 'react-router-dom' import classNames from 'classnames' +import { EnvironmentConfig } from '~/config' import { profileContext, ProfileContextData } from '~/libs/core' import { Button, IconSolid, InputDatePicker, InputMultiselectOption, - InputRadio, InputSelect, InputSelectReact, InputText, InputTextarea } from '~/libs/ui' -import { extractSkillsFromText, InputSkillSelector } from '~/libs/shared' + InputRadio, InputSelect, InputSelectReact, InputText } from '~/libs/ui' +import { extractSkillsFromText, FieldHtmlEditor, InputSkillSelector } from '~/libs/shared' import { getProject, getProjects, ProjectsResponse, useProjects } from '../../services/projects' import { ProjectTypes, ProjectTypeValues } from '../../constants' @@ -35,12 +37,13 @@ const editableFields = [ 'tzRestrictions', 'numHoursPerWeek', ] - // eslint-disable-next-line const CopilotRequestForm: FC<{}> = () => { const { profile }: ProfileContextData = useContext(profileContext) const navigate = useNavigate() const routeParams: Params = useParams() + const requestUrl = routeParams.requestId + ? `${EnvironmentConfig.API.V6}/projects/copilots/requests/${routeParams.requestId}` : undefined const [params] = useSearchParams() const [formValues, setFormValues] = useState({}) @@ -194,6 +197,18 @@ const CopilotRequestForm: FC<{}> = () => { setIsFormChanged(true) } + const overviewInitialized = useRef(false) + function handleOverviewChange(content: string): void { + overviewInitialized.current = true + setFormValues((prev: any) => ({ ...prev, overview: content })) + setFormErrors((prev: any) => { + const updated = { ...prev } + delete updated.overview + return updated + }) + setIsFormChanged(true) + } + function handleSkillsChange(ev: any): void { const options = (ev.target.value as unknown) as InputMultiselectOption[] const updatedSkills = options.map(v => ({ @@ -265,8 +280,15 @@ const CopilotRequestForm: FC<{}> = () => { } // Check if overview has enough content for AI processing + function stripHtml(html: string): string { + const doc = new DOMParser() + .parseFromString(html, 'text/html') + return doc.body.textContent || '' + } + const canGenerateSkills = useMemo(() => { - const overview = formValues.overview?.trim() || '' + const overview = stripHtml(formValues.overview || '') + .trim() return overview.length >= MIN_OVERVIEW_LENGTH && !isGeneratingSkills }, [formValues.overview, isGeneratingSkills]) @@ -289,7 +311,8 @@ const CopilotRequestForm: FC<{}> = () => { { condition: !formValues.paymentType, key: 'paymentType', message: 'Selection is required' }, { condition: !formValues.projectType, key: 'projectType', message: 'Selecting project type is required' }, { - condition: !formValues.overview || formValues.overview.trim().length < 10, + condition: stripHtml(formValues.overview || '') + .trim().length < 10, key: 'overview', message: 'Project overview must be at least 10 characters', }, @@ -365,6 +388,10 @@ const CopilotRequestForm: FC<{}> = () => { copilotRequestData ? 'Copilot request updated successfully' : 'Copilot request sent successfully', ) + if (requestUrl) { + mutate(requestUrl) + } + setFormValues({ complexity: '', numHoursPerWeek: '', @@ -381,6 +408,7 @@ const CopilotRequestForm: FC<{}> = () => { setIsFormChanged(false) setFormErrors({}) setPaymentType('') + overviewInitialized.current = false // Added a small timeout for the toast to be visible properly to the users setTimeout(() => { navigate(`${rootRoute}/requests`) @@ -404,7 +432,13 @@ const CopilotRequestForm: FC<{}> = () => { handleProjectSearch(inputValue) .then(callback) }, 300), []) - + const editorKey = useMemo( + () => (copilotRequestData?.id ?? 'new'), + [copilotRequestData?.id], + ) + useEffect(() => { + overviewInitialized.current = false + }, [copilotRequestData]) return (
@@ -529,13 +563,14 @@ const CopilotRequestForm: FC<{}> = () => {

Please provide an overview of the project the copilot will undertake

- @@ -555,7 +590,8 @@ const CopilotRequestForm: FC<{}> = () => {
{!canGenerateSkills && formValues.overview - && formValues.overview.trim().length < MIN_OVERVIEW_LENGTH + && stripHtml(formValues.overview) + .trim().length < MIN_OVERVIEW_LENGTH && (

Add at least diff --git a/src/apps/copilots/src/pages/copilot-request-form/styles.module.scss b/src/apps/copilots/src/pages/copilot-request-form/styles.module.scss index 5eeef5331..4f9e74c29 100644 --- a/src/apps/copilots/src/pages/copilot-request-form/styles.module.scss +++ b/src/apps/copilots/src/pages/copilot-request-form/styles.module.scss @@ -190,3 +190,20 @@ $gradient: linear-gradient( color: black; } } +.richTextWrapper { + border: 1px solid #aaaaaa; + border-radius: 0.375rem; + overflow: hidden; + margin-bottom: 0.5rem; + + &:focus-within { + border-color: #137D60; + } +} + +.richTextError { + border: 2px solid $red-100; + border-radius: 0.375rem; + overflow: hidden; + margin-bottom: 0.5rem; +} \ No newline at end of file diff --git a/src/apps/copilots/src/pages/copilot-requests/copilot-request-modal/CopilotRequestModal.tsx b/src/apps/copilots/src/pages/copilot-requests/copilot-request-modal/CopilotRequestModal.tsx index 202f61924..371c65bc4 100644 --- a/src/apps/copilots/src/pages/copilot-requests/copilot-request-modal/CopilotRequestModal.tsx +++ b/src/apps/copilots/src/pages/copilot-requests/copilot-request-modal/CopilotRequestModal.tsx @@ -114,7 +114,10 @@ const CopilotRequestModal: FC = props => {

Overview
-
{props.request.overview}
+
Skills
diff --git a/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts b/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts new file mode 100644 index 000000000..f0674b639 --- /dev/null +++ b/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts @@ -0,0 +1,142 @@ +import { EnvironmentConfig } from '~/config' +import { UserSkill, xhrGetAsync } from '~/libs/core' + +export type CompletedProfile = { + countryCode?: string + countryName?: string + city?: string + firstName?: string + handle: string + lastName?: string + photoURL?: string + skillCount?: number + userId?: number | string + isOpenToWork?: boolean | null + openToWork?: { + availability?: string + preferredRoles?: string[] + } | null +} + +export type CompletedProfilesResponse = { + data: CompletedProfile[] + page: number + perPage: number + total: number + totalPages: number +} + +export const DEFAULT_PAGE_SIZE = 50 + +function normalizeToList(raw: any): any[] { + if (Array.isArray(raw)) { + return raw + } + + if (Array.isArray(raw?.data)) { + return raw.data + } + + if (Array.isArray(raw?.result?.content)) { + return raw.result.content + } + + if (Array.isArray(raw?.result)) { + return raw.result + } + + return [] +} + +function normalizeCompletedProfilesResponse( + raw: any, + fallbackPage: number, + fallbackPerPage: number, +): CompletedProfilesResponse { + if (raw && Array.isArray(raw.data)) { + const total: number = Number(raw.total ?? raw.data.length) + const perPage: number = Number(raw.perPage ?? fallbackPerPage) + const page: number = Number(raw.page ?? fallbackPage) + const safePerPage = Number.isFinite(perPage) ? Math.max(perPage, 1) : fallbackPerPage + const safeTotal = Number.isFinite(total) ? Math.max(total, 0) : raw.data.length + + return { + data: raw.data, + page: Number.isFinite(page) ? Math.max(page, 1) : fallbackPage, + perPage: safePerPage, + total: safeTotal, + totalPages: Number.isFinite(raw.totalPages) + ? Math.max(Number(raw.totalPages), 1) + : Math.max(Math.ceil(safeTotal / safePerPage), 1), + } + } + + const rows = normalizeToList(raw) + const total = Number(raw?.total ?? rows.length) + const safeTotal = Number.isFinite(total) ? Math.max(total, 0) : rows.length + + return { + data: rows, + page: fallbackPage, + perPage: fallbackPerPage, + total: safeTotal, + totalPages: Math.max(Math.ceil(safeTotal / fallbackPerPage), 1), + } +} + +export type OpenToWorkFilter = 'all' | 'yes' | 'no' + +export async function fetchCompletedProfiles( + countryCode: string | undefined, + page: number, + perPage: number, + openToWorkFilter?: OpenToWorkFilter, + skillIds?: string[], +): Promise { + const queryParams = new URLSearchParams({ + page: String(page), + perPage: String(perPage), + }) + + if (countryCode) { + queryParams.set('countryCode', countryCode) + } + + if (openToWorkFilter === 'yes') { + queryParams.set('openToWork', 'true') + } + + if (openToWorkFilter === 'no') { + queryParams.set('openToWork', 'false') + } + + if (Array.isArray(skillIds) && skillIds.length > 0) { + skillIds.forEach(id => { + if (id) { + queryParams.append('skillId', String(id)) + } + }) + } + + const response = await xhrGetAsync( + `${EnvironmentConfig.REPORTS_API}/topcoder/completed-profiles?${queryParams.toString()}`, + ) + + return normalizeCompletedProfilesResponse(response, page, perPage) +} + +export async function fetchMemberSkillsData(userId: string | number | undefined): Promise { + if (!userId) { + return [] + } + + const baseUrl = `${EnvironmentConfig.API.V5}/standardized-skills` + const url = `${baseUrl}/user-skills/${userId}?disablePagination=true` + + try { + return await xhrGetAsync(url) + } catch { + // If skills API fails, return empty array to not block the page + return [] + } +} diff --git a/src/apps/customer-portal/src/lib/services/talentSearch.service.ts b/src/apps/customer-portal/src/lib/services/talentSearch.service.ts index 718bcae6c..9eb9a94c3 100644 --- a/src/apps/customer-portal/src/lib/services/talentSearch.service.ts +++ b/src/apps/customer-portal/src/lib/services/talentSearch.service.ts @@ -25,6 +25,8 @@ export type MemberSearchPayload = { limit: number openToWork?: boolean page: number + preferredRoles?: string[] + profileComplete?: boolean recentlyActive?: boolean sortBy?: 'handle' | 'matchIndex' sortOrder?: 'asc' | 'desc' diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.module.scss b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.module.scss index 5a6dd85e6..22116c969 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.module.scss +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.module.scss @@ -3,17 +3,20 @@ .container { display: flex; flex-direction: column; - gap: $sp-4; } :global([class*='ContentLayout-module_content-outer']) { - margin: 24px auto 0 !important; + margin: 0 auto 0 !important; } :global([class*='ContentLayout-module_content__']) { padding-bottom: 0 !important; } +:global([class*='BreadCrumb-module_breadcrumb']) { + display: none; +} + .pageArea { position: relative; @include substractPagePaddings; @@ -32,9 +35,9 @@ .pageBody { display: grid; grid-template-columns: 443px 1fr; - gap: 40px; + gap: 24px; margin-top: -232px; - padding: $sp-4 $sp-12 $sp-14 $sp-12; + padding: $sp-2 $sp-8 $sp-10 $sp-8; position: relative; z-index: 1; font-family: $font-roboto; @@ -62,17 +65,17 @@ background: $tc-white; border: 0; border-radius: 16px; - padding: 32px; + padding: 20px; display: flex; flex-direction: column; - gap: $sp-3; + gap: $sp-2; } .sidebar .panel + .panel { border-top-left-radius: 0; border-top-right-radius: 0; border-top: 0; - padding-top: 20px; + padding-top: 14px; } .panelTitle { @@ -107,9 +110,9 @@ } .jobDescriptionField { - margin-top: 0.5rem; + margin-top: 0; :global(textarea) { - min-height: 442px; + min-height: 170px; resize: vertical; color: $black-100; font-size: 14px; @@ -119,7 +122,7 @@ .aiActions { display: flex; - gap: $sp-2; + gap: $sp-1; :global(button) { font-size: 14px; @@ -134,6 +137,7 @@ } .filterBlock { + margin-bottom: 2px; :global([class*='__value-container']) { min-height: 18px; } @@ -175,8 +179,8 @@ align-items: center; gap: $sp-2; color: $black-100; - font-size: 16px; - line-height: 24px; + font-size: 15px; + line-height: 21px; font-weight: 400; position: relative; cursor: pointer; @@ -242,8 +246,10 @@ } .clearFiltersWrap { - margin-top: 10px; + margin-top: 6px; align-self: flex-start; + display: flex; + gap: $sp-2; :global(button) { font-size: 14px; @@ -280,7 +286,7 @@ align-items: center; justify-content: space-between; gap: $sp-3; - padding-top: 8px; + padding-top: 0; @include ltemd { flex-direction: column; diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index b62c33dfa..0fb8ed9a2 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -1,6 +1,6 @@ /* eslint-disable complexity */ /* eslint-disable react/jsx-no-bind */ -import { ChangeEvent, FC, FocusEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ChangeEvent, FC, FocusEvent, useCallback, useMemo, useRef, useState } from 'react' import classNames from 'classnames' import { CountryLookup, useCountryLookup } from '~/libs/core' @@ -9,12 +9,11 @@ import { IconOutline, InputMultiselect, InputMultiselectOption, - InputSelect, - InputSelectOption, InputTextarea, Tooltip, } from '~/libs/ui' import { extractSkillsFromText, fetchSkillAutocompleteOptions } from '~/libs/shared' +import { preferredRoleOptions } from '~/libs/shared/lib/components/modify-open-to-work-modal/ModifyOpenToWorkModal' import { TalentResultCard } from '../components/TalentResultCard' import { @@ -24,33 +23,32 @@ import { searchMembers, SearchTalent, } from '../../../lib' -import personSearchImage from '../../../lib/assets/person-search.png' import styles from './TalentSearchPage.module.scss' -type TalentSearchSortOption = 'alphabetical' | 'matching-index' - export const TalentSearchPage: FC = () => { - const skipNextAutoSearchRef = useRef(false) - const searchGenerationRef = useRef(0) // ← add this + const searchGenerationRef = useRef(0) const [lastSearchedDescription, setLastSearchedDescription] = useState('') const countryLookup: CountryLookup[] | undefined = useCountryLookup() const [jobDescription, setJobDescription] = useState('') const [isExtractingSkills, setIsExtractingSkills] = useState(false) const [errorMessage, setErrorMessage] = useState('') - const [hasSearched, setHasSearched] = useState(true) + const [hasSearched, setHasSearched] = useState(false) const [skillOptionsLoading, setSkillOptionsLoading] = useState(false) const [selectedSkills, setSelectedSkills] = useState([]) - const [sortBy, setSortBy] = useState('alphabetical') const [selectedCountries, setSelectedCountries] = useState([]) + const [selectedPreferredRoles, setSelectedPreferredRoles] = useState([]) + const [onlyProfileComplete, setOnlyProfileComplete] = useState(false) const [onlyOpenToWork, setOnlyOpenToWork] = useState(false) const [onlyActive, setOnlyActive] = useState(false) - const [isSearchingMembers, setIsSearchingMembers] = useState(true) + const [isSearchingMembers, setIsSearchingMembers] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false) const [results, setResults] = useState([]) const [totalResults, setTotalResults] = useState(0) const [currentPage, setCurrentPage] = useState(1) + const [lastAppliedSearchSignature, setLastAppliedSearchSignature] = useState('') + const [showSkillMatchOnCards, setShowSkillMatchOnCards] = useState(false) const countryNameByCode = useMemo((): Map => new Map( (countryLookup || []) .filter(country => country.countryCode && country.country) @@ -75,35 +73,44 @@ export const TalentSearchPage: FC = () => { .filter(Boolean), [selectedCountries], ) - - const hasSkillSearch = selectedSkills.length > 0 - const hasActiveFilters = hasSkillSearch - || selectedCountries.length > 0 - || onlyOpenToWork - || onlyActive - const shouldShowIntroState = !hasSearched && !hasActiveFilters - const activeSort: TalentSearchSortOption = hasSkillSearch ? 'matching-index' : sortBy - const sortOptions = useMemo( - (): InputSelectOption[] => (hasSkillSearch - ? [{ label: 'Matching Index', value: 'matching-index' }] - : [{ label: 'Alphabetical', value: 'alphabetical' }]), - [hasSkillSearch], + const selectedPreferredRoleValues = useMemo( + (): string[] => selectedPreferredRoles + .map(role => String(role.value || '') + .trim() + .toUpperCase()) + .filter(Boolean) + .sort(), + [selectedPreferredRoles], ) - const filteredResults = useMemo(() => results.filter(talent => { - if (onlyActive && !talent.isRecentlyActive) { - return false - } - - if (onlyOpenToWork && !talent.openToWork) { - return false - } - - return true - }), [onlyActive, onlyOpenToWork, results]) + const shouldShowIntroState = !hasSearched + const currentSearchSignature = useMemo( + (): string => JSON.stringify({ + countries: selectedCountryCodesList + .slice() + .sort(), + openToWork: onlyOpenToWork, + preferredRoles: selectedPreferredRoleValues, + profileComplete: onlyProfileComplete, + recentlyActive: onlyActive, + skills: selectedSkills + .map(skill => String(skill.value || '') + .trim()) + .filter(Boolean) + .sort(), + }), + [ + onlyActive, + onlyOpenToWork, + onlyProfileComplete, + selectedCountryCodesList, + selectedPreferredRoleValues, + selectedSkills, + ], + ) // Order comes from reports-api (sortBy/sortOrder on each request) so pagination stays globally consistent. - const displayedResults = filteredResults + const displayedResults = results const foundMembersCount = totalResults || displayedResults.length const displayedResultsWithCountryName = useMemo( @@ -148,6 +155,18 @@ export const TalentSearchPage: FC = () => { .includes(normalizedQuery)) }, [countryFilterOptions]) + const loadPreferredRoleOptions = useCallback(async (query: string): Promise => { + const normalizedQuery = query.trim() + .toLowerCase() + if (!normalizedQuery) { + return preferredRoleOptions + } + + return preferredRoleOptions.filter(option => String(option.label || '') + .toLowerCase() + .includes(normalizedQuery)) + }, []) + const runMemberSearch = useCallback(async ( skillsToSearch: InputMultiselectOption[], overrides?: { @@ -156,17 +175,22 @@ export const TalentSearchPage: FC = () => { generation?: number openToWork?: boolean page?: number + preferredRoles?: string[] + profileComplete?: boolean recentlyActive?: boolean }, ): Promise => { const append = overrides?.append === true + const countries = (overrides?.countries ?? selectedCountryCodesList) .filter(Boolean) const generation = overrides?.generation const openToWork = overrides?.openToWork ?? onlyOpenToWork const page = overrides?.page ?? 1 + const profileComplete = overrides?.profileComplete ?? onlyProfileComplete const recentlyActive = overrides?.recentlyActive ?? onlyActive - const hasSkills = skillsToSearch.length > 0 + const preferredRoles = (overrides?.preferredRoles ?? selectedPreferredRoleValues) + .filter(Boolean) const payload: MemberSearchPayload = { limit: MEMBER_SEARCH_LIMIT, page, @@ -179,18 +203,24 @@ export const TalentSearchPage: FC = () => { wins: 1, })), skillSearchType: 'OR', - sortBy: hasSkills ? 'matchIndex' : 'handle', - sortOrder: hasSkills ? 'desc' : 'asc', } if (countries.length > 0) { payload.countries = countries } + if (preferredRoles.length > 0) { + payload.preferredRoles = preferredRoles + } + if (openToWork) { payload.openToWork = true } + if (profileComplete) { + payload.profileComplete = true + } + if (recentlyActive) { payload.recentlyActive = true } @@ -245,27 +275,18 @@ export const TalentSearchPage: FC = () => { setIsSearchingMembers(false) } } - }, [onlyActive, onlyOpenToWork, selectedCountryCodesList]) + }, [onlyActive, onlyOpenToWork, onlyProfileComplete, selectedCountryCodesList, selectedPreferredRoleValues]) const clearAllFilters = useCallback((): void => { - searchGenerationRef.current += 1 setSelectedCountries([]) + setSelectedPreferredRoles([]) + setOnlyProfileComplete(false) setOnlyOpenToWork(false) setOnlyActive(false) - setSortBy('alphabetical') setSelectedSkills([]) - setHasSearched(true) setErrorMessage('') - skipNextAutoSearchRef.current = true setLastSearchedDescription('') - runMemberSearch([], { - countries: [], - generation: searchGenerationRef.current, - openToWork: false, - page: 1, - recentlyActive: false, - }) - }, [runMemberSearch]) + }, []) const handleAiSearch = useCallback(async (): Promise => { const normalizedDescription = jobDescription.trim() @@ -307,54 +328,49 @@ export const TalentSearchPage: FC = () => { setSelectedSkills(extractedOptions) if (extractedOptions.length === 0) { - setResults([]) - setTotalResults(0) - setHasSearched(true) setErrorMessage('No skills were extracted from the job description.') - skipNextAutoSearchRef.current = true return } - setHasSearched(true) - skipNextAutoSearchRef.current = true - const searchSucceeded = await runMemberSearch(extractedOptions, { generation, page: 1 }) - if (searchGenerationRef.current !== generation) return - - if (searchSucceeded) { - setLastSearchedDescription(normalizedDescription) - } + setLastSearchedDescription(normalizedDescription) } catch { - skipNextAutoSearchRef.current = true if (searchGenerationRef.current !== generation) return setErrorMessage('Failed to extract skills. Please try again.') - setHasSearched(true) } finally { setIsExtractingSkills(false) } - }, [isExtractingSkills, jobDescription, runMemberSearch]) + }, [isExtractingSkills, jobDescription]) - useEffect(() => { - if ((shouldShowIntroState) || isExtractingSkills) { + const handleSearch = useCallback(async (): Promise => { + if (isSearchingMembers) { return } - if (skipNextAutoSearchRef.current) { - skipNextAutoSearchRef.current = false - return + setHasSearched(true) + const hadSkills = selectedSkills.length > 0 + const searchSucceeded = await runMemberSearch(selectedSkills, { + countries: selectedCountryCodesList, + openToWork: onlyOpenToWork, + page: 1, + preferredRoles: selectedPreferredRoleValues, + profileComplete: onlyProfileComplete, + recentlyActive: onlyActive, + }) + if (searchSucceeded) { + setLastAppliedSearchSignature(currentSearchSignature) + setShowSkillMatchOnCards(hadSkills) } - - runMemberSearch(selectedSkills, { generation: searchGenerationRef.current, page: 1 }) }, [ - hasSearched, - hasActiveFilters, - isExtractingSkills, + currentSearchSignature, + isSearchingMembers, onlyActive, onlyOpenToWork, + onlyProfileComplete, runMemberSearch, - selectedCountries, + selectedCountryCodesList, + selectedPreferredRoleValues, selectedSkills, - shouldShowIntroState, ]) const handleLoadMore = useCallback((): void => { @@ -367,7 +383,7 @@ export const TalentSearchPage: FC = () => { page: currentPage + 1, }) }, [currentPage, hasMoreResults, isLoadingMore, isSearchingMembers, runMemberSearch, selectedSkills]) - const isSearchButtonDisabled = useMemo( + const isAiExtractButtonDisabled = useMemo( () => isExtractingSkills || !jobDescription.trim() || jobDescription.trim() === lastSearchedDescription, @@ -375,7 +391,7 @@ export const TalentSearchPage: FC = () => { ) return ( @@ -384,20 +400,12 @@ export const TalentSearchPage: FC = () => {
{errorMessage && ( @@ -429,7 +437,6 @@ export const TalentSearchPage: FC = () => {
-

Filter

{ onChange={(event: ChangeEvent) => { const value = (event.target.value || []) as InputMultiselectOption[] setSelectedSkills(value) - setHasSearched(true) if (value.length === 0) { setLastSearchedDescription('') } @@ -463,6 +469,21 @@ export const TalentSearchPage: FC = () => { placeholder='Select country' />
+
+ ) => { + const value = (event.target.value || []) as InputMultiselectOption[] + setSelectedPreferredRoles(value) + }} + placeholder='Select preferred roles' + /> +
+
+
@@ -520,12 +563,10 @@ export const TalentSearchPage: FC = () => { > {shouldShowIntroState && (
- Person search -

Find the right talent

+

+ Paste a job description to AI-extract skills, or enter skills manually + to find talents +

)} @@ -540,21 +581,6 @@ export const TalentSearchPage: FC = () => {  that match your search.

-
- Sort by - ) => { - const nextSort = event.target.value || 'alphabetical' - setSortBy( - nextSort as TalentSearchSortOption, - ) - }} - /> -
)} {isSearchingMembers && ( @@ -575,7 +601,7 @@ export const TalentSearchPage: FC = () => { ))}
diff --git a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx index b2f47e90f..70c809db0 100644 --- a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx @@ -46,6 +46,19 @@ function getUniqueMatchedSkills(talent: TalentResultCardTalent): TalentResultCar }) } +function matchedSkillStatsLabel(skill: MatchedSkill): string { + const parts: string[] = [] + if (skill.wins > 0) { + parts.push(`${skill.wins} wins`) + } + + if (skill.submitted > 0) { + parts.push(`${skill.submitted} submissions`) + } + + return parts.length > 0 ? `: ${parts.join(', ')}` : '' +} + function buildMatchedSkillsTooltipContent( count: number, skills: MatchedSkill[], @@ -59,7 +72,7 @@ function buildMatchedSkillsTooltipContent( {skills.map((skill: MatchedSkill) => (
  • {skill.name} - {`: ${skill.wins} wins, ${skill.submitted} submissions`} + {matchedSkillStatsLabel(skill)}
  • ))} @@ -181,7 +194,7 @@ export const TalentResultCard: FC = (props: TalentResultC rel='noopener noreferrer' target='_blank' > - Experience Match + View Profile
    diff --git a/src/apps/dev-center/src/dev-center-pages/platform-ui-app/getting-started/GettingStartedGuide.md b/src/apps/dev-center/src/dev-center-pages/platform-ui-app/getting-started/GettingStartedGuide.md index 291640e86..bf9e09688 100644 --- a/src/apps/dev-center/src/dev-center-pages/platform-ui-app/getting-started/GettingStartedGuide.md +++ b/src/apps/dev-center/src/dev-center-pages/platform-ui-app/getting-started/GettingStartedGuide.md @@ -159,11 +159,6 @@ Application that allows users to manage their own profile data, and allows visit Located `src/apps/profiles`. -#### Talent Search App -This is an internal app for finding members based on skills and other search facets. - -Located `src/apps/talent-search`. - #### Skills Manager Admin app that allows one to manage the standardized skills. diff --git a/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx b/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx index 196c59286..bf04de309 100644 --- a/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx +++ b/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx @@ -15,7 +15,7 @@ import { import type { Engagement, EngagementAssignment } from '../../lib/models' import { formatCurrencyAmount, - formatStandardHoursPerWeek, + formatStandardHoursPerDay, } from '../../lib/utils/currency.utils' import { formatDate } from '../../lib/utils/date.utils' import { formatLocation } from '../../lib/utils/api.utils' @@ -152,10 +152,6 @@ const AssignmentCard: FC = (props: AssignmentCardProps) => () => normalizeStatusKey(assignment?.status), [assignment?.status], ) - const paymentLabel = useMemo( - () => formatCurrencyAmount(assignment?.agreementRate, FALLBACK_VALUE_LABEL), - [assignment?.agreementRate], - ) const ratePerHourLabel = useMemo( () => formatCurrencyAmount(assignment?.ratePerHour, FALLBACK_VALUE_LABEL), [assignment?.ratePerHour], @@ -168,9 +164,9 @@ const AssignmentCard: FC = (props: AssignmentCardProps) => () => formatDurationMonths(assignment?.durationMonths), [assignment?.durationMonths], ) - const standardHoursPerWeekLabel = useMemo( - () => formatStandardHoursPerWeek(assignment?.standardHoursPerWeek, FALLBACK_VALUE_LABEL), - [assignment?.standardHoursPerWeek], + const standardHoursPerDayLabel = useMemo( + () => formatStandardHoursPerDay(assignment?.standardHoursPerDay, FALLBACK_VALUE_LABEL), + [assignment?.standardHoursPerDay], ) const assignmentStatus = assignment?.status?.toLowerCase() const showAssignedActions = assignmentStatus === 'assigned' @@ -253,11 +249,10 @@ const AssignmentCard: FC = (props: AssignmentCardProps) =>
    - {`Std hrs / week: ${standardHoursPerWeekLabel}`} + {`Std hrs / day: ${standardHoursPerDayLabel}`}
    - - {`Rate / week: ${paymentLabel}`} + {`Payment cycle: ${assignment?.paymentCycle ?? 'TBD'}`}
    diff --git a/src/apps/engagements/src/components/assignment-offer-modal/AssignmentOfferModal.tsx b/src/apps/engagements/src/components/assignment-offer-modal/AssignmentOfferModal.tsx index b1c943639..f3a61b5a7 100644 --- a/src/apps/engagements/src/components/assignment-offer-modal/AssignmentOfferModal.tsx +++ b/src/apps/engagements/src/components/assignment-offer-modal/AssignmentOfferModal.tsx @@ -5,7 +5,7 @@ import { BaseModal, Button } from '~/libs/ui' import type { Engagement, EngagementAssignment } from '../../lib/models' import { formatCurrencyAmount, - formatStandardHoursPerWeek, + formatStandardHoursPerDay, } from '../../lib/utils/currency.utils' import { formatDate } from '../../lib/utils/date.utils' @@ -63,9 +63,9 @@ const AssignmentOfferModal: FC = ( ? 'Review the details below before rejecting this offer.' : 'Review the details below before accepting this offer.' - const agreementRateLabel = useMemo( - () => formatCurrencyAmount(assignment.agreementRate, FALLBACK_LABEL), - [assignment.agreementRate], + const paymentCycleLabel = useMemo( + () => assignment.paymentCycle ?? FALLBACK_LABEL, + [assignment.paymentCycle], ) const startDateLabel = useMemo( () => formatAssignmentDate(assignment.startDate), @@ -79,9 +79,9 @@ const AssignmentOfferModal: FC = ( () => formatCurrencyAmount(assignment.ratePerHour, FALLBACK_LABEL), [assignment.ratePerHour], ) - const standardHoursPerWeekLabel = useMemo( - () => formatStandardHoursPerWeek(assignment.standardHoursPerWeek, FALLBACK_LABEL), - [assignment.standardHoursPerWeek], + const standardHoursPerDayLabel = useMemo( + () => formatStandardHoursPerDay(assignment.standardHoursPerDay, FALLBACK_LABEL), + [assignment.standardHoursPerDay], ) const otherRemarksLabel = useMemo( () => formatRemarks(assignment.otherRemarks), @@ -135,12 +135,12 @@ const AssignmentOfferModal: FC = ( {ratePerHourLabel}
    - Standard hours per week - {standardHoursPerWeekLabel} + Standard hours per day + {standardHoursPerDayLabel}
    - Assignment rate per week - {agreementRateLabel} + Payment Cycle + {paymentCycleLabel}
    Other remarks diff --git a/src/apps/engagements/src/lib/models/Engagement.model.ts b/src/apps/engagements/src/lib/models/Engagement.model.ts index dc88a8103..de9f9de42 100644 --- a/src/apps/engagements/src/lib/models/Engagement.model.ts +++ b/src/apps/engagements/src/lib/models/Engagement.model.ts @@ -15,8 +15,10 @@ export interface EngagementAssignment { status?: string termsAccepted?: boolean agreementRate?: string + paymentCycle?: string ratePerHour?: string standardHoursPerWeek?: number + standardHoursPerDay?: number durationMonths?: number otherRemarks?: string startDate?: string diff --git a/src/apps/engagements/src/lib/services/engagements.service.ts b/src/apps/engagements/src/lib/services/engagements.service.ts index e86af1e35..93e024814 100644 --- a/src/apps/engagements/src/lib/services/engagements.service.ts +++ b/src/apps/engagements/src/lib/services/engagements.service.ts @@ -29,7 +29,9 @@ interface BackendEngagementAssignment { termsAccepted?: boolean | null agreementRate?: string | number | null ratePerHour?: string | number | null + standardHoursPerDay?: number | string | null standardHoursPerWeek?: number | string | null + paymentCycle?: string durationMonths?: number | string | null otherRemarks?: string | null startDate?: string | null @@ -212,6 +214,7 @@ const normalizeAssignments = (assignments?: BackendEngagementAssignment[]): Enga return assignments.map(assignment => { const ratePerHour = normalizeEnumValue(assignment.ratePerHour) const parsedRatePerHour = normalizePositiveNumericValue(assignment.ratePerHour) + const standardHoursPerDay = normalizePositiveNumericValue(assignment.standardHoursPerDay) const standardHoursPerWeek = normalizePositiveNumericValue(assignment.standardHoursPerWeek) const agreementRate = normalizeEnumValue(assignment.agreementRate) ?? ( @@ -230,7 +233,9 @@ const normalizeAssignments = (assignments?: BackendEngagementAssignment[]): Enga memberHandle: withDefault('', assignment.memberHandle), memberId: withDefault('', assignment.memberId), otherRemarks: normalizeEnumValue(assignment.otherRemarks), + paymentCycle: assignment.paymentCycle, ratePerHour, + standardHoursPerDay, standardHoursPerWeek, startDate: normalizeEnumValue(assignment.startDate), status: normalizeAssignmentStatus(assignment.status), diff --git a/src/apps/engagements/src/lib/utils/currency.utils.ts b/src/apps/engagements/src/lib/utils/currency.utils.ts index 145607818..194a48ea2 100644 --- a/src/apps/engagements/src/lib/utils/currency.utils.ts +++ b/src/apps/engagements/src/lib/utils/currency.utils.ts @@ -63,7 +63,7 @@ export const formatCurrencyAmount = ( * @param fallback Label shown when the value is absent or invalid. * @returns Human-readable weekly hours label. */ -export const formatStandardHoursPerWeek = ( +export const formatStandardHoursPerDay = ( value?: string | number | null, fallback = 'TBD', ): string => { diff --git a/src/apps/engagements/src/lib/utils/index.ts b/src/apps/engagements/src/lib/utils/index.ts index 2fc1ffd2b..cd6d693a7 100644 --- a/src/apps/engagements/src/lib/utils/index.ts +++ b/src/apps/engagements/src/lib/utils/index.ts @@ -7,7 +7,7 @@ export { formatLocation } from './api.utils' export { truncateText } from './application.utils' export { formatCurrencyAmount, - formatStandardHoursPerWeek, + formatStandardHoursPerDay, normalizePositiveNumericValue, } from './currency.utils' export { formatDate } from './date.utils' diff --git a/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.module.scss b/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.module.scss index 8fa6e3f84..06b6332a1 100644 --- a/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.module.scss +++ b/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.module.scss @@ -64,6 +64,31 @@ margin-top: 16px; } +.sections { + display: flex; + flex-direction: column; + gap: 32px; + margin-top: 16px; +} + +.assignmentSection { + display: flex; + flex-direction: column; + gap: 12px; +} + +.sectionTitle { + margin: 0; + color: $black-100; + font-size: 20px; + font-weight: 600; + line-height: 28px; +} + +.sectionGrid { + @include gridColumns(3, 2, 1); +} + .pagination { display: flex; justify-content: center; diff --git a/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.spec.tsx b/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.spec.tsx index 7dc9ade8e..47a2a766f 100644 --- a/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.spec.tsx +++ b/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.spec.tsx @@ -102,6 +102,7 @@ jest.mock('../../components', () => ({ }) => (

    {props.engagement.title}

    + {props.assignment?.status} {props.assignment?.status?.toLowerCase() === 'selected' && (
    )} - {!error && ( + {!error && loading && (
    - {loading ? skeletonCards.map(card => ( + {skeletonCards.map(card => (
    - )) : assignments.map(engagement => { - const contactEmail = normalizeContactEmail(engagement.createdByEmail) - const assignment = getUserAssignment(engagement) - const handleDocumentExperienceClick = function (): void { - handleDocumentExperience(engagement) - } - - const handleAcceptOfferClick = function (): void { - setProfileGateState(undefined) - - if (profileCompleteness?.isLoading) { - return - } - - if ( - profileCompleteness - && typeof profileCompleteness.percent === 'number' - && profileCompleteness.percent < 100 - ) { - setProfileGateState({ - engagementId: engagement.id, - message: PROFILE_GATE_ERROR_MESSAGE, - }) - return - } - - startTermsAgreementFlow(() => { - handleOpenOfferModal(engagement, 'accept') - }) - } - - const handleRejectOfferClick = function (): void { - handleOpenOfferModal(engagement, 'reject') - } - - return ( - - ) - })} + ))} +
    + )} + {!error && !loading && ( +
    + {[ + { + engagements: assignmentSections.active, + id: 'active', + title: 'Active', + }, + { + engagements: assignmentSections.past, + id: 'past', + title: 'Past', + }, + ].filter(section => section.engagements.length > 0) + .map(section => ( +
    +

    {section.title}

    +
    + {section.engagements.map(engagement => { + const contactEmail = normalizeContactEmail(engagement.createdByEmail) + const assignment = getUserAssignment(engagement) + const handleDocumentExperienceClick = function (): void { + handleDocumentExperience(engagement) + } + + const handleAcceptOfferClick = function (): void { + setProfileGateState(undefined) + + if (profileCompleteness?.isLoading) { + return + } + + if ( + profileCompleteness + && typeof profileCompleteness.percent === 'number' + && profileCompleteness.percent < 100 + ) { + setProfileGateState({ + engagementId: engagement.id, + message: PROFILE_GATE_ERROR_MESSAGE, + }) + return + } + + startTermsAgreementFlow(() => { + handleOpenOfferModal(engagement, 'accept') + }) + } + + const handleRejectOfferClick = function (): void { + handleOpenOfferModal(engagement, 'reject') + } + + return ( + + ) + })} +
    +
    + ))}
    )} {!error && assignments.length > 0 && ( diff --git a/src/apps/onboarding/src/models/MemberInfo.ts b/src/apps/onboarding/src/models/MemberInfo.ts index 23de4ea8a..236abe13b 100644 --- a/src/apps/onboarding/src/models/MemberInfo.ts +++ b/src/apps/onboarding/src/models/MemberInfo.ts @@ -1,8 +1,11 @@ -import { MemberMaxRating } from '~/apps/talent-search/src/lib/models' import { MemberStats, UserSkill } from '~/libs/core' import MemberAddress from './MemberAddress' +export type MemberMaxRating = { + rating?: number +} + export default interface MemberInfo { userId: number handle: string diff --git a/src/apps/onboarding/src/pages/account-details/index.tsx b/src/apps/onboarding/src/pages/account-details/index.tsx index 36e17f2bb..438c6c734 100644 --- a/src/apps/onboarding/src/pages/account-details/index.tsx +++ b/src/apps/onboarding/src/pages/account-details/index.tsx @@ -7,7 +7,7 @@ import classNames from 'classnames' import { Button, IconOutline, InputSelect, PageDivider } from '~/libs/ui' import { getCountryLookup } from '~/libs/core/lib/profile/profile-functions/profile-store/profile-xhr.store' import { EnvironmentConfig } from '~/config' -import { Member } from '~/apps/talent-search/src/lib/models' +import { UserProfile } from '~/libs/core' import { ProgressBar } from '../../components/progress-bar' import { validatePhonenumber } from '../../utils/validation' @@ -28,7 +28,7 @@ const blankConnectInfo: ConnectInfo = emptyConnectInfo() const PageAccountDetailsContent: FC<{ reduxAddress: MemberAddress | undefined reduxConnectInfo: ConnectInfo | undefined - reduxMemberInfo: Member | undefined + reduxMemberInfo: UserProfile | undefined updateMemberConnectInfos: (infos: ConnectInfo[]) => void createMemberConnectInfos: (infos: ConnectInfo[]) => void updateMemberHomeAddresss: (infos: MemberAddress[]) => void diff --git a/src/apps/onboarding/src/pages/skills/index.tsx b/src/apps/onboarding/src/pages/skills/index.tsx index 9ac5a1606..81c127ddd 100644 --- a/src/apps/onboarding/src/pages/skills/index.tsx +++ b/src/apps/onboarding/src/pages/skills/index.tsx @@ -4,7 +4,7 @@ import { connect } from 'react-redux' import classNames from 'classnames' import { Button, PageDivider } from '~/libs/ui' -import { Member } from '~/apps/talent-search/src/lib/models' +import { UserProfile } from '~/libs/core' import { MemberSkillEditor, useMemberSkillEditor } from '~/libs/shared' import { ProgressBar } from '../../components/progress-bar' @@ -12,7 +12,7 @@ import { ProgressBar } from '../../components/progress-bar' import styles from './styles.module.scss' export const PageSkillsContent: FC<{ - reduxMemberInfo: Member | undefined + reduxMemberInfo: UserProfile | undefined }> = props => { const navigate: any = useNavigate() const [loading, setLoading] = useState(false) diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx index 0bc933be7..14b912d9d 100644 --- a/src/apps/platform/src/platform.routes.tsx +++ b/src/apps/platform/src/platform.routes.tsx @@ -3,7 +3,6 @@ import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core' import { learnRoutes } from '~/apps/learn' import { devCenterRoutes } from '~/apps/dev-center' import { profilesRoutes } from '~/apps/profiles' -import { talentSearchRoutes } from '~/apps/talent-search' import { accountsRoutes } from '~/apps/accounts' import { onboardingRoutes } from '~/apps/onboarding' import { walletRoutes } from '~/apps/wallet' @@ -38,7 +37,6 @@ export const platformRoutes: Array = [ ...devCenterRoutes, ...copilotsRoutes, ...learnRoutes, - ...talentSearchRoutes, ...profilesRoutes, ...walletRoutes, ...walletAdminRoutes, diff --git a/src/apps/profiles/src/components/tc-achievements/ChallengeHistoryView/ChallengeHistoryCard/ChallengeHistoryCard.tsx b/src/apps/profiles/src/components/tc-achievements/ChallengeHistoryView/ChallengeHistoryCard/ChallengeHistoryCard.tsx index 2ecf88826..c737a699a 100644 --- a/src/apps/profiles/src/components/tc-achievements/ChallengeHistoryView/ChallengeHistoryCard/ChallengeHistoryCard.tsx +++ b/src/apps/profiles/src/components/tc-achievements/ChallengeHistoryView/ChallengeHistoryCard/ChallengeHistoryCard.tsx @@ -14,6 +14,7 @@ interface ChallengeHistoryCardProps { } const ChallengeHistoryCard: FC = props => { + const challengeTitle = props.challenge.challengeName || `Challenge ${props.challenge.challengeId}` const rating = props.challenge.newRating ?? props.challenge.rating const placement = props.challenge.placement @@ -27,7 +28,7 @@ const ChallengeHistoryCard: FC = props => {
    - {props.challenge.challengeName} + {challengeTitle}
    diff --git a/src/apps/profiles/src/hooks/useRatingHistoryOptions.spec.tsx b/src/apps/profiles/src/hooks/useRatingHistoryOptions.spec.tsx new file mode 100644 index 000000000..b4b82487f --- /dev/null +++ b/src/apps/profiles/src/hooks/useRatingHistoryOptions.spec.tsx @@ -0,0 +1,96 @@ +import type { StatsHistory } from '~/libs/core' + +import { getRatingHistoryData } from './useRatingHistoryOptions' + +jest.mock('~/libs/core', () => ({ + getRatingColor: (rating: number): string => `rating-${rating}`, + TC_RATING_COLORS: [{ + color: '#555555', + limit: 900, + }, { + color: '#2D7E2D', + limit: 1200, + }, { + color: '#616BD5', + limit: 1500, + }, { + color: '#F2C900', + limit: 2200, + }, { + color: '#EF3A3A', + limit: Infinity, + }], +}), { + virtual: true, +}) + +describe('getRatingHistoryData', () => { + it('sorts rated history points chronologically without mutating the source history', () => { + const trackHistory: StatsHistory[] = [{ + challengeId: 'latest', + challengeName: 'Latest rated challenge', + date: 3000, + newRating: 2100, + rating: 2100, + ratingDate: 3000, + }, { + challengeId: 'oldest', + challengeName: 'Oldest rated challenge', + newRating: 1500, + ratingDate: 1000, + }] + const originalOrder: string[] = trackHistory.map(challenge => challenge.challengeId as string) + + expect(getRatingHistoryData(trackHistory)) + .toEqual([{ + color: 'rating-1500', + name: 'Oldest rated challenge', + x: 1000, + y: 1500, + }, { + color: 'rating-2100', + name: 'Latest rated challenge', + x: 3000, + y: 2100, + }]) + expect(trackHistory.map(challenge => challenge.challengeId)) + .toEqual(originalOrder) + }) + + it('omits unrated history entries so Highcharts can draw a continuous rated line', () => { + const trackHistory: StatsHistory[] = [{ + challengeId: 'rated-before', + challengeName: 'Rated before', + date: 1000, + newRating: 1800, + rating: 1800, + ratingDate: 1000, + }, { + challengeId: 'unrated', + challengeName: 'Unrated marathon match', + date: 2000, + newRating: undefined as unknown as number, + ratingDate: 2000, + }, { + challengeId: 'rated-after', + challengeName: 'Rated after', + date: 3000, + newRating: 2300, + rating: 2300, + ratingDate: 3000, + }] + + expect(getRatingHistoryData(trackHistory)) + .toEqual([{ + color: 'rating-1800', + name: 'Rated before', + x: 1000, + y: 1800, + }, { + color: 'rating-2300', + name: 'Rated after', + x: 3000, + y: 2300, + }]) + }) +}) diff --git a/src/apps/profiles/src/hooks/useRatingHistoryOptions.tsx b/src/apps/profiles/src/hooks/useRatingHistoryOptions.tsx index 696b11cb1..4aa9caa16 100644 --- a/src/apps/profiles/src/hooks/useRatingHistoryOptions.tsx +++ b/src/apps/profiles/src/hooks/useRatingHistoryOptions.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { cloneDeep, get } from 'lodash' +import { cloneDeep } from 'lodash' import Highcharts from 'highcharts' import { getRatingColor, StatsHistory, TC_RATING_COLORS } from '~/libs/core' @@ -44,13 +44,51 @@ export const RATING_CHART_CONFIG: Highcharts.Options = { }, } +/** + * Converts raw track history records into Highcharts rating points. + * + * @param trackHistory - Raw track history entries from the member stats API. + * @returns Chronologically sorted chart points. Entries without a finite date or rating are omitted + * because they do not represent a rating change and would split the line in Highcharts. + */ +export function getRatingHistoryData(trackHistory: Array): Highcharts.PointOptionsObject[] { + return trackHistory + .reduce((points, challenge) => { + const date: number | undefined = typeof challenge.date === 'number' && Number.isFinite(challenge.date) + ? challenge.date + : challenge.ratingDate + const rating: number | undefined = typeof challenge.rating === 'number' && Number.isFinite(challenge.rating) + ? challenge.rating + : challenge.newRating + + if ( + typeof date !== 'number' + || !Number.isFinite(date) + || typeof rating !== 'number' + || !Number.isFinite(rating) + ) { + return points + } + + points.push({ + color: getRatingColor(rating), + name: challenge.challengeName, + x: date, + y: rating, + }) + + return points + }, []) + .sort((a, b) => (a.x as number) - (b.x as number)) +} + /** * Custom hook to generate Highcharts options for a rating history chart. * - * @param {Array | undefined} trackHistory - The array of historical stats data. - * @param {string} seriesName - The name of the series for the chart. - * @returns {Highcharts.Options | undefined} - Highcharts options for the rating history chart or - * undefined if data is empty. + * @param trackHistory - The array of historical stats data. + * @param seriesName - The name of the series for the chart. + * @returns Highcharts options for the rating history chart, or undefined when there are no rated + * history entries to draw. */ export function useRatingHistoryOptions( trackHistory: Array | undefined, @@ -64,9 +102,8 @@ export function useRatingHistoryOptions( // Return undefined if the track history data is empty if (!trackHistory?.length) return undefined - // Determine the date and rating fields based on the first entry in the track history - const dateField: string = get(trackHistory[0], 'date') ? 'date' : 'ratingDate' - const ratingField: string = get(trackHistory[0], 'rating') ? 'rating' : 'newRating' + const historyData: Highcharts.PointOptionsObject[] = getRatingHistoryData(trackHistory) + if (!historyData.length) return undefined // Configure series for the chart options.plotOptions = { @@ -81,13 +118,7 @@ export function useRatingHistoryOptions( options.series = [{ color: 'transparent', - data: trackHistory.sort((a, b) => get(b, dateField) - get(a, dateField)) - .map((challenge: StatsHistory) => ({ - color: getRatingColor(challenge.newRating ?? challenge.rating), - name: challenge.challengeName, - x: get(challenge, dateField), - y: get(challenge, ratingField), - })), + data: historyData, name: seriesName, type: 'line', }] diff --git a/src/apps/profiles/src/member-profile/MemberProfilePage.tsx b/src/apps/profiles/src/member-profile/MemberProfilePage.tsx index b8054c8ac..d5a85cf72 100644 --- a/src/apps/profiles/src/member-profile/MemberProfilePage.tsx +++ b/src/apps/profiles/src/member-profile/MemberProfilePage.tsx @@ -3,9 +3,9 @@ import { Params, useNavigate, useParams } from 'react-router-dom' import { AxiosError } from 'axios' import { profileContext, ProfileContextData, profileGetPublicAsync, UserProfile } from '~/libs/core' -import { TALENT_SEARCH_PATHS } from '~/apps/talent-search' import { LoadingSpinner } from '~/libs/ui' +import { rootRoute } from '../profiles.routes' import { notifyUniNavi } from '../lib' import { ProfilePageLayout } from './page-layout' @@ -38,7 +38,7 @@ const MemberProfilePage: FC<{}> = () => { }) .catch((e: AxiosError) => { if (e.code === AxiosError.ERR_BAD_REQUEST && e.response?.status === 404) { - window.location.href = `${TALENT_SEARCH_PATHS.absoluteUrl}?memberNotFound` + navigate(rootRoute) } }) } diff --git a/src/apps/profiles/src/member-profile/tc-achievements/sub-track-view/SubTrackView.tsx b/src/apps/profiles/src/member-profile/tc-achievements/sub-track-view/SubTrackView.tsx index 74c0d835e..afda7afcc 100644 --- a/src/apps/profiles/src/member-profile/tc-achievements/sub-track-view/SubTrackView.tsx +++ b/src/apps/profiles/src/member-profile/tc-achievements/sub-track-view/SubTrackView.tsx @@ -34,21 +34,6 @@ const SubTrackView: FC = props => { ] }, [props.profile.handle, statsRoute, trackData?.name, trackData?.subTracks]) - const summaryTrackData = useMemo(() => { - const supportsHistoryBackedSummary = ['DATA_SCIENCE', 'DEVELOP'].includes(String(subTrackData?.parentTrack)) - - if (!supportsHistoryBackedSummary || trackHistory.length === 0) { - return subTrackData - } - - return { - ...subTrackData, - challenges: trackHistory.length, - submissions: trackHistory.length, - wins: trackHistory.filter(challenge => challenge.placement === 1).length, - } - }, [subTrackData, trackHistory]) - return (!trackData || isEmpty(subTrackData)) ? props.renderDefault() : (
    = props => { title={subTrackLabelToHumanName(subTrackData.name)} backAction={backRoute} closeAction={statsRoute(props.profile.handle)} - trackData={summaryTrackData} + trackData={subTrackData} > {subTrackData.name === 'SRM' ? ( diff --git a/src/apps/profiles/src/profiles-landing-page/ProfilesLandingPage.tsx b/src/apps/profiles/src/profiles-landing-page/ProfilesLandingPage.tsx index 08cac5dba..0039ee3ec 100644 --- a/src/apps/profiles/src/profiles-landing-page/ProfilesLandingPage.tsx +++ b/src/apps/profiles/src/profiles-landing-page/ProfilesLandingPage.tsx @@ -2,23 +2,20 @@ import { FC, useContext, useEffect } from 'react' import { NavigateFunction, useNavigate } from 'react-router-dom' import { profileContext, ProfileContextData } from '~/libs/core' -import { TALENT_SEARCH_PATHS } from '~/apps/talent-search' import { rootRoute } from '../profiles.routes' const ProfilesLandingPage: FC = () => { const navigate: NavigateFunction = useNavigate() - const { profile: authProfile, initialized }: ProfileContextData = useContext(profileContext) + const { profile: authProfile }: ProfileContextData = useContext(profileContext) // redirect to profile page if logged in useEffect(() => { if (authProfile) { navigate(`${rootRoute}/${authProfile.handle}`) - } else if (initialized) { - window.location.href = `${TALENT_SEARCH_PATHS.absoluteUrl}` } - }, [authProfile, navigate, initialized]) + }, [authProfile, navigate]) return ( // TODO: no profile specified - redirect to talent search or dedicated page diff --git a/src/apps/reports/src/config/routes.config.ts b/src/apps/reports/src/config/routes.config.ts index fb7d07091..cc76b5dd3 100644 --- a/src/apps/reports/src/config/routes.config.ts +++ b/src/apps/reports/src/config/routes.config.ts @@ -10,3 +10,5 @@ export const rootRoute: string export const reportsPageRouteId = 'reports' export const bulkMemberLookupRouteId = 'bulk-member-lookup' +export const billingAccountsPageRouteId = 'sfdc-payments' +export const talentPageRouteId = 'talent' diff --git a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx index cb0c2e34a..ad5d15c5e 100644 --- a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx +++ b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx @@ -4,6 +4,7 @@ import { MouseEvent, SetStateAction, useCallback, + useContext, useEffect, useMemo, useRef, @@ -15,7 +16,14 @@ import classNames from 'classnames' import { useClickOutside } from '~/libs/shared/lib/hooks' import { TabsNavItem } from '~/libs/ui' -import { bulkMemberLookupRouteId, reportsPageRouteId } from '../../../config/routes.config' +import { + billingAccountsPageRouteId, + bulkMemberLookupRouteId, + reportsPageRouteId, + talentPageRouteId, +} from '../../../config/routes.config' +import { ReportsAppContext, ReportsAppContextModel } from '../../contexts' +import { canAccessTalentReport } from '../../utils' import styles from './NavTabs.module.scss' @@ -24,17 +32,37 @@ const NavTabs: FC = () => { 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 { loginUserInfo }: ReportsAppContextModel = useContext(ReportsAppContext) + const canAccessTalent = useMemo(() => ( + canAccessTalentReport(loginUserInfo?.roles) + ), [loginUserInfo]) + + const tabs = useMemo(() => { + const baseTabs: TabsNavItem[] = [ + { + id: reportsPageRouteId, + title: 'Reports', + }, + { + id: bulkMemberLookupRouteId, + title: 'Bulk Member Lookup', + }, + { + id: billingAccountsPageRouteId, + title: 'SFDC Payments', + }, + ] + + return canAccessTalent + ? [ + ...baseTabs, + { + id: talentPageRouteId, + title: 'Talent', + }, + ] + : baseTabs + }, [canAccessTalent]) const activeTabPathName: string = useMemo(() => { const matchingTabs = tabs diff --git a/src/apps/reports/src/lib/services/index.ts b/src/apps/reports/src/lib/services/index.ts index b2c6dc18b..bb718795c 100644 --- a/src/apps/reports/src/lib/services/index.ts +++ b/src/apps/reports/src/lib/services/index.ts @@ -1,7 +1,11 @@ export { + buildOpenToWorkTalentPath, downloadBlobFile, + downloadOpenToWorkTalentCsv, downloadReportAsCsv, downloadReportAsJson, + fetchOpenToWorkTalent, + fetchReportJson, fetchReportsIndex, postReportAsCsv, postReportAsJson, @@ -10,8 +14,17 @@ export { } from './reports.service' export type { + BillingAccountDetail, + BillingAccountProfileResponse, + BillingAccountsViewData, + OpenToWorkTalentAvailability, + OpenToWorkTalentMember, + OpenToWorkTalentQuery, + OpenToWorkTalentResponse, + OpenToWorkTalentRoleCount, ReportDefinition, ReportGroup, ReportParameter, ReportsIndexResponse, + SfdcBillingAccountPaymentRow, } 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 index d752087c8..220c0e774 100644 --- a/src/apps/reports/src/lib/services/reports.service.ts +++ b/src/apps/reports/src/lib/services/reports.service.ts @@ -28,6 +28,84 @@ export type ReportGroup = { export type ReportsIndexResponse = Record +export type BillingAccountDetail = { + name: string + description: string | null + subcontractingEndCustomer: string | null + status: string + startDate: string | null + endDate: string | null + budget: string | number + markup: string | number +} + +export type SfdcBillingAccountPaymentRow = { + paymentId: string + paymentDate: string + billingAccountId: string + paymentStatus: string + challengeFee: string | number + paymentAmount: string | number + challengeId: string + category: string + isTask: boolean + challengeName: string | null + challengeStatus: string | null + winnerHandle: string + winnerId: string + winnerFirstName: string + winnerLastName: string +} + +/** Response from GET /sfdc/billing-accounts */ +export type BillingAccountProfileResponse = { + billingAccount?: BillingAccountDetail +} + +/** Billing Accounts in-app view: profile + rows from GET /sfdc/payments */ +export type BillingAccountsViewData = { + billingAccount?: BillingAccountDetail + payments: SfdcBillingAccountPaymentRow[] +} + +export type OpenToWorkTalentAvailability = 'FULL_TIME' | 'PART_TIME' + +export type OpenToWorkTalentQuery = { + role?: string + availability?: OpenToWorkTalentAvailability + page?: number + perPage?: number +} + +export type OpenToWorkTalentRoleCount = { + role: string + count: number +} + +export type OpenToWorkTalentMember = { + userId: string + handle: string + firstName: string | null + lastName: string | null + country: string | null + availability: string | null + preferredRoles: string[] + memberSince: string | null + maxRating: number | null + challengeWins: number + taskWins: number + totalWins: number +} + +export type OpenToWorkTalentResponse = { + totalMembers: number + total: number + page: number + perPage: number + roleCounts: OpenToWorkTalentRoleCount[] + data: OpenToWorkTalentMember[] +} + const reportsDownloadClient: AxiosInstance = xhrCreateInstance() const buildReportUrl = (path: string): string => { @@ -35,6 +113,38 @@ const buildReportUrl = (path: string): string => { return `${EnvironmentConfig.API.V6}/reports${normalizedPath}` } +/** + * Builds a reports API path with Talent query parameters. + * @param basePath Reports API path relative to `/reports`. + * @param query Optional role, availability, and pagination values. + * @returns Path and query string suitable for reports API calls. + */ +export const buildOpenToWorkTalentPath = ( + basePath: string, + query: OpenToWorkTalentQuery = {}, +): string => { + const params = new URLSearchParams() + + if (query.role) { + params.append('role', query.role) + } + + if (query.availability) { + params.append('availability', query.availability) + } + + if (query.page) { + params.append('page', String(query.page)) + } + + if (query.perPage) { + params.append('perPage', String(query.perPage)) + } + + const queryString = params.toString() + return queryString ? `${basePath}?${queryString}` : basePath +} + export const fetchReportsIndex = async (): Promise => ( xhrGetAsync(`${EnvironmentConfig.API.V6}/reports/directory`) ) @@ -137,10 +247,44 @@ export const downloadReportAsJson = (path: string): Promise => ( downloadReportBlob(path, 'application/json') ) +export const fetchReportJson = async (path: string): Promise => { + if (!path) { + throw new Error('Report path is required') + } + + const normalizedPath = path.startsWith('/') ? path : `/${path}` + const url = `${EnvironmentConfig.API.V6}/reports${normalizedPath}` + return xhrGetAsync(url, reportsDownloadClient) +} + export const downloadReportAsCsv = (path: string): Promise => ( downloadReportBlob(path, 'text/csv') ) +/** + * Fetches the Talent tab dashboard and paginated member list. + * @param query Optional role, availability, and pagination values. + * @returns Open-to-work Talent report data. + */ +export const fetchOpenToWorkTalent = ( + query: OpenToWorkTalentQuery, +): Promise => ( + fetchReportJson( + buildOpenToWorkTalentPath('/member/open-to-work', query), + ) +) + +/** + * Downloads the Talent tab CSV export for the selected filters. + * @param query Optional role and availability filters. + * @returns Blob response encoded as CSV. + */ +export const downloadOpenToWorkTalentCsv = ( + query: OpenToWorkTalentQuery, +): Promise => ( + downloadReportAsCsv(buildOpenToWorkTalentPath('/member/open-to-work/export', query)) +) + /** * Triggers a browser download for a report blob. * @param blob the report data returned from the reports API. diff --git a/src/apps/reports/src/lib/utils/index.ts b/src/apps/reports/src/lib/utils/index.ts index 83bf5f8f3..747acdbe6 100644 --- a/src/apps/reports/src/lib/utils/index.ts +++ b/src/apps/reports/src/lib/utils/index.ts @@ -1,5 +1,7 @@ import { toast } from 'react-toastify' +export { canAccessTalentReport } from './talent-access.utils' + /** * Handles API errors by extracting the most useful message and showing a toast. * @param error Axios error-like object. diff --git a/src/apps/reports/src/lib/utils/talent-access.utils.spec.ts b/src/apps/reports/src/lib/utils/talent-access.utils.spec.ts new file mode 100644 index 000000000..5156402a8 --- /dev/null +++ b/src/apps/reports/src/lib/utils/talent-access.utils.spec.ts @@ -0,0 +1,17 @@ +import { canAccessTalentReport } from './talent-access.utils' + +describe('talent-access utils', () => { + it('allows administrators and Talent Managers to access the Talent report', () => { + expect(canAccessTalentReport(['administrator'])) + .toBe(true) + expect(canAccessTalentReport([' Talent Manager '])) + .toBe(true) + }) + + it('denies users without a Talent report role', () => { + expect(canAccessTalentReport(['Topcoder User'])) + .toBe(false) + expect(canAccessTalentReport(undefined)) + .toBe(false) + }) +}) diff --git a/src/apps/reports/src/lib/utils/talent-access.utils.ts b/src/apps/reports/src/lib/utils/talent-access.utils.ts new file mode 100644 index 000000000..572399150 --- /dev/null +++ b/src/apps/reports/src/lib/utils/talent-access.utils.ts @@ -0,0 +1,14 @@ +const talentReportRoles = new Set([ + 'administrator', + 'talent manager', +]) + +/** + * Checks whether a user role list can access the reports Talent tab. + * @param roles Roles from the authenticated Topcoder profile or decoded token. + * @returns Whether the user is an administrator or Talent Manager. + */ +export function canAccessTalentReport(roles?: string[]): boolean { + return !!roles?.some(role => talentReportRoles.has(role.trim() + .toLowerCase())) +} 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 index 8bf6bb896..5de227b0e 100644 --- a/src/apps/reports/src/pages/bulk-member-lookup/BulkMemberLookupPage.module.scss +++ b/src/apps/reports/src/pages/bulk-member-lookup/BulkMemberLookupPage.module.scss @@ -6,7 +6,6 @@ .instructions { color: #565a5f; - max-width: 720px; } .uploadSection { diff --git a/src/apps/reports/src/pages/reports/BillingAccountsPage.tsx b/src/apps/reports/src/pages/reports/BillingAccountsPage.tsx new file mode 100644 index 000000000..fc399e312 --- /dev/null +++ b/src/apps/reports/src/pages/reports/BillingAccountsPage.tsx @@ -0,0 +1,3 @@ +import { BillingAccountsPage } from './ReportsPage' + +export default BillingAccountsPage diff --git a/src/apps/reports/src/pages/reports/ReportsPage.module.scss b/src/apps/reports/src/pages/reports/ReportsPage.module.scss index e804f2221..4b6c4e110 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.module.scss +++ b/src/apps/reports/src/pages/reports/ReportsPage.module.scss @@ -1,3 +1,4 @@ +@import '@libs/ui/styles/includes'; .page { display: flex; flex-direction: column; @@ -9,6 +10,16 @@ max-width: 720px; } +.billingDefaultWindowNote { + margin: 8px 0 0; + padding: 8px 12px; + border-radius: 4px; + background: #fff4d6; + color: #5f4a00; + font-weight: 500; + max-width: 720px; +} + .selectors { display: flex; flex-wrap: wrap; @@ -26,26 +37,97 @@ gap: 4px; } +.filtersPanel { + margin-top: 12px; + padding: 16px; + border: 1px solid #e4e6e9; + border-radius: 8px; + background: #fcfcfd; +} + .params { display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 16px; - margin-top: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 30px 50px; + margin-top: 0; + align-items: start; +} + +@media (max-width: 1200px) { + .params { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .params { + grid-template-columns: 1fr; + } +} + +.paramCard { + display: grid; + grid-template-rows: auto auto; + row-gap: 8px; + align-content: start; +} + +.paramHeader { + min-height: 28px; +} + +.paramTitleRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.paramHeaderActions { + display: flex; + align-items: center; + gap: 8px; } .paramLabel { font-weight: 600; + color: #2f3338; } -.paramMeta { - color: #6b6f75; - font-size: 12px; +.paramTypePill { + font-size: 11px; + color: #5e6369; + background: #f3f4f6; + border-radius: 999px; + padding: 2px 8px; + white-space: nowrap; } -.paramHint { +.actionsBar { + display: flex; + justify-content: flex-start; + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid #eceef1; +} + +.paramInfoButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: 0; + padding: 0; + border-radius: 50%; + background: transparent; color: #6b6f75; - font-size: 12px; - font-style: italic; + cursor: pointer; + + svg { + width: 16px; + height: 16px; + } } .reportTitle { @@ -94,3 +176,151 @@ font-style: italic; color: #6b6f75; } + +.billingAccountIdLink { + margin: 0; + padding: 0; + border: 0; + background: none; + color: $link-blue-dark; + cursor: pointer; + font: inherit; + text-align: inherit; +} + +.billingAccountIdLink:hover { + color: #0a58ca; +} + +.billingModalBody { + min-width: 280px; + padding-top: 4px; +} + +.billingModalMeta { + font-size: 13px; + color: #6b6f75; + margin-bottom: 14px; +} + +.billingModalLoading { + padding: 16px 0; + color: #494f55; +} + +.billingDetailGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px 24px; +} + +.billingDetailItem { + display: flex; + flex-direction: column; + gap: 2px; +} + +.billingDetailLabel { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #6b6f75; +} + +.billingDetailValue { + font-size: 14px; + color: #1a1d21; + word-break: break-word; +} + +.billingMissingNotice { + margin-top: 8px; + padding: 12px; + border-radius: 4px; + background: #f3f4f6; + color: #494f55; + font-size: 14px; +} + +.paymentsSection { + margin-top: 24px; +} + +.paymentsResults { + display: flex; + flex-direction: column; + gap: 12px; +} + +.paymentsSectionTitle { + font-weight: 600; + margin-bottom: 8px; +} + +.tableWrap { + overflow-x: auto; + border: 1px solid #e4e6e9; + border-radius: 6px; +} + +.paymentsPagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.perPageControl { + display: flex; + align-items: center; + gap: 8px; +} + +.perPageControl label { + color: #565a5f; + font-size: 13px; +} + +.perPageControl select { + min-width: 72px; + padding: 5px 8px; + border: 1px solid #d7d9dd; + border-radius: 6px; + background: #fff; +} + +.paginationMeta { + color: #565a5f; + font-size: 13px; +} + +.paymentsTable { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.paymentsTable th, +.paymentsTable td { + padding: 8px 10px; + text-align: left; + border-bottom: 1px solid #e4e6e9; + vertical-align: top; +} + +.paymentsTable th { + background: #f3f4f6; + font-weight: 600; + white-space: nowrap; +} + +.paymentsTable tbody tr:last-child td { + border-bottom: none; +} + +.paymentsEmpty { + margin-top: 8px; + color: #6b6f75; + font-style: italic; +} diff --git a/src/apps/reports/src/pages/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx index b00f5cf2f..a31638598 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.tsx +++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx @@ -1,26 +1,524 @@ -import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' +import { + ChangeEvent, + Dispatch, + FC, + SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import { format as formatIsoDate, isValid, parseISO } from 'date-fns' import { NavigateFunction, useNavigate } from 'react-router-dom' -import { Button, InputSelect, InputSelectOption, InputText, LoadingSpinner, PageTitle } from '~/libs/ui' +import { + BaseModal, + Button, + IconOutline, + InputDatePicker, + InputSelect, + InputSelectOption, + InputText, + LoadingSpinner, + PageTitle, + Tooltip, +} from '~/libs/ui' +import { Pagination } from '~/apps/admin/src/lib' import { bulkMemberLookupRouteId } from '../../config/routes.config' import { handleError } from '../../lib/utils' import { + BillingAccountDetail, + BillingAccountProfileResponse, + BillingAccountsViewData, downloadBlobFile, downloadReportAsCsv, downloadReportAsJson, + fetchReportJson, fetchReportsIndex, ReportDefinition, ReportGroup, ReportParameter, ReportsIndexResponse, + SfdcBillingAccountPaymentRow, } from '../../lib/services' -import { getReportParameterValidationError } from './reports-page.validation' +import { getReportParameterValidationError, isValidReportDateValue } from './reports-page.validation' import styles from './ReportsPage.module.scss' const pageTitle = 'Reports' const bulkMembersByHandlesPath = '/identity/users-by-handles' +const BILLING_ACCOUNTS_REPORT_PATH = '/sfdc/billing-accounts' +const SFDC_PAYMENTS_REPORT_PATH = '/sfdc/payments' +const BILLING_ACCOUNTS_REPORT_DEFINITION: ReportDefinition = { + description: + `View SFDC payments across all billing accounts by + default. Optionally filter by billing account ID, dates, or category.`, + method: 'GET', + name: 'Billing Accounts', + parameters: [ + { + description: 'Optional billing account ID to narrow payments to a single account.', + location: 'query', + name: 'billingAccountId', + required: false, + type: 'string', + }, + { + description: 'Optional start date for payment filtering (ISO 8601)', + location: 'query', + name: 'startDate', + type: 'date', + }, + { + description: 'Optional end date for payment filtering (ISO 8601)', + location: 'query', + name: 'endDate', + type: 'date', + }, + { + description: 'Optional payment category group', + location: 'query', + name: 'paymentCategory', + options: ['TAAS_PAYMENT', 'TOPGEAR_PAYMENT', 'POINTS_AWARD', 'TOPCODER'], + type: 'enum', + }, + ], + path: BILLING_ACCOUNTS_REPORT_PATH, +} + +type ReportsPageTab = 'reports' | 'billingAccounts' + +/** API category values excluded from the Topcoder filter group. */ +const BILLING_TOPCODER_EXCLUDED_CATEGORIES = [ + 'TAAS_PAYMENT', + 'TOPGEAR_PAYMENT', + 'POINTS_AWARD', +] as const + +/** UI-only value for payments whose category is not TaaS, Topgear, or Points. */ +const BILLING_TOPCODER_CATEGORY_FILTER = 'TOPCODER' + +const BILLING_PAYMENT_CATEGORY_OPTIONS: InputSelectOption[] = [ + { label: 'All categories', value: '' }, + { label: 'TaaS', value: 'TAAS_PAYMENT' }, + { label: 'Topgear', value: 'TOPGEAR_PAYMENT' }, + { label: 'Points', value: 'POINTS_AWARD' }, + { label: 'Topcoder', value: BILLING_TOPCODER_CATEGORY_FILTER }, +] + +const filterBillingPaymentsByCategory = ( + payments: SfdcBillingAccountPaymentRow[], + paymentCategory?: string, +): SfdcBillingAccountPaymentRow[] => { + const filter = paymentCategory?.trim() + + if (!filter) { + return payments + } + + if (filter === BILLING_TOPCODER_CATEGORY_FILTER) { + const excludedCategories = new Set(BILLING_TOPCODER_EXCLUDED_CATEGORIES) + return payments.filter(row => !excludedCategories.has(row.category)) + } + + return payments.filter(row => row.category === filter) +} + +const buildSfdcPaymentsQueryPath = ( + billingAccountId: string | undefined, + startDate?: string, + endDate?: string, +): string => { + const query = new URLSearchParams() + const trimmedBa = billingAccountId?.trim() + + if (trimmedBa) { + query.append('billingAccountIds', trimmedBa) + } + + const start = startDate?.trim() + const end = endDate?.trim() + + if (start) { + query.append('startDate', start) + } + + if (end) { + query.append('endDate', end) + } + + const queryString = query.toString() + return queryString ? `${SFDC_PAYMENTS_REPORT_PATH}?${queryString}` : SFDC_PAYMENTS_REPORT_PATH +} + +const formatReportCell = (value: unknown): string => { + if (value === null || value === undefined || value === '') { + return '—' + } + + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No' + } + + return String(value) +} + +const formatPaymentDate = (iso: string): string => { + const parsed = Date.parse(iso) + + if (Number.isNaN(parsed)) { + return iso + } + + return new Date(parsed) + .toLocaleString() +} + +const PAYMENT_TABLE_COLUMNS: { key: keyof SfdcBillingAccountPaymentRow; label: string }[] = [ + { key: 'paymentId', label: 'Payment ID' }, + { key: 'paymentDate', label: 'Payment date' }, + { key: 'billingAccountId', label: 'Billing account ID' }, + { key: 'paymentStatus', label: 'Status' }, + { key: 'challengeFee', label: 'Challenge fee' }, + { key: 'paymentAmount', label: 'Payment amount' }, + { key: 'challengeId', label: 'Challenge ID' }, + { key: 'category', label: 'Category' }, + { key: 'isTask', label: 'Task' }, + { key: 'challengeName', label: 'Challenge name' }, + { key: 'challengeStatus', label: 'Challenge status' }, + { key: 'winnerHandle', label: 'Winner handle' }, + { key: 'winnerId', label: 'Winner ID' }, + { key: 'winnerFirstName', label: 'Winner first name' }, + { key: 'winnerLastName', label: 'Winner last name' }, +] +const PAYMENT_ROWS_PER_PAGE_OPTIONS = [10, 25, 50] + +type BillingAccountDateParamInputProps = { + label: string + parameterErrors: Record + parameterName: 'startDate' | 'endDate' + parameterValues: Record + setParameterValues: Dispatch>> +} + +function billingAccountDatePickerBounds( + parameterName: 'startDate' | 'endDate', + parsedStart: Date | undefined, + parsedEnd: Date | undefined, +): { maxDate?: Date; minDate?: Date } { + const startOk = !!parsedStart && isValid(parsedStart) + const endOk = !!parsedEnd && isValid(parsedEnd) + + if (parameterName === 'endDate' && startOk) { + return { maxDate: undefined, minDate: parsedStart } + } + + if (parameterName === 'startDate' && endOk) { + return { maxDate: parsedEnd, minDate: undefined } + } + + return {} +} + +const BillingAccountDateParamInput: FC = ( + props: BillingAccountDateParamInputProps, +) => { + const startRaw = props.parameterValues.startDate?.trim() + const endRaw = props.parameterValues.endDate?.trim() + const parsedStart = startRaw && isValidReportDateValue(startRaw) ? parseISO(startRaw) : undefined + const parsedEnd = endRaw && isValidReportDateValue(endRaw) ? parseISO(endRaw) : undefined + const rawValue = props.parameterValues[props.parameterName]?.trim() + const selectedDate = rawValue && isValidReportDateValue(rawValue) ? parseISO(rawValue) : undefined + const dateBounds = billingAccountDatePickerBounds( + props.parameterName, + parsedStart, + parsedEnd, + ) + + function handleDateChange(date: Date | null): void { + props.setParameterValues(previous => ({ + ...previous, + [props.parameterName]: date && isValid(date) ? formatIsoDate(date, 'yyyy-MM-dd') : '', + })) + } + + return ( + + ) +} + +type BillingAccountIdCellProps = { + rawId: unknown + onOpen: (id: string) => void +} + +const BillingAccountIdCell: FC = (props: BillingAccountIdCellProps) => { + const displayed = formatReportCell(props.rawId) + + function handleClick(): void { + props.onOpen(String(props.rawId)) + } + + if (displayed === '—') { + return <>{displayed} + } + + return ( + + ) +} + +const BillingAccountSummaryBody = (props: { + billingAccount: BillingAccountDetail | undefined + billingAccountIdLabel: string +}): JSX.Element => ( + <> +
    + {`Billing account ID: ${props.billingAccountIdLabel}`} +
    + {props.billingAccount ? ( +
    +
    + Name + {props.billingAccount.name} +
    +
    + Description + + {formatReportCell(props.billingAccount.description)} + +
    +
    + Subcontracting end customer + + {formatReportCell(props.billingAccount.subcontractingEndCustomer)} + +
    +
    + Status + {props.billingAccount.status} +
    +
    + Start date + + {props.billingAccount.startDate + ? formatPaymentDate(String(props.billingAccount.startDate)) + : '—'} + +
    +
    + End date + + {props.billingAccount.endDate + ? formatPaymentDate(String(props.billingAccount.endDate)) + : '—'} + +
    +
    + Budget + + {formatReportCell(props.billingAccount.budget)} + +
    +
    + Markup + + {formatReportCell(props.billingAccount.markup)} + +
    +
    + ) : ( +
    + No billing account profile was found for this ID. +
    + )} + +) + +const BillingAccountReportResults = ( + props: { data: BillingAccountsViewData }, +): JSX.Element => { + const payments: BillingAccountsViewData['payments'] = props.data.payments + const [currentPage, setCurrentPage] = useState(1) + const [rowsPerPage, setRowsPerPage] = useState(PAYMENT_ROWS_PER_PAGE_OPTIONS[0]) + const [modalBaId, setModalBaId] = useState(undefined) + const [modalProfile, setModalProfile] = useState(undefined) + const [modalLoading, setModalLoading] = useState(false) + const openBillingProfileModal = useCallback((id: string) => { + setModalBaId(id) + }, []) + const total = payments.length + const totalPages = Math.max(1, Math.ceil(total / rowsPerPage)) + const currentSliceStart = (currentPage - 1) * rowsPerPage + const paginatedPayments = payments.slice(currentSliceStart, currentSliceStart + rowsPerPage) + const showingStart = total === 0 ? 0 : ((currentPage - 1) * rowsPerPage) + 1 + const showingEnd = Math.min(currentPage * rowsPerPage, total) + + useEffect(() => { + setCurrentPage(1) + }, [payments]) + + useEffect(() => { + if (!modalBaId) { + setModalProfile(undefined) + setModalLoading(false) + return undefined + } + + let cancelled = false + setModalLoading(true) + setModalProfile(undefined) + + const profileQuery = new URLSearchParams({ billingAccountId: modalBaId }) + const profilePath = `${BILLING_ACCOUNTS_REPORT_PATH}?${profileQuery.toString()}` + + fetchReportJson(profilePath) + .then(response => { + if (!cancelled) { + setModalProfile(response.billingAccount) + } + }) + .catch(() => { + if (!cancelled) { + setModalProfile(undefined) + } + }) + .finally(() => { + if (!cancelled) { + setModalLoading(false) + } + }) + + return () => { + cancelled = true + } + }, [modalBaId]) + + function handleRowsPerPageChange(event: ChangeEvent): void { + setRowsPerPage(Number(event.target.value)) + setCurrentPage(1) + } + + function handleCloseBillingModal(): void { + setModalBaId(undefined) + } + + function renderPaymentCell( + row: SfdcBillingAccountPaymentRow, + colKey: keyof SfdcBillingAccountPaymentRow, + ): JSX.Element | string { + const value = row[colKey] + + if (colKey === 'paymentDate') { + return formatPaymentDate(String(value)) + } + + if (colKey === 'billingAccountId') { + return + } + + return formatReportCell(value) + } + + return ( +
    + + Close + + )} + > + {modalBaId === undefined ? undefined : ( +
    + {modalLoading ? ( +
    Loading billing account…
    + ) : ( + + )} +
    + )} +
    + +
    +
    Payments
    + {total === 0 ? ( +
    No payments matched the selected filters.
    + ) : ( +
    +
    +
    + + + {PAYMENT_TABLE_COLUMNS.map(col => ( + + ))} + + + + {paginatedPayments.map(row => ( + + {PAYMENT_TABLE_COLUMNS.map(col => ( + + ))} + + ))} + +
    {col.label}
    {renderPaymentCell(row, col.key)}
    + +
    +
    + + +
    +
    + {`Showing ${showingStart}-${showingEnd} of ${total} payments`} +
    + +
    + + )} + + + ) +} const buildDownloadName = ( name: string, @@ -48,12 +546,30 @@ const formatMethod = (method?: string): string => ( method ? method.toUpperCase() : 'GET' ) +const formatParameterLabel = (name: string): string => ( + name + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/Ids\b/g, 'IDs') + .replace(/^./, char => char.toUpperCase()) +) + +const buildParameterTooltipContent = (parameter: ReportParameter): JSX.Element => ( + <> +
    {parameter.description?.trim() || 'No description available.'}
    +
    + {`Location: ${parameter.location || 'query'} (${parameter.name})`} +
    + +) + type ReportActionsProps = { handleCsvDownload: () => void handleJsonDownload: () => void + handleResetFilters: () => void handleOpenBulkMemberLookup: () => void isDownloadDisabled: boolean isHandleLookupPostReport: boolean + isResetDisabled: boolean isPostReport: boolean } @@ -94,11 +610,19 @@ const ReportActions = (props: ReportActionsProps): JSX.Element => { > Download as CSV + ) } type SelectedReportSectionProps = { + descriptionNote?: JSX.Element renderParameterInput: (parameter: ReportParameter) => JSX.Element reportActions: JSX.Element selectedReport?: ReportDefinition @@ -118,6 +642,7 @@ const SelectedReportSection = (props: SelectedReportSectionProps): JSX.Element = {props.selectedReport.description} )} + {props.descriptionNote}
    {formatMethod(props.selectedReport.method)} {' '} @@ -125,51 +650,67 @@ const SelectedReportSection = (props: SelectedReportSectionProps): JSX.Element =
    - {(props.selectedReport.parameters?.length ?? 0) > 0 && ( -
    - {props.selectedReport.parameters?.map(parameter => ( -
    -
    - {parameter.name} - {parameter.required ? ' *' : ''} -
    - {parameter.description && ( -
    {parameter.description}
    - )} -
    - Location: - {' '} - {parameter.location || 'query'} - {' '} - • Type: - {' '} - {parameter.type} -
    - {parameter.type.endsWith('[]') && ( -
    - Use comma-separated values for lists. + {(props.selectedReport.parameters?.length ?? 0) > 0 ? ( +
    +
    + {props.selectedReport.parameters?.map(parameter => ( +
    +
    +
    +
    + {formatParameterLabel(parameter.name)} + {parameter.required ? ' *' : ''} +
    +
    +
    {parameter.type}
    + + + +
    +
    - )} - {props.renderParameterInput(parameter)} -
    - ))} + {props.renderParameterInput(parameter)} +
    + ))} +
    +
    + {props.reportActions} +
    + ) : ( + props.reportActions )} - - {props.reportActions} ) } -export const ReportsPage: FC = () => { +type ReportsPageContentProps = { + initialTab: ReportsPageTab +} + +// eslint-disable-next-line complexity +const ReportsPageContent: FC = props => { const navigate: NavigateFunction = useNavigate() + const [activeTab] = useState(props.initialTab) const [reportsIndex, setReportsIndex] = useState({}) const [selectedBasePath, setSelectedBasePath] = useState('') const [selectedReportPath, setSelectedReportPath] = useState('') const [isLoading, setIsLoading] = useState(false) const [downloadingFormat, setDownloadingFormat] = useState<'json' | 'csv' | undefined>(undefined) const [parameterValues, setParameterValues] = useState>({}) - + const [billingAccountViewData, setBillingAccountViewData] = useState< + BillingAccountsViewData | undefined + >(undefined) + const [isBillingAccountViewLoading, setIsBillingAccountViewLoading] = useState(false) useEffect(() => { let isMounted = true setIsLoading(true) @@ -230,15 +771,21 @@ export const ReportsPage: FC = () => { selectedGroup?.reports?.find(report => report.path === selectedReportPath) ), [selectedGroup, selectedReportPath]) + const selectedReportForForm = activeTab === 'billingAccounts' + ? BILLING_ACCOUNTS_REPORT_DEFINITION + : selectedReport + const handleBasePathChange = useCallback((event: ChangeEvent) => { setSelectedBasePath(event.target.value) setSelectedReportPath('') setParameterValues({}) + setBillingAccountViewData(undefined) }, []) const handleReportChange = useCallback((event: ChangeEvent) => { setSelectedReportPath(event.target.value) setParameterValues({}) + setBillingAccountViewData(undefined) }, []) const handleParameterChange = useCallback((event: ChangeEvent) => { @@ -291,7 +838,7 @@ export const ReportsPage: FC = () => { }, [parameterValues]) const parameterErrors = useMemo>(() => ( - (selectedReport?.parameters ?? []).reduce>((errors, parameter) => { + (selectedReportForForm?.parameters ?? []).reduce>((errors, parameter) => { const error = getReportParameterValidationError(parameter, parameterValues[parameter.name]) if (error) { @@ -300,30 +847,75 @@ export const ReportsPage: FC = () => { return errors }, {}) - ), [parameterValues, selectedReport]) + ), [parameterValues, selectedReportForForm]) const hasInvalidParameterValues = useMemo(() => ( Object.keys(parameterErrors).length > 0 ), [parameterErrors]) - const handleDownload = useCallback(async (format: 'json' | 'csv') => { + const fetchBillingPaymentsForParams = useCallback(async (params: Record) => { + try { + setIsBillingAccountViewLoading(true) + const billingAccountId = params.billingAccountId?.trim() + const paymentsPath = buildSfdcPaymentsQueryPath( + billingAccountId || undefined, + params.startDate, + params.endDate, + ) + const payments = await fetchReportJson(paymentsPath) + setBillingAccountViewData({ + payments: filterBillingPaymentsByCategory(payments, params.paymentCategory), + }) + } catch (error) { + handleError(error) + } finally { + setIsBillingAccountViewLoading(false) + } + }, []) + + useEffect(() => { + if (activeTab !== 'billingAccounts') { + return undefined + } + + fetchBillingPaymentsForParams({}) + .catch(handleError) + + return undefined + }, [activeTab, fetchBillingPaymentsForParams]) + + const handleBillingAccountView = useCallback(() => { + if (activeTab !== 'billingAccounts' || hasInvalidParameterValues) { + return + } + + fetchBillingPaymentsForParams(parameterValues) + .catch(handleError) + }, [ + activeTab, + fetchBillingPaymentsForParams, + hasInvalidParameterValues, + parameterValues, + ]) + + const handleDownload = useCallback(async (downloadFormat: 'json' | 'csv') => { if (!selectedReport || hasInvalidParameterValues) { return } try { - setDownloadingFormat(format) + setDownloadingFormat(downloadFormat) const requestPath = buildReportPathWithParams(selectedReport) - const blob = format === 'json' + const blob = downloadFormat === 'json' ? await downloadReportAsJson(requestPath) : await downloadReportAsCsv(requestPath) const challengeIdSuffix = parameterValues.challengeId?.trim() const fileName = buildDownloadName( selectedReport.name, - format, + downloadFormat, challengeIdSuffix, ) downloadBlobFile(blob, fileName) @@ -338,18 +930,35 @@ export const ReportsPage: FC = () => { navigate(bulkMemberLookupRouteId) }, [navigate]) + const handleResetFilters = useCallback(() => { + setParameterValues({}) + + if (activeTab === 'billingAccounts') { + fetchBillingPaymentsForParams({}) + .catch(handleError) + return + } + + setBillingAccountViewData(undefined) + }, [activeTab, fetchBillingPaymentsForParams]) + + const handleBillingAccountViewClick = useCallback(() => { + handleBillingAccountView() + }, [handleBillingAccountView]) + const isDownloading = downloadingFormat !== undefined + const isBusy = isDownloading || isBillingAccountViewLoading const requiredParamsMissing = useMemo(() => { - const params = selectedReport?.parameters ?? [] + const params = selectedReportForForm?.parameters ?? [] return params.some(param => param.required && !(parameterValues[param.name]?.trim())) - }, [parameterValues, selectedReport]) + }, [parameterValues, selectedReportForForm]) const hasUnresolvedPathParams = useMemo(() => ( - (selectedReport?.parameters ?? []) + (selectedReportForForm?.parameters ?? []) .filter(param => param.location === 'path') .some(param => !parameterValues[param.name]?.trim()) - ), [parameterValues, selectedReport]) + ), [parameterValues, selectedReportForForm]) const isPostReport = selectedReport?.method?.toUpperCase() === 'POST' const isHandleLookupPostReport = isPostReport && selectedReport.path === bulkMembersByHandlesPath @@ -360,6 +969,13 @@ export const ReportsPage: FC = () => { || hasInvalidParameterValues || hasUnresolvedPathParams + const billingAccountViewDisabled = !selectedReportForForm + || isDownloading + || isBillingAccountViewLoading + || hasInvalidParameterValues + + const isResetDisabled = Object.keys(parameterValues).length === 0 + const handleJsonDownload = useCallback(() => { handleDownload('json') }, [handleDownload]) @@ -368,47 +984,87 @@ export const ReportsPage: FC = () => { handleDownload('csv') }, [handleDownload]) + const billingAccountReportActions = ( +
    + + +
    + ) + const reportActions = ( ) + // eslint-disable-next-line complexity -- mirrors report parameter types (text, select, billing dates) const renderParameterInput = useCallback((parameter: ReportParameter) => { const commonProps = { - label: parameter.name, + label: formatParameterLabel(parameter.name), name: parameter.name, placeholder: parameter.type === 'date' ? 'YYYY-MM-DD' : (parameter.type.endsWith('[]') ? 'Comma-separated values' : 'Enter value'), } - if (parameter.type === 'boolean') { - const options: InputSelectOption[] = [ - { label: 'True', value: 'true' }, - { label: 'False', value: 'false' }, - ] + const isBillingForm = selectedReportForForm?.path === BILLING_ACCOUNTS_REPORT_PATH + const isBillingDateField = isBillingForm + && parameter.type === 'date' + && (parameter.name === 'startDate' || parameter.name === 'endDate') + + if (isBillingForm && parameter.name === 'paymentCategory') { return ( + ) + } + + if (isBillingDateField) { + return ( + ) } - if (parameter.type === 'enum') { - const options: InputSelectOption[] = (parameter.options ?? []).map(option => ({ - label: option, - value: option, - })) + if (parameter.type === 'boolean' || parameter.type === 'enum') { + const options: InputSelectOption[] = parameter.type === 'boolean' + ? [ + { label: 'True', value: 'true' }, + { label: 'False', value: 'false' }, + ] + : (parameter.options ?? []).map(option => ({ + label: option, + value: option, + })) return ( { return ( { hint={parameter.type === 'date' ? 'Use ISO 8601 format (e.g. 2024-01-31)' : undefined} /> ) - }, [createSelectParamChange, handleParameterChange, parameterErrors, parameterValues]) + }, [ + createSelectParamChange, + handleParameterChange, + parameterErrors, + parameterValues, + selectedReportForForm?.path, + setParameterValues, + ]) return ( <> - {isDownloading && ( - + {isBusy && ( + )}
    {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. -

    + {activeTab === 'reports' && ( +

    + Select a base path to view available reports. Choose a report, fill required + parameters, and download JSON or CSV from the reports API. +

    + )} {isLoading ? (
    @@ -451,42 +1120,62 @@ export const ReportsPage: FC = () => {
    ) : ( <> - {basePathOptions.length ? ( -
    - - - {selectedGroup && ( - + {activeTab === 'reports' ? ( + <> + {basePathOptions.length ? ( +
    + + + {selectedGroup && ( + + )} +
    + ) : ( +
    + No reports are currently available. +
    )} -
    + + + ) : ( -
    - No reports are currently available. -
    + + If no dates are specified, records from the past 45 days are + displayed by default. +

    + )} + renderParameterInput={renderParameterInput} + reportActions={billingAccountReportActions} + selectedReport={BILLING_ACCOUNTS_REPORT_DEFINITION} + /> )} - + {activeTab === 'billingAccounts' && billingAccountViewData ? ( + + ) : undefined} )}
    @@ -494,4 +1183,12 @@ export const ReportsPage: FC = () => { ) } +export const ReportsPage: FC = () => ( + +) + +export const BillingAccountsPage: FC = () => ( + +) + export default ReportsPage diff --git a/src/apps/reports/src/pages/talent/TalentPage.module.scss b/src/apps/reports/src/pages/talent/TalentPage.module.scss new file mode 100644 index 000000000..8fb04cb20 --- /dev/null +++ b/src/apps/reports/src/pages/talent/TalentPage.module.scss @@ -0,0 +1,398 @@ +@import '@libs/ui/styles/includes'; + +.page { + display: flex; + flex-direction: column; + gap: 24px; +} +.header { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: flex-start; + + h1 { + margin: 0 0 6px; + font-size: 32px; + line-height: 40px; + color: #111b46; + } + + p { + margin: 0; + color: #5a6488; + } +} + +.headerActions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 12px; +} + +.summaryGrid { + display: grid; + grid-template-columns: minmax(240px, 360px) minmax(0, 1fr); + gap: 16px; +} + +.metricPanel, +.rolesPanel, +.membersSection { + border: 1px solid #e3e7ef; + border-radius: 8px; + background: #fff; + box-shadow: 0 2px 8px rgba(18, 24, 40, 0.05); +} + +.metricPanel { + min-height: 220px; + display: flex; + flex-direction: column; + justify-content: center; + padding: 28px; +} + +.panelLabel { + font-weight: 700; + text-transform: uppercase; + color: #111b46; + font-size: 13px; + letter-spacing: 0.02em; +} + +.metricPanel strong { + margin-top: 18px; + color: #0f62fe; + font-size: 52px; + line-height: 60px; +} + +.metricMeta { + margin-top: 4px; + color: #5a6488; +} + +.rolesPanel { + min-height: 220px; + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + align-items: center; + gap: 24px; + padding: 24px; +} + +.rolesChartWrap { + display: flex; + justify-content: center; +} + +.donut { + width: 180px; + height: 180px; + border-radius: 50%; + display: grid; + place-items: center; +} + +.donutInner { + width: 104px; + height: 104px; + border-radius: 50%; + background: #fff; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #111b46; + + strong { + font-size: 24px; + line-height: 30px; + } + + span { + color: #5a6488; + font-size: 13px; + } +} + +.roleList { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: 8px 20px; + + button { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + min-height: 38px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: #111b46; + cursor: pointer; + font: inherit; + text-align: left; + } + + button:hover, + .activeRole { + border-color: #c7d7ff; + background: #f4f7ff; + } +} + +.roleName { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.swatch { + width: 10px; + height: 10px; + border-radius: 50%; + flex: 0 0 auto; +} + +.membersSection { + padding: 20px 24px 12px; +} + +.membersHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 16px; + + h2 { + display: inline-flex; + align-items: center; + gap: 10px; + margin: 0; + color: #111b46; + font-size: 22px; + line-height: 30px; + } +} + +.countBadge { + display: inline-flex; + align-items: center; + min-height: 28px; + margin-left: 10px; + padding: 4px 8px; + border-radius: 6px; + background: #e9f1ff; + color: #0f62fe; + font-weight: 700; +} + +.filterGroup { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 12px; + color: #111b46; + font-weight: 600; +} + +.segmentedControl { + display: inline-flex; + border: 1px solid #d6dbe8; + border-radius: 6px; + overflow: hidden; + + button { + min-height: 34px; + padding: 6px 12px; + border: 0; + border-right: 1px solid #d6dbe8; + background: #fff; + color: #34406b; + cursor: pointer; + font: inherit; + } + + button:last-child { + border-right: 0; + } + + button:hover, + .activeSegment { + background: #0f62fe; + color: #fff; + } +} + +.tableWrap { + overflow-x: auto; + border: 1px solid #e3e7ef; + border-radius: 8px; +} + +.membersTable { + width: 100%; + min-width: 980px; + border-collapse: collapse; + font-size: 13px; + + th, + td { + padding: 12px 14px; + text-align: left; + border-bottom: 1px solid #edf0f6; + vertical-align: top; + } + + th { + background: #f7f8fb; + color: #5a6488; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + white-space: nowrap; + } + + tbody tr:last-child td { + border-bottom: 0; + } +} + +.memberCell { + display: flex; + flex-direction: column; + gap: 2px; + + strong { + color: #0f62fe; + font-size: 14px; + } + + span { + color: #5a6488; + } +} + +.roleChips { + display: flex; + flex-wrap: wrap; + gap: 6px; + + span { + display: inline-flex; + align-items: center; + max-width: 240px; + min-height: 24px; + padding: 3px 8px; + border-radius: 6px; + background: #f1f5f9; + color: #26345d; + white-space: normal; + } +} + +.profileLink { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 6px 12px; + border: 1px solid #0f62fe; + border-radius: 6px; + color: #0f62fe; + font-weight: 700; + white-space: nowrap; +} + +.profileLink:hover { + background: #f4f7ff; + color: #0043ce; +} + +.emptyState { + padding: 24px; + color: #5a6488; + font-style: italic; +} + +.paginationBar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 12px; + color: #5a6488; + font-size: 13px; +} + +.rowsControl { + display: flex; + align-items: center; + gap: 8px; + + label { + color: #5a6488; + } + + select { + min-width: 72px; + padding: 5px 8px; + border: 1px solid #d6dbe8; + border-radius: 6px; + background: #fff; + color: #111b46; + } +} + +@media (max-width: 1180px) { + .summaryGrid, + .rolesPanel { + grid-template-columns: 1fr; + } + + .roleList { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .header, + .membersHeader, + .filterGroup { + align-items: stretch; + flex-direction: column; + } + + .headerActions, + .filterGroup { + justify-content: flex-start; + } + + .metricPanel, + .rolesPanel, + .membersSection { + padding: 16px; + } + + .metricPanel strong { + font-size: 42px; + line-height: 50px; + } + + .segmentedControl { + width: 100%; + + button { + flex: 1 1 0; + } + } +} diff --git a/src/apps/reports/src/pages/talent/TalentPage.tsx b/src/apps/reports/src/pages/talent/TalentPage.tsx new file mode 100644 index 000000000..3dcb72325 --- /dev/null +++ b/src/apps/reports/src/pages/talent/TalentPage.tsx @@ -0,0 +1,433 @@ +import { + ChangeEvent, + CSSProperties, + FC, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' +import { Navigate } from 'react-router-dom' + +import { EnvironmentConfig } from '~/config' +import { ReportsAppContext, ReportsAppContextModel } from '~/apps/reports/src/lib' +import { Pagination } from '~/apps/admin/src/lib' +import { + Button, + IconOutline, + LoadingSpinner, + PageTitle, +} from '~/libs/ui' + +import { + downloadBlobFile, + downloadOpenToWorkTalentCsv, + fetchOpenToWorkTalent, + OpenToWorkTalentAvailability, + OpenToWorkTalentMember, + OpenToWorkTalentResponse, + OpenToWorkTalentRoleCount, +} from '../../lib/services' +import { canAccessTalentReport, handleError } from '../../lib/utils' +import { reportsPageRouteId, rootRoute } from '../../config/routes.config' + +import { + formatAvailability, + formatMemberSince, + formatPreferredRole, +} from './TalentPage.utils' +import styles from './TalentPage.module.scss' + +const pageTitle = 'Talent' +const rowsPerPageOptions = [10, 25, 50] +const chartColors = [ + '#0f62fe', + '#2ebac6', + '#6aae3f', + '#ff8a00', + '#6c5ce7', + '#e82f72', + '#f6b31a', + '#2d7f75', + '#8f6448', + '#aeb5c8', + '#4b7bec', + '#d653a3', +] + +const reportsLandingRoute = rootRoute || `/${reportsPageRouteId}` + +type TalentRoleSegment = OpenToWorkTalentRoleCount & { + color: string + label: string + percent: number +} + +type AvailabilityOption = { + label: string + value?: OpenToWorkTalentAvailability +} + +const availabilityOptions: AvailabilityOption[] = [ + { label: 'All', value: undefined }, + { label: 'Full-time', value: 'FULL_TIME' }, + { label: 'Part-time', value: 'PART_TIME' }, +] + +/** + * Builds the conic-gradient background used by the role summary chart. + * @param segments Role count segments with colors and percentages. + * @returns CSS background value for the donut chart. + */ +function buildDonutBackground(segments: TalentRoleSegment[]): string { + if (!segments.length) { + return '#e8ebf2' + } + + let cursor = 0 + const stops = segments.map(segment => { + const start = cursor + const end = cursor + segment.percent + cursor = end + return `${segment.color} ${start}% ${end}%` + }) + + return `conic-gradient(${stops.join(', ')})` +} + +/** + * Formats member first/last name values for table display. + * @param member Open-to-work member row. + * @returns Display name, falling back to handle. + */ +function formatMemberName(member: OpenToWorkTalentMember): string { + const name = [member.firstName, member.lastName] + .map(value => value?.trim()) + .filter(Boolean) + .join(' ') + + return name || member.handle +} + +/** + * Administrator and Talent Manager report page for open-to-work members. + * + * It fetches preferred-role aggregates, renders a role-filterable member list, + * and downloads the matching CSV export from reports-api. + */ +// eslint-disable-next-line complexity +const TalentPage: FC = () => { + const { loginUserInfo }: ReportsAppContextModel = useContext(ReportsAppContext) + const isAuthLoaded = loginUserInfo !== undefined + const canAccessTalent = canAccessTalentReport(loginUserInfo?.roles) + + const [selectedRole, setSelectedRole] = useState(undefined) + const [availability, setAvailability] = useState(undefined) + const [page, setPage] = useState(1) + const [perPage, setPerPage] = useState(rowsPerPageOptions[0]) + const [data, setData] = useState(undefined) + const [isLoading, setIsLoading] = useState(false) + const [isDownloading, setIsDownloading] = useState(false) + const [refreshKey, setRefreshKey] = useState(0) + + const roleSegments = useMemo(() => { + const roleTotal = (data?.roleCounts ?? []) + .reduce((total, roleCount) => total + roleCount.count, 0) + + return (data?.roleCounts ?? []).map((roleCount, index) => ({ + ...roleCount, + color: chartColors[index % chartColors.length], + label: formatPreferredRole(roleCount.role), + percent: roleTotal > 0 ? (roleCount.count / roleTotal) * 100 : 0, + })) + }, [data]) + + const donutStyle = useMemo(() => ({ + background: buildDonutBackground(roleSegments), + }), [roleSegments]) + + const selectedRoleLabel = selectedRole ? formatPreferredRole(selectedRole) : 'All roles' + const totalPages = Math.max(1, Math.ceil((data?.total ?? 0) / perPage)) + const totalShownStart = data?.total ? ((page - 1) * perPage) + 1 : 0 + const totalShownEnd = Math.min(page * perPage, data?.total ?? 0) + const paginationLabel = `Showing ${totalShownStart}-${totalShownEnd} of ${ + (data?.total ?? 0).toLocaleString() + } members` + + useEffect(() => { + if (!canAccessTalent) { + return undefined + } + + let cancelled = false + setIsLoading(true) + + fetchOpenToWorkTalent({ + availability, + page, + perPage, + role: selectedRole, + }) + .then(response => { + if (!cancelled) { + setData(response) + } + }) + .catch(handleError) + .finally(() => { + if (!cancelled) { + setIsLoading(false) + } + }) + + return () => { + cancelled = true + } + }, [availability, canAccessTalent, page, perPage, refreshKey, selectedRole]) + + useEffect(() => { + if (page > totalPages) { + setPage(totalPages) + } + }, [page, totalPages]) + + const handleSelectAllRoles = useCallback(() => { + setSelectedRole(undefined) + setPage(1) + }, []) + + const createHandleRoleClick = useCallback((role: string) => () => { + setSelectedRole(role) + setPage(1) + }, []) + + const createHandleAvailabilityClick = useCallback(( + nextAvailability?: OpenToWorkTalentAvailability, + ) => () => { + setAvailability(nextAvailability) + setPage(1) + }, []) + + const handleRowsPerPageChange = useCallback((event: ChangeEvent) => { + setPerPage(Number(event.target.value)) + setPage(1) + }, []) + + const handleRefresh = useCallback(() => { + setRefreshKey(key => key + 1) + }, []) + + const handleDownload = useCallback(async (): Promise => { + try { + setIsDownloading(true) + const blob = await downloadOpenToWorkTalentCsv({ + availability, + role: selectedRole, + }) + const rolePart = selectedRole ? selectedRole.toLowerCase() : 'all-roles' + downloadBlobFile(blob, `open-to-work-talent-${rolePart}.csv`) + } catch (error) { + handleError(error) + } finally { + setIsDownloading(false) + } + }, [availability, selectedRole]) + + if (isAuthLoaded && !canAccessTalent) { + return + } + + if (!isAuthLoaded) { + return + } + + return ( + <> + {pageTitle} + {(isLoading || isDownloading) && ( + + )} + +
    +
    +
    +

    Talent

    +

    Open-to-work Topcoder members grouped by preferred role.

    +
    +
    + + +
    +
    + +
    +
    + Open to work + {(data?.totalMembers ?? 0).toLocaleString()} + members with preferred roles +
    + +
    +
    +
    +
    + {(data?.totalMembers ?? 0).toLocaleString()} + members +
    +
    +
    +
    + + {roleSegments.map(segment => ( + + ))} +
    +
    +
    + +
    +
    +
    +

    Members open to work

    + {(data?.total ?? 0).toLocaleString()} +
    +
    + {selectedRoleLabel} +
    + {availabilityOptions.map(option => ( + + ))} +
    +
    +
    + +
    + + + + + + + + + + + + + + + {(data?.data ?? []).map(member => ( + + + + + + + + + + + ))} + +
    MemberPreferred rolesAvailabilityCountryMember sinceRatingWinsActions
    +
    + {formatMemberName(member)} + {member.handle} +
    +
    +
    + {member.preferredRoles.map(role => ( + {formatPreferredRole(role)} + ))} +
    +
    {formatAvailability(member.availability)}{member.country || 'Not available'}{formatMemberSince(member.memberSince)}{member.maxRating ?? 'Not rated'}{member.totalWins.toLocaleString()} + + View profile + +
    + {!isLoading && data?.data.length === 0 && ( +
    No members match the selected filters.
    + )} +
    + +
    + {paginationLabel} +
    + + +
    + +
    +
    +
    + + ) +} + +export default TalentPage diff --git a/src/apps/reports/src/pages/talent/TalentPage.utils.spec.ts b/src/apps/reports/src/pages/talent/TalentPage.utils.spec.ts new file mode 100644 index 000000000..c7b277cab --- /dev/null +++ b/src/apps/reports/src/pages/talent/TalentPage.utils.spec.ts @@ -0,0 +1,28 @@ +import { + formatAvailability, + formatMemberSince, + formatPreferredRole, +} from './TalentPage.utils' + +describe('TalentPage utils', () => { + it('formats known and unknown preferred roles', () => { + expect(formatPreferredRole('FULL_STACK_DEVELOPER')) + .toBe('Full-Stack Developer') + expect(formatPreferredRole('CUSTOM_ROLE_VALUE')) + .toBe('Custom Role Value') + }) + + it('formats availability values', () => { + expect(formatAvailability('FULL_TIME')) + .toBe('Full-time') + expect(formatAvailability(undefined)) + .toBe('Not specified') + }) + + it('formats member since dates', () => { + expect(formatMemberSince('2024-02-03T00:00:00.000Z')) + .toMatch(/2024/) + expect(formatMemberSince('invalid')) + .toBe('Not available') + }) +}) diff --git a/src/apps/reports/src/pages/talent/TalentPage.utils.ts b/src/apps/reports/src/pages/talent/TalentPage.utils.ts new file mode 100644 index 000000000..874a87500 --- /dev/null +++ b/src/apps/reports/src/pages/talent/TalentPage.utils.ts @@ -0,0 +1,76 @@ +export const availabilityLabels: Record = { + FULL_TIME: 'Full-time', + PART_TIME: 'Part-time', +} + +export const preferredRoleLabels: Record = { + AI_ML_ENGINEER: 'AI / ML Engineer', + AI_PROMPT_ENGINEER: 'AI Prompt Engineer', + CLOUD_ENGINEER: 'Cloud Engineer / Solutions Architect', + CYBERSECURITY_ENGINEER: 'Cybersecurity Analyst / Security Engineer', + DATA_SCIENTIST_ENGINEER: 'Data Scientist / Data Engineer', + DB_ADMIN: 'Database Administrator', + DEVOPS_SRE: 'DevOps Engineer / SRE', + ENTERPRISE_ARCHITECT: 'Enterprise Architect', + FULL_STACK_DEVELOPER: 'Full-Stack Developer', + QA_AUTOMATION_ENGINEER: 'QA Lead / Automation Engineer', + TECHNICAL_PM: 'Technical Project Manager', + UX_DESIGNER: 'UX Designer', +} + +/** + * Formats a preferred-role value for display. + * @param role Preferred role value stored in the openToWork trait. + * @returns Human-readable role label. + */ +export function formatPreferredRole(role: string): string { + if (preferredRoleLabels[role]) { + return preferredRoleLabels[role] + } + + return role + .toLowerCase() + .split(/[_\s-]+/) + .filter(Boolean) + .map(part => { + const firstLetter = part.charAt(0) + .toUpperCase() + return `${firstLetter}${part.slice(1)}` + }) + .join(' ') +} + +/** + * Formats an open-to-work availability value for display. + * @param availability Availability value from the openToWork trait. + * @returns Human-readable availability label. + */ +export function formatAvailability(availability: string | null | undefined): string { + if (!availability) { + return 'Not specified' + } + + return availabilityLabels[availability] ?? formatPreferredRole(availability) +} + +/** + * Formats an ISO date as a compact member-since label. + * @param isoDate Date string returned by the reports API. + * @returns Month/year label, or fallback text when no valid date exists. + */ +export function formatMemberSince(isoDate: string | null | undefined): string { + if (!isoDate) { + return 'Not available' + } + + const parsed = new Date(isoDate) + + if (Number.isNaN(parsed.getTime())) { + return 'Not available' + } + + return parsed.toLocaleDateString(undefined, { + month: 'short', + year: 'numeric', + }) +} diff --git a/src/apps/talent-search/src/routes/talent-page/index.ts b/src/apps/reports/src/pages/talent/index.ts similarity index 100% rename from src/apps/talent-search/src/routes/talent-page/index.ts rename to src/apps/reports/src/pages/talent/index.ts diff --git a/src/apps/reports/src/reports-app.routes.tsx b/src/apps/reports/src/reports-app.routes.tsx index df4b56ecd..b77563358 100644 --- a/src/apps/reports/src/reports-app.routes.tsx +++ b/src/apps/reports/src/reports-app.routes.tsx @@ -11,9 +11,11 @@ import { } from '~/libs/core' import { + billingAccountsPageRouteId, bulkMemberLookupRouteId, reportsPageRouteId, rootRoute, + talentPageRouteId, } from './config/routes.config' const ReportsApp: LazyLoadedComponent = lazyLoad(() => import('./ReportsApp')) @@ -21,10 +23,17 @@ const ReportsPage: LazyLoadedComponent = lazyLoad( () => import('./pages/reports/ReportsPage'), 'ReportsPage', ) +const BillingAccountsPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/reports/BillingAccountsPage'), +) const BulkMemberLookupPage: LazyLoadedComponent = lazyLoad( () => import('./pages/bulk-member-lookup/BulkMemberLookupPage'), 'BulkMemberLookupPage', ) +const TalentPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/talent'), + 'TalentPage', +) export const toolTitle: string = ToolTitle.reports @@ -43,11 +52,21 @@ export const reportsRoutes: ReadonlyArray = [ element: , route: reportsPageRouteId, }, + { + authRequired: true, + element: , + route: billingAccountsPageRouteId, + }, { authRequired: true, element: , route: bulkMemberLookupRouteId, }, + { + authRequired: true, + element: , + route: talentPageRouteId, + }, ], domain: AppSubdomain.reports, element: , diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss index e09b31d55..c3294b1b3 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss @@ -15,6 +15,9 @@ display: flex; gap: $sp-2; + + flex-wrap: wrap; + align-items: flex-start; } .lockedTitle { @@ -28,8 +31,10 @@ .lockedMessage { margin-top: $sp-1; font-size: 14px; - max-width: 720px; - white-space: normal; + max-width: 100%; + word-break: break-word; + overflow-wrap: break-word; + white-space: pre-line; } .reRunIcon { @@ -146,3 +151,116 @@ } } + +@media (max-width: 768px) { + .wrap { + overflow: visible; + } + + .lockedBanner { + flex-direction: column; + margin-left: $sp-1; + margin-right: $sp-1; + padding: $sp-3 $sp-2; + gap: $sp-1; + } + + .lockedTitle { + width: 100%; + } + + .lockedMessage { + width: 100%; + margin-top: $sp-2; + } + + .mobileRow { + flex-direction: column; + padding-left: $sp-2; + padding-right: $sp-2; + + > * { + flex: 0 0 auto; + width: 100%; + margin-bottom: $sp-1; + } + } +} + + +.notesBanner { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 12px 16px; + background: #e8f4fd; + border: 1px solid #b3d9f7; + border-radius: 4px; + margin-bottom: 12px; + color: #1a6fa8; +} + +.notesTitle { + font-weight: 600; + margin-bottom: 4px; +} + +.notesMessage { + font-size: 13px; + line-height: 1.4; + margin-top: 4px; + white-space: pre-wrap; +} + +.escalationNotes { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255,255,255,0.3); +} + +// Score with inline override +.scoreWithOverride { + display: flex; + align-items: center; + gap: 8px; +} + +.originalScore { + color: $black-40; + font-size: 12px; +} + +.overrideInput { + width: 70px; + padding: 4px 6px; + border: 1px solid $link-blue-dark; + border-radius: 4px; + font-size: 13px; + font-family: inherit; + text-align: center; + + &:focus { + outline: none; + border-color: $link-blue-dark; + box-shadow: 0 0 0 2px rgba(13, 105, 212, 0.15); + } + + &::placeholder { + color: $black-40; + font-size: 10px; + } +} + +.overriddenScore { + display: flex; + align-items: center; + gap: 4px; + color: $orange-120; + font-weight: 600; +} + +.overrideLabel { + font-size: 11px; + font-weight: 400; + color: $black-40; +} diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx index e91f51241..384489ada 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -1,3 +1,5 @@ +/* eslint-disable complexity */ +/* eslint-disable max-len */ import { FC, MouseEvent as ReactMouseEvent, useCallback, useContext, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { toast } from 'react-toastify' @@ -28,6 +30,8 @@ import { AiReviewConfigWorkflow, AiReviewDecision, AiReviewDecisionBreakdownWorkflow, + AiReviewDecisionEscalation, + BackendResource, BackendSubmission, ChallengeDetailContextModel, } from '../../models' @@ -44,11 +48,12 @@ interface AiReviewsTableProps { interface AiReviewerRow { id: string isGating?: boolean + initialScore?: number minScore?: number reviewDate?: string run?: Pick score?: number - status?: 'failed' | 'failed-score' | 'passed' | 'pending' + status?: 'failed' | 'failed-score' | 'passed' | 'pending' | 'cancelled' title: string weight?: number workflowId?: string @@ -62,11 +67,15 @@ function normalizeStatus( runStatus?: string | null, score?: number | null, minScore?: number, -): 'failed' | 'failed-score' | 'passed' | 'pending' { +): 'failed' | 'failed-score' | 'passed' | 'pending' | 'cancelled' { if (!runStatus) { return 'pending' } + if (runStatus === AiWorkflowRunStatusEnum.CANCELLED) { + return 'cancelled' + } + if (aiRunInProgress({ status: runStatus as AiWorkflowRunStatusEnum })) { return 'pending' } @@ -110,6 +119,66 @@ function getDecisionBySubmission( return decisions[submissionId] } +/** + * Resolves a memberId to a display handle using the resourceMemberIdMapping. + * Falls back to the raw id string if no match is found. + */ +function resolveHandle( + memberId: string | null | undefined, + resourceMemberIdMapping: Record, +): string { + if (!memberId) return '' + return resourceMemberIdMapping[memberId]?.memberHandle ?? memberId +} + +/** + * Builds a list of human-readable note strings from escalations and lock/unlock reason. + * + * @param escalations - List of escalation objects from the AI review decision + * @param reason - The reason string from the decision + * @param showAuthor - When true, appends "(by )" to each note. + * Pass false for reviewer role so author identity is hidden. + * Defaults to true. + * @param resourceMemberIdMapping - Map of memberId → BackendResource used to resolve handles. + * @param submissionLocked - When true, labels the reason as "Locked Reason"; + * otherwise labels it as "Unlock Reason". + */ +function buildDecisionNotes( + escalations?: AiReviewDecisionEscalation[], + reason?: string | null, + showAuthor: boolean = true, + resourceMemberIdMapping: Record = {}, + submissionLocked: boolean = false, +): string[] { + const parts: string[] = [] + + escalations?.forEach(esc => { + if (esc.escalationNotes) { + const handle = resolveHandle(esc.createdBy, resourceMemberIdMapping) + const by = showAuthor && handle ? ` (by ${handle})` : '' + parts.push(`Escalation Note${by}: ${esc.escalationNotes}`) + } + + if (esc.approverNotes) { + const handle = resolveHandle(esc.updatedBy, resourceMemberIdMapping) + const by = showAuthor && handle ? ` (by ${handle})` : '' + const prefix = esc.status === 'APPROVED' + ? 'Approval Note' + : esc.status === 'REJECTED' + ? 'Rejection Note' + : 'Approver Note' + parts.push(`${prefix}${by}: ${esc.approverNotes}`) + } + }) + + if (reason) { + const reasonLabel: string = submissionLocked ? 'Locked Reason' : 'Unlock Reason' + parts.push(`${reasonLabel}: ${reason}`) + } + + return parts +} + // eslint-disable-next-line complexity const AiReviewsTable: FC = props => { const { runs, isLoading }: AiWorkflowRunsResponse = useFetchAiWorkflowsRuns(props.submission.id) @@ -121,6 +190,8 @@ const AiReviewsTable: FC = props => { = challengeDetailContext.isLoadingAiReviewConfig const isLoadingAiReviewDecisions: ChallengeDetailContextModel['isLoadingAiReviewDecisions'] = challengeDetailContext.isLoadingAiReviewDecisions + const resourceMemberIdMapping: ChallengeDetailContextModel['resourceMemberIdMapping'] + = challengeDetailContext.resourceMemberIdMapping const windowSize: WindowSize = useWindowSize() const isTablet = useMemo( @@ -185,6 +256,7 @@ const AiReviewsTable: FC = props => { return { id: workflowId, + initialScore: run?.initialScore, isGating: fromDecision?.isGating ?? configured?.isGating, minScore, reviewDate: run?.completedAt, @@ -241,10 +313,17 @@ const AiReviewsTable: FC = props => { const loading = isLoading || isLoadingAiReviewConfig || isLoadingAiReviewDecisions const rolePermissions: UseRolePermissionsResult = useRolePermissions() - const { isAdmin, hasSubmitterRole }: UseRolePermissionsResult = rolePermissions + const { isAdmin, hasSubmitterRole, hasCopilotRole, isProjectManager }: UseRolePermissionsResult = rolePermissions const { mutate }: FullConfiguration = useSWRConfig() const [, setRerunningRunId] = useState(undefined) + /** + * Only Copilot, Project Manager, and Admin can see WHO performed the action. + * Reviewers can see the note TEXT but NOT the author "(by handle)". + * Submitters cannot see notes at all. + */ + const canSeeAuthor = isAdmin || hasCopilotRole || isProjectManager + const handleRerun = useCallback(async (runId?: string): Promise => { if (!runId || runId === '-1') return @@ -274,12 +353,11 @@ const AiReviewsTable: FC = props => { } const failedReviewersText = failedGatingReviewers.length - ? `Gating Reviewers failed: ${failedGatingReviewers.join(', ')}. - This submission is automatically failed regardless of Overall Score.` + ? `This submission failed regardless of Overall Score because it failed one or more of the AI Gating Reviews. + Gating Reviewers failed: ${failedGatingReviewers.join(', ')}.` : `This submission is failed because ${hasSubmitterRole ? 'your' : 'the'} - Overall Score is bellow minimum threshold.` + Overall Score is below minimum threshold.` - // Message text varies by role const roleBasedText = hasSubmitterRole ? 'Improve your submission and resubmit.' : '' @@ -293,12 +371,67 @@ const AiReviewsTable: FC = props => { } if (hasSubmitterRole) { - return 'Submission Locked - Your submission will not be reviewed in the Review Phase.' + return 'Submission Locked - Your submission won\'t be reviewed during the Review Phase.' } - return 'Submission Locked - This submission doesn\'t have to be reviewed in Review Phase.' + return 'Submission Locked - This submission won\'t be reviewed during the Review Phase.' }, [currentDecision?.submissionLocked, hasSubmitterRole]) + /** + * Builds the notes list shown in the banner. + * + * - Submitters → NO notes shown at all (empty array) + * - Reviewers → note text only, NO "(by handle)" (canSeeAuthor = false) + * - Copilot / PM / Admin → note text WITH "(by handle)" (canSeeAuthor = true) + */ + const decisionNotes = useMemo((): string[] => { + if (!currentDecision || hasSubmitterRole) return [] + + return buildDecisionNotes( + currentDecision.escalations, + currentDecision.reason, + canSeeAuthor, + resourceMemberIdMapping, + currentDecision.submissionLocked, + ) + }, [canSeeAuthor, currentDecision, hasSubmitterRole, resourceMemberIdMapping]) + + const hasDecisionNotes = decisionNotes.length > 0 + + const notesPanel = ( + <> + {/* Unlocked submission: show blue notes banner */} + {currentDecision?.status === 'HUMAN_OVERRIDE' + && !currentDecision?.submissionLocked + && hasDecisionNotes && ( +
    + +
    +
    Submission Unlocked
    + {decisionNotes.map((note, i) => ( + // eslint-disable-next-line react/no-array-index-key +
    {note}
    + ))} +
    +
    + )} + + {/* Locked submission with escalation/approval notes: show yellow notes banner */} + {currentDecision?.submissionLocked && hasDecisionNotes && ( +
    + +
    +
    Review Activity Notes
    + {decisionNotes.map((note, i) => ( + // eslint-disable-next-line react/no-array-index-key +
    {note}
    + ))} +
    +
    + )} + + ) + if (isTablet) { return (
    @@ -312,6 +445,8 @@ const AiReviewsTable: FC = props => {
    )} + {notesPanel} + {!reviewerRows.length && loading && (
    Loading...
    )} @@ -372,11 +507,18 @@ const AiReviewsTable: FC = props => {
    {typeof row.score === 'number' ? ( row.workflowId ? ( - - {formatScore(row.score)} - + <> + + {formatScore(row.score)} + + {row.initialScore !== null && row.initialScore !== undefined && ( + + (overriden) + + )} + ) : formatScore(row.score) ) : '-'}
    @@ -425,6 +567,8 @@ const AiReviewsTable: FC = props => {
    )} + {notesPanel} + @@ -480,11 +624,18 @@ const AiReviewsTable: FC = props => { diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx index 976d3079d..310d2b484 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx @@ -8,21 +8,24 @@ import StatusLabel from './StatusLabel' interface AiWorkflowRunStatusProps { run?: Pick - status?: 'passed' | 'pending' | 'failed-score' | 'failed' | 'human-override' + status?: 'passed' | 'pending' | 'failed-score' | 'failed' | 'cancelled' | 'human-override' score?: number hideLabel?: boolean showScore?: boolean action?: ReactNode } -const aiRunStatus = (run: Pick): string => { +const aiRunStatus = ( + run: Pick, +): 'pending' | 'failed' | 'cancelled' | 'passed' | 'failed-score' => { const isInProgress = aiRunInProgress(run) const isFailed = aiRunFailed(run) + const isCancelled = run.status === 'CANCELLED' const isPassing = ( run.status === 'SUCCESS' && run.score >= (run.workflow.scorecard?.minimumPassingScore ?? 0) ) - return isInProgress ? 'pending' : isFailed ? 'failed' : ( + return isInProgress ? 'pending' : isCancelled ? 'cancelled' : isFailed ? 'failed' : ( isPassing ? 'passed' : 'failed-score' ) } @@ -85,6 +88,16 @@ export const AiWorkflowRunStatus: FC = props => { action={props.action} /> )} + {displayStatus === 'cancelled' && ( + } + hideLabel={props.hideLabel} + status='failed' + label='Cancelled' + score={score} + action={props.action} + /> + )} {displayStatus === 'human-override' && ( } diff --git a/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx b/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx index ff02f379e..2adafc562 100644 --- a/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx +++ b/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx @@ -24,10 +24,7 @@ import { ScorecardQuestion, SelectOption, } from '../../models' -import { formAppealResponseSchema, isAppealsResponsePhase } from '../../utils' -import { - QUESTION_YES_NO_OPTIONS, -} from '../../../config/index.config' +import { formAppealResponseSchema, getScoreResponseOptions, isAppealsResponsePhase } from '../../utils' import { ChallengeDetailContext } from '../../contexts' import styles from './AppealComment.module.scss' @@ -103,28 +100,9 @@ export const AppealComment: FC = (props: Props) => { } }, [addAppealResponse, appealInfo, reviewItem, updatedResponse]) - const responseOptions = useMemo(() => { - if (scorecardQuestion.type === 'SCALE') { - const length - = scorecardQuestion.scaleMax - - scorecardQuestion.scaleMin - + 1 - return Array.from( - new Array(length), - (x, i) => `${i + scorecardQuestion.scaleMin}`, - ) - .map(item => ({ - label: item, - value: item, - })) - } - - if (scorecardQuestion.type === 'YES_NO') { - return QUESTION_YES_NO_OPTIONS - } - - return [] - }, [scorecardQuestion]) + const responseOptions = useMemo(() => ( + getScoreResponseOptions(scorecardQuestion) + ), [scorecardQuestion]) useEffect(() => { setAppealResponse(data.appealResponse?.content ?? '') diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx index 74824f97f..02da68b9c 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ /** * Challenge Details Content. */ @@ -34,6 +35,7 @@ import { shouldIncludeInReviewPhase, } from '../../utils/reviewPhaseGuards' +import TabContentAiApproval from './TabContentAiApproval' import TabContentApproval from './TabContentApproval' import TabContentCheckpoint from './TabContentCheckpoint' import TabContentIterativeReview from './TabContentIterativeReview' @@ -83,6 +85,7 @@ const TabContentPlaceholder = (props: { message: string }): JSX.Element => ( ) +const AI_REVIEW_KEY = normalizeType('ai review') const SUBMISSION_TAB_KEYS = new Set([ normalizeType('submission'), normalizeType('specification submission'), @@ -90,6 +93,7 @@ const SUBMISSION_TAB_KEYS = new Set([ normalizeType('ai screening'), normalizeType('submission / screening'), normalizeType('topgear submission'), + AI_REVIEW_KEY, ]) const CHECKPOINT_REVIEW_KEY = normalizeType('checkpoint review') @@ -156,6 +160,7 @@ const renderSubmissionTab = ({ .startsWith('submission') || isSpecificationSubmissionTab || selectedTabNormalized === AI_SCREENING_KEY + || selectedTabNormalized === AI_REVIEW_KEY || isTopgearSubmissionTab const visibleSubmissions = shouldRestrictToContestSubmissions ? submissions.filter( @@ -196,6 +201,7 @@ export const ChallengeDetailsContent: FC = (props: Props) => { const { challengeInfo, myResources, + aiReviewConfig, }: ChallengeDetailContextModel = useContext(ChallengeDetailContext) const { actionChallengeRole }: useRoleProps = useRole() const hasIterativeReviewerRole = useMemo( @@ -274,6 +280,21 @@ export const ChallengeDetailsContent: FC = (props: Props) => { : undefined), [challengeInfo?.phases, props.selectedPhaseId], ) + const isFuturePhase = useMemo(() => { + if (!props.isActiveChallenge) return false + if (!selectedPhase) return false + const isOpen = Boolean((selectedPhase as { isOpen?: boolean }).isOpen) + const hasStarted = Boolean(selectedPhase.actualStartDate) + // If phase is not open and hasn't actually started, consider it future + if (!isOpen && !hasStarted) return true + // Fallback to scheduled start in the future if available + const startMs = Date.parse(selectedPhase.actualStartDate || selectedPhase.scheduledStartDate || '') + if (Number.isFinite(startMs)) { + return startMs > Date.now() + } + + return false + }, [actionChallengeRole, selectedPhase, props.isActiveChallenge]) const isFuturePhaseForSubmitter = useMemo(() => { if (!props.isActiveChallenge) return false if (actionChallengeRole !== SUBMITTER) return false @@ -493,6 +514,19 @@ export const ChallengeDetailsContent: FC = (props: Props) => { } if (selectedTabNormalized === 'approval') { + if (aiReviewConfig?.mode === 'AI_ONLY') { + return isFuturePhase ? ( + + ) : ( + + ) + } + return ( = (props: Props) => { ) } + if (selectedTabNormalized === 'review' && aiReviewConfig?.mode === 'AI_ONLY') { + return ( + + ) + } + return ( void +} + +interface SubmissionRowData { + submission: BackendSubmission + decision: AiReviewDecision | undefined +} + +function formatScore(score: number | null | undefined): string { + if (score === null || score === undefined) { + return '-' + } + + return Number.isInteger(score) ? `${score}` : score.toFixed(2) +} + +export const TabContentAiApproval: FC = (props: Props) => { + const { + aiReviewDecisionsBySubmissionId, + challengeInfo, + }: ChallengeDetailContextModel = useContext(ChallengeDetailContext) + const { isPrivilegedRole }: useRoleProps = useRole() + + const aiReviewers = useMemo<{ aiWorkflowId: string }[]>( + () => (challengeInfo?.reviewers?.filter(r => !!r.aiWorkflowId) as { aiWorkflowId: string }[]) ?? [], + [challengeInfo?.reviewers], + ) + + const isApprovalPhaseOpen = useMemo( + () => (challengeInfo?.phases ?? []).some( + p => (p.name || '').toLowerCase() === 'approval' && Boolean(p.isOpen), + ), + [challengeInfo?.phases], + ) + + const canEdit = isPrivilegedRole && isApprovalPhaseOpen + + const { + getRestrictionMessageForMember, + isSubmissionDownloadRestricted, + isSubmissionDownloadRestrictedForMember, + restrictionMessage, + shouldRestrictSubmitterToOwnSubmission, + }: UseSubmissionDownloadAccessResult = useSubmissionDownloadAccess() + + const { + ownedMemberIds, + }: UseRolePermissionsResult = useRolePermissions() + + const downloadButtonConfig = useMemo( + () => ({ + downloadSubmission: props.downloadSubmission, + getRestrictionMessageForMember, + isDownloading: props.isDownloading, + isSubmissionDownloadRestricted, + isSubmissionDownloadRestrictedForMember, + ownedMemberIds, + restrictionMessage, + shouldRestrictSubmitterToOwnSubmission, + }), + [ + props.downloadSubmission, + props.isDownloading, + getRestrictionMessageForMember, + isSubmissionDownloadRestricted, + isSubmissionDownloadRestrictedForMember, + ownedMemberIds, + restrictionMessage, + shouldRestrictSubmitterToOwnSubmission, + ], + ) + + const navigate = useNavigate() + + const contestSubmissions = useMemo( + () => props.submissions.filter(s => (s.type || '').toUpperCase() === 'CONTEST_SUBMISSION' && s.isLatest), + [props.submissions], + ) + + const tableData = useMemo( + () => contestSubmissions.map(submission => ({ + decision: aiReviewDecisionsBySubmissionId[submission.id], + submission, + })), + [contestSubmissions, aiReviewDecisionsBySubmissionId], + ) + + const handleViewScorecard = useCallback( + (submissionId: string, workflowId?: string) => (): void => { + const path = workflowId + ? `../reviews/${submissionId}?workflowId=${workflowId}` + : `../reviews/${submissionId}` + navigate(path) + }, + [navigate], + ) + + const columns = useMemo[]>(() => { + const cols: TableColumn[] = [ + { + columnId: 'submission-id', + label: 'Submission ID', + renderer: (row: SubmissionRowData) => ( + renderSubmissionIdCell( + row.submission as unknown as SubmissionRow, + downloadButtonConfig, + ) + ), + type: 'element', + }, + { + columnId: 'submitted-date', + label: 'Submitted', + renderer: (row: SubmissionRowData) => { + const date = row.submission.createdAt + ? moment(row.submission.createdAt) + .format(TABLE_DATE_FORMAT) + : '-' + + return {date} + }, + type: 'element', + }, + { + columnId: 'status', + label: 'Status', + renderer: (row: SubmissionRowData) => { + const status = row.decision?.status ?? 'PENDING' + const statusMap: Record = { + ERROR: { className: styles.statusError, label: 'Error' }, + FAILED: { className: styles.statusFailed, label: 'Failed' }, + HUMAN_OVERRIDE: { className: styles.statusOverride, label: 'Override' }, + PASSED: { className: styles.statusPassed, label: 'Passed' }, + PENDING: { className: styles.statusPending, label: 'Pending' }, + } + const config = statusMap[status] ?? statusMap.PENDING + + return ( + + {config.label} + + ) + }, + type: 'element', + }, + { + columnId: 'ai-score', + label: 'AI Score', + renderer: (row: SubmissionRowData) => ( + + {formatScore(row.decision?.totalScore)} + + ), + type: 'element', + }, + ] + + if (canEdit) { + cols.push({ + columnId: 'actions', + label: 'Actions', + renderer: (row: SubmissionRowData) => { + if (!row.decision) { + return - + } + + const workflowId = row.decision.breakdown?.workflows?.[0]?.workflowId + + return ( +
    + +
    + ) + }, + type: 'element', + }) + } + + cols.push({ + columnId: 'ai-reviews-expand', + isExpand: true, + label: '', + renderer: (row: SubmissionRowData) => ( +
    + {row.decision?.managerComment && ( +
    + Manager Comment: + {row.decision.managerComment} +
    + )} + + +
    + ), + type: 'element', + }) + + return cols + }, [ + aiReviewers, + canEdit, + handleViewScorecard, + ]) + + if (props.isLoading) { + return + } + + if (contestSubmissions.length === 0) { + return + } + + return ( + +

    + Review the AI scorecards below. + {canEdit && ( + <> + {' '} + Click Edit scorecard to inspect workflow scores. + + )} +

    + +
    {typeof row.score === 'number' ? ( row.workflowId ? ( - - {formatScore(row.score)} - + <> + + {formatScore(row.score)} + + {row.initialScore !== null && row.initialScore !== undefined && ( + + (overriden) + + )} + ) : formatScore(row.score) ) : '-'}
    + + + ) +} + +export default TabContentAiApproval diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx index 2d6e7c532..fd108a356 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx @@ -24,7 +24,7 @@ import { ReviewResult, SubmissionInfo, } from '../../models' -import { hasIsLatestFlag } from '../../utils' +import { hasIsLatestFlag, isMarathonMatchChallenge } from '../../utils' import { TableAppeals } from '../TableAppeals' import { TableAppealsForSubmitter } from '../TableAppealsForSubmitter' import { TableAppealsResponse } from '../TableAppealsResponse' @@ -86,7 +86,15 @@ const parseScoreValue = (value: unknown): number | undefined => { return undefined } -const resolveSubmissionReviewScore = (submission: SubmissionInfo): number | undefined => { +const resolveSubmissionReviewScore = ( + submission: SubmissionInfo, + preferAggregateScore: boolean, +): number | undefined => { + const aggregateScore = parseScoreValue(submission.aggregateScore) + if (preferAggregateScore && aggregateScore !== undefined) { + return aggregateScore + } + const reviewResultScores = Array.isArray(submission.reviews) ? submission.reviews .map(review => parseScoreValue(review?.score)) @@ -98,7 +106,6 @@ const resolveSubmissionReviewScore = (submission: SubmissionInfo): number | unde return total / reviewResultScores.length } - const aggregateScore = parseScoreValue(submission.aggregateScore) if (aggregateScore !== undefined) { return aggregateScore } @@ -122,10 +129,13 @@ type SubmissionScoreEntry = { submission: SubmissionInfo } -const sortSubmissionsByReviewScoreDesc = (rows: SubmissionInfo[]): SubmissionInfo[] => { +const sortSubmissionsByReviewScoreDesc = ( + rows: SubmissionInfo[], + preferAggregateScore: boolean, +): SubmissionInfo[] => { const entries: SubmissionScoreEntry[] = rows.map((submission, index) => ({ index, - score: resolveSubmissionReviewScore(submission), + score: resolveSubmissionReviewScore(submission, preferAggregateScore), submission, })) @@ -227,6 +237,10 @@ export const TabContentReview: FC = (props: Props) => { () => challengeInfo?.submissions ?? [], [challengeInfo?.submissions], ) + const useAggregateReviewScore = useMemo( + () => isMarathonMatchChallenge(challengeInfo), + [challengeInfo], + ) const myOwnedMemberIds = useMemo>( () => { const ids = new Set() @@ -736,15 +750,15 @@ export const TabContentReview: FC = (props: Props) => { ) const reviewerRowsForReviewTab = useMemo( () => (shouldSortReviewTabByScore - ? sortSubmissionsByReviewScoreDesc(filteredReviews) + ? sortSubmissionsByReviewScoreDesc(filteredReviews, useAggregateReviewScore) : filteredReviews), - [filteredReviews, shouldSortReviewTabByScore], + [filteredReviews, shouldSortReviewTabByScore, useAggregateReviewScore], ) const submitterRowsForReviewTab = useMemo( () => (shouldSortReviewTabByScore - ? sortSubmissionsByReviewScoreDesc(filteredSubmitterReviews) + ? sortSubmissionsByReviewScoreDesc(filteredSubmitterReviews, useAggregateReviewScore) : filteredSubmitterReviews), - [filteredSubmitterReviews, shouldSortReviewTabByScore], + [filteredSubmitterReviews, shouldSortReviewTabByScore, useAggregateReviewScore], ) const hideHandleColumn = props.isActiveChallenge && actionChallengeRole === REVIEWER diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.spec.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.spec.tsx new file mode 100644 index 000000000..481e63317 --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.spec.tsx @@ -0,0 +1,299 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { act } from 'react' +import type { PropsWithChildren } from 'react' +import { + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' + +import type { + BackendSubmission, + ChallengeDetailContextModel, + ChallengeInfo, + ReviewAppContextModel, +} from '../../models' +import { + ChallengeDetailContext, + ReviewAppContext, +} from '../../contexts' +import { reprocessTopgearSubmission } from '../../services' + +import { TabContentSubmissions } from './TabContentSubmissions' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + REVIEW: { + PROFILE_PAGE_URL: 'https://profiles.example.com', + }, + }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + getRatingColor: jest.fn() + .mockReturnValue('#000000'), + UserRole: { + administrator: 'administrator', + projectManager: 'projectManager', + }, + xhrGetAsync: jest.fn(), +}), { virtual: true }) + +jest.mock('../../contexts', () => { + const React: typeof import('react') = jest.requireActual('react') + + return { + ChallengeDetailContext: React.createContext({}), + ReviewAppContext: React.createContext({}), + } +}) + +jest.mock('react-toastify', () => ({ + toast: { + error: jest.fn(), + success: jest.fn(), + }, +})) + +jest.mock('~/apps/admin/src/lib', () => ({ + TableLoading: () =>
    Loading
    , +}), { virtual: true }) + +jest.mock('~/apps/admin/src/lib/components/common/TableMobile', () => ({ + TableMobile: () =>
    Mobile table
    , +}), { virtual: true }) + +jest.mock('~/apps/admin/src/lib/utils', () => ({ + handleError: jest.fn(), +}), { virtual: true }) + +jest.mock('~/libs/shared', () => ({ + copyTextToClipboard: () => Promise.resolve(), + useWindowSize: () => ({ + height: 800, + width: 1200, + }), +}), { virtual: true }) + +jest.mock('~/libs/ui', () => ({ + IconOutline: { + DocumentDuplicateIcon: () => , + RefreshIcon: () => , + }, + Table: (props: { + columns: Array<{ + propertyName?: string + renderer?: (row: BackendSubmission, rows: BackendSubmission[]) => JSX.Element + }> + data: BackendSubmission[] + }) => ( +
    + + {props.data.map(row => ( + + {props.columns.map((column, index) => ( + + ))} + + ))} + +
    + {column.renderer + ? column.renderer(row, props.data) + : row[column.propertyName as keyof BackendSubmission] as string} +
    + ), + Tooltip: (props: PropsWithChildren) => <>{props.children}, +}), { virtual: true }) + +jest.mock('../../hooks', () => ({ + useRolePermissions: () => ({ + actionChallengeRole: undefined, + canManageCompletedReviews: true, + canViewAllSubmissions: true, + hasCopilotRole: false, + hasReviewerRole: false, + hasSubmitterRole: false, + isAdmin: true, + isCopilotWithReviewerAssignments: false, + isProjectManager: false, + ownedMemberIds: new Set(), + }), +})) + +jest.mock('../../hooks/useSubmissionDownloadAccess', () => ({ + useSubmissionDownloadAccess: () => ({ + currentMemberId: 'admin-user', + getRestrictionMessageForMember: () => undefined, + isSubmissionDownloadRestricted: false, + isSubmissionDownloadRestrictedForMember: () => false, + isSubmissionPhaseOpen: false, + restrictionMessage: '', + shouldRestrictSubmitterToOwnSubmission: false, + }), +})) + +jest.mock('../../services', () => ({ + canReprocessTopgearSubmission: () => true, + reprocessTopgearSubmission: jest.fn(), +})) + +jest.mock('../CollapsibleAiReviewsRow', () => ({ + CollapsibleAiReviewsRow: () =>
    AI reviews
    , +})) + +jest.mock('../ConfirmModal', () => ({ + ConfirmModal: (props: PropsWithChildren<{ + action?: string + cancelText?: string + onClose: () => void + onConfirm: () => void + open: boolean + title: string + }>) => ( + props.open ? ( +
    + {props.children} + + +
    + ) : undefined + ), +})) + +jest.mock('../SubmissionHistoryModal', () => ({ + SubmissionHistoryModal: () => undefined, +})) + +jest.mock('../TableNoRecord', () => ({ + TableNoRecord: (props: { message?: string }) =>
    {props.message}
    , +})) + +jest.mock('../TableWrapper', () => ({ + TableWrapper: (props: PropsWithChildren<{ className?: string }>) => ( +
    {props.children}
    + ), +})) + +const mockedReprocessTopgearSubmission = reprocessTopgearSubmission as jest.Mock + +const submission = { + challengeId: 'challenge-1', + id: 'submission-1', + isFileSubmission: false, + memberId: 'member-1', + review: [], + reviewSummation: [], + submittedDate: '2026-05-01T00:00:00.000Z', + url: 'https://example.com/submission', +} as unknown as BackendSubmission + +const challengeInfo = { + id: 'challenge-1', + metadata: [], + phases: [], + status: 'Completed', + submissions: [], + track: { + id: 'track-1', + name: 'Development', + }, + type: { + id: 'type-1', + name: 'Topgear Task', + }, +} as unknown as ChallengeInfo + +const challengeDetailContextValue = { + aiReviewDecisionsBySubmissionId: {}, + challengeId: 'challenge-1', + challengeInfo, + challengeSubmissions: [submission], + hasChallengeScopedFetchError: false, + isLoadingAiReviewConfig: false, + isLoadingAiReviewDecisions: false, + isLoadingChallengeInfo: false, + isLoadingChallengeResources: false, + isLoadingChallengeSubmissions: false, + myResources: [], + myRoles: [], + registrants: [], + resourceMemberIdMapping: {}, + resources: [], + retryChallengeScopedFetches: jest.fn(), + reviewers: [], +} as ChallengeDetailContextModel + +const reviewAppContextValue = { + cancelLoadChallengeRelativeInfos: jest.fn(), + challengeRelativeInfosMapping: {}, + loadChallengeRelativeInfos: jest.fn(), + loginUserInfo: { + roles: ['administrator'], + userId: 123, + }, +} as ReviewAppContextModel + +function renderSubmissions(): ReturnType { + return render( + + + + + , + ) +} + +describe('TabContentSubmissions', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedReprocessTopgearSubmission.mockResolvedValue('ok') + }) + + it('confirms before reprocessing a Topgear submission', async () => { + renderSubmissions() + + fireEvent.click(screen.getByRole('button', { name: 'Reprocess Topgear submission' })) + + expect(screen.getByRole('dialog', { name: 'Reprocess Topgear Submission' })) + .toBeTruthy() + expect(screen.getByText('Are you sure you want to reprocess this Topgear submission?')) + .toBeTruthy() + expect(mockedReprocessTopgearSubmission) + .not + .toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'No' })) + + expect(screen.queryByRole('dialog', { name: 'Reprocess Topgear Submission' })) + .toBeNull() + expect(mockedReprocessTopgearSubmission) + .not + .toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Reprocess Topgear submission' })) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Yes' })) + }) + + await waitFor(() => { + expect(mockedReprocessTopgearSubmission) + .toHaveBeenCalledWith({ + submission, + submissionInfo: expect.objectContaining({ + id: 'submission-1', + }), + }) + }) + }) +}) diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx index 85e83888f..536480f4e 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx @@ -18,6 +18,7 @@ import { TableLoading } from '~/apps/admin/src/lib' 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 } from '~/apps/admin/src/lib/utils' import { copyTextToClipboard, useWindowSize, WindowSize } from '~/libs/shared' import { IconOutline, Table, TableColumn, Tooltip } from '~/libs/ui' @@ -46,6 +47,11 @@ import { TABLE_DATE_FORMAT } from '../../../config/index.config' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' import { useRolePermissions, UseRolePermissionsResult } from '../../hooks' import { SUBMISSION_DOWNLOAD_RESTRICTION_MESSAGE } from '../../constants' +import { + canReprocessTopgearSubmission, + reprocessTopgearSubmission, +} from '../../services' +import { ConfirmModal } from '../ConfirmModal' import { canDownloadSubmissionFromSubmissionsTab } from './submissionDownloadPermissions' import styles from './TabContentSubmissions.module.scss' @@ -81,6 +87,16 @@ export const TabContentSubmissions: FC = props => { }: UseRolePermissionsResult = useRolePermissions() const { challengeInfo, registrants }: ChallengeDetailContextModel = useContext(ChallengeDetailContext) + const [isReprocessingSubmission, setIsReprocessingSubmission] = useState({}) + const [ + pendingReprocessSubmissionId, + setPendingReprocessSubmissionId, + ] = useState(undefined) + + const canShowTopgearReprocess = useMemo( + () => canReprocessTopgearSubmission(challengeInfo, isAdmin), + [challengeInfo, isAdmin], + ) const isCompletedDesignChallenge = useMemo(() => { if (!challengeInfo) return false @@ -212,6 +228,73 @@ export const TabContentSubmissions: FC = props => { [openHistoryModalForKey], ) + const handleReprocessSubmission = useCallback( + async (event: MouseEvent): Promise => { + event.stopPropagation() + event.preventDefault() + + const submissionId = event.currentTarget.dataset.submissionId + if (!submissionId) { + return + } + + setPendingReprocessSubmissionId(submissionId) + }, + [], + ) + + const closeReprocessConfirmation = useCallback((): void => { + setPendingReprocessSubmissionId(undefined) + }, []) + + const handleConfirmReprocessSubmission = useCallback( + async (): Promise => { + const submissionId = pendingReprocessSubmissionId + if (!submissionId) { + closeReprocessConfirmation() + return + } + + const submission = submissionMetaById.get(submissionId) + if (!submission) { + toast.error('Submission could not be found for reprocess', { + toastId: `topgear-submission-reprocess-${submissionId}`, + }) + closeReprocessConfirmation() + return + } + + setIsReprocessingSubmission(previous => ({ + ...previous, + [submissionId]: true, + })) + + try { + await reprocessTopgearSubmission({ + submission, + submissionInfo: submissionInfoById.get(submissionId), + }) + toast.success('Reprocess submission request sent', { + toastId: `topgear-submission-reprocess-${submissionId}`, + }) + closeReprocessConfirmation() + } catch (error) { + handleError(error as Error) + } finally { + setIsReprocessingSubmission(previous => ({ + ...previous, + [submissionId]: false, + })) + } + }, + [ + closeReprocessConfirmation, + pendingReprocessSubmissionId, + submissionInfoById, + submissionMetaById, + ], + ) + const resolveSubmissionMeta = useCallback( (submissionId: string): SubmissionInfo | undefined => submissionInfoById.get(submissionId), [submissionInfoById], @@ -353,6 +436,19 @@ export const TabContentSubmissions: FC = props => { > + {canShowTopgearReprocess && ( + + )} ) }, @@ -458,9 +554,12 @@ export const TabContentSubmissions: FC = props => { restrictionMessage, props.downloadSubmission, props.isDownloading, + isReprocessingSubmission, historyByMember, handleHistoryButtonClick, + handleReprocessSubmission, shouldShowHistoryActions, + canShowTopgearReprocess, isAdmin, isProjectManager, hasCopilotRole, @@ -528,6 +627,22 @@ export const TabContentSubmissions: FC = props => { getSubmissionMeta={resolveSubmissionMeta} aiReviewers={props.aiReviewers} /> + +
    + Are you sure you want to reprocess this Topgear submission? +
    +
    ) } diff --git a/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.module.scss b/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.module.scss new file mode 100644 index 000000000..8f61ea643 --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.module.scss @@ -0,0 +1,22 @@ +@import '@libs/ui/styles/includes'; + +.container { + align-items: center; + background: #fff4f4; + border: 1px solid #f2c9c9; + border-radius: 8px; + color: #8a1f1f; + display: flex; + gap: $sp-3; + justify-content: space-between; + margin-top: $sp-4; + padding: $sp-3; +} + +.message { + font-family: "Nunito Sans", sans-serif; + font-size: 14px; + font-weight: 700; + line-height: 20px; + margin: 0; +} diff --git a/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.tsx b/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.tsx new file mode 100644 index 000000000..0066ca59e --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react' + +import { Button } from '~/libs/ui' + +import styles from './ChallengeScopedErrorState.module.scss' + +interface ChallengeScopedErrorStateProps { + message?: string + onRetry: () => void +} + +/** + * Renders the shared retryable error state for challenge-scoped route fetches. + * + * @param props.message optional message shown in the error panel. + * @param props.onRetry callback used by route pages to revalidate failed challenge data. + * @returns a generic route-level error panel with a retry action. + */ +export const ChallengeScopedErrorState: FC = ( + props: ChallengeScopedErrorStateProps, +) => ( +
    +

    + {props.message ?? 'Something went wrong while loading the challenge. Please try again.'} +

    + +
    +) + +export default ChallengeScopedErrorState diff --git a/src/apps/review/src/lib/components/ChallengeScopedErrorState/index.ts b/src/apps/review/src/lib/components/ChallengeScopedErrorState/index.ts new file mode 100644 index 000000000..fea79cdd9 --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengeScopedErrorState/index.ts @@ -0,0 +1 @@ +export * from './ChallengeScopedErrorState' diff --git a/src/apps/review/src/lib/components/ChallengeTimeline/ChallengeTimeline.module.scss b/src/apps/review/src/lib/components/ChallengeTimeline/ChallengeTimeline.module.scss index f42b3d062..b05f654f0 100644 --- a/src/apps/review/src/lib/components/ChallengeTimeline/ChallengeTimeline.module.scss +++ b/src/apps/review/src/lib/components/ChallengeTimeline/ChallengeTimeline.module.scss @@ -46,6 +46,15 @@ color: $orange-140; } +.statusDisabled { + background-color: $black-10; + color: $black-40; +} + +.disabledCell { + color: $black-40; +} + .actionsList { display: inline-flex; flex-wrap: wrap; diff --git a/src/apps/review/src/lib/components/ChallengeTimeline/ChallengeTimeline.tsx b/src/apps/review/src/lib/components/ChallengeTimeline/ChallengeTimeline.tsx index 32b201dc4..9df11d8bb 100644 --- a/src/apps/review/src/lib/components/ChallengeTimeline/ChallengeTimeline.tsx +++ b/src/apps/review/src/lib/components/ChallengeTimeline/ChallengeTimeline.tsx @@ -25,6 +25,7 @@ export interface ChallengeTimelineRow { end: string actions?: ChallengeTimelineAction[] duration?: number + disabled?: boolean } interface Props { @@ -48,7 +49,9 @@ export const ChallengeTimeline: FC = (props: Props) => { isSortable: false, label: 'Phase', propertyName: 'name', - renderer: (row: ChallengeTimelineRow) => {row.name}, + renderer: (row: ChallengeTimelineRow) => ( + {row.name} + ), type: 'element', }, { @@ -58,6 +61,14 @@ export const ChallengeTimeline: FC = (props: Props) => { label: 'Status', propertyName: 'status', renderer: (row: ChallengeTimelineRow) => { + if (row.disabled) { + return ( + + N/A + + ) + } + const normalizedStatus = row.status.trim() .toLowerCase() const statusClassKey = STATUS_CLASS_MAP[normalizedStatus] @@ -77,7 +88,9 @@ export const ChallengeTimeline: FC = (props: Props) => { isSortable: false, label: 'Start', propertyName: 'start', - renderer: (row: ChallengeTimelineRow) => {row.start}, + renderer: (row: ChallengeTimelineRow) => ( + {row.start} + ), type: 'element', }, { @@ -86,7 +99,9 @@ export const ChallengeTimeline: FC = (props: Props) => { isSortable: false, label: 'End', propertyName: 'end', - renderer: (row: ChallengeTimelineRow) => {row.end}, + renderer: (row: ChallengeTimelineRow) => ( + {row.end} + ), type: 'element', }, ] diff --git a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.module.scss b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.module.scss index 862bdf09f..9b4368d75 100644 --- a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.module.scss +++ b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.module.scss @@ -57,6 +57,10 @@ .runStatus { min-width: 105px; + display: flex; + gap: 4px; + align-items: center; + flex-wrap: wrap; } .table { diff --git a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx index 084022e09..d040ab303 100644 --- a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx +++ b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx @@ -26,6 +26,8 @@ interface CollapsibleAiReviewsRowProps { export function normalizeDecisionStatus( status?: AiReviewDecisionStatus, + totalScore?: number | null, + minPassingThreshold?: number | null, ): 'passed' | 'failed-score' | 'pending' | 'failed' | 'human-override' { if (!status || status === 'PENDING') { return 'pending' @@ -44,12 +46,44 @@ export function normalizeDecisionStatus( } if (status === 'HUMAN_OVERRIDE') { + if ( + typeof totalScore === 'number' + && typeof minPassingThreshold === 'number' + ) { + return totalScore >= minPassingThreshold ? 'passed' : 'failed-score' + } + return 'human-override' } return 'pending' } +interface ScoreBadgeProps { + score: number + normalizedStatus: ReturnType + aiReviewConfig: ChallengeDetailContextModel['aiReviewConfig'] +} + +const ScoreBadge: FC = props => ( + + } + triggerOn='hover' + > + + + + + {formatScore(props.score)} + +) + const CollapsibleAiReviewsRow: FC = props => { const challengeDetailContext: ChallengeDetailContextModel = useContext(ChallengeDetailContext) const aiReviewConfig: ChallengeDetailContextModel['aiReviewConfig'] = challengeDetailContext.aiReviewConfig @@ -66,9 +100,17 @@ const CollapsibleAiReviewsRow: FC = props => { [aiReviewDecisionsBySubmissionId, props.submission.id], ) + const minPassingThreshold = currentDecision?.breakdown?.minPassingThreshold + ?? aiReviewConfig?.minPassingThreshold + + // Extracted into its own memo to reduce the complexity count of the component arrow function const normalizedStatus = useMemo( - () => normalizeDecisionStatus(currentDecision?.status), - [currentDecision?.status], + () => normalizeDecisionStatus( + currentDecision?.status, + currentDecision?.totalScore, + minPassingThreshold, + ), + [currentDecision?.status, currentDecision?.totalScore, minPassingThreshold], ) const [isOpen, setIsOpen] = useState(props.defaultOpen ?? false) @@ -124,24 +166,11 @@ const CollapsibleAiReviewsRow: FC = props => {
    {hasScore && ( - - } - triggerOn='hover' - > - - - - - {formatScore(currentDecision!.totalScore)} - + )} {currentDecision && (
    @@ -152,7 +181,10 @@ const CollapsibleAiReviewsRow: FC = props => {
    {isOpen && portalContainer && createPortal(
    - +
    , portalContainer, )} diff --git a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss index 544b430b9..a1e5a0a2e 100644 --- a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss +++ b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss @@ -34,6 +34,15 @@ $error-line-height: 14px; pointer-events: none; } + &.readOnly { + :global { + .editor-statusbar, + .editor-toolbar { + pointer-events: none; + } + } + } + :global { .EasyMDEContainer { height: 100px; diff --git a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.spec.tsx b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.spec.tsx new file mode 100644 index 000000000..be151abb1 --- /dev/null +++ b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.spec.tsx @@ -0,0 +1,159 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { render, waitFor } from '@testing-library/react' + +import { FieldMarkdownEditor } from './FieldMarkdownEditor' + +const mockEasyMDEInstances: any[] = [] + +jest.mock('~/apps/admin/src/lib/hooks', () => { + const React: typeof import('react') = jest.requireActual('react') + + return { + useOnComponentDidMount: (onMounted: () => void): void => { + React.useEffect(() => { + onMounted() + }, []) + }, + } +}, { virtual: true }) + +jest.mock('../../contexts', () => { + const React: typeof import('react') = jest.requireActual('react') + + return { + ChallengeDetailContext: React.createContext({ + challengeId: 'challenge-id', + }), + } +}) + +jest.mock('../../services', () => ({ + uploadReviewAttachment: jest.fn(), +})) + +jest.mock('../../utils', () => ({ + humanFileSize: jest.fn(() => '1 KB'), +})) + +jest.mock('easymde', () => { + class EasyMDEMock { + constructor(options: any) { + const wrapper = globalThis.document.createElement('div') + wrapper.appendChild(globalThis.document.createElement('div')) + + let editorValue = options.initialValue ?? '' + const imageInput = { value: '' } + const codemirror = { + focus: jest.fn(), + getCursor: jest.fn(() => ({ + ch: 0, + line: 0, + })), + getLine: jest.fn(() => ''), + getSelection: jest.fn(() => ''), + getTokenAt: jest.fn(() => ({ + type: '', + })), + getValue: jest.fn(() => editorValue), + getWrapperElement: jest.fn(() => wrapper), + indexFromPos: jest.fn(() => 0), + on: jest.fn(), + replaceRange: jest.fn(), + replaceSelection: jest.fn(), + setOption: jest.fn(), + setSelection: jest.fn(), + } + Object.assign(this, { + codemirror, + gui: { + toolbar: { + getElementsByClassName: jest.fn(() => [imageInput]), + }, + }, + options, + updateStatusBar: jest.fn(), + value: jest.fn((incomingValue?: string) => { + if (incomingValue === undefined) { + return editorValue + } + + editorValue = incomingValue + return undefined + }), + }) + + mockEasyMDEInstances.push(this) + } + } + + Object.assign(EasyMDEMock, { + drawImage: jest.fn(), + drawLink: jest.fn(), + drawTable: jest.fn(), + drawUploadedImage: jest.fn(), + toggleBlockquote: jest.fn(), + toggleCodeBlock: jest.fn(), + toggleHeading1: jest.fn(), + toggleHeading2: jest.fn(), + toggleHeading3: jest.fn(), + toggleOrderedList: jest.fn(), + toggleStrikethrough: jest.fn(), + toggleUnorderedList: jest.fn(), + }) + + return { + __esModule: true, + default: EasyMDEMock, + } +}) + +describe('FieldMarkdownEditor', () => { + beforeEach(() => { + jest.clearAllMocks() + mockEasyMDEInstances.length = 0 + }) + + it('uses the latest read-only state for the EasyMDE upload callback', async () => { + const uploadAttachment = jest.fn() + .mockResolvedValue({ + url: 'https://example.com/uploaded.png', + }) + + const rendered: ReturnType = render( + , + ) + + await waitFor(() => { + expect(mockEasyMDEInstances) + .toHaveLength(1) + }) + + const easyMDE = mockEasyMDEInstances[0] + rendered.rerender( + , + ) + + await easyMDE.options.imageUploadFunction( + new File(['image'], 'uploaded.png', { type: 'image/png' }), + ) + + expect(uploadAttachment) + .not + .toHaveBeenCalled() + }) + + it('installs EasyMDE upload handlers so editable transitions can upload', async () => { + render() + + await waitFor(() => { + expect(mockEasyMDEInstances) + .toHaveLength(1) + }) + + expect(mockEasyMDEInstances[0].options.uploadImage) + .toBe(true) + }) +}) diff --git a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx index 37dea39b8..3484a0c04 100644 --- a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx +++ b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx @@ -51,6 +51,7 @@ interface Props { maxCharactersAllowed?: number textareaId?: string ariaLabel?: string + readOnly?: boolean } const errorMessages = { fileTooLarge: @@ -152,10 +153,24 @@ const toggleStrategy = { } type CodeMirrorType = keyof typeof stateStrategy | 'variable-2' +type UploadImageHandler = (file: File) => Promise + +const readOnlyUploadEvents = [ + 'dragend', + 'dragenter', + 'dragleave', + 'dragover', + 'drop', + 'paste', +] export const FieldMarkdownEditor: FC = (props: Props) => { const elementRef = useRef(null) const easyMDE = useRef(null) + const customUploadImageRef = useRef(async () => undefined) + const isReadOnlyRef = useRef(false) + const isReadOnly = !!props.disabled || !!props.readOnly + isReadOnlyRef.current = isReadOnly const [remainingCharacters, setRemainingCharacters] = useState( (props.maxCharactersAllowed || 0) - (props.initialValue?.length || 0), ) @@ -527,6 +542,10 @@ export const FieldMarkdownEditor: FC = (props: Props) => { * Upload image */ const customUploadImage = useCallback(async (file: File) => { + if (isReadOnlyRef.current) { + return + } + const editor = easyMDE.current if (!editor) { return @@ -651,6 +670,7 @@ export const FieldMarkdownEditor: FC = (props: Props) => { uploadAttachment, uploadCategory, ]) + customUploadImageRef.current = customUploadImage useOnComponentDidMount(() => { easyMDE.current = new EasyMDE({ @@ -673,7 +693,7 @@ export const FieldMarkdownEditor: FC = (props: Props) => { sbProgress: 'Uploading #file_name#: #progress#%', sizeUnits: ' B, KB, MB', }, - imageUploadFunction: file => customUploadImage(file), + imageUploadFunction: file => customUploadImageRef.current(file), initialValue: props.initialValue ?? '', insertTexts: { file: ['[](', '#url#)'], @@ -868,6 +888,39 @@ export const FieldMarkdownEditor: FC = (props: Props) => { }) }) + useEffect(() => { + if (!easyMDE.current) { + return undefined + } + + easyMDE.current.codemirror.setOption('readOnly', isReadOnly + ? 'nocursor' + : false) + + const wrapper = easyMDE.current.codemirror.getWrapperElement() + const blockReadOnlyUpload = (event: Event): void => { + if (!isReadOnlyRef.current) { + return + } + + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + } + + if (isReadOnly) { + readOnlyUploadEvents.forEach(eventName => { + wrapper.addEventListener(eventName, blockReadOnlyUpload, true) + }) + } + + return () => { + readOnlyUploadEvents.forEach(eventName => { + wrapper.removeEventListener(eventName, blockReadOnlyUpload, true) + }) + } + }, [isReadOnly]) + useEffect(() => { if (!easyMDE.current) { return @@ -886,6 +939,7 @@ export const FieldMarkdownEditor: FC = (props: Props) => { className={classNames(styles.container, props.className, { [styles.isError]: !!props.error, [styles.disabled]: !!props.disabled, + [styles.readOnly]: !!props.readOnly, [styles.showBorder]: !!props.showBorder, })} > diff --git a/src/apps/review/src/lib/components/ManagerComment/ManagerComment.tsx b/src/apps/review/src/lib/components/ManagerComment/ManagerComment.tsx index ce099349f..a7ce6ddbe 100644 --- a/src/apps/review/src/lib/components/ManagerComment/ManagerComment.tsx +++ b/src/apps/review/src/lib/components/ManagerComment/ManagerComment.tsx @@ -17,8 +17,7 @@ import { yupResolver } from '@hookform/resolvers/yup' import { MarkdownReview } from '../MarkdownReview' import { FieldMarkdownEditor } from '../FieldMarkdownEditor' import { FormManagerComment, ReviewItemInfo, ScorecardQuestion, SelectOption } from '../../models' -import { formManagerCommentSchema } from '../../utils' -import { QUESTION_YES_NO_OPTIONS } from '../../../config/index.config' +import { formManagerCommentSchema, getScoreResponseOptions } from '../../utils' import styles from './ManagerComment.module.scss' @@ -69,28 +68,9 @@ export const ManagerComment: FC = (props: Props) => { ) }, [addManagerComment, reviewItem]) - const responseOptions = useMemo(() => { - if (scorecardQuestion.type === 'SCALE') { - const length - = scorecardQuestion.scaleMax - - scorecardQuestion.scaleMin - + 1 - return Array.from( - new Array(length), - (x, i) => `${i + scorecardQuestion.scaleMin}`, - ) - .map(item => ({ - label: item, - value: item, - })) - } - - if (scorecardQuestion.type === 'YES_NO') { - return QUESTION_YES_NO_OPTIONS - } - - return [] - }, [scorecardQuestion]) + const responseOptions = useMemo(() => ( + getScoreResponseOptions(scorecardQuestion) + ), [scorecardQuestion]) useEffect(() => { if (reviewItem.managerComment) { diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.module.scss b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.module.scss index 67f35f286..010e8644c 100644 --- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.module.scss +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.module.scss @@ -55,6 +55,81 @@ } } +.feedbackEditRow { + display: flex; + align-items: center; + gap: $sp-3; + margin-bottom: $sp-3; + flex-wrap: wrap; +} + +.editActions { + display: flex; + align-items: center; + gap: $sp-2; +} + +.scoreEditSelect { + min-width: 140px; + padding: $sp-2 $sp-3; + border: 1px solid var(--BorderColor); + border-radius: 8px; + background: var(--Background); + color: var(--FontColor); + font-size: 14px; + line-height: 20px; + min-height: 40px; +} + +.scoreEditTextarea { + width: 100%; + min-height: 112px; + margin-top: $sp-2; + padding: $sp-3; + border: 1px solid var(--BorderColor); + border-radius: 8px; + background: var(--Background); + color: var(--FontColor); + resize: vertical; + font-size: 14px; + line-height: 22px; +} + +.scoreEditTrigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + margin-left: $sp-2; + padding: 0; + border: none; + background: transparent; + color: var(--TextSecondary); + cursor: pointer; + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +.scoreEditItem { + display: block; + width: 100%; + text-align: left; + border: none; + background: transparent; + padding: $sp-3 $sp-4; + color: var(--FontColor); + cursor: pointer; + + &:hover, + &:focus { + background: var(--Tertiary); + } +} + .mdReview { h1, h2, h3, h4, h5, h6 { font-size: 14px; diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx index 0aa841c61..0b03cf92e 100644 --- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx @@ -1,12 +1,17 @@ -import { FC, useCallback, useMemo, useState } from 'react' +import { ChangeEvent, FC, useCallback, useMemo, useState } from 'react' import { mutate } from 'swr' import { IconAiReview } from '~/apps/review/src/lib/assets/icons' import { ReviewsContextModel, ScorecardQuestion } from '~/apps/review/src/lib/models' -import { createFeedbackComment } from '~/apps/review/src/lib/services' +import { createFeedbackComment, updateRunItemScore } from '~/apps/review/src/lib/services' +import { getAiReviewDecisionsCacheKey } from '~/apps/review/src/lib/services/aiReview.service' +import { getAiWorkflowRunsCacheKey } from '~/apps/review/src/lib/hooks/useFetchAiWorkflowRuns' import { useReviewsContext } from '~/apps/review/src/pages/reviews/ReviewsContext' +import { getScoreResponseOptions } from '~/apps/review/src/lib/utils' import { EnvironmentConfig } from '~/config' -import { Tooltip } from '~/libs/ui' +import { Button, IconOutline, Tooltip } from '~/libs/ui' +import { useRole } from '~/apps/review/src/lib/hooks' +import { handleError } from '~/libs/shared/lib/utils/handle-error' import { ScorecardViewerContextValue, useScorecardViewerContext } from '../../ScorecardViewer.context' import { ScorecardQuestionRow } from '../ScorecardQuestionRow' @@ -18,17 +23,161 @@ import { AiFeedbackReply } from '../AiFeedbackReply/AiFeedbackReply' import styles from './AiFeedback.module.scss' +const getInitialEditedScore = (questionScore: number | undefined, isYesNo: boolean): string => { + if (isYesNo) { + return questionScore ? 'Yes' : 'No' + } + + return String(questionScore ?? '') +} + +const getQuestionScoreFromEditedScore = (editedScore: string, isYesNo: boolean): number => { + if (isYesNo) { + if (editedScore === 'Yes') { + return 1 + } + + if (editedScore === 'No') { + return 0 + } + } + + return Number(editedScore) +} + interface AiFeedbackProps { question: ScorecardQuestion } +const renderAiFeedbackContent = ( + props: AiFeedbackProps, + feedback: any, + scoreMap: Map, + isEditingScore: boolean, + editedScore: string, + isUpdatingScore: boolean, + isYesNo: boolean, + hasQuestionScoreEditAccess: boolean, + scoreOptions: Array<{ value: string, label: string }>, + commentsArr: any[], + showReply: boolean, + handleScoreChange: (event: ChangeEvent) => void, + handleStartEditing: () => void, + handleCancelEditing: () => void, + handleSaveScore: (content: string) => Promise, + onShowReply: () => void, + onSubmitReply: (content: string) => Promise, + handleCloseReply: () => void, +): JSX.Element => ( + } + index='AI Feedback' + className={styles.wrap} + score={( + + )} + > +
    + + {isEditingScore ? ( + + ) : ( + isYesNo ? (feedback.questionScore ? 'Yes' : 'No') : ( + + + {feedback.questionScore} + + + ) + )} + + + {hasQuestionScoreEditAccess && !isEditingScore && ( +
    +
    + )} +
    + + {isEditingScore && ( + + )} + + + + + + {commentsArr.length > 0 && ( + + )} + + {showReply && ( + + )} +
    +) + const AiFeedback: FC = props => { const { aiFeedbackItems, scoreMap }: ScorecardViewerContextValue = useScorecardViewerContext() const feedback: any = useMemo(() => ( aiFeedbackItems?.find((r: any) => r.scorecardQuestionId === props.question.id) ), [props.question.id, aiFeedbackItems]) - const { workflowId, workflowRun }: ReviewsContextModel = useReviewsContext() + const { + workflowId, + workflowRun, + challengeInfo, + submissionId, + aiReviewConfig, + }: ReviewsContextModel = useReviewsContext() + const { isPrivilegedRole }: { isPrivilegedRole: boolean } = useRole() const [showReply, setShowReply] = useState(false) + const [isUpdatingScore, setIsUpdatingScore] = useState(false) + const [isEditingScore, setIsEditingScore] = useState(false) + const [editedScore, setEditedScore] = useState('') + + const isApprovalPhaseOpen = useMemo( + () => (challengeInfo?.phases ?? []).some( + p => (p.name || '').toLowerCase() === 'approval' && Boolean(p.isOpen), + ), + [challengeInfo?.phases], + ) const commentsArr: any[] = (feedback?.comments) || [] @@ -45,66 +194,108 @@ const AiFeedback: FC = props => { setShowReply(false) }, [workflowId, workflowRun?.id, workflowRun?.status, feedback?.id]) - if (!aiFeedbackItems?.length || !feedback) { - return <> - } - const isYesNo = props.question.type === 'YES_NO' + const hasQuestionScoreEditAccess = isPrivilegedRole + && !!workflowId + && !!workflowRun?.id + && !!feedback?.id + && isApprovalPhaseOpen - return ( - } - index='AI Feedback' - className={styles.wrap} - score={( - - )} - > -

    - - {isYesNo && (feedback.questionScore ? 'Yes' : 'No')} - {!isYesNo && ( - - - {feedback.questionScore} - - - )} - -

    + const scoreOptions = useMemo(() => getScoreResponseOptions(props.question), [props.question]) - + const handleStartEditing = useCallback(() => { + setIsEditingScore(true) + setEditedScore(getInitialEditedScore(feedback?.questionScore, isYesNo)) + }, [feedback?.questionScore, isYesNo]) - + const handleScoreChange = useCallback((event: ChangeEvent) => { + setEditedScore(event.target.value) + }, []) - {commentsArr.length > 0 && ( - - )} + const handleCancelEditing = useCallback(() => { + setIsEditingScore(false) + }, []) - { - showReply && ( - - ) + const handleCloseReply = useCallback(() => { + setShowReply(false) + }, []) + + const handleSaveScore = useCallback(async (content: string) => { + if (!hasQuestionScoreEditAccess || isUpdatingScore) { + return + } + + if (!workflowId || !workflowRun?.id || !feedback?.id) { + return + } + + const questionScore = getQuestionScoreFromEditedScore(editedScore, isYesNo) + if (!Number.isFinite(questionScore)) { + return + } + + setIsUpdatingScore(true) + const itemsKey = `${EnvironmentConfig.API.V6}/workflows/${workflowId}/runs/${ + workflowRun.id + }/items?[${workflowRun?.status}]` + + try { + await updateRunItemScore(workflowId, workflowRun.id, feedback.id, { + comment: content.trim(), + questionScore, + }) + + await mutate(itemsKey) + if (submissionId) { + await mutate(getAiWorkflowRunsCacheKey(submissionId)) } -
    + + if (aiReviewConfig?.id) { + await mutate(getAiReviewDecisionsCacheKey(aiReviewConfig.id)) + } + + setIsEditingScore(false) + } catch (err) { + handleError(err) + } finally { + setIsUpdatingScore(false) + } + }, [ + editedScore, + feedback?.id, + hasQuestionScoreEditAccess, + isYesNo, + isUpdatingScore, + workflowId, + workflowRun?.id, + workflowRun?.status, + submissionId, + aiReviewConfig?.id, + ]) + + if (!aiFeedbackItems?.length || !feedback) { + return <> + } + + return renderAiFeedbackContent( + props, + feedback, + scoreMap, + isEditingScore, + editedScore, + isUpdatingScore, + isYesNo, + hasQuestionScoreEditAccess, + scoreOptions, + commentsArr, + showReply, + handleScoreChange, + handleStartEditing, + handleCancelEditing, + handleSaveScore, + onShowReply, + onSubmitReply, + handleCloseReply, ) } diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedbackReply/AiFeedbackReply.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedbackReply/AiFeedbackReply.tsx index 0ee6fe0ee..b37073d64 100644 --- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedbackReply/AiFeedbackReply.tsx +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedbackReply/AiFeedbackReply.tsx @@ -21,6 +21,7 @@ interface AiFeedbackReplyProps { initialValue?: string onCloseReply: () => void onSubmitReply: (content: string, id?: string) => Promise + submitLabel?: string } export const AiFeedbackReply: FC = props => { @@ -94,9 +95,7 @@ export const AiFeedbackReply: FC = props => { className='filledButton' type='submit' > - { - props.id ? 'Edit Reply' : 'Submit Reply' - } + {props.submitLabel ?? (props.id ? 'Edit Reply' : 'Submit Reply')}