From 758e5c34a962315193c15628a94ddeda4e506108 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 17 Feb 2026 10:46:33 +0200 Subject: [PATCH 01/16] PM-3851 #time 5h Split reviews in 2 tabs: human & ai --- .../Buttons/OutlineButton/Outline.module.scss | 6 + src/components/Buttons/OutlineButton/index.js | 5 +- .../ChallengeReviewer-Field/AIReviewTab.js | 307 +++++ .../ChallengeReviewer-Field.module.scss | 83 ++ .../ChallengeReviewer-Field/HumanReviewTab.js | 730 ++++++++++++ .../ChallengeReviewer-Field/index.js | 1035 +---------------- 6 files changed, 1181 insertions(+), 985 deletions(-) create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/HumanReviewTab.js diff --git a/src/components/Buttons/OutlineButton/Outline.module.scss b/src/components/Buttons/OutlineButton/Outline.module.scss index ecb2bdde..ef50afd0 100644 --- a/src/components/Buttons/OutlineButton/Outline.module.scss +++ b/src/components/Buttons/OutlineButton/Outline.module.scss @@ -52,4 +52,10 @@ color: $tc-gray-40; } } + + &.minWidth { + width: min-content; + padding-left: 16px; + padding-right: 16px; + } } diff --git a/src/components/Buttons/OutlineButton/index.js b/src/components/Buttons/OutlineButton/index.js index e5e270d8..32cec7ef 100644 --- a/src/components/Buttons/OutlineButton/index.js +++ b/src/components/Buttons/OutlineButton/index.js @@ -6,7 +6,7 @@ import cn from 'classnames' import styles from './Outline.module.scss' import _ from 'lodash' -const OutlineButton = ({ type, text, link, onClick, url, className, submit, disabled, target = 'self', rel }) => { +const OutlineButton = ({ type, text, link, onClick, url, className, submit, disabled, target = 'self', rel, minWidth = false }) => { const containerClassName = cn(styles.container, styles[type], className) const handleUrlClick = (event) => { @@ -34,7 +34,7 @@ const OutlineButton = ({ type, text, link, onClick, url, className, submit, disa return ( + + )} + + {error && !isLoading && ( +
+ {error} +
+ )} + + ) + } +} + +AIReviewTab.propTypes = { + challenge: PropTypes.object.isRequired, + onUpdateReviewers: PropTypes.func.isRequired, + metadata: PropTypes.shape({ + workflows: PropTypes.array, + challengeTracks: PropTypes.array + }), + isLoading: PropTypes.bool, + readOnly: PropTypes.bool +} + +export default AIReviewTab diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss index 268b520e..968f4b2e 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss @@ -46,6 +46,50 @@ } } +// Tabs styling +.tabsContainer { + display: flex; + flex-direction: column; + border: 1px solid #ddd; + border-radius: 4px; + background-color: #fff; +} + +.tabsHeader { + display: flex; + border-bottom: 2px solid #ddd; + background-color: #f9f9f9; +} + +.tabButton { + flex: 1; + 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; + } +} + +.tabContent { + padding: 20px; +} + .description { color: #666; margin-bottom: 20px; @@ -77,6 +121,27 @@ font-style: normal; } +.applyDefaultBtn, +.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; + } +} + .reviewerForm { background-color: white; border: 1px solid #ddd; @@ -132,6 +197,14 @@ background-color: white; } +.formGroup input[type="checkbox"] { + width: auto; +} + +.formGroup.mtop { + margin-top: 32px; +} + .formGroup input:focus, .formGroup select:focus { outline: none; @@ -227,4 +300,14 @@ align-items: flex-start; gap: 10px; } + + .tabsHeader { + flex-direction: row; + } + + .tabButton { + padding: 10px 8px; + font-size: 13px; + } } + diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/HumanReviewTab.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/HumanReviewTab.js new file mode 100644 index 00000000..256f5c9d --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/HumanReviewTab.js @@ -0,0 +1,730 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import cn from 'classnames' +import { PrimaryButton, OutlineButton } from '../../Buttons' +import { REVIEW_OPPORTUNITY_TYPE_LABELS, REVIEW_OPPORTUNITY_TYPES, VALIDATION_VALUE_TYPE } from '../../../config/constants' +import styles from './ChallengeReviewer-Field.module.scss' +import { validateValue } from '../../../util/input-check' +import AssignedMemberField from '../AssignedMember-Field' +import { DES_TRACK_ID } from '../../../config/constants' +import { isEqual } from 'lodash' + +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.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 + } + + componentDidMount () { + const { challenge, challengeResources } = this.props + if (challenge && challenge.id && challengeResources) { + this.updateAssignedMembers(challengeResources, challenge) + } + } + + componentDidUpdate (prevProps) { + const { challenge: prevChallenge, challengeResources: prevResources } = prevProps + const { 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, 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 requiredPhaseIds = [] + 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) { + requiredPhaseIds.push(phase.phaseId || phase.id) + } + } + } + } + + return requiredPhaseIds + } + + getRoleNameForReviewer (reviewer) { + const { challenge } = this.props + const phase = (challenge.phases || []).find(p => (p.id === reviewer.phaseId) || (p.phaseId === reviewer.phaseId)) + const phaseName = (phase && phase.name) ? phase.name.toLowerCase() : '' + + const normalizedPhaseName = phaseName.replace(/[-\s]/g, '') + + if (phaseName.includes('iterative review') || normalizedPhaseName === 'iterativereview') return 'Iterative Reviewer' + if (normalizedPhaseName === 'approval') return 'Approver' + if (normalizedPhaseName === 'checkpointscreening') return 'Checkpoint Screener' + if (normalizedPhaseName === 'checkpointreview') return 'Checkpoint Reviewer' + if (normalizedPhaseName === 'screening') return 'Screener' + return 'Reviewer' + } + + async onAssignmentChange (reviewerIndex, slotIndex, option) { + const { challenge, replaceResourceInRole, createResource } = 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 = this.getRoleNameForReviewer(reviewer) + const newAssigned = [...currentAssignedMembers] + newAssigned[slotIndex] = option + + const newAssignedMembers = { ...this.state.assignedMembers } + newAssignedMembers[reviewerIndex] = newAssigned + this.setState({ assignedMembers: newAssignedMembers }) + + if (option) { + await this.createOrReplaceResource( + option, + reviewer, + roleName, + currentAssignedMembers[slotIndex], + challenge + ) + } else if (currentAssignedMembers[slotIndex]) { + const oldOption = currentAssignedMembers[slotIndex] + await replaceResourceInRole( + challenge.id, + oldOption.roleId, + null, + oldOption.handle + ) + } + } + + async createOrReplaceResource (option, reviewer, roleName, oldOption, challenge) { + const { replaceResourceInRole, createResource } = this.props + + if (oldOption) { + await replaceResourceInRole( + challenge.id, + oldOption.roleId, + option.handle, + oldOption.handle + ) + } else { + await createResource(challenge.id, { + handle: option.handle, + roleName + }) + } + } + + 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 { challenge, deleteResource } = this.props + const removedMembers = currentAssigned.slice(newCount) + const newAssigned = currentAssigned.slice(0, newCount) + + for (const member of removedMembers) { + if (member && member.resourceId) { + await deleteResource(member.resourceId) + } + } + + 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, prevChallenge = null) { + const memberReviewers = (challenge.reviewers || []).filter(r => !this.isAIReviewer(r)) + const newAssignedMembers = {} + + memberReviewers.forEach((reviewer, reviewerIndex) => { + const roleName = this.getRoleNameForReviewer(reviewer) + const resourceRoleId = challenge.resourceRoles + ? challenge.resourceRoles[roleName] + : undefined + + 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 memberReviewers = currentReviewers.filter(r => !this.isAIReviewer(r)) + + 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, metadata = {} } = 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 { defaultReviewers = [] } = metadata + const defaultReviewer = this.findDefaultReviewer(value) || updatedReviewers[actualIndex] + Object.assign(fieldUpdate, { + fixedAmount: defaultReviewer.fixedAmount, + baseCoefficient: defaultReviewer.baseCoefficient, + incrementalCoefficient: defaultReviewer.incrementalCoefficient + }) + } + + 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 selectedPhase = challenge.phases.find(p => p.phaseId === reviewer.phaseId) + const isDesignChallenge = challenge && challenge.trackId === DES_TRACK_ID + const normalize = (value) => (value || '') + .toString() + .toLowerCase() + .trim() + .replace(/\bphase\b$/, '') + .replace(/[-_\s]/g, '') + + const filteredScorecards = scorecards.filter(item => { + if (!selectedPhase || !selectedPhase.name || !item || !item.type) { + return false + } + + const normalizedType = normalize(item.type) + const normalizedPhaseName = normalize(selectedPhase.name) + + if (!normalizedType || !normalizedPhaseName) { + return false + } + + return normalizedType === normalizedPhaseName + }) + + 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 firstPlacePrize = this.getFirstPlacePrizeValue(challenge) + const estimatedSubmissionsCount = 2 + + const reviewersCost = reviewers + .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 count = parseInt(r.memberReviewerCount) || 1 + return sum + reviewerCost * count + }, 0) + .toFixed(2) + + 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/index.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js index 0de3fa0b..5367b28d 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js @@ -2,23 +2,10 @@ 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 './AIReviewTab' // Keep track filters aligned with the scorecards API regardless of legacy values const SCORECARD_TRACK_ALIASES = { @@ -66,89 +53,15 @@ class ChallengeReviewerField extends Component { super(props) this.state = { error: null, - // Map reviewer index -> array of assigned member details { handle, userId } - 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.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) - } + activeTab: 'human' // 'human' or 'ai' } - 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() } @@ -156,105 +69,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 && @@ -264,169 +80,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 @@ -472,580 +131,22 @@ 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 (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 selectedPhase = challenge.phases.find(p => p.phaseId === reviewer.phaseId) - const isDesignChallenge = challenge && challenge.trackId === DES_TRACK_ID - const normalize = (value) => (value || '') - .toString() - .toLowerCase() - .trim() - .replace(/\bphase\b$/, '') - .replace(/[-_\s]/g, '') - - const filteredScorecards = scorecards.filter(item => { - if (!selectedPhase || !selectedPhase.name || !item || !item.type) { - return false - } - - const normalizedType = normalize(item.type) - const normalizedPhaseName = normalize(selectedPhase.name) - - if (!normalizedType || !normalizedPhaseName) { - return false - } - - return normalizedType === normalizedPhaseName - }) - - 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 { 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 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 ( @@ -1080,78 +181,46 @@ class ChallengeReviewerField extends Component {
- {(!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)} -
-
- Total AI Reviewers: - {reviewers.filter(r => this.isAIReviewer(r)).length} -
-
- Estimated Review Cost: - - ${reviewersCost} - -
-
- )} + {activeTab === 'human' && ( + this.props.onUpdateReviewers(update)} + replaceResourceInRole={this.props.replaceResourceInRole} + createResource={this.props.createResource} + deleteResource={this.props.deleteResource} + challengeResources={this.props.challengeResources} + /> + )} - {!readOnly && ( -
- this.props.onUpdateReviewers(update)} /> -
- )} + )} +
{error && !isLoading && (
{error} From 4dfa2634409d797783c23545a24d2a59335e3a97 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 17 Feb 2026 12:39:49 +0200 Subject: [PATCH 02/16] PM-3851 #time 3h ai tab split into template, manual config --- .../ChallengeReviewer-Field/AIReviewTab.js | 120 +++++++++- .../AIReviewTab.module.scss | 139 ++++++++++++ .../ChallengeReviewer-Field/AIWorkflowCard.js | 65 ++++++ .../AiWorkflowCard.module.scss | 124 +++++++++++ .../ChallengeReviewer-Field.module.scss | 203 +---------------- .../shared.module.scss | 206 ++++++++++++++++++ 6 files changed, 658 insertions(+), 199 deletions(-) create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.module.scss create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIWorkflowCard.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AiWorkflowCard.module.scss create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/shared.module.scss diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js index 95205759..859b89bb 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js @@ -2,7 +2,8 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import cn from 'classnames' import { PrimaryButton, OutlineButton } from '../../Buttons' -import styles from './ChallengeReviewer-Field.module.scss' +import AIWorkflowCard from './AIWorkflowCard' +import styles from './AIReviewTab.module.scss' class AIReviewTab extends Component { constructor (props) { @@ -10,13 +11,117 @@ class AIReviewTab extends Component { this.state = { error: null, selectedTemplate: null, - aiReviewConfigs: [] // Will manage AI review configs separately + aiReviewConfigs: [], // Will manage AI review configs separately + configurationMode: null // 'template' or 'manual' or null } this.addAIReviewer = this.addAIReviewer.bind(this) this.removeAIReviewer = this.removeAIReviewer.bind(this) this.updateAIReviewer = this.updateAIReviewer.bind(this) this.renderAIReviewerForm = this.renderAIReviewerForm.bind(this) + this.isInitialState = this.isInitialState.bind(this) + this.renderInitialState = this.renderInitialState.bind(this) + this.handleTemplateSelection = this.handleTemplateSelection.bind(this) + this.handleManualConfiguration = this.handleManualConfiguration.bind(this) + this.getAssignedWorkflows = this.getAssignedWorkflows.bind(this) + } + + /** + * Checks if we're in the initial state: + * - AI workflows are assigned (from DefaultChallengeReviewer) + * - But no aiReviewConfig has been created yet + */ + isInitialState () { + const { challenge } = this.props + const aiReviewers = (challenge.reviewers || []).filter(r => this.isAIReviewer(r)) + // Initial state: has AI reviewers but no aiReviewConfig + // TODO: Update this check based on actual aiReviewConfig property once defined + return aiReviewers.length > 0 && !this.state.configurationMode + } + + /** + * Get workflows assigned to this challenge + */ + getAssignedWorkflows () { + const { challenge, metadata = {} } = this.props + const { workflows = [] } = metadata + const aiReviewers = (challenge.reviewers || []).filter(r => this.isAIReviewer(r)) + + return aiReviewers.map(reviewer => { + const workflow = workflows.find(w => w.id === reviewer.aiWorkflowId) + return { + reviewer, + workflow, + scorecardId: reviewer.scorecardId + } + }) + } + + handleTemplateSelection () { + this.setState({ configurationMode: 'template' }) + } + + handleManualConfiguration () { + this.setState({ configurationMode: 'manual' }) + } + + renderInitialState () { + const assignedWorkflows = this.getAssignedWorkflows() + + return ( +
+
+
⚠️
+
+

AI workflows are assigned but no AI Review Config has been created

+

Workflows will run but scoring, gating, and thresholds are not defined.

+

Choose how to configure:

+
+
+ +
+
+

📋 Use a Template

+

Pre-fill from a standard config for this track & type.

+ +
+ +
+

✏️ Configure Manually

+

Set up each workflow weight, mode, and threshold yourself.

+ +
+
+ +
+

Assigned AI Workflows

+

These AI workflows are assigned to this challenge from the default reviewer configuration.

+ +
+ {assignedWorkflows.map((item, index) => ( + this.removeAIReviewer(index)} + readOnly={false} + /> + ))} +
+
+
+ ) } addAIReviewer () { @@ -231,13 +336,22 @@ class AIReviewTab extends Component { render () { const { challenge, isLoading, readOnly = false } = this.props - const { error } = this.state + const { error, configurationMode } = this.state const aiReviewers = (challenge.reviewers || []).filter(r => this.isAIReviewer(r)) if (isLoading) { return
Loading...
} + // Show initial state if workflows are assigned but no configuration mode selected yet + if (this.isInitialState()) { + return ( +
+ {this.renderInitialState()} +
+ ) + } + return (
{!readOnly && ( diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.module.scss new file mode 100644 index 00000000..7f2c0344 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.module.scss @@ -0,0 +1,139 @@ +@use '../../../styles/includes' as *; +@import './shared.module.scss'; + +.initialStateContainer { + display: flex; + flex-direction: column; + gap: 30px; +} + +.warningBox { + display: flex; + gap: 16px; + background-color: #fff8e1; + border: 2px solid #ffd54f; + border-radius: 8px; + padding: 20px; + align-items: flex-start; +} + +.warningIcon { + font-size: 28px; + flex-shrink: 0; +} + +.warningContent { + flex: 1; + + h3 { + margin: 0 0 10px 0; + font-size: 18px; + font-weight: 600; + color: #333; + } + + p { + margin: 8px 0; + color: #555; + font-size: 14px; + line-height: 1.5; + } + + p strong { + font-weight: 600; + } +} + +.configurationOptions { + display: flex; + gap: 20px; + flex-wrap: wrap; + + @media (max-width: 768px) { + flex-direction: column; + } +} + +.optionCard { + flex: 1; + min-width: 250px; + border: 2px solid #e0e0e0; + border-radius: 8px; + padding: 24px; + text-align: center; + transition: all 0.3s ease; + background-color: #fafafa; + + &:hover { + border-color: #0066cc; + box-shadow: 0 4px 12px rgba(0, 102, 204, 0.15); + } + + h4 { + margin: 0 0 8px 0; + font-size: 16px; + font-weight: 600; + color: #333; + } + + p { + margin: 0 0 16px 0; + color: #666; + font-size: 14px; + line-height: 1.5; + } +} + +.optionIcon { + font-size: 36px; + margin-bottom: 12px; + display: block; +} + +.optionButton { + padding: 10px 24px; + background-color: #0066cc; + color: white; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.3s ease; + + &:hover { + background-color: #0052a3; + } + + &:active { + background-color: #004080; + } +} + +.assignedWorkflowsSection { + padding-top: 20px; + border-top: 1px solid #e0e0e0; + + h3 { + margin: 0 0 8px 0; + font-size: 16px; + font-weight: 600; + color: #333; + } + + p { + margin: 0 0 16px 0; + color: #666; + font-size: 14px; + } +} + +.workflowsList { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIWorkflowCard.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIWorkflowCard.js new file mode 100644 index 00000000..6f8ed4ed --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIWorkflowCard.js @@ -0,0 +1,65 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styles from './AiWorkflowCard.module.scss' + +const AIWorkflowCard = ({ workflow, scorecardId, description, onRemove, readOnly = false }) => { + return ( +
+
+
+
+ 🤖 + {workflow.name} +
+
+ {!readOnly && onRemove && ( + + )} +
+ +
+ {description && ( +
+ Description: +

{description}

+
+ )} + + {scorecardId && ( +
+ Scorecard: + + {scorecardId} + + +
+ )} +
+
+ ) +} + +AIWorkflowCard.propTypes = { + workflow: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string.isRequired + }).isRequired, + scorecardId: PropTypes.string, + description: PropTypes.string, + onRemove: PropTypes.func, + readOnly: PropTypes.bool +} + +export default AIWorkflowCard 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..9b0464be --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiWorkflowCard.module.scss @@ -0,0 +1,124 @@ +@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; + } +} + +.externalIcon { + font-size: 12px; + opacity: 0.7; +} \ No newline at end of file diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss index 968f4b2e..cc0642ce 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; @@ -86,43 +87,21 @@ } } -.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; -} - .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; + p { + margin: 0 0 15px 0; + color: #1976d2; + font-style: normal; + } } -.applyDefaultBtn, -.addReviewerBtn { +.applyDefaultBtn { padding: 8px 16px; background-color: #0066cc; color: white; @@ -142,172 +121,4 @@ } } -.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[type="checkbox"] { - width: auto; -} - -.formGroup.mtop { - margin-top: 32px; -} - -.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; - padding: 5px; -} - -.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; - } - - .tabsHeader { - flex-direction: row; - } - - .tabButton { - padding: 10px 8px; - font-size: 13px; - } -} 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; + } +} From 8c4214af62c63d69a8c17464f2980f5e215ecac3 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 18 Feb 2026 08:44:41 +0200 Subject: [PATCH 03/16] PM-3851 #time 4h mock reviewers API servies --- mock-data/ai-review-config.json | 48 ++++ mock-data/ai-review-templates.json | 171 +++++++++++++ src/services/aiReviewConfigHelpers.js | 256 +++++++++++++++++++ src/services/aiReviewConfigs.js | 121 +++++++++ src/services/aiReviewTemplateHelpers.js | 225 ++++++++++++++++ src/services/aiReviewTemplates.js | 127 +++++++++ src/services/mocks/aiReviewConfigs.mock.js | 203 +++++++++++++++ src/services/mocks/aiReviewTemplates.mock.js | 191 ++++++++++++++ src/services/mocks/index.js | 14 + 9 files changed, 1356 insertions(+) create mode 100644 mock-data/ai-review-config.json create mode 100644 mock-data/ai-review-templates.json create mode 100644 src/services/aiReviewConfigHelpers.js create mode 100644 src/services/aiReviewConfigs.js create mode 100644 src/services/aiReviewTemplateHelpers.js create mode 100644 src/services/aiReviewTemplates.js create mode 100644 src/services/mocks/aiReviewConfigs.mock.js create mode 100644 src/services/mocks/aiReviewTemplates.mock.js create mode 100644 src/services/mocks/index.js diff --git a/mock-data/ai-review-config.json b/mock-data/ai-review-config.json new file mode 100644 index 00000000..2a4e6c65 --- /dev/null +++ b/mock-data/ai-review-config.json @@ -0,0 +1,48 @@ +{ + "id": "config_challenge_001", + "challengeId": "challenge_12345", + "minPassingThreshold": 75.00, + "autoFinalize": false, + "mode": "AI_GATING", + "formula": { + "type": "gating", + "gates": [ + { + "workflowId": "wf_001_security", + "threshold": 80, + "isMandatory": true + } + ], + "scoring": { + "type": "weighted", + "weights": { + "wf_001_security": 0.4, + "wf_002_quality": 0.6 + } + } + }, + "workflows": [ + { + "id": "config_wf_001", + "configId": "config_challenge_001", + "workflowId": "wf_001_security", + "weightPercent": 40.00, + "isGating": true, + "createdAt": "2026-02-10T10:00:00Z", + "createdBy": "user_123" + }, + { + "id": "config_wf_002", + "configId": "config_challenge_001", + "workflowId": "wf_002_quality", + "weightPercent": 60.00, + "isGating": false, + "createdAt": "2026-02-10T10:00:00Z", + "createdBy": "user_123" + } + ], + "createdAt": "2026-02-10T10:00:00Z", + "createdBy": "user_123", + "updatedAt": "2026-02-15T14:30:00Z", + "updatedBy": "user_123" +} diff --git a/mock-data/ai-review-templates.json b/mock-data/ai-review-templates.json new file mode 100644 index 00000000..0fb95b98 --- /dev/null +++ b/mock-data/ai-review-templates.json @@ -0,0 +1,171 @@ +{ + "templates": [ + { + "id": "template_001", + "challengeTrack": "dev", + "challengeType": "code", + "title": "Development Code Challenge - Standard", + "description": "Standard AI review configuration for development track code challenges. Combines code quality and security checks with gating mechanism.", + "minPassingThreshold": 75.00, + "mode": "AI_GATING", + "autoFinalize": false, + "formula": { + "type": "gating", + "gates": [ + { + "workflowId": "wf_001_security", + "threshold": 80, + "isMandatory": true + } + ], + "scoring": { + "type": "weighted", + "weights": { + "wf_001_security": 0.4, + "wf_002_quality": 0.6 + } + } + }, + "workflows": [ + { + "id": "tcwf_001", + "configId": "template_001", + "workflowId": "wf_001_security", + "weightPercent": 40.00, + "isGating": true, + "workflow": { + "id": "wf_001_security", + "name": "Security Code Review", + "description": "Analyzes code for security vulnerabilities", + "scorecardId": "scorecard_sec_001" + } + }, + { + "id": "tcwf_002", + "configId": "template_001", + "workflowId": "wf_002_quality", + "weightPercent": 60.00, + "isGating": false, + "workflow": { + "id": "wf_002_quality", + "name": "Code Quality Check", + "description": "Evaluates code quality metrics", + "scorecardId": "scorecard_quality_001" + } + } + ], + "createdAt": "2026-01-15T10:30:00Z", + "createdBy": "admin_user_001", + "updatedAt": "2026-02-10T14:20:00Z", + "updatedBy": "admin_user_002" + }, + { + "id": "template_002", + "challengeTrack": "design", + "challengeType": "design", + "title": "Design Challenge - AI Only", + "description": "AI-only review for design challenges. No human review required - AI makes final decisions.", + "minPassingThreshold": 70.00, + "mode": "AI_ONLY", + "autoFinalize": true, + "formula": { + "type": "weighted", + "weights": { + "wf_003_design": 0.5, + "wf_004_usability": 0.5 + } + }, + "workflows": [ + { + "id": "tcwf_003", + "configId": "template_002", + "workflowId": "wf_003_design", + "weightPercent": 50.00, + "isGating": false, + "workflow": { + "id": "wf_003_design", + "name": "Design Review", + "description": "Reviews design assets and composition", + "scorecardId": "scorecard_design_001" + } + }, + { + "id": "tcwf_004", + "configId": "template_002", + "workflowId": "wf_004_usability", + "weightPercent": 50.00, + "isGating": false, + "workflow": { + "id": "wf_004_usability", + "name": "Usability Assessment", + "description": "Evaluates usability and UX principles", + "scorecardId": "scorecard_usability_001" + } + } + ], + "createdAt": "2026-01-20T09:00:00Z", + "createdBy": "admin_user_001", + "updatedAt": "2026-02-05T16:45:00Z", + "updatedBy": "admin_user_001" + }, + { + "id": "template_003", + "challengeTrack": "data-science", + "challengeType": "data-science", + "title": "Data Science Challenge - Gating", + "description": "Data science challenge with AI gating for submissions. AI filters low-quality submissions; humans review the rest.", + "minPassingThreshold": 80.00, + "mode": "AI_GATING", + "autoFinalize": false, + "formula": { + "type": "gating", + "gates": [ + { + "workflowId": "wf_005_accuracy", + "threshold": 75, + "isMandatory": true + } + ], + "scoring": { + "type": "weighted", + "weights": { + "wf_005_accuracy": 0.5, + "wf_006_methodology": 0.5 + } + } + }, + "workflows": [ + { + "id": "tcwf_005", + "configId": "template_003", + "workflowId": "wf_005_accuracy", + "weightPercent": 50.00, + "isGating": true, + "workflow": { + "id": "wf_005_accuracy", + "name": "Model Accuracy Validator", + "description": "Validates machine learning model accuracy", + "scorecardId": "scorecard_accuracy_001" + } + }, + { + "id": "tcwf_006", + "configId": "template_003", + "workflowId": "wf_006_methodology", + "weightPercent": 50.00, + "isGating": false, + "workflow": { + "id": "wf_006_methodology", + "name": "Methodology Review", + "description": "Reviews data science methodology", + "scorecardId": "scorecard_methodology_001" + } + } + ], + "createdAt": "2026-02-01T11:15:00Z", + "createdBy": "admin_user_002", + "updatedAt": "2026-02-15T13:30:00Z", + "updatedBy": "admin_user_002" + } + ] +} diff --git a/src/services/aiReviewConfigHelpers.js b/src/services/aiReviewConfigHelpers.js new file mode 100644 index 00000000..e1a62a0b --- /dev/null +++ b/src/services/aiReviewConfigHelpers.js @@ -0,0 +1,256 @@ +/** + * AI Review Config Integration Utilities + * + * Helper functions for managing AI review configs in React components. + */ + +/** + * Hook-like function to manage AI review configs + */ +export const createConfigManager = (useDevConfig = false) => { + const service = useDevConfig + ? require('./aiReviewConfigs.mock') + : require('./aiReviewConfigs') + + return { + create: (data) => service.mockCreateAIReviewConfig + ? service.mockCreateAIReviewConfig(data) + : service.createAIReviewConfig(data), + + fetchByChallenge: (challengeId) => service.mockFetchAIReviewConfigByChallenge + ? service.mockFetchAIReviewConfigByChallenge(challengeId) + : service.fetchAIReviewConfigByChallenge(challengeId), + + update: (id, data) => service.mockUpdateAIReviewConfig + ? service.mockUpdateAIReviewConfig(id, data) + : service.updateAIReviewConfig(id, data), + + delete: (id) => service.mockDeleteAIReviewConfig + ? service.mockDeleteAIReviewConfig(id) + : service.deleteAIReviewConfig(id) + } +} + +/** + * Format config for display in UI + * + * @param {Object} config - The config to format + * @returns {Object} - Formatted config + */ +export const formatConfigForDisplay = (config) => { + if (!config) { + return null + } + + return { + ...config, + modeLabel: config.mode === 'AI_ONLY' ? 'AI Only Review' : 'AI Gating + Human Review', + workflowCount: config.workflows ? config.workflows.length : 0, + totalWeight: config.workflows + ? config.workflows.reduce((sum, w) => sum + (w.weightPercent || 0), 0) + : 0, + gatingWorkflows: config.workflows + ? config.workflows.filter(w => w.isGating) + : [], + scoringWorkflows: config.workflows + ? config.workflows.filter(w => !w.isGating) + : [] + } +} + +/** + * Validate config data before submission + * + * @param {Object} configData - The config data to validate + * @returns {Object} - { isValid: boolean, errors: Array } + */ +export const validateConfigData = (configData) => { + const errors = [] + + // Required fields + if (!configData.challengeId || configData.challengeId.trim() === '') { + errors.push('Challenge ID is required') + } + + if (configData.minPassingThreshold === undefined || configData.minPassingThreshold === null) { + errors.push('Min Passing Threshold is required') + } else if (configData.minPassingThreshold < 0 || configData.minPassingThreshold > 100) { + errors.push('Min Passing Threshold must be between 0 and 100') + } + + // Validate mode + if (!configData.mode || !['AI_GATING', 'AI_ONLY'].includes(configData.mode)) { + errors.push('Valid Review Mode (AI_GATING or AI_ONLY) is required') + } + + // Validate workflows + if (!configData.workflows || configData.workflows.length === 0) { + errors.push('At least one workflow is required') + } else { + // Check workflow IDs + const invalidWorkflows = configData.workflows.filter( + w => !w.workflowId || w.workflowId.trim() === '' + ) + if (invalidWorkflows.length > 0) { + errors.push(`${invalidWorkflows.length} workflow(s) are missing Workflow ID`) + } + + // Check weights + const totalWeight = configData.workflows.reduce((sum, w) => sum + (w.weightPercent || 0), 0) + if (Math.abs(totalWeight - 100) > 0.01) { + errors.push(`Workflow weights must sum to 100% (currently ${totalWeight.toFixed(2)}%)`) + } + + // Check for gating workflows if in AI_GATING mode + if (configData.mode === 'AI_GATING') { + const gatingWorkflows = configData.workflows.filter(w => w.isGating) + if (gatingWorkflows.length === 0) { + console.warn('No gating workflows found for AI_GATING mode - all submissions will pass through') + } + } + } + + return { + isValid: errors.length === 0, + errors + } +} + +/** + * Calculate aggregated score from workflow scores + * + * @param {Object} config - The AI review config + * @param {Object} workflowScores - Map of workflowId to score + * @returns {Object} - { totalScore, componentScores, passesGating, passesThreshold } + */ +export const calculateAggregatedScore = (config, workflowScores) => { + if (!config || !config.workflows || !workflowScores) { + return null + } + + const componentScores = {} + let weightedSum = 0 + let totalWeight = 0 + + config.workflows.forEach(w => { + const score = workflowScores[w.workflowId] + if (score !== undefined && score !== null) { + componentScores[w.workflowId] = { + score, + weight: w.weightPercent, + weighted: (score * w.weightPercent) / 100 + } + weightedSum += componentScores[w.workflowId].weighted + totalWeight += w.weightPercent + } + }) + + const totalScore = totalWeight > 0 ? (weightedSum / totalWeight) * 100 : 0 + + // Check gating + let passesGating = true + const gatingWorkflows = config.workflows.filter(w => w.isGating) + if (gatingWorkflows.length > 0 && config.mode === 'AI_GATING') { + passesGating = gatingWorkflows.every(w => { + const score = workflowScores[w.workflowId] + return score !== undefined && score !== null && score >= config.minPassingThreshold + }) + } + + return { + totalScore: Math.round(totalScore * 100) / 100, + componentScores, + passesGating, + passesThreshold: totalScore >= config.minPassingThreshold, + status: passesGating && totalScore >= config.minPassingThreshold ? 'PASSED' : 'FAILED' + } +} + +/** + * Get summary statistics for a config + * + * @param {Object} config - The config to analyze + * @returns {Object} - Summary information + */ +export const getConfigSummary = (config) => { + if (!config) { + return null + } + + const gatingWorkflows = config.workflows ? config.workflows.filter(w => w.isGating) : [] + const scoringWorkflows = config.workflows ? config.workflows.filter(w => !w.isGating) : [] + + return { + challengeId: config.challengeId, + mode: config.mode, + modeLabel: config.mode === 'AI_ONLY' ? 'AI Only' : 'AI Gating', + minPassingThreshold: config.minPassingThreshold, + autoFinalize: config.autoFinalize, + totalWorkflows: config.workflows ? config.workflows.length : 0, + gatingWorkflows: gatingWorkflows.length, + scoringWorkflows: scoringWorkflows.length, + totalWeight: config.workflows + ? config.workflows.reduce((sum, w) => sum + (w.weightPercent || 0), 0) + : 0, + createdAt: config.createdAt, + createdBy: config.createdBy || 'Unknown', + updatedAt: config.updatedAt, + updatedBy: config.updatedBy || 'Unknown' + } +} + +/** + * Compare two configs and show differences + * + * @param {Object} original - Original config + * @param {Object} updated - Updated config + * @returns {Object} - Differences found + */ +export const compareConfigs = (original, updated) => { + const differences = { + settings: {}, + workflows: { added: [], removed: [], modified: [] } + } + + // Compare settings + const settingFields = ['minPassingThreshold', 'mode', 'autoFinalize'] + settingFields.forEach(field => { + if (original[field] !== updated[field]) { + differences.settings[field] = { + from: original[field], + to: updated[field] + } + } + }) + + // Compare workflows + if (original.workflows && updated.workflows) { + const updatedWorkflowIds = new Set(updated.workflows.map(w => w.workflowId)) + original.workflows.forEach(w => { + if (!updatedWorkflowIds.has(w.workflowId)) { + differences.workflows.removed.push(w) + } + }) + + const originalWorkflowMap = new Map(original.workflows.map(w => [w.workflowId, w])) + updated.workflows.forEach(w => { + const origW = originalWorkflowMap.get(w.workflowId) + if (!origW) { + differences.workflows.added.push(w) + } else if ( + origW.weightPercent !== w.weightPercent || + origW.isGating !== w.isGating + ) { + differences.workflows.modified.push({ + workflowId: w.workflowId, + changes: { + weightPercent: { from: origW.weightPercent, to: w.weightPercent }, + isGating: { from: origW.isGating, to: w.isGating } + } + }) + } + }) + } + + return differences +} diff --git a/src/services/aiReviewConfigs.js b/src/services/aiReviewConfigs.js new file mode 100644 index 00000000..8af93810 --- /dev/null +++ b/src/services/aiReviewConfigs.js @@ -0,0 +1,121 @@ +import _ from 'lodash' +import { axiosInstance } from './axiosWithAuth' + +const { AI_REVIEW_CONFIGS_API_URL } = process.env + +/** + * Create a new AI review config for a challenge + * @param {Object} configData - The config data (challengeId, minPassingThreshold, mode, workflows, etc.) + * @returns {Promise} + */ +export async function createAIReviewConfig (configData) { + try { + // Validate required fields + if (!configData.challengeId) { + throw new Error('Challenge ID is required') + } + + if (configData.minPassingThreshold === undefined || configData.minPassingThreshold === null) { + throw new Error('minPassingThreshold is required') + } + + if (!configData.mode || !['AI_GATING', 'AI_ONLY'].includes(configData.mode)) { + throw new Error('Valid mode (AI_GATING or AI_ONLY) is required') + } + + if (!configData.workflows || configData.workflows.length === 0) { + throw new Error('At least one workflow is required') + } + + // Validate workflow IDs + const invalidWorkflows = configData.workflows.filter(w => !w.workflowId || w.workflowId.trim() === '') + if (invalidWorkflows.length > 0) { + throw new Error('All workflows must have valid workflow IDs') + } + + const response = await axiosInstance.post( + `${AI_REVIEW_CONFIGS_API_URL}/ai-review/configs`, + configData + ) + return _.get(response, 'data', {}) + } catch (error) { + console.error('Error creating AI review config:', error.message) + throw error + } +} + +/** + * Fetch AI review config for a specific challenge + * @param {String} challengeId - The ID of the challenge + * @returns {Promise} + */ +export async function fetchAIReviewConfigByChallenge (challengeId) { + try { + if (!challengeId || challengeId.trim() === '') { + throw new Error('Challenge ID is required') + } + + const response = await axiosInstance.get( + `${AI_REVIEW_CONFIGS_API_URL}/ai-review/configs/${challengeId}` + ) + return _.get(response, 'data', null) + } catch (error) { + // 404 is expected when no config exists yet + if (error.response && error.response.status === 404) { + return null + } + console.error(`Error fetching AI review config for challenge ${challengeId}:`, error.message) + throw error + } +} + +/** + * Update an existing AI review config + * @param {String} configId - The ID of the config to update + * @param {Object} configData - The updated config data + * @returns {Promise} + */ +export async function updateAIReviewConfig (configId, configData) { + try { + if (!configId || configId.trim() === '') { + throw new Error('Config ID is required') + } + + // Validate workflow IDs if being updated + if (configData.workflows && configData.workflows.length > 0) { + const invalidWorkflows = configData.workflows.filter(w => !w.workflowId || w.workflowId.trim() === '') + if (invalidWorkflows.length > 0) { + throw new Error('All workflows must have valid workflow IDs') + } + } + + const response = await axiosInstance.put( + `${AI_REVIEW_CONFIGS_API_URL}/ai-review/configs/${configId}`, + configData + ) + return _.get(response, 'data', {}) + } catch (error) { + console.error(`Error updating AI review config ${configId}:`, error.message) + throw error + } +} + +/** + * Delete an AI review config + * @param {String} configId - The ID of the config to delete + * @returns {Promise} + */ +export async function deleteAIReviewConfig (configId) { + try { + if (!configId || configId.trim() === '') { + throw new Error('Config ID is required') + } + + await axiosInstance.delete( + `${AI_REVIEW_CONFIGS_API_URL}/ai-review/configs/${configId}` + ) + } catch (error) { + console.error(`Error deleting AI review config ${configId}:`, error.message) + throw error + } +} diff --git a/src/services/aiReviewTemplateHelpers.js b/src/services/aiReviewTemplateHelpers.js new file mode 100644 index 00000000..ad5570e4 --- /dev/null +++ b/src/services/aiReviewTemplateHelpers.js @@ -0,0 +1,225 @@ +/** + * AI Review Template Integration Utilities + * + * Helper functions to integrate AI review templates into React components. + */ + +import * as templateService from '../aiReviewTemplates' + +/** + * Hook-like function to manage AI review templates + * Can be adapted to a custom hook (useAIReviewTemplates) + */ +export const createTemplateManager = (useDevConfig = false) => { + // In development, you can set useDevConfig = true to use mock data + const service = useDevConfig + ? require('./aiReviewTemplates.mock') + : templateService + + return { + // CRUD operations + fetchAll: (filters) => service.mockFetchAIReviewTemplates + ? service.mockFetchAIReviewTemplates(filters) + : service.fetchAIReviewTemplates(filters), + + fetchById: (id) => service.mockFetchAIReviewTemplate + ? service.mockFetchAIReviewTemplate(id) + : service.fetchAIReviewTemplate(id), + + create: (data) => service.mockCreateAIReviewTemplate + ? service.mockCreateAIReviewTemplate(data) + : service.createAIReviewTemplate(data), + + update: (id, data) => service.mockUpdateAIReviewTemplate + ? service.mockUpdateAIReviewTemplate(id, data) + : service.updateAIReviewTemplate(id, data), + + delete: (id) => service.mockDeleteAIReviewTemplate + ? service.mockDeleteAIReviewTemplate(id) + : service.deleteAIReviewTemplate(id), + + fetchByTrackAndType: (track, type) => service.mockFetchTemplateByTrackAndType + ? service.mockFetchTemplateByTrackAndType(track, type) + : service.fetchTemplateByTrackAndType(track, type) + } +} + +/** + * Transform a template to a challenge AI review config + * + * @param {Object} template - The template to transform + * @param {String} challengeId - The challenge ID + * @returns {Object} - The resulting config + */ +export const transformTemplateToConfig = (template, challengeId) => { + if (!template) { + return null + } + + return { + id: `config_${Date.now()}`, + challengeId, + minPassingThreshold: template.minPassingThreshold, + autoFinalize: template.autoFinalize, + mode: template.mode, + formula: template.formula, + workflows: template.workflows.map(w => ({ + id: `config_wf_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + configId: `config_${Date.now()}`, + workflowId: w.workflowId, + weightPercent: w.weightPercent, + isGating: w.isGating, + createdAt: new Date().toISOString(), + createdBy: 'current_user' + })), + createdAt: template.createdAt, + createdBy: template.createdBy, + updatedAt: new Date().toISOString(), + updatedBy: 'current_user' + } +} + +/** + * Format a template for display in UI + * + * @param {Object} template - The template to format + * @returns {Object} - Formatted template + */ +export const formatTemplateForDisplay = (template) => { + if (!template) { + return null + } + + return { + ...template, + modeLabel: template.mode === 'AI_ONLY' ? 'AI Only Review' : 'AI Gating + Human Review', + workflowCount: template.workflows ? template.workflows.length : 0, + totalWeight: template.workflows + ? template.workflows.reduce((sum, w) => sum + (w.weightPercent || 0), 0) + : 0, + displayName: `${template.title} (${template.challengeTrack}/${template.challengeType})` + } +} + +/** + * Validate template data before submission + * + * @param {Object} templateData - The template data to validate + * @returns {Object} - { isValid: boolean, errors: Array } + */ +export const validateTemplateData = (templateData) => { + const errors = [] + + // Required fields + if (!templateData.challengeTrack || templateData.challengeTrack.trim() === '') { + errors.push('Challenge Track is required') + } + + if (!templateData.challengeType || templateData.challengeType.trim() === '') { + errors.push('Challenge Type is required') + } + + if (!templateData.title || templateData.title.trim() === '') { + errors.push('Title is required') + } + + if (templateData.minPassingThreshold === undefined || templateData.minPassingThreshold === null) { + errors.push('Min Passing Threshold is required') + } else if (templateData.minPassingThreshold < 0 || templateData.minPassingThreshold > 100) { + errors.push('Min Passing Threshold must be between 0 and 100') + } + + // Validate mode + if (![' AI_GATING', 'AI_ONLY'].includes(templateData.mode)) { + errors.push('Invalid Review Mode selected') + } + + // Validate workflows + if (!templateData.workflows || templateData.workflows.length === 0) { + errors.push('At least one workflow is required') + } else { + // Check workflow IDs + const invalidWorkflows = templateData.workflows.filter( + w => !w.workflowId || w.workflowId.trim() === '' + ) + if (invalidWorkflows.length > 0) { + errors.push(`${invalidWorkflows.length} workflow(s) are missing Workflow ID`) + } + + // Check weights + const totalWeight = templateData.workflows.reduce((sum, w) => sum + (w.weightPercent || 0), 0) + if (Math.abs(totalWeight - 100) > 0.01) { + errors.push(`Workflow weights must sum to 100% (currently ${totalWeight.toFixed(2)}%)`) + } + } + + return { + isValid: errors.length === 0, + errors + } +} + +/** + * Compare two templates and show differences + * + * @param {Object} original - Original template + * @param {Object} updated - Updated template + * @returns {Object} - Differences found + */ +export const compareTemplates = (original, updated) => { + const differences = { + header: {}, + workflows: { added: [], removed: [], modified: [] }, + formula: null + } + + // Compare header fields + const headerFields = ['title', 'description', 'minPassingThreshold', 'mode', 'autoFinalize'] + headerFields.forEach(field => { + if (original[field] !== updated[field]) { + differences.header[field] = { + from: original[field], + to: updated[field] + } + } + }) + + // Compare workflows + if (original.workflows && updated.workflows) { + const updatedWorkflowIds = new Set(updated.workflows.map(w => w.workflowId)) + original.workflows.forEach(w => { + if (!updatedWorkflowIds.has(w.workflowId)) { + differences.workflows.removed.push(w) + } + }) + + const originalWorkflowMap = new Map(original.workflows.map(w => [w.workflowId, w])) + updated.workflows.forEach(w => { + const origW = originalWorkflowMap.get(w.workflowId) + if (!origW) { + differences.workflows.added.push(w) + } else if ( + origW.weightPercent !== w.weightPercent || + origW.isGating !== w.isGating + ) { + differences.workflows.modified.push({ + workflowId: w.workflowId, + changes: { + weightPercent: { from: origW.weightPercent, to: w.weightPercent }, + isGating: { from: origW.isGating, to: w.isGating } + } + }) + } + }) + } + + // Compare formula + if (JSON.stringify(original.formula) !== JSON.stringify(updated.formula)) { + differences.formula = { + from: original.formula, + to: updated.formula + } + } + + return differences +} diff --git a/src/services/aiReviewTemplates.js b/src/services/aiReviewTemplates.js new file mode 100644 index 00000000..e4d9ec4d --- /dev/null +++ b/src/services/aiReviewTemplates.js @@ -0,0 +1,127 @@ +import _ from 'lodash' +import qs from 'qs' +import { axiosInstance } from './axiosWithAuth' + +const { AI_REVIEW_TEMPLATES_API_URL } = process.env + +/** + * Fetch all AI review templates with optional filters + * @param {Object} filters - Filter options (challengeTrack, challengeType) + * @returns {Promise} + */ +export async function fetchAIReviewTemplates (filters = {}) { + try { + const queryString = Object.keys(filters).length > 0 + ? `?${qs.stringify(filters, { encode: false })}` + : '' + const response = await axiosInstance.get(`${AI_REVIEW_TEMPLATES_API_URL}/ai-review/templates${queryString}`) + return _.get(response, 'data', []) + } catch (error) { + console.error('Error fetching AI review templates:', error.message) + throw error + } +} + +/** + * Fetch a specific AI review template by ID + * @param {String} templateId - The ID of the template to fetch + * @returns {Promise} + */ +export async function fetchAIReviewTemplate (templateId) { + try { + const response = await axiosInstance.get(`${AI_REVIEW_TEMPLATES_API_URL}/ai-review/templates/${templateId}`) + return _.get(response, 'data', {}) + } catch (error) { + console.error(`Error fetching AI review template ${templateId}:`, error.message) + throw error + } +} + +/** + * Create a new AI review template + * @param {Object} templateData - The template data + * @returns {Promise} + */ +export async function createAIReviewTemplate (templateData) { + try { + // Validate required workflow IDs exist + if (templateData.workflows && templateData.workflows.length > 0) { + const workflowIds = templateData.workflows.map(w => w.workflowId) + // Note: In a real scenario, you'd validate these IDs against the workflows API + if (!Array.isArray(workflowIds) || workflowIds.some(id => !id || id.trim() === '')) { + throw new Error('All workflows must have valid IDs') + } + } + + const response = await axiosInstance.post( + `${AI_REVIEW_TEMPLATES_API_URL}/ai-review/templates`, + templateData + ) + return _.get(response, 'data', {}) + } catch (error) { + console.error('Error creating AI review template:', error.message) + throw error + } +} + +/** + * Update an existing AI review template + * @param {String} templateId - The ID of the template to update + * @param {Object} templateData - The updated template data + * @returns {Promise} + */ +export async function updateAIReviewTemplate (templateId, templateData) { + try { + // Validate required workflow IDs exist if workflows are being updated + if (templateData.workflows && templateData.workflows.length > 0) { + const workflowIds = templateData.workflows.map(w => w.workflowId) + if (!Array.isArray(workflowIds) || workflowIds.some(id => !id || id.trim() === '')) { + throw new Error('All workflows must have valid IDs') + } + } + + const response = await axiosInstance.put( + `${AI_REVIEW_TEMPLATES_API_URL}/ai-review/templates/${templateId}`, + templateData + ) + return _.get(response, 'data', {}) + } catch (error) { + console.error(`Error updating AI review template ${templateId}:`, error.message) + throw error + } +} + +/** + * Delete an AI review template + * @param {String} templateId - The ID of the template to delete + * @returns {Promise} + */ +export async function deleteAIReviewTemplate (templateId) { + try { + await axiosInstance.delete(`${AI_REVIEW_TEMPLATES_API_URL}/ai-review/templates/${templateId}`) + } catch (error) { + console.error(`Error deleting AI review template ${templateId}:`, error.message) + throw error + } +} + +/** + * Fetch and filter AI review templates by challenge track and type + * @param {String} challengeTrack - The challenge track + * @param {String} challengeType - The challenge type + * @returns {Promise} + */ +export async function fetchTemplateByTrackAndType (challengeTrack, challengeType) { + try { + const filters = { + challengeTrack, + challengeType + } + const templates = await fetchAIReviewTemplates(filters) + // Return the first matching template or null if none found + return templates.length > 0 ? templates[0] : null + } catch (error) { + console.error(`Error fetching template for ${challengeTrack}/${challengeType}:`, error.message) + throw error + } +} diff --git a/src/services/mocks/aiReviewConfigs.mock.js b/src/services/mocks/aiReviewConfigs.mock.js new file mode 100644 index 00000000..456820bd --- /dev/null +++ b/src/services/mocks/aiReviewConfigs.mock.js @@ -0,0 +1,203 @@ +/** + * Mock AI Review Configs Service for testing + * + * This module provides mock responses for AI review configs. + * Used for development and testing without making actual API calls. + */ + +import configData from '../../mock-data/ai-review-config.json' + +/** + * Simulates a delay for API calls + */ +const mockDelay = (ms = 500) => new Promise(resolve => setTimeout(resolve, ms)) + +// In-memory store for configs (persists during current session) +let configsStore = { + [configData.challengeId]: JSON.parse(JSON.stringify(configData)) +} + +/** + * Mock: Create a new AI review config + */ +export const mockCreateAIReviewConfig = async (configData) => { + await mockDelay() + + // Validate required fields + if (!configData.challengeId) { + throw new Error('Challenge ID is required') + } + + if (configData.minPassingThreshold === undefined || configData.minPassingThreshold === null) { + throw new Error('minPassingThreshold is required') + } + + if (!configData.mode || !['AI_GATING', 'AI_ONLY'].includes(configData.mode)) { + throw new Error('Valid mode (AI_GATING or AI_ONLY) is required') + } + + if (!configData.workflows || configData.workflows.length === 0) { + throw new Error('At least one workflow is required') + } + + // Validate workflow IDs + const invalidWorkflows = configData.workflows.filter(w => !w.workflowId || w.workflowId.trim() === '') + if (invalidWorkflows.length > 0) { + throw new Error('All workflows must have valid workflow IDs') + } + + // Check if config already exists for this challenge + if (configsStore[configData.challengeId]) { + throw new Error(`Config already exists for challenge ${configData.challengeId}. Use update instead.`) + } + + const newConfig = { + id: `config_${Date.now()}`, + ...configData, + workflows: configData.workflows.map((w, idx) => ({ + id: `config_wf_${Date.now()}_${idx}`, + configId: `config_${Date.now()}`, + workflowId: w.workflowId, + weightPercent: w.weightPercent, + isGating: w.isGating || false, + createdAt: new Date().toISOString(), + createdBy: 'current_user' + })), + createdAt: new Date().toISOString(), + createdBy: 'current_user', + updatedAt: new Date().toISOString(), + updatedBy: 'current_user' + } + + configsStore[configData.challengeId] = newConfig + return newConfig +} + +/** + * Mock: Fetch config by challenge ID + */ +export const mockFetchAIReviewConfigByChallenge = async (challengeId) => { + await mockDelay() + + if (!challengeId || challengeId.trim() === '') { + throw new Error('Challenge ID is required') + } + + const config = configsStore[challengeId] + return config || null +} + +/** + * Mock: Update a config + */ +export const mockUpdateAIReviewConfig = async (configId, configUpdateData) => { + await mockDelay() + + if (!configId || configId.trim() === '') { + throw new Error('Config ID is required') + } + + // Validate workflow IDs if being updated + if (configUpdateData.workflows && configUpdateData.workflows.length > 0) { + const invalidWorkflows = configUpdateData.workflows.filter(w => !w.workflowId || w.workflowId.trim() === '') + if (invalidWorkflows.length > 0) { + throw new Error('All workflows must have valid workflow IDs') + } + } + + // Find config by ID + let foundConfig = null + let challengeIdKey = null + + for (const [cId, config] of Object.entries(configsStore)) { + if (config.id === configId) { + foundConfig = config + challengeIdKey = cId + break + } + } + + if (!foundConfig) { + throw new Error(`Config not found: ${configId}`) + } + + const updatedConfig = { + ...foundConfig, + ...configUpdateData, + id: configId, // Ensure ID doesn't change + challengeId: foundConfig.challengeId, // Ensure challenge ID doesn't change + createdAt: foundConfig.createdAt, // Preserve creation date + createdBy: foundConfig.createdBy, // Preserve creator + updatedAt: new Date().toISOString(), + updatedBy: 'current_user' + } + + // If workflows are being updated, transform them + if (configUpdateData.workflows) { + updatedConfig.workflows = configUpdateData.workflows.map((w, idx) => { + // Preserve existing workflow ID if not being changed + const existingWorkflow = foundConfig.workflows.find(ew => ew.workflowId === w.workflowId) + return { + id: existingWorkflow?.id || `config_wf_${Date.now()}_${idx}`, + configId: configId, + workflowId: w.workflowId, + weightPercent: w.weightPercent, + isGating: w.isGating || false, + createdAt: existingWorkflow?.createdAt || new Date().toISOString(), + createdBy: existingWorkflow?.createdBy || 'current_user' + } + }) + } + + configsStore[challengeIdKey] = updatedConfig + return updatedConfig +} + +/** + * Mock: Delete a config + */ +export const mockDeleteAIReviewConfig = async (configId) => { + await mockDelay() + + if (!configId || configId.trim() === '') { + throw new Error('Config ID is required') + } + + let found = false + + for (const [cId, config] of Object.entries(configsStore)) { + if (config.id === configId) { + delete configsStore[cId] + found = true + break + } + } + + if (!found) { + throw new Error(`Config not found: ${configId}`) + } +} + +/** + * Mock: Get all configs (utility function for testing) + */ +export const mockGetAllConfigs = async () => { + await mockDelay(100) + return Object.values(configsStore) +} + +/** + * Mock: Clear all configs (utility function for tests cleanup) + */ +export const mockClearAllConfigs = () => { + configsStore = {} +} + +/** + * Mock: Reset to initial state + */ +export const mockResetConfigs = () => { + configsStore = { + [configData.challengeId]: JSON.parse(JSON.stringify(configData)) + } +} diff --git a/src/services/mocks/aiReviewTemplates.mock.js b/src/services/mocks/aiReviewTemplates.mock.js new file mode 100644 index 00000000..54c42871 --- /dev/null +++ b/src/services/mocks/aiReviewTemplates.mock.js @@ -0,0 +1,191 @@ +/** + * Mock AI Review Service for testing + * + * This module provides mock responses for AI review templates and configs. + * Used for development and testing without making actual API calls. + */ + +// Import mock data +import templatesData from '../../mock-data/ai-review-templates.json' +import configData from '../../mock-data/ai-review-config.json' + +/** + * Simulates a delay for API calls + */ +const mockDelay = (ms = 500) => new Promise(resolve => setTimeout(resolve, ms)) + +/** + * Mock: Fetch all AI review templates with optional filters + */ +export const mockFetchAIReviewTemplates = async (filters = {}) => { + await mockDelay() + + let templates = [...templatesData.templates] + + // Apply filters + if (filters.challengeTrack) { + templates = templates.filter(t => t.challengeTrack === filters.challengeTrack) + } + + if (filters.challengeType) { + templates = templates.filter(t => t.challengeType === filters.challengeType) + } + + return templates +} + +/** + * Mock: Fetch a specific template by ID + */ +export const mockFetchAIReviewTemplate = async (templateId) => { + await mockDelay() + + const template = templatesData.templates.find(t => t.id === templateId) + if (!template) { + throw new Error(`Template not found: ${templateId}`) + } + + return template +} + +/** + * Mock: Fetch template by track and type + */ +export const mockFetchTemplateByTrackAndType = async (challengeTrack, challengeType) => { + await mockDelay() + + const template = templatesData.templates.find( + t => t.challengeTrack === challengeTrack && t.challengeType === challengeType + ) + + return template || null +} + +/** + * Mock: Create a new template + */ +export const mockCreateAIReviewTemplate = async (templateData) => { + await mockDelay() + + // Validate required fields + if (!templateData.challengeTrack || !templateData.challengeType) { + throw new Error('challengeTrack and challengeType are required') + } + + if (templateData.minPassingThreshold === undefined) { + throw new Error('minPassingThreshold is required') + } + + // Validate workflow IDs + if (templateData.workflows && templateData.workflows.length > 0) { + const workflowIds = templateData.workflows.map(w => w.workflowId) + if (workflowIds.some(id => !id || id.trim() === '')) { + throw new Error('All workflows must have valid IDs') + } + } + + const newTemplate = { + id: `template_${Date.now()}`, + ...templateData, + createdAt: new Date().toISOString(), + createdBy: 'current_user', + updatedAt: new Date().toISOString(), + updatedBy: 'current_user' + } + + templatesData.templates.push(newTemplate) + return newTemplate +} + +/** + * Mock: Update a template + */ +export const mockUpdateAIReviewTemplate = async (templateId, templateData) => { + await mockDelay() + + const index = templatesData.templates.findIndex(t => t.id === templateId) + if (index === -1) { + throw new Error(`Template not found: ${templateId}`) + } + + // Validate workflow IDs if being updated + if (templateData.workflows && templateData.workflows.length > 0) { + const workflowIds = templateData.workflows.map(w => w.workflowId) + if (workflowIds.some(id => !id || id.trim() === '')) { + throw new Error('All workflows must have valid IDs') + } + } + + const updatedTemplate = { + ...templatesData.templates[index], + ...templateData, + id: templateId, // Ensure ID doesn't change + createdAt: templatesData.templates[index].createdAt, // Preserve creation date + createdBy: templatesData.templates[index].createdBy, // Preserve creator + updatedAt: new Date().toISOString(), + updatedBy: 'current_user' + } + + templatesData.templates[index] = updatedTemplate + return updatedTemplate +} + +/** + * Mock: Delete a template + */ +export const mockDeleteAIReviewTemplate = async (templateId) => { + await mockDelay() + + const index = templatesData.templates.findIndex(t => t.id === templateId) + if (index === -1) { + throw new Error(`Template not found: ${templateId}`) + } + + templatesData.templates.splice(index, 1) +} + +/** + * Mock: Fetch AI review config for a challenge + */ +export const mockFetchAIReviewConfig = async (challengeId) => { + await mockDelay() + + if (configData.challengeId === challengeId) { + return configData + } + + return null +} + +/** + * Mock: Create AI review config from template + */ +export const mockCreateAIReviewConfigFromTemplate = async (challengeId, templateId) => { + await mockDelay() + + const template = await mockFetchAIReviewTemplate(templateId) + + const newConfig = { + id: `config_${Date.now()}`, + challengeId, + minPassingThreshold: template.minPassingThreshold, + autoFinalize: template.autoFinalize, + mode: template.mode, + formula: template.formula, + workflows: template.workflows.map(w => ({ + id: `config_wf_${Date.now()}_${Math.random()}`, + configId: `config_${Date.now()}`, + workflowId: w.workflowId, + weightPercent: w.weightPercent, + isGating: w.isGating, + createdAt: new Date().toISOString(), + createdBy: 'current_user' + })), + createdAt: new Date().toISOString(), + createdBy: 'current_user', + updatedAt: new Date().toISOString(), + updatedBy: 'current_user' + } + + return newConfig +} diff --git a/src/services/mocks/index.js b/src/services/mocks/index.js new file mode 100644 index 00000000..7c0fb2c7 --- /dev/null +++ b/src/services/mocks/index.js @@ -0,0 +1,14 @@ +/** + * Mock AI Review Services + * + * This module exports both real and mock implementations of the AI review services. + * Use the 'real' implementations for production and the 'mock' for testing/development. + */ + +// Real implementations (use axiosInstance for API calls) +export * from '../aiReviewTemplates' +export * from '../aiReviewConfigs' + +// Mock implementations (use static data) +export * as mockTemplates from './aiReviewTemplates.mock' +export * as mockConfigs from './aiReviewConfigs.mock' From 4419e7b4f7d1801ac1b4291e7dcfbd906d8fd9eb Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 18 Feb 2026 12:11:19 +0200 Subject: [PATCH 04/16] PM-3851 #time 3h use template view --- mock-data/ai-review-templates.json | 12 +- .../ChallengeReviewer-Field/AIReviewTab.js | 583 ++++++++++++++---- .../AIReviewTab.module.scss | 481 +++++++++++++++ src/services/aiReviewConfigHelpers.js | 2 +- src/services/aiReviewTemplateHelpers.js | 4 +- src/services/mocks/aiReviewConfigs.mock.js | 2 +- src/services/mocks/aiReviewTemplates.mock.js | 4 +- 7 files changed, 942 insertions(+), 146 deletions(-) diff --git a/mock-data/ai-review-templates.json b/mock-data/ai-review-templates.json index 0fb95b98..8d91577c 100644 --- a/mock-data/ai-review-templates.json +++ b/mock-data/ai-review-templates.json @@ -2,8 +2,8 @@ "templates": [ { "id": "template_001", - "challengeTrack": "dev", - "challengeType": "code", + "challengeTrack": "Development", + "challengeType": "Challenge", "title": "Development Code Challenge - Standard", "description": "Standard AI review configuration for development track code challenges. Combines code quality and security checks with gating mechanism.", "minPassingThreshold": 75.00, @@ -61,8 +61,8 @@ }, { "id": "template_002", - "challengeTrack": "design", - "challengeType": "design", + "challengeTrack": "Design", + "challengeType": "Challenge", "title": "Design Challenge - AI Only", "description": "AI-only review for design challenges. No human review required - AI makes final decisions.", "minPassingThreshold": 70.00, @@ -110,8 +110,8 @@ }, { "id": "template_003", - "challengeTrack": "data-science", - "challengeType": "data-science", + "challengeTrack": "Data Science", + "challengeType": "Marathon Match", "title": "Data Science Challenge - Gating", "description": "Data science challenge with AI gating for submissions. AI filters low-quality submissions; humans review the rest.", "minPassingThreshold": 80.00, diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js index 859b89bb..3a2fd7fc 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js @@ -1,51 +1,76 @@ -import React, { Component } from 'react' +import React, { useState, useMemo } from 'react' import PropTypes from 'prop-types' import cn from 'classnames' -import { PrimaryButton, OutlineButton } from '../../Buttons' +import { OutlineButton } from '../../Buttons' import AIWorkflowCard from './AIWorkflowCard' +import { createTemplateManager } from '../../../services/aiReviewTemplateHelpers' +import { createConfigManager } from '../../../services/aiReviewConfigHelpers' import styles from './AIReviewTab.module.scss' -class AIReviewTab extends Component { - constructor (props) { - super(props) - this.state = { - error: null, - selectedTemplate: null, - aiReviewConfigs: [], // Will manage AI review configs separately - configurationMode: null // 'template' or 'manual' or null - } - - this.addAIReviewer = this.addAIReviewer.bind(this) - this.removeAIReviewer = this.removeAIReviewer.bind(this) - this.updateAIReviewer = this.updateAIReviewer.bind(this) - this.renderAIReviewerForm = this.renderAIReviewerForm.bind(this) - this.isInitialState = this.isInitialState.bind(this) - this.renderInitialState = this.renderInitialState.bind(this) - this.handleTemplateSelection = this.handleTemplateSelection.bind(this) - this.handleManualConfiguration = this.handleManualConfiguration.bind(this) - this.getAssignedWorkflows = this.getAssignedWorkflows.bind(this) - } +const AIReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, readOnly = false }) => { + const [error, setError] = useState(null) + const [selectedTemplate, setSelectedTemplate] = useState(null) + const [aiReviewConfigs, setAiReviewConfigs] = useState([]) + const [configurationMode, setConfigurationMode] = useState(null) + const [templates, setTemplates] = useState([]) + const [templatesLoading, setTemplatesLoading] = useState(false) + const [configuration, setConfiguration] = useState({ + mode: 'AI_GATING', + minPassingThreshold: 75, + autoFinalize: false, + workflows: [] + }) + + const templateManager = useMemo(() => createTemplateManager(true), []) + const configManager = useMemo(() => createConfigManager(true), []) /** * Checks if we're in the initial state: * - AI workflows are assigned (from DefaultChallengeReviewer) * - But no aiReviewConfig has been created yet */ - isInitialState () { - const { challenge } = this.props - const aiReviewers = (challenge.reviewers || []).filter(r => this.isAIReviewer(r)) + const isAIReviewer = (reviewer) => { + return reviewer && ( + (reviewer.aiWorkflowId && reviewer.aiWorkflowId.trim() !== '') || + (reviewer.isMemberReview === false) + ) + } + + const isInitialState = () => { + const aiReviewers = (challenge.reviewers || []).filter(r => isAIReviewer(r)) // Initial state: has AI reviewers but no aiReviewConfig // TODO: Update this check based on actual aiReviewConfig property once defined - return aiReviewers.length > 0 && !this.state.configurationMode + return aiReviewers.length > 0 && !configurationMode + } + + /** + * Load templates based on challenge track and type + */ + const loadTemplates = async () => { + setTemplatesLoading(true) + setError(null) + + try { + const fetchedTemplates = await templateManager.fetchAll({ + challengeTrack: challenge.track.name, + challengeType: challenge.type.name, + }) + + setTemplates(fetchedTemplates || []) + setTemplatesLoading(false) + } catch (error) { + console.error('Error loading templates:', error) + setError('Failed to load templates') + setTemplatesLoading(false) + } } /** * Get workflows assigned to this challenge */ - getAssignedWorkflows () { - const { challenge, metadata = {} } = this.props + const getAssignedWorkflows = () => { const { workflows = [] } = metadata - const aiReviewers = (challenge.reviewers || []).filter(r => this.isAIReviewer(r)) + const aiReviewers = (challenge.reviewers || []).filter(r => isAIReviewer(r)) return aiReviewers.map(reviewer => { const workflow = workflows.find(w => w.id === reviewer.aiWorkflowId) @@ -57,16 +82,325 @@ class AIReviewTab extends Component { }) } - handleTemplateSelection () { - this.setState({ configurationMode: 'template' }) + const handleTemplateSelection = () => { + setConfigurationMode('template') + // Load templates after setting the mode + loadTemplates() + } + + const handleManualConfiguration = () => { + setConfigurationMode('manual') + } + + const handleSwitchConfigurationMode = (newMode) => { + setConfigurationMode(newMode) + setSelectedTemplate(null) + setConfiguration({ + mode: 'AI_GATING', + minPassingThreshold: 75, + autoFinalize: false, + workflows: [] + }) + + if (newMode === 'template') { + loadTemplates() + } + } + + const handleTemplateChange = (templateId) => { + const template = templates.find(t => t.id === templateId) + + if (template) { + setSelectedTemplate(template) + applyTemplateToConfiguration(template) + } + } + + const applyTemplateToConfiguration = (template) => { + if (!template) return + + const newConfiguration = { + mode: template.mode || 'AI_GATING', + minPassingThreshold: template.minPassingThreshold || 75, + autoFinalize: template.autoFinalize || false, + workflows: template.workflows || [] + } + + setConfiguration(newConfiguration) + } + + const updateConfiguration = (field, value) => { + setConfiguration(prev => ({ + ...prev, + [field]: value + })) + } + + const renderTemplateConfiguration = () => { + const { workflows: availableWorkflows = [] } = metadata + + return ( +
+ {/* Configuration Source Selector */} +
+

Configuration Source:

+
+ + + {!readOnly && ( + + )} +
+
+ + {/* Template Selection Section */} +
+

📋 AI Review Template

+ +
+ +
+ + {selectedTemplate && ( +
+

{selectedTemplate.description}

+
+ )} +
+ + {/* Review Settings Section */} + {selectedTemplate && ( +
+

⚙️ Review Settings

+ +
+ {/* Review Mode */} +
+ + +

+ {configuration.mode === 'AI_GATING' + ? 'AI gates low-quality submissions; humans review the rest.' + : 'AI makes the final decision on all submissions.'} +

+
+ + {/* Auto-Finalize */} +
+ + +

+ Only available in AI_ONLY mode +

+
+
+ + {/* Min Passing Threshold Slider */} +
+ +
+ updateConfiguration('minPassingThreshold', parseInt(e.target.value, 10))} + disabled={readOnly} + className={styles.slider} + /> + + {configuration.minPassingThreshold} % + +
+
+
+ )} + + {/* AI Workflows Section */} + {selectedTemplate && configuration.workflows && configuration.workflows.length > 0 && ( +
+

AI Workflows (from template)

+ +
+ + + + + + + + + + + {configuration.workflows.map((workflow, index) => { + const isAssigned = (challenge.reviewers || []).some(r => + isAIReviewer(r) && r.aiWorkflowId === workflow.workflowId + ) + const workflowDetails = workflow.workflow || {} + + return ( + + + + + + + ) + })} + +
WorkflowWeightTypeChallenge Match
+ 🤖 + {workflowDetails.name} +
+ {workflowDetails.description} +
+
+ {workflow.weightPercent}% + + {workflow.isGating ? ( + ⚡GATE + ) : ( + ✓ Review + )} + + {isAssigned ? ( + ✅ Assigned + ) : ( + ⚠️ Not assigned + )} +
+
+ +

+ ✅ = Also assigned as AI Reviewer in this challenge
+ ⚠️ = Not assigned — will be auto-added on save +

+
+ )} + + {/* Summary Section */} + {selectedTemplate && ( +
+

Summary

+
+
+

Mode

+
{configuration.mode}
+
+
+

Threshold

+
{configuration.minPassingThreshold}%
+
+
+

Workflows

+
+ {configuration.workflows.length} total + {configuration.workflows.some(w => w.isGating) && ( +
+ {configuration.workflows.filter(w => w.isGating).length} gating +
+ )} +
+
+
+
+ )} + + {/* Remove Configuration Button */} + {!readOnly && selectedTemplate && ( +
+ +
+ )} + + {templatesLoading && ( +
Loading templates...
+ )} +
+ ) } - handleManualConfiguration () { - this.setState({ configurationMode: 'manual' }) + const removeAIReviewer = (index) => { + const currentReviewers = challenge.reviewers || [] + + // Map the AI reviewer index to the actual index in the full reviewers array + const aiReviewers = currentReviewers.filter(r => isAIReviewer(r)) + const reviewerToRemove = aiReviewers[index] + const actualIndex = currentReviewers.indexOf(reviewerToRemove) + + if (actualIndex !== -1) { + const updatedReviewers = currentReviewers.filter((_, i) => i !== actualIndex) + onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) + } } - renderInitialState () { - const assignedWorkflows = this.getAssignedWorkflows() + const renderInitialState = () => { + const assignedWorkflows = getAssignedWorkflows() return (
@@ -85,7 +419,7 @@ class AIReviewTab extends Component {

Pre-fill from a standard config for this track & type.

@@ -96,7 +430,7 @@ class AIReviewTab extends Component {

Set up each workflow weight, mode, and threshold yourself.

@@ -114,7 +448,7 @@ class AIReviewTab extends Component { workflow={item.workflow || { name: item.reviewer.aiWorkflowId }} scorecardId={item.scorecardId} description='' - onRemove={() => this.removeAIReviewer(index)} + onRemove={() => removeAIReviewer(index)} readOnly={false} /> ))} @@ -124,8 +458,7 @@ class AIReviewTab extends Component { ) } - addAIReviewer () { - const { challenge, onUpdateReviewers, metadata = {} } = this.props + const addAIReviewer = () => { const { workflows = [] } = metadata const currentReviewers = challenge.reviewers || [] @@ -156,37 +489,21 @@ class AIReviewTab extends Component { aiWorkflowId: (defaultWorkflow && defaultWorkflow.id) || '' } - if (this.state.error) { - this.setState({ error: null }) + if (error) { + setError(null) } const updatedReviewers = currentReviewers.concat([newReviewer]) onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) } - removeAIReviewer (index) { - const { challenge, onUpdateReviewers } = this.props - const currentReviewers = challenge.reviewers || [] - - // Map the AI reviewer index to the actual index in the full reviewers array - const aiReviewers = currentReviewers.filter(r => this.isAIReviewer(r)) - const reviewerToRemove = aiReviewers[index] - const actualIndex = currentReviewers.indexOf(reviewerToRemove) - - if (actualIndex !== -1) { - const updatedReviewers = currentReviewers.filter((_, i) => i !== actualIndex) - onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) - } - } - - updateAIReviewer (index, field, value) { - const { challenge, onUpdateReviewers, metadata = {} } = this.props + const updateAIReviewer = (index, field, value) => { const currentReviewers = challenge.reviewers || [] const updatedReviewers = currentReviewers.slice() const fieldUpdate = { [field]: value } // Map the AI reviewer index to the actual index in the full reviewers array - const aiReviewers = currentReviewers.filter(r => this.isAIReviewer(r)) + const aiReviewers = currentReviewers.filter(r => isAIReviewer(r)) const reviewerToUpdate = aiReviewers[index] const actualIndex = currentReviewers.indexOf(reviewerToUpdate) @@ -204,14 +521,7 @@ class AIReviewTab extends Component { onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) } - isAIReviewer (reviewer) { - return reviewer && ( - (reviewer.aiWorkflowId && reviewer.aiWorkflowId.trim() !== '') || - (reviewer.isMemberReview === false) - ) - } - - validateAIReviewer (reviewer) { + const validateAIReviewer = (reviewer) => { const errors = {} if (!reviewer.aiWorkflowId || reviewer.aiWorkflowId.trim() === '') { @@ -225,10 +535,9 @@ class AIReviewTab extends Component { return errors } - renderAIReviewerForm (reviewer, index) { - const { challenge, metadata = {}, readOnly = false } = this.props + const renderAIReviewerForm = (reviewer, index) => { const { workflows = [] } = metadata - const validationErrors = challenge.submitTriggered ? this.validateAIReviewer(reviewer) : {} + const validationErrors = challenge.submitTriggered ? validateAIReviewer(reviewer) : {} return (
@@ -239,7 +548,7 @@ class AIReviewTab extends Component { minWidth text='Remove' type='danger' - onClick={() => this.removeAIReviewer(index)} + onClick={() => removeAIReviewer(index)} /> )}
@@ -257,7 +566,7 @@ class AIReviewTab extends Component { ) : ( this.updateAIReviewer(index, 'aiWorkflowId', e.target.value)} + onChange={(e) => updateAIReviewer(index, 'aiWorkflowId', e.target.value)} > {workflows.map(workflow => ( @@ -334,77 +643,83 @@ class AIReviewTab extends Component { ) } - render () { - const { challenge, isLoading, readOnly = false } = this.props - const { error, configurationMode } = this.state - const aiReviewers = (challenge.reviewers || []).filter(r => this.isAIReviewer(r)) - if (isLoading) { - return
Loading...
- } + const aiReviewers = (challenge.reviewers || []).filter(r => isAIReviewer(r)) - // Show initial state if workflows are assigned but no configuration mode selected yet - if (this.isInitialState()) { - return ( -
- {this.renderInitialState()} -
- ) - } + if (isLoading) { + return
Loading...
+ } + // Show template configuration if in template mode + if (configurationMode === 'template') { return (
- {!readOnly && ( -
- Configure AI-powered review workflows for this challenge. Select AI templates and assign to phases. -
- )} - - {!readOnly && aiReviewers.length === 0 && ( -
-

No AI review workflows configured. Click "Add AI Reviewer" to get started.

-
- )} + {renderTemplateConfiguration()} +
+ ) + } - {readOnly && aiReviewers.length === 0 && ( -
-

No AI review workflows configured for this challenge.

-
- )} + // Show initial state if workflows are assigned but no configuration mode selected yet + if (isInitialState()) { + return ( +
+ {renderInitialState()} +
+ ) + } - {aiReviewers.length > 0 && aiReviewers.map((reviewer, index) => - this.renderAIReviewerForm(reviewer, index) - )} + return ( +
+ {!readOnly && ( +
+ Configure AI-powered review workflows for this challenge. Select AI templates and assign to phases. +
+ )} - {aiReviewers.length > 0 && ( -
-

AI Review Summary

-
- Total AI Workflows: - {aiReviewers.length} -
-
- )} + {!readOnly && aiReviewers.length === 0 && ( +
+

No AI review workflows configured. Click "Add AI Reviewer" to get started.

+
+ )} - {!readOnly && ( -
- + {readOnly && aiReviewers.length === 0 && ( +
+

No AI review workflows configured for this challenge.

+
+ )} + + {aiReviewers.length > 0 && aiReviewers.map((reviewer, index) => + renderAIReviewerForm(reviewer, index) + )} + + {aiReviewers.length > 0 && ( +
+

AI Review Summary

+
+ Total AI Workflows: + {aiReviewers.length}
- )} +
+ )} + + {!readOnly && ( +
+ +
+ )} - {error && !isLoading && ( -
- {error} -
- )} -
- ) - } + {error && !isLoading && ( +
+ {error} +
+ )} +
+ ) } AIReviewTab.propTypes = { diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.module.scss index 7f2c0344..1a3a5527 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.module.scss +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.module.scss @@ -136,4 +136,485 @@ @media (max-width: 768px) { grid-template-columns: 1fr; } +} + +// Template Configuration Styles +.templateConfiguration { + display: flex; + flex-direction: column; + gap: 32px; +} + +.configurationSourceSelector { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + background-color: #f5f5f5; + border-radius: 8px; + + h4 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #333; + } +} + +.sourceOptions { + display: flex; + align-items: center; + gap: 20px; + + .radioLabel { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 14px; + color: #555; + + input[type='radio'] { + cursor: pointer; + } + } + + .switchButton { + margin-left: auto; + padding: 6px 16px; + background-color: #0066cc; + color: white; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.3s ease; + + &:hover { + background-color: #0052a3; + } + } +} + +.templateSection { + display: flex; + flex-direction: column; + gap: 16px; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #333; + } +} + +.templateSelector { + display: flex; + flex-direction: column; + gap: 8px; + + .templateDropdown { + padding: 10px 12px; + border: 1px solid #d0d0d0; + border-radius: 4px; + font-size: 14px; + background-color: white; + cursor: pointer; + transition: border-color 0.3s ease; + + &:hover:not(:disabled) { + border-color: #0066cc; + } + + &:focus { + outline: none; + border-color: #0066cc; + box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1); + } + + &:disabled { + background-color: #f5f5f5; + cursor: not-allowed; + color: #999; + } + } +} + +.templateDescription { + padding: 12px 16px; + background-color: #f0f8ff; + border-left: 4px solid #0066cc; + border-radius: 4px; + font-size: 13px; + color: #555; + line-height: 1.6; + + p { + margin: 0; + } +} + +.reviewSettingsSection { + display: flex; + flex-direction: column; + gap: 20px; + padding: 20px; + background-color: #fafafa; + border-radius: 8px; + border: 1px solid #e0e0e0; + + h3 { + margin: 0 0 8px 0; + font-size: 16px; + font-weight: 600; + color: #333; + } +} + +.settingsGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +} + +.setting { + display: flex; + flex-direction: column; + gap: 8px; + + label { + font-size: 14px; + font-weight: 500; + color: #333; + } + + select { + padding: 8px 10px; + border: 1px solid #d0d0d0; + border-radius: 4px; + font-size: 14px; + background-color: white; + cursor: pointer; + + &:disabled { + background-color: #f5f5f5; + cursor: not-allowed; + color: #999; + } + } + + .checkboxLabel { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 14px; + color: #555; + + input[type='checkbox'] { + cursor: pointer; + } + } + + .modeInfo, + .autoFinalizeInfo { + margin: 0; + font-size: 12px; + color: #888; + font-style: italic; + } +} + +.thresholdSection { + display: flex; + flex-direction: column; + gap: 12px; + + label { + font-size: 14px; + font-weight: 500; + color: #333; + } + + .thresholdSlider { + display: flex; + align-items: center; + gap: 12px; + + .slider { + flex: 1; + height: 6px; + border-radius: 3px; + background: linear-gradient(to right, #d0d0d0 0%, #d0d0d0 100%); + outline: none; + -webkit-appearance: none; + appearance: none; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #0066cc; + cursor: pointer; + transition: background-color 0.3s ease; + + &:hover { + background-color: #0052a3; + } + } + + &::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #0066cc; + cursor: pointer; + border: none; + transition: background-color 0.3s ease; + + &:hover { + background-color: #0052a3; + } + } + } + + .thresholdValue { + font-weight: 600; + color: #0066cc; + min-width: 50px; + text-align: right; + font-size: 14px; + } + } +} + +.workflowsSection { + display: flex; + flex-direction: column; + gap: 16px; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #333; + + .workflowsNote { + font-size: 12px; + font-weight: 400; + color: #888; + margin-left: 8px; + } + } +} + +.workflowsTable { + overflow-x: auto; + + table { + width: 100%; + border-collapse: collapse; + border: 1px solid #e0e0e0; + border-radius: 4px; + + thead { + background-color: #f5f5f5; + + th { + padding: 12px; + text-align: left; + font-size: 13px; + font-weight: 600; + color: #333; + border-bottom: 2px solid #e0e0e0; + } + } + + tbody { + tr { + border-bottom: 1px solid #e0e0e0; + transition: background-color 0.2s ease; + + &:hover { + background-color: #fafafa; + } + + &:last-child { + border-bottom: none; + } + + td { + padding: 12px; + font-size: 14px; + color: #555; + vertical-align: top; + + &.weight { + font-weight: 600; + color: #0066cc; + } + + &.type { + .gatingBadge { + display: inline-block; + padding: 2px 8px; + background-color: #fff3cd; + border: 1px solid #ffc107; + border-radius: 3px; + font-size: 12px; + font-weight: 500; + color: #856404; + } + + .normalBadge { + display: inline-block; + padding: 2px 8px; + background-color: #d1ecf1; + border: 1px solid #17a2b8; + border-radius: 3px; + font-size: 12px; + font-weight: 500; + color: #0c5460; + } + } + + &.match { + .assignedBadge { + display: inline-block; + padding: 2px 8px; + background-color: #d4edda; + border: 1px solid #28a745; + border-radius: 3px; + font-size: 12px; + font-weight: 500; + color: #155724; + } + + .notAssignedBadge { + display: inline-block; + padding: 2px 8px; + background-color: #fff3cd; + border: 1px solid #ffc107; + border-radius: 3px; + font-size: 12px; + font-weight: 500; + color: #856404; + } + } + } + } + } + } +} + +.workflowIcon { + margin-right: 6px; + font-size: 14px; +} + +.workflowName { + font-weight: 500; + color: #333; +} + +.workflowDescription { + font-size: 12px; + color: #888; + margin-top: 4px; + font-style: italic; +} + +.workflowsInfo { + font-size: 12px; + color: #888; + margin: 0; + line-height: 1.6; +} + +.summarySection { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; + background-color: #f0f8ff; + border: 1px solid #b3d9ff; + border-radius: 8px; + + h3 { + margin: 0 0 8px 0; + font-size: 16px; + font-weight: 600; + color: #333; + } +} + +.summaryGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 16px; +} + +.summaryCard { + padding: 12px; + background-color: white; + border: 1px solid #d0e8ff; + border-radius: 4px; + text-align: center; + + h4 { + margin: 0 0 8px 0; + font-size: 12px; + font-weight: 500; + color: #888; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .summaryValue { + font-size: 16px; + font-weight: 600; + color: #0066cc; + + .summarySubtext { + font-size: 12px; + color: #888; + font-weight: 400; + margin-top: 4px; + } + } +} + +.removeConfigSection { + display: flex; + justify-content: center; + padding-top: 12px; + border-top: 1px solid #e0e0e0; + + .removeConfigButton { + padding: 10px 24px; + background-color: #dc3545; + color: white; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.3s ease; + + &:hover { + background-color: #c82333; + } + + &:active { + background-color: #bd2130; + } + } } \ No newline at end of file diff --git a/src/services/aiReviewConfigHelpers.js b/src/services/aiReviewConfigHelpers.js index e1a62a0b..a8add43c 100644 --- a/src/services/aiReviewConfigHelpers.js +++ b/src/services/aiReviewConfigHelpers.js @@ -9,7 +9,7 @@ */ export const createConfigManager = (useDevConfig = false) => { const service = useDevConfig - ? require('./aiReviewConfigs.mock') + ? require('./mocks/aiReviewConfigs.mock') : require('./aiReviewConfigs') return { diff --git a/src/services/aiReviewTemplateHelpers.js b/src/services/aiReviewTemplateHelpers.js index ad5570e4..6492756c 100644 --- a/src/services/aiReviewTemplateHelpers.js +++ b/src/services/aiReviewTemplateHelpers.js @@ -4,7 +4,7 @@ * Helper functions to integrate AI review templates into React components. */ -import * as templateService from '../aiReviewTemplates' +import * as templateService from './aiReviewTemplates' /** * Hook-like function to manage AI review templates @@ -13,7 +13,7 @@ import * as templateService from '../aiReviewTemplates' export const createTemplateManager = (useDevConfig = false) => { // In development, you can set useDevConfig = true to use mock data const service = useDevConfig - ? require('./aiReviewTemplates.mock') + ? require('./mocks/aiReviewTemplates.mock') : templateService return { diff --git a/src/services/mocks/aiReviewConfigs.mock.js b/src/services/mocks/aiReviewConfigs.mock.js index 456820bd..db7ab510 100644 --- a/src/services/mocks/aiReviewConfigs.mock.js +++ b/src/services/mocks/aiReviewConfigs.mock.js @@ -5,7 +5,7 @@ * Used for development and testing without making actual API calls. */ -import configData from '../../mock-data/ai-review-config.json' +import configData from '../../../mock-data/ai-review-config.json' /** * Simulates a delay for API calls diff --git a/src/services/mocks/aiReviewTemplates.mock.js b/src/services/mocks/aiReviewTemplates.mock.js index 54c42871..6e9185b2 100644 --- a/src/services/mocks/aiReviewTemplates.mock.js +++ b/src/services/mocks/aiReviewTemplates.mock.js @@ -6,8 +6,8 @@ */ // Import mock data -import templatesData from '../../mock-data/ai-review-templates.json' -import configData from '../../mock-data/ai-review-config.json' +import templatesData from '../../../mock-data/ai-review-templates.json' +import configData from '../../../mock-data/ai-review-config.json' /** * Simulates a delay for API calls From ff154a2887159a6f184bd6f31fb39e7466ee6d11 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 19 Feb 2026 09:23:32 +0200 Subject: [PATCH 05/16] PM-3851 #3.5h manual config for ai reviewers --- .../ChallengeReviewer-Field/AIReviewTab.js | 390 +++++++++++++++++- .../AIReviewTab.module.scss | 200 +++++++++ .../ChallengeReviewer-Field/AIWorkflowCard.js | 10 +- .../AiWorkflowCard.module.scss | 5 - 4 files changed, 575 insertions(+), 30 deletions(-) diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js index 3a2fd7fc..6e1138c2 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js @@ -5,15 +5,16 @@ import { OutlineButton } from '../../Buttons' import AIWorkflowCard from './AIWorkflowCard' import { createTemplateManager } from '../../../services/aiReviewTemplateHelpers' import { createConfigManager } from '../../../services/aiReviewConfigHelpers' +import ConfirmationModal from '../../Modal/ConfirmationModal' import styles from './AIReviewTab.module.scss' const AIReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, readOnly = false }) => { const [error, setError] = useState(null) const [selectedTemplate, setSelectedTemplate] = useState(null) - const [aiReviewConfigs, setAiReviewConfigs] = useState([]) const [configurationMode, setConfigurationMode] = useState(null) const [templates, setTemplates] = useState([]) const [templatesLoading, setTemplatesLoading] = useState(false) + const [showSwitchToManualConfirm, setShowSwitchToManualConfirm] = useState(false) const [configuration, setConfiguration] = useState({ mode: 'AI_GATING', minPassingThreshold: 75, @@ -93,20 +94,31 @@ const AIReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, r } const handleSwitchConfigurationMode = (newMode) => { + if (newMode === 'manual' && configurationMode === 'template') { + setShowSwitchToManualConfirm(true) + return + } + setConfigurationMode(newMode) setSelectedTemplate(null) - setConfiguration({ - mode: 'AI_GATING', - minPassingThreshold: 75, - autoFinalize: false, - workflows: [] - }) - + if (newMode === 'template') { + setConfiguration({ + mode: 'AI_GATING', + minPassingThreshold: 75, + autoFinalize: false, + workflows: [] + }) loadTemplates() } } + const confirmSwitchToManual = () => { + setShowSwitchToManualConfirm(false) + setConfigurationMode('manual') + setSelectedTemplate(null) + } + const handleTemplateChange = (templateId) => { const template = templates.find(t => t.id === templateId) @@ -136,6 +148,48 @@ const AIReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, r })) } + const addWorkflowToConfiguration = () => { + setConfiguration(prev => ({ + ...prev, + workflows: prev.workflows.concat([ + { workflowId: '', weightPercent: 0, isGating: false } + ]) + })) + } + + const updateWorkflowInConfiguration = (index, field, value) => { + setConfiguration(prev => { + const workflows = prev.workflows.map((workflow, idx) => { + if (idx !== index) { + return workflow + } + + const nextWorkflow = { + ...workflow, + [field]: value + } + + if (field === 'isGating' && value) { + nextWorkflow.weightPercent = 0 + } + + return nextWorkflow + }) + + return { + ...prev, + workflows + } + }) + } + + const removeWorkflowFromConfiguration = (index) => { + setConfiguration(prev => ({ + ...prev, + workflows: prev.workflows.filter((_, idx) => idx !== index) + })) + } + const renderTemplateConfiguration = () => { const { workflows: availableWorkflows = [] } = metadata @@ -385,6 +439,284 @@ const AIReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, r ) } + const renderManualConfiguration = () => { + const { workflows: availableWorkflows = [] } = metadata + const scoringWorkflows = configuration.workflows.filter(workflow => !workflow.isGating) + const gatingWorkflows = configuration.workflows.filter(workflow => workflow.isGating) + const scoringTotal = scoringWorkflows.reduce((sum, workflow) => sum + (Number(workflow.weightPercent) || 0), 0) + const hasScoringWorkflows = scoringWorkflows.length > 0 + const isWeightValid = !hasScoringWorkflows || Math.abs(scoringTotal - 100) < 0.01 + const remainingWeight = Math.round((100 - scoringTotal) * 100) / 100 + const scoringSummary = hasScoringWorkflows + ? `${scoringWorkflows.map(workflow => `${Number(workflow.weightPercent) || 0}%`).join(' + ')} = ${scoringTotal}%` + : 'no scoring workflows' + + return ( +
+
+

Configuration Source:

+
+ + + {!readOnly && ( + + )} +
+
+ +
+

Review Settings

+ +
+
+ + +

+ {configuration.mode === 'AI_GATING' + ? 'AI gates low-quality submissions; humans review the rest.' + : 'AI makes the final decision on all submissions.'} +

+
+ +
+ + +

Only available in AI_ONLY mode

+
+
+ +
+ +
+ updateConfiguration('minPassingThreshold', parseInt(e.target.value, 10))} + disabled={readOnly} + className={styles.slider} + /> + + {configuration.minPassingThreshold} % + +
+
+
+ +
+

AI Workflows (editable)

+ + {configuration.workflows.map((workflow, index) => { + const workflowDetails = availableWorkflows.find(item => item.id === workflow.workflowId) || {} + const isAssigned = (challenge.reviewers || []).some(r => + isAIReviewer(r) && r.aiWorkflowId === workflow.workflowId + ) + + return ( +
+
+
Workflow {index + 1}
+ {!readOnly && ( + + )} +
+ +
+
+ + +
+ +
+
+ + updateWorkflowInConfiguration( + index, + 'weightPercent', + parseInt(e.target.value, 10) || 0 + )} + disabled={readOnly || workflow.isGating} + className={styles.weightInput} + /> +
Weight for scoring. Ignored if gating.
+
+ +
+ + +
+ {workflow.isGating ? '⚡ Pass/fail gate.' : 'Submissions below threshold are locked.'} +
+
+
+ + {workflow.workflowId && ( +
+ {isAssigned + ? 'Matched: This workflow is assigned as AI Reviewer for this challenge.' + : 'Not assigned: will be auto-added on save.'} +
+ )} + + {workflowDetails.description && ( +
{workflowDetails.description}
+ )} +
+
+ ) + })} + + {!readOnly && ( + + )} +
+ +
+

Weight Validation

+
+
+ Scoring workflows weight total: {scoringSummary} {isWeightValid ? 'OK' : 'Invalid'} +
+ {!isWeightValid && hasScoringWorkflows && ( +
+ Scoring workflow weights must total 100%. {remainingWeight > 0 + ? `Remaining: ${remainingWeight}% unassigned.` + : `Over by ${Math.abs(remainingWeight)}%.`} +
+ )} +
Gating workflows: {gatingWorkflows.length}
+
+
+ +
+

Summary

+
+
+

Mode

+
{configuration.mode}
+
+
+

Threshold

+
{configuration.minPassingThreshold}%
+
+
+

Workflows

+
+ {configuration.workflows.length} total + {configuration.workflows.some(w => w.isGating) && ( +
+ {configuration.workflows.filter(w => w.isGating).length} gating +
+ )} +
+
+
+
+ + {!readOnly && ( +
+ +
+ )} +
+ ) + } + const removeAIReviewer = (index) => { const currentReviewers = challenge.reviewers || [] @@ -443,14 +775,16 @@ const AIReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, r
{assignedWorkflows.map((item, index) => ( - removeAIReviewer(index)} - readOnly={false} - /> + <> + removeAIReviewer(index)} + readOnly={false} + /> + ))}
@@ -655,6 +989,30 @@ const AIReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, r return (
{renderTemplateConfiguration()} + {showSwitchToManualConfirm && ( + +

The template settings will be copied into editable fields.

+

You can then modify workflows, weights, and settings individually.

+

This will disconnect from the template. Future template updates will not apply.

+
+ )} + cancelText='Cancel' + confirmText='Switch to Manual' + onCancel={() => setShowSwitchToManualConfirm(false)} + onConfirm={confirmSwitchToManual} + /> + )} + + ) + } + + if (configurationMode === 'manual') { + return ( +
+ {renderManualConfiguration()}
) } diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.module.scss index 1a3a5527..57c98daa 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.module.scss +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.module.scss @@ -617,4 +617,204 @@ background-color: #bd2130; } } +} + +// Manual Configuration Styles +.manualConfiguration { + display: flex; + flex-direction: column; + gap: 32px; +} + +.manualWorkflowsSection { + display: flex; + flex-direction: column; + gap: 16px; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #333; + } +} + +.manualWorkflowCard { + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 16px; + background-color: #fafafa; + display: flex; + flex-direction: column; + gap: 16px; +} + +.manualWorkflowHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.manualWorkflowTitle { + font-weight: 600; + color: #333; +} + +.removeWorkflowButton { + padding: 6px 12px; + border-radius: 4px; + border: 1px solid #dc3545; + background-color: #fff; + color: #dc3545; + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background-color: #dc3545; + color: white; + } +} + +.manualWorkflowBody { + display: flex; + flex-direction: column; + gap: 12px; +} + +.manualWorkflowField { + display: flex; + flex-direction: column; + gap: 8px; + + label { + font-size: 13px; + font-weight: 500; + color: #333; + } +} + +.manualWorkflowRow { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.workflowSelect, +.weightInput { + padding: 8px 10px; + border: 1px solid #d0d0d0; + border-radius: 4px; + font-size: 14px; + background-color: #fff; + + &:disabled { + background-color: #f5f5f5; + color: #999; + cursor: not-allowed; + } +} + +.toggleLabel { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: #555; + margin-top: 10px; + margin-bottom: 11px; + + input { + cursor: pointer; + width: auto; + } +} + +.fieldHint { + font-size: 12px; + color: #888; +} + +.assignmentNotice { + padding: 10px 12px; + border-radius: 6px; + font-size: 12px; + line-height: 1.5; +} + +.assignmentMatch { + background-color: #e8f5e9; + border: 1px solid #2e7d32; + color: #1b5e20; +} + +.assignmentMissing { + background-color: #fff8e1; + border: 1px solid #f9a825; + color: #8d6e00; +} + +.workflowDescriptionHint { + font-size: 12px; + color: #777; + white-space: pre-wrap; +} + +.addWorkflowButton { + align-self: flex-start; + padding: 8px 16px; + border-radius: 4px; + border: 1px solid #0066cc; + background-color: #fff; + color: #0066cc; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background-color: #0066cc; + color: white; + } +} + +.weightValidationSection { + display: flex; + flex-direction: column; + gap: 12px; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #333; + } +} + +.validationCard { + padding: 16px; + border-radius: 8px; + border: 1px solid #e0e0e0; + background-color: #fafafa; + font-size: 13px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.validationSuccess { + border-color: #2e7d32; + background-color: #e8f5e9; + color: #1b5e20; +} + +.validationWarning { + border-color: #f9a825; + background-color: #fff8e1; + color: #8d6e00; +} + +.validationMessage { + font-size: 12px; + color: inherit; } \ No newline at end of file diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIWorkflowCard.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIWorkflowCard.js index 6f8ed4ed..87e2cd3a 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIWorkflowCard.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIWorkflowCard.js @@ -35,15 +35,7 @@ const AIWorkflowCard = ({ workflow, scorecardId, description, onRemove, readOnly {scorecardId && (
Scorecard: - - {scorecardId} - - + {scorecardId}
)} diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiWorkflowCard.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiWorkflowCard.module.scss index 9b0464be..8e1edde4 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiWorkflowCard.module.scss +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiWorkflowCard.module.scss @@ -117,8 +117,3 @@ text-decoration: underline; } } - -.externalIcon { - font-size: 12px; - opacity: 0.7; -} \ No newline at end of file From 126a57a9f28661b2202c2b88b5e4d9126464888a Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 20 Feb 2026 09:14:54 +0200 Subject: [PATCH 06/16] PM-3851 - #4.5h start integration --- mock-data/ai-review-config.json | 54 +- .../ChallengeReviewer-Field/AIReviewTab.js | 1094 ----------------- .../{ => AIReviewTab}/AIReviewTab.module.scss | 31 +- .../components/ConfigurationSourceSelector.js | 58 + .../components/ManualWorkflowCard.js | 134 ++ .../components/ReviewSettingsSection.js | 89 ++ .../AIReviewTab/components/SummarySection.js | 49 + .../components/WeightValidationCard.js | 49 + .../hooks/useConfigurationState.js | 142 +++ .../AIReviewTab/hooks/useTemplateManager.js | 76 ++ .../AIReviewTab/index.js | 614 +++++++++ .../AIReviewTab/views/InitialStateView.js | 91 ++ .../views/ManualConfigurationView.js | 125 ++ .../views/TemplateConfigurationView.js | 179 +++ .../AiReviewerTab/AiReviewTab.js | 121 ++ .../AiReviewerTab/AiReviewTab.module.scss | 847 +++++++++++++ .../components/ConfigurationSourceSelector.js | 58 + .../components/ManualWorkflowCard.js | 134 ++ .../components/ReviewSettingsSection.js | 89 ++ .../components/SummarySection.js | 49 + .../components/WeightValidationCard.js | 49 + .../hooks/useConfigurationState.js | 140 +++ .../AiReviewerTab/hooks/useTemplateManager.js | 82 ++ .../AiReviewerTab/index.js | 1 + .../AiReviewerTab/utils.js | 6 + .../AiReviewerTab/views/InitialStateView.js | 103 ++ .../views/TemplateConfigurationView.js | 212 ++++ .../ChallengeReviewer-Field/index.js | 4 +- src/config/constants.js | 1 + src/services/aiReviewConfigs.js | 11 +- src/services/aiReviewTemplates.js | 15 +- 31 files changed, 3555 insertions(+), 1152 deletions(-) delete mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js rename src/components/ChallengeEditor/ChallengeReviewer-Field/{ => AIReviewTab}/AIReviewTab.module.scss (96%) create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ConfigurationSourceSelector.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ManualWorkflowCard.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ReviewSettingsSection.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/SummarySection.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/WeightValidationCard.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/hooks/useConfigurationState.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/hooks/useTemplateManager.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/index.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/InitialStateView.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/ManualConfigurationView.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/TemplateConfigurationView.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.module.scss create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ConfigurationSourceSelector.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ReviewSettingsSection.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/SummarySection.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/WeightValidationCard.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useTemplateManager.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/index.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/utils.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/InitialStateView.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js diff --git a/mock-data/ai-review-config.json b/mock-data/ai-review-config.json index 2a4e6c65..2e05f122 100644 --- a/mock-data/ai-review-config.json +++ b/mock-data/ai-review-config.json @@ -1,48 +1,20 @@ { - "id": "config_challenge_001", - "challengeId": "challenge_12345", - "minPassingThreshold": 75.00, + "challengeId": "97701509-f4ee-4a03-9bd1-bad7413d2274", + "minPassingThreshold": 15, + "mode": "AI_ONLY", + "templateId": "template_001", "autoFinalize": false, - "mode": "AI_GATING", - "formula": { - "type": "gating", - "gates": [ - { - "workflowId": "wf_001_security", - "threshold": 80, - "isMandatory": true - } - ], - "scoring": { - "type": "weighted", - "weights": { - "wf_001_security": 0.4, - "wf_002_quality": 0.6 - } - } - }, + "formula": {}, "workflows": [ { - "id": "config_wf_001", - "configId": "config_challenge_001", - "workflowId": "wf_001_security", - "weightPercent": 40.00, - "isGating": true, - "createdAt": "2026-02-10T10:00:00Z", - "createdBy": "user_123" + "workflowId": "1lROGgC0jANqJL", + "weightPercent": 80, + "isGating": false }, { - "id": "config_wf_002", - "configId": "config_challenge_001", - "workflowId": "wf_002_quality", - "weightPercent": 60.00, - "isGating": false, - "createdAt": "2026-02-10T10:00:00Z", - "createdBy": "user_123" + "workflowId": "J0aZLgbf9NUvnZ", + "weightPercent": 20, + "isGating": false } - ], - "createdAt": "2026-02-10T10:00:00Z", - "createdBy": "user_123", - "updatedAt": "2026-02-15T14:30:00Z", - "updatedBy": "user_123" -} + ] +} \ No newline at end of file diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js deleted file mode 100644 index 6e1138c2..00000000 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.js +++ /dev/null @@ -1,1094 +0,0 @@ -import React, { useState, useMemo } from 'react' -import PropTypes from 'prop-types' -import cn from 'classnames' -import { OutlineButton } from '../../Buttons' -import AIWorkflowCard from './AIWorkflowCard' -import { createTemplateManager } from '../../../services/aiReviewTemplateHelpers' -import { createConfigManager } from '../../../services/aiReviewConfigHelpers' -import ConfirmationModal from '../../Modal/ConfirmationModal' -import styles from './AIReviewTab.module.scss' - -const AIReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, readOnly = false }) => { - const [error, setError] = useState(null) - const [selectedTemplate, setSelectedTemplate] = useState(null) - const [configurationMode, setConfigurationMode] = useState(null) - const [templates, setTemplates] = useState([]) - const [templatesLoading, setTemplatesLoading] = useState(false) - const [showSwitchToManualConfirm, setShowSwitchToManualConfirm] = useState(false) - const [configuration, setConfiguration] = useState({ - mode: 'AI_GATING', - minPassingThreshold: 75, - autoFinalize: false, - workflows: [] - }) - - const templateManager = useMemo(() => createTemplateManager(true), []) - const configManager = useMemo(() => createConfigManager(true), []) - - /** - * Checks if we're in the initial state: - * - AI workflows are assigned (from DefaultChallengeReviewer) - * - But no aiReviewConfig has been created yet - */ - const isAIReviewer = (reviewer) => { - return reviewer && ( - (reviewer.aiWorkflowId && reviewer.aiWorkflowId.trim() !== '') || - (reviewer.isMemberReview === false) - ) - } - - const isInitialState = () => { - const aiReviewers = (challenge.reviewers || []).filter(r => isAIReviewer(r)) - // Initial state: has AI reviewers but no aiReviewConfig - // TODO: Update this check based on actual aiReviewConfig property once defined - return aiReviewers.length > 0 && !configurationMode - } - - /** - * Load templates based on challenge track and type - */ - const loadTemplates = async () => { - setTemplatesLoading(true) - setError(null) - - try { - const fetchedTemplates = await templateManager.fetchAll({ - challengeTrack: challenge.track.name, - challengeType: challenge.type.name, - }) - - setTemplates(fetchedTemplates || []) - setTemplatesLoading(false) - } catch (error) { - console.error('Error loading templates:', error) - setError('Failed to load templates') - setTemplatesLoading(false) - } - } - - /** - * Get workflows assigned to this challenge - */ - const getAssignedWorkflows = () => { - const { workflows = [] } = metadata - const aiReviewers = (challenge.reviewers || []).filter(r => isAIReviewer(r)) - - return aiReviewers.map(reviewer => { - const workflow = workflows.find(w => w.id === reviewer.aiWorkflowId) - return { - reviewer, - workflow, - scorecardId: reviewer.scorecardId - } - }) - } - - const handleTemplateSelection = () => { - setConfigurationMode('template') - // Load templates after setting the mode - loadTemplates() - } - - const handleManualConfiguration = () => { - setConfigurationMode('manual') - } - - const handleSwitchConfigurationMode = (newMode) => { - if (newMode === 'manual' && configurationMode === 'template') { - setShowSwitchToManualConfirm(true) - return - } - - setConfigurationMode(newMode) - setSelectedTemplate(null) - - if (newMode === 'template') { - setConfiguration({ - mode: 'AI_GATING', - minPassingThreshold: 75, - autoFinalize: false, - workflows: [] - }) - loadTemplates() - } - } - - const confirmSwitchToManual = () => { - setShowSwitchToManualConfirm(false) - setConfigurationMode('manual') - setSelectedTemplate(null) - } - - const handleTemplateChange = (templateId) => { - const template = templates.find(t => t.id === templateId) - - if (template) { - setSelectedTemplate(template) - applyTemplateToConfiguration(template) - } - } - - const applyTemplateToConfiguration = (template) => { - if (!template) return - - const newConfiguration = { - mode: template.mode || 'AI_GATING', - minPassingThreshold: template.minPassingThreshold || 75, - autoFinalize: template.autoFinalize || false, - workflows: template.workflows || [] - } - - setConfiguration(newConfiguration) - } - - const updateConfiguration = (field, value) => { - setConfiguration(prev => ({ - ...prev, - [field]: value - })) - } - - const addWorkflowToConfiguration = () => { - setConfiguration(prev => ({ - ...prev, - workflows: prev.workflows.concat([ - { workflowId: '', weightPercent: 0, isGating: false } - ]) - })) - } - - const updateWorkflowInConfiguration = (index, field, value) => { - setConfiguration(prev => { - const workflows = prev.workflows.map((workflow, idx) => { - if (idx !== index) { - return workflow - } - - const nextWorkflow = { - ...workflow, - [field]: value - } - - if (field === 'isGating' && value) { - nextWorkflow.weightPercent = 0 - } - - return nextWorkflow - }) - - return { - ...prev, - workflows - } - }) - } - - const removeWorkflowFromConfiguration = (index) => { - setConfiguration(prev => ({ - ...prev, - workflows: prev.workflows.filter((_, idx) => idx !== index) - })) - } - - const renderTemplateConfiguration = () => { - const { workflows: availableWorkflows = [] } = metadata - - return ( -
- {/* Configuration Source Selector */} -
-

Configuration Source:

-
- - - {!readOnly && ( - - )} -
-
- - {/* Template Selection Section */} -
-

📋 AI Review Template

- -
- -
- - {selectedTemplate && ( -
-

{selectedTemplate.description}

-
- )} -
- - {/* Review Settings Section */} - {selectedTemplate && ( -
-

⚙️ Review Settings

- -
- {/* Review Mode */} -
- - -

- {configuration.mode === 'AI_GATING' - ? 'AI gates low-quality submissions; humans review the rest.' - : 'AI makes the final decision on all submissions.'} -

-
- - {/* Auto-Finalize */} -
- - -

- Only available in AI_ONLY mode -

-
-
- - {/* Min Passing Threshold Slider */} -
- -
- updateConfiguration('minPassingThreshold', parseInt(e.target.value, 10))} - disabled={readOnly} - className={styles.slider} - /> - - {configuration.minPassingThreshold} % - -
-
-
- )} - - {/* AI Workflows Section */} - {selectedTemplate && configuration.workflows && configuration.workflows.length > 0 && ( -
-

AI Workflows (from template)

- -
- - - - - - - - - - - {configuration.workflows.map((workflow, index) => { - const isAssigned = (challenge.reviewers || []).some(r => - isAIReviewer(r) && r.aiWorkflowId === workflow.workflowId - ) - const workflowDetails = workflow.workflow || {} - - return ( - - - - - - - ) - })} - -
WorkflowWeightTypeChallenge Match
- 🤖 - {workflowDetails.name} -
- {workflowDetails.description} -
-
- {workflow.weightPercent}% - - {workflow.isGating ? ( - ⚡GATE - ) : ( - ✓ Review - )} - - {isAssigned ? ( - ✅ Assigned - ) : ( - ⚠️ Not assigned - )} -
-
- -

- ✅ = Also assigned as AI Reviewer in this challenge
- ⚠️ = Not assigned — will be auto-added on save -

-
- )} - - {/* Summary Section */} - {selectedTemplate && ( -
-

Summary

-
-
-

Mode

-
{configuration.mode}
-
-
-

Threshold

-
{configuration.minPassingThreshold}%
-
-
-

Workflows

-
- {configuration.workflows.length} total - {configuration.workflows.some(w => w.isGating) && ( -
- {configuration.workflows.filter(w => w.isGating).length} gating -
- )} -
-
-
-
- )} - - {/* Remove Configuration Button */} - {!readOnly && selectedTemplate && ( -
- -
- )} - - {templatesLoading && ( -
Loading templates...
- )} -
- ) - } - - const renderManualConfiguration = () => { - const { workflows: availableWorkflows = [] } = metadata - const scoringWorkflows = configuration.workflows.filter(workflow => !workflow.isGating) - const gatingWorkflows = configuration.workflows.filter(workflow => workflow.isGating) - const scoringTotal = scoringWorkflows.reduce((sum, workflow) => sum + (Number(workflow.weightPercent) || 0), 0) - const hasScoringWorkflows = scoringWorkflows.length > 0 - const isWeightValid = !hasScoringWorkflows || Math.abs(scoringTotal - 100) < 0.01 - const remainingWeight = Math.round((100 - scoringTotal) * 100) / 100 - const scoringSummary = hasScoringWorkflows - ? `${scoringWorkflows.map(workflow => `${Number(workflow.weightPercent) || 0}%`).join(' + ')} = ${scoringTotal}%` - : 'no scoring workflows' - - return ( -
-
-

Configuration Source:

-
- - - {!readOnly && ( - - )} -
-
- -
-

Review Settings

- -
-
- - -

- {configuration.mode === 'AI_GATING' - ? 'AI gates low-quality submissions; humans review the rest.' - : 'AI makes the final decision on all submissions.'} -

-
- -
- - -

Only available in AI_ONLY mode

-
-
- -
- -
- updateConfiguration('minPassingThreshold', parseInt(e.target.value, 10))} - disabled={readOnly} - className={styles.slider} - /> - - {configuration.minPassingThreshold} % - -
-
-
- -
-

AI Workflows (editable)

- - {configuration.workflows.map((workflow, index) => { - const workflowDetails = availableWorkflows.find(item => item.id === workflow.workflowId) || {} - const isAssigned = (challenge.reviewers || []).some(r => - isAIReviewer(r) && r.aiWorkflowId === workflow.workflowId - ) - - return ( -
-
-
Workflow {index + 1}
- {!readOnly && ( - - )} -
- -
-
- - -
- -
-
- - updateWorkflowInConfiguration( - index, - 'weightPercent', - parseInt(e.target.value, 10) || 0 - )} - disabled={readOnly || workflow.isGating} - className={styles.weightInput} - /> -
Weight for scoring. Ignored if gating.
-
- -
- - -
- {workflow.isGating ? '⚡ Pass/fail gate.' : 'Submissions below threshold are locked.'} -
-
-
- - {workflow.workflowId && ( -
- {isAssigned - ? 'Matched: This workflow is assigned as AI Reviewer for this challenge.' - : 'Not assigned: will be auto-added on save.'} -
- )} - - {workflowDetails.description && ( -
{workflowDetails.description}
- )} -
-
- ) - })} - - {!readOnly && ( - - )} -
- -
-

Weight Validation

-
-
- Scoring workflows weight total: {scoringSummary} {isWeightValid ? 'OK' : 'Invalid'} -
- {!isWeightValid && hasScoringWorkflows && ( -
- Scoring workflow weights must total 100%. {remainingWeight > 0 - ? `Remaining: ${remainingWeight}% unassigned.` - : `Over by ${Math.abs(remainingWeight)}%.`} -
- )} -
Gating workflows: {gatingWorkflows.length}
-
-
- -
-

Summary

-
-
-

Mode

-
{configuration.mode}
-
-
-

Threshold

-
{configuration.minPassingThreshold}%
-
-
-

Workflows

-
- {configuration.workflows.length} total - {configuration.workflows.some(w => w.isGating) && ( -
- {configuration.workflows.filter(w => w.isGating).length} gating -
- )} -
-
-
-
- - {!readOnly && ( -
- -
- )} -
- ) - } - - const removeAIReviewer = (index) => { - const currentReviewers = challenge.reviewers || [] - - // Map the AI reviewer index to the actual index in the full reviewers array - const aiReviewers = currentReviewers.filter(r => isAIReviewer(r)) - const reviewerToRemove = aiReviewers[index] - const actualIndex = currentReviewers.indexOf(reviewerToRemove) - - if (actualIndex !== -1) { - const updatedReviewers = currentReviewers.filter((_, i) => i !== actualIndex) - onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) - } - } - - const renderInitialState = () => { - const assignedWorkflows = getAssignedWorkflows() - - return ( -
-
-
⚠️
-
-

AI workflows are assigned but no AI Review Config has been created

-

Workflows will run but scoring, gating, and thresholds are not defined.

-

Choose how to configure:

-
-
- -
-
-

📋 Use a Template

-

Pre-fill from a standard config for this track & type.

- -
- -
-

✏️ Configure Manually

-

Set up each workflow weight, mode, and threshold yourself.

- -
-
- -
-

Assigned AI Workflows

-

These AI workflows are assigned to this challenge from the default reviewer configuration.

- -
- {assignedWorkflows.map((item, index) => ( - <> - removeAIReviewer(index)} - readOnly={false} - /> - - ))} -
-
-
- ) - } - - const addAIReviewer = () => { - const { workflows = [] } = metadata - const currentReviewers = challenge.reviewers || [] - - const defaultWorkflow = workflows && workflows.length > 0 ? workflows[0] : null - - const reviewPhases = challenge.phases && challenge.phases.filter(phase => - phase.name && (phase.name.toLowerCase().includes('review') || phase.name.toLowerCase().includes('submission')) - ) - 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 (firstReviewPhase) { - defaultPhaseId = firstReviewPhase.phaseId || firstReviewPhase.id - } else if (fallbackPhase) { - defaultPhaseId = fallbackPhase.phaseId || fallbackPhase.id - } - - const scorecardId = defaultWorkflow && defaultWorkflow.scorecardId ? defaultWorkflow.scorecardId : undefined - - const newReviewer = { - scorecardId, - isMemberReview: false, - phaseId: defaultPhaseId, - aiWorkflowId: (defaultWorkflow && defaultWorkflow.id) || '' - } - - if (error) { - setError(null) - } - - const updatedReviewers = currentReviewers.concat([newReviewer]) - onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) - } - - const updateAIReviewer = (index, field, value) => { - const currentReviewers = challenge.reviewers || [] - const updatedReviewers = currentReviewers.slice() - const fieldUpdate = { [field]: value } - - // Map the AI reviewer index to the actual index in the full reviewers array - const aiReviewers = currentReviewers.filter(r => isAIReviewer(r)) - const reviewerToUpdate = aiReviewers[index] - const actualIndex = currentReviewers.indexOf(reviewerToUpdate) - - if (actualIndex === -1) return - - if (field === 'aiWorkflowId') { - const { workflows = [] } = metadata - const selectedWorkflow = workflows.find(w => w.id === value) - if (selectedWorkflow && selectedWorkflow.scorecardId) { - fieldUpdate.scorecardId = selectedWorkflow.scorecardId - } - } - - updatedReviewers[actualIndex] = Object.assign({}, updatedReviewers[actualIndex], fieldUpdate) - onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) - } - - const validateAIReviewer = (reviewer) => { - const errors = {} - - if (!reviewer.aiWorkflowId || reviewer.aiWorkflowId.trim() === '') { - errors.aiWorkflowId = 'AI Workflow is required' - } - - if (!reviewer.phaseId) { - errors.phaseId = 'Phase is required' - } - - return errors - } - - const renderAIReviewerForm = (reviewer, index) => { - const { workflows = [] } = metadata - const validationErrors = challenge.submitTriggered ? validateAIReviewer(reviewer) : {} - - return ( -
-
-

AI Reviewer Configuration {index + 1}

- {!readOnly && ( - removeAIReviewer(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 ? ( - - {(() => { - const workflow = workflows.find(w => w.id === reviewer.aiWorkflowId) - return workflow ? workflow.name : 'Not selected' - })()} - - ) : ( - - )} - {!readOnly && challenge.submitTriggered && validationErrors.aiWorkflowId && ( -
- {validationErrors.aiWorkflowId} -
- )} -
-
- - {readOnly && reviewer.scorecardId && ( -
-
- - - {reviewer.scorecardId} - -
-
- )} -
- ) - } - - - const aiReviewers = (challenge.reviewers || []).filter(r => isAIReviewer(r)) - - if (isLoading) { - return
Loading...
- } - - // Show template configuration if in template mode - if (configurationMode === 'template') { - return ( -
- {renderTemplateConfiguration()} - {showSwitchToManualConfirm && ( - -

The template settings will be copied into editable fields.

-

You can then modify workflows, weights, and settings individually.

-

This will disconnect from the template. Future template updates will not apply.

-
- )} - cancelText='Cancel' - confirmText='Switch to Manual' - onCancel={() => setShowSwitchToManualConfirm(false)} - onConfirm={confirmSwitchToManual} - /> - )} - - ) - } - - if (configurationMode === 'manual') { - return ( -
- {renderManualConfiguration()} -
- ) - } - - // Show initial state if workflows are assigned but no configuration mode selected yet - if (isInitialState()) { - return ( -
- {renderInitialState()} -
- ) - } - - return ( -
- {!readOnly && ( -
- Configure AI-powered review workflows for this challenge. Select AI templates and assign to phases. -
- )} - - {!readOnly && aiReviewers.length === 0 && ( -
-

No AI review workflows configured. Click "Add AI Reviewer" to get started.

-
- )} - - {readOnly && aiReviewers.length === 0 && ( -
-

No AI review workflows configured for this challenge.

-
- )} - - {aiReviewers.length > 0 && aiReviewers.map((reviewer, index) => - renderAIReviewerForm(reviewer, index) - )} - - {aiReviewers.length > 0 && ( -
-

AI Review Summary

-
- Total AI Workflows: - {aiReviewers.length} -
-
- )} - - {!readOnly && ( -
- -
- )} - - {error && !isLoading && ( -
- {error} -
- )} -
- ) -} - -AIReviewTab.propTypes = { - challenge: PropTypes.object.isRequired, - onUpdateReviewers: PropTypes.func.isRequired, - metadata: PropTypes.shape({ - workflows: PropTypes.array, - challengeTracks: PropTypes.array - }), - isLoading: PropTypes.bool, - readOnly: PropTypes.bool -} - -export default AIReviewTab diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/AIReviewTab.module.scss similarity index 96% rename from src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.module.scss rename to src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/AIReviewTab.module.scss index 57c98daa..72e91bc7 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab.module.scss +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/AIReviewTab.module.scss @@ -1,5 +1,5 @@ -@use '../../../styles/includes' as *; -@import './shared.module.scss'; +@use '../../../../styles/includes' as *; +@import '../shared.module.scss'; .initialStateContainer { display: flex; @@ -619,6 +619,33 @@ } } +.actionButtonsSection { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 20px; + + .saveConfigButton { + padding: 10px 24px; + background-color: #28a745; + color: white; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.3s ease; + + &:hover { + background-color: #218838; + } + + &:active { + background-color: #1e7e34; + } + } +} + // Manual Configuration Styles .manualConfiguration { display: flex; diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ConfigurationSourceSelector.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ConfigurationSourceSelector.js new file mode 100644 index 00000000..4c059bec --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ConfigurationSourceSelector.js @@ -0,0 +1,58 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styles from '../AIReviewTab.module.scss' + +/** + * Configuration Source Selector - Toggle between template and manual configuration + */ +const ConfigurationSourceSelector = ({ mode, onSwitch, readOnly }) => { + return ( +
+

Configuration Source:

+
+ + + {!readOnly && ( + + )} +
+
+ ) +} + +ConfigurationSourceSelector.propTypes = { + mode: PropTypes.oneOf(['template', 'manual']).isRequired, + onSwitch: PropTypes.func.isRequired, + readOnly: PropTypes.bool +} + +ConfigurationSourceSelector.defaultProps = { + readOnly: false +} + +export default ConfigurationSourceSelector diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ManualWorkflowCard.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ManualWorkflowCard.js new file mode 100644 index 00000000..5a9ee2af --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ManualWorkflowCard.js @@ -0,0 +1,134 @@ +import React from 'react' +import PropTypes from 'prop-types' +import cn from 'classnames' +import styles from '../AIReviewTab.module.scss' + +/** + * Manual Workflow Card - Editable workflow configuration card + * Allows user to configure workflow ID, weight, and gating status + */ +const ManualWorkflowCard = ({ + workflow, + index, + availableWorkflows, + challenge, + onUpdate, + onRemove, + readOnly, + isAIReviewer +}) => { + const workflowDetails = availableWorkflows.find(item => item.id === workflow.workflowId) || {} + const isAssigned = (challenge.reviewers || []).some(r => + isAIReviewer(r) && r.aiWorkflowId === workflow.workflowId + ) + + return ( +
+
+
Workflow {index + 1}
+ {!readOnly && ( + + )} +
+ +
+
+ + +
+ +
+
+ + onUpdate( + index, + 'weightPercent', + parseInt(e.target.value, 10) || 0 + )} + disabled={readOnly || workflow.isGating} + className={styles.weightInput} + /> +
Weight for scoring. Ignored if gating.
+
+ +
+ + +
+ {workflow.isGating ? '⚡ Pass/fail gate.' : 'Submissions below threshold are locked.'} +
+
+
+ + {workflow.workflowId && ( +
+ {isAssigned + ? 'Matched: This workflow is assigned as AI Reviewer for this challenge.' + : 'Not assigned: will be auto-added on save.'} +
+ )} + + {workflowDetails.description && ( +
{workflowDetails.description}
+ )} +
+
+ ) +} + +ManualWorkflowCard.propTypes = { + workflow: PropTypes.shape({ + workflowId: PropTypes.string, + weightPercent: PropTypes.number, + isGating: PropTypes.bool + }).isRequired, + index: PropTypes.number.isRequired, + availableWorkflows: PropTypes.array.isRequired, + challenge: PropTypes.object.isRequired, + onUpdate: PropTypes.func.isRequired, + onRemove: PropTypes.func.isRequired, + readOnly: PropTypes.bool, + isAIReviewer: PropTypes.func.isRequired +} + +ManualWorkflowCard.defaultProps = { + readOnly: false +} + +export default ManualWorkflowCard diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ReviewSettingsSection.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ReviewSettingsSection.js new file mode 100644 index 00000000..1fcdf3be --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ReviewSettingsSection.js @@ -0,0 +1,89 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styles from '../AIReviewTab.module.scss' + +/** + * Review Settings Section - Mode, auto-finalize, and threshold configuration + */ +const ReviewSettingsSection = ({ configuration, onUpdateConfiguration, readOnly, showTitle = true }) => { + return ( +
+ {showTitle &&

⚙️ Review Settings

} + +
+ {/* Review Mode */} +
+ + +

+ {configuration.mode === 'AI_GATING' + ? 'AI gates low-quality submissions; humans review the rest.' + : 'AI makes the final decision on all submissions.'} +

+
+ + {/* Auto-Finalize */} +
+ + +

+ Only available in AI_ONLY mode +

+
+
+ + {/* Min Passing Threshold Slider */} +
+ +
+ onUpdateConfiguration('minPassingThreshold', parseInt(e.target.value, 10))} + disabled={readOnly} + className={styles.slider} + /> + + {configuration.minPassingThreshold} % + +
+
+
+ ) +} + +ReviewSettingsSection.propTypes = { + configuration: PropTypes.shape({ + mode: PropTypes.string.isRequired, + minPassingThreshold: PropTypes.number.isRequired, + autoFinalize: PropTypes.bool.isRequired + }).isRequired, + onUpdateConfiguration: PropTypes.func.isRequired, + readOnly: PropTypes.bool, + showTitle: PropTypes.bool +} + +ReviewSettingsSection.defaultProps = { + readOnly: false, + showTitle: true +} + +export default ReviewSettingsSection diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/SummarySection.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/SummarySection.js new file mode 100644 index 00000000..8f73cdb1 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/SummarySection.js @@ -0,0 +1,49 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styles from '../AIReviewTab.module.scss' + +/** + * Summary Section - Display configuration summary with cards + */ +const SummarySection = ({ configuration }) => { + return ( +
+

Summary

+
+
+

Mode

+
{configuration.mode}
+
+
+

Threshold

+
{configuration.minPassingThreshold}%
+
+
+

Workflows

+
+ {configuration.workflows.length} total + {configuration.workflows.some(w => w.isGating) && ( +
+ {configuration.workflows.filter(w => w.isGating).length} gating +
+ )} +
+
+
+
+ ) +} + +SummarySection.propTypes = { + configuration: PropTypes.shape({ + mode: PropTypes.string.isRequired, + minPassingThreshold: PropTypes.number.isRequired, + workflows: PropTypes.arrayOf( + PropTypes.shape({ + isGating: PropTypes.bool + }) + ).isRequired + }).isRequired +} + +export default SummarySection diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/WeightValidationCard.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/WeightValidationCard.js new file mode 100644 index 00000000..b570d920 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/WeightValidationCard.js @@ -0,0 +1,49 @@ +import React from 'react' +import PropTypes from 'prop-types' +import cn from 'classnames' +import styles from '../AIReviewTab.module.scss' + +/** + * Weight Validation Card - Validates that scoring workflow weights total 100% + */ +const WeightValidationCard = ({ workflows }) => { + const scoringWorkflows = workflows.filter(workflow => !workflow.isGating) + const gatingWorkflows = workflows.filter(workflow => workflow.isGating) + const scoringTotal = scoringWorkflows.reduce((sum, workflow) => sum + (Number(workflow.weightPercent) || 0), 0) + const hasScoringWorkflows = scoringWorkflows.length > 0 + const isWeightValid = !hasScoringWorkflows || Math.abs(scoringTotal - 100) < 0.01 + const remainingWeight = Math.round((100 - scoringTotal) * 100) / 100 + const scoringSummary = hasScoringWorkflows + ? `${scoringWorkflows.map(workflow => `${Number(workflow.weightPercent) || 0}%`).join(' + ')} = ${scoringTotal}%` + : 'no scoring workflows' + + return ( +
+

Weight Validation

+
+
+ Scoring workflows weight total: {scoringSummary} {isWeightValid ? 'OK' : 'Invalid'} +
+ {!isWeightValid && hasScoringWorkflows && ( +
+ Scoring workflow weights must total 100%. {remainingWeight > 0 + ? `Remaining: ${remainingWeight}% unassigned.` + : `Over by ${Math.abs(remainingWeight)}%.`} +
+ )} +
Gating workflows: {gatingWorkflows.length}
+
+
+ ) +} + +WeightValidationCard.propTypes = { + workflows: PropTypes.arrayOf( + PropTypes.shape({ + weightPercent: PropTypes.number, + isGating: PropTypes.bool + }) + ).isRequired +} + +export default WeightValidationCard diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/hooks/useConfigurationState.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/hooks/useConfigurationState.js new file mode 100644 index 00000000..1cfb4b1f --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/hooks/useConfigurationState.js @@ -0,0 +1,142 @@ +import { useState, useCallback, useMemo, useEffect } from 'react' +import { createConfigManager } from '../../../../../services/aiReviewConfigHelpers' + +/** + * Custom hook for managing AI Review configuration state + * Handles configuration object with mode, threshold, autoFinalize, and workflows + */ +const useConfigurationState = ( + challengeId, + initialConfig = { + mode: 'AI_GATING', + minPassingThreshold: 75, + autoFinalize: false, + workflows: [] + }, +) => { + const configManager = useMemo(() => createConfigManager(true), []) + const [configuration, setConfiguration] = useState(initialConfig) + const [configurationMode, setConfigurationMode] = useState(null) + + /** + * Update a single field in the configuration + */ + const updateConfiguration = useCallback((field, value) => { + setConfiguration(prev => ({ + ...prev, + [field]: value + })) + }, []) + + /** + * Add a new workflow to the configuration + */ + const addWorkflow = useCallback(() => { + setConfiguration(prev => ({ + ...prev, + workflows: prev.workflows.concat([ + { workflowId: '', weightPercent: 0, isGating: false } + ]) + })) + }, [setConfiguration]) + + /** + * Update a specific workflow in the configuration + */ + const updateWorkflow = useCallback((index, field, value) => { + setConfiguration(prev => { + const workflows = prev.workflows.map((workflow, idx) => { + if (idx !== index) { + return workflow + } + + const nextWorkflow = { + ...workflow, + [field]: value + } + + // Reset weight if workflow becomes gating + if (field === 'isGating' && value) { + nextWorkflow.weightPercent = 0 + } + + return nextWorkflow + }) + + return { + ...prev, + workflows + } + }) + }, [setConfiguration]) + + /** + * Remove a workflow from the configuration + */ + const removeWorkflow = useCallback((index) => { + setConfiguration(prev => ({ + ...prev, + workflows: prev.workflows.filter((_, idx) => idx !== index) + })) + }, [setConfiguration]) + + /** + * Reset configuration to default or provided state + */ + const resetConfiguration = useCallback((newConfig = initialConfig) => { + setConfiguration(newConfig) + }, [setConfiguration]) + + /** + * Apply a template to the configuration + */ + const applyTemplate = useCallback((template) => { + if (!template) return + + const newConfiguration = { + mode: template.mode || 'AI_GATING', + minPassingThreshold: template.minPassingThreshold || 75, + autoFinalize: template.autoFinalize || false, + workflows: template.workflows || [] + } + + setConfiguration(newConfiguration) + }, [setConfiguration]) + + + // Fetch AI review config when component loads + useEffect(() => { + const loadAIReviewConfig = async () => { + try { + console.log('here2', challengeId) + if (challengeId) { + console.log('here3', challengeId) + const config = await configManager.fetchByChallenge(challengeId) + if (config) { + // Load the config into the configuration state + setConfigurationMode('manual') + updateConfiguration(config) + } + } + } catch (err) { + console.error('Error loading AI review configuration:', err) + } + } + + loadAIReviewConfig() + }, [challengeId, updateConfiguration, configManager]) + + return { + configuration, + configurationMode, + setConfigurationMode, + updateConfiguration, + addWorkflow, + updateWorkflow, + removeWorkflow, + resetConfiguration, + applyTemplate + } +} + +export default useConfigurationState diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/hooks/useTemplateManager.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/hooks/useTemplateManager.js new file mode 100644 index 00000000..265f834b --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/hooks/useTemplateManager.js @@ -0,0 +1,76 @@ +import { useState, useMemo } from 'react' +import { createTemplateManager } from '../../../../../services/aiReviewTemplateHelpers' + +/** + * Custom hook for managing AI Review templates + * Handles template loading, selection, and state management + */ +const useTemplateManager = () => { + const [templates, setTemplates] = useState([]) + const [selectedTemplate, setSelectedTemplate] = useState(null) + const [templatesLoading, setTemplatesLoading] = useState(false) + const [error, setError] = useState(null) + + const templateManager = useMemo(() => createTemplateManager(false), []) + + /** + * Load templates based on challenge track and type + */ + const loadTemplates = async (challengeTrack, challengeType) => { + setTemplatesLoading(true) + setError(null) + + try { + const fetchedTemplates = await templateManager.fetchAll({ + challengeTrack, + challengeType + }) + + setTemplates(fetchedTemplates || []) + setTemplatesLoading(false) + } catch (err) { + console.error('Error loading templates:', err) + setError('Failed to load templates') + setTemplatesLoading(false) + } + } + + /** + * Select a template by ID + */ + const selectTemplate = (templateId) => { + const template = templates.find(t => t.id === templateId) + setSelectedTemplate(template || null) + return template || null + } + + /** + * Clear selected template + */ + const clearSelection = () => { + setSelectedTemplate(null) + } + + /** + * Reset all state + */ + const reset = () => { + setTemplates([]) + setSelectedTemplate(null) + setTemplatesLoading(false) + setError(null) + } + + return { + templates, + selectedTemplate, + templatesLoading, + error, + loadTemplates, + selectTemplate, + clearSelection, + reset + } +} + +export default useTemplateManager diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/index.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/index.js new file mode 100644 index 00000000..96866621 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/index.js @@ -0,0 +1,614 @@ +import React, { useState, useEffect } from 'react' +import PropTypes from 'prop-types' +import cn from 'classnames' +import { OutlineButton } from '../../../Buttons' +import { createAIReviewConfig, updateAIReviewConfig, fetchAIReviewConfigByChallenge } from '../../../../services/aiReviewConfigs' +import ConfirmationModal from '../../../Modal/ConfirmationModal' +import useConfigurationState from './hooks/useConfigurationState' +import useTemplateManager from './hooks/useTemplateManager' +import InitialStateView from './views/InitialStateView' +import TemplateConfigurationView from './views/TemplateConfigurationView' +import ManualConfigurationView from './views/ManualConfigurationView' +import styles from './AIReviewTab.module.scss' +import sharedStyles from '../shared.module.scss' + +/** + * AIReviewTab - Main component for managing AI review configuration + * Orchestrates between different views: initial state, template, manual, and legacy + */ +const AIReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, readOnly = false }) => { + const [error, setError] = useState(null) + const [showSwitchToManualConfirm, setShowSwitchToManualConfirm] = useState(false) + + useEffect(() => { + challenge.reviewers.forEach(r => { + if (r.isMemberReview) { + return; + } + r.aiReviewTemplateId = 'template_001'; + }) + }, [challenge]); + + const { + configuration, + configurationMode, + setConfigurationMode, + updateConfiguration, + addWorkflow, + updateWorkflow, + removeWorkflow, + resetConfiguration, + applyTemplate + } = useConfigurationState(challenge) + + const { + templates, + selectedTemplate, + templatesLoading, + error: templateError, + loadTemplates, + selectTemplate, + clearSelection + } = useTemplateManager() + + /** + * Check if a reviewer is an AI reviewer + */ + const isAIReviewer = (reviewer) => { + return reviewer && ( + (reviewer.aiWorkflowId && reviewer.aiWorkflowId.trim() !== '') || + (reviewer.isMemberReview === false) + ) + } + + /** + * Check if we're in initial state (AI reviewers assigned but no config) + */ + const isInitialState = () => { + const aiReviewers = (challenge.reviewers || []).filter(r => isAIReviewer(r)) + return aiReviewers.length > 0 && !configurationMode + } + + /** + * Get workflows assigned to this challenge + */ + const getAssignedWorkflows = () => { + const { workflows = [] } = metadata + const aiReviewers = (challenge.reviewers || []).filter(r => isAIReviewer(r)) + + return aiReviewers.map(reviewer => { + const workflow = workflows.find(w => w.id === reviewer.aiWorkflowId) + return { + reviewer, + workflow, + scorecardId: reviewer.scorecardId + } + }) + } + + /** + * Handle template selection mode + */ + const handleTemplateSelection = () => { + setConfigurationMode('template') + loadTemplates(challenge.track.name, challenge.type.name) + } + + /** + * Handle manual configuration mode + */ + const handleManualConfiguration = () => { + setConfigurationMode('manual') + } + + /** + * Handle switching between configuration modes + */ + const handleSwitchConfigurationMode = (newMode) => { + if (newMode === 'manual' && configurationMode === 'template') { + setShowSwitchToManualConfirm(true) + return + } + + setConfigurationMode(newMode) + clearSelection() + + if (newMode === 'template') { + resetConfiguration() + loadTemplates(challenge.track.name, challenge.type.name) + } + } + + /** + * Confirm switch to manual mode + */ + const confirmSwitchToManual = () => { + setShowSwitchToManualConfirm(false) + setConfigurationMode('manual') + clearSelection() + } + + /** + * Handle template change + */ + const handleTemplateChange = (templateId) => { + const template = selectTemplate(templateId) + if (template) { + applyTemplate(template) + } + } + + /** + * Handle remove configuration + */ + const handleRemoveConfiguration = () => { + setConfigurationMode(null) + clearSelection() + resetConfiguration() + } + + /** + * Handle save configuration + */ + const handleSaveConfiguration = async () => { + // Validate configuration before saving + if (!configuration.workflows || configuration.workflows.length === 0) { + setError('At least one AI workflow is required') + return + } + + // Check if any workflow has invalid settings + const hasInvalidWorkflow = configuration.workflows.some(workflow => { + return !workflow.workflowId + }) + + if (hasInvalidWorkflow) { + setError('All workflows must have a name and ID') + return + } + + try { + // Prepare configuration data for API + const configData = { + challengeId: challenge.id, + minPassingThreshold: configuration.minPassingThreshold || 0, + mode: configuration.mode || 'AI_ONLY', + workflows: configuration.workflows + } + + // Create or update the configuration via API + let savedConfig = null + const existingConfig = await fetchAIReviewConfigByChallenge(challenge.id) + + if (existingConfig && existingConfig.id) { + // Update existing config + savedConfig = await updateAIReviewConfig(existingConfig.id, configData) + } else { + // Create new config + savedConfig = await createAIReviewConfig(configData) + } + + // Ensure reviewers exist for each workflow in the configuration + const currentReviewers = challenge.reviewers || [] + let updatedReviewers = [...currentReviewers] + + // Find the first available review phase + const reviewPhases = challenge.phases && challenge.phases.filter(phase => + phase.name && (phase.name.toLowerCase().includes('review') || phase.name.toLowerCase().includes('submission')) + ) + const defaultPhaseId = (reviewPhases && reviewPhases.length > 0) + ? (reviewPhases[0].phaseId || reviewPhases[0].id) + : (challenge.phases && challenge.phases.length > 0) + ? (challenge.phases[0].phaseId || challenge.phases[0].id) + : '' + + // For each workflow in the configuration, ensure there's a corresponding reviewer + configuration.workflows.forEach(workflow => { + const workflowExists = updatedReviewers.some(reviewer => + isAIReviewer(reviewer) && reviewer.aiWorkflowId === workflow.workflowId + ) + + if (!workflowExists) { + // Add a new AI reviewer for this workflow + const newReviewer = { + isMemberReview: false, + phaseId: defaultPhaseId, + aiWorkflowId: workflow.workflowId, + scorecardId: workflow.scorecardId + } + updatedReviewers.push(newReviewer) + } + }) + + // Update all AI reviewers with the saved configuration + updatedReviewers = updatedReviewers.map(reviewer => { + if (isAIReviewer(reviewer)) { + return { + ...reviewer, + aiReviewConfiguration: savedConfig || configuration + } + } + return reviewer + }) + + if (error) { + setError(null) + } + + // Update the challenge with the new reviewers and configuration + onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) + } catch (err) { + setError(`Failed to save AI review configuration: ${err.message}`) + console.error('Error saving AI review configuration:', err) + } + } + + /** + * Add an AI reviewer + */ + const addAIReviewer = () => { + const { workflows = [] } = metadata + const currentReviewers = challenge.reviewers || [] + + const defaultWorkflow = workflows && workflows.length > 0 ? workflows[0] : null + + const reviewPhases = challenge.phases && challenge.phases.filter(phase => + phase.name && (phase.name.toLowerCase().includes('review') || phase.name.toLowerCase().includes('submission')) + ) + 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 (firstReviewPhase) { + defaultPhaseId = firstReviewPhase.phaseId || firstReviewPhase.id + } else if (fallbackPhase) { + defaultPhaseId = fallbackPhase.phaseId || fallbackPhase.id + } + + const scorecardId = defaultWorkflow && defaultWorkflow.scorecardId ? defaultWorkflow.scorecardId : undefined + + const newReviewer = { + scorecardId, + isMemberReview: false, + phaseId: defaultPhaseId, + aiWorkflowId: (defaultWorkflow && defaultWorkflow.id) || '' + } + + if (error) { + setError(null) + } + + const updatedReviewers = currentReviewers.concat([newReviewer]) + onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) + } + + /** + * Update an AI reviewer + */ + const updateAIReviewer = (index, field, value) => { + const currentReviewers = challenge.reviewers || [] + const updatedReviewers = currentReviewers.slice() + const fieldUpdate = { [field]: value } + + // Map the AI reviewer index to the actual index in the full reviewers array + const aiReviewers = currentReviewers.filter(r => isAIReviewer(r)) + const reviewerToUpdate = aiReviewers[index] + const actualIndex = currentReviewers.indexOf(reviewerToUpdate) + + if (actualIndex === -1) return + + if (field === 'aiWorkflowId') { + const { workflows = [] } = metadata + const selectedWorkflow = workflows.find(w => w.id === value) + if (selectedWorkflow && selectedWorkflow.scorecardId) { + fieldUpdate.scorecardId = selectedWorkflow.scorecardId + } + } + + updatedReviewers[actualIndex] = Object.assign({}, updatedReviewers[actualIndex], fieldUpdate) + onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) + } + + /** + * Remove an AI reviewer + */ + const removeAIReviewer = (index) => { + const currentReviewers = challenge.reviewers || [] + + // Map the AI reviewer index to the actual index in the full reviewers array + const aiReviewers = currentReviewers.filter(r => isAIReviewer(r)) + const reviewerToRemove = aiReviewers[index] + const actualIndex = currentReviewers.indexOf(reviewerToRemove) + + if (actualIndex !== -1) { + const updatedReviewers = currentReviewers.filter((_, i) => i !== actualIndex) + onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) + } + } + + /** + * Validate an AI reviewer + */ + const validateAIReviewer = (reviewer) => { + const errors = {} + + if (!reviewer.aiWorkflowId || reviewer.aiWorkflowId.trim() === '') { + errors.aiWorkflowId = 'AI Workflow is required' + } + + if (!reviewer.phaseId) { + errors.phaseId = 'Phase is required' + } + + return errors + } + + /** + * Render AI reviewer form (legacy mode) + */ + const renderAIReviewerForm = (reviewer, index) => { + const { workflows = [] } = metadata + const validationErrors = challenge.submitTriggered ? validateAIReviewer(reviewer) : {} + + return ( +
+
+

AI Reviewer Configuration {index + 1}

+ {!readOnly && ( + removeAIReviewer(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 ? ( + + {(() => { + const workflow = workflows.find(w => w.id === reviewer.aiWorkflowId) + return workflow ? workflow.name : 'Not selected' + })()} + + ) : ( + + )} + {!readOnly && challenge.submitTriggered && validationErrors.aiWorkflowId && ( +
+ {validationErrors.aiWorkflowId} +
+ )} +
+
+ + {readOnly && reviewer.scorecardId && ( +
+
+ + + {reviewer.scorecardId} + +
+
+ )} +
+ ) + } + + // Get AI reviewers + const aiReviewers = (challenge.reviewers || []).filter(r => isAIReviewer(r)) + + // Show loading state + if (isLoading) { + return
Loading...
+ } + + // Show template configuration if in template mode + if (configurationMode === 'template') { + return ( +
+ + {showSwitchToManualConfirm && ( + +

The template settings will be copied into editable fields.

+

You can then modify workflows, weights, and settings individually.

+

This will disconnect from the template. Future template updates will not apply.

+
+ )} + cancelText='Cancel' + confirmText='Switch to Manual' + onCancel={() => setShowSwitchToManualConfirm(false)} + onConfirm={confirmSwitchToManual} + /> + )} + + ) + } + + // Show manual configuration if in manual mode + if (configurationMode === 'manual') { + return ( +
+ +
+ ) + } + + // Show initial state if workflows are assigned but no configuration mode selected yet + if (isInitialState()) { + return ( +
+ +
+ ) + } + + // Legacy mode - show traditional AI reviewer forms + return ( +
+ {!readOnly && ( +
+ Configure AI-powered review workflows for this challenge. Select AI templates and assign to phases. +
+ )} + + {!readOnly && aiReviewers.length === 0 && ( +
+

No AI review workflows configured. Click "Add AI Reviewer" to get started.

+
+ )} + + {readOnly && aiReviewers.length === 0 && ( +
+

No AI review workflows configured for this challenge.

+
+ )} + + {aiReviewers.length > 0 && aiReviewers.map((reviewer, index) => + renderAIReviewerForm(reviewer, index) + )} + + {aiReviewers.length > 0 && ( +
+

AI Review Summary

+
+ Total AI Workflows: + {aiReviewers.length} +
+
+ )} + + {!readOnly && ( +
+ +
+ )} + + {(error || templateError) && !isLoading && ( +
+ {error || templateError} +
+ )} +
+ ) +} + +AIReviewTab.propTypes = { + challenge: PropTypes.object.isRequired, + onUpdateReviewers: PropTypes.func.isRequired, + metadata: PropTypes.shape({ + workflows: PropTypes.array, + challengeTracks: PropTypes.array + }), + isLoading: PropTypes.bool, + readOnly: PropTypes.bool +} + +AIReviewTab.defaultProps = { + metadata: {}, + isLoading: false, + readOnly: false +} + +export default AIReviewTab diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/InitialStateView.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/InitialStateView.js new file mode 100644 index 00000000..ae06d200 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/InitialStateView.js @@ -0,0 +1,91 @@ +import React from 'react' +import PropTypes from 'prop-types' +import AIWorkflowCard from '../../AIWorkflowCard' +import styles from '../AIReviewTab.module.scss' + +/** + * Initial State View - Shown when AI reviewers are assigned but no configuration exists + * Provides options to use a template or configure manually + */ +const InitialStateView = ({ + assignedWorkflows, + onSelectTemplate, + onSelectManual, + onRemoveReviewer, + readOnly +}) => { + return ( +
+
+
⚠️
+
+

AI workflows are assigned but no AI Review Config has been created

+

Workflows will run but scoring, gating, and thresholds are not defined.

+

Choose how to configure:

+
+
+ +
+
+

📋 Use a Template

+

Pre-fill from a standard config for this track & type.

+ +
+ +
+

✏️ Configure Manually

+

Set up each workflow weight, mode, and threshold yourself.

+ +
+
+ +
+

Assigned AI Workflows

+

These AI workflows are assigned to this challenge from the default reviewer configuration.

+ +
+ {assignedWorkflows.map((item, index) => ( + onRemoveReviewer(index)} + readOnly={readOnly} + /> + ))} +
+
+
+ ) +} + +InitialStateView.propTypes = { + assignedWorkflows: PropTypes.arrayOf( + PropTypes.shape({ + reviewer: PropTypes.object.isRequired, + workflow: PropTypes.object, + scorecardId: PropTypes.string + }) + ).isRequired, + onSelectTemplate: PropTypes.func.isRequired, + onSelectManual: PropTypes.func.isRequired, + onRemoveReviewer: PropTypes.func.isRequired, + readOnly: PropTypes.bool +} + +InitialStateView.defaultProps = { + readOnly: false +} + +export default InitialStateView diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/ManualConfigurationView.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/ManualConfigurationView.js new file mode 100644 index 00000000..04ad3cd7 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/ManualConfigurationView.js @@ -0,0 +1,125 @@ +import React from 'react' +import PropTypes from 'prop-types' +import ConfigurationSourceSelector from '../components/ConfigurationSourceSelector' +import ReviewSettingsSection from '../components/ReviewSettingsSection' +import SummarySection from '../components/SummarySection' +import WeightValidationCard from '../components/WeightValidationCard' +import ManualWorkflowCard from '../components/ManualWorkflowCard' +import styles from '../AIReviewTab.module.scss' + +/** + * Manual Configuration View - Manually configure AI review settings and workflows + */ +const ManualConfigurationView = ({ + challenge, + configuration, + availableWorkflows, + onUpdateConfiguration, + onAddWorkflow, + onUpdateWorkflow, + onRemoveWorkflow, + onSwitchMode, + onRemoveConfig, + onSaveConfiguration, + readOnly, + isAIReviewer +}) => { + return ( +
+ {/* Configuration Source Selector */} + onSwitchMode('template')} + readOnly={readOnly} + /> + + {/* Review Settings Section */} + + + {/* Manual Workflows Section */} +
+

AI Workflows (editable)

+ + {configuration.workflows.map((workflow, index) => ( + onRemoveWorkflow(index)} + readOnly={readOnly} + isAIReviewer={isAIReviewer} + /> + ))} + + {!readOnly && ( + + )} +
+ + {/* Weight Validation Section */} + + + {/* Summary Section */} + + + {/* Action Buttons Section */} + {!readOnly && ( +
+ +
+ +
+
+ )} +
+ ) +} + +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, + onSaveConfiguration: PropTypes.func.isRequired, + readOnly: PropTypes.bool, + isAIReviewer: PropTypes.func.isRequired +} + +ManualConfigurationView.defaultProps = { + readOnly: false +} + +export default ManualConfigurationView diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/TemplateConfigurationView.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/TemplateConfigurationView.js new file mode 100644 index 00000000..75f76ba9 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/TemplateConfigurationView.js @@ -0,0 +1,179 @@ +import React from 'react' +import PropTypes from 'prop-types' +import ConfigurationSourceSelector from '../components/ConfigurationSourceSelector' +import ReviewSettingsSection from '../components/ReviewSettingsSection' +import SummarySection from '../components/SummarySection' +import styles from '../AIReviewTab.module.scss' + +/** + * Template Configuration View - Select and configure using a template + */ +const TemplateConfigurationView = ({ + challenge, + configuration, + templates, + selectedTemplate, + templatesLoading, + onTemplateChange, + onUpdateConfiguration, + onSwitchMode, + onRemoveConfig, + readOnly, + isAIReviewer +}) => { + return ( +
+ {/* Configuration Source Selector */} + onSwitchMode('manual')} + readOnly={readOnly} + /> + + {/* Template Selection Section */} +
+

📋 AI Review Template

+ +
+ +
+ + {selectedTemplate && ( +
+

{selectedTemplate.description}

+
+ )} +
+ + {/* Review Settings Section */} + {selectedTemplate && ( + + )} + + {/* AI Workflows Section */} + {selectedTemplate && configuration.workflows && configuration.workflows.length > 0 && ( +
+

AI Workflows (from template)

+ +
+ + + + + + + + + + + {configuration.workflows.map((workflow, index) => { + const isAssigned = (challenge.reviewers || []).some(r => + isAIReviewer(r) && r.aiWorkflowId === workflow.workflowId + ) + const workflowDetails = workflow.workflow || {} + + return ( + + + + + + + ) + })} + +
WorkflowWeightTypeChallenge Match
+ 🤖 + {workflowDetails.name} +
+ {workflowDetails.description} +
+
+ {workflow.weightPercent}% + + {workflow.isGating ? ( + ⚡GATE + ) : ( + ✓ Review + )} + + {isAssigned ? ( + ✅ Assigned + ) : ( + ⚠️ Not assigned + )} +
+
+ +

+ ✅ = Also assigned as AI Reviewer in this challenge
+ ⚠️ = Not assigned — will be auto-added on save +

+
+ )} + + {/* Summary Section */} + {selectedTemplate && ( + + )} + + {/* Remove Configuration Button */} + {!readOnly && selectedTemplate && ( +
+ +
+ )} + + {templatesLoading && ( +
Loading templates...
+ )} +
+ ) +} + +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, + templates: PropTypes.array.isRequired, + selectedTemplate: PropTypes.object, + templatesLoading: PropTypes.bool.isRequired, + onTemplateChange: PropTypes.func.isRequired, + onUpdateConfiguration: PropTypes.func.isRequired, + onSwitchMode: PropTypes.func.isRequired, + onRemoveConfig: PropTypes.func.isRequired, + readOnly: PropTypes.bool, + isAIReviewer: PropTypes.func.isRequired +} + +TemplateConfigurationView.defaultProps = { + readOnly: false, + selectedTemplate: null +} + +export default TemplateConfigurationView diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js new file mode 100644 index 00000000..db8f08f3 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js @@ -0,0 +1,121 @@ +import cn from 'classnames' +import React, { useCallback, useMemo } from 'react' +import PropTypes from 'prop-types' +import { isAIReviewer } from './utils'; +import styles from './AiReviewTab.module.scss' +import sharedStyles from '../shared.module.scss' +import useConfigurationState from './hooks/useConfigurationState'; +import InitialStateView from './views/InitialStateView'; +import TemplateConfigurationView from './views/TemplateConfigurationView'; + +/** + * AiReviewTab - Main component for managing AI review configuration + * Orchestrates between different views: initial state, template, manual, and legacy + */ +const AiReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, readOnly = false }) => { + const { + configuration, + configurationMode, + setConfigurationMode, + updateConfiguration, + addWorkflow, + updateWorkflow, + removeWorkflow, + resetConfiguration, + applyTemplate + } = useConfigurationState(challenge.id) + + const aiReviewers = useMemo(() => ( + (challenge.reviewers || []).filter(isAIReviewer) + ), [challenge.reviewers]); + + const removeAIReviewer = useCallback((index) => { + const allChallengeReviewers = challenge.reviewers || [] + // Map the AI reviewer index to the actual index in the full reviewers array + const reviewerToRemove = aiReviewers[index] + const actualIndex = allChallengeReviewers.indexOf(reviewerToRemove) + + if (actualIndex !== -1) { + const updatedReviewers = allChallengeReviewers.filter((_, i) => i !== actualIndex) + onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) + } + }, [challenge.reviewers, onUpdateReviewers]) + + const handleRemoveConfiguration = useCallback(() => { + setConfigurationMode(null) + resetConfiguration() + }, [setConfigurationMode, resetConfiguration]) + + // Show loading state + if (isLoading) { + return
Loading...
+ } + + // Show template configuration if in template mode + if (configurationMode === 'template') { + return ( +
+ + {/* {showSwitchToManualConfirm && ( + +

The template settings will be copied into editable fields.

+

You can then modify workflows, weights, and settings individually.

+

This will disconnect from the template. Future template updates will not apply.

+
+ )} + cancelText='Cancel' + confirmText='Switch to Manual' + onCancel={() => setShowSwitchToManualConfirm(false)} + onConfirm={confirmSwitchToManual} + /> + )} */} + + ) + } + + // initial state (no configuration mode was selected: template/manual) + return ( +
+ setConfigurationMode('template')} + onSelectManual={() => setConfigurationMode('manual')} + onRemoveReviewer={removeAIReviewer} + readOnly={readOnly} + /> +
+ ) +} + +AiReviewTab.propTypes = { + challenge: PropTypes.object.isRequired, + onUpdateReviewers: PropTypes.func.isRequired, + metadata: PropTypes.shape({ + workflows: PropTypes.array, + challengeTracks: PropTypes.array + }), + isLoading: PropTypes.bool, + readOnly: PropTypes.bool +} + +AiReviewTab.defaultProps = { + metadata: {}, + isLoading: false, + readOnly: false +} + +export default AiReviewTab diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.module.scss new file mode 100644 index 00000000..72e91bc7 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.module.scss @@ -0,0 +1,847 @@ +@use '../../../../styles/includes' as *; +@import '../shared.module.scss'; + +.initialStateContainer { + display: flex; + flex-direction: column; + gap: 30px; +} + +.warningBox { + display: flex; + gap: 16px; + background-color: #fff8e1; + border: 2px solid #ffd54f; + border-radius: 8px; + padding: 20px; + align-items: flex-start; +} + +.warningIcon { + font-size: 28px; + flex-shrink: 0; +} + +.warningContent { + flex: 1; + + h3 { + margin: 0 0 10px 0; + font-size: 18px; + font-weight: 600; + color: #333; + } + + p { + margin: 8px 0; + color: #555; + font-size: 14px; + line-height: 1.5; + } + + p strong { + font-weight: 600; + } +} + +.configurationOptions { + display: flex; + gap: 20px; + flex-wrap: wrap; + + @media (max-width: 768px) { + flex-direction: column; + } +} + +.optionCard { + flex: 1; + min-width: 250px; + border: 2px solid #e0e0e0; + border-radius: 8px; + padding: 24px; + text-align: center; + transition: all 0.3s ease; + background-color: #fafafa; + + &:hover { + border-color: #0066cc; + box-shadow: 0 4px 12px rgba(0, 102, 204, 0.15); + } + + h4 { + margin: 0 0 8px 0; + font-size: 16px; + font-weight: 600; + color: #333; + } + + p { + margin: 0 0 16px 0; + color: #666; + font-size: 14px; + line-height: 1.5; + } +} + +.optionIcon { + font-size: 36px; + margin-bottom: 12px; + display: block; +} + +.optionButton { + padding: 10px 24px; + background-color: #0066cc; + color: white; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.3s ease; + + &:hover { + background-color: #0052a3; + } + + &:active { + background-color: #004080; + } +} + +.assignedWorkflowsSection { + padding-top: 20px; + border-top: 1px solid #e0e0e0; + + h3 { + margin: 0 0 8px 0; + font-size: 16px; + font-weight: 600; + color: #333; + } + + p { + margin: 0 0 16px 0; + color: #666; + font-size: 14px; + } +} + +.workflowsList { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +} + +// Template Configuration Styles +.templateConfiguration { + display: flex; + flex-direction: column; + gap: 32px; +} + +.configurationSourceSelector { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + background-color: #f5f5f5; + border-radius: 8px; + + h4 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #333; + } +} + +.sourceOptions { + display: flex; + align-items: center; + gap: 20px; + + .radioLabel { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 14px; + color: #555; + + input[type='radio'] { + cursor: pointer; + } + } + + .switchButton { + margin-left: auto; + padding: 6px 16px; + background-color: #0066cc; + color: white; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.3s ease; + + &:hover { + background-color: #0052a3; + } + } +} + +.templateSection { + display: flex; + flex-direction: column; + gap: 16px; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #333; + } +} + +.templateSelector { + display: flex; + flex-direction: column; + gap: 8px; + + .templateDropdown { + padding: 10px 12px; + border: 1px solid #d0d0d0; + border-radius: 4px; + font-size: 14px; + background-color: white; + cursor: pointer; + transition: border-color 0.3s ease; + + &:hover:not(:disabled) { + border-color: #0066cc; + } + + &:focus { + outline: none; + border-color: #0066cc; + box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1); + } + + &:disabled { + background-color: #f5f5f5; + cursor: not-allowed; + color: #999; + } + } +} + +.templateDescription { + padding: 12px 16px; + background-color: #f0f8ff; + border-left: 4px solid #0066cc; + border-radius: 4px; + font-size: 13px; + color: #555; + line-height: 1.6; + + p { + margin: 0; + } +} + +.reviewSettingsSection { + display: flex; + flex-direction: column; + gap: 20px; + padding: 20px; + background-color: #fafafa; + border-radius: 8px; + border: 1px solid #e0e0e0; + + h3 { + margin: 0 0 8px 0; + font-size: 16px; + font-weight: 600; + color: #333; + } +} + +.settingsGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +} + +.setting { + display: flex; + flex-direction: column; + gap: 8px; + + label { + font-size: 14px; + font-weight: 500; + color: #333; + } + + select { + padding: 8px 10px; + border: 1px solid #d0d0d0; + border-radius: 4px; + font-size: 14px; + background-color: white; + cursor: pointer; + + &:disabled { + background-color: #f5f5f5; + cursor: not-allowed; + color: #999; + } + } + + .checkboxLabel { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 14px; + color: #555; + + input[type='checkbox'] { + cursor: pointer; + } + } + + .modeInfo, + .autoFinalizeInfo { + margin: 0; + font-size: 12px; + color: #888; + font-style: italic; + } +} + +.thresholdSection { + display: flex; + flex-direction: column; + gap: 12px; + + label { + font-size: 14px; + font-weight: 500; + color: #333; + } + + .thresholdSlider { + display: flex; + align-items: center; + gap: 12px; + + .slider { + flex: 1; + height: 6px; + border-radius: 3px; + background: linear-gradient(to right, #d0d0d0 0%, #d0d0d0 100%); + outline: none; + -webkit-appearance: none; + appearance: none; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #0066cc; + cursor: pointer; + transition: background-color 0.3s ease; + + &:hover { + background-color: #0052a3; + } + } + + &::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #0066cc; + cursor: pointer; + border: none; + transition: background-color 0.3s ease; + + &:hover { + background-color: #0052a3; + } + } + } + + .thresholdValue { + font-weight: 600; + color: #0066cc; + min-width: 50px; + text-align: right; + font-size: 14px; + } + } +} + +.workflowsSection { + display: flex; + flex-direction: column; + gap: 16px; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #333; + + .workflowsNote { + font-size: 12px; + font-weight: 400; + color: #888; + margin-left: 8px; + } + } +} + +.workflowsTable { + overflow-x: auto; + + table { + width: 100%; + border-collapse: collapse; + border: 1px solid #e0e0e0; + border-radius: 4px; + + thead { + background-color: #f5f5f5; + + th { + padding: 12px; + text-align: left; + font-size: 13px; + font-weight: 600; + color: #333; + border-bottom: 2px solid #e0e0e0; + } + } + + tbody { + tr { + border-bottom: 1px solid #e0e0e0; + transition: background-color 0.2s ease; + + &:hover { + background-color: #fafafa; + } + + &:last-child { + border-bottom: none; + } + + td { + padding: 12px; + font-size: 14px; + color: #555; + vertical-align: top; + + &.weight { + font-weight: 600; + color: #0066cc; + } + + &.type { + .gatingBadge { + display: inline-block; + padding: 2px 8px; + background-color: #fff3cd; + border: 1px solid #ffc107; + border-radius: 3px; + font-size: 12px; + font-weight: 500; + color: #856404; + } + + .normalBadge { + display: inline-block; + padding: 2px 8px; + background-color: #d1ecf1; + border: 1px solid #17a2b8; + border-radius: 3px; + font-size: 12px; + font-weight: 500; + color: #0c5460; + } + } + + &.match { + .assignedBadge { + display: inline-block; + padding: 2px 8px; + background-color: #d4edda; + border: 1px solid #28a745; + border-radius: 3px; + font-size: 12px; + font-weight: 500; + color: #155724; + } + + .notAssignedBadge { + display: inline-block; + padding: 2px 8px; + background-color: #fff3cd; + border: 1px solid #ffc107; + border-radius: 3px; + font-size: 12px; + font-weight: 500; + color: #856404; + } + } + } + } + } + } +} + +.workflowIcon { + margin-right: 6px; + font-size: 14px; +} + +.workflowName { + font-weight: 500; + color: #333; +} + +.workflowDescription { + font-size: 12px; + color: #888; + margin-top: 4px; + font-style: italic; +} + +.workflowsInfo { + font-size: 12px; + color: #888; + margin: 0; + line-height: 1.6; +} + +.summarySection { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; + background-color: #f0f8ff; + border: 1px solid #b3d9ff; + border-radius: 8px; + + h3 { + margin: 0 0 8px 0; + font-size: 16px; + font-weight: 600; + color: #333; + } +} + +.summaryGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 16px; +} + +.summaryCard { + padding: 12px; + background-color: white; + border: 1px solid #d0e8ff; + border-radius: 4px; + text-align: center; + + h4 { + margin: 0 0 8px 0; + font-size: 12px; + font-weight: 500; + color: #888; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .summaryValue { + font-size: 16px; + font-weight: 600; + color: #0066cc; + + .summarySubtext { + font-size: 12px; + color: #888; + font-weight: 400; + margin-top: 4px; + } + } +} + +.removeConfigSection { + display: flex; + justify-content: center; + padding-top: 12px; + border-top: 1px solid #e0e0e0; + + .removeConfigButton { + padding: 10px 24px; + background-color: #dc3545; + color: white; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.3s ease; + + &:hover { + background-color: #c82333; + } + + &:active { + background-color: #bd2130; + } + } +} + +.actionButtonsSection { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 20px; + + .saveConfigButton { + padding: 10px 24px; + background-color: #28a745; + color: white; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.3s ease; + + &:hover { + background-color: #218838; + } + + &:active { + background-color: #1e7e34; + } + } +} + +// Manual Configuration Styles +.manualConfiguration { + display: flex; + flex-direction: column; + gap: 32px; +} + +.manualWorkflowsSection { + display: flex; + flex-direction: column; + gap: 16px; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #333; + } +} + +.manualWorkflowCard { + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 16px; + background-color: #fafafa; + display: flex; + flex-direction: column; + gap: 16px; +} + +.manualWorkflowHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.manualWorkflowTitle { + font-weight: 600; + color: #333; +} + +.removeWorkflowButton { + padding: 6px 12px; + border-radius: 4px; + border: 1px solid #dc3545; + background-color: #fff; + color: #dc3545; + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background-color: #dc3545; + color: white; + } +} + +.manualWorkflowBody { + display: flex; + flex-direction: column; + gap: 12px; +} + +.manualWorkflowField { + display: flex; + flex-direction: column; + gap: 8px; + + label { + font-size: 13px; + font-weight: 500; + color: #333; + } +} + +.manualWorkflowRow { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.workflowSelect, +.weightInput { + padding: 8px 10px; + border: 1px solid #d0d0d0; + border-radius: 4px; + font-size: 14px; + background-color: #fff; + + &:disabled { + background-color: #f5f5f5; + color: #999; + cursor: not-allowed; + } +} + +.toggleLabel { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: #555; + margin-top: 10px; + margin-bottom: 11px; + + input { + cursor: pointer; + width: auto; + } +} + +.fieldHint { + font-size: 12px; + color: #888; +} + +.assignmentNotice { + padding: 10px 12px; + border-radius: 6px; + font-size: 12px; + line-height: 1.5; +} + +.assignmentMatch { + background-color: #e8f5e9; + border: 1px solid #2e7d32; + color: #1b5e20; +} + +.assignmentMissing { + background-color: #fff8e1; + border: 1px solid #f9a825; + color: #8d6e00; +} + +.workflowDescriptionHint { + font-size: 12px; + color: #777; + white-space: pre-wrap; +} + +.addWorkflowButton { + align-self: flex-start; + padding: 8px 16px; + border-radius: 4px; + border: 1px solid #0066cc; + background-color: #fff; + color: #0066cc; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background-color: #0066cc; + color: white; + } +} + +.weightValidationSection { + display: flex; + flex-direction: column; + gap: 12px; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #333; + } +} + +.validationCard { + padding: 16px; + border-radius: 8px; + border: 1px solid #e0e0e0; + background-color: #fafafa; + font-size: 13px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.validationSuccess { + border-color: #2e7d32; + background-color: #e8f5e9; + color: #1b5e20; +} + +.validationWarning { + border-color: #f9a825; + background-color: #fff8e1; + color: #8d6e00; +} + +.validationMessage { + font-size: 12px; + color: inherit; +} \ No newline at end of file diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ConfigurationSourceSelector.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ConfigurationSourceSelector.js new file mode 100644 index 00000000..7a79c3aa --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ConfigurationSourceSelector.js @@ -0,0 +1,58 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styles from '../AiReviewTab.module.scss' + +/** + * Configuration Source Selector - Toggle between template and manual configuration + */ +const ConfigurationSourceSelector = ({ mode, onSwitch, readOnly }) => { + return ( +
+

Configuration Source:

+
+ + + {!readOnly && ( + + )} +
+
+ ) +} + +ConfigurationSourceSelector.propTypes = { + mode: PropTypes.oneOf(['template', 'manual']).isRequired, + onSwitch: PropTypes.func.isRequired, + readOnly: PropTypes.bool +} + +ConfigurationSourceSelector.defaultProps = { + readOnly: false +} + +export default ConfigurationSourceSelector diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js new file mode 100644 index 00000000..5a9ee2af --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js @@ -0,0 +1,134 @@ +import React from 'react' +import PropTypes from 'prop-types' +import cn from 'classnames' +import styles from '../AIReviewTab.module.scss' + +/** + * Manual Workflow Card - Editable workflow configuration card + * Allows user to configure workflow ID, weight, and gating status + */ +const ManualWorkflowCard = ({ + workflow, + index, + availableWorkflows, + challenge, + onUpdate, + onRemove, + readOnly, + isAIReviewer +}) => { + const workflowDetails = availableWorkflows.find(item => item.id === workflow.workflowId) || {} + const isAssigned = (challenge.reviewers || []).some(r => + isAIReviewer(r) && r.aiWorkflowId === workflow.workflowId + ) + + return ( +
+
+
Workflow {index + 1}
+ {!readOnly && ( + + )} +
+ +
+
+ + +
+ +
+
+ + onUpdate( + index, + 'weightPercent', + parseInt(e.target.value, 10) || 0 + )} + disabled={readOnly || workflow.isGating} + className={styles.weightInput} + /> +
Weight for scoring. Ignored if gating.
+
+ +
+ + +
+ {workflow.isGating ? '⚡ Pass/fail gate.' : 'Submissions below threshold are locked.'} +
+
+
+ + {workflow.workflowId && ( +
+ {isAssigned + ? 'Matched: This workflow is assigned as AI Reviewer for this challenge.' + : 'Not assigned: will be auto-added on save.'} +
+ )} + + {workflowDetails.description && ( +
{workflowDetails.description}
+ )} +
+
+ ) +} + +ManualWorkflowCard.propTypes = { + workflow: PropTypes.shape({ + workflowId: PropTypes.string, + weightPercent: PropTypes.number, + isGating: PropTypes.bool + }).isRequired, + index: PropTypes.number.isRequired, + availableWorkflows: PropTypes.array.isRequired, + challenge: PropTypes.object.isRequired, + onUpdate: PropTypes.func.isRequired, + onRemove: PropTypes.func.isRequired, + readOnly: PropTypes.bool, + isAIReviewer: PropTypes.func.isRequired +} + +ManualWorkflowCard.defaultProps = { + readOnly: false +} + +export default ManualWorkflowCard diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ReviewSettingsSection.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ReviewSettingsSection.js new file mode 100644 index 00000000..5c7de43c --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ReviewSettingsSection.js @@ -0,0 +1,89 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styles from '../AiReviewTab.module.scss' + +/** + * Review Settings Section - Mode, auto-finalize, and threshold configuration + */ +const ReviewSettingsSection = ({ configuration, onUpdateConfiguration, readOnly, showTitle = true }) => { + return ( +
+ {showTitle &&

⚙️ Review Settings

} + +
+ {/* Review Mode */} +
+ + +

+ {configuration.mode === 'AI_GATING' + ? 'AI gates low-quality submissions; humans review the rest.' + : 'AI makes the final decision on all submissions.'} +

+
+ + {/* Auto-Finalize */} +
+ + +

+ Only available in AI_ONLY mode +

+
+
+ + {/* Min Passing Threshold Slider */} +
+ +
+ onUpdateConfiguration('minPassingThreshold', parseInt(e.target.value, 10))} + disabled={readOnly} + className={styles.slider} + /> + + {configuration.minPassingThreshold} % + +
+
+
+ ) +} + +ReviewSettingsSection.propTypes = { + configuration: PropTypes.shape({ + mode: PropTypes.string.isRequired, + minPassingThreshold: PropTypes.number.isRequired, + autoFinalize: PropTypes.bool.isRequired + }).isRequired, + onUpdateConfiguration: PropTypes.func.isRequired, + readOnly: PropTypes.bool, + showTitle: PropTypes.bool +} + +ReviewSettingsSection.defaultProps = { + readOnly: false, + showTitle: true +} + +export default ReviewSettingsSection diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/SummarySection.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/SummarySection.js new file mode 100644 index 00000000..59357244 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/SummarySection.js @@ -0,0 +1,49 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styles from '../AiReviewTab.module.scss' + +/** + * Summary Section - Display configuration summary with cards + */ +const SummarySection = ({ configuration }) => { + return ( +
+

Summary

+
+
+

Mode

+
{configuration.mode}
+
+
+

Threshold

+
{configuration.minPassingThreshold}%
+
+
+

Workflows

+
+ {configuration.workflows.length} total + {configuration.workflows.some(w => w.isGating) && ( +
+ {configuration.workflows.filter(w => w.isGating).length} gating +
+ )} +
+
+
+
+ ) +} + +SummarySection.propTypes = { + configuration: PropTypes.shape({ + mode: PropTypes.string.isRequired, + minPassingThreshold: PropTypes.number.isRequired, + workflows: PropTypes.arrayOf( + PropTypes.shape({ + isGating: PropTypes.bool + }) + ).isRequired + }).isRequired +} + +export default SummarySection diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/WeightValidationCard.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/WeightValidationCard.js new file mode 100644 index 00000000..b570d920 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/WeightValidationCard.js @@ -0,0 +1,49 @@ +import React from 'react' +import PropTypes from 'prop-types' +import cn from 'classnames' +import styles from '../AIReviewTab.module.scss' + +/** + * Weight Validation Card - Validates that scoring workflow weights total 100% + */ +const WeightValidationCard = ({ workflows }) => { + const scoringWorkflows = workflows.filter(workflow => !workflow.isGating) + const gatingWorkflows = workflows.filter(workflow => workflow.isGating) + const scoringTotal = scoringWorkflows.reduce((sum, workflow) => sum + (Number(workflow.weightPercent) || 0), 0) + const hasScoringWorkflows = scoringWorkflows.length > 0 + const isWeightValid = !hasScoringWorkflows || Math.abs(scoringTotal - 100) < 0.01 + const remainingWeight = Math.round((100 - scoringTotal) * 100) / 100 + const scoringSummary = hasScoringWorkflows + ? `${scoringWorkflows.map(workflow => `${Number(workflow.weightPercent) || 0}%`).join(' + ')} = ${scoringTotal}%` + : 'no scoring workflows' + + return ( +
+

Weight Validation

+
+
+ Scoring workflows weight total: {scoringSummary} {isWeightValid ? 'OK' : 'Invalid'} +
+ {!isWeightValid && hasScoringWorkflows && ( +
+ Scoring workflow weights must total 100%. {remainingWeight > 0 + ? `Remaining: ${remainingWeight}% unassigned.` + : `Over by ${Math.abs(remainingWeight)}%.`} +
+ )} +
Gating workflows: {gatingWorkflows.length}
+
+
+ ) +} + +WeightValidationCard.propTypes = { + workflows: PropTypes.arrayOf( + PropTypes.shape({ + weightPercent: PropTypes.number, + isGating: PropTypes.bool + }) + ).isRequired +} + +export default WeightValidationCard diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js new file mode 100644 index 00000000..5756d96e --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js @@ -0,0 +1,140 @@ +import { useState, useCallback, useMemo, useEffect } from 'react' +import { createConfigManager } from '../../../../../services/aiReviewConfigHelpers' + +/** + * Custom hook for managing AI Review configuration state + * Handles configuration object with mode, threshold, autoFinalize, and workflows + */ +const useConfigurationState = ( + challengeId, + initialConfig = { + mode: 'AI_GATING', + minPassingThreshold: 75, + autoFinalize: false, + workflows: [] + }, +) => { + const configManager = useMemo(() => createConfigManager(true), []) + const [configuration, setConfiguration] = useState(initialConfig) + const [configurationMode, setConfigurationMode] = useState(null) + + /** + * Update a single field in the configuration + */ + const updateConfiguration = useCallback((field, value) => { + setConfiguration(prev => ({ + ...prev, + [field]: value + })) + }, []) + + /** + * Add a new workflow to the configuration + */ + const addWorkflow = useCallback(() => { + setConfiguration(prev => ({ + ...prev, + workflows: prev.workflows.concat([ + { workflowId: '', weightPercent: 0, isGating: false } + ]) + })) + }, [setConfiguration]) + + /** + * Update a specific workflow in the configuration + */ + const updateWorkflow = useCallback((index, field, value) => { + setConfiguration(prev => { + const workflows = prev.workflows.map((workflow, idx) => { + if (idx !== index) { + return workflow + } + + const nextWorkflow = { + ...workflow, + [field]: value + } + + // Reset weight if workflow becomes gating + if (field === 'isGating' && value) { + nextWorkflow.weightPercent = 0 + } + + return nextWorkflow + }) + + return { + ...prev, + workflows + } + }) + }, [setConfiguration]) + + /** + * Remove a workflow from the configuration + */ + const removeWorkflow = useCallback((index) => { + setConfiguration(prev => ({ + ...prev, + workflows: prev.workflows.filter((_, idx) => idx !== index) + })) + }, [setConfiguration]) + + /** + * Reset configuration to default or provided state + */ + const resetConfiguration = useCallback((newConfig = initialConfig) => { + setConfiguration(newConfig) + }, [setConfiguration]) + + /** + * Apply a template to the configuration + */ + const applyTemplate = useCallback((template) => { + if (!template) return + + const newConfiguration = { + mode: template.mode || 'AI_GATING', + minPassingThreshold: template.minPassingThreshold || 75, + autoFinalize: template.autoFinalize || false, + workflows: template.workflows || [] + } + + setConfiguration(newConfiguration) + }, [setConfiguration]) + + + // Fetch AI review config when component loads + useEffect(() => { + const loadAIReviewConfig = async () => { + try { + if (challengeId) { + const config = await configManager.fetchByChallenge(challengeId) + if (config) { + // Load the config into the configuration state + setConfigurationMode(config.templateId ? 'template' : 'manual') + resetConfiguration(config) + } + } + } catch (err) { + console.error('Error loading AI review configuration:', err) + } + } + + loadAIReviewConfig() + }, [challengeId, updateConfiguration, configManager]) + + return { + configuration, + configurationMode, + setConfigurationMode, + updateConfiguration, + addWorkflow, + updateWorkflow, + removeWorkflow, + resetConfiguration, + applyTemplate + } +} + +export default useConfigurationState diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useTemplateManager.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useTemplateManager.js new file mode 100644 index 00000000..d1e29a85 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useTemplateManager.js @@ -0,0 +1,82 @@ +import { useState, useMemo, useCallback, useEffect } from 'react' +import { createTemplateManager } from '../../../../../services/aiReviewTemplateHelpers' + +/** + * Custom hook for managing AI Review templates + * Handles template loading, selection, and state management + */ +const useTemplateManager = (templateId, challengeTrack, challengeType) => { + const [templates, setTemplates] = useState([]) + const [selectedTemplate, setSelectedTemplate] = useState() + const [templatesLoading, setTemplatesLoading] = useState(false) + const [error, setError] = useState(null) + + const templateManager = useMemo(() => createTemplateManager(true), []) + + /** + * Select a template by ID + */ + const selectTemplate = useCallback((templateId) => { + const template = templates.find(t => t.id === templateId) + setSelectedTemplate(template || null) + return template || null + }, [templates, setSelectedTemplate]) + + /** + * Clear selected template + */ + const clearSelection = useCallback(() => { + setSelectedTemplate(null) + }, [setSelectedTemplate]) + + /** + * Reset all state + */ + const reset = useCallback(() => { + setTemplates([]) + setSelectedTemplate(null) + setTemplatesLoading(false) + setError(null) + }, [setTemplates, setSelectedTemplate, setTemplatesLoading, setError]) + + useEffect(() => { + selectTemplate(templateId); + }, [selectTemplate, templateId]); + + const loadTemplates = useCallback(async () => { + setTemplatesLoading(true) + setError(null) + + try { + const fetchedTemplates = await templateManager.fetchAll({ + challengeTrack, + challengeType + }) + + setTemplates(fetchedTemplates || []) + setTemplatesLoading(false) + } catch (err) { + console.error('Error loading templates:', err) + setError('Failed to load templates') + setTemplatesLoading(false) + } + }, [setTemplates, setTemplatesLoading, setError, templateManager, challengeTrack, challengeType]) + + useEffect(() => { + if (challengeTrack && challengeType) { + loadTemplates(); + } + }, [loadTemplates, challengeTrack, challengeType]); + + return { + templates, + selectedTemplate, + templatesLoading, + error, + selectTemplate, + clearSelection, + reset + } +} + +export default useTemplateManager diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/index.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/index.js new file mode 100644 index 00000000..3a363964 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/index.js @@ -0,0 +1 @@ +export { default as AiReviewTab } from './AiReviewTab'; diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/utils.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/utils.js new file mode 100644 index 00000000..c01ab7fa --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/utils.js @@ -0,0 +1,6 @@ +export const isAIReviewer = (reviewer) => { + return reviewer && ( + (reviewer.aiWorkflowId && reviewer.aiWorkflowId.trim() !== '') || + (reviewer.isMemberReview === false) + ) +} \ No newline at end of file diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/InitialStateView.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/InitialStateView.js new file mode 100644 index 00000000..6ac6d362 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/InitialStateView.js @@ -0,0 +1,103 @@ +import React, { useMemo } from 'react' +import PropTypes from 'prop-types' +import AIWorkflowCard from '../../AIWorkflowCard' +import styles from '../AiReviewTab.module.scss' + +/** + * Initial State View - Shown when AI reviewers are assigned but no configuration exists + * Provides options to use a template or configure manually + */ +const InitialStateView = ({ + onSelectTemplate, + onSelectManual, + onRemoveReviewer, + readOnly, + metadata, + aiReviewers, +}) => { + const { workflows = [] } = metadata + const assignedWorkflows = useMemo(() => aiReviewers.map(reviewer => { + const workflow = workflows.find(w => w.id === reviewer.aiWorkflowId) + return { + reviewer, + workflow, + scorecardId: reviewer.scorecardId + } + }), [aiReviewers, workflows]) + + return ( +
+
+
⚠️
+
+

AI workflows are assigned but no AI Review Config has been created

+

Workflows will run but scoring, gating, and thresholds are not defined.

+

Choose how to configure:

+
+
+ +
+
+

📋 Use a Template

+

Pre-fill from a standard config for this track & type.

+ +
+ +
+

✏️ Configure Manually

+

Set up each workflow weight, mode, and threshold yourself.

+ +
+
+ + {assignedWorkflows.length > 0 && ( +
+

Assigned AI Workflows

+

These AI workflows are assigned to this challenge from the default reviewer configuration.

+ +
+ {assignedWorkflows.map((item, index) => ( + onRemoveReviewer(index)} + readOnly={readOnly} + /> + ))} +
+
+ )} +
+ ) +} + +InitialStateView.propTypes = { + metadata: PropTypes.shape({ + workflows: PropTypes.array, + }), + aiReviewers: PropTypes.array, + onSelectTemplate: PropTypes.func.isRequired, + onSelectManual: PropTypes.func.isRequired, + onRemoveReviewer: PropTypes.func.isRequired, + readOnly: PropTypes.bool +} + +InitialStateView.defaultProps = { + readOnly: false, + metadata: {}, + aiReviewers: [], +} + +export default InitialStateView 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..cbc090dd --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js @@ -0,0 +1,212 @@ +import React, {useCallback} 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 ReviewSettingsSection from '../../AIReviewTab/components/ReviewSettingsSection' +import ConfigurationSourceSelector from '../../AIReviewTab/components/ConfigurationSourceSelector' + +/** + * Template Configuration View - Select and configure using a template + */ +const TemplateConfigurationView = ({ + challenge, + configuration, + onTemplateChange, + onUpdateConfiguration, + onSwitchMode, + onRemoveConfig, + readOnly, + metadata, +}) => { + const { workflows = [] } = metadata + const { + templates, + selectedTemplate, + templatesLoading, + error: templateError, + selectTemplate, + clearSelection + } = useTemplateManager( + configuration.templateId, + challenge.track.name, + challenge.type.name, + ) + + 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]) + + + if (templateError) { + return ( +
+ {templateError} +
+ ) + } + + return ( +
+ {/* Configuration Source Selector */} + ConfigurationSourceSelector + onSwitchMode('manual')} + readOnly={readOnly} + /> + + {/* Template Selection Section */} +
+

📋 AI Review Template

+ +
+ +
+ + {selectedTemplate && ( +
+

{selectedTemplate.description}

+
+ )} +
+ + {/* Review Settings Section */} + {/* {selectedTemplate && ( + + )} */} + + {/* AI Workflows Section */} + {selectedTemplate && configuration.workflows && configuration.workflows.length > 0 && ( +
+

AI Workflows (from template)

+ +
+ + + + + + + + + + + {configuration.workflows.map((workflow, index) => { + const isAssigned = (challenge.reviewers || []).some(r => r.aiWorkflowId === workflow.workflowId) + const workflowDetails = workflows.find(w => w.id === workflow.workflowId) || {} + + return ( + + + + + + + ) + })} + +
WorkflowWeightTypeChallenge Match
+ 🤖 + {workflowDetails.name} + {/*
+ {workflowDetails.description} +
*/} +
+ {workflow.weightPercent}% + + {workflow.isGating ? ( + ⚡GATE + ) : ( + ✓ Review + )} + + {isAssigned ? ( + ✅ Assigned + ) : ( + ⚠️ Not assigned + )} +
+
+ +

+ ✅ = Also assigned as AI Reviewer in this challenge
+ ⚠️ = Not assigned — will be auto-added on save +

+
+ )} + + {/* Summary Section */} + {selectedTemplate && ( + + )} + + {/* Remove Configuration Button */} + {!readOnly && selectedTemplate && ( +
+ +
+ )} + + {templatesLoading && ( +
Loading templates...
+ )} +
+ ) +} + +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, + metadata: PropTypes.shape({ + workflows: PropTypes.array, + }), +} + +TemplateConfigurationView.defaultProps = { + readOnly: false, + metadata: {}, +} + +export default TemplateConfigurationView diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js index 5367b28d..f7f5a3f8 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js @@ -5,7 +5,7 @@ import cn from 'classnames' import { loadScorecards, loadDefaultReviewers, loadWorkflows, replaceResourceInRole, createResource, deleteResource } from '../../../actions/challenges' import styles from './ChallengeReviewer-Field.module.scss' import HumanReviewTab from './HumanReviewTab' -import AIReviewTab from './AIReviewTab' +import { AiReviewTab } from './AiReviewerTab' // Keep track filters aligned with the scorecards API regardless of legacy values const SCORECARD_TRACK_ALIASES = { @@ -212,7 +212,7 @@ class ChallengeReviewerField extends Component { )} {activeTab === 'ai' && ( - 0 ? `?${qs.stringify(filters, { encode: false })}` : '' - const response = await axiosInstance.get(`${AI_REVIEW_TEMPLATES_API_URL}/ai-review/templates${queryString}`) + const response = await axiosInstance.get(`${TC_REVIEWS_API_BASE_URL}/ai-review/templates${queryString}`) return _.get(response, 'data', []) } catch (error) { console.error('Error fetching AI review templates:', error.message) @@ -29,7 +30,7 @@ export async function fetchAIReviewTemplates (filters = {}) { */ export async function fetchAIReviewTemplate (templateId) { try { - const response = await axiosInstance.get(`${AI_REVIEW_TEMPLATES_API_URL}/ai-review/templates/${templateId}`) + const response = await axiosInstance.get(`${TC_REVIEWS_API_BASE_URL}/ai-review/templates/${templateId}`) return _.get(response, 'data', {}) } catch (error) { console.error(`Error fetching AI review template ${templateId}:`, error.message) @@ -54,7 +55,7 @@ export async function createAIReviewTemplate (templateData) { } const response = await axiosInstance.post( - `${AI_REVIEW_TEMPLATES_API_URL}/ai-review/templates`, + `${TC_REVIEWS_API_BASE_URL}/ai-review/templates`, templateData ) return _.get(response, 'data', {}) @@ -81,7 +82,7 @@ export async function updateAIReviewTemplate (templateId, templateData) { } const response = await axiosInstance.put( - `${AI_REVIEW_TEMPLATES_API_URL}/ai-review/templates/${templateId}`, + `${TC_REVIEWS_API_BASE_URL}/ai-review/templates/${templateId}`, templateData ) return _.get(response, 'data', {}) @@ -98,7 +99,7 @@ export async function updateAIReviewTemplate (templateId, templateData) { */ export async function deleteAIReviewTemplate (templateId) { try { - await axiosInstance.delete(`${AI_REVIEW_TEMPLATES_API_URL}/ai-review/templates/${templateId}`) + await axiosInstance.delete(`${TC_REVIEWS_API_BASE_URL}/ai-review/templates/${templateId}`) } catch (error) { console.error(`Error deleting AI review template ${templateId}:`, error.message) throw error From da740a9573bce3ba8b0b917ed25fe794f2ee4ad2 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 20 Feb 2026 09:54:04 +0200 Subject: [PATCH 07/16] PM-3851 #time 2h refactor manual & template views --- .../AiReviewerTab/AiReviewTab.js | 57 ++++--- .../components/ManualWorkflowCard.js | 5 +- .../components/WeightValidationCard.js | 2 +- .../views/ManualConfigurationView.js | 144 ++++++++++++++++++ .../views/TemplateConfigurationView.js | 48 ++++-- 5 files changed, 221 insertions(+), 35 deletions(-) create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js index db8f08f3..f0d255df 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js @@ -7,6 +7,7 @@ import sharedStyles from '../shared.module.scss' import useConfigurationState from './hooks/useConfigurationState'; import InitialStateView from './views/InitialStateView'; import TemplateConfigurationView from './views/TemplateConfigurationView'; +import ManualConfigurationView from './views/ManualConfigurationView'; /** * AiReviewTab - Main component for managing AI review configuration @@ -45,8 +46,19 @@ const AiReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, r setConfigurationMode(null) resetConfiguration() }, [setConfigurationMode, resetConfiguration]) + + const handleSwitchConfigurationMode = useCallback((mode, template) => { + if (mode === 'manual') { + console.log('switch to manual', template) + if (template) { + resetConfiguration(template); + } + } else { + resetConfiguration() + } + setConfigurationMode(mode); + }, [setConfigurationMode]); - // Show loading state if (isLoading) { return
Loading...
} @@ -60,32 +72,35 @@ const AiReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, r configuration={configuration} onTemplateChange={applyTemplate} onUpdateConfiguration={updateConfiguration} - // onSwitchMode={handleSwitchConfigurationMode} + onSwitchMode={handleSwitchConfigurationMode} onRemoveConfig={handleRemoveConfiguration} readOnly={readOnly} - isAIReviewer={isAIReviewer} - metadata={metadata} + availableWorkflows={metadata.workflows || []} /> - {/* {showSwitchToManualConfirm && ( - -

The template settings will be copied into editable fields.

-

You can then modify workflows, weights, and settings individually.

-

This will disconnect from the template. Future template updates will not apply.

- - )} - cancelText='Cancel' - confirmText='Switch to Manual' - onCancel={() => setShowSwitchToManualConfirm(false)} - onConfirm={confirmSwitchToManual} - /> - )} */} ) } - + + // Show manual configuration if in manual mode + if (configurationMode === 'manual') { + return ( +
+ +
+ ) + } + // initial state (no configuration mode was selected: template/manual) return (
diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js index 5a9ee2af..f86b86f3 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js @@ -1,7 +1,8 @@ import React from 'react' import PropTypes from 'prop-types' import cn from 'classnames' -import styles from '../AIReviewTab.module.scss' +import styles from '../AiReviewTab.module.scss' +import { isAIReviewer } from '../utils' /** * Manual Workflow Card - Editable workflow configuration card @@ -15,7 +16,6 @@ const ManualWorkflowCard = ({ onUpdate, onRemove, readOnly, - isAIReviewer }) => { const workflowDetails = availableWorkflows.find(item => item.id === workflow.workflowId) || {} const isAssigned = (challenge.reviewers || []).some(r => @@ -124,7 +124,6 @@ ManualWorkflowCard.propTypes = { onUpdate: PropTypes.func.isRequired, onRemove: PropTypes.func.isRequired, readOnly: PropTypes.bool, - isAIReviewer: PropTypes.func.isRequired } ManualWorkflowCard.defaultProps = { diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/WeightValidationCard.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/WeightValidationCard.js index b570d920..0dd6fa55 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/WeightValidationCard.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/WeightValidationCard.js @@ -1,7 +1,7 @@ import React from 'react' import PropTypes from 'prop-types' import cn from 'classnames' -import styles from '../AIReviewTab.module.scss' +import styles from '../AiReviewTab.module.scss' /** * Weight Validation Card - Validates that scoring workflow weights total 100% diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js new file mode 100644 index 00000000..fcd986e8 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js @@ -0,0 +1,144 @@ +import React, { useState, useCallback } from 'react' +import PropTypes from 'prop-types' +import ConfigurationSourceSelector from '../components/ConfigurationSourceSelector' +import ReviewSettingsSection from '../components/ReviewSettingsSection' +import SummarySection from '../components/SummarySection' +import WeightValidationCard from '../components/WeightValidationCard' +import ManualWorkflowCard from '../components/ManualWorkflowCard' +import styles from '../AiReviewTab.module.scss' +import ConfirmationModal from '../../../../Modal/ConfirmationModal' + +/** + * Manual Configuration View - Manually configure AI review settings and workflows + */ +const ManualConfigurationView = ({ + challenge, + configuration, + availableWorkflows, + onUpdateConfiguration, + onAddWorkflow, + onUpdateWorkflow, + onRemoveWorkflow, + onSwitchMode, + onRemoveConfig, + readOnly, +}) => { + const [showSwitchConfirmModal, setShowSwitchConfirmModal] = useState(false) + + const handleConfirmSwitch = useCallback(() => { + onRemoveConfig(); + onSwitchMode('template'); + }, [onSwitchMode, onRemoveConfig]); + + return ( +
+ {/* Configuration Source Selector */} + setShowSwitchConfirmModal(true)} + readOnly={readOnly} + /> + + {/* Review Settings Section */} + + + {/* Manual Workflows Section */} +
+

AI Workflows (editable)

+ + {configuration.workflows.map((workflow, index) => ( + onRemoveWorkflow(index)} + readOnly={readOnly} + /> + ))} + + {!readOnly && ( + + )} +
+ + {/* Weight Validation Section */} + + + {/* Summary Section */} + + + {/* Action Buttons Section */} + {!readOnly && ( +
+ {/* */} +
+ +
+
+ )} + + {showSwitchConfirmModal && ( + +

Your current manual configuration will be discarded.

+

You can then select a template from a pre-defined list specific to your challenge type.

+
+ )} + cancelText='Cancel' + confirmText='Switch to Template' + onCancel={() => setShowSwitchConfirmModal(false)} + onConfirm={handleConfirmSwitch} + /> + )} +
+ ) +} + +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 index cbc090dd..dda1bf86 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react' +import React, { useCallback, useState } from 'react' import cn from 'classnames' import PropTypes from 'prop-types' import styles from '../AiReviewTab.module.scss' @@ -6,6 +6,7 @@ import useTemplateManager from '../hooks/useTemplateManager' import SummarySection from '../components/SummarySection' import ReviewSettingsSection from '../../AIReviewTab/components/ReviewSettingsSection' import ConfigurationSourceSelector from '../../AIReviewTab/components/ConfigurationSourceSelector' +import ConfirmationModal from '../../../../Modal/ConfirmationModal' /** * Template Configuration View - Select and configure using a template @@ -18,9 +19,8 @@ const TemplateConfigurationView = ({ onSwitchMode, onRemoveConfig, readOnly, - metadata, + availableWorkflows: workflows, }) => { - const { workflows = [] } = metadata const { templates, selectedTemplate, @@ -33,6 +33,7 @@ const TemplateConfigurationView = ({ challenge.track.name, challenge.type.name, ) + const [showSwitchToManualConfirm, setShowSwitchToManualConfirm] = useState(false) const handleTemplateChange = useCallback((e) => { const templateId = e.target.value @@ -45,8 +46,22 @@ const TemplateConfigurationView = ({ const handleRemoveConfig = useCallback(() => { clearSelection() onRemoveConfig() - }, [onRemoveConfig, clearSelection]) - + }, [onRemoveConfig, clearSelection]); + + const handleConfirmSwitch = useCallback(() => { + clearSelection(); + onSwitchMode('manual', selectedTemplate); + }, [onSwitchMode, selectedTemplate]); + + const handleOnSwitchConfig = useCallback(() => { + if (selectedTemplate?.id) { + setShowSwitchToManualConfirm(true); + } else { + handleConfirmSwitch(); + } + }, [ + selectedTemplate, setShowSwitchToManualConfirm, handleConfirmSwitch + ]); if (templateError) { return ( @@ -62,7 +77,7 @@ const TemplateConfigurationView = ({ ConfigurationSourceSelector onSwitchMode('manual')} + onSwitch={handleOnSwitchConfig} readOnly={readOnly} /> @@ -182,6 +197,22 @@ const TemplateConfigurationView = ({ {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} + /> + )} ) } @@ -199,14 +230,11 @@ TemplateConfigurationView.propTypes = { onSwitchMode: PropTypes.func.isRequired, onRemoveConfig: PropTypes.func.isRequired, readOnly: PropTypes.bool, - metadata: PropTypes.shape({ - workflows: PropTypes.array, - }), + availableWorkflows: PropTypes.array.isRequired, } TemplateConfigurationView.defaultProps = { readOnly: false, - metadata: {}, } export default TemplateConfigurationView From 4fa4d79f97d8954b9d206690e5b2c1028cdf9c57 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 20 Feb 2026 10:32:03 +0200 Subject: [PATCH 08/16] PM-3851 #time 1h cleanup --- .../AIReviewTab/AIReviewTab.module.scss | 847 ------------------ .../components/ConfigurationSourceSelector.js | 58 -- .../components/ManualWorkflowCard.js | 134 --- .../components/ReviewSettingsSection.js | 89 -- .../AIReviewTab/components/SummarySection.js | 49 - .../components/WeightValidationCard.js | 49 - .../hooks/useConfigurationState.js | 142 --- .../AIReviewTab/hooks/useTemplateManager.js | 76 -- .../AIReviewTab/index.js | 614 ------------- .../AIReviewTab/views/InitialStateView.js | 91 -- .../views/ManualConfigurationView.js | 125 --- .../views/TemplateConfigurationView.js | 179 ---- .../AiReviewerTab/AiReviewTab.js | 3 +- .../hooks/useConfigurationState.js | 5 + .../views/TemplateConfigurationView.js | 4 +- 15 files changed, 8 insertions(+), 2457 deletions(-) delete mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/AIReviewTab.module.scss delete mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ConfigurationSourceSelector.js delete mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ManualWorkflowCard.js delete mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ReviewSettingsSection.js delete mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/SummarySection.js delete mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/WeightValidationCard.js delete mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/hooks/useConfigurationState.js delete mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/hooks/useTemplateManager.js delete mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/index.js delete mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/InitialStateView.js delete mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/ManualConfigurationView.js delete mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/TemplateConfigurationView.js diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/AIReviewTab.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/AIReviewTab.module.scss deleted file mode 100644 index 72e91bc7..00000000 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/AIReviewTab.module.scss +++ /dev/null @@ -1,847 +0,0 @@ -@use '../../../../styles/includes' as *; -@import '../shared.module.scss'; - -.initialStateContainer { - display: flex; - flex-direction: column; - gap: 30px; -} - -.warningBox { - display: flex; - gap: 16px; - background-color: #fff8e1; - border: 2px solid #ffd54f; - border-radius: 8px; - padding: 20px; - align-items: flex-start; -} - -.warningIcon { - font-size: 28px; - flex-shrink: 0; -} - -.warningContent { - flex: 1; - - h3 { - margin: 0 0 10px 0; - font-size: 18px; - font-weight: 600; - color: #333; - } - - p { - margin: 8px 0; - color: #555; - font-size: 14px; - line-height: 1.5; - } - - p strong { - font-weight: 600; - } -} - -.configurationOptions { - display: flex; - gap: 20px; - flex-wrap: wrap; - - @media (max-width: 768px) { - flex-direction: column; - } -} - -.optionCard { - flex: 1; - min-width: 250px; - border: 2px solid #e0e0e0; - border-radius: 8px; - padding: 24px; - text-align: center; - transition: all 0.3s ease; - background-color: #fafafa; - - &:hover { - border-color: #0066cc; - box-shadow: 0 4px 12px rgba(0, 102, 204, 0.15); - } - - h4 { - margin: 0 0 8px 0; - font-size: 16px; - font-weight: 600; - color: #333; - } - - p { - margin: 0 0 16px 0; - color: #666; - font-size: 14px; - line-height: 1.5; - } -} - -.optionIcon { - font-size: 36px; - margin-bottom: 12px; - display: block; -} - -.optionButton { - padding: 10px 24px; - background-color: #0066cc; - color: white; - border: none; - border-radius: 4px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: background-color 0.3s ease; - - &:hover { - background-color: #0052a3; - } - - &:active { - background-color: #004080; - } -} - -.assignedWorkflowsSection { - padding-top: 20px; - border-top: 1px solid #e0e0e0; - - h3 { - margin: 0 0 8px 0; - font-size: 16px; - font-weight: 600; - color: #333; - } - - p { - margin: 0 0 16px 0; - color: #666; - font-size: 14px; - } -} - -.workflowsList { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 16px; - - @media (max-width: 768px) { - grid-template-columns: 1fr; - } -} - -// Template Configuration Styles -.templateConfiguration { - display: flex; - flex-direction: column; - gap: 32px; -} - -.configurationSourceSelector { - display: flex; - flex-direction: column; - gap: 12px; - padding: 16px; - background-color: #f5f5f5; - border-radius: 8px; - - h4 { - margin: 0; - font-size: 14px; - font-weight: 600; - color: #333; - } -} - -.sourceOptions { - display: flex; - align-items: center; - gap: 20px; - - .radioLabel { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; - font-size: 14px; - color: #555; - - input[type='radio'] { - cursor: pointer; - } - } - - .switchButton { - margin-left: auto; - padding: 6px 16px; - background-color: #0066cc; - color: white; - border: none; - border-radius: 4px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: background-color 0.3s ease; - - &:hover { - background-color: #0052a3; - } - } -} - -.templateSection { - display: flex; - flex-direction: column; - gap: 16px; - - h3 { - margin: 0; - font-size: 16px; - font-weight: 600; - color: #333; - } -} - -.templateSelector { - display: flex; - flex-direction: column; - gap: 8px; - - .templateDropdown { - padding: 10px 12px; - border: 1px solid #d0d0d0; - border-radius: 4px; - font-size: 14px; - background-color: white; - cursor: pointer; - transition: border-color 0.3s ease; - - &:hover:not(:disabled) { - border-color: #0066cc; - } - - &:focus { - outline: none; - border-color: #0066cc; - box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1); - } - - &:disabled { - background-color: #f5f5f5; - cursor: not-allowed; - color: #999; - } - } -} - -.templateDescription { - padding: 12px 16px; - background-color: #f0f8ff; - border-left: 4px solid #0066cc; - border-radius: 4px; - font-size: 13px; - color: #555; - line-height: 1.6; - - p { - margin: 0; - } -} - -.reviewSettingsSection { - display: flex; - flex-direction: column; - gap: 20px; - padding: 20px; - background-color: #fafafa; - border-radius: 8px; - border: 1px solid #e0e0e0; - - h3 { - margin: 0 0 8px 0; - font-size: 16px; - font-weight: 600; - color: #333; - } -} - -.settingsGrid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 24px; - - @media (max-width: 768px) { - grid-template-columns: 1fr; - } -} - -.setting { - display: flex; - flex-direction: column; - gap: 8px; - - label { - font-size: 14px; - font-weight: 500; - color: #333; - } - - select { - padding: 8px 10px; - border: 1px solid #d0d0d0; - border-radius: 4px; - font-size: 14px; - background-color: white; - cursor: pointer; - - &:disabled { - background-color: #f5f5f5; - cursor: not-allowed; - color: #999; - } - } - - .checkboxLabel { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; - font-size: 14px; - color: #555; - - input[type='checkbox'] { - cursor: pointer; - } - } - - .modeInfo, - .autoFinalizeInfo { - margin: 0; - font-size: 12px; - color: #888; - font-style: italic; - } -} - -.thresholdSection { - display: flex; - flex-direction: column; - gap: 12px; - - label { - font-size: 14px; - font-weight: 500; - color: #333; - } - - .thresholdSlider { - display: flex; - align-items: center; - gap: 12px; - - .slider { - flex: 1; - height: 6px; - border-radius: 3px; - background: linear-gradient(to right, #d0d0d0 0%, #d0d0d0 100%); - outline: none; - -webkit-appearance: none; - appearance: none; - - &::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 16px; - height: 16px; - border-radius: 50%; - background: #0066cc; - cursor: pointer; - transition: background-color 0.3s ease; - - &:hover { - background-color: #0052a3; - } - } - - &::-moz-range-thumb { - width: 16px; - height: 16px; - border-radius: 50%; - background: #0066cc; - cursor: pointer; - border: none; - transition: background-color 0.3s ease; - - &:hover { - background-color: #0052a3; - } - } - } - - .thresholdValue { - font-weight: 600; - color: #0066cc; - min-width: 50px; - text-align: right; - font-size: 14px; - } - } -} - -.workflowsSection { - display: flex; - flex-direction: column; - gap: 16px; - - h3 { - margin: 0; - font-size: 16px; - font-weight: 600; - color: #333; - - .workflowsNote { - font-size: 12px; - font-weight: 400; - color: #888; - margin-left: 8px; - } - } -} - -.workflowsTable { - overflow-x: auto; - - table { - width: 100%; - border-collapse: collapse; - border: 1px solid #e0e0e0; - border-radius: 4px; - - thead { - background-color: #f5f5f5; - - th { - padding: 12px; - text-align: left; - font-size: 13px; - font-weight: 600; - color: #333; - border-bottom: 2px solid #e0e0e0; - } - } - - tbody { - tr { - border-bottom: 1px solid #e0e0e0; - transition: background-color 0.2s ease; - - &:hover { - background-color: #fafafa; - } - - &:last-child { - border-bottom: none; - } - - td { - padding: 12px; - font-size: 14px; - color: #555; - vertical-align: top; - - &.weight { - font-weight: 600; - color: #0066cc; - } - - &.type { - .gatingBadge { - display: inline-block; - padding: 2px 8px; - background-color: #fff3cd; - border: 1px solid #ffc107; - border-radius: 3px; - font-size: 12px; - font-weight: 500; - color: #856404; - } - - .normalBadge { - display: inline-block; - padding: 2px 8px; - background-color: #d1ecf1; - border: 1px solid #17a2b8; - border-radius: 3px; - font-size: 12px; - font-weight: 500; - color: #0c5460; - } - } - - &.match { - .assignedBadge { - display: inline-block; - padding: 2px 8px; - background-color: #d4edda; - border: 1px solid #28a745; - border-radius: 3px; - font-size: 12px; - font-weight: 500; - color: #155724; - } - - .notAssignedBadge { - display: inline-block; - padding: 2px 8px; - background-color: #fff3cd; - border: 1px solid #ffc107; - border-radius: 3px; - font-size: 12px; - font-weight: 500; - color: #856404; - } - } - } - } - } - } -} - -.workflowIcon { - margin-right: 6px; - font-size: 14px; -} - -.workflowName { - font-weight: 500; - color: #333; -} - -.workflowDescription { - font-size: 12px; - color: #888; - margin-top: 4px; - font-style: italic; -} - -.workflowsInfo { - font-size: 12px; - color: #888; - margin: 0; - line-height: 1.6; -} - -.summarySection { - display: flex; - flex-direction: column; - gap: 12px; - padding: 20px; - background-color: #f0f8ff; - border: 1px solid #b3d9ff; - border-radius: 8px; - - h3 { - margin: 0 0 8px 0; - font-size: 16px; - font-weight: 600; - color: #333; - } -} - -.summaryGrid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 16px; -} - -.summaryCard { - padding: 12px; - background-color: white; - border: 1px solid #d0e8ff; - border-radius: 4px; - text-align: center; - - h4 { - margin: 0 0 8px 0; - font-size: 12px; - font-weight: 500; - color: #888; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .summaryValue { - font-size: 16px; - font-weight: 600; - color: #0066cc; - - .summarySubtext { - font-size: 12px; - color: #888; - font-weight: 400; - margin-top: 4px; - } - } -} - -.removeConfigSection { - display: flex; - justify-content: center; - padding-top: 12px; - border-top: 1px solid #e0e0e0; - - .removeConfigButton { - padding: 10px 24px; - background-color: #dc3545; - color: white; - border: none; - border-radius: 4px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: background-color 0.3s ease; - - &:hover { - background-color: #c82333; - } - - &:active { - background-color: #bd2130; - } - } -} - -.actionButtonsSection { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 20px; - - .saveConfigButton { - padding: 10px 24px; - background-color: #28a745; - color: white; - border: none; - border-radius: 4px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: background-color 0.3s ease; - - &:hover { - background-color: #218838; - } - - &:active { - background-color: #1e7e34; - } - } -} - -// Manual Configuration Styles -.manualConfiguration { - display: flex; - flex-direction: column; - gap: 32px; -} - -.manualWorkflowsSection { - display: flex; - flex-direction: column; - gap: 16px; - - h3 { - margin: 0; - font-size: 16px; - font-weight: 600; - color: #333; - } -} - -.manualWorkflowCard { - border: 1px solid #e0e0e0; - border-radius: 8px; - padding: 16px; - background-color: #fafafa; - display: flex; - flex-direction: column; - gap: 16px; -} - -.manualWorkflowHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.manualWorkflowTitle { - font-weight: 600; - color: #333; -} - -.removeWorkflowButton { - padding: 6px 12px; - border-radius: 4px; - border: 1px solid #dc3545; - background-color: #fff; - color: #dc3545; - font-size: 12px; - cursor: pointer; - transition: all 0.2s ease; - - &:hover { - background-color: #dc3545; - color: white; - } -} - -.manualWorkflowBody { - display: flex; - flex-direction: column; - gap: 12px; -} - -.manualWorkflowField { - display: flex; - flex-direction: column; - gap: 8px; - - label { - font-size: 13px; - font-weight: 500; - color: #333; - } -} - -.manualWorkflowRow { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px; -} - -.workflowSelect, -.weightInput { - padding: 8px 10px; - border: 1px solid #d0d0d0; - border-radius: 4px; - font-size: 14px; - background-color: #fff; - - &:disabled { - background-color: #f5f5f5; - color: #999; - cursor: not-allowed; - } -} - -.toggleLabel { - display: flex; - align-items: center; - gap: 8px; - font-size: 14px; - color: #555; - margin-top: 10px; - margin-bottom: 11px; - - input { - cursor: pointer; - width: auto; - } -} - -.fieldHint { - font-size: 12px; - color: #888; -} - -.assignmentNotice { - padding: 10px 12px; - border-radius: 6px; - font-size: 12px; - line-height: 1.5; -} - -.assignmentMatch { - background-color: #e8f5e9; - border: 1px solid #2e7d32; - color: #1b5e20; -} - -.assignmentMissing { - background-color: #fff8e1; - border: 1px solid #f9a825; - color: #8d6e00; -} - -.workflowDescriptionHint { - font-size: 12px; - color: #777; - white-space: pre-wrap; -} - -.addWorkflowButton { - align-self: flex-start; - padding: 8px 16px; - border-radius: 4px; - border: 1px solid #0066cc; - background-color: #fff; - color: #0066cc; - font-size: 13px; - cursor: pointer; - transition: all 0.2s ease; - - &:hover { - background-color: #0066cc; - color: white; - } -} - -.weightValidationSection { - display: flex; - flex-direction: column; - gap: 12px; - - h3 { - margin: 0; - font-size: 16px; - font-weight: 600; - color: #333; - } -} - -.validationCard { - padding: 16px; - border-radius: 8px; - border: 1px solid #e0e0e0; - background-color: #fafafa; - font-size: 13px; - display: flex; - flex-direction: column; - gap: 6px; -} - -.validationSuccess { - border-color: #2e7d32; - background-color: #e8f5e9; - color: #1b5e20; -} - -.validationWarning { - border-color: #f9a825; - background-color: #fff8e1; - color: #8d6e00; -} - -.validationMessage { - font-size: 12px; - color: inherit; -} \ No newline at end of file diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ConfigurationSourceSelector.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ConfigurationSourceSelector.js deleted file mode 100644 index 4c059bec..00000000 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ConfigurationSourceSelector.js +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import styles from '../AIReviewTab.module.scss' - -/** - * Configuration Source Selector - Toggle between template and manual configuration - */ -const ConfigurationSourceSelector = ({ mode, onSwitch, readOnly }) => { - return ( -
-

Configuration Source:

-
- - - {!readOnly && ( - - )} -
-
- ) -} - -ConfigurationSourceSelector.propTypes = { - mode: PropTypes.oneOf(['template', 'manual']).isRequired, - onSwitch: PropTypes.func.isRequired, - readOnly: PropTypes.bool -} - -ConfigurationSourceSelector.defaultProps = { - readOnly: false -} - -export default ConfigurationSourceSelector diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ManualWorkflowCard.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ManualWorkflowCard.js deleted file mode 100644 index 5a9ee2af..00000000 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ManualWorkflowCard.js +++ /dev/null @@ -1,134 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import cn from 'classnames' -import styles from '../AIReviewTab.module.scss' - -/** - * Manual Workflow Card - Editable workflow configuration card - * Allows user to configure workflow ID, weight, and gating status - */ -const ManualWorkflowCard = ({ - workflow, - index, - availableWorkflows, - challenge, - onUpdate, - onRemove, - readOnly, - isAIReviewer -}) => { - const workflowDetails = availableWorkflows.find(item => item.id === workflow.workflowId) || {} - const isAssigned = (challenge.reviewers || []).some(r => - isAIReviewer(r) && r.aiWorkflowId === workflow.workflowId - ) - - return ( -
-
-
Workflow {index + 1}
- {!readOnly && ( - - )} -
- -
-
- - -
- -
-
- - onUpdate( - index, - 'weightPercent', - parseInt(e.target.value, 10) || 0 - )} - disabled={readOnly || workflow.isGating} - className={styles.weightInput} - /> -
Weight for scoring. Ignored if gating.
-
- -
- - -
- {workflow.isGating ? '⚡ Pass/fail gate.' : 'Submissions below threshold are locked.'} -
-
-
- - {workflow.workflowId && ( -
- {isAssigned - ? 'Matched: This workflow is assigned as AI Reviewer for this challenge.' - : 'Not assigned: will be auto-added on save.'} -
- )} - - {workflowDetails.description && ( -
{workflowDetails.description}
- )} -
-
- ) -} - -ManualWorkflowCard.propTypes = { - workflow: PropTypes.shape({ - workflowId: PropTypes.string, - weightPercent: PropTypes.number, - isGating: PropTypes.bool - }).isRequired, - index: PropTypes.number.isRequired, - availableWorkflows: PropTypes.array.isRequired, - challenge: PropTypes.object.isRequired, - onUpdate: PropTypes.func.isRequired, - onRemove: PropTypes.func.isRequired, - readOnly: PropTypes.bool, - isAIReviewer: PropTypes.func.isRequired -} - -ManualWorkflowCard.defaultProps = { - readOnly: false -} - -export default ManualWorkflowCard diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ReviewSettingsSection.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ReviewSettingsSection.js deleted file mode 100644 index 1fcdf3be..00000000 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/ReviewSettingsSection.js +++ /dev/null @@ -1,89 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import styles from '../AIReviewTab.module.scss' - -/** - * Review Settings Section - Mode, auto-finalize, and threshold configuration - */ -const ReviewSettingsSection = ({ configuration, onUpdateConfiguration, readOnly, showTitle = true }) => { - return ( -
- {showTitle &&

⚙️ Review Settings

} - -
- {/* Review Mode */} -
- - -

- {configuration.mode === 'AI_GATING' - ? 'AI gates low-quality submissions; humans review the rest.' - : 'AI makes the final decision on all submissions.'} -

-
- - {/* Auto-Finalize */} -
- - -

- Only available in AI_ONLY mode -

-
-
- - {/* Min Passing Threshold Slider */} -
- -
- onUpdateConfiguration('minPassingThreshold', parseInt(e.target.value, 10))} - disabled={readOnly} - className={styles.slider} - /> - - {configuration.minPassingThreshold} % - -
-
-
- ) -} - -ReviewSettingsSection.propTypes = { - configuration: PropTypes.shape({ - mode: PropTypes.string.isRequired, - minPassingThreshold: PropTypes.number.isRequired, - autoFinalize: PropTypes.bool.isRequired - }).isRequired, - onUpdateConfiguration: PropTypes.func.isRequired, - readOnly: PropTypes.bool, - showTitle: PropTypes.bool -} - -ReviewSettingsSection.defaultProps = { - readOnly: false, - showTitle: true -} - -export default ReviewSettingsSection diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/SummarySection.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/SummarySection.js deleted file mode 100644 index 8f73cdb1..00000000 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/SummarySection.js +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import styles from '../AIReviewTab.module.scss' - -/** - * Summary Section - Display configuration summary with cards - */ -const SummarySection = ({ configuration }) => { - return ( -
-

Summary

-
-
-

Mode

-
{configuration.mode}
-
-
-

Threshold

-
{configuration.minPassingThreshold}%
-
-
-

Workflows

-
- {configuration.workflows.length} total - {configuration.workflows.some(w => w.isGating) && ( -
- {configuration.workflows.filter(w => w.isGating).length} gating -
- )} -
-
-
-
- ) -} - -SummarySection.propTypes = { - configuration: PropTypes.shape({ - mode: PropTypes.string.isRequired, - minPassingThreshold: PropTypes.number.isRequired, - workflows: PropTypes.arrayOf( - PropTypes.shape({ - isGating: PropTypes.bool - }) - ).isRequired - }).isRequired -} - -export default SummarySection diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/WeightValidationCard.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/WeightValidationCard.js deleted file mode 100644 index b570d920..00000000 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/components/WeightValidationCard.js +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import cn from 'classnames' -import styles from '../AIReviewTab.module.scss' - -/** - * Weight Validation Card - Validates that scoring workflow weights total 100% - */ -const WeightValidationCard = ({ workflows }) => { - const scoringWorkflows = workflows.filter(workflow => !workflow.isGating) - const gatingWorkflows = workflows.filter(workflow => workflow.isGating) - const scoringTotal = scoringWorkflows.reduce((sum, workflow) => sum + (Number(workflow.weightPercent) || 0), 0) - const hasScoringWorkflows = scoringWorkflows.length > 0 - const isWeightValid = !hasScoringWorkflows || Math.abs(scoringTotal - 100) < 0.01 - const remainingWeight = Math.round((100 - scoringTotal) * 100) / 100 - const scoringSummary = hasScoringWorkflows - ? `${scoringWorkflows.map(workflow => `${Number(workflow.weightPercent) || 0}%`).join(' + ')} = ${scoringTotal}%` - : 'no scoring workflows' - - return ( -
-

Weight Validation

-
-
- Scoring workflows weight total: {scoringSummary} {isWeightValid ? 'OK' : 'Invalid'} -
- {!isWeightValid && hasScoringWorkflows && ( -
- Scoring workflow weights must total 100%. {remainingWeight > 0 - ? `Remaining: ${remainingWeight}% unassigned.` - : `Over by ${Math.abs(remainingWeight)}%.`} -
- )} -
Gating workflows: {gatingWorkflows.length}
-
-
- ) -} - -WeightValidationCard.propTypes = { - workflows: PropTypes.arrayOf( - PropTypes.shape({ - weightPercent: PropTypes.number, - isGating: PropTypes.bool - }) - ).isRequired -} - -export default WeightValidationCard diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/hooks/useConfigurationState.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/hooks/useConfigurationState.js deleted file mode 100644 index 1cfb4b1f..00000000 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/hooks/useConfigurationState.js +++ /dev/null @@ -1,142 +0,0 @@ -import { useState, useCallback, useMemo, useEffect } from 'react' -import { createConfigManager } from '../../../../../services/aiReviewConfigHelpers' - -/** - * Custom hook for managing AI Review configuration state - * Handles configuration object with mode, threshold, autoFinalize, and workflows - */ -const useConfigurationState = ( - challengeId, - initialConfig = { - mode: 'AI_GATING', - minPassingThreshold: 75, - autoFinalize: false, - workflows: [] - }, -) => { - const configManager = useMemo(() => createConfigManager(true), []) - const [configuration, setConfiguration] = useState(initialConfig) - const [configurationMode, setConfigurationMode] = useState(null) - - /** - * Update a single field in the configuration - */ - const updateConfiguration = useCallback((field, value) => { - setConfiguration(prev => ({ - ...prev, - [field]: value - })) - }, []) - - /** - * Add a new workflow to the configuration - */ - const addWorkflow = useCallback(() => { - setConfiguration(prev => ({ - ...prev, - workflows: prev.workflows.concat([ - { workflowId: '', weightPercent: 0, isGating: false } - ]) - })) - }, [setConfiguration]) - - /** - * Update a specific workflow in the configuration - */ - const updateWorkflow = useCallback((index, field, value) => { - setConfiguration(prev => { - const workflows = prev.workflows.map((workflow, idx) => { - if (idx !== index) { - return workflow - } - - const nextWorkflow = { - ...workflow, - [field]: value - } - - // Reset weight if workflow becomes gating - if (field === 'isGating' && value) { - nextWorkflow.weightPercent = 0 - } - - return nextWorkflow - }) - - return { - ...prev, - workflows - } - }) - }, [setConfiguration]) - - /** - * Remove a workflow from the configuration - */ - const removeWorkflow = useCallback((index) => { - setConfiguration(prev => ({ - ...prev, - workflows: prev.workflows.filter((_, idx) => idx !== index) - })) - }, [setConfiguration]) - - /** - * Reset configuration to default or provided state - */ - const resetConfiguration = useCallback((newConfig = initialConfig) => { - setConfiguration(newConfig) - }, [setConfiguration]) - - /** - * Apply a template to the configuration - */ - const applyTemplate = useCallback((template) => { - if (!template) return - - const newConfiguration = { - mode: template.mode || 'AI_GATING', - minPassingThreshold: template.minPassingThreshold || 75, - autoFinalize: template.autoFinalize || false, - workflows: template.workflows || [] - } - - setConfiguration(newConfiguration) - }, [setConfiguration]) - - - // Fetch AI review config when component loads - useEffect(() => { - const loadAIReviewConfig = async () => { - try { - console.log('here2', challengeId) - if (challengeId) { - console.log('here3', challengeId) - const config = await configManager.fetchByChallenge(challengeId) - if (config) { - // Load the config into the configuration state - setConfigurationMode('manual') - updateConfiguration(config) - } - } - } catch (err) { - console.error('Error loading AI review configuration:', err) - } - } - - loadAIReviewConfig() - }, [challengeId, updateConfiguration, configManager]) - - return { - configuration, - configurationMode, - setConfigurationMode, - updateConfiguration, - addWorkflow, - updateWorkflow, - removeWorkflow, - resetConfiguration, - applyTemplate - } -} - -export default useConfigurationState diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/hooks/useTemplateManager.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/hooks/useTemplateManager.js deleted file mode 100644 index 265f834b..00000000 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/hooks/useTemplateManager.js +++ /dev/null @@ -1,76 +0,0 @@ -import { useState, useMemo } from 'react' -import { createTemplateManager } from '../../../../../services/aiReviewTemplateHelpers' - -/** - * Custom hook for managing AI Review templates - * Handles template loading, selection, and state management - */ -const useTemplateManager = () => { - const [templates, setTemplates] = useState([]) - const [selectedTemplate, setSelectedTemplate] = useState(null) - const [templatesLoading, setTemplatesLoading] = useState(false) - const [error, setError] = useState(null) - - const templateManager = useMemo(() => createTemplateManager(false), []) - - /** - * Load templates based on challenge track and type - */ - const loadTemplates = async (challengeTrack, challengeType) => { - setTemplatesLoading(true) - setError(null) - - try { - const fetchedTemplates = await templateManager.fetchAll({ - challengeTrack, - challengeType - }) - - setTemplates(fetchedTemplates || []) - setTemplatesLoading(false) - } catch (err) { - console.error('Error loading templates:', err) - setError('Failed to load templates') - setTemplatesLoading(false) - } - } - - /** - * Select a template by ID - */ - const selectTemplate = (templateId) => { - const template = templates.find(t => t.id === templateId) - setSelectedTemplate(template || null) - return template || null - } - - /** - * Clear selected template - */ - const clearSelection = () => { - setSelectedTemplate(null) - } - - /** - * Reset all state - */ - const reset = () => { - setTemplates([]) - setSelectedTemplate(null) - setTemplatesLoading(false) - setError(null) - } - - return { - templates, - selectedTemplate, - templatesLoading, - error, - loadTemplates, - selectTemplate, - clearSelection, - reset - } -} - -export default useTemplateManager diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/index.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/index.js deleted file mode 100644 index 96866621..00000000 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/index.js +++ /dev/null @@ -1,614 +0,0 @@ -import React, { useState, useEffect } from 'react' -import PropTypes from 'prop-types' -import cn from 'classnames' -import { OutlineButton } from '../../../Buttons' -import { createAIReviewConfig, updateAIReviewConfig, fetchAIReviewConfigByChallenge } from '../../../../services/aiReviewConfigs' -import ConfirmationModal from '../../../Modal/ConfirmationModal' -import useConfigurationState from './hooks/useConfigurationState' -import useTemplateManager from './hooks/useTemplateManager' -import InitialStateView from './views/InitialStateView' -import TemplateConfigurationView from './views/TemplateConfigurationView' -import ManualConfigurationView from './views/ManualConfigurationView' -import styles from './AIReviewTab.module.scss' -import sharedStyles from '../shared.module.scss' - -/** - * AIReviewTab - Main component for managing AI review configuration - * Orchestrates between different views: initial state, template, manual, and legacy - */ -const AIReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, readOnly = false }) => { - const [error, setError] = useState(null) - const [showSwitchToManualConfirm, setShowSwitchToManualConfirm] = useState(false) - - useEffect(() => { - challenge.reviewers.forEach(r => { - if (r.isMemberReview) { - return; - } - r.aiReviewTemplateId = 'template_001'; - }) - }, [challenge]); - - const { - configuration, - configurationMode, - setConfigurationMode, - updateConfiguration, - addWorkflow, - updateWorkflow, - removeWorkflow, - resetConfiguration, - applyTemplate - } = useConfigurationState(challenge) - - const { - templates, - selectedTemplate, - templatesLoading, - error: templateError, - loadTemplates, - selectTemplate, - clearSelection - } = useTemplateManager() - - /** - * Check if a reviewer is an AI reviewer - */ - const isAIReviewer = (reviewer) => { - return reviewer && ( - (reviewer.aiWorkflowId && reviewer.aiWorkflowId.trim() !== '') || - (reviewer.isMemberReview === false) - ) - } - - /** - * Check if we're in initial state (AI reviewers assigned but no config) - */ - const isInitialState = () => { - const aiReviewers = (challenge.reviewers || []).filter(r => isAIReviewer(r)) - return aiReviewers.length > 0 && !configurationMode - } - - /** - * Get workflows assigned to this challenge - */ - const getAssignedWorkflows = () => { - const { workflows = [] } = metadata - const aiReviewers = (challenge.reviewers || []).filter(r => isAIReviewer(r)) - - return aiReviewers.map(reviewer => { - const workflow = workflows.find(w => w.id === reviewer.aiWorkflowId) - return { - reviewer, - workflow, - scorecardId: reviewer.scorecardId - } - }) - } - - /** - * Handle template selection mode - */ - const handleTemplateSelection = () => { - setConfigurationMode('template') - loadTemplates(challenge.track.name, challenge.type.name) - } - - /** - * Handle manual configuration mode - */ - const handleManualConfiguration = () => { - setConfigurationMode('manual') - } - - /** - * Handle switching between configuration modes - */ - const handleSwitchConfigurationMode = (newMode) => { - if (newMode === 'manual' && configurationMode === 'template') { - setShowSwitchToManualConfirm(true) - return - } - - setConfigurationMode(newMode) - clearSelection() - - if (newMode === 'template') { - resetConfiguration() - loadTemplates(challenge.track.name, challenge.type.name) - } - } - - /** - * Confirm switch to manual mode - */ - const confirmSwitchToManual = () => { - setShowSwitchToManualConfirm(false) - setConfigurationMode('manual') - clearSelection() - } - - /** - * Handle template change - */ - const handleTemplateChange = (templateId) => { - const template = selectTemplate(templateId) - if (template) { - applyTemplate(template) - } - } - - /** - * Handle remove configuration - */ - const handleRemoveConfiguration = () => { - setConfigurationMode(null) - clearSelection() - resetConfiguration() - } - - /** - * Handle save configuration - */ - const handleSaveConfiguration = async () => { - // Validate configuration before saving - if (!configuration.workflows || configuration.workflows.length === 0) { - setError('At least one AI workflow is required') - return - } - - // Check if any workflow has invalid settings - const hasInvalidWorkflow = configuration.workflows.some(workflow => { - return !workflow.workflowId - }) - - if (hasInvalidWorkflow) { - setError('All workflows must have a name and ID') - return - } - - try { - // Prepare configuration data for API - const configData = { - challengeId: challenge.id, - minPassingThreshold: configuration.minPassingThreshold || 0, - mode: configuration.mode || 'AI_ONLY', - workflows: configuration.workflows - } - - // Create or update the configuration via API - let savedConfig = null - const existingConfig = await fetchAIReviewConfigByChallenge(challenge.id) - - if (existingConfig && existingConfig.id) { - // Update existing config - savedConfig = await updateAIReviewConfig(existingConfig.id, configData) - } else { - // Create new config - savedConfig = await createAIReviewConfig(configData) - } - - // Ensure reviewers exist for each workflow in the configuration - const currentReviewers = challenge.reviewers || [] - let updatedReviewers = [...currentReviewers] - - // Find the first available review phase - const reviewPhases = challenge.phases && challenge.phases.filter(phase => - phase.name && (phase.name.toLowerCase().includes('review') || phase.name.toLowerCase().includes('submission')) - ) - const defaultPhaseId = (reviewPhases && reviewPhases.length > 0) - ? (reviewPhases[0].phaseId || reviewPhases[0].id) - : (challenge.phases && challenge.phases.length > 0) - ? (challenge.phases[0].phaseId || challenge.phases[0].id) - : '' - - // For each workflow in the configuration, ensure there's a corresponding reviewer - configuration.workflows.forEach(workflow => { - const workflowExists = updatedReviewers.some(reviewer => - isAIReviewer(reviewer) && reviewer.aiWorkflowId === workflow.workflowId - ) - - if (!workflowExists) { - // Add a new AI reviewer for this workflow - const newReviewer = { - isMemberReview: false, - phaseId: defaultPhaseId, - aiWorkflowId: workflow.workflowId, - scorecardId: workflow.scorecardId - } - updatedReviewers.push(newReviewer) - } - }) - - // Update all AI reviewers with the saved configuration - updatedReviewers = updatedReviewers.map(reviewer => { - if (isAIReviewer(reviewer)) { - return { - ...reviewer, - aiReviewConfiguration: savedConfig || configuration - } - } - return reviewer - }) - - if (error) { - setError(null) - } - - // Update the challenge with the new reviewers and configuration - onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) - } catch (err) { - setError(`Failed to save AI review configuration: ${err.message}`) - console.error('Error saving AI review configuration:', err) - } - } - - /** - * Add an AI reviewer - */ - const addAIReviewer = () => { - const { workflows = [] } = metadata - const currentReviewers = challenge.reviewers || [] - - const defaultWorkflow = workflows && workflows.length > 0 ? workflows[0] : null - - const reviewPhases = challenge.phases && challenge.phases.filter(phase => - phase.name && (phase.name.toLowerCase().includes('review') || phase.name.toLowerCase().includes('submission')) - ) - 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 (firstReviewPhase) { - defaultPhaseId = firstReviewPhase.phaseId || firstReviewPhase.id - } else if (fallbackPhase) { - defaultPhaseId = fallbackPhase.phaseId || fallbackPhase.id - } - - const scorecardId = defaultWorkflow && defaultWorkflow.scorecardId ? defaultWorkflow.scorecardId : undefined - - const newReviewer = { - scorecardId, - isMemberReview: false, - phaseId: defaultPhaseId, - aiWorkflowId: (defaultWorkflow && defaultWorkflow.id) || '' - } - - if (error) { - setError(null) - } - - const updatedReviewers = currentReviewers.concat([newReviewer]) - onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) - } - - /** - * Update an AI reviewer - */ - const updateAIReviewer = (index, field, value) => { - const currentReviewers = challenge.reviewers || [] - const updatedReviewers = currentReviewers.slice() - const fieldUpdate = { [field]: value } - - // Map the AI reviewer index to the actual index in the full reviewers array - const aiReviewers = currentReviewers.filter(r => isAIReviewer(r)) - const reviewerToUpdate = aiReviewers[index] - const actualIndex = currentReviewers.indexOf(reviewerToUpdate) - - if (actualIndex === -1) return - - if (field === 'aiWorkflowId') { - const { workflows = [] } = metadata - const selectedWorkflow = workflows.find(w => w.id === value) - if (selectedWorkflow && selectedWorkflow.scorecardId) { - fieldUpdate.scorecardId = selectedWorkflow.scorecardId - } - } - - updatedReviewers[actualIndex] = Object.assign({}, updatedReviewers[actualIndex], fieldUpdate) - onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) - } - - /** - * Remove an AI reviewer - */ - const removeAIReviewer = (index) => { - const currentReviewers = challenge.reviewers || [] - - // Map the AI reviewer index to the actual index in the full reviewers array - const aiReviewers = currentReviewers.filter(r => isAIReviewer(r)) - const reviewerToRemove = aiReviewers[index] - const actualIndex = currentReviewers.indexOf(reviewerToRemove) - - if (actualIndex !== -1) { - const updatedReviewers = currentReviewers.filter((_, i) => i !== actualIndex) - onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) - } - } - - /** - * Validate an AI reviewer - */ - const validateAIReviewer = (reviewer) => { - const errors = {} - - if (!reviewer.aiWorkflowId || reviewer.aiWorkflowId.trim() === '') { - errors.aiWorkflowId = 'AI Workflow is required' - } - - if (!reviewer.phaseId) { - errors.phaseId = 'Phase is required' - } - - return errors - } - - /** - * Render AI reviewer form (legacy mode) - */ - const renderAIReviewerForm = (reviewer, index) => { - const { workflows = [] } = metadata - const validationErrors = challenge.submitTriggered ? validateAIReviewer(reviewer) : {} - - return ( -
-
-

AI Reviewer Configuration {index + 1}

- {!readOnly && ( - removeAIReviewer(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 ? ( - - {(() => { - const workflow = workflows.find(w => w.id === reviewer.aiWorkflowId) - return workflow ? workflow.name : 'Not selected' - })()} - - ) : ( - - )} - {!readOnly && challenge.submitTriggered && validationErrors.aiWorkflowId && ( -
- {validationErrors.aiWorkflowId} -
- )} -
-
- - {readOnly && reviewer.scorecardId && ( -
-
- - - {reviewer.scorecardId} - -
-
- )} -
- ) - } - - // Get AI reviewers - const aiReviewers = (challenge.reviewers || []).filter(r => isAIReviewer(r)) - - // Show loading state - if (isLoading) { - return
Loading...
- } - - // Show template configuration if in template mode - if (configurationMode === 'template') { - return ( -
- - {showSwitchToManualConfirm && ( - -

The template settings will be copied into editable fields.

-

You can then modify workflows, weights, and settings individually.

-

This will disconnect from the template. Future template updates will not apply.

-
- )} - cancelText='Cancel' - confirmText='Switch to Manual' - onCancel={() => setShowSwitchToManualConfirm(false)} - onConfirm={confirmSwitchToManual} - /> - )} - - ) - } - - // Show manual configuration if in manual mode - if (configurationMode === 'manual') { - return ( -
- -
- ) - } - - // Show initial state if workflows are assigned but no configuration mode selected yet - if (isInitialState()) { - return ( -
- -
- ) - } - - // Legacy mode - show traditional AI reviewer forms - return ( -
- {!readOnly && ( -
- Configure AI-powered review workflows for this challenge. Select AI templates and assign to phases. -
- )} - - {!readOnly && aiReviewers.length === 0 && ( -
-

No AI review workflows configured. Click "Add AI Reviewer" to get started.

-
- )} - - {readOnly && aiReviewers.length === 0 && ( -
-

No AI review workflows configured for this challenge.

-
- )} - - {aiReviewers.length > 0 && aiReviewers.map((reviewer, index) => - renderAIReviewerForm(reviewer, index) - )} - - {aiReviewers.length > 0 && ( -
-

AI Review Summary

-
- Total AI Workflows: - {aiReviewers.length} -
-
- )} - - {!readOnly && ( -
- -
- )} - - {(error || templateError) && !isLoading && ( -
- {error || templateError} -
- )} -
- ) -} - -AIReviewTab.propTypes = { - challenge: PropTypes.object.isRequired, - onUpdateReviewers: PropTypes.func.isRequired, - metadata: PropTypes.shape({ - workflows: PropTypes.array, - challengeTracks: PropTypes.array - }), - isLoading: PropTypes.bool, - readOnly: PropTypes.bool -} - -AIReviewTab.defaultProps = { - metadata: {}, - isLoading: false, - readOnly: false -} - -export default AIReviewTab diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/InitialStateView.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/InitialStateView.js deleted file mode 100644 index ae06d200..00000000 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/InitialStateView.js +++ /dev/null @@ -1,91 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import AIWorkflowCard from '../../AIWorkflowCard' -import styles from '../AIReviewTab.module.scss' - -/** - * Initial State View - Shown when AI reviewers are assigned but no configuration exists - * Provides options to use a template or configure manually - */ -const InitialStateView = ({ - assignedWorkflows, - onSelectTemplate, - onSelectManual, - onRemoveReviewer, - readOnly -}) => { - return ( -
-
-
⚠️
-
-

AI workflows are assigned but no AI Review Config has been created

-

Workflows will run but scoring, gating, and thresholds are not defined.

-

Choose how to configure:

-
-
- -
-
-

📋 Use a Template

-

Pre-fill from a standard config for this track & type.

- -
- -
-

✏️ Configure Manually

-

Set up each workflow weight, mode, and threshold yourself.

- -
-
- -
-

Assigned AI Workflows

-

These AI workflows are assigned to this challenge from the default reviewer configuration.

- -
- {assignedWorkflows.map((item, index) => ( - onRemoveReviewer(index)} - readOnly={readOnly} - /> - ))} -
-
-
- ) -} - -InitialStateView.propTypes = { - assignedWorkflows: PropTypes.arrayOf( - PropTypes.shape({ - reviewer: PropTypes.object.isRequired, - workflow: PropTypes.object, - scorecardId: PropTypes.string - }) - ).isRequired, - onSelectTemplate: PropTypes.func.isRequired, - onSelectManual: PropTypes.func.isRequired, - onRemoveReviewer: PropTypes.func.isRequired, - readOnly: PropTypes.bool -} - -InitialStateView.defaultProps = { - readOnly: false -} - -export default InitialStateView diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/ManualConfigurationView.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/ManualConfigurationView.js deleted file mode 100644 index 04ad3cd7..00000000 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/ManualConfigurationView.js +++ /dev/null @@ -1,125 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import ConfigurationSourceSelector from '../components/ConfigurationSourceSelector' -import ReviewSettingsSection from '../components/ReviewSettingsSection' -import SummarySection from '../components/SummarySection' -import WeightValidationCard from '../components/WeightValidationCard' -import ManualWorkflowCard from '../components/ManualWorkflowCard' -import styles from '../AIReviewTab.module.scss' - -/** - * Manual Configuration View - Manually configure AI review settings and workflows - */ -const ManualConfigurationView = ({ - challenge, - configuration, - availableWorkflows, - onUpdateConfiguration, - onAddWorkflow, - onUpdateWorkflow, - onRemoveWorkflow, - onSwitchMode, - onRemoveConfig, - onSaveConfiguration, - readOnly, - isAIReviewer -}) => { - return ( -
- {/* Configuration Source Selector */} - onSwitchMode('template')} - readOnly={readOnly} - /> - - {/* Review Settings Section */} - - - {/* Manual Workflows Section */} -
-

AI Workflows (editable)

- - {configuration.workflows.map((workflow, index) => ( - onRemoveWorkflow(index)} - readOnly={readOnly} - isAIReviewer={isAIReviewer} - /> - ))} - - {!readOnly && ( - - )} -
- - {/* Weight Validation Section */} - - - {/* Summary Section */} - - - {/* Action Buttons Section */} - {!readOnly && ( -
- -
- -
-
- )} -
- ) -} - -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, - onSaveConfiguration: PropTypes.func.isRequired, - readOnly: PropTypes.bool, - isAIReviewer: PropTypes.func.isRequired -} - -ManualConfigurationView.defaultProps = { - readOnly: false -} - -export default ManualConfigurationView diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/TemplateConfigurationView.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/TemplateConfigurationView.js deleted file mode 100644 index 75f76ba9..00000000 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AIReviewTab/views/TemplateConfigurationView.js +++ /dev/null @@ -1,179 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import ConfigurationSourceSelector from '../components/ConfigurationSourceSelector' -import ReviewSettingsSection from '../components/ReviewSettingsSection' -import SummarySection from '../components/SummarySection' -import styles from '../AIReviewTab.module.scss' - -/** - * Template Configuration View - Select and configure using a template - */ -const TemplateConfigurationView = ({ - challenge, - configuration, - templates, - selectedTemplate, - templatesLoading, - onTemplateChange, - onUpdateConfiguration, - onSwitchMode, - onRemoveConfig, - readOnly, - isAIReviewer -}) => { - return ( -
- {/* Configuration Source Selector */} - onSwitchMode('manual')} - readOnly={readOnly} - /> - - {/* Template Selection Section */} -
-

📋 AI Review Template

- -
- -
- - {selectedTemplate && ( -
-

{selectedTemplate.description}

-
- )} -
- - {/* Review Settings Section */} - {selectedTemplate && ( - - )} - - {/* AI Workflows Section */} - {selectedTemplate && configuration.workflows && configuration.workflows.length > 0 && ( -
-

AI Workflows (from template)

- -
- - - - - - - - - - - {configuration.workflows.map((workflow, index) => { - const isAssigned = (challenge.reviewers || []).some(r => - isAIReviewer(r) && r.aiWorkflowId === workflow.workflowId - ) - const workflowDetails = workflow.workflow || {} - - return ( - - - - - - - ) - })} - -
WorkflowWeightTypeChallenge Match
- 🤖 - {workflowDetails.name} -
- {workflowDetails.description} -
-
- {workflow.weightPercent}% - - {workflow.isGating ? ( - ⚡GATE - ) : ( - ✓ Review - )} - - {isAssigned ? ( - ✅ Assigned - ) : ( - ⚠️ Not assigned - )} -
-
- -

- ✅ = Also assigned as AI Reviewer in this challenge
- ⚠️ = Not assigned — will be auto-added on save -

-
- )} - - {/* Summary Section */} - {selectedTemplate && ( - - )} - - {/* Remove Configuration Button */} - {!readOnly && selectedTemplate && ( -
- -
- )} - - {templatesLoading && ( -
Loading templates...
- )} -
- ) -} - -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, - templates: PropTypes.array.isRequired, - selectedTemplate: PropTypes.object, - templatesLoading: PropTypes.bool.isRequired, - onTemplateChange: PropTypes.func.isRequired, - onUpdateConfiguration: PropTypes.func.isRequired, - onSwitchMode: PropTypes.func.isRequired, - onRemoveConfig: PropTypes.func.isRequired, - readOnly: PropTypes.bool, - isAIReviewer: PropTypes.func.isRequired -} - -TemplateConfigurationView.defaultProps = { - readOnly: false, - selectedTemplate: null -} - -export default TemplateConfigurationView diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js index f0d255df..b49e311e 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js @@ -15,6 +15,7 @@ import ManualConfigurationView from './views/ManualConfigurationView'; */ const AiReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, readOnly = false }) => { const { + isLoading: isLoadingConfigs, configuration, configurationMode, setConfigurationMode, @@ -59,7 +60,7 @@ const AiReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, r setConfigurationMode(mode); }, [setConfigurationMode]); - if (isLoading) { + if (isLoading || isLoadingConfigs) { return
Loading...
} diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js index 5756d96e..315d62ae 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js @@ -14,6 +14,7 @@ const useConfigurationState = ( workflows: [] }, ) => { + const [isLoading, setIsLoading] = useState(true); const configManager = useMemo(() => createConfigManager(true), []) const [configuration, setConfiguration] = useState(initialConfig) const [configurationMode, setConfigurationMode] = useState(null) @@ -107,6 +108,7 @@ const useConfigurationState = ( // Fetch AI review config when component loads useEffect(() => { const loadAIReviewConfig = async () => { + setIsLoading(true); try { if (challengeId) { const config = await configManager.fetchByChallenge(challengeId) @@ -118,6 +120,8 @@ const useConfigurationState = ( } } catch (err) { console.error('Error loading AI review configuration:', err) + } finally { + setIsLoading(false); } } @@ -125,6 +129,7 @@ const useConfigurationState = ( }, [challengeId, updateConfiguration, configManager]) return { + isLoading, configuration, configurationMode, setConfigurationMode, diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js index dda1bf86..48bcc524 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js @@ -4,9 +4,8 @@ import PropTypes from 'prop-types' import styles from '../AiReviewTab.module.scss' import useTemplateManager from '../hooks/useTemplateManager' import SummarySection from '../components/SummarySection' -import ReviewSettingsSection from '../../AIReviewTab/components/ReviewSettingsSection' -import ConfigurationSourceSelector from '../../AIReviewTab/components/ConfigurationSourceSelector' import ConfirmationModal from '../../../../Modal/ConfirmationModal' +import ConfigurationSourceSelector from '../components/ConfigurationSourceSelector' /** * Template Configuration View - Select and configure using a template @@ -74,7 +73,6 @@ const TemplateConfigurationView = ({ return (
{/* Configuration Source Selector */} - ConfigurationSourceSelector Date: Fri, 20 Feb 2026 11:21:34 +0200 Subject: [PATCH 09/16] PM-3851 #time 1h more cleanup --- .../AiReviewerTab/AiReviewTab.module.scss | 18 +- .../components/SummarySection.js | 4 +- .../hooks/useConfigurationState.js | 9 +- .../AiReviewerTab/hooks/useTemplateManager.js | 10 +- .../views/ManualConfigurationView.js | 16 +- .../views/TemplateConfigurationView.js | 2 +- src/services/aiReviewConfigHelpers.js | 129 ++--------- src/services/aiReviewTemplateHelpers.js | 203 +----------------- src/services/aiReviewTemplates.js | 104 --------- 9 files changed, 57 insertions(+), 438 deletions(-) diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.module.scss index 72e91bc7..f01f646d 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.module.scss +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.module.scss @@ -435,6 +435,10 @@ font-weight: 600; color: #333; border-bottom: 2px solid #e0e0e0; + + &:last-child { + width: 136px; + } } } @@ -463,24 +467,24 @@ } &.type { - .gatingBadge { + > .gatingBadge, .normalBadge { display: inline-block; padding: 2px 8px; - background-color: #fff3cd; - border: 1px solid #ffc107; border-radius: 3px; font-size: 12px; font-weight: 500; + white-space: nowrap; + } + + .gatingBadge { + background-color: #fff3cd; + border: 1px solid #ffc107; color: #856404; } .normalBadge { - display: inline-block; - padding: 2px 8px; background-color: #d1ecf1; border: 1px solid #17a2b8; - border-radius: 3px; - font-size: 12px; font-weight: 500; color: #0c5460; } diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/SummarySection.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/SummarySection.js index 59357244..e2b825c2 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/SummarySection.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/SummarySection.js @@ -12,7 +12,9 @@ const SummarySection = ({ configuration }) => {

Mode

-
{configuration.mode}
+
+ {configuration.mode === 'AI_ONLY' ? 'AI Only Review' : 'AI Gating + Human Review'} +

Threshold

diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js index 315d62ae..48b0cec5 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js @@ -1,5 +1,5 @@ -import { useState, useCallback, useMemo, useEffect } from 'react' -import { createConfigManager } from '../../../../../services/aiReviewConfigHelpers' +import { useState, useCallback, useEffect } from 'react' +import { fetchAIReviewConfigByChallenge } from '../../../../../services/aiReviewConfigs'; /** * Custom hook for managing AI Review configuration state @@ -15,7 +15,6 @@ const useConfigurationState = ( }, ) => { const [isLoading, setIsLoading] = useState(true); - const configManager = useMemo(() => createConfigManager(true), []) const [configuration, setConfiguration] = useState(initialConfig) const [configurationMode, setConfigurationMode] = useState(null) @@ -111,7 +110,7 @@ const useConfigurationState = ( setIsLoading(true); try { if (challengeId) { - const config = await configManager.fetchByChallenge(challengeId) + const config = await fetchAIReviewConfigByChallenge(challengeId) if (config) { // Load the config into the configuration state setConfigurationMode(config.templateId ? 'template' : 'manual') @@ -126,7 +125,7 @@ const useConfigurationState = ( } loadAIReviewConfig() - }, [challengeId, updateConfiguration, configManager]) + }, [challengeId, updateConfiguration]) return { isLoading, diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useTemplateManager.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useTemplateManager.js index d1e29a85..648610ae 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useTemplateManager.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useTemplateManager.js @@ -1,5 +1,5 @@ -import { useState, useMemo, useCallback, useEffect } from 'react' -import { createTemplateManager } from '../../../../../services/aiReviewTemplateHelpers' +import { useState, useCallback, useEffect } from 'react' +import { fetchAIReviewTemplates } from '../../../../../services/aiReviewTemplates' /** * Custom hook for managing AI Review templates @@ -11,8 +11,6 @@ const useTemplateManager = (templateId, challengeTrack, challengeType) => { const [templatesLoading, setTemplatesLoading] = useState(false) const [error, setError] = useState(null) - const templateManager = useMemo(() => createTemplateManager(true), []) - /** * Select a template by ID */ @@ -48,7 +46,7 @@ const useTemplateManager = (templateId, challengeTrack, challengeType) => { setError(null) try { - const fetchedTemplates = await templateManager.fetchAll({ + const fetchedTemplates = await fetchAIReviewTemplates({ challengeTrack, challengeType }) @@ -60,7 +58,7 @@ const useTemplateManager = (templateId, challengeTrack, challengeType) => { setError('Failed to load templates') setTemplatesLoading(false) } - }, [setTemplates, setTemplatesLoading, setError, templateManager, challengeTrack, challengeType]) + }, [setTemplates, setTemplatesLoading, setError, challengeTrack, challengeType]) useEffect(() => { if (challengeTrack && challengeType) { diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js index fcd986e8..a46acb3f 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react' +import React, { useState, useCallback, useRef } from 'react' import PropTypes from 'prop-types' import ConfigurationSourceSelector from '../components/ConfigurationSourceSelector' import ReviewSettingsSection from '../components/ReviewSettingsSection' @@ -7,6 +7,7 @@ import WeightValidationCard from '../components/WeightValidationCard' import ManualWorkflowCard from '../components/ManualWorkflowCard' import styles from '../AiReviewTab.module.scss' import ConfirmationModal from '../../../../Modal/ConfirmationModal' +import { compareConfigs, configHasChanges } from '../../../../../services/aiReviewConfigHelpers' /** * Manual Configuration View - Manually configure AI review settings and workflows @@ -23,6 +24,7 @@ const ManualConfigurationView = ({ onRemoveConfig, readOnly, }) => { + const origConfig = useRef(configuration); const [showSwitchConfirmModal, setShowSwitchConfirmModal] = useState(false) const handleConfirmSwitch = useCallback(() => { @@ -30,12 +32,22 @@ const ManualConfigurationView = ({ onSwitchMode('template'); }, [onSwitchMode, onRemoveConfig]); + const handleOnSwitchConfig = useCallback(() => { + if (configHasChanges(origConfig.current, configuration)) { + setShowSwitchConfirmModal(true); + } else { + handleConfirmSwitch(); + } + }, [ + configuration, setShowSwitchConfirmModal, handleConfirmSwitch + ]); + return (
{/* Configuration Source Selector */} setShowSwitchConfirmModal(true)} + onSwitch={handleOnSwitchConfig} readOnly={readOnly} /> diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js index 48bcc524..e0219c2e 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js @@ -149,7 +149,7 @@ const TemplateConfigurationView = ({ {workflow.isGating ? ( - ⚡GATE + ⚡ GATE ) : ( ✓ Review )} diff --git a/src/services/aiReviewConfigHelpers.js b/src/services/aiReviewConfigHelpers.js index a8add43c..9e4b90f8 100644 --- a/src/services/aiReviewConfigHelpers.js +++ b/src/services/aiReviewConfigHelpers.js @@ -31,33 +31,6 @@ export const createConfigManager = (useDevConfig = false) => { } } -/** - * Format config for display in UI - * - * @param {Object} config - The config to format - * @returns {Object} - Formatted config - */ -export const formatConfigForDisplay = (config) => { - if (!config) { - return null - } - - return { - ...config, - modeLabel: config.mode === 'AI_ONLY' ? 'AI Only Review' : 'AI Gating + Human Review', - workflowCount: config.workflows ? config.workflows.length : 0, - totalWeight: config.workflows - ? config.workflows.reduce((sum, w) => sum + (w.weightPercent || 0), 0) - : 0, - gatingWorkflows: config.workflows - ? config.workflows.filter(w => w.isGating) - : [], - scoringWorkflows: config.workflows - ? config.workflows.filter(w => !w.isGating) - : [] - } -} - /** * Validate config data before submission * @@ -116,89 +89,6 @@ export const validateConfigData = (configData) => { } } -/** - * Calculate aggregated score from workflow scores - * - * @param {Object} config - The AI review config - * @param {Object} workflowScores - Map of workflowId to score - * @returns {Object} - { totalScore, componentScores, passesGating, passesThreshold } - */ -export const calculateAggregatedScore = (config, workflowScores) => { - if (!config || !config.workflows || !workflowScores) { - return null - } - - const componentScores = {} - let weightedSum = 0 - let totalWeight = 0 - - config.workflows.forEach(w => { - const score = workflowScores[w.workflowId] - if (score !== undefined && score !== null) { - componentScores[w.workflowId] = { - score, - weight: w.weightPercent, - weighted: (score * w.weightPercent) / 100 - } - weightedSum += componentScores[w.workflowId].weighted - totalWeight += w.weightPercent - } - }) - - const totalScore = totalWeight > 0 ? (weightedSum / totalWeight) * 100 : 0 - - // Check gating - let passesGating = true - const gatingWorkflows = config.workflows.filter(w => w.isGating) - if (gatingWorkflows.length > 0 && config.mode === 'AI_GATING') { - passesGating = gatingWorkflows.every(w => { - const score = workflowScores[w.workflowId] - return score !== undefined && score !== null && score >= config.minPassingThreshold - }) - } - - return { - totalScore: Math.round(totalScore * 100) / 100, - componentScores, - passesGating, - passesThreshold: totalScore >= config.minPassingThreshold, - status: passesGating && totalScore >= config.minPassingThreshold ? 'PASSED' : 'FAILED' - } -} - -/** - * Get summary statistics for a config - * - * @param {Object} config - The config to analyze - * @returns {Object} - Summary information - */ -export const getConfigSummary = (config) => { - if (!config) { - return null - } - - const gatingWorkflows = config.workflows ? config.workflows.filter(w => w.isGating) : [] - const scoringWorkflows = config.workflows ? config.workflows.filter(w => !w.isGating) : [] - - return { - challengeId: config.challengeId, - mode: config.mode, - modeLabel: config.mode === 'AI_ONLY' ? 'AI Only' : 'AI Gating', - minPassingThreshold: config.minPassingThreshold, - autoFinalize: config.autoFinalize, - totalWorkflows: config.workflows ? config.workflows.length : 0, - gatingWorkflows: gatingWorkflows.length, - scoringWorkflows: scoringWorkflows.length, - totalWeight: config.workflows - ? config.workflows.reduce((sum, w) => sum + (w.weightPercent || 0), 0) - : 0, - createdAt: config.createdAt, - createdBy: config.createdBy || 'Unknown', - updatedAt: config.updatedAt, - updatedBy: config.updatedBy || 'Unknown' - } -} - /** * Compare two configs and show differences * @@ -254,3 +144,22 @@ export const compareConfigs = (original, updated) => { return differences } + +/** + * Check if config has changed between original and updated versions + * + * @param {Object} original - Original config + * @param {Object} updated - Updated config + * @returns {boolean} - True if any changes detected, false otherwise + */ +export const configHasChanges = (original, updated) => { + const differences = compareConfigs(original, updated) + + const hasSettingChanges = Object.keys(differences.settings).length > 0 + const hasWorkflowChanges = + differences.workflows.added.length > 0 || + differences.workflows.removed.length > 0 || + differences.workflows.modified.length > 0 + + return hasSettingChanges || hasWorkflowChanges +} diff --git a/src/services/aiReviewTemplateHelpers.js b/src/services/aiReviewTemplateHelpers.js index 6492756c..6332093e 100644 --- a/src/services/aiReviewTemplateHelpers.js +++ b/src/services/aiReviewTemplateHelpers.js @@ -17,209 +17,8 @@ export const createTemplateManager = (useDevConfig = false) => { : templateService return { - // CRUD operations fetchAll: (filters) => service.mockFetchAIReviewTemplates ? service.mockFetchAIReviewTemplates(filters) : service.fetchAIReviewTemplates(filters), - - fetchById: (id) => service.mockFetchAIReviewTemplate - ? service.mockFetchAIReviewTemplate(id) - : service.fetchAIReviewTemplate(id), - - create: (data) => service.mockCreateAIReviewTemplate - ? service.mockCreateAIReviewTemplate(data) - : service.createAIReviewTemplate(data), - - update: (id, data) => service.mockUpdateAIReviewTemplate - ? service.mockUpdateAIReviewTemplate(id, data) - : service.updateAIReviewTemplate(id, data), - - delete: (id) => service.mockDeleteAIReviewTemplate - ? service.mockDeleteAIReviewTemplate(id) - : service.deleteAIReviewTemplate(id), - - fetchByTrackAndType: (track, type) => service.mockFetchTemplateByTrackAndType - ? service.mockFetchTemplateByTrackAndType(track, type) - : service.fetchTemplateByTrackAndType(track, type) } -} - -/** - * Transform a template to a challenge AI review config - * - * @param {Object} template - The template to transform - * @param {String} challengeId - The challenge ID - * @returns {Object} - The resulting config - */ -export const transformTemplateToConfig = (template, challengeId) => { - if (!template) { - return null - } - - return { - id: `config_${Date.now()}`, - challengeId, - minPassingThreshold: template.minPassingThreshold, - autoFinalize: template.autoFinalize, - mode: template.mode, - formula: template.formula, - workflows: template.workflows.map(w => ({ - id: `config_wf_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - configId: `config_${Date.now()}`, - workflowId: w.workflowId, - weightPercent: w.weightPercent, - isGating: w.isGating, - createdAt: new Date().toISOString(), - createdBy: 'current_user' - })), - createdAt: template.createdAt, - createdBy: template.createdBy, - updatedAt: new Date().toISOString(), - updatedBy: 'current_user' - } -} - -/** - * Format a template for display in UI - * - * @param {Object} template - The template to format - * @returns {Object} - Formatted template - */ -export const formatTemplateForDisplay = (template) => { - if (!template) { - return null - } - - return { - ...template, - modeLabel: template.mode === 'AI_ONLY' ? 'AI Only Review' : 'AI Gating + Human Review', - workflowCount: template.workflows ? template.workflows.length : 0, - totalWeight: template.workflows - ? template.workflows.reduce((sum, w) => sum + (w.weightPercent || 0), 0) - : 0, - displayName: `${template.title} (${template.challengeTrack}/${template.challengeType})` - } -} - -/** - * Validate template data before submission - * - * @param {Object} templateData - The template data to validate - * @returns {Object} - { isValid: boolean, errors: Array } - */ -export const validateTemplateData = (templateData) => { - const errors = [] - - // Required fields - if (!templateData.challengeTrack || templateData.challengeTrack.trim() === '') { - errors.push('Challenge Track is required') - } - - if (!templateData.challengeType || templateData.challengeType.trim() === '') { - errors.push('Challenge Type is required') - } - - if (!templateData.title || templateData.title.trim() === '') { - errors.push('Title is required') - } - - if (templateData.minPassingThreshold === undefined || templateData.minPassingThreshold === null) { - errors.push('Min Passing Threshold is required') - } else if (templateData.minPassingThreshold < 0 || templateData.minPassingThreshold > 100) { - errors.push('Min Passing Threshold must be between 0 and 100') - } - - // Validate mode - if (![' AI_GATING', 'AI_ONLY'].includes(templateData.mode)) { - errors.push('Invalid Review Mode selected') - } - - // Validate workflows - if (!templateData.workflows || templateData.workflows.length === 0) { - errors.push('At least one workflow is required') - } else { - // Check workflow IDs - const invalidWorkflows = templateData.workflows.filter( - w => !w.workflowId || w.workflowId.trim() === '' - ) - if (invalidWorkflows.length > 0) { - errors.push(`${invalidWorkflows.length} workflow(s) are missing Workflow ID`) - } - - // Check weights - const totalWeight = templateData.workflows.reduce((sum, w) => sum + (w.weightPercent || 0), 0) - if (Math.abs(totalWeight - 100) > 0.01) { - errors.push(`Workflow weights must sum to 100% (currently ${totalWeight.toFixed(2)}%)`) - } - } - - return { - isValid: errors.length === 0, - errors - } -} - -/** - * Compare two templates and show differences - * - * @param {Object} original - Original template - * @param {Object} updated - Updated template - * @returns {Object} - Differences found - */ -export const compareTemplates = (original, updated) => { - const differences = { - header: {}, - workflows: { added: [], removed: [], modified: [] }, - formula: null - } - - // Compare header fields - const headerFields = ['title', 'description', 'minPassingThreshold', 'mode', 'autoFinalize'] - headerFields.forEach(field => { - if (original[field] !== updated[field]) { - differences.header[field] = { - from: original[field], - to: updated[field] - } - } - }) - - // Compare workflows - if (original.workflows && updated.workflows) { - const updatedWorkflowIds = new Set(updated.workflows.map(w => w.workflowId)) - original.workflows.forEach(w => { - if (!updatedWorkflowIds.has(w.workflowId)) { - differences.workflows.removed.push(w) - } - }) - - const originalWorkflowMap = new Map(original.workflows.map(w => [w.workflowId, w])) - updated.workflows.forEach(w => { - const origW = originalWorkflowMap.get(w.workflowId) - if (!origW) { - differences.workflows.added.push(w) - } else if ( - origW.weightPercent !== w.weightPercent || - origW.isGating !== w.isGating - ) { - differences.workflows.modified.push({ - workflowId: w.workflowId, - changes: { - weightPercent: { from: origW.weightPercent, to: w.weightPercent }, - isGating: { from: origW.isGating, to: w.isGating } - } - }) - } - }) - } - - // Compare formula - if (JSON.stringify(original.formula) !== JSON.stringify(updated.formula)) { - differences.formula = { - from: original.formula, - to: updated.formula - } - } - - return differences -} +} \ No newline at end of file diff --git a/src/services/aiReviewTemplates.js b/src/services/aiReviewTemplates.js index 74e6b3c9..1a44a1f3 100644 --- a/src/services/aiReviewTemplates.js +++ b/src/services/aiReviewTemplates.js @@ -22,107 +22,3 @@ export async function fetchAIReviewTemplates (filters = {}) { throw error } } - -/** - * Fetch a specific AI review template by ID - * @param {String} templateId - The ID of the template to fetch - * @returns {Promise} - */ -export async function fetchAIReviewTemplate (templateId) { - try { - const response = await axiosInstance.get(`${TC_REVIEWS_API_BASE_URL}/ai-review/templates/${templateId}`) - return _.get(response, 'data', {}) - } catch (error) { - console.error(`Error fetching AI review template ${templateId}:`, error.message) - throw error - } -} - -/** - * Create a new AI review template - * @param {Object} templateData - The template data - * @returns {Promise} - */ -export async function createAIReviewTemplate (templateData) { - try { - // Validate required workflow IDs exist - if (templateData.workflows && templateData.workflows.length > 0) { - const workflowIds = templateData.workflows.map(w => w.workflowId) - // Note: In a real scenario, you'd validate these IDs against the workflows API - if (!Array.isArray(workflowIds) || workflowIds.some(id => !id || id.trim() === '')) { - throw new Error('All workflows must have valid IDs') - } - } - - const response = await axiosInstance.post( - `${TC_REVIEWS_API_BASE_URL}/ai-review/templates`, - templateData - ) - return _.get(response, 'data', {}) - } catch (error) { - console.error('Error creating AI review template:', error.message) - throw error - } -} - -/** - * Update an existing AI review template - * @param {String} templateId - The ID of the template to update - * @param {Object} templateData - The updated template data - * @returns {Promise} - */ -export async function updateAIReviewTemplate (templateId, templateData) { - try { - // Validate required workflow IDs exist if workflows are being updated - if (templateData.workflows && templateData.workflows.length > 0) { - const workflowIds = templateData.workflows.map(w => w.workflowId) - if (!Array.isArray(workflowIds) || workflowIds.some(id => !id || id.trim() === '')) { - throw new Error('All workflows must have valid IDs') - } - } - - const response = await axiosInstance.put( - `${TC_REVIEWS_API_BASE_URL}/ai-review/templates/${templateId}`, - templateData - ) - return _.get(response, 'data', {}) - } catch (error) { - console.error(`Error updating AI review template ${templateId}:`, error.message) - throw error - } -} - -/** - * Delete an AI review template - * @param {String} templateId - The ID of the template to delete - * @returns {Promise} - */ -export async function deleteAIReviewTemplate (templateId) { - try { - await axiosInstance.delete(`${TC_REVIEWS_API_BASE_URL}/ai-review/templates/${templateId}`) - } catch (error) { - console.error(`Error deleting AI review template ${templateId}:`, error.message) - throw error - } -} - -/** - * Fetch and filter AI review templates by challenge track and type - * @param {String} challengeTrack - The challenge track - * @param {String} challengeType - The challenge type - * @returns {Promise} - */ -export async function fetchTemplateByTrackAndType (challengeTrack, challengeType) { - try { - const filters = { - challengeTrack, - challengeType - } - const templates = await fetchAIReviewTemplates(filters) - // Return the first matching template or null if none found - return templates.length > 0 ? templates[0] : null - } catch (error) { - console.error(`Error fetching template for ${challengeTrack}/${challengeType}:`, error.message) - throw error - } -} From 5a32ff816e15f6765046d593bca00aa150788707 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 20 Feb 2026 11:46:16 +0200 Subject: [PATCH 10/16] PM-3851 #30m render ai & human reviewers tabs from start, to keep state alive --- .../AiReviewerTab/views/ManualConfigurationView.js | 2 +- .../ChallengeReviewer-Field.module.scss | 3 +++ .../ChallengeEditor/ChallengeReviewer-Field/index.js | 8 ++++---- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js index a46acb3f..201002a0 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js @@ -7,7 +7,7 @@ import WeightValidationCard from '../components/WeightValidationCard' import ManualWorkflowCard from '../components/ManualWorkflowCard' import styles from '../AiReviewTab.module.scss' import ConfirmationModal from '../../../../Modal/ConfirmationModal' -import { compareConfigs, configHasChanges } from '../../../../../services/aiReviewConfigHelpers' +import { configHasChanges } from '../../../../../services/aiReviewConfigHelpers' /** * Manual Configuration View - Manually configure AI review settings and workflows diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss index cc0642ce..c31fbc5f 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss @@ -54,6 +54,9 @@ border: 1px solid #ddd; border-radius: 4px; background-color: #fff; + .hidden { + display: none !important; + } } .tabsHeader { diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js index f7f5a3f8..68e0ac98 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js @@ -197,7 +197,7 @@ class ChallengeReviewerField extends Component { - {activeTab === 'human' && ( +
- )} +
- {activeTab === 'ai' && ( +
this.props.onUpdateReviewers(update)} /> - )} +
{error && !isLoading && (
From 3f7179879d66b042f06beb644ca006c639ea95ef Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 20 Feb 2026 13:49:34 +0200 Subject: [PATCH 11/16] PM-3851 #time 1h autosave ai review config --- .../AiReviewerTab/AiReviewTab.js | 63 +++++++------ .../AiReviewerTab/AiReviewTab.module.scss | 25 +++++ .../hooks/useConfigurationState.js | 92 ++++++++++++++++++- 3 files changed, 146 insertions(+), 34 deletions(-) diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js index b49e311e..4377ff2d 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js @@ -2,6 +2,7 @@ import cn from 'classnames' import React, { useCallback, useMemo } from 'react' import PropTypes from 'prop-types' import { isAIReviewer } from './utils'; +import { deleteAIReviewConfig } from '../../../../services/aiReviewConfigs' import styles from './AiReviewTab.module.scss' import sharedStyles from '../shared.module.scss' import useConfigurationState from './hooks/useConfigurationState'; @@ -24,7 +25,9 @@ const AiReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, r updateWorkflow, removeWorkflow, resetConfiguration, - applyTemplate + applyTemplate, + isSaving, + configId } = useConfigurationState(challenge.id) const aiReviewers = useMemo(() => ( @@ -44,9 +47,15 @@ const AiReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, r }, [challenge.reviewers, onUpdateReviewers]) const handleRemoveConfiguration = useCallback(() => { + // Call delete API if config exists + if (configId) { + deleteAIReviewConfig(configId).catch(err => { + console.error('Error deleting AI review configuration:', err) + }) + } setConfigurationMode(null) resetConfiguration() - }, [setConfigurationMode, resetConfiguration]) + }, [setConfigurationMode, resetConfiguration, configId]) const handleSwitchConfigurationMode = useCallback((mode, template) => { if (mode === 'manual') { @@ -64,10 +73,24 @@ const AiReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, r return
Loading...
} - // Show template configuration if in template mode - if (configurationMode === 'template') { - return ( -
+ return ( +
+ {isSaving &&
💾 Saving...
} + + {/* initial state (no configuration mode was selected: template/manual) */} + {configurationMode === null && ( + setConfigurationMode('template')} + onSelectManual={() => setConfigurationMode('manual')} + onRemoveReviewer={removeAIReviewer} + readOnly={readOnly} + /> + )} + + {/* Show template configuration if in template mode */} + {configurationMode === 'template' && ( -
- ) - } + )} - // Show manual configuration if in manual mode - if (configurationMode === 'manual') { - return ( -
+ {/* Show manual configuration if in manual mode */} + {configurationMode === 'manual' && ( -
- ) - } - - // initial state (no configuration mode was selected: template/manual) - return ( -
- setConfigurationMode('template')} - onSelectManual={() => setConfigurationMode('manual')} - onRemoveReviewer={removeAIReviewer} - readOnly={readOnly} - /> + )}
- ) + ); } AiReviewTab.propTypes = { diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.module.scss index f01f646d..b379dcb3 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.module.scss +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.module.scss @@ -848,4 +848,29 @@ .validationMessage { font-size: 12px; color: inherit; +} + +.autoSaveIndicator { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background-color: #e3f2fd; + border: 1px solid #90caf9; + border-radius: 4px; + font-size: 13px; + color: #1565c0; + margin-bottom: 12px; + animation: fadeInOut 0.3s ease-in-out; +} + +@keyframes fadeInOut { + 0% { + opacity: 0; + transform: translateY(-10px); + } + 100% { + opacity: 1; + transform: translateY(0); + } } \ No newline at end of file diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js index 48b0cec5..84003798 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js @@ -1,5 +1,7 @@ -import { useState, useCallback, useEffect } from 'react' -import { fetchAIReviewConfigByChallenge } from '../../../../../services/aiReviewConfigs'; +import { useState, useCallback, useEffect, useRef } from 'react' +import { fetchAIReviewConfigByChallenge, createAIReviewConfig, updateAIReviewConfig, deleteAIReviewConfig } from '../../../../../services/aiReviewConfigs'; +import { validateConfigData, configHasChanges } from '../../../../../services/aiReviewConfigHelpers'; +import { toastFailure } from '../../../../../util/toaster'; /** * Custom hook for managing AI Review configuration state @@ -17,6 +19,10 @@ const useConfigurationState = ( const [isLoading, setIsLoading] = useState(true); const [configuration, setConfiguration] = useState(initialConfig) const [configurationMode, setConfigurationMode] = useState(null) + const [isSaving, setIsSaving] = useState(false) + const lastSavedConfigRef = useRef(initialConfig) + const saveTimeoutRef = useRef(null) + const configId = configuration?.id; /** * Update a single field in the configuration @@ -97,7 +103,8 @@ const useConfigurationState = ( mode: template.mode || 'AI_GATING', minPassingThreshold: template.minPassingThreshold || 75, autoFinalize: template.autoFinalize || false, - workflows: template.workflows || [] + workflows: template.workflows || [], + templateId: template.id, } setConfiguration(newConfiguration) @@ -114,7 +121,8 @@ const useConfigurationState = ( if (config) { // Load the config into the configuration state setConfigurationMode(config.templateId ? 'template' : 'manual') - resetConfiguration(config) + setConfiguration(config) + lastSavedConfigRef.current = config } } } catch (err) { @@ -127,6 +135,78 @@ const useConfigurationState = ( loadAIReviewConfig() }, [challengeId, updateConfiguration]) + /** + * Autosave configuration changes with debouncing + */ + useEffect(() => { + // Only autosave if configuration mode is set (meaning user has started configuring) + if (!configurationMode || !challengeId) { + return + } + + // Clear any pending save + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current) + } + + // Check if there are changes + if (!configHasChanges(lastSavedConfigRef.current, configuration)) { + return + } + + // Debounce save by 1.5 seconds + saveTimeoutRef.current = setTimeout(async () => { + setIsSaving(true) + + try { + // Prepare config data for saving + const configData = { + challengeId, + mode: configuration.mode, + minPassingThreshold: configuration.minPassingThreshold, + autoFinalize: configuration.autoFinalize, + workflows: configuration.workflows, + templateId: configuration.templateId + } + + // Validate before saving + const validation = validateConfigData(configData) + if (!validation.isValid) { + console.warn('Configuration validation warnings:', validation.errors) + // Don't save - let user continue editing + return; + } + + let savedConfig + if (!configId) { + // Create new config + savedConfig = await createAIReviewConfig(configData) + updateConfiguration('id', savedConfig.id) + } else { + // Update existing config + await updateAIReviewConfig(configId, configData) + savedConfig = configData + } + + // Update the last saved config reference + lastSavedConfigRef.current = savedConfig + } catch (error) { + console.error('Error autosaving AI review configuration:', error) + toastFailure(`⚠️ Autosave error: ${error.message}`) + // Don't re-throw - let component continue functioning + } finally { + setIsSaving(false) + } + }, 1500) + + // Cleanup timeout on unmount + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current) + } + } + }, [configuration, configurationMode, challengeId, configId]) + return { isLoading, configuration, @@ -137,7 +217,9 @@ const useConfigurationState = ( updateWorkflow, removeWorkflow, resetConfiguration, - applyTemplate + applyTemplate, + isSaving, + configId } } From 7e375bcce1bbdf9c451ff267871e108d2a3563ac Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 20 Feb 2026 17:33:47 +0200 Subject: [PATCH 12/16] PM-3851 #time 4h work on integration --- .../AiReviewerTab/AiReviewTab.js | 13 ++- .../components/AiWorkflowsTableListing.js | 83 ++++++++++++++++++ .../components/ManualWorkflowCard.js | 4 - .../hooks/useConfigurationState.js | 5 +- .../views/ManualConfigurationView.js | 85 +++++++++++-------- .../views/TemplateConfigurationView.js | 61 ++----------- src/components/ChallengeEditor/index.js | 57 +++++++++++++ 7 files changed, 207 insertions(+), 101 deletions(-) create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/AiWorkflowsTableListing.js diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js index 4377ff2d..e39f13da 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js @@ -1,4 +1,3 @@ -import cn from 'classnames' import React, { useCallback, useMemo } from 'react' import PropTypes from 'prop-types' import { isAIReviewer } from './utils'; @@ -9,6 +8,7 @@ import useConfigurationState from './hooks/useConfigurationState'; import InitialStateView from './views/InitialStateView'; import TemplateConfigurationView from './views/TemplateConfigurationView'; import ManualConfigurationView from './views/ManualConfigurationView'; +import { pick } from 'lodash'; /** * AiReviewTab - Main component for managing AI review configuration @@ -59,15 +59,20 @@ const AiReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, r const handleSwitchConfigurationMode = useCallback((mode, template) => { if (mode === 'manual') { - console.log('switch to manual', template) if (template) { - resetConfiguration(template); + applyTemplate(pick(template, [ + 'mode', + 'minPassingThreshold', + 'autoFinalize', + 'formula', + 'workflows', + ])); } } else { resetConfiguration() } setConfigurationMode(mode); - }, [setConfigurationMode]); + }, [setConfigurationMode, applyTemplate, resetConfiguration]); if (isLoading || isLoadingConfigs) { return
Loading...
diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/AiWorkflowsTableListing.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/AiWorkflowsTableListing.js new file mode 100644 index 00000000..d89391bf --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/AiWorkflowsTableListing.js @@ -0,0 +1,83 @@ +import React from 'react'; +import styles from '../AiReviewTab.module.scss' +import PropTypes from 'prop-types'; + +const AiWorkflowsTableListing = ({ + challenge, + workflows, +}) => { + return ( +
+

AI Workflows (from template)

+ +
+ + + + + + + + + + + {workflows.map((workflow, index) => { + const isAssigned = (challenge.reviewers || []).some(r => r.aiWorkflowId === workflow.workflowId) + const workflowDetails = workflows.find(w => w.id === workflow.workflowId) || {} + + return ( + + + + + + + ) + })} + +
WorkflowWeightTypeChallenge Match
+ 🤖 + {workflowDetails.name} + {/*
+ {workflowDetails.description} +
*/} +
+ {workflow.weightPercent}% + + {workflow.isGating ? ( + ⚡ GATE + ) : ( + ✓ Review + )} + + {isAssigned ? ( + ✅ Assigned + ) : ( + ⚠️ Not assigned + )} +
+
+ +

+ ✅ = Also assigned as AI Reviewer in this challenge
+ ⚠️ = Not assigned — will be auto-added on save +

+
+ ); +}; + +AiWorkflowsTableListing.propTypes = { + challenge: PropTypes.object.isRequired, + workflows: PropTypes.arrayOf( + PropTypes.shape({ + weightPercent: PropTypes.number, + isGating: PropTypes.bool, + workflowId: PropTypes.string, + }) + ).isRequired +} + +AiWorkflowsTableListing.defaultProps = { +} + +export default AiWorkflowsTableListing diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js index f86b86f3..55b0bb36 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js @@ -103,10 +103,6 @@ const ManualWorkflowCard = ({ : 'Not assigned: will be auto-added on save.'}
)} - - {workflowDetails.description && ( -
{workflowDetails.description}
- )}
) diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js index 84003798..92dfdfb3 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js @@ -100,15 +100,16 @@ const useConfigurationState = ( if (!template) return const newConfiguration = { + id: configId, mode: template.mode || 'AI_GATING', minPassingThreshold: template.minPassingThreshold || 75, autoFinalize: template.autoFinalize || false, workflows: template.workflows || [], - templateId: template.id, + templateId: template.id || '', } setConfiguration(newConfiguration) - }, [setConfiguration]) + }, [setConfiguration, configId]) // Fetch AI review config when component loads diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js index 201002a0..1f1c0e1b 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js @@ -8,6 +8,7 @@ import ManualWorkflowCard from '../components/ManualWorkflowCard' import styles from '../AiReviewTab.module.scss' import ConfirmationModal from '../../../../Modal/ConfirmationModal' import { configHasChanges } from '../../../../../services/aiReviewConfigHelpers' +import AiWorkflowsTableListing from '../components/AiWorkflowsTableListing' /** * Manual Configuration View - Manually configure AI review settings and workflows @@ -45,49 +46,63 @@ const ManualConfigurationView = ({ return (
{/* Configuration Source Selector */} - + {!readOnly && ( + + )} {/* Review Settings Section */} - + {!readOnly && ( + + )} {/* Manual Workflows Section */} -
-

AI Workflows (editable)

+ {!readOnly && ( +
+

AI Workflows (editable)

- {configuration.workflows.map((workflow, index) => ( - onRemoveWorkflow(index)} - readOnly={readOnly} - /> - ))} + {configuration.workflows.map((workflow, index) => ( + onRemoveWorkflow(index)} + readOnly={readOnly} + /> + ))} - {!readOnly && ( - - )} -
+ {!readOnly && ( + + )} +
+ )} {/* Weight Validation Section */} - + {!readOnly && ( + + )} + + {readOnly && ( + + )} {/* Summary Section */} diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js index e0219c2e..1a96429a 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js @@ -6,6 +6,7 @@ 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 @@ -117,62 +118,10 @@ const TemplateConfigurationView = ({ {/* AI Workflows Section */} {selectedTemplate && configuration.workflows && configuration.workflows.length > 0 && ( -
-

AI Workflows (from template)

- -
- - - - - - - - - - - {configuration.workflows.map((workflow, index) => { - const isAssigned = (challenge.reviewers || []).some(r => r.aiWorkflowId === workflow.workflowId) - const workflowDetails = workflows.find(w => w.id === workflow.workflowId) || {} - - return ( - - - - - - - ) - })} - -
WorkflowWeightTypeChallenge Match
- 🤖 - {workflowDetails.name} - {/*
- {workflowDetails.description} -
*/} -
- {workflow.weightPercent}% - - {workflow.isGating ? ( - ⚡ GATE - ) : ( - ✓ Review - )} - - {isAssigned ? ( - ✅ Assigned - ) : ( - ⚠️ Not assigned - )} -
-
- -

- ✅ = Also assigned as AI Reviewer in this challenge
- ⚠️ = Not assigned — will be auto-added on save -

-
+ )} {/* Summary Section */} diff --git a/src/components/ChallengeEditor/index.js b/src/components/ChallengeEditor/index.js index 88297520..1002f871 100644 --- a/src/components/ChallengeEditor/index.js +++ b/src/components/ChallengeEditor/index.js @@ -73,6 +73,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 @@ -1365,10 +1366,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 From 2cb88bd6a9e672224d9c115f6d792881465e2ab7 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 20 Feb 2026 17:48:28 +0200 Subject: [PATCH 13/16] small updates --- .../AiReviewerTab/components/ManualWorkflowCard.js | 1 - .../AiReviewerTab/components/SummarySection.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js index 55b0bb36..e9799f7a 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js @@ -17,7 +17,6 @@ const ManualWorkflowCard = ({ onRemove, readOnly, }) => { - const workflowDetails = availableWorkflows.find(item => item.id === workflow.workflowId) || {} const isAssigned = (challenge.reviewers || []).some(r => isAIReviewer(r) && r.aiWorkflowId === workflow.workflowId ) diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/SummarySection.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/SummarySection.js index e2b825c2..af67a183 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/SummarySection.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/SummarySection.js @@ -13,7 +13,7 @@ const SummarySection = ({ configuration }) => {

Mode

- {configuration.mode === 'AI_ONLY' ? 'AI Only Review' : 'AI Gating + Human Review'} + {configuration.mode === 'AI_ONLY' ? 'AI Only Review' : 'AI Gating'}
From ca0fcc099426387b24723f2635e985668628ad69 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 23 Feb 2026 10:28:23 +0200 Subject: [PATCH 14/16] PM-3851 #time 30m update weights validation --- .../AiReviewerTab/components/ManualWorkflowCard.js | 6 +++--- .../components/WeightValidationCard.js | 13 +++++-------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js index e9799f7a..4184fa30 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js @@ -60,14 +60,14 @@ const ManualWorkflowCard = ({ type='number' min='0' max='100' - value={workflow.isGating ? '' : (workflow.weightPercent || 0)} - placeholder={workflow.isGating ? 'N/A (gating)' : '0'} + value={workflow.weightPercent || 0} + placeholder="0" onChange={(e) => onUpdate( index, 'weightPercent', parseInt(e.target.value, 10) || 0 )} - disabled={readOnly || workflow.isGating} + disabled={readOnly} className={styles.weightInput} />
Weight for scoring. Ignored if gating.
diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/WeightValidationCard.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/WeightValidationCard.js index 0dd6fa55..7d039696 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/WeightValidationCard.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/WeightValidationCard.js @@ -9,13 +9,10 @@ import styles from '../AiReviewTab.module.scss' const WeightValidationCard = ({ workflows }) => { const scoringWorkflows = workflows.filter(workflow => !workflow.isGating) const gatingWorkflows = workflows.filter(workflow => workflow.isGating) - const scoringTotal = scoringWorkflows.reduce((sum, workflow) => sum + (Number(workflow.weightPercent) || 0), 0) - const hasScoringWorkflows = scoringWorkflows.length > 0 - const isWeightValid = !hasScoringWorkflows || Math.abs(scoringTotal - 100) < 0.01 - const remainingWeight = Math.round((100 - scoringTotal) * 100) / 100 - const scoringSummary = hasScoringWorkflows - ? `${scoringWorkflows.map(workflow => `${Number(workflow.weightPercent) || 0}%`).join(' + ')} = ${scoringTotal}%` - : 'no scoring workflows' + const weightsTotal = workflows.reduce((sum, workflow) => sum + (Number(workflow.weightPercent) || 0), 0) + const isWeightValid = Math.abs(weightsTotal - 100) < 0.01 + const remainingWeight = Math.round((100 - weightsTotal) * 100) / 100 + const scoringSummary = `${scoringWorkflows.map(workflow => `${Number(workflow.weightPercent) || 0}%`).join(' + ')} = ${weightsTotal}%` return (
@@ -24,7 +21,7 @@ const WeightValidationCard = ({ workflows }) => {
Scoring workflows weight total: {scoringSummary} {isWeightValid ? 'OK' : 'Invalid'}
- {!isWeightValid && hasScoringWorkflows && ( + {!isWeightValid && (
Scoring workflow weights must total 100%. {remainingWeight > 0 ? `Remaining: ${remainingWeight}% unassigned.` From 5cee3cb5046e702518c39ac6fd36544510bc8058 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 23 Feb 2026 11:45:17 +0200 Subject: [PATCH 15/16] PM-3851 - #time 1h review configuration summary (in readonly mode) --- .../components/ManualWorkflowCard.js | 10 +- .../hooks/useConfigurationState.js | 11 +- .../ChallengeReviewer-Field.module.scss | 4 + .../ReviewSummary/ReviewSummary.js | 346 ++++++++++++++++++ .../ReviewSummary/ReviewSummary.module.scss | 320 ++++++++++++++++ .../ReviewSummary/index.js | 1 + .../ChallengeReviewer-Field/index.js | 15 + 7 files changed, 696 insertions(+), 11 deletions(-) create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/ReviewSummary.js create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/ReviewSummary.module.scss create mode 100644 src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/index.js diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js index 4184fa30..9e09c83a 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/ManualWorkflowCard.js @@ -70,7 +70,7 @@ const ManualWorkflowCard = ({ disabled={readOnly} className={styles.weightInput} /> -
Weight for scoring. Ignored if gating.
+
Weight for scoring.
@@ -85,7 +85,13 @@ const ManualWorkflowCard = ({ {workflow.isGating ? 'Yes' : 'No'}
- {workflow.isGating ? '⚡ Pass/fail gate.' : 'Submissions below threshold are locked.'} + {workflow.isGating && ( + <> + ⚡ Pass/fail gate. +
+ + )} + Submissions below threshold are locked.
diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js index 92dfdfb3..0f786f78 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js @@ -56,17 +56,10 @@ const useConfigurationState = ( return workflow } - const nextWorkflow = { + return { ...workflow, [field]: value - } - - // Reset weight if workflow becomes gating - if (field === 'isGating' && value) { - nextWorkflow.weightPercent = 0 - } - - return nextWorkflow + }; }) return { diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss index c31fbc5f..e8ff3a1a 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss @@ -41,6 +41,10 @@ width: 600px; } + &.full { + width: 100%; + } + .fieldError { margin-top: 12px; } 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..40f6da9a --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/ReviewSummary.js @@ -0,0 +1,346 @@ +import React, { useState, useEffect } from 'react' +import PropTypes from 'prop-types' +import cn from 'classnames' +import { REVIEW_OPPORTUNITY_TYPE_LABELS } from '../../../../config/constants' +import { isAIReviewer } from '../AiReviewerTab/utils' +import { fetchAIReviewConfigByChallenge } from '../../../../services/aiReviewConfigs' +import styles from './ReviewSummary.module.scss' + +const ReviewSummary = ({ + challenge, + metadata = {}, + readOnly = false +}) => { + const [aiConfiguration, setAiConfiguration] = useState(null) + const [isLoadingAIConfig, 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?.id]) + + if (!challenge) return null + + const { scorecards = [] } = metadata + + // Filter human and AI reviewers + const allReviewers = challenge.reviewers || [] + const humanReviewers = allReviewers.filter(r => !isAIReviewer(r)) + + // Calculate review cost based on human reviewers + const calculateReviewCost = () => { + return humanReviewers + .reduce((sum, r) => { + const memberCount = parseInt(r.memberReviewerCount) || 1 + const baseAmount = parseFloat(r.fixedAmount) || 0 + const prizeAmount = challenge.prizeSets && challenge.prizeSets[0] + ? parseFloat(challenge.prizeSets[0].prizes?.[0]?.value) || 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) + .toFixed(2) + } + + // 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 : '-' + } + + // Check if AI review is configured + const hasAIConfiguration = aiConfiguration && (aiConfiguration.workflows?.length > 0) + + // 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?.some(w => w.isGating) + + const reviewCost = calculateReviewCost() + + return ( +
+

Review Configuration Summary

+ + {/* Human and AI Review Overview */} +
+ {/* Human Review Card */} +
+
+ 👥 + Human Review +
+
+ {humanReviewers.length === 0 ? ( +

No human reviewers configured

+ ) : ( + <> +
+ Reviewers: + {humanReviewers.reduce((sum, r) => sum + (parseInt(r.memberReviewerCount) || 1), 0)} +
+ + {humanReviewers.length > 0 && ( + <> +
+ Scorecard: + + {humanReviewers.map((r, idx) => ( +
{getScorecardName(r.scorecardId)}
+ ))} +
+
+ +
+ Phase: + + {humanReviewers.map((r, idx) => ( +
{getPhaseName(r.phaseId)}
+ ))} +
+
+ +
+ Review Type: + + {humanReviewers.map((r, idx) => ( +
{REVIEW_OPPORTUNITY_TYPE_LABELS[r.type] || 'Regular'}
+ ))} +
+
+ +
+ Public Opportunity: + + {humanReviewers.map((r, idx) => ( +
+ + {r.shouldOpenOpportunity ? '✅ Yes' : '❌ No'} + +
+ ))} +
+
+ + )} + + )} +
+
+ + {/* AI Review Card */} +
+
+ 🤖 + AI Review +
+
+ {!hasAIConfiguration ? ( +

No AI review configured

+ ) : ( + <> +
+ Mode: + {aiConfiguration.mode || 'Not set'} +
+ + {aiConfiguration.minPassingThreshold !== undefined && ( +
+ Threshold: + {aiConfiguration.minPassingThreshold}% +
+ )} + + {aiConfiguration.autoFinalize !== undefined && ( +
+ Auto-Finalize: + {aiConfiguration.autoFinalize ? '✅ On' : '❌ Off'} +
+ )} + + {aiConfiguration.workflows && aiConfiguration.workflows.length > 0 && ( +
+ Workflows: + + + + + + + + + + {aiConfiguration.workflows.map((workflow, idx) => ( + + + + + + ))} + +
NameWeightType
{workflow.workflow.name || workflow.workflowId || '-'}{workflow.weightPercent}% + {workflow.isGating ? ( + ⚡ GATE + ) : ( + 📝 + )} +
+
+ )} + + )} +
+
+
+ + {/* Review Flow Diagram */} + {(humanReviewers.length > 0 || hasAIConfiguration) && ( +
+

Review Flow

+
+ {/* Step 1: Submission */} +
+
📥
+
Submission
+
Received
+
+ + {/* Arrow 1 */} + {hasAIConfiguration && ( +
+ )} + {!hasAIConfiguration && humanReviewers.length > 0 && ( +
+ )} + + {/* Step 2: AI Review / AI Gate (if configured) */} + {hasAIConfiguration && ( +
+
🤖
+
{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
+
+ {humanReviewers.reduce((sum, r) => sum + (parseInt(r.memberReviewerCount) || 1), 0)} reviewers +
+
+ )} + + {/* Step 3: Human Review (for human-only flow) */} + {!hasAIConfiguration && humanReviewers.length > 0 && ( +
+
👥
+
Human Review
+
+ {humanReviewers.reduce((sum, r) => sum + (parseInt(r.memberReviewerCount) || 1), 0)} reviewers +
+
+ )} + + {/* Failure Path: Arrow down from AI Gate (only for AI_GATING with gating workflows) */} + {hasAIConfiguration && isAIGating && ( +
+ ↓ + {/* Failure Label */} + {hasAIConfiguration && isAIGating && ( +
+ < {aiConfiguration.minPassingThreshold || 75}% +
+ )} + ↓ +
+ )} + + {/* Failure Path: Locked Step (only for AI_GATING with gating workflows) */} + {isAIGatingMode && isAIGating && ( +
+
🔒
+
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, + challengeTracks: PropTypes.array + }), + readOnly: PropTypes.bool +} + +ReviewSummary.defaultProps = { + metadata: {}, + 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..4cdbde2e --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/ReviewSummary/ReviewSummary.module.scss @@ -0,0 +1,320 @@ +// @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; + + .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; + + .badgeYes, + .badgeNo { + 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; + } + } + } + + .workflowsSection { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #f0f0f0; + + .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/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 68e0ac98..4b10c875 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js @@ -6,6 +6,7 @@ import { loadScorecards, loadDefaultReviewers, loadWorkflows, replaceResourceInR import styles from './ChallengeReviewer-Field.module.scss' 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 = { @@ -176,6 +177,7 @@ class ChallengeReviewerField extends Component { return ( <> + {!readOnly && (
@@ -228,6 +230,19 @@ class ChallengeReviewerField extends Component { )}
+ )} + + {/* Review Summary Section */} + {readOnly && (challenge.reviewers && challenge.reviewers.length > 0) && ( +
+
+ +
+
+ )} ) } From a4998fcc4e385a7d1c10b3548f0227b55ca698ba Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 23 Feb 2026 12:02:55 +0200 Subject: [PATCH 16/16] sast fix --- src/services/aiReviewConfigs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/aiReviewConfigs.js b/src/services/aiReviewConfigs.js index 83840819..5c7a1d88 100644 --- a/src/services/aiReviewConfigs.js +++ b/src/services/aiReviewConfigs.js @@ -63,7 +63,7 @@ export async function fetchAIReviewConfigByChallenge (challengeId) { if (error.response && error.response.status === 404) { return null } - console.error(`Error fetching AI review config for challenge ${challengeId}:`, error.message) + console.error(`Error fetching AI review config for challenge %s:`, challengeId, error.message) throw error } }