diff --git a/config/constants/development.js b/config/constants/development.js index 773fd132..438ffea1 100644 --- a/config/constants/development.js +++ b/config/constants/development.js @@ -26,6 +26,7 @@ module.exports = { RESOURCE_ROLES_API_URL: `${DEV_API_HOSTNAME}/v6/resource-roles`, SUBMISSIONS_API_URL: `${DEV_API_HOSTNAME}/v6/submissions`, REVIEW_TYPE_API_URL: `${DEV_API_HOSTNAME}/v6/reviewTypes`, + SCORECARDS_API_URL: `${DEV_API_HOSTNAME}/v6/scorecards`, SUBMISSION_REVIEW_APP_URL: `https://submission-review.${DOMAIN}/challenges`, STUDIO_URL: `https://studio.${DOMAIN}`, CONNECT_APP_URL: `https://connect.${DOMAIN}`, diff --git a/config/constants/production.js b/config/constants/production.js index 882ee5be..7e6411fc 100644 --- a/config/constants/production.js +++ b/config/constants/production.js @@ -27,6 +27,7 @@ module.exports = { RESOURCE_ROLES_API_URL: `${PROD_API_HOSTNAME}/v5/resource-roles`, SUBMISSIONS_API_URL: `${PROD_API_HOSTNAME}/v5/submissions`, REVIEW_TYPE_API_URL: `${PROD_API_HOSTNAME}/v5/reviewTypes`, + SCORECARDS_API_URL: `${PROD_API_HOSTNAME}/v5/scorecards`, //update to use v6 SUBMISSION_REVIEW_APP_URL: `https://submission-review.${DOMAIN}/challenges`, STUDIO_URL: `https://studio.${DOMAIN}`, CONNECT_APP_URL: `https://connect.${DOMAIN}`, diff --git a/src/actions/challenges.js b/src/actions/challenges.js index fbdcebbc..f644d7fc 100644 --- a/src/actions/challenges.js +++ b/src/actions/challenges.js @@ -20,7 +20,9 @@ import { createChallenge as createChallengeAPI, createResource as createResourceAPI, deleteResource as deleteResourceAPI, - updateChallengeSkillsApi + updateChallengeSkillsApi, + fetchDefaultReviewers, + fetchScorecards } from '../services/challenges' import { searchProfilesByUserIds } from '../services/user' import { @@ -765,3 +767,53 @@ export function updateChallengeSkills (challengeId, skills) { } } } + +/** + * Load scorecards + * @param {Object} filters filters for scorecards + */ +export function loadScorecards (filters = {}) { + return async (dispatch) => { + try { + const scorecards = await fetchScorecards(filters) + dispatch({ + type: LOAD_CHALLENGE_METADATA_SUCCESS, + metadataKey: 'scorecards', + metadataValue: scorecards.scoreCards || [] + }) + } catch (error) { + console.error('Error loading scorecards:', error) + // Return empty array on error to maintain consistency + dispatch({ + type: LOAD_CHALLENGE_METADATA_SUCCESS, + metadataKey: 'scorecards', + metadataValue: [] + }) + } + } +} + +/** + * Load default reviewers + * @param {Object} filters filters for default reviewers + */ +export function loadDefaultReviewers (filters = {}) { + return async (dispatch) => { + try { + const defaultReviewers = await fetchDefaultReviewers(filters) + dispatch({ + type: LOAD_CHALLENGE_METADATA_SUCCESS, + metadataKey: 'defaultReviewers', + metadataValue: defaultReviewers + }) + } catch (error) { + console.error('Error loading default reviewers:', error) + // Return empty array on error to maintain consistency + dispatch({ + type: LOAD_CHALLENGE_METADATA_SUCCESS, + metadataKey: 'defaultReviewers', + metadataValue: [] + }) + } + } +} diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss new file mode 100644 index 00000000..33b54f77 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss @@ -0,0 +1,230 @@ +@import '../../../styles/includes'; + +.row { + box-sizing: border-box; + display: flex; + flex-direction: row; + margin: 30px 30px 0 30px; + align-content: space-between; + justify-content: flex-start; + + .field { + @include upto-sm { + display: block; + padding-bottom: 10px; + } + + label { + @include roboto-bold(); + font-size: 16px; + line-height: 19px; + font-weight: 500; + color: $tc-gray-80; + } + + &.col1 { + max-width: 185px; + min-width: 185px; + margin-right: 14px; + white-space: nowrap; + display: flex; + align-items: center; + } + + &.col2 { + align-self: flex-end; + margin-bottom: auto; + margin-top: auto; + display: flex; + flex-direction: column; + width: 600px; + } + } +} + +.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; + 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; +} + +.formRow { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin-bottom: 15px; +} + +.formGroup { + flex: 1; + min-width: 200px; +} + +.formGroup label { + display: block; + margin-bottom: 5px; + font-weight: 500; + color: #555; + font-size: 14px; +} + +.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; + margin-top: 20px; +} + +.summary { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + 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; +} + +.summaryRow span:last-child { + font-weight: 500; +} + +.loading { + text-align: center; + padding: 40px; + color: #666; + font-style: italic; +} + +.error { + color: $tc-red; + background-color: #ffebee; + padding: 15px; + border-radius: 4px; + border: 1px solid #ffcdd2; + margin-bottom: 20px; +} + +.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; +} + +.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/index.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js new file mode 100644 index 00000000..bd91706e --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js @@ -0,0 +1,493 @@ +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_TYPES, VALIDATION_VALUE_TYPE } from '../../../config/constants' +import { loadScorecards, loadDefaultReviewers } from '../../../actions/challenges' +import styles from './ChallengeReviewer-Field.module.scss' +import { convertDollarToInteger, validateValue } from '../../../util/input-check' + +class ChallengeReviewerField extends Component { + constructor (props) { + super(props) + this.state = { + error: null + } + + // Bind methods + 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) + } + + componentDidMount () { + this.loadScorecards() + this.loadDefaultReviewers() + } + + loadScorecards () { + const { challenge, loadScorecards } = this.props + // Build query parameters for the scorecard API + const filters = {} + + // Add challenge track if available + if (challenge.track) { + filters.challengeTrack = challenge.track.toUpperCase() + } + + loadScorecards(filters) + } + + loadDefaultReviewers () { + const { challenge, loadDefaultReviewers } = this.props + + // only load default reviewers if we have typeId and trackId + if (!challenge.typeId || !challenge.trackId) { + console.log('Cannot load default reviewers: missing typeId or trackId') + return + } + + loadDefaultReviewers({ + typeId: challenge.typeId, + trackId: challenge.trackId + }) + } + + addReviewer () { + const { challenge, onUpdateReviewers } = this.props + const currentReviewers = challenge.reviewers || [] + + // Create a new default reviewer based on track and type + const defaultReviewer = 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 + + const newReviewer = { + scorecardId: (defaultReviewer && defaultReviewer.scorecardId) || '', + isMemberReview: true, + memberReviewerCount: (defaultReviewer && defaultReviewer.memberReviewerCount) || 1, + phaseId: (defaultReviewer && defaultReviewer.phaseId) || (firstReviewPhase ? (firstReviewPhase.id || firstReviewPhase.phaseId) : ''), + basePayment: (defaultReviewer && defaultReviewer.basePayment) || '0', + incrementalPayment: (defaultReviewer && defaultReviewer.incrementalPayment) || 0, + type: (defaultReviewer && defaultReviewer.opportunityType) || REVIEW_OPPORTUNITY_TYPES.REGULAR_REVIEW, + isAIReviewer: (defaultReviewer && defaultReviewer.isAIReviewer) || false + } + + 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 + updatedReviewers[index] = Object.assign({}, updatedReviewers[index], fieldUpdate) + onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) + } + + findDefaultReviewer () { + const { challenge, metadata = {} } = this.props + const { defaultReviewers = [] } = metadata + + if (!challenge || !challenge.trackId || !challenge.typeId) { + return null + } + + return defaultReviewers.find(dr => + dr.trackId === challenge.trackId && dr.typeId === challenge.typeId + ) + } + + validateReviewer (reviewer) { + const errors = [] + + if (typeof reviewer.isAIReviewer !== 'boolean') { + errors.push('Reviewer type must be specified') + } + + if (!reviewer.scorecardId) { + errors.push('Scorecard is required') + } + + if (!reviewer.phaseId) { + errors.push('Phase is required') + } + + const memberCount = parseInt(reviewer.memberReviewerCount) || 1 + if (!reviewer.isAIReviewer && (memberCount < 1 || !Number.isInteger(memberCount))) { + errors.push('Number of reviewers must be a positive integer') + } + + const basePayment = convertDollarToInteger(reviewer.basePayment || '0', '') + if (!reviewer.isAIReviewer && (basePayment < 0)) { + errors.push('Base payment must be non-negative') + } + + 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 = this.validateReviewer(reviewer) + + return ( +
+
+ {index > 0 &&

Reviewer {index + 1}

} + {!readOnly && ( + this.removeReviewer(index)} + /> + )} +
+ + {validationErrors.length > 0 && ( +
+ {validationErrors.map((error, i) => ( +
{error}
+ ))} +
+ )} + +
+
+ + {readOnly ? ( + {reviewer.isAIReviewer ? 'AI Reviewer' : 'Member Reviewer'} + ) : ( + + )} +
+ +
+ + {readOnly ? ( + + {(() => { + const scorecard = scorecards.find(s => s.id === reviewer.scorecardId) + return scorecard ? `${scorecard.name} - ${scorecard.type} (${scorecard.challengeTrack}) v${scorecard.version}` : 'Not selected' + })()} + + ) : ( + + )} +
+ +
+ + {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' + })()} + + ) : ( + + )} +
+
+ + {!reviewer.isAIReviewer && ( +
+
+ + {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 ? ( + ${reviewer.basePayment || '0'} + ) : ( + { + const validatedValue = validateValue(e.target.value, VALIDATION_VALUE_TYPE.INTEGER) + this.updateReviewer(index, 'basePayment', validatedValue) + }} + /> + )} +
+ +
+ + {readOnly ? ( + ${reviewer.incrementalPayment || 0} + ) : ( + this.updateReviewer(index, 'incrementalPayment', parseFloat(e.target.value))} + /> + )} +
+
+ )} + +
+
+ + {readOnly ? ( + + {(() => { + const typeMap = { + [REVIEW_OPPORTUNITY_TYPES.REGULAR_REVIEW]: 'Regular Review', + [REVIEW_OPPORTUNITY_TYPES.COMPONENT_DEV_REVIEW]: 'Component Dev Review', + [REVIEW_OPPORTUNITY_TYPES.SPEC_REVIEW]: 'Spec Review', + [REVIEW_OPPORTUNITY_TYPES.ITERATIVE_REVIEW]: 'Iterative Review', + [REVIEW_OPPORTUNITY_TYPES.SCENARIOS_REVIEW]: 'Scenarios Review' + } + return typeMap[reviewer.type] || 'Regular Review' + })()} + + ) : ( + + )} +
+
+
+ ) + } + + render () { + const { challenge, metadata = {}, isLoading, readOnly = false } = this.props + const { error } = this.state + const { scorecards = [], defaultReviewers = [] } = metadata + const reviewers = challenge.reviewers || [] + + if (isLoading) { + return ( +
+
+ +
+
+
Loading...
+
+
+ ) + } + + // Only show error if there's a real error, not just missing data + if (error && !scorecards.length && !defaultReviewers.length) { + return ( +
+
+ +
+
+ {error} +
+
+ ) + } + + return ( + <> +
+
+ +
+
+ {!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 => !r.isAIReviewer).reduce((sum, r) => sum + (parseInt(r.memberReviewerCount) || 0), 0)} +
+
+ Total AI Reviewers: + {reviewers.filter(r => r.isAIReviewer).length} +
+
+ Total Review Cost: + ${reviewers.filter(r => !r.isAIReviewer).reduce((sum, r) => { + const base = convertDollarToInteger(r.basePayment || '0', '') + const count = parseInt(r.memberReviewerCount) || 1 + return sum + (base * count) + }, 0).toFixed(2)} +
+
+ )} + + {!readOnly && ( +
+ +
+ )} +
+
+ + ) + } +} + +ChallengeReviewerField.propTypes = { + challenge: PropTypes.object.isRequired, + onUpdateReviewers: PropTypes.func.isRequired, + metadata: PropTypes.shape({ + scorecards: PropTypes.array, + defaultReviewers: PropTypes.array + }), + isLoading: PropTypes.bool, + readOnly: PropTypes.bool, + loadScorecards: PropTypes.func.isRequired, + loadDefaultReviewers: PropTypes.func.isRequired +} + +const mapStateToProps = (state) => ({ + metadata: state.challenges.metadata || {}, + isLoading: state.challenges.isLoading +}) + +const mapDispatchToProps = { + loadScorecards, + loadDefaultReviewers +} + +export default connect(mapStateToProps, mapDispatchToProps)(ChallengeReviewerField) diff --git a/src/components/ChallengeEditor/ChallengeTotal-Field/index.js b/src/components/ChallengeEditor/ChallengeTotal-Field/index.js index d968d00c..020ebd4f 100644 --- a/src/components/ChallengeEditor/ChallengeTotal-Field/index.js +++ b/src/components/ChallengeEditor/ChallengeTotal-Field/index.js @@ -13,13 +13,26 @@ const ChallengeTotalField = ({ challenge }) => { .map(v => convertDollarToInteger(v, '$')) .reduce((prev, next) => prev + next, 0) } + + let reviewerTotal = 0 + if (challenge.reviewers && Array.isArray(challenge.reviewers)) { + reviewerTotal = challenge.reviewers + .filter(r => !r.isAIReviewer) + .reduce((sum, r) => { + const base = convertDollarToInteger(r.basePayment || '0', '') + const count = parseInt(r.memberReviewerCount) || 1 + return sum + (base * count) + }, 0) + } + + const totalChallengeCost = (challengeTotal || 0) + reviewerTotal return (
- $ {challengeTotal || 0} + $ {totalChallengeCost}
) diff --git a/src/components/ChallengeEditor/ChallengeView/index.js b/src/components/ChallengeEditor/ChallengeView/index.js index 430e22bf..34622a0e 100644 --- a/src/components/ChallengeEditor/ChallengeView/index.js +++ b/src/components/ChallengeEditor/ChallengeView/index.js @@ -241,6 +241,7 @@ const ChallengeView = ({ {/* hide until challenge API change is pushed to PROD https://github.com/topcoder-platform/challenge-api/issues/348 */} {false && )} + {showReviewerField && ( + + )} {}, onUpdateSkills: () => {}, onUpdateMultiSelect: () => {}, - readOnly: false + readOnly: false, + showReviewerField: false, + onUpdateReviewers: () => {} } TextEditorField.propTypes = { @@ -145,7 +156,9 @@ TextEditorField.propTypes = { onUpdateSkills: PropTypes.func, onUpdateMultiSelect: PropTypes.func, shouldShowPrivateDescription: PropTypes.bool, - readOnly: PropTypes.bool + readOnly: PropTypes.bool, + showReviewerField: PropTypes.bool, + onUpdateReviewers: PropTypes.func } export default TextEditorField diff --git a/src/components/ChallengeEditor/index.js b/src/components/ChallengeEditor/index.js index 50111604..bd1a43d6 100644 --- a/src/components/ChallengeEditor/index.js +++ b/src/components/ChallengeEditor/index.js @@ -239,6 +239,9 @@ class ChallengeEditor extends Component { const challengeDetail = { ...challengeData } const isRequiredNda = challengeDetail.terms && _.some(challengeDetail.terms, { id: DEFAULT_NDA_UUID }) const isOpenAdvanceSettings = challengeDetail.groups.length > 0 || isRequiredNda + if (!challengeDetail.reviewers) { + challengeDetail.reviewers = [] + } setState({ challenge: challengeDetail, assignedMemberDetails, @@ -943,7 +946,8 @@ class ChallengeEditor extends Component { 'milestoneId', 'discussions', 'task', - 'skills' + 'skills', + 'reviewers' ], this.state.challenge) const isTask = _.find(metadata.challengeTypes, { id: challenge.typeId, isTask: true }) challenge.legacy = _.assign(this.state.challenge.legacy, { @@ -1038,7 +1042,8 @@ class ChallengeEditor extends Component { terms: [{ id: DEFAULT_TERM_UUID, roleId: SUBMITTER_ROLE_UUID }], groups: [], milestoneId, - tags + tags, + reviewers: [] // prizeSets: this.getDefaultPrizeSets() } if (isTask) { @@ -1854,6 +1859,8 @@ class ChallengeEditor extends Component { onUpdateSkills={this.onUpdateSkills} onUpdateMultiSelect={this.onUpdateMultiSelect} onUpdateMetadata={this.onUpdateMetadata} + showReviewerField + onUpdateReviewers={this.onUpdateOthers} /> {/* hide until challenge API change is pushed to PROD https://github.com/topcoder-platform/challenge-api/issues/348 */} {false && } + */ +export async function fetchScorecards (filters = {}) { + const query = { + perPage: 100, + page: 1, + ...filters + } + const response = await axiosInstance.get(`${SCORECARDS_API_URL}?${qs.stringify(query, { encode: false })}`) + return _.get(response, 'data', {}) +} +/** + * Api request for fetching default reviewers + * @param {Object} filters filters for default reviewers + * @returns {Promise<*>} + */ +export async function fetchDefaultReviewers (filters = {}) { + // Use CHALLENGE_API_URL directly for default reviewers endpoint + const response = await axiosInstance.get(`${CHALLENGE_API_URL}/default-reviewers?${qs.stringify(filters, { encode: false })}`) + return _.get(response, 'data', []) +}