{
setAcceptStartDate(value)
if (acceptErrors.startDate) {
@@ -458,50 +436,84 @@ const ApplicationsList = ({
-
{
- setAcceptEndDate(value)
- if (acceptErrors.endDate) {
- setAcceptErrors(prev => ({ ...prev, endDate: '' }))
+ {
+ setAcceptDurationMonths(sanitizePositiveNumericInput(event.target.value))
+ if (acceptErrors.durationMonths) {
+ setAcceptErrors(prev => ({ ...prev, durationMonths: '' }))
}
}}
/>
- {acceptErrors.endDate && (
- {acceptErrors.endDate}
+ {acceptErrors.durationMonths && (
+ {acceptErrors.durationMonths}
)}
-
+
{
- setAcceptRate(event.target.value)
- if (acceptErrors.rate) {
- setAcceptErrors(prev => ({ ...prev, rate: '' }))
+ setAcceptRatePerHour(sanitizePositiveNumericInput(event.target.value))
+ if (acceptErrors.ratePerHour) {
+ setAcceptErrors(prev => ({ ...prev, ratePerHour: '' }))
}
}}
/>
- {acceptErrors.rate && (
-
{acceptErrors.rate}
+ {acceptErrors.ratePerHour && (
+
{acceptErrors.ratePerHour}
)}
+
+
+
{
+ setAcceptStandardHoursPerWeek(
+ sanitizePositiveNumericInput(event.target.value, 2)
+ )
+ if (acceptErrors.standardHoursPerWeek) {
+ setAcceptErrors(prev => ({ ...prev, standardHoursPerWeek: '' }))
+ }
+ }}
+ />
+ {acceptErrors.standardHoursPerWeek && (
+
{acceptErrors.standardHoursPerWeek}
+ )}
+
+
+
+
+
+ )
+}
+
+ManualConfigurationView.propTypes = {
+ challenge: PropTypes.object.isRequired,
+ configuration: PropTypes.shape({
+ mode: PropTypes.string.isRequired,
+ minPassingThreshold: PropTypes.number.isRequired,
+ autoFinalize: PropTypes.bool.isRequired,
+ workflows: PropTypes.array.isRequired
+ }).isRequired,
+ availableWorkflows: PropTypes.array.isRequired,
+ onUpdateConfiguration: PropTypes.func.isRequired,
+ onAddWorkflow: PropTypes.func.isRequired,
+ onUpdateWorkflow: PropTypes.func.isRequired,
+ onRemoveWorkflow: PropTypes.func.isRequired,
+ onSwitchMode: PropTypes.func.isRequired,
+ onRemoveConfig: PropTypes.func.isRequired,
+ readOnly: PropTypes.bool
+}
+
+ManualConfigurationView.defaultProps = {
+ readOnly: false
+}
+
+export default ManualConfigurationView
diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js
new file mode 100644
index 00000000..e6d932ec
--- /dev/null
+++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js
@@ -0,0 +1,188 @@
+import React, { useCallback, useState } from 'react'
+import cn from 'classnames'
+import PropTypes from 'prop-types'
+import styles from '../AiReviewTab.module.scss'
+import useTemplateManager from '../hooks/useTemplateManager'
+import SummarySection from '../components/SummarySection'
+import ConfirmationModal from '../../../../Modal/ConfirmationModal'
+import ConfigurationSourceSelector from '../components/ConfigurationSourceSelector'
+import AiWorkflowsTableListing from '../components/AiWorkflowsTableListing'
+
+/**
+ * Template Configuration View - Select and configure using a template
+ */
+const TemplateConfigurationView = ({
+ challenge,
+ configuration,
+ onTemplateChange,
+ onUpdateConfiguration,
+ onSwitchMode,
+ onRemoveConfig,
+ readOnly,
+ availableWorkflows: workflows
+}) => {
+ const {
+ templates,
+ selectedTemplate,
+ templatesLoading,
+ error: templateError,
+ selectTemplate,
+ clearSelection
+ } = useTemplateManager(
+ configuration.templateId,
+ challenge.track.name,
+ challenge.type.name
+ )
+ const [showSwitchToManualConfirm, setShowSwitchToManualConfirm] = useState(false)
+
+ const handleTemplateChange = useCallback((e) => {
+ const templateId = e.target.value
+ const template = selectTemplate(templateId)
+ if (template) {
+ onTemplateChange(template)
+ }
+ }, [selectTemplate, onTemplateChange])
+
+ const handleRemoveConfig = useCallback(() => {
+ clearSelection()
+ onRemoveConfig()
+ }, [onRemoveConfig, clearSelection])
+
+ const handleConfirmSwitch = useCallback(() => {
+ clearSelection()
+ onSwitchMode('manual', selectedTemplate)
+ }, [onSwitchMode, selectedTemplate])
+
+ const handleOnSwitchConfig = useCallback(() => {
+ if (selectedTemplate && selectedTemplate.id) {
+ setShowSwitchToManualConfirm(true)
+ } else {
+ handleConfirmSwitch()
+ }
+ }, [
+ selectedTemplate, setShowSwitchToManualConfirm, handleConfirmSwitch
+ ])
+
+ if (templateError) {
+ return (
+
+ {templateError}
+
+ )
+ }
+
+ return (
+
+ {/* Configuration Source Selector */}
+
+
+ {/* Template Selection Section */}
+
+
📋 AI Review Template
+
+
+
+
+
+ {selectedTemplate && (
+
+
{selectedTemplate.description}
+
+ )}
+
+
+ {/* Review Settings Section */}
+ {/* {selectedTemplate && (
+
+ )} */}
+
+ {/* AI Workflows Section */}
+ {selectedTemplate && configuration.workflows && configuration.workflows.length > 0 && (
+
+ )}
+
+ {/* Summary Section */}
+ {selectedTemplate && (
+
+ )}
+
+ {/* Remove Configuration Button */}
+ {!readOnly && selectedTemplate && (
+
+
+
+ )}
+
+ {templatesLoading && (
+
Loading templates...
+ )}
+
+ {showSwitchToManualConfirm && (
+
+ The template settings will be copied into editable fields.
+ You can then modify workflows, weights, and settings individually.
+
+ )}
+ cancelText='Cancel'
+ confirmText='Switch to Manual'
+ onCancel={() => setShowSwitchToManualConfirm(false)}
+ onConfirm={handleConfirmSwitch}
+ />
+ )}
+
+ )
+}
+
+TemplateConfigurationView.propTypes = {
+ challenge: PropTypes.object.isRequired,
+ configuration: PropTypes.shape({
+ mode: PropTypes.string.isRequired,
+ minPassingThreshold: PropTypes.number.isRequired,
+ autoFinalize: PropTypes.bool.isRequired,
+ workflows: PropTypes.array.isRequired
+ }).isRequired,
+ onTemplateChange: PropTypes.func.isRequired,
+ onUpdateConfiguration: PropTypes.func.isRequired,
+ onSwitchMode: PropTypes.func.isRequired,
+ onRemoveConfig: PropTypes.func.isRequired,
+ readOnly: PropTypes.bool,
+ availableWorkflows: PropTypes.array.isRequired
+}
+
+TemplateConfigurationView.defaultProps = {
+ readOnly: false
+}
+
+export default TemplateConfigurationView
diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiWorkflowCard.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiWorkflowCard.module.scss
new file mode 100644
index 00000000..8e1edde4
--- /dev/null
+++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiWorkflowCard.module.scss
@@ -0,0 +1,119 @@
+@use '../../../styles/includes' as *;
+
+.workflowCard {
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ background-color: white;
+ overflow: hidden;
+ transition: all 0.3s ease;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+
+ &:hover {
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
+ }
+}
+
+.workflowcardHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px;
+ background-color: #f8f9fa;
+ border-bottom: 1px solid #e0e0e0;
+}
+
+.workflowInfo {
+ flex: 1;
+}
+
+.workflowName {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 14px;
+ font-weight: 500;
+ color: #333;
+}
+
+.workflowIcon {
+ font-size: 18px;
+}
+
+.workflowTitle {
+ word-break: break-word;
+}
+
+.workflowRemoveBtn {
+ background: none;
+ border: none;
+ color: #999;
+ font-size: 20px;
+ cursor: pointer;
+ padding: 0;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ transition: all 0.2s ease;
+ flex-shrink: 0;
+
+ &:hover {
+ color: #d32f2f;
+ background-color: #ffebee;
+ }
+}
+
+.workflowcardContent {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.workflowDescription {
+ strong {
+ display: block;
+ font-size: 12px;
+ color: #999;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 4px;
+ }
+
+ p {
+ margin: 0;
+ font-size: 14px;
+ color: #555;
+ line-height: 1.4;
+ }
+}
+
+.workflowScorecard {
+ strong {
+ display: block;
+ font-size: 12px;
+ color: #999;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 4px;
+ }
+}
+
+.scorecardLink {
+ color: #0066cc;
+ text-decoration: none;
+ font-size: 14px;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ border-radius: 4px;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background-color: #e3f2fd;
+ text-decoration: underline;
+ }
+}
diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss
index 268b520e..e8ff3a1a 100644
--- a/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss
+++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss
@@ -1,4 +1,5 @@
@use '../../../styles/includes' as *;
+@import './shared.module.scss';
.row {
box-sizing: border-box;
@@ -40,191 +41,91 @@
width: 600px;
}
+ &.full {
+ width: 100%;
+ }
+
.fieldError {
margin-top: 12px;
}
}
}
-.description {
- color: #666;
- margin-bottom: 20px;
- font-size: 14px;
- line-height: 1.4;
-}
-
-.noReviewers {
- text-align: center;
- padding: 30px;
- color: #999;
- font-style: italic;
- background-color: #f5f5f5;
- border-radius: 4px;
- margin-bottom: 20px;
-}
-
-.defaultReviewerNote {
- margin-top: 20px;
- padding: 15px;
- background-color: #e3f2fd;
- border: 1px solid #bbdefb;
- border-radius: 4px;
-}
-
-.defaultReviewerNote p {
- margin: 0 0 15px 0;
- color: #1976d2;
- font-style: normal;
-}
-
-.reviewerForm {
- background-color: white;
+// Tabs styling
+.tabsContainer {
+ display: flex;
+ flex-direction: column;
border: 1px solid #ddd;
border-radius: 4px;
- padding: 20px;
- margin-bottom: 20px;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
-}
-
-.reviewerHeader {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
- padding-bottom: 10px;
- border-bottom: 1px solid #eee;
-}
-
-.reviewerHeader h4 {
- margin: 0;
- color: #333;
- font-size: 16px;
- font-weight: 600;
+ background-color: #fff;
+ .hidden {
+ display: none !important;
+ }
}
-.formRow {
+.tabsHeader {
display: flex;
- flex-wrap: wrap;
- gap: 20px;
- margin-bottom: 15px;
+ border-bottom: 2px solid #ddd;
+ background-color: #f9f9f9;
}
-.formGroup {
+.tabButton {
flex: 1;
- min-width: 200px;
-}
-
-.formGroup label {
- display: block;
- margin-bottom: 5px;
- font-weight: 500;
- color: #555;
+ padding: 12px 16px;
+ background: transparent;
+ border: none;
+ cursor: pointer;
font-size: 14px;
+ font-weight: 500;
+ color: #666;
+ border-bottom: 3px solid transparent;
+ transition: all 0.3s ease;
+ margin-bottom: -1px;
+
+ &:hover {
+ background-color: #f0f0f0;
+ color: #333;
+ }
+
+ &.active {
+ color: #0066cc;
+ border-bottom-color: #0066cc;
+ background-color: #fff;
+ }
}
-.formGroup input,
-.formGroup select {
- width: 100%;
- padding: 8px 12px;
- border: 1px solid #ccc;
- border-radius: 4px;
- font-size: 14px;
- background-color: white;
-}
-
-.formGroup input:focus,
-.formGroup select:focus {
- outline: none;
- border-color: #0066cc;
- box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
-}
-
-.addButton {
- text-align: center;
+.defaultReviewerNote {
margin-top: 20px;
-}
-
-.summary {
- background-color: #f8f9fa;
- border: 1px solid #dee2e6;
+ padding: 15px;
+ background-color: #e3f2fd;
+ border: 1px solid #bbdefb;
border-radius: 4px;
- padding: 20px;
- margin: 20px 0;
-}
-.summary h4 {
- margin: 0 0 15px 0;
- color: #333;
- font-size: 16px;
- font-weight: 600;
-}
-
-.summaryRow {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 8px 0;
- border-bottom: 1px solid #eee;
-}
-
-.summaryRow:last-child {
- border-bottom: none;
- font-weight: 600;
- color: #0066cc;
-}
-
-.summaryRow span:first-child {
- color: #666;
+ p {
+ margin: 0 0 15px 0;
+ color: #1976d2;
+ font-style: normal;
+ }
}
-.summaryRow span:last-child {
+.applyDefaultBtn {
+ padding: 8px 16px;
+ background-color: #0066cc;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
font-weight: 500;
-}
-
-.loading {
- text-align: center;
- padding: 40px;
- color: #666;
- font-style: italic;
-}
-
-.error {
- color: $tc-red;
- padding: 5px;
-}
+ transition: background-color 0.3s ease;
-.validationErrors {
- background-color: #fff3cd;
- border: 1px solid #ffeaa7;
- border-radius: 4px;
- padding: 10px;
- margin-bottom: 15px;
-}
+ &:hover {
+ background-color: #0052a3;
+ }
-.validationError {
- color: #856404;
- font-size: 13px;
- margin-bottom: 5px;
+ &:active {
+ background-color: #004080;
+ }
}
-.validationError:last-child {
- margin-bottom: 0;
-}
-// Responsive adjustments
-@media (max-width: 768px) {
- .formRow {
- flex-direction: column;
- gap: 15px;
- }
-
- .formGroup {
- min-width: 100%;
- }
-
- .reviewerHeader {
- flex-direction: column;
- align-items: flex-start;
- gap: 10px;
- }
-}
diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/HumanReviewTab.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/HumanReviewTab.js
new file mode 100644
index 00000000..d950a4b1
--- /dev/null
+++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/HumanReviewTab.js
@@ -0,0 +1,748 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import cn from 'classnames'
+import { OutlineButton } from '../../Buttons'
+import { REVIEW_OPPORTUNITY_TYPE_LABELS, REVIEW_OPPORTUNITY_TYPES, VALIDATION_VALUE_TYPE, DES_TRACK_ID } from '../../../config/constants'
+import styles from './ChallengeReviewer-Field.module.scss'
+import { validateValue } from '../../../util/input-check'
+import AssignedMemberField from '../AssignedMember-Field'
+import { isEqual } from 'lodash'
+import { getResourceRoleByName, getRoleNameForReviewer } from '../../../util/tc'
+import calculateReviewCost from './ReviewSummary/calcReviewCost'
+
+const normalizePhaseToken = (value) => (value || '')
+ .toString()
+ .toLowerCase()
+ .trim()
+ .replace(/\bphase\b$/, '')
+ .replace(/[-_\s]/g, '')
+
+const normalizeIdValue = (value) => (
+ value === undefined || value === null
+ ? ''
+ : value.toString()
+)
+
+const getScorecardsForPhase = (scorecards = [], phases = [], phaseId) => {
+ const normalizedPhaseId = normalizeIdValue(phaseId)
+ if (!normalizedPhaseId) {
+ return []
+ }
+
+ const selectedPhase = phases.find(phase => (
+ normalizeIdValue(phase.phaseId) === normalizedPhaseId ||
+ normalizeIdValue(phase.id) === normalizedPhaseId
+ ))
+
+ if (!selectedPhase || !selectedPhase.name) {
+ return []
+ }
+
+ const normalizedPhaseName = normalizePhaseToken(selectedPhase.name)
+ if (!normalizedPhaseName) {
+ return []
+ }
+
+ return scorecards.filter(scorecard => (
+ scorecard &&
+ normalizePhaseToken(scorecard.type) === normalizedPhaseName
+ ))
+}
+
+class HumanReviewTab extends Component {
+ constructor (props) {
+ super(props)
+ this.state = {
+ error: null,
+ assignedMembers: {}
+ }
+
+ this.addReviewer = this.addReviewer.bind(this)
+ this.removeReviewer = this.removeReviewer.bind(this)
+ this.updateReviewer = this.updateReviewer.bind(this)
+ this.renderReviewerForm = this.renderReviewerForm.bind(this)
+ this.handleApplyDefault = this.handleApplyDefault.bind(this)
+ this.getMissingRequiredPhases = this.getMissingRequiredPhases.bind(this)
+ this.onAssignmentChange = this.onAssignmentChange.bind(this)
+ this.syncAssignmentsOnCountChange = this.syncAssignmentsOnCountChange.bind(this)
+ this.handlePhaseChangeWithReassign = this.handlePhaseChangeWithReassign.bind(this)
+ this.handleToggleShouldOpen = this.handleToggleShouldOpen.bind(this)
+ this.updateAssignedMembers = this.updateAssignedMembers.bind(this)
+ this.doUpdateAssignedMembers = true
+ }
+
+ componentDidMount () {
+ const { metadata, challenge, challengeResources } = this.props
+ if (challenge && challenge.id && challengeResources) {
+ this.updateAssignedMembers(challengeResources, challenge, metadata)
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ const { challenge: prevChallenge } = prevProps
+ const { metadata, challenge, challengeResources } = this.props
+
+ const reviewersChanged = (() => {
+ if (!prevChallenge || !challenge) return false
+ const prev = (prevChallenge.reviewers || []).filter(r => !this.isAIReviewer(r))
+ const curr = (challenge.reviewers || []).filter(r => !this.isAIReviewer(r))
+ if (prev.length !== curr.length) return true
+ return prev.some((p, i) => {
+ const { scorecardId: prevScorecardId, ...prevRest } = p
+ const currentReviewer = curr[i] || {}
+ const { scorecardId: currScorecardId, ...currRest } = currentReviewer
+ if (JSON.stringify(currRest) !== JSON.stringify(prevRest)) {
+ return true
+ }
+ })
+ })()
+
+ if (challenge && this.doUpdateAssignedMembers && reviewersChanged) {
+ this.updateAssignedMembers(challengeResources, challenge, metadata, prevChallenge)
+ }
+ }
+
+ isAIReviewer (reviewer) {
+ return reviewer && (
+ (reviewer.aiWorkflowId && reviewer.aiWorkflowId.trim() !== '') ||
+ (reviewer.isMemberReview === false)
+ )
+ }
+
+ isPublicOpportunityOpen (reviewer) {
+ return reviewer && reviewer.shouldOpenOpportunity === true
+ }
+
+ getMissingRequiredPhases () {
+ const { challenge } = this.props
+ const requiredPhases = []
+ const memberReviewers = (challenge.reviewers || []).filter(r => !this.isAIReviewer(r))
+
+ if (challenge && challenge.phases) {
+ for (const phase of challenge.phases) {
+ const phaseName = (phase.name || '').toLowerCase()
+ const hasReviewPhase = phaseName.includes('review')
+ if (hasReviewPhase) {
+ const hasReviewer = memberReviewers.some(r => (r.phaseId === phase.id || r.phaseId === phase.phaseId))
+ if (!hasReviewer) {
+ requiredPhases.push(phase.name || phase.phaseId || phase.id)
+ }
+ }
+ }
+ }
+
+ return requiredPhases
+ }
+
+ async onAssignmentChange (reviewerIndex, slotIndex, option) {
+ const { challenge, metadata = {}, replaceResourceInRole } = this.props
+ // reviewerIndex is the filtered human reviewer index
+ const humanReviewers = (challenge.reviewers || []).filter(r => !this.isAIReviewer(r))
+ const reviewer = humanReviewers[reviewerIndex]
+ if (!reviewer || this.isAIReviewer(reviewer)) return
+
+ const currentAssignedMembers = this.state.assignedMembers[reviewerIndex] || []
+ const roleName = getRoleNameForReviewer(reviewer, challenge.phases)
+ const role = getResourceRoleByName(metadata.resourceRoles || [], roleName)
+ const newAssigned = [...currentAssignedMembers]
+ newAssigned[slotIndex] = option && {
+ handle: option.label,
+ userId: option.value,
+ roleId: role.id
+ }
+
+ const newAssignedMembers = { ...this.state.assignedMembers }
+ newAssignedMembers[reviewerIndex] = newAssigned
+ this.setState({ assignedMembers: newAssignedMembers })
+
+ if (option) {
+ await this.createOrReplaceResource(
+ option,
+ reviewer,
+ role.id,
+ currentAssignedMembers[slotIndex],
+ challenge
+ )
+ } else if (currentAssignedMembers[slotIndex]) {
+ const oldOption = currentAssignedMembers[slotIndex]
+ await replaceResourceInRole(
+ challenge.id,
+ oldOption.roleId,
+ null,
+ oldOption.handle
+ )
+ }
+ }
+
+ async createOrReplaceResource (option, reviewer, roleId, oldOption, challenge) {
+ const { replaceResourceInRole, createResource } = this.props
+
+ if (oldOption) {
+ await replaceResourceInRole(
+ challenge.id,
+ oldOption.roleId,
+ option.label,
+ oldOption.handle
+ )
+ } else {
+ await createResource(challenge.id, roleId, option.label)
+ }
+ }
+
+ async syncAssignmentsOnCountChange (reviewerIndex, newCount) {
+ const currentAssigned = this.state.assignedMembers[reviewerIndex] || []
+ const diff = newCount - currentAssigned.length
+
+ if (diff > 0) {
+ // Add empty slots
+ const newAssigned = [...currentAssigned, ...Array(diff).fill(null)]
+ const newAssignedMembers = { ...this.state.assignedMembers }
+ newAssignedMembers[reviewerIndex] = newAssigned
+ this.setState({ assignedMembers: newAssignedMembers })
+ } else if (diff < 0) {
+ // Remove slots (delete resources)
+ const { deleteResource, challenge } = this.props
+ const removedMembers = currentAssigned.slice(newCount)
+ const newAssigned = currentAssigned.slice(0, newCount)
+
+ for (const member of removedMembers) {
+ if (member && member.roleId && member.handle) {
+ await deleteResource(challenge.id, member.roleId, member.handle)
+ }
+ }
+
+ const newAssignedMembers = { ...this.state.assignedMembers }
+ newAssignedMembers[reviewerIndex] = newAssigned
+ this.setState({ assignedMembers: newAssignedMembers })
+ }
+ }
+
+ async handlePhaseChangeWithReassign (reviewerIndex, newPhaseId) {
+ this.updateReviewer(reviewerIndex, 'phaseId', newPhaseId)
+ // Reassignment would happen here if needed
+ }
+
+ async handleToggleShouldOpen (reviewerIndex, nextValue) {
+ this.updateReviewer(reviewerIndex, 'shouldOpenOpportunity', nextValue)
+ }
+
+ updateAssignedMembers (challengeResources, challenge, metadata, prevChallenge = null) {
+ const memberReviewers = (challenge.reviewers || []).filter(r => !this.isAIReviewer(r))
+ const newAssignedMembers = {}
+
+ memberReviewers.forEach((reviewer, reviewerIndex) => {
+ const roleName = getRoleNameForReviewer(reviewer, challenge.phases)
+ const role = getResourceRoleByName(metadata.resourceRoles || [], roleName)
+ const resourceRoleId = role && role.id
+
+ const matchingResources = challengeResources
+ ? challengeResources.filter(resource => resource.roleId === resourceRoleId)
+ : []
+
+ newAssignedMembers[reviewerIndex] = Array(parseInt(reviewer.memberReviewerCount) || 1)
+ .fill(null)
+ .map((_, i) => {
+ const matchingResource = matchingResources[i]
+ if (matchingResource) {
+ return {
+ handle: matchingResource.memberHandle,
+ userId: matchingResource.memberId,
+ roleId: matchingResource.roleId,
+ resourceId: matchingResource.id
+ }
+ }
+ return null
+ })
+ })
+
+ this.doUpdateAssignedMembers = true
+ if (!isEqual(newAssignedMembers, this.state.assignedMembers)) {
+ this.setState({ assignedMembers: newAssignedMembers })
+ }
+ }
+
+ addReviewer () {
+ const { challenge, onUpdateReviewers } = this.props
+ const currentReviewers = challenge.reviewers || []
+
+ const { metadata = {} } = this.props
+ const { defaultReviewers = [] } = metadata
+
+ const defaultReviewer = defaultReviewers && defaultReviewers.length > 0 ? defaultReviewers[0] : null
+
+ const reviewPhases = challenge.phases && challenge.phases.filter(phase =>
+ phase.name && phase.name.toLowerCase().includes('review')
+ )
+ const firstReviewPhase = reviewPhases && reviewPhases.length > 0 ? reviewPhases[0] : null
+
+ const fallbackPhase = !firstReviewPhase && challenge.phases && challenge.phases.length > 0
+ ? challenge.phases[0]
+ : null
+
+ let defaultPhaseId = ''
+ if (defaultReviewer && defaultReviewer.phaseId) {
+ defaultPhaseId = defaultReviewer.phaseId
+ } else if (firstReviewPhase) {
+ defaultPhaseId = firstReviewPhase.phaseId || firstReviewPhase.id
+ } else if (fallbackPhase) {
+ defaultPhaseId = fallbackPhase.phaseId || fallbackPhase.id
+ }
+
+ const newReviewer = {
+ scorecardId: (defaultReviewer && defaultReviewer.scorecardId) || '',
+ isMemberReview: true,
+ phaseId: defaultPhaseId,
+ fixedAmount: (defaultReviewer && defaultReviewer.fixedAmount) || 0,
+ baseCoefficient: (defaultReviewer && defaultReviewer.baseCoefficient) || '0.13',
+ incrementalCoefficient: (defaultReviewer && defaultReviewer.incrementalCoefficient) || 0.05,
+ type: (defaultReviewer && defaultReviewer.opportunityType) || REVIEW_OPPORTUNITY_TYPES.REGULAR_REVIEW,
+ shouldOpenOpportunity: false,
+ memberReviewerCount: (defaultReviewer && defaultReviewer.memberReviewerCount) || 1
+ }
+
+ if (this.state.error) {
+ this.setState({ error: null })
+ }
+
+ const updatedReviewers = currentReviewers.concat([newReviewer])
+ onUpdateReviewers({ field: 'reviewers', value: updatedReviewers })
+ }
+
+ removeReviewer (index) {
+ const { challenge, onUpdateReviewers } = this.props
+ const currentReviewers = challenge.reviewers || []
+
+ // Map the human reviewer index to the actual index in the full reviewers array
+ const humanReviewers = currentReviewers.filter(r => !this.isAIReviewer(r))
+ const reviewerToRemove = humanReviewers[index]
+ const actualIndex = currentReviewers.indexOf(reviewerToRemove)
+
+ if (actualIndex !== -1) {
+ const updatedReviewers = currentReviewers.filter((_, i) => i !== actualIndex)
+ onUpdateReviewers({ field: 'reviewers', value: updatedReviewers })
+ }
+ }
+
+ updateReviewer (index, field, value) {
+ const { challenge, onUpdateReviewers } = this.props
+ const currentReviewers = challenge.reviewers || []
+ const updatedReviewers = currentReviewers.slice()
+ const fieldUpdate = { [field]: value }
+
+ // Map the human reviewer index to the actual index in the full reviewers array
+ const humanReviewers = currentReviewers.filter(r => !this.isAIReviewer(r))
+ const reviewerToUpdate = humanReviewers[index]
+ const actualIndex = currentReviewers.indexOf(reviewerToUpdate)
+
+ if (actualIndex === -1) return
+
+ if (field === 'phaseId') {
+ const defaultReviewer = this.findDefaultReviewer(value) || updatedReviewers[actualIndex]
+ Object.assign(fieldUpdate, {
+ fixedAmount: defaultReviewer.fixedAmount,
+ baseCoefficient: defaultReviewer.baseCoefficient,
+ incrementalCoefficient: defaultReviewer.incrementalCoefficient
+ })
+
+ if (updatedReviewers[actualIndex] && (updatedReviewers[actualIndex].isMemberReview !== false)) {
+ const { metadata = {} } = this.props
+ const scorecardsForPhase = getScorecardsForPhase(
+ metadata.scorecards || [],
+ challenge.phases || [],
+ value
+ )
+ const currentScorecardId = normalizeIdValue(updatedReviewers[actualIndex].scorecardId)
+ const hasCurrentScorecard = scorecardsForPhase.some(scorecard => (
+ normalizeIdValue(scorecard.id) === currentScorecardId
+ ))
+
+ if (!hasCurrentScorecard) {
+ const defaultScorecardId = normalizeIdValue(defaultReviewer && defaultReviewer.scorecardId)
+ const hasDefaultScorecard = defaultScorecardId && scorecardsForPhase.some(scorecard => (
+ normalizeIdValue(scorecard.id) === defaultScorecardId
+ ))
+ const fallbackScorecardId = hasDefaultScorecard
+ ? defaultScorecardId
+ : normalizeIdValue(scorecardsForPhase[0] && scorecardsForPhase[0].id)
+
+ fieldUpdate.scorecardId = fallbackScorecardId || ''
+ }
+ }
+ }
+
+ if (field === 'memberReviewerCount') {
+ const newCount = parseInt(value) || 1
+ this.syncAssignmentsOnCountChange(index, Math.max(1, newCount))
+ }
+
+ updatedReviewers[actualIndex] = Object.assign({}, updatedReviewers[actualIndex], fieldUpdate)
+ onUpdateReviewers({ field: 'reviewers', value: updatedReviewers })
+ }
+
+ findDefaultReviewer (phaseId) {
+ const { challenge, metadata = {} } = this.props
+ const { defaultReviewers = [] } = metadata
+
+ if (!challenge || !challenge.trackId || !challenge.typeId) {
+ return null
+ }
+
+ return phaseId ? defaultReviewers.find(dr => dr.phaseId === phaseId) : defaultReviewers[0]
+ }
+
+ validateReviewer (reviewer) {
+ const errors = {}
+
+ if (!reviewer.scorecardId) {
+ errors.scorecardId = 'Scorecard is required'
+ }
+
+ const memberCount = parseInt(reviewer.memberReviewerCount) || 1
+ if (memberCount < 1 || !Number.isInteger(memberCount)) {
+ errors.memberReviewerCount = 'Number of reviewers must be a positive integer'
+ }
+
+ if (!reviewer.phaseId) {
+ errors.phaseId = 'Phase is required'
+ }
+
+ return errors
+ }
+
+ handleApplyDefault () {
+ const defaultReviewer = this.findDefaultReviewer()
+ if (defaultReviewer) {
+ this.addReviewer()
+ }
+ }
+
+ renderReviewerForm (reviewer, index) {
+ const { challenge, metadata = {}, readOnly = false } = this.props
+ const { scorecards = [] } = metadata
+ const validationErrors = challenge.submitTriggered ? this.validateReviewer(reviewer) : {}
+ const filteredScorecards = getScorecardsForPhase(
+ scorecards,
+ challenge.phases || [],
+ reviewer.phaseId
+ )
+ const isDesignChallenge = challenge && challenge.trackId === DES_TRACK_ID
+
+ return (
+
+
+
Reviewer {index + 1}
+ {!readOnly && (
+ this.removeReviewer(index)}
+ />
+ )}
+
+
+
+
+
+ {readOnly ? (
+
+ {(() => {
+ const phase = (challenge.phases || []).find(p => (p.id === reviewer.phaseId) || (p.phaseId === reviewer.phaseId))
+ return phase ? (phase.name || `Phase ${phase.phaseId || phase.id}`) : 'Not selected'
+ })()}
+
+ ) : (
+
+ )}
+ {!readOnly && challenge.submitTriggered && validationErrors.phaseId && (
+
+ {validationErrors.phaseId}
+
+ )}
+
+
+
+
+
+
+ {readOnly ? (
+
{reviewer.memberReviewerCount || 1}
+ ) : (
+
{
+ const validatedValue = validateValue(e.target.value, VALIDATION_VALUE_TYPE.INTEGER)
+ const parsedValue = parseInt(validatedValue) || 1
+ this.updateReviewer(index, 'memberReviewerCount', Math.max(1, parsedValue))
+ }}
+ />
+ )}
+ {!readOnly && challenge.submitTriggered && validationErrors.memberReviewerCount && (
+
+ {validationErrors.memberReviewerCount}
+
+ )}
+
+
+
+
+
+
+ {readOnly ? (
+
+ {(() => {
+ const scorecard = scorecards.find(s => s.id === reviewer.scorecardId)
+ return scorecard ? `${scorecard.name || 'Unknown'} - ${scorecard.type || 'Unknown'} (${scorecard.challengeTrack || 'Unknown'}) v${scorecard.version || 'Unknown'}` : 'Not selected'
+ })()}
+
+ ) : (
+
+ )}
+ {!readOnly && challenge.submitTriggered && validationErrors.scorecardId && (
+
+ {validationErrors.scorecardId}
+
+ )}
+
+
+
+
+
+
+ {readOnly ? (
+
+ {REVIEW_OPPORTUNITY_TYPE_LABELS[reviewer.type] || 'Regular Review'}
+
+ ) : (
+
+ )}
+
+ {!isDesignChallenge && (
+
+
+
+ )}
+
+
+ {!this.isAIReviewer(reviewer) && (isDesignChallenge || !this.isPublicOpportunityOpen(reviewer)) && (
+
+
+
+ {Array.from({ length: parseInt(reviewer.memberReviewerCount || 1) }, (_, i) => {
+ const assigned = (this.state.assignedMembers[index] || [])[i] || null
+ return (
+
+
this.onAssignmentChange(index, i, option)}
+ />
+
+ )
+ })}
+
+
+ )}
+
+ )
+ }
+
+ getFirstPlacePrizeValue (challenge) {
+ const prizeSets = challenge.prizeSets || []
+ const placementPrizeSet = prizeSets.find(p => p.type === 'PLACEMENT')
+ if (placementPrizeSet && placementPrizeSet.prizes && placementPrizeSet.prizes.length > 0) {
+ return parseFloat(placementPrizeSet.prizes[0].value) || 0
+ }
+ return 0
+ }
+
+ render () {
+ const { challenge, isLoading, readOnly = false } = this.props
+ const { error } = this.state
+ const reviewers = (challenge.reviewers || []).filter(r => !this.isAIReviewer(r))
+ const reviewersCost = calculateReviewCost(reviewers, challenge)
+
+ if (isLoading) {
+ return
Loading...
+ }
+
+ return (
+
+ {(!readOnly && challenge.submitTriggered) && (() => {
+ const missing = this.getMissingRequiredPhases()
+ if (missing.length > 0) {
+ return (
+
+ {`Please configure a scorecard for: ${missing.join(', ')}`}
+
+ )
+ }
+ return null
+ })()}
+
+ {!readOnly && (
+
+ Configure member reviewers for this challenge. Set up scorecards and assign team members.
+
+ )}
+
+ {!readOnly && reviewers.length === 0 && (
+
+
No member reviewers configured. Click "Add Member Reviewer" to get started.
+ {this.findDefaultReviewer() && (
+
+
Note: Default reviewer configuration is available for this track and type combination.
+
+
+ )}
+
+ )}
+
+ {readOnly && reviewers.length === 0 && (
+
+
No member reviewers configured for this challenge.
+
+ )}
+
+ {reviewers.length > 0 && reviewers.map((reviewer, index) =>
+ this.renderReviewerForm(reviewer, index)
+ )}
+
+ {reviewers.length > 0 && (
+
+
Review Summary
+
+ Total Member Reviewers:
+ {reviewers.reduce((sum, r) => sum + (parseInt(r.memberReviewerCount) || 1), 0)}
+
+
+ Estimated Review Cost:
+
+ ${reviewersCost}
+
+
+
+ )}
+
+ {!readOnly && (
+
+
+
+ )}
+
+ {error && !isLoading && (
+
+ {error}
+
+ )}
+
+ )
+ }
+}
+
+HumanReviewTab.propTypes = {
+ challenge: PropTypes.object.isRequired,
+ onUpdateReviewers: PropTypes.func.isRequired,
+ metadata: PropTypes.shape({
+ scorecards: PropTypes.array,
+ defaultReviewers: PropTypes.array,
+ resourceRoles: PropTypes.array,
+ challengeTracks: PropTypes.array
+ }),
+ isLoading: PropTypes.bool,
+ readOnly: PropTypes.bool,
+ replaceResourceInRole: PropTypes.func.isRequired,
+ createResource: PropTypes.func.isRequired,
+ deleteResource: PropTypes.func.isRequired,
+ challengeResources: PropTypes.array.isRequired
+}
+
+export default HumanReviewTab
diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/ReviewSummary.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/ReviewSummary.js
new file mode 100644
index 00000000..2946bf0b
--- /dev/null
+++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/ReviewSummary.js
@@ -0,0 +1,369 @@
+import React, { useState, useEffect, useCallback } from 'react'
+import PropTypes from 'prop-types'
+import cn from 'classnames'
+import { REVIEW_APP_URL, REVIEW_OPPORTUNITY_TYPE_LABELS } from '../../../../config/constants'
+import { isAIReviewer } from '../AiReviewerTab/utils'
+import calculateReviewCost from './calcReviewCost'
+import { fetchAIReviewConfigByChallenge } from '../../../../services/aiReviewConfigs'
+import { getResourceRoleByName, getRoleNameForReviewer } from '../../../../util/tc'
+import styles from './ReviewSummary.module.scss'
+
+const ReviewSummary = ({
+ challenge,
+ metadata = {},
+ challengeResources = [],
+ readOnly = false
+}) => {
+ const [aiConfiguration, setAiConfiguration] = useState(null)
+ const [, setIsLoadingAIConfig] = useState(false)
+
+ useEffect(() => {
+ if (challenge && challenge.id) {
+ setIsLoadingAIConfig(true)
+ fetchAIReviewConfigByChallenge(challenge.id)
+ .then(config => {
+ setAiConfiguration(config)
+ setIsLoadingAIConfig(false)
+ })
+ .catch(error => {
+ console.error('Error fetching AI review config:', error)
+ setIsLoadingAIConfig(false)
+ })
+ }
+ }, [challenge && challenge.id])
+
+ if (!challenge) return null
+
+ const { scorecards = [], workflows = [] } = metadata
+
+ // Filter human and AI reviewers
+ const allReviewers = challenge.reviewers || []
+ const humanReviewers = allReviewers.filter(r => !isAIReviewer(r))
+ const aiReviewers = allReviewers.filter(r => isAIReviewer(r))
+
+ // Calculate review cost based on human reviewers (delegated to util)
+
+ // Get scorecard name from ID
+ const getScorecardName = (scorecardId) => {
+ if (!scorecardId) return 'Not selected'
+ const scorecard = scorecards.find(s => s.id === scorecardId)
+ return scorecard ? scorecard.name : 'Unknown Scorecard'
+ }
+
+ // Get phase name
+ const getPhaseName = (phaseId) => {
+ if (!phaseId || !challenge.phases) return '-'
+ const phase = challenge.phases.find(p => (p.id === phaseId || p.phaseId === phaseId))
+ return phase ? phase.name : '-'
+ }
+
+ const getAssignedMembersForReviewer = useCallback((reviewer) => {
+ const reviewerCount = parseInt(reviewer.memberReviewerCount) || 1
+ const roleName = getRoleNameForReviewer(reviewer, challenge.phases)
+ const role = getResourceRoleByName(metadata.resourceRoles || [], roleName)
+ const resourceRoleId = role && role.id
+
+ if (!resourceRoleId) return ''
+
+ const matchingResources = challengeResources
+ .filter(resource => resource.roleId === resourceRoleId)
+ .slice(0, reviewerCount)
+
+ return matchingResources
+ .map(resource => resource.memberHandle)
+ .filter(Boolean)
+ .join(', ')
+ }, [challenge, metadata, challengeResources])
+
+ // Check if AI review is configured
+ const hasAIConfigWorkflows = aiConfiguration && aiConfiguration.workflows && (aiConfiguration.workflows.length > 0)
+ const hasLegacyAIReviewers = aiReviewers.length > 0
+ const hasAIConfiguration = hasAIConfigWorkflows || hasLegacyAIReviewers
+
+ // Check if AI mode is gating or only
+ const isAIOnlyMode = aiConfiguration && aiConfiguration.mode === 'AI_ONLY'
+ const isAIGatingMode = aiConfiguration && aiConfiguration.mode === 'AI_GATING'
+
+ // Check if AI is gating reviewer (has gating workflows)
+ const isAIGating = aiConfiguration && aiConfiguration.workflows && aiConfiguration.workflows.some(w => w.isGating)
+
+ const legacyAIWorkflows = aiReviewers.map(reviewer => {
+ const workflow = workflows.find(w => w.id === reviewer.aiWorkflowId)
+ return {
+ workflowName: workflow && workflow.name
+ ? workflow.name
+ : (reviewer.aiWorkflowId || 'Legacy AI Reviewer'),
+ weightPercent: '-',
+ isGating: false,
+ workflow: {
+ scorecard: workflow ? {
+ name: workflow.scorecard.name,
+ id: workflow.scorecard.id
+ } : {}
+ }
+ }
+ })
+
+ const totalHumanReviewerCount = humanReviewers.reduce((sum, r) => sum + (parseInt(r.memberReviewerCount) || 1), 0)
+
+ const reviewCost = calculateReviewCost(humanReviewers, challenge)
+
+ return (
+
+
Review Configuration Summary
+
+ {/* Human and AI Review Overview */}
+
+ {/* Human Review Card */}
+
+
+ 👥
+ Human Review
+
+
+ {humanReviewers.length === 0 ? (
+
No human reviewers configured
+ ) : (
+ <>
+
+ Reviewers:
+ {totalHumanReviewerCount}
+
+
+
+
+
+
+ | # |
+ Phase |
+ Scorecard |
+ Review Type |
+ Count |
+ Public Opportunity |
+ Assigned Members |
+
+
+
+ {humanReviewers.map((reviewer, idx) => (
+
+ | {idx + 1} |
+ {getPhaseName(reviewer.phaseId)} |
+ {getScorecardName(reviewer.scorecardId)} |
+ {REVIEW_OPPORTUNITY_TYPE_LABELS[reviewer.type] || 'Regular'} |
+ {parseInt(reviewer.memberReviewerCount) || 1} |
+
+
+ {reviewer.shouldOpenOpportunity ? '✅ Yes' : '❌ No'}
+
+ |
+
+ {getAssignedMembersForReviewer(reviewer) || '-'}
+ |
+
+ ))}
+
+
+
+ >
+ )}
+
+
+
+ {/* AI Review Card */}
+
+
+ 🤖
+ AI Review
+
+
+ {!hasAIConfiguration ? (
+
No AI review configured
+ ) : (
+ <>
+ {hasAIConfigWorkflows && (
+
+ Mode:
+ {aiConfiguration && aiConfiguration.mode}
+
+ )}
+
+ {aiConfiguration && aiConfiguration.minPassingThreshold !== undefined && (
+
+ Threshold:
+ {aiConfiguration.minPassingThreshold}%
+
+ )}
+
+ {aiConfiguration && aiConfiguration.autoFinalize !== undefined && (
+
+ Auto-Finalize:
+ {aiConfiguration.autoFinalize ? '✅ On' : '❌ Off'}
+
+ )}
+
+ {((aiConfiguration && aiConfiguration.workflows && aiConfiguration.workflows.length > 0) || legacyAIWorkflows.length > 0) && (
+
+
Workflows:
+
+
+
+ | Name |
+ Weight |
+ Scorecard |
+ Type |
+
+
+
+ {(hasAIConfigWorkflows ? aiConfiguration.workflows : legacyAIWorkflows).map((workflow, idx) => (
+
+ | {workflow.workflowName || (workflow.workflow && workflow.workflow.name) || workflow.workflowId || '-'} |
+ {workflow.weightPercent === '-' ? '-' : `${workflow.weightPercent}%`} |
+
+ {workflow.workflow && workflow.workflow.scorecard && workflow.workflow.scorecard.id ? (
+
+ {workflow.workflow.scorecard.name}
+
+ ) : '-'}
+ |
+
+ {workflow.isGating ? (
+ ⚡ GATE
+ ) : (
+ {hasAIConfigWorkflows ? '📝' : '📝'}
+ )}
+ |
+
+ ))}
+
+
+
+ )}
+ >
+ )}
+
+
+
+
+ {/* Review Flow Diagram */}
+ {(humanReviewers.length > 0 || hasAIConfigWorkflows) && (
+
+
Review Flow
+
+ {/* Step 1: Submission */}
+
+
📥
+
Submission
+
Received
+
+
+ {/* Arrow 1 */}
+ {hasAIConfigWorkflows && (
+
→
+ )}
+ {!hasAIConfigWorkflows && humanReviewers.length > 0 && (
+
→
+ )}
+
+ {/* Step 2: AI Review / AI Gate (if configured) */}
+ {hasAIConfigWorkflows && (
+
+
🤖
+
{isAIOnlyMode ? 'AI Review' : 'AI Gate'}
+
+ score ≥ {aiConfiguration.minPassingThreshold || 75}%
+
+ {isAIGatingMode && (
+
pass / lock
+ )}
+
+ )}
+
+ {/* Arrow 2 (to human or end) - only for AI_GATING with human reviewers */}
+ {isAIGatingMode && humanReviewers.length > 0 && (
+
→
+ )}
+
+ {/* Step 3: Human Review (only for AI_GATING mode) */}
+ {isAIGatingMode && humanReviewers.length > 0 && (
+
+
👥
+
Human Review
+
+ {totalHumanReviewerCount} reviewers
+
+
+ )}
+
+ {/* Step 3: Human Review (for human-only flow) */}
+ {!hasAIConfigWorkflows && humanReviewers.length > 0 && (
+
+
👥
+
Human Review
+
+ {totalHumanReviewerCount} reviewers
+
+
+ )}
+
+ {/* Failure Path: Arrow down from AI Gate (only for AI_GATING with gating workflows) */}
+ {hasAIConfigWorkflows && isAIGatingMode && (
+
+ ↓
+ {/* Failure Label */}
+
+ < {aiConfiguration.minPassingThreshold || 75}%
+
+ ↓
+
+ )}
+
+ {/* Failure Path: Locked Step (only for AI_GATING with gating workflows) */}
+ {hasAIConfigWorkflows && isAIGatingMode && (
+
+
🔒
+
Locked
+
No human
+
review needed
+
+ )}
+
+
+ )}
+
+ {/* Estimated Cost */}
+ {humanReviewers.length > 0 && (
+
+
+ Estimated Review Cost:
+ ${reviewCost}
+
+
+ )}
+
+ )
+}
+
+ReviewSummary.propTypes = {
+ challenge: PropTypes.object.isRequired,
+ metadata: PropTypes.shape({
+ scorecards: PropTypes.array,
+ workflows: PropTypes.array,
+ resourceRoles: PropTypes.array,
+ challengeTracks: PropTypes.array
+ }),
+ challengeResources: PropTypes.array,
+ readOnly: PropTypes.bool
+}
+
+ReviewSummary.defaultProps = {
+ metadata: {},
+ challengeResources: [],
+ readOnly: false
+}
+
+export default ReviewSummary
diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/ReviewSummary.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/ReviewSummary.module.scss
new file mode 100644
index 00000000..ee2182fb
--- /dev/null
+++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/ReviewSummary.module.scss
@@ -0,0 +1,365 @@
+// @import '../../../../styles/variables.scss';
+
+.reviewSummaryContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+ padding: 1.5rem;
+ background: #f9f9f9;
+ border-radius: 8px;
+ border: 1px solid #e0e0e0;
+
+ .title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #333;
+ margin: 0;
+ padding-bottom: 0.5rem;
+ border-bottom: 2px solid #ddd;
+ }
+
+ .overviewSection {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1.5rem;
+
+ @media (max-width: 768px) {
+ grid-template-columns: 1fr;
+ }
+ }
+
+ .reviewCard {
+ background: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 6px;
+ overflow: hidden;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+
+ .cardHeader {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1rem;
+ background: #f5f5f5;
+ border-bottom: 1px solid #e0e0e0;
+
+ .icon {
+ font-size: 1.5rem;
+ }
+
+ .cardTitle {
+ font-size: 1rem;
+ font-weight: 600;
+ color: #333;
+ }
+ }
+
+ .cardContent {
+ padding: 1rem;
+
+ .badgeYes,
+ .badgeNo {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.25rem 0.75rem;
+ border-radius: 12px;
+ font-size: 0.85rem;
+ font-weight: 500;
+ }
+
+ .badgeYes {
+ background: #e8f5e9;
+ color: #2e7d32;
+ }
+
+ .badgeNo {
+ background: #ffebee;
+ color: #c62828;
+ }
+
+ .empty {
+ color: #999;
+ font-style: italic;
+ margin: 0;
+ padding: 1rem 0;
+ text-align: center;
+ }
+
+ .row {
+ display: flex;
+ gap: 1rem;
+ padding: 0.75rem 0;
+ border-bottom: 1px solid #f0f0f0;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ .label {
+ font-weight: 600;
+ color: #555;
+ min-width: 120px;
+ flex: 0 0 auto;
+ }
+
+ .value {
+ color: #333;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+
+ > div {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+ }
+
+ .badgeRow {
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ .humanTableSection {
+ margin-top: 0.75rem;
+ }
+
+ .humanTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9rem;
+
+ thead {
+ background: #f9f9f9;
+
+ th {
+ padding: 0.5rem;
+ text-align: left;
+ font-weight: 600;
+ color: #555;
+ border-bottom: 1px solid #e0e0e0;
+ font-size: 0.85rem;
+ line-height: 18px;
+ }
+ }
+
+ tbody {
+ tr {
+ border-bottom: 1px solid #f0f0f0;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ td {
+ padding: 0.5rem;
+ color: #333;
+ vertical-align: top;
+ }
+ }
+ }
+ }
+
+ * + .workflowsSection {
+ margin-top: 1rem;
+ border-top: 1px solid #f0f0f0;
+ }
+
+ .workflowsSection {
+ padding-top: 1rem;
+
+ .label {
+ display: block;
+ font-weight: 600;
+ color: #555;
+ margin-bottom: 0.75rem;
+ }
+
+ .workflowsTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9rem;
+
+ thead {
+ background: #f9f9f9;
+
+ th {
+ padding: 0.5rem;
+ text-align: left;
+ font-weight: 600;
+ color: #555;
+ border-bottom: 1px solid #e0e0e0;
+ font-size: 0.85rem;
+ }
+ }
+
+ tbody {
+ tr {
+ border-bottom: 1px solid #f0f0f0;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ td {
+ padding: 0.5rem;
+ color: #333;
+
+ &.typeCell {
+ text-align: center;
+ }
+ }
+ }
+ }
+
+ .gate {
+ display: inline-block;
+ background: #fff3cd;
+ color: #856404;
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ font-weight: 500;
+ font-size: 0.75rem;
+ }
+
+ .review {
+ font-size: 1rem;
+ }
+ }
+ }
+ }
+ }
+
+ .flowSection {
+ background: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 6px;
+ padding: 1.5rem;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+
+ .flowTitle {
+ font-size: 1rem;
+ font-weight: 600;
+ color: #333;
+ margin: 0 0 1.5rem 0;
+ }
+
+ .flowDiagram {
+ display: grid;
+ grid-auto-flow: column;
+ gap: 1rem;
+ padding: 1.5rem 0;
+ position: relative;
+ overflow-x: auto;
+
+ // Grid for AI Gating flow (with locked failure path)
+ &.withAIGating {
+ grid-template-columns: auto auto auto auto auto;
+ grid-template-rows: auto auto auto;
+ }
+
+ // Grid for AI Gating flow without gating workflows
+ &.withAI {
+ grid-template-columns: auto auto auto auto auto;
+ grid-template-rows: auto;
+ }
+
+ // Grid for AI Only flow (no gating, no human review)
+ &.withAIOnly {
+ grid-template-columns: auto auto auto;
+ grid-template-rows: auto;
+ }
+
+ // Grid for human-only flow
+ &.humanOnly {
+ grid-template-columns: auto auto auto;
+ grid-template-rows: auto;
+ }
+
+ @media (max-width: 768px) {
+ grid-auto-flow: row;
+ grid-template-columns: 1fr;
+ overflow-x: visible;
+ }
+ }
+
+ .flowBox {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ min-width: 120px;
+ padding: 1rem;
+ background: #f5f5f5;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ text-align: center;
+ font-weight: 500;
+ color: #333;
+
+ > div:first-child {
+ font-size: 1.5rem;
+ }
+
+ > div:nth-child(2) {
+ font-weight: 600;
+ font-size: 0.95rem;
+ }
+
+ .flowDescription {
+ font-size: 0.75rem;
+ color: #666;
+ font-weight: 400;
+ }
+ }
+
+ .arrow {
+ font-size: 1.5rem;
+ color: #666;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ &.failureArrow {
+ flex-direction: column;
+ }
+ }
+
+ .failLabel {
+ font-size: 0.85rem;
+ color: #d32f2f;
+ font-weight: 600;
+ }
+ }
+
+ .costSection {
+ background: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 6px;
+ padding: 1rem 1.5rem;
+ display: flex;
+ justify-content: flex-end;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+
+ .costRow {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+
+ .costLabel {
+ font-weight: 600;
+ color: #555;
+ }
+
+ .costValue {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #2e7d32;
+ min-width: 80px;
+ text-align: right;
+ }
+ }
+ }
+}
diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/calcReviewCost.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/calcReviewCost.js
new file mode 100644
index 00000000..07dc3842
--- /dev/null
+++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/calcReviewCost.js
@@ -0,0 +1,28 @@
+// Utility to calculate estimated review cost for human reviewers
+export function calculateReviewCost (humanReviewers = [], challenge = {}) {
+ const total = humanReviewers
+ .reduce((sum, r) => {
+ const memberCount = parseInt(r.memberReviewerCount) || 1
+ const baseAmount = parseFloat(r.fixedAmount) || 0
+ const prizeSet = challenge.prizeSets && challenge.prizeSets[0]
+ const prizeValue = prizeSet && prizeSet.prizes && prizeSet.prizes[0] && prizeSet.prizes[0].value
+ const prizeAmount = prizeSet
+ ? parseFloat(prizeValue) || 0
+ : 0
+
+ const estimatedSubmissions = 2
+ const baseCoefficient = parseFloat(r.baseCoefficient) || 0.13
+ const incrementalCoefficient = parseFloat(r.incrementalCoefficient) || 0.05
+
+ const calculatedCost = memberCount * (
+ baseAmount + (prizeAmount * baseCoefficient) +
+ (prizeAmount * estimatedSubmissions * incrementalCoefficient)
+ )
+
+ return sum + calculatedCost
+ }, 0)
+
+ return total.toFixed(2)
+}
+
+export default calculateReviewCost
diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/index.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/index.js
new file mode 100644
index 00000000..d2a01f23
--- /dev/null
+++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/index.js
@@ -0,0 +1 @@
+export { default } from './ReviewSummary'
diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js
index d22fd537..bfe0153c 100644
--- a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js
+++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js
@@ -2,23 +2,11 @@ import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import cn from 'classnames'
-import { PrimaryButton, OutlineButton } from '../../Buttons'
-import { REVIEW_OPPORTUNITY_TYPE_LABELS, REVIEW_OPPORTUNITY_TYPES, VALIDATION_VALUE_TYPE, MARATHON_TYPE_ID, DES_TRACK_ID } from '../../../config/constants'
import { loadScorecards, loadDefaultReviewers, loadWorkflows, replaceResourceInRole, createResource, deleteResource } from '../../../actions/challenges'
import styles from './ChallengeReviewer-Field.module.scss'
-import { validateValue } from '../../../util/input-check'
-import AssignedMemberField from '../AssignedMember-Field'
-import { getResourceRoleByName } from '../../../util/tc'
-import { isEqual } from 'lodash'
-
-const ResourceToPhaseNameMap = {
- Reviewer: 'Review',
- Approver: 'Approval',
- Screener: 'Screening',
- 'Iterative Reviewer': 'Iterative Review',
- 'Checkpoint Reviewer': 'Checkpoint Review',
- 'Checkpoint Screener': 'Checkpoint Screening'
-}
+import HumanReviewTab from './HumanReviewTab'
+import { AiReviewTab } from './AiReviewerTab'
+import ReviewSummary from './ReviewSummary'
// Keep track filters aligned with the scorecards API regardless of legacy values
const SCORECARD_TRACK_ALIASES = {
@@ -61,133 +49,20 @@ const normalizeTrackForScorecards = (challenge, metadata) => {
return null
}
-const normalizePhaseToken = (value) => (value || '')
- .toString()
- .toLowerCase()
- .trim()
- .replace(/\bphase\b$/, '')
- .replace(/[-_\s]/g, '')
-
-const normalizeIdValue = (value) => (
- value === undefined || value === null
- ? ''
- : value.toString()
-)
-
-const getScorecardsForPhase = (scorecards = [], phases = [], phaseId) => {
- const normalizedPhaseId = normalizeIdValue(phaseId)
- if (!normalizedPhaseId) {
- return []
- }
-
- const selectedPhase = phases.find(phase => (
- normalizeIdValue(phase.phaseId) === normalizedPhaseId ||
- normalizeIdValue(phase.id) === normalizedPhaseId
- ))
-
- if (!selectedPhase || !selectedPhase.name) {
- return []
- }
-
- const normalizedPhaseName = normalizePhaseToken(selectedPhase.name)
- if (!normalizedPhaseName) {
- return []
- }
-
- return scorecards.filter(scorecard => (
- scorecard &&
- normalizePhaseToken(scorecard.type) === normalizedPhaseName
- ))
-}
-
class ChallengeReviewerField extends Component {
constructor (props) {
super(props)
this.state = {
error: null,
- // Map reviewer index -> array of assigned member details { handle, userId }
- assignedMembers: {}
+ activeTab: 'human' // 'human' or 'ai'
}
- this.addReviewer = this.addReviewer.bind(this)
- this.removeReviewer = this.removeReviewer.bind(this)
- this.updateReviewer = this.updateReviewer.bind(this)
- this.renderReviewerForm = this.renderReviewerForm.bind(this)
- this.handleApplyDefault = this.handleApplyDefault.bind(this)
- this.isAIReviewer = this.isAIReviewer.bind(this)
- this.getMissingRequiredPhases = this.getMissingRequiredPhases.bind(this)
- this.getRoleNameForReviewer = this.getRoleNameForReviewer.bind(this)
- this.onAssignmentChange = this.onAssignmentChange.bind(this)
- this.syncAssignmentsOnCountChange = this.syncAssignmentsOnCountChange.bind(this)
- this.handlePhaseChangeWithReassign = this.handlePhaseChangeWithReassign.bind(this)
- this.handleToggleShouldOpen = this.handleToggleShouldOpen.bind(this)
- this.updateAssignedMembers = this.updateAssignedMembers.bind(this)
- this.doUpdateAssignedMembers = true
- }
-
- isAIReviewer (reviewer) {
- return reviewer && (
- (reviewer.aiWorkflowId && reviewer.aiWorkflowId.trim() !== '') ||
- (reviewer.isMemberReview === false)
- )
- }
-
- isPublicOpportunityOpen (reviewer) {
- return reviewer && reviewer.shouldOpenOpportunity === true
- }
-
- getMissingRequiredPhases () {
- const { challenge } = this.props
- // Marathon Match does not require review configuration
- if (challenge && challenge.typeId === MARATHON_TYPE_ID) {
- return []
- }
- const reviewers = challenge.reviewers || []
- const phases = Array.isArray(challenge.phases) ? challenge.phases : []
-
- const requiredPhaseNames = [
- 'Screening',
- 'Review',
- 'Post-mortem',
- 'Approval',
- 'Checkpoint Screening',
- 'Iterative Review'
- ]
-
- const normalize = (name) => (name || '')
- .toString()
- .toLowerCase()
- .replace(/[-\s]/g, '')
-
- const requiredNormalized = new Set(requiredPhaseNames.map(normalize))
-
- // Map challenge phases by normalized name to phase ids (only those we care about)
- const requiredPhaseEntries = phases
- .filter(p => requiredNormalized.has(normalize(p.name)))
- .map(p => ({ name: p.name, id: p.phaseId || p.id }))
-
- const missing = []
- for (const entry of requiredPhaseEntries) {
- const hasReviewerWithScorecard = reviewers.some(r => {
- const rPhaseId = r.phaseId
- const hasScorecard = !!r.scorecardId
- return rPhaseId === entry.id && hasScorecard
- })
- if (!hasReviewerWithScorecard) {
- // Use the canonical capitalization from requiredPhaseNames when possible
- const canonical = requiredPhaseNames.find(n => normalize(n) === normalize(entry.name)) || entry.name
- missing.push(canonical)
- }
- }
-
- return missing
+ this.loadScorecards = this.loadScorecards.bind(this)
+ this.loadDefaultReviewers = this.loadDefaultReviewers.bind(this)
+ this.loadWorkflows = this.loadWorkflows.bind(this)
}
componentDidMount () {
- const { challenge, challengeResources } = this.props
- if (challenge && challenge.id && challengeResources) {
- this.updateAssignedMembers(challengeResources, challenge)
- }
if (this.props.challenge.track || this.props.challenge.type) {
this.loadScorecards()
}
@@ -195,105 +70,8 @@ class ChallengeReviewerField extends Component {
this.loadWorkflows()
}
- updateAssignedMembers (challengeResources, challenge, prevChallenge = null) {
- const reviewersWithPhaseName = challenge.reviewers.map(item => {
- const phase = challenge.phases && challenge.phases.find(p => (p.id === item.phaseId) || (p.phaseId === item.phaseId))
- return {
- ...item,
- name: phase && phase.name
- }
- })
-
- const reviewerIndex = {}
- reviewersWithPhaseName.forEach((reviewer, index) => {
- if (!reviewerIndex[reviewer.name]) {
- reviewerIndex[reviewer.name] = []
- }
- reviewerIndex[reviewer.name].push(index)
- })
-
- const assignedMembers = {}
-
- const unchangedReviewers = new Set()
- if (prevChallenge && prevChallenge.reviewers) {
- const prevReviewers = prevChallenge.reviewers || []
- challenge.reviewers.forEach((reviewer, index) => {
- const prevReviewer = prevReviewers[index]
- if (prevReviewer &&
- prevReviewer.phaseId === reviewer.phaseId &&
- (reviewer.isMemberReview !== false) &&
- (prevReviewer.isMemberReview !== false)) {
- unchangedReviewers.add(index)
- if (this.state.assignedMembers[index]) {
- assignedMembers[index] = [...this.state.assignedMembers[index]]
- }
- }
- })
- }
-
- challengeResources.forEach((resource) => {
- const phaseName = ResourceToPhaseNameMap[resource.roleName]
- if (!phaseName) return
-
- const indices = reviewerIndex[phaseName] || []
-
- // Distribute resources across all reviewers with the same phase name
- indices.forEach((index) => {
- const reviewer = challenge.reviewers[index]
- if (!reviewer || (reviewer.isMemberReview === false)) return
-
- if (unchangedReviewers.has(index)) {
- const existing = assignedMembers[index] || []
- const alreadyAssigned = existing.some(m =>
- m && (m.userId === resource.memberId || m.handle === resource.memberHandle)
- )
- if (!alreadyAssigned) {
- if (!assignedMembers[index]) {
- assignedMembers[index] = []
- }
- assignedMembers[index].push({
- handle: resource.memberHandle,
- userId: resource.memberId
- })
- }
- } else {
- if (!assignedMembers[index]) {
- assignedMembers[index] = []
- }
- const existing = assignedMembers[index]
- const alreadyAssigned = existing.some(m =>
- m && (m.userId === resource.memberId || m.handle === resource.memberHandle)
- )
- if (!alreadyAssigned) {
- assignedMembers[index].push({
- handle: resource.memberHandle,
- userId: resource.memberId
- })
- }
- }
- })
- })
-
- // Clean up assignments for reviewers that no longer exist or are no longer member reviewers
- Object.keys(assignedMembers).forEach(indexStr => {
- const index = parseInt(indexStr, 10)
- const reviewer = challenge.reviewers[index]
- if (index >= challenge.reviewers.length ||
- !reviewer ||
- (reviewer.isMemberReview === false)) {
- delete assignedMembers[index]
- }
- })
-
- if (!isEqual(this.state.assignedMembers, assignedMembers)) {
- this.setState({
- assignedMembers
- })
- }
- }
-
componentDidUpdate (prevProps) {
- const { challenge, challengeResources } = this.props
+ const { challenge } = this.props
const prevChallenge = prevProps.challenge
if (challenge && prevChallenge &&
@@ -303,169 +81,12 @@ class ChallengeReviewerField extends Component {
}
}
- const reviewersChanged = (() => {
- if (!challenge || !prevChallenge) return false
- const currReviewers = challenge.reviewers || []
- const prevReviewers = prevChallenge.reviewers || []
- if (currReviewers.length !== prevReviewers.length) return true
- for (let i = 0; i < currReviewers.length; i++) {
- const curr = currReviewers[i]
- const prev = prevReviewers[i]
- const { scorecardId: currScorecardId, ...currRest } = curr
- const { scorecardId: prevScorecardId, ...prevRest } = prev
- if (JSON.stringify(currRest) !== JSON.stringify(prevRest)) {
- return true
- }
- }
- return false
- })()
-
- if (challenge && this.doUpdateAssignedMembers && reviewersChanged) {
- this.updateAssignedMembers(challengeResources, challenge, prevChallenge)
- }
-
if (challenge && prevChallenge &&
(challenge.typeId !== prevChallenge.typeId || challenge.trackId !== prevChallenge.trackId)) {
this.loadDefaultReviewers()
}
}
- getRoleNameForReviewer (reviewer) {
- const { challenge } = this.props
- const phase = (challenge.phases || []).find(p => (p.id === reviewer.phaseId) || (p.phaseId === reviewer.phaseId))
- const name = (phase && phase.name) ? phase.name.toLowerCase() : ''
-
- // Normalize for matching
- const norm = name.replace(/[-\s]/g, '')
-
- if (name.includes('iterative review') || norm === 'iterativereview') return 'Iterative Reviewer'
- if (norm === 'approval') return 'Approver'
- if (norm === 'checkpointscreening') return 'Checkpoint Screener'
- if (norm === 'checkpointreview') return 'Checkpoint Reviewer'
- if (norm === 'screening') return 'Screener'
- // default to Reviewer for any kind of review
- return 'Reviewer'
- }
-
- async onAssignmentChange (reviewerIndex, slotIndex, option) {
- const { challenge, metadata = {}, replaceResourceInRole } = this.props
- if (!challenge || !challenge.id) return
-
- const roleName = this.getRoleNameForReviewer((challenge.reviewers || [])[reviewerIndex] || {})
- const role = getResourceRoleByName(metadata.resourceRoles || [], roleName)
- if (!role) return
-
- this.setState(prev => {
- const prevHandles = prev.assignedMembers[reviewerIndex] || []
- const prevMember = prevHandles[slotIndex] || null
- const newHandles = [...prevHandles]
-
- let newMemberHandle = null
- if (option && option.value) {
- newHandles[slotIndex] = {
- handle: option.label,
- userId: parseInt(option.value, 10)
- }
- newMemberHandle = option.label
- } else {
- newHandles[slotIndex] = null
- }
-
- // fire resource update
- const oldHandle = prevMember && prevMember.handle
- // replaceResourceInRole gracefully handles deletion when newMember is falsy
- replaceResourceInRole(challenge.id, role.id, newMemberHandle, oldHandle)
- this.doUpdateAssignedMembers = false
- return {
- assignedMembers: {
- ...prev.assignedMembers,
- [reviewerIndex]: newHandles
- }
- }
- }, () => {
- const n = this
- setTimeout(() => {
- n.doUpdateAssignedMembers = true
- }, 1000)
- })
- }
-
- async syncAssignmentsOnCountChange (reviewerIndex, newCount) {
- const { challenge, metadata = {}, deleteResource } = this.props
- const roleName = this.getRoleNameForReviewer((challenge.reviewers || [])[reviewerIndex] || {})
- const role = getResourceRoleByName(metadata.resourceRoles || [], roleName)
- if (!role) return
- this.setState(prev => {
- const current = prev.assignedMembers[reviewerIndex] || []
- const toRemove = current.slice(newCount).filter(Boolean)
- // remove extra assigned resources
- toRemove.forEach(m => {
- if (challenge && challenge.id && m && m.handle) {
- deleteResource(challenge.id, role.id, m.handle)
- }
- })
- const next = current.slice(0, newCount)
- return {
- assignedMembers: {
- ...prev.assignedMembers,
- [reviewerIndex]: next
- }
- }
- })
- }
-
- async handlePhaseChangeWithReassign (reviewerIndex, newPhaseId) {
- const { challenge, metadata = {}, createResource, deleteResource } = this.props
- const reviewers = challenge.reviewers || []
- const currentReviewer = reviewers[reviewerIndex]
- if (!currentReviewer) return
-
- const oldRoleName = this.getRoleNameForReviewer(currentReviewer)
- const newReviewer = { ...currentReviewer, phaseId: newPhaseId }
- const newRoleName = this.getRoleNameForReviewer(newReviewer)
-
- if (oldRoleName === newRoleName) return
-
- const oldRole = getResourceRoleByName(metadata.resourceRoles || [], oldRoleName)
- const newRole = getResourceRoleByName(metadata.resourceRoles || [], newRoleName)
- if (!oldRole || !newRole) return
-
- const assigned = (this.state.assignedMembers[reviewerIndex] || []).filter(Boolean)
- // move any existing assigned members from old role to new role
- for (const m of assigned) {
- try {
- if (challenge && challenge.id && m && m.handle) {
- await deleteResource(challenge.id, oldRole.id, m.handle)
- await createResource(challenge.id, newRole.id, m.handle)
- }
- } catch (e) {}
- }
- }
-
- async handleToggleShouldOpen (reviewerIndex, nextValue) {
- // If toggling to open public opportunity, remove any existing assigned members for this reviewer
- if (nextValue) {
- const { challenge, metadata = {}, deleteResource } = this.props
- const roleName = this.getRoleNameForReviewer((challenge.reviewers || [])[reviewerIndex] || {})
- const role = getResourceRoleByName(metadata.resourceRoles || [], roleName)
- if (!role) return
- const assigned = (this.state.assignedMembers[reviewerIndex] || []).filter(Boolean)
- for (const m of assigned) {
- try {
- if (challenge && challenge.id && m && m.handle) {
- await deleteResource(challenge.id, role.id, m.handle)
- }
- } catch (e) {}
- }
- this.setState(prev => ({
- assignedMembers: {
- ...prev.assignedMembers,
- [reviewerIndex]: []
- }
- }))
- }
- }
-
loadScorecards () {
const { challenge, loadScorecards, metadata } = this.props
@@ -511,588 +132,23 @@ class ChallengeReviewerField extends Component {
loadWorkflows()
}
- addReviewer () {
- const { challenge, onUpdateReviewers } = this.props
- const currentReviewers = challenge.reviewers || []
-
- // Create a new default reviewer based on track and type
- const defaultTrackReviewer = this.findDefaultReviewer()
-
- // Get the first available review phase if phases exist
- const reviewPhases = challenge.phases && challenge.phases.filter(phase =>
- phase.name && phase.name.toLowerCase().includes('review')
- )
- const firstReviewPhase = reviewPhases && reviewPhases.length > 0 ? reviewPhases[0] : null
-
- // If no review phases, get the first available phase as fallback
- const fallbackPhase = !firstReviewPhase && challenge.phases && challenge.phases.length > 0
- ? challenge.phases[0]
- : null
-
- // Determine the default phase ID
- let defaultPhaseId = ''
- if (defaultTrackReviewer && defaultTrackReviewer.phaseId) {
- defaultPhaseId = defaultTrackReviewer.phaseId
- } else if (firstReviewPhase) {
- defaultPhaseId = firstReviewPhase.phaseId || firstReviewPhase.id
- } else if (fallbackPhase) {
- defaultPhaseId = fallbackPhase.phaseId || fallbackPhase.id
- }
-
- const defaultReviewer = this.findDefaultReviewer(defaultPhaseId) || defaultTrackReviewer
-
- const isAIReviewer = this.isAIReviewer(defaultTrackReviewer)
-
- // For AI reviewers, get scorecardId from the workflow if available
- let scorecardId = ''
- if (isAIReviewer) {
- const { metadata = {} } = this.props
- const { workflows = [] } = metadata
- const defaultWorkflowId = defaultReviewer && defaultReviewer.aiWorkflowId
- if (defaultWorkflowId) {
- const workflow = workflows.find(w => w.id === defaultWorkflowId)
- scorecardId = workflow && workflow.scorecardId ? workflow.scorecardId : undefined
- } else {
- scorecardId = undefined
- }
- } else {
- scorecardId = (defaultReviewer && defaultReviewer.scorecardId) || ''
- }
-
- const newReviewer = {
- scorecardId,
- isMemberReview: !isAIReviewer,
- phaseId: defaultPhaseId,
- fixedAmount: (defaultReviewer && defaultReviewer.fixedAmount) || 0,
- baseCoefficient: (defaultReviewer && defaultReviewer.baseCoefficient) || '0.13',
- incrementalCoefficient: (defaultReviewer && defaultReviewer.incrementalCoefficient) || 0.05,
- type: isAIReviewer
- ? undefined
- : (defaultReviewer && defaultReviewer.opportunityType) || REVIEW_OPPORTUNITY_TYPES.REGULAR_REVIEW,
- shouldOpenOpportunity: false
- }
-
- if (isAIReviewer) {
- newReviewer.aiWorkflowId = (defaultReviewer && defaultReviewer.aiWorkflowId) || ''
- }
-
- // Set member-specific fields for member reviewers
- if (!isAIReviewer) {
- newReviewer.memberReviewerCount = (defaultReviewer && defaultReviewer.memberReviewerCount) || 1
- }
-
- // Clear any prior transient error when add succeeds
- if (this.state.error) {
- this.setState({ error: null })
- }
-
- const updatedReviewers = currentReviewers.concat([newReviewer])
- onUpdateReviewers({ field: 'reviewers', value: updatedReviewers })
- }
-
- removeReviewer (index) {
- const { challenge, onUpdateReviewers } = this.props
- const currentReviewers = challenge.reviewers || []
- const updatedReviewers = currentReviewers.filter((_, i) => i !== index)
- onUpdateReviewers({ field: 'reviewers', value: updatedReviewers })
- }
-
- updateReviewer (index, field, value) {
- const { challenge, onUpdateReviewers } = this.props
- const currentReviewers = challenge.reviewers || []
- const updatedReviewers = currentReviewers.slice()
- const fieldUpdate = {}
- fieldUpdate[field] = value
-
- if (field === 'aiWorkflowId') {
- const { metadata = {} } = this.props
- const { workflows = [] } = metadata
- const workflow = workflows.find(w => w.id === value)
- if (workflow && workflow.scorecardId) {
- fieldUpdate.scorecardId = workflow.scorecardId
- } else {
- fieldUpdate.scorecardId = undefined
- }
- }
-
- // Special handling for phase and count changes
- if (field === 'phaseId') {
- // Before changing phase, ensure we're not creating a duplicate manual reviewer for the target phase
- const targetPhaseId = value
- const isCurrentMember = (updatedReviewers[index] && (updatedReviewers[index].isMemberReview !== false))
- if (isCurrentMember) {
- const conflict = (currentReviewers || []).some((r, i) => i !== index && (r.isMemberReview !== false) && (r.phaseId === targetPhaseId))
- if (conflict) {
- const phase = (challenge.phases || []).find(p => (p.id === targetPhaseId) || (p.phaseId === targetPhaseId))
- const phaseName = phase ? (phase.name || targetPhaseId) : targetPhaseId
- this.setState({
- error: `Cannot move manual reviewer to phase '${phaseName}' because a manual reviewer configuration already exists for that phase.`
- })
- return
- }
- }
-
- this.handlePhaseChangeWithReassign(index, value)
-
- // update payment based on default reviewer
- const defaultReviewer = this.findDefaultReviewer(value) || updatedReviewers[index]
- Object.assign(fieldUpdate, {
- fixedAmount: defaultReviewer.fixedAmount,
- baseCoefficient: defaultReviewer.baseCoefficient,
- incrementalCoefficient: defaultReviewer.incrementalCoefficient
- })
-
- if (updatedReviewers[index] && (updatedReviewers[index].isMemberReview !== false)) {
- const { metadata = {} } = this.props
- const scorecardsForPhase = getScorecardsForPhase(
- metadata.scorecards || [],
- challenge.phases || [],
- value
- )
- const currentScorecardId = normalizeIdValue(updatedReviewers[index].scorecardId)
- const hasCurrentScorecard = scorecardsForPhase.some(scorecard => (
- normalizeIdValue(scorecard.id) === currentScorecardId
- ))
-
- if (!hasCurrentScorecard) {
- const defaultScorecardId = normalizeIdValue(defaultReviewer && defaultReviewer.scorecardId)
- const hasDefaultScorecard = defaultScorecardId && scorecardsForPhase.some(scorecard => (
- normalizeIdValue(scorecard.id) === defaultScorecardId
- ))
- const fallbackScorecardId = hasDefaultScorecard
- ? defaultScorecardId
- : normalizeIdValue(scorecardsForPhase[0] && scorecardsForPhase[0].id)
-
- fieldUpdate.scorecardId = fallbackScorecardId || ''
- }
- }
- }
-
- if (field === 'memberReviewerCount') {
- const newCount = parseInt(value) || 1
- this.syncAssignmentsOnCountChange(index, Math.max(1, newCount))
- }
-
- updatedReviewers[index] = Object.assign({}, updatedReviewers[index], fieldUpdate)
- onUpdateReviewers({ field: 'reviewers', value: updatedReviewers })
- }
-
- findDefaultReviewer (phaseId) {
- const { challenge, metadata = {} } = this.props
- const { defaultReviewers = [] } = metadata
-
- if (!challenge || !challenge.trackId || !challenge.typeId) {
- return null
- }
-
- return phaseId ? defaultReviewers.find(dr => dr.phaseId === phaseId) : defaultReviewers[0]
- }
-
- validateReviewer (reviewer) {
- const errors = {}
- const isAI = this.isAIReviewer(reviewer)
-
- if (isAI) {
- if (!reviewer.aiWorkflowId || reviewer.aiWorkflowId.trim() === '') {
- errors.aiWorkflowId = 'AI Workflow is required'
- }
- } else {
- if (!reviewer.scorecardId) {
- errors.scorecardId = 'Scorecard is required'
- }
-
- const memberCount = parseInt(reviewer.memberReviewerCount) || 1
- if (memberCount < 1 || !Number.isInteger(memberCount)) {
- errors.memberReviewerCount = 'Number of reviewers must be a positive integer'
- }
- }
-
- if (!reviewer.phaseId) {
- errors.phaseId = 'Phase is required'
- }
-
- return errors
- }
-
- handleApplyDefault () {
- const defaultReviewer = this.findDefaultReviewer()
- if (defaultReviewer) {
- this.addReviewer()
- }
- }
-
- renderReviewerForm (reviewer, index) {
- const { challenge, metadata = {}, readOnly = false } = this.props
- const { scorecards = [], workflows = [] } = metadata
- const validationErrors = challenge.submitTriggered ? this.validateReviewer(reviewer) : {}
- const filteredScorecards = getScorecardsForPhase(
- scorecards,
- challenge.phases || [],
- reviewer.phaseId
- )
- const isDesignChallenge = challenge && challenge.trackId === DES_TRACK_ID
-
- return (
-
-
-
Reviewer Type {index + 1}
- {!readOnly && (
- this.removeReviewer(index)}
- />
- )}
-
-
-
-
-
- {readOnly ? (
- {this.isAIReviewer(reviewer) ? 'AI Reviewer' : 'Member Reviewer'}
- ) : (
-
- )}
-
-
- {this.isAIReviewer(reviewer) ? (
-
-
- {readOnly ? (
-
- {(() => {
- const workflow = workflows.find(w => w.id === reviewer.aiWorkflowId)
- return workflow ? workflow.name : 'Not selected'
- })()}
-
- ) : (
-
- )}
- {!readOnly && challenge.submitTriggered && validationErrors.aiWorkflowId && (
-
- {validationErrors.aiWorkflowId}
-
- )}
-
- ) : (
-
-
- {readOnly ? (
-
- {(() => {
- const scorecard = scorecards.find(s => s.id === reviewer.scorecardId)
- return scorecard ? `${scorecard.name || 'Unknown'} - ${scorecard.type || 'Unknown'} (${scorecard.challengeTrack || 'Unknown'}) v${scorecard.version || 'Unknown'}` : 'Not selected'
- })()}
-
- ) : (
-
- )}
- {!readOnly && challenge.submitTriggered && validationErrors.scorecardId && (
-
- {validationErrors.scorecardId}
-
- )}
-
- )}
-
-
-
- {readOnly ? (
-
- {(() => {
- const phase = challenge.phases && challenge.phases.find(p =>
- (p.id === reviewer.phaseId) || (p.phaseId === reviewer.phaseId)
- )
- return phase ? (phase.name || `Phase ${phase.phaseId || phase.id}`) : 'Not selected'
- })()}
-
- ) : (
-
- )}
- {!readOnly && challenge.submitTriggered && validationErrors.phaseId && (
-
- {validationErrors.phaseId}
-
- )}
-
-
-
- {!this.isAIReviewer(reviewer) && (
-
-
-
- {readOnly ? (
-
{reviewer.memberReviewerCount || 1}
- ) : (
-
{
- const validatedValue = validateValue(e.target.value, VALIDATION_VALUE_TYPE.INTEGER)
- const parsedValue = parseInt(validatedValue) || 1
- this.updateReviewer(index, 'memberReviewerCount', Math.max(1, parsedValue))
- }}
- />
- )}
- {!readOnly && challenge.submitTriggered && validationErrors.memberReviewerCount && (
-
- {validationErrors.memberReviewerCount}
-
- )}
-
-
- )}
-
- {!this.isAIReviewer(reviewer) && (
-
-
-
- {readOnly ? (
-
- { REVIEW_OPPORTUNITY_TYPE_LABELS[reviewer.type] || 'Regular Review'}
-
- ) : (
-
- )}
-
- {!isDesignChallenge && (
-
-
-
- )}
-
- )}
-
- {/* Design challenges do not expose public opportunity toggles, so always allow member assignment there. */}
- {!this.isAIReviewer(reviewer) && (isDesignChallenge || !this.isPublicOpportunityOpen(reviewer)) && (
-
-
-
- {Array.from({ length: parseInt(reviewer.memberReviewerCount || 1) }, (_, i) => {
- const assigned = (this.state.assignedMembers[index] || [])[i] || null
- return (
-
-
this.onAssignmentChange(index, i, option)}
- />
-
- )
- })}
-
-
- )}
-
+ isAIReviewer (reviewer) {
+ return reviewer && (
+ (reviewer.aiWorkflowId && reviewer.aiWorkflowId.trim() !== '') ||
+ (reviewer.isMemberReview === false)
)
}
- getFirstPlacePrizeValue (challenge) {
- const placementPrizeSet = challenge.prizeSets.find(set => set.type === 'PLACEMENT')
- if (placementPrizeSet && placementPrizeSet.prizes && placementPrizeSet.prizes[0] && placementPrizeSet.prizes[0].value) {
- return placementPrizeSet.prizes[0].value
- }
- return 0
- }
-
render () {
- const { challenge, metadata = {}, isLoading, readOnly = false } = this.props
- const { error } = this.state
+ const { challenge, metadata = {}, isLoading, readOnly = false, aiReadOnly = false } = this.props
+ const { error, activeTab } = this.state
const { scorecards = [], defaultReviewers = [], workflows = [] } = metadata
- const reviewers = challenge.reviewers || []
- const firstPlacePrize = this.getFirstPlacePrizeValue(challenge)
- const estimatedSubmissionsCount = 2 // Estimate assumes two submissions
- const reviewersCost = reviewers
- .filter((r) => !this.isAIReviewer(r))
- .reduce((sum, r) => {
- const fixedAmount = parseFloat(r.fixedAmount || 0)
- const baseCoefficient = parseFloat(r.baseCoefficient || 0)
- const incrementalCoefficient = parseFloat(r.incrementalCoefficient || 0)
- const reviewerCost = fixedAmount + (baseCoefficient + incrementalCoefficient * estimatedSubmissionsCount) * firstPlacePrize
+ const isAiTabReadOnly = readOnly || aiReadOnly
- const count = parseInt(r.memberReviewerCount) || 1
- return sum + reviewerCost * count
- }, 0)
- .toFixed(2)
+ // Count reviewers by type
+ const allReviewers = challenge.reviewers || []
+ const humanReviewersCount = allReviewers.filter(r => !this.isAIReviewer(r)).length
+ const aiReviewersCount = allReviewers.filter(r => this.isAIReviewer(r)).length
if (isLoading) {
return (
@@ -1122,90 +178,73 @@ class ChallengeReviewerField extends Component {
return (
<>
-
-
-
-
-
- {(!readOnly && challenge.submitTriggered) && (() => {
- const missing = this.getMissingRequiredPhases()
- if (missing.length > 0) {
- return (
-
- {`Please configure a scorecard for: ${missing.join(', ')}`}
-
- )
- }
- return null
- })()}
- {!readOnly && (
-
- Configure how this challenge will be reviewed. You can add multiple reviewers including AI and member reviewers.
-
- )}
-
- {!readOnly && reviewers && reviewers.length === 0 && (
-
-
No reviewers configured. Click "Add Reviewer" to get started.
- {this.findDefaultReviewer() && (
-
-
Note: Default reviewer configuration is available for this track and type combination.
-
-
- )}
-
- )}
-
- {readOnly && reviewers && reviewers.length === 0 && (
-
-
No reviewers configured for this challenge.
-
- )}
-
- {reviewers && reviewers.map((reviewer, index) =>
- this.renderReviewerForm(reviewer, index)
- )}
-
- {reviewers && reviewers.length > 0 && (
-
-
Review Summary
-
-
Total Member Reviewers:
-
{reviewers.filter(r => !this.isAIReviewer(r)).reduce((sum, r) => sum + (parseInt(r.memberReviewerCount) || 0), 0)}
+ {!readOnly && (
+
+
+
+
+
+
+
+
+
-
-
Total AI Reviewers:
-
{reviewers.filter(r => this.isAIReviewer(r)).length}
+
+
+ this.props.onUpdateReviewers(update)}
+ replaceResourceInRole={this.props.replaceResourceInRole}
+ createResource={this.props.createResource}
+ deleteResource={this.props.deleteResource}
+ challengeResources={this.props.challengeResources}
+ />
-
-
Estimated Review Cost:
-
- ${reviewersCost}
-
+
+
+
this.props.onUpdateReviewers(update)}
+ />
- )}
+ {error && !isLoading && (
+
+ {error}
+
+ )}
+
+
+ )}
- {!readOnly && (
-
- )}
- {error && !isLoading && (
-
- {error}
-
- )}
+ {/* Review Summary Section */}
+ {readOnly && (challenge.reviewers && challenge.reviewers.length > 0) && (
+
-
+ )}
>
)
}
@@ -1223,6 +262,7 @@ ChallengeReviewerField.propTypes = {
}),
isLoading: PropTypes.bool,
readOnly: PropTypes.bool,
+ aiReadOnly: PropTypes.bool,
loadScorecards: PropTypes.func.isRequired,
loadDefaultReviewers: PropTypes.func.isRequired,
loadWorkflows: PropTypes.func.isRequired,
diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/shared.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/shared.module.scss
new file mode 100644
index 00000000..3c5b8e7a
--- /dev/null
+++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/shared.module.scss
@@ -0,0 +1,206 @@
+@use '../../../styles/includes' as *;
+
+.tabContent {
+ padding: 20px;
+}
+
+.description {
+ color: #666;
+ margin-bottom: 20px;
+ font-size: 14px;
+ line-height: 1.4;
+}
+
+.noReviewers {
+ text-align: center;
+ padding: 30px;
+ color: #999;
+ font-style: italic;
+ background-color: #f5f5f5;
+ border-radius: 4px;
+ margin-bottom: 20px;
+}
+
+.addReviewerBtn {
+ padding: 8px 16px;
+ background-color: #0066cc;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 500;
+ transition: background-color 0.3s ease;
+
+ &:hover {
+ background-color: #0052a3;
+ }
+
+ &:active {
+ background-color: #004080;
+ }
+}
+
+.addButton {
+ text-align: center;
+ margin-top: 20px;
+}
+
+.reviewerForm {
+ background-color: white;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ padding: 20px;
+ margin-bottom: 20px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.reviewerHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid #eee;
+
+ h4 {
+ margin: 0;
+ color: #333;
+ font-size: 16px;
+ font-weight: 600;
+ }
+}
+
+.formRow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+ margin-bottom: 15px;
+}
+
+.formGroup {
+ flex: 1;
+ min-width: 200px;
+
+ label {
+ display: block;
+ margin-bottom: 5px;
+ font-weight: 500;
+ color: #555;
+ font-size: 14px;
+ }
+
+ input,
+ select {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ font-size: 14px;
+ background-color: white;
+ }
+
+ input[type="checkbox"] {
+ width: auto;
+ }
+
+ &.mtop {
+ margin-top: 32px;
+ }
+
+ input:focus,
+ select:focus {
+ outline: none;
+ border-color: #0066cc;
+ box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
+ }
+}
+
+.summary {
+ background-color: #f8f9fa;
+ border: 1px solid #dee2e6;
+ border-radius: 4px;
+ padding: 20px;
+ margin: 20px 0;
+
+ h4 {
+ margin: 0 0 15px 0;
+ color: #333;
+ font-size: 16px;
+ font-weight: 600;
+ }
+}
+
+.summaryRow {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 0;
+ border-bottom: 1px solid #eee;
+
+ &:last-child {
+ border-bottom: none;
+ font-weight: 600;
+ color: #0066cc;
+ }
+
+ span:first-child {
+ color: #666;
+ }
+
+ span:last-child {
+ font-weight: 500;
+ }
+}
+
+.loading {
+ text-align: center;
+ padding: 40px;
+ color: #666;
+ font-style: italic;
+}
+
+.error {
+ color: $tc-red;
+ padding: 5px;
+}
+
+.fieldError {
+ margin-top: 12px;
+}
+
+.validationErrors {
+ background-color: #fff3cd;
+ border: 1px solid #ffeaa7;
+ border-radius: 4px;
+ padding: 10px;
+ margin-bottom: 15px;
+}
+
+.validationError {
+ color: #856404;
+ font-size: 13px;
+ margin-bottom: 5px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+}
+
+// Responsive adjustments
+@media (max-width: 768px) {
+ .formRow {
+ flex-direction: column;
+ gap: 15px;
+ }
+
+ .formGroup {
+ min-width: 100%;
+ }
+
+ .reviewerHeader {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ }
+}
diff --git a/src/components/ChallengeEditor/ChallengeView/index.js b/src/components/ChallengeEditor/ChallengeView/index.js
index 8637bd6a..7e2e0cb7 100644
--- a/src/components/ChallengeEditor/ChallengeView/index.js
+++ b/src/components/ChallengeEditor/ChallengeView/index.js
@@ -26,9 +26,11 @@ import {
DEV_TRACK_ID,
MARATHON_TYPE_ID,
CHALLENGE_TYPE_ID,
- COMMUNITY_APP_URL
+ COMMUNITY_APP_URL,
+ AI_SCREENING_PHASE_NAME
} from '../../../config/constants'
import PhaseInput from '../../PhaseInput'
+import { hasAiReviewers } from '../ChallengeReviewer-Field/AiReviewerTab/utils'
import CheckpointPrizesField from '../CheckpointPrizes-Field'
import { isBetaMode } from '../../../util/localstorage'
import WiproAllowedField from '../WiproAllowedField'
@@ -224,21 +226,42 @@ const ChallengeView = ({
{
- challenge.legacy.subTrack === 'WEB_DESIGNS' && challenge.phases.length === 8 ? phases.map((phase, index) => (
-
- )) : _.sortBy(phases, ['scheduledEndDate']).map((phase, index) => (
-
- ))
+ (() => {
+ const phaseList = challenge.legacy.subTrack === 'WEB_DESIGNS' && challenge.phases.length === 8
+ ? phases
+ : _.sortBy(phases, ['scheduledEndDate'])
+ const hasRealAiScreeningPhase = phaseList.some(p => p.name === AI_SCREENING_PHASE_NAME)
+ const showVirtualAiScreening = hasAiReviewers(challenge.reviewers) && !hasRealAiScreeningPhase
+ const submissionIndex = phaseList.findIndex(p => p.name === 'Submission')
+ const checkpointSubmissionIndex = phaseList.findIndex(p => p.name === 'Checkpoint Submission')
+ return (
+ <>
+ {phaseList.map((phase, index) => (
+
+
+ {showVirtualAiScreening && (index === submissionIndex || index === checkpointSubmissionIndex) && (
+
+ )}
+
+ ))}
+ {showVirtualAiScreening && submissionIndex === -1 && checkpointSubmissionIndex === -1 && (
+
+ )}
+ >
+ )
+ })()
}
{showTimeline && (
)}
{},
readOnly: false,
showReviewerField: false,
- onUpdateReviewers: () => {}
+ onUpdateReviewers: () => {},
+ aiReviewerReadOnly: false
}
TextEditorField.propTypes = {
@@ -158,7 +161,8 @@ TextEditorField.propTypes = {
shouldShowPrivateDescription: PropTypes.bool,
readOnly: PropTypes.bool,
showReviewerField: PropTypes.bool,
- onUpdateReviewers: PropTypes.func
+ onUpdateReviewers: PropTypes.func,
+ aiReviewerReadOnly: PropTypes.bool
}
export default TextEditorField
diff --git a/src/components/ChallengeEditor/index.js b/src/components/ChallengeEditor/index.js
index de2c2cce..3d21e062 100644
--- a/src/components/ChallengeEditor/index.js
+++ b/src/components/ChallengeEditor/index.js
@@ -27,7 +27,8 @@ import {
QA_TRACK_ID, DESIGN_CHALLENGE_TYPES, ROUND_TYPES,
MULTI_ROUND_CHALLENGE_TEMPLATE_ID,
CHALLENGE_STATUS,
- SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS
+ SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS,
+ AI_SCREENING_PHASE_NAME
} from '../../config/constants'
import {
getDomainTypes,
@@ -62,6 +63,7 @@ import Track from '../Track'
import ConfirmationModal from '../Modal/ConfirmationModal'
import AlertModal from '../Modal/AlertModal'
import PhaseInput from '../PhaseInput'
+import { hasAiReviewers } from './ChallengeReviewer-Field/AiReviewerTab/utils'
import LegacyLinks from '../LegacyLinks'
import AssignedMemberField from './AssignedMember-Field'
import Tooltip from '../Tooltip'
@@ -75,6 +77,7 @@ import DiscussionField from './Discussion-Field'
import CheckpointPrizesField from './CheckpointPrizes-Field'
import { canChangeDuration } from '../../util/phase'
import { isBetaMode } from '../../util/localstorage'
+import { fetchAIReviewConfigByChallenge } from '../../services/aiReviewConfigs'
const theme = {
container: styles.modalContainer
@@ -1395,10 +1398,66 @@ class ChallengeEditor extends Component {
return challengeId
}
+ /**
+ * Sync AI review config workflows to challenge reviewers array
+ * Maps workflows from AI review config to reviewer objects with aiWorkflowId and isMemberReview=false
+ */
+ async syncAIReviewConfigToReviewers (challengeId) {
+ try {
+ // Fetch the AI review config for this challenge
+ const aiConfig = await fetchAIReviewConfigByChallenge(challengeId)
+
+ if (!aiConfig || !aiConfig.workflows || aiConfig.workflows.length === 0) {
+ // No AI config or workflows, nothing to sync
+ return
+ }
+
+ // Get current reviewers from state
+ const currentReviewers = this.state.challenge.reviewers || []
+
+ // Separate AI reviewers from human reviewers
+ const humanReviewers = currentReviewers.filter(r => {
+ const isAI = (r.aiWorkflowId && r.aiWorkflowId.trim() !== '') || r.isMemberReview === false
+ return !isAI
+ })
+
+ // Create reviewer entries for each workflow in the config
+ const aiReviewers = aiConfig.workflows.map(workflow => ({
+ aiWorkflowId: workflow.workflowId,
+ scorecardId: workflow.workflow.scorecardId,
+ phaseId: '6950164f-3c5e-4bdc-abc8-22aaf5a1bd49',
+ shouldOpenOpportunity: false,
+ isMemberReview: false
+ }))
+
+ // Combine human reviewers with synced AI reviewers
+ const syncedReviewers = [...humanReviewers, ...aiReviewers]
+
+ // Update state with synced reviewers
+ await new Promise(resolve => {
+ this.setState(prevState => ({
+ challenge: {
+ ...prevState.challenge,
+ reviewers: syncedReviewers
+ }
+ }), resolve)
+ })
+
+ console.log('Synced AI review config workflows to reviewers:', aiReviewers.length)
+ } catch (error) {
+ // Log error but don't fail the save operation
+ console.error('Error syncing AI review config to reviewers:', error)
+ }
+ }
+
async updateAllChallengeInfo (status, cb = () => { }) {
const { updateChallengeDetails, assignedMemberDetails: oldAssignedMember, projectDetail, challengeDetails } = this.props
if (this.state.isSaving) return
this.setState({ isSaving: true }, async () => {
+ // Sync AI review config workflows to reviewers before collecting challenge data
+ const challengeId = this.getCurrentChallengeId()
+ await this.syncAIReviewConfigToReviewers(challengeId)
+
let challenge = this.collectChallengeData(status)
let newChallenge = _.cloneDeep(this.state.challenge)
newChallenge.status = status
@@ -1583,7 +1642,8 @@ class ChallengeEditor extends Component {
assignYourselfCopilot,
challengeResources,
loggedInUser,
- challengeDetails
+ challengeDetails,
+ totalSubmissions
} = this.props
if (_.isEmpty(challenge)) {
return Error loading challenge
@@ -1844,6 +1904,7 @@ class ChallengeEditor extends Component {
const isFunChallenge = challenge.funChallenge === true
const showRoundType = isDesignChallenge && isChallengeType
const showCheckpointPrizes = challenge.timelineTemplateId === MULTI_ROUND_CHALLENGE_TEMPLATE_ID
+ const isAiReviewerConfigReadOnly = totalSubmissions > 0
const useDashboardData = _.find(challenge.metadata, { name: 'show_data_dashboard' })
const showDashBoard = this.shouldShowDashboardSetting(challenge)
@@ -1988,18 +2049,42 @@ class ChallengeEditor extends Component {
{
- phases.map((phase, index) => (
-
{
- this.onUpdatePhaseDate(item, index)
- }}
- />
- )
- )
+ (() => {
+ const hasRealAiScreeningPhase = phases.some(p => p.name === AI_SCREENING_PHASE_NAME)
+ const showVirtualAiScreening = hasAiReviewers(challenge.reviewers) && !hasRealAiScreeningPhase
+ const submissionIndex = phases.findIndex(p => p.name === 'Submission')
+ const checkpointSubmissionIndex = phases.findIndex(p => p.name === 'Checkpoint Submission')
+ return (
+ <>
+ {phases.map((phase, index) => (
+
+ {
+ this.onUpdatePhaseDate(item, index)
+ }}
+ />
+ {showVirtualAiScreening && (index === submissionIndex || index === checkpointSubmissionIndex) && (
+
+ )}
+
+ ))}
+ {showVirtualAiScreening && (submissionIndex === -1 || checkpointSubmissionIndex === -1) && (
+
+ )}
+ >
+ )
+ })()
}
>
)}
@@ -2048,6 +2133,7 @@ class ChallengeEditor extends Component {
onUpdateMetadata={this.onUpdateMetadata}
showReviewerField={!isTask}
onUpdateReviewers={this.onUpdateOthers}
+ aiReviewerReadOnly={isAiReviewerConfigReadOnly}
/>
{/* hide until challenge API change is pushed to PROD https://github.com/topcoder-platform/challenge-api/issues/348 */}
{false && {
}
}
-const toValidMoment = (value) => {
- if (!value) {
- return null
- }
- if (moment.isMoment(value)) {
- return value.isValid() ? value : null
- }
- const parsed = moment(value)
- return parsed.isValid() ? parsed : null
-}
-
const formatAssignmentDate = (value) => {
if (!value) {
return '-'
}
- return moment(value).format('MMM DD, YYYY HH:mm')
+ return moment(value).format('MMM DD, YYYY')
}
/**
@@ -127,10 +130,12 @@ const EngagementEditor = ({
}) => {
const [assignModal, setAssignModal] = useState(null)
const [assignStartDate, setAssignStartDate] = useState(null)
- const [assignEndDate, setAssignEndDate] = useState(null)
- const [assignRate, setAssignRate] = useState('')
+ const [assignDurationMonths, setAssignDurationMonths] = useState('')
+ const [assignRatePerHour, setAssignRatePerHour] = useState('')
+ const [assignStandardHoursPerWeek, setAssignStandardHoursPerWeek] = useState('')
const [assignOtherRemarks, setAssignOtherRemarks] = useState('')
const [assignErrors, setAssignErrors] = useState({})
+ const [isGeneratingDescription, setIsGeneratingDescription] = useState(false)
const { timeZoneOptions, timeZoneOptionByZone } = useMemo(() => {
const optionByLabel = new Map()
moment.tz.names().forEach((zone) => {
@@ -320,34 +325,9 @@ const EngagementEditor = ({
)
const assignHandle = assignModal ? assignModal.handle : null
const assignHandleColor = assignHandle ? '#000' : undefined
- const today = moment().startOf('day')
- const parsedAssignStart = assignStartDate ? moment(assignStartDate) : null
- const parsedAssignStartDay = parsedAssignStart && parsedAssignStart.isValid()
- ? parsedAssignStart.clone().startOf('day')
- : null
- const minAssignEndDay = parsedAssignStartDay && parsedAssignStartDay.isAfter(today) ? parsedAssignStartDay : today
- const isAssignStartDateValid = (current) => {
- const currentMoment = toValidMoment(current)
- if (!currentMoment) {
- return false
- }
- return currentMoment.isSameOrAfter(today, 'day')
- }
- const isAssignEndDateValid = (current) => {
- const currentMoment = toValidMoment(current)
- if (!currentMoment) {
- return false
- }
- return currentMoment.isSameOrAfter(minAssignEndDay, 'day')
- }
- const getMinAssignStartDateTime = () => moment().toDate()
- const getMinAssignEndDateTime = () => {
- const now = moment()
- if (parsedAssignStart && parsedAssignStart.isValid() && parsedAssignStart.isAfter(now)) {
- return parsedAssignStart.toDate()
- }
- return now.toDate()
- }
+ const assignAssignmentRate = useMemo(() => {
+ return calculateAssignmentRatePerWeek(assignRatePerHour, assignStandardHoursPerWeek)
+ }, [assignRatePerHour, assignStandardHoursPerWeek])
const assignSubtitle = assignHandle ? (
{
setAssignModal(null)
setAssignStartDate(null)
- setAssignEndDate(null)
- setAssignRate('')
+ setAssignDurationMonths('')
+ setAssignRatePerHour('')
+ setAssignStandardHoursPerWeek('')
setAssignOtherRemarks('')
setAssignErrors({})
}
@@ -385,9 +366,12 @@ const EngagementEditor = ({
const normalizedHandle = handle ? handle.toLowerCase() : null
const existingDetails = normalizedHandle ? assignmentDetailsByHandle[normalizedHandle] : null
setAssignModal({ index, handle })
- setAssignStartDate(existingDetails ? existingDetails.startDate || null : null)
- setAssignEndDate(existingDetails ? existingDetails.endDate || null : null)
- setAssignRate(existingDetails ? existingDetails.agreementRate || '' : '')
+ setAssignStartDate(existingDetails
+ ? deserializeTentativeAssignmentDate(existingDetails.startDate)
+ : null)
+ setAssignDurationMonths(existingDetails ? existingDetails.durationMonths || '' : '')
+ setAssignRatePerHour(existingDetails ? existingDetails.ratePerHour || '' : '')
+ setAssignStandardHoursPerWeek(existingDetails ? existingDetails.standardHoursPerWeek || '' : '')
setAssignOtherRemarks(existingDetails ? existingDetails.otherRemarks || '' : '')
setAssignErrors({})
}
@@ -396,6 +380,39 @@ const EngagementEditor = ({
resetAssignState()
}
+ const handleAIAutowrite = async () => {
+ if (isGeneratingDescription) return
+
+ setIsGeneratingDescription(true)
+
+ try {
+ const input = engagement.description
+ const result = await autowriteDescription(input)
+
+ const generatedDescription = result.formattedDescription
+
+ if (!generatedDescription) {
+ throw new Error('No formattedDescription returned')
+ }
+
+ onUpdateDescription(generatedDescription)
+
+ toastSuccess(
+ 'Description Generated',
+ 'AI generated description has been added.'
+ )
+ } catch (error) {
+ console.error('AI autowrite error:', error)
+
+ toastFailure(
+ 'Error',
+ 'Failed to generate description. Please try again.'
+ )
+ } finally {
+ setIsGeneratingDescription(false)
+ }
+ }
+
const handleAssignSubmit = () => {
if (!assignModal) {
return
@@ -403,21 +420,25 @@ const EngagementEditor = ({
const nextErrors = {}
const parsedStart = assignStartDate ? moment(assignStartDate) : null
- const parsedEnd = assignEndDate ? moment(assignEndDate) : null
- const normalizedRate = assignRate != null ? String(assignRate).trim() : ''
+ const parsedDurationMonths = toPositiveInteger(assignDurationMonths)
+ const parsedRatePerHour = toPositiveNumber(assignRatePerHour)
+ const parsedStandardHoursPerWeek = toPositiveNumberWithMaxDecimalPlaces(
+ assignStandardHoursPerWeek,
+ 2
+ )
const normalizedOtherRemarks = assignOtherRemarks != null ? String(assignOtherRemarks).trim() : ''
if (!parsedStart || !parsedStart.isValid()) {
- nextErrors.startDate = 'Start date is required.'
+ nextErrors.startDate = 'Engagement start date is required.'
}
- if (!parsedEnd || !parsedEnd.isValid()) {
- nextErrors.endDate = 'End date is required.'
+ if (parsedDurationMonths === null) {
+ nextErrors.durationMonths = 'Duration must be a positive whole number.'
}
- if (!normalizedRate) {
- nextErrors.rate = 'Assignment rate is required.'
+ if (parsedRatePerHour === null) {
+ nextErrors.ratePerHour = 'Rate per hour must be a positive number.'
}
- if (parsedStart && parsedEnd && parsedStart.isValid() && parsedEnd.isValid() && parsedEnd.isBefore(parsedStart)) {
- nextErrors.endDate = 'End date must be after start date.'
+ if (parsedStandardHoursPerWeek === null) {
+ nextErrors.standardHoursPerWeek = 'Standard hours per week must be a positive number with up to 2 decimal places.'
}
if (Object.keys(nextErrors).length > 0) {
@@ -437,8 +458,10 @@ const EngagementEditor = ({
nextAssignmentDetails[assignModal.index] = {
memberHandle: assignModal.handle,
startDate: serializeTentativeAssignmentDate(parsedStart),
- endDate: serializeTentativeAssignmentDate(parsedEnd),
- agreementRate: normalizedRate,
+ durationMonths: parsedDurationMonths,
+ ratePerHour: parsedRatePerHour.toString(),
+ standardHoursPerWeek: parsedStandardHoursPerWeek,
+ agreementRate: assignAssignmentRate,
otherRemarks: normalizedOtherRemarks
}
@@ -485,7 +508,7 @@ const EngagementEditor = ({
{
setAssignStartDate(value)
if (assignErrors.startDate) {
@@ -508,49 +530,84 @@ const EngagementEditor = ({
-
{
- setAssignEndDate(value)
- if (assignErrors.endDate) {
- setAssignErrors(prev => ({ ...prev, endDate: '' }))
+ {
+ setAssignDurationMonths(sanitizePositiveNumericInput(event.target.value))
+ if (assignErrors.durationMonths) {
+ setAssignErrors(prev => ({ ...prev, durationMonths: '' }))
}
}}
/>
- {assignErrors.endDate && (
- {assignErrors.endDate}
+ {assignErrors.durationMonths && (
+ {assignErrors.durationMonths}
)}
-
+
+
+
{
+ setAssignRatePerHour(sanitizePositiveNumericInput(event.target.value))
+ if (assignErrors.ratePerHour) {
+ setAssignErrors(prev => ({ ...prev, ratePerHour: '' }))
+ }
+ }}
+ />
+ {assignErrors.ratePerHour && (
+
{assignErrors.ratePerHour}
+ )}
+
+
{
- setAssignRate(event.target.value)
- if (assignErrors.rate) {
- setAssignErrors(prev => ({ ...prev, rate: '' }))
+ setAssignStandardHoursPerWeek(
+ sanitizePositiveNumericInput(event.target.value, 2)
+ )
+ if (assignErrors.standardHoursPerWeek) {
+ setAssignErrors(prev => ({ ...prev, standardHoursPerWeek: '' }))
}
}}
/>
- {assignErrors.rate && (
-
{assignErrors.rate}
+ {assignErrors.standardHoursPerWeek && (
+
{assignErrors.standardHoursPerWeek}
)}
+
+
+
+