diff --git a/mock-data/ai-review-config.json b/mock-data/ai-review-config.json new file mode 100644 index 00000000..2e05f122 --- /dev/null +++ b/mock-data/ai-review-config.json @@ -0,0 +1,20 @@ +{ + "challengeId": "97701509-f4ee-4a03-9bd1-bad7413d2274", + "minPassingThreshold": 15, + "mode": "AI_ONLY", + "templateId": "template_001", + "autoFinalize": false, + "formula": {}, + "workflows": [ + { + "workflowId": "1lROGgC0jANqJL", + "weightPercent": 80, + "isGating": false + }, + { + "workflowId": "J0aZLgbf9NUvnZ", + "weightPercent": 20, + "isGating": false + } + ] +} \ No newline at end of file diff --git a/mock-data/ai-review-templates.json b/mock-data/ai-review-templates.json new file mode 100644 index 00000000..8d91577c --- /dev/null +++ b/mock-data/ai-review-templates.json @@ -0,0 +1,171 @@ +{ + "templates": [ + { + "id": "template_001", + "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, + "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": "Challenge", + "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": "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, + "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/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 ( + )} + + +
+ {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/AiReviewerTab/AiReviewTab.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js new file mode 100644 index 00000000..e39f13da --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.js @@ -0,0 +1,147 @@ +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'; +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 + * Orchestrates between different views: initial state, template, manual, and legacy + */ +const AiReviewTab = ({ challenge, onUpdateReviewers, metadata = {}, isLoading, readOnly = false }) => { + const { + isLoading: isLoadingConfigs, + configuration, + configurationMode, + setConfigurationMode, + updateConfiguration, + addWorkflow, + updateWorkflow, + removeWorkflow, + resetConfiguration, + applyTemplate, + isSaving, + configId + } = 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(() => { + // 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, configId]) + + const handleSwitchConfigurationMode = useCallback((mode, template) => { + if (mode === 'manual') { + if (template) { + applyTemplate(pick(template, [ + 'mode', + 'minPassingThreshold', + 'autoFinalize', + 'formula', + 'workflows', + ])); + } + } else { + resetConfiguration() + } + setConfigurationMode(mode); + }, [setConfigurationMode, applyTemplate, resetConfiguration]); + + if (isLoading || isLoadingConfigs) { + return
Loading...
+ } + + 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 */} + {configurationMode === 'manual' && ( + + )} +
+ ); +} + +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..b379dcb3 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/AiReviewTab.module.scss @@ -0,0 +1,876 @@ +@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; + + &:last-child { + width: 136px; + } + } + } + + 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, .normalBadge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + } + + .gatingBadge { + background-color: #fff3cd; + border: 1px solid #ffc107; + color: #856404; + } + + .normalBadge { + background-color: #d1ecf1; + border: 1px solid #17a2b8; + 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; +} + +.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/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/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..9e09c83a --- /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' +import { isAIReviewer } from '../utils' + +/** + * 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, +}) => { + 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} + className={styles.weightInput} + /> +
Weight for scoring.
+
+ +
+ + +
+ {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.'} +
+ )} +
+
+ ) +} + +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, +} + +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..af67a183 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/SummarySection.js @@ -0,0 +1,51 @@ +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 === 'AI_ONLY' ? 'AI Only Review' : 'AI Gating'} +
+
+
+

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..7d039696 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/components/WeightValidationCard.js @@ -0,0 +1,46 @@ +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 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 ( +
+

Weight Validation

+
+
+ Scoring workflows weight total: {scoringSummary} {isWeightValid ? 'OK' : 'Invalid'} +
+ {!isWeightValid && ( +
+ 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..0f786f78 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useConfigurationState.js @@ -0,0 +1,220 @@ +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 + * Handles configuration object with mode, threshold, autoFinalize, and workflows + */ +const useConfigurationState = ( + challengeId, + initialConfig = { + mode: 'AI_GATING', + minPassingThreshold: 75, + autoFinalize: false, + workflows: [] + }, +) => { + 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 + */ + 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 + } + + return { + ...workflow, + [field]: value + }; + }) + + 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 = { + id: configId, + mode: template.mode || 'AI_GATING', + minPassingThreshold: template.minPassingThreshold || 75, + autoFinalize: template.autoFinalize || false, + workflows: template.workflows || [], + templateId: template.id || '', + } + + setConfiguration(newConfiguration) + }, [setConfiguration, configId]) + + + // Fetch AI review config when component loads + useEffect(() => { + const loadAIReviewConfig = async () => { + setIsLoading(true); + try { + if (challengeId) { + const config = await fetchAIReviewConfigByChallenge(challengeId) + if (config) { + // Load the config into the configuration state + setConfigurationMode(config.templateId ? 'template' : 'manual') + setConfiguration(config) + lastSavedConfigRef.current = config + } + } + } catch (err) { + console.error('Error loading AI review configuration:', err) + } finally { + setIsLoading(false); + } + } + + 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, + configurationMode, + setConfigurationMode, + updateConfiguration, + addWorkflow, + updateWorkflow, + removeWorkflow, + resetConfiguration, + applyTemplate, + isSaving, + configId + } +} + +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..648610ae --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/hooks/useTemplateManager.js @@ -0,0 +1,80 @@ +import { useState, useCallback, useEffect } from 'react' +import { fetchAIReviewTemplates } from '../../../../../services/aiReviewTemplates' + +/** + * 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) + + /** + * 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 fetchAIReviewTemplates({ + challengeTrack, + challengeType + }) + + setTemplates(fetchedTemplates || []) + setTemplatesLoading(false) + } catch (err) { + console.error('Error loading templates:', err) + setError('Failed to load templates') + setTemplatesLoading(false) + } + }, [setTemplates, setTemplatesLoading, setError, 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/ManualConfigurationView.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js new file mode 100644 index 00000000..1f1c0e1b --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/ManualConfigurationView.js @@ -0,0 +1,171 @@ +import React, { useState, useCallback, useRef } 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' +import { configHasChanges } from '../../../../../services/aiReviewConfigHelpers' +import AiWorkflowsTableListing from '../components/AiWorkflowsTableListing' + +/** + * Manual Configuration View - Manually configure AI review settings and workflows + */ +const ManualConfigurationView = ({ + challenge, + configuration, + availableWorkflows, + onUpdateConfiguration, + onAddWorkflow, + onUpdateWorkflow, + onRemoveWorkflow, + onSwitchMode, + onRemoveConfig, + readOnly, +}) => { + const origConfig = useRef(configuration); + const [showSwitchConfirmModal, setShowSwitchConfirmModal] = useState(false) + + const handleConfirmSwitch = useCallback(() => { + onRemoveConfig(); + onSwitchMode('template'); + }, [onSwitchMode, onRemoveConfig]); + + const handleOnSwitchConfig = useCallback(() => { + if (configHasChanges(origConfig.current, configuration)) { + setShowSwitchConfirmModal(true); + } else { + handleConfirmSwitch(); + } + }, [ + configuration, setShowSwitchConfirmModal, handleConfirmSwitch + ]); + + return ( +
+ {/* Configuration Source Selector */} + {!readOnly && ( + + )} + + {/* Review Settings Section */} + {!readOnly && ( + + )} + + {/* Manual Workflows Section */} + {!readOnly && ( +
+

AI Workflows (editable)

+ + {configuration.workflows.map((workflow, index) => ( + onRemoveWorkflow(index)} + readOnly={readOnly} + /> + ))} + + {!readOnly && ( + + )} +
+ )} + + {/* Weight Validation Section */} + {!readOnly && ( + + )} + + {readOnly && ( + + )} + + {/* 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 new file mode 100644 index 00000000..1a96429a --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiReviewerTab/views/TemplateConfigurationView.js @@ -0,0 +1,187 @@ +import React, { useCallback, useState } from 'react' +import cn from 'classnames' +import PropTypes from 'prop-types' +import styles from '../AiReviewTab.module.scss' +import useTemplateManager from '../hooks/useTemplateManager' +import SummarySection from '../components/SummarySection' +import ConfirmationModal from '../../../../Modal/ConfirmationModal' +import ConfigurationSourceSelector from '../components/ConfigurationSourceSelector' +import AiWorkflowsTableListing from '../components/AiWorkflowsTableListing' + +/** + * Template Configuration View - Select and configure using a template + */ +const TemplateConfigurationView = ({ + challenge, + configuration, + onTemplateChange, + onUpdateConfiguration, + onSwitchMode, + onRemoveConfig, + readOnly, + availableWorkflows: workflows, +}) => { + const { + templates, + selectedTemplate, + templatesLoading, + error: templateError, + selectTemplate, + clearSelection + } = useTemplateManager( + configuration.templateId, + challenge.track.name, + challenge.type.name, + ) + const [showSwitchToManualConfirm, setShowSwitchToManualConfirm] = useState(false) + + const handleTemplateChange = useCallback((e) => { + const templateId = e.target.value + const template = selectTemplate(templateId) + if (template) { + onTemplateChange(template) + } + }, [selectTemplate, onTemplateChange]); + + const handleRemoveConfig = useCallback(() => { + clearSelection() + onRemoveConfig() + }, [onRemoveConfig, clearSelection]); + + const handleConfirmSwitch = useCallback(() => { + clearSelection(); + onSwitchMode('manual', selectedTemplate); + }, [onSwitchMode, selectedTemplate]); + + const handleOnSwitchConfig = useCallback(() => { + if (selectedTemplate?.id) { + setShowSwitchToManualConfirm(true); + } else { + handleConfirmSwitch(); + } + }, [ + selectedTemplate, setShowSwitchToManualConfirm, handleConfirmSwitch + ]); + + if (templateError) { + return ( +
+ {templateError} +
+ ) + } + + return ( +
+ {/* Configuration Source Selector */} + + + {/* Template Selection Section */} +
+

📋 AI Review Template

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

{selectedTemplate.description}

+
+ )} +
+ + {/* Review Settings Section */} + {/* {selectedTemplate && ( + + )} */} + + {/* AI Workflows Section */} + {selectedTemplate && configuration.workflows && configuration.workflows.length > 0 && ( + + )} + + {/* Summary Section */} + {selectedTemplate && ( + + )} + + {/* Remove Configuration Button */} + {!readOnly && selectedTemplate && ( +
+ +
+ )} + + {templatesLoading && ( +
Loading templates...
+ )} + + {showSwitchToManualConfirm && ( + +

The template settings will be copied into editable fields.

+

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

+
+ )} + cancelText='Cancel' + confirmText='Switch to Manual' + onCancel={() => setShowSwitchToManualConfirm(false)} + onConfirm={handleConfirmSwitch} + /> + )} + + ) +} + +TemplateConfigurationView.propTypes = { + challenge: PropTypes.object.isRequired, + configuration: PropTypes.shape({ + mode: PropTypes.string.isRequired, + minPassingThreshold: PropTypes.number.isRequired, + autoFinalize: PropTypes.bool.isRequired, + workflows: PropTypes.array.isRequired + }).isRequired, + onTemplateChange: PropTypes.func.isRequired, + onUpdateConfiguration: PropTypes.func.isRequired, + onSwitchMode: PropTypes.func.isRequired, + onRemoveConfig: PropTypes.func.isRequired, + readOnly: PropTypes.bool, + availableWorkflows: PropTypes.array.isRequired, +} + +TemplateConfigurationView.defaultProps = { + readOnly: false, +} + +export default TemplateConfigurationView diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/AiWorkflowCard.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiWorkflowCard.module.scss new file mode 100644 index 00000000..8e1edde4 --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/AiWorkflowCard.module.scss @@ -0,0 +1,119 @@ +@use '../../../styles/includes' as *; + +.workflowCard { + border: 1px solid #e0e0e0; + border-radius: 8px; + background-color: white; + overflow: hidden; + transition: all 0.3s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + } +} + +.workflowcardHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + background-color: #f8f9fa; + border-bottom: 1px solid #e0e0e0; +} + +.workflowInfo { + flex: 1; +} + +.workflowName { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 500; + color: #333; +} + +.workflowIcon { + font-size: 18px; +} + +.workflowTitle { + word-break: break-word; +} + +.workflowRemoveBtn { + background: none; + border: none; + color: #999; + font-size: 20px; + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s ease; + flex-shrink: 0; + + &:hover { + color: #d32f2f; + background-color: #ffebee; + } +} + +.workflowcardContent { + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.workflowDescription { + strong { + display: block; + font-size: 12px; + color: #999; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; + } + + p { + margin: 0; + font-size: 14px; + color: #555; + line-height: 1.4; + } +} + +.workflowScorecard { + strong { + display: block; + font-size: 12px; + color: #999; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; + } +} + +.scorecardLink { + color: #0066cc; + text-decoration: none; + font-size: 14px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 4px; + transition: all 0.2s ease; + + &:hover { + background-color: #e3f2fd; + text-decoration: underline; + } +} diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss index 268b520e..e8ff3a1a 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss @@ -1,4 +1,5 @@ @use '../../../styles/includes' as *; +@import './shared.module.scss'; .row { box-sizing: border-box; @@ -40,191 +41,91 @@ width: 600px; } + &.full { + width: 100%; + } + .fieldError { margin-top: 12px; } } } -.description { - color: #666; - margin-bottom: 20px; - font-size: 14px; - line-height: 1.4; -} - -.noReviewers { - text-align: center; - padding: 30px; - color: #999; - font-style: italic; - background-color: #f5f5f5; - border-radius: 4px; - margin-bottom: 20px; -} - -.defaultReviewerNote { - margin-top: 20px; - padding: 15px; - background-color: #e3f2fd; - border: 1px solid #bbdefb; - border-radius: 4px; -} - -.defaultReviewerNote p { - margin: 0 0 15px 0; - color: #1976d2; - font-style: normal; -} - -.reviewerForm { - background-color: white; +// Tabs styling +.tabsContainer { + display: flex; + flex-direction: column; border: 1px solid #ddd; border-radius: 4px; - padding: 20px; - margin-bottom: 20px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); -} - -.reviewerHeader { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - padding-bottom: 10px; - border-bottom: 1px solid #eee; -} - -.reviewerHeader h4 { - margin: 0; - color: #333; - font-size: 16px; - font-weight: 600; + background-color: #fff; + .hidden { + display: none !important; + } } -.formRow { +.tabsHeader { display: flex; - flex-wrap: wrap; - gap: 20px; - margin-bottom: 15px; + border-bottom: 2px solid #ddd; + background-color: #f9f9f9; } -.formGroup { +.tabButton { flex: 1; - min-width: 200px; -} - -.formGroup label { - display: block; - margin-bottom: 5px; - font-weight: 500; - color: #555; + padding: 12px 16px; + background: transparent; + border: none; + cursor: pointer; font-size: 14px; + font-weight: 500; + color: #666; + border-bottom: 3px solid transparent; + transition: all 0.3s ease; + margin-bottom: -1px; + + &:hover { + background-color: #f0f0f0; + color: #333; + } + + &.active { + color: #0066cc; + border-bottom-color: #0066cc; + background-color: #fff; + } } -.formGroup input, -.formGroup select { - width: 100%; - padding: 8px 12px; - border: 1px solid #ccc; - border-radius: 4px; - font-size: 14px; - background-color: white; -} - -.formGroup input:focus, -.formGroup select:focus { - outline: none; - border-color: #0066cc; - box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2); -} - -.addButton { - text-align: center; +.defaultReviewerNote { margin-top: 20px; -} - -.summary { - background-color: #f8f9fa; - border: 1px solid #dee2e6; + padding: 15px; + background-color: #e3f2fd; + border: 1px solid #bbdefb; border-radius: 4px; - padding: 20px; - margin: 20px 0; -} -.summary h4 { - margin: 0 0 15px 0; - color: #333; - font-size: 16px; - font-weight: 600; -} - -.summaryRow { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 0; - border-bottom: 1px solid #eee; -} - -.summaryRow:last-child { - border-bottom: none; - font-weight: 600; - color: #0066cc; -} - -.summaryRow span:first-child { - color: #666; + p { + margin: 0 0 15px 0; + color: #1976d2; + font-style: normal; + } } -.summaryRow span:last-child { +.applyDefaultBtn { + padding: 8px 16px; + background-color: #0066cc; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; font-weight: 500; -} - -.loading { - text-align: center; - padding: 40px; - color: #666; - font-style: italic; -} - -.error { - color: $tc-red; - padding: 5px; -} + transition: background-color 0.3s ease; -.validationErrors { - background-color: #fff3cd; - border: 1px solid #ffeaa7; - border-radius: 4px; - padding: 10px; - margin-bottom: 15px; -} + &:hover { + background-color: #0052a3; + } -.validationError { - color: #856404; - font-size: 13px; - margin-bottom: 5px; + &:active { + background-color: #004080; + } } -.validationError:last-child { - margin-bottom: 0; -} -// Responsive adjustments -@media (max-width: 768px) { - .formRow { - flex-direction: column; - gap: 15px; - } - - .formGroup { - min-width: 100%; - } - - .reviewerHeader { - flex-direction: column; - align-items: flex-start; - gap: 10px; - } -} diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/HumanReviewTab.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/HumanReviewTab.js new file mode 100644 index 00000000..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/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 0de3fa0b..4b10c875 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js @@ -2,23 +2,11 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' import cn from 'classnames' -import { PrimaryButton, OutlineButton } from '../../Buttons' -import { REVIEW_OPPORTUNITY_TYPE_LABELS, REVIEW_OPPORTUNITY_TYPES, VALIDATION_VALUE_TYPE, MARATHON_TYPE_ID, DES_TRACK_ID } from '../../../config/constants' import { loadScorecards, loadDefaultReviewers, loadWorkflows, replaceResourceInRole, createResource, deleteResource } from '../../../actions/challenges' import styles from './ChallengeReviewer-Field.module.scss' -import { validateValue } from '../../../util/input-check' -import AssignedMemberField from '../AssignedMember-Field' -import { getResourceRoleByName } from '../../../util/tc' -import { isEqual } from 'lodash' - -const ResourceToPhaseNameMap = { - Reviewer: 'Review', - Approver: 'Approval', - Screener: 'Screening', - 'Iterative Reviewer': 'Iterative Review', - 'Checkpoint Reviewer': 'Checkpoint Review', - 'Checkpoint Screener': 'Checkpoint Screening' -} +import HumanReviewTab from './HumanReviewTab' +import { AiReviewTab } from './AiReviewerTab' +import ReviewSummary from './ReviewSummary' // Keep track filters aligned with the scorecards API regardless of legacy values const SCORECARD_TRACK_ALIASES = { @@ -66,89 +54,15 @@ class ChallengeReviewerField extends Component { super(props) this.state = { error: null, - // Map reviewer index -> array of assigned member details { handle, userId } - assignedMembers: {} + activeTab: 'human' // 'human' or 'ai' } - this.addReviewer = this.addReviewer.bind(this) - this.removeReviewer = this.removeReviewer.bind(this) - this.updateReviewer = this.updateReviewer.bind(this) - this.renderReviewerForm = this.renderReviewerForm.bind(this) - this.handleApplyDefault = this.handleApplyDefault.bind(this) - this.isAIReviewer = this.isAIReviewer.bind(this) - this.getMissingRequiredPhases = this.getMissingRequiredPhases.bind(this) - this.getRoleNameForReviewer = this.getRoleNameForReviewer.bind(this) - this.onAssignmentChange = this.onAssignmentChange.bind(this) - this.syncAssignmentsOnCountChange = this.syncAssignmentsOnCountChange.bind(this) - this.handlePhaseChangeWithReassign = this.handlePhaseChangeWithReassign.bind(this) - this.handleToggleShouldOpen = this.handleToggleShouldOpen.bind(this) - this.updateAssignedMembers = this.updateAssignedMembers.bind(this) - this.doUpdateAssignedMembers = true - } - - isAIReviewer (reviewer) { - return reviewer && ( - (reviewer.aiWorkflowId && reviewer.aiWorkflowId.trim() !== '') || - (reviewer.isMemberReview === false) - ) - } - - isPublicOpportunityOpen (reviewer) { - return reviewer && reviewer.shouldOpenOpportunity === true - } - - getMissingRequiredPhases () { - const { challenge } = this.props - // Marathon Match does not require review configuration - if (challenge && challenge.typeId === MARATHON_TYPE_ID) { - return [] - } - const reviewers = challenge.reviewers || [] - const phases = Array.isArray(challenge.phases) ? challenge.phases : [] - - const requiredPhaseNames = [ - 'Screening', - 'Review', - 'Post-mortem', - 'Approval', - 'Checkpoint Screening', - 'Iterative Review' - ] - - const normalize = (name) => (name || '') - .toString() - .toLowerCase() - .replace(/[-\s]/g, '') - - const requiredNormalized = new Set(requiredPhaseNames.map(normalize)) - - // Map challenge phases by normalized name to phase ids (only those we care about) - const requiredPhaseEntries = phases - .filter(p => requiredNormalized.has(normalize(p.name))) - .map(p => ({ name: p.name, id: p.phaseId || p.id })) - - const missing = [] - for (const entry of requiredPhaseEntries) { - const hasReviewerWithScorecard = reviewers.some(r => { - const rPhaseId = r.phaseId - const hasScorecard = !!r.scorecardId - return rPhaseId === entry.id && hasScorecard - }) - if (!hasReviewerWithScorecard) { - // Use the canonical capitalization from requiredPhaseNames when possible - const canonical = requiredPhaseNames.find(n => normalize(n) === normalize(entry.name)) || entry.name - missing.push(canonical) - } - } - - return missing + this.loadScorecards = this.loadScorecards.bind(this) + this.loadDefaultReviewers = this.loadDefaultReviewers.bind(this) + this.loadWorkflows = this.loadWorkflows.bind(this) } componentDidMount () { - const { challenge, challengeResources } = this.props - if (challenge && challenge.id && challengeResources) { - this.updateAssignedMembers(challengeResources, challenge) - } if (this.props.challenge.track || this.props.challenge.type) { this.loadScorecards() } @@ -156,105 +70,8 @@ class ChallengeReviewerField extends Component { this.loadWorkflows() } - updateAssignedMembers (challengeResources, challenge, prevChallenge = null) { - const reviewersWithPhaseName = challenge.reviewers.map(item => { - const phase = challenge.phases && challenge.phases.find(p => (p.id === item.phaseId) || (p.phaseId === item.phaseId)) - return { - ...item, - name: phase && phase.name - } - }) - - const reviewerIndex = {} - reviewersWithPhaseName.forEach((reviewer, index) => { - if (!reviewerIndex[reviewer.name]) { - reviewerIndex[reviewer.name] = [] - } - reviewerIndex[reviewer.name].push(index) - }) - - const assignedMembers = {} - - const unchangedReviewers = new Set() - if (prevChallenge && prevChallenge.reviewers) { - const prevReviewers = prevChallenge.reviewers || [] - challenge.reviewers.forEach((reviewer, index) => { - const prevReviewer = prevReviewers[index] - if (prevReviewer && - prevReviewer.phaseId === reviewer.phaseId && - (reviewer.isMemberReview !== false) && - (prevReviewer.isMemberReview !== false)) { - unchangedReviewers.add(index) - if (this.state.assignedMembers[index]) { - assignedMembers[index] = [...this.state.assignedMembers[index]] - } - } - }) - } - - challengeResources.forEach((resource) => { - const phaseName = ResourceToPhaseNameMap[resource.roleName] - if (!phaseName) return - - const indices = reviewerIndex[phaseName] || [] - - // Distribute resources across all reviewers with the same phase name - indices.forEach((index) => { - const reviewer = challenge.reviewers[index] - if (!reviewer || (reviewer.isMemberReview === false)) return - - if (unchangedReviewers.has(index)) { - const existing = assignedMembers[index] || [] - const alreadyAssigned = existing.some(m => - m && (m.userId === resource.memberId || m.handle === resource.memberHandle) - ) - if (!alreadyAssigned) { - if (!assignedMembers[index]) { - assignedMembers[index] = [] - } - assignedMembers[index].push({ - handle: resource.memberHandle, - userId: resource.memberId - }) - } - } else { - if (!assignedMembers[index]) { - assignedMembers[index] = [] - } - const existing = assignedMembers[index] - const alreadyAssigned = existing.some(m => - m && (m.userId === resource.memberId || m.handle === resource.memberHandle) - ) - if (!alreadyAssigned) { - assignedMembers[index].push({ - handle: resource.memberHandle, - userId: resource.memberId - }) - } - } - }) - }) - - // Clean up assignments for reviewers that no longer exist or are no longer member reviewers - Object.keys(assignedMembers).forEach(indexStr => { - const index = parseInt(indexStr, 10) - const reviewer = challenge.reviewers[index] - if (index >= challenge.reviewers.length || - !reviewer || - (reviewer.isMemberReview === false)) { - delete assignedMembers[index] - } - }) - - if (!isEqual(this.state.assignedMembers, assignedMembers)) { - this.setState({ - assignedMembers - }) - } - } - componentDidUpdate (prevProps) { - const { challenge, challengeResources } = this.props + const { challenge } = this.props const prevChallenge = prevProps.challenge if (challenge && prevChallenge && @@ -264,169 +81,12 @@ class ChallengeReviewerField extends Component { } } - const reviewersChanged = (() => { - if (!challenge || !prevChallenge) return false - const currReviewers = challenge.reviewers || [] - const prevReviewers = prevChallenge.reviewers || [] - if (currReviewers.length !== prevReviewers.length) return true - for (let i = 0; i < currReviewers.length; i++) { - const curr = currReviewers[i] - const prev = prevReviewers[i] - const { scorecardId: currScorecardId, ...currRest } = curr - const { scorecardId: prevScorecardId, ...prevRest } = prev - if (JSON.stringify(currRest) !== JSON.stringify(prevRest)) { - return true - } - } - return false - })() - - if (challenge && this.doUpdateAssignedMembers && reviewersChanged) { - this.updateAssignedMembers(challengeResources, challenge, prevChallenge) - } - if (challenge && prevChallenge && (challenge.typeId !== prevChallenge.typeId || challenge.trackId !== prevChallenge.trackId)) { this.loadDefaultReviewers() } } - getRoleNameForReviewer (reviewer) { - const { challenge } = this.props - const phase = (challenge.phases || []).find(p => (p.id === reviewer.phaseId) || (p.phaseId === reviewer.phaseId)) - const name = (phase && phase.name) ? phase.name.toLowerCase() : '' - - // Normalize for matching - const norm = name.replace(/[-\s]/g, '') - - if (name.includes('iterative review') || norm === 'iterativereview') return 'Iterative Reviewer' - if (norm === 'approval') return 'Approver' - if (norm === 'checkpointscreening') return 'Checkpoint Screener' - if (norm === 'checkpointreview') return 'Checkpoint Reviewer' - if (norm === 'screening') return 'Screener' - // default to Reviewer for any kind of review - return 'Reviewer' - } - - async onAssignmentChange (reviewerIndex, slotIndex, option) { - const { challenge, metadata = {}, replaceResourceInRole } = this.props - if (!challenge || !challenge.id) return - - const roleName = this.getRoleNameForReviewer((challenge.reviewers || [])[reviewerIndex] || {}) - const role = getResourceRoleByName(metadata.resourceRoles || [], roleName) - if (!role) return - - this.setState(prev => { - const prevHandles = prev.assignedMembers[reviewerIndex] || [] - const prevMember = prevHandles[slotIndex] || null - const newHandles = [...prevHandles] - - let newMemberHandle = null - if (option && option.value) { - newHandles[slotIndex] = { - handle: option.label, - userId: parseInt(option.value, 10) - } - newMemberHandle = option.label - } else { - newHandles[slotIndex] = null - } - - // fire resource update - const oldHandle = prevMember && prevMember.handle - // replaceResourceInRole gracefully handles deletion when newMember is falsy - replaceResourceInRole(challenge.id, role.id, newMemberHandle, oldHandle) - this.doUpdateAssignedMembers = false - return { - assignedMembers: { - ...prev.assignedMembers, - [reviewerIndex]: newHandles - } - } - }, () => { - const n = this - setTimeout(() => { - n.doUpdateAssignedMembers = true - }, 1000) - }) - } - - async syncAssignmentsOnCountChange (reviewerIndex, newCount) { - const { challenge, metadata = {}, deleteResource } = this.props - const roleName = this.getRoleNameForReviewer((challenge.reviewers || [])[reviewerIndex] || {}) - const role = getResourceRoleByName(metadata.resourceRoles || [], roleName) - if (!role) return - this.setState(prev => { - const current = prev.assignedMembers[reviewerIndex] || [] - const toRemove = current.slice(newCount).filter(Boolean) - // remove extra assigned resources - toRemove.forEach(m => { - if (challenge && challenge.id && m && m.handle) { - deleteResource(challenge.id, role.id, m.handle) - } - }) - const next = current.slice(0, newCount) - return { - assignedMembers: { - ...prev.assignedMembers, - [reviewerIndex]: next - } - } - }) - } - - async handlePhaseChangeWithReassign (reviewerIndex, newPhaseId) { - const { challenge, metadata = {}, createResource, deleteResource } = this.props - const reviewers = challenge.reviewers || [] - const currentReviewer = reviewers[reviewerIndex] - if (!currentReviewer) return - - const oldRoleName = this.getRoleNameForReviewer(currentReviewer) - const newReviewer = { ...currentReviewer, phaseId: newPhaseId } - const newRoleName = this.getRoleNameForReviewer(newReviewer) - - if (oldRoleName === newRoleName) return - - const oldRole = getResourceRoleByName(metadata.resourceRoles || [], oldRoleName) - const newRole = getResourceRoleByName(metadata.resourceRoles || [], newRoleName) - if (!oldRole || !newRole) return - - const assigned = (this.state.assignedMembers[reviewerIndex] || []).filter(Boolean) - // move any existing assigned members from old role to new role - for (const m of assigned) { - try { - if (challenge && challenge.id && m && m.handle) { - await deleteResource(challenge.id, oldRole.id, m.handle) - await createResource(challenge.id, newRole.id, m.handle) - } - } catch (e) {} - } - } - - async handleToggleShouldOpen (reviewerIndex, nextValue) { - // If toggling to open public opportunity, remove any existing assigned members for this reviewer - if (nextValue) { - const { challenge, metadata = {}, deleteResource } = this.props - const roleName = this.getRoleNameForReviewer((challenge.reviewers || [])[reviewerIndex] || {}) - const role = getResourceRoleByName(metadata.resourceRoles || [], roleName) - if (!role) return - const assigned = (this.state.assignedMembers[reviewerIndex] || []).filter(Boolean) - for (const m of assigned) { - try { - if (challenge && challenge.id && m && m.handle) { - await deleteResource(challenge.id, role.id, m.handle) - } - } catch (e) {} - } - this.setState(prev => ({ - assignedMembers: { - ...prev.assignedMembers, - [reviewerIndex]: [] - } - })) - } - } - loadScorecards () { const { challenge, loadScorecards, metadata } = this.props @@ -472,580 +132,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 ( @@ -1075,83 +177,52 @@ class ChallengeReviewerField extends Component { return ( <> + {!readOnly && (
- {(!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} - -
+
+ 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} @@ -1159,6 +230,19 @@ class ChallengeReviewerField extends Component { )}
+ )} + + {/* Review Summary Section */} + {readOnly && (challenge.reviewers && challenge.reviewers.length > 0) && ( +
+
+ +
+
+ )} ) } diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/shared.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/shared.module.scss new file mode 100644 index 00000000..3c5b8e7a --- /dev/null +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/shared.module.scss @@ -0,0 +1,206 @@ +@use '../../../styles/includes' as *; + +.tabContent { + padding: 20px; +} + +.description { + color: #666; + margin-bottom: 20px; + font-size: 14px; + line-height: 1.4; +} + +.noReviewers { + text-align: center; + padding: 30px; + color: #999; + font-style: italic; + background-color: #f5f5f5; + border-radius: 4px; + margin-bottom: 20px; +} + +.addReviewerBtn { + padding: 8px 16px; + background-color: #0066cc; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: background-color 0.3s ease; + + &:hover { + background-color: #0052a3; + } + + &:active { + background-color: #004080; + } +} + +.addButton { + text-align: center; + margin-top: 20px; +} + +.reviewerForm { + background-color: white; + border: 1px solid #ddd; + border-radius: 4px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.reviewerHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid #eee; + + h4 { + margin: 0; + color: #333; + font-size: 16px; + font-weight: 600; + } +} + +.formRow { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin-bottom: 15px; +} + +.formGroup { + flex: 1; + min-width: 200px; + + label { + display: block; + margin-bottom: 5px; + font-weight: 500; + color: #555; + font-size: 14px; + } + + input, + select { + width: 100%; + padding: 8px 12px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + background-color: white; + } + + input[type="checkbox"] { + width: auto; + } + + &.mtop { + margin-top: 32px; + } + + input:focus, + select:focus { + outline: none; + border-color: #0066cc; + box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2); + } +} + +.summary { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 20px; + margin: 20px 0; + + h4 { + margin: 0 0 15px 0; + color: #333; + font-size: 16px; + font-weight: 600; + } +} + +.summaryRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #eee; + + &:last-child { + border-bottom: none; + font-weight: 600; + color: #0066cc; + } + + span:first-child { + color: #666; + } + + span:last-child { + font-weight: 500; + } +} + +.loading { + text-align: center; + padding: 40px; + color: #666; + font-style: italic; +} + +.error { + color: $tc-red; + padding: 5px; +} + +.fieldError { + margin-top: 12px; +} + +.validationErrors { + background-color: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 4px; + padding: 10px; + margin-bottom: 15px; +} + +.validationError { + color: #856404; + font-size: 13px; + margin-bottom: 5px; + + &:last-child { + margin-bottom: 0; + } +} + +// Responsive adjustments +@media (max-width: 768px) { + .formRow { + flex-direction: column; + gap: 15px; + } + + .formGroup { + min-width: 100%; + } + + .reviewerHeader { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } +} diff --git a/src/components/ChallengeEditor/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 diff --git a/src/config/constants.js b/src/config/constants.js index ba943753..b60e2b59 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -42,6 +42,7 @@ export const { PROFILE_URL, TC_FINANCE_API_URL, TC_AI_API_BASE_URL, + TC_REVIEWS_API_BASE_URL, TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID, ENGAGEMENTS_APP_URL } = process.env diff --git a/src/services/aiReviewConfigHelpers.js b/src/services/aiReviewConfigHelpers.js new file mode 100644 index 00000000..9e4b90f8 --- /dev/null +++ b/src/services/aiReviewConfigHelpers.js @@ -0,0 +1,165 @@ +/** + * 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('./mocks/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) + } +} + +/** + * 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 + } +} + +/** + * 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 +} + +/** + * 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/aiReviewConfigs.js b/src/services/aiReviewConfigs.js new file mode 100644 index 00000000..5c7a1d88 --- /dev/null +++ b/src/services/aiReviewConfigs.js @@ -0,0 +1,120 @@ +import _ from 'lodash' +import { axiosInstance } from './axiosWithAuth' +import { TC_REVIEWS_API_BASE_URL } from '../config/constants' + +/** + * 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( + `${TC_REVIEWS_API_BASE_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( + `${TC_REVIEWS_API_BASE_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 %s:`, 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( + `${TC_REVIEWS_API_BASE_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( + `${TC_REVIEWS_API_BASE_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..6332093e --- /dev/null +++ b/src/services/aiReviewTemplateHelpers.js @@ -0,0 +1,24 @@ +/** + * 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('./mocks/aiReviewTemplates.mock') + : templateService + + return { + fetchAll: (filters) => service.mockFetchAIReviewTemplates + ? service.mockFetchAIReviewTemplates(filters) + : service.fetchAIReviewTemplates(filters), + } +} \ No newline at end of file diff --git a/src/services/aiReviewTemplates.js b/src/services/aiReviewTemplates.js new file mode 100644 index 00000000..1a44a1f3 --- /dev/null +++ b/src/services/aiReviewTemplates.js @@ -0,0 +1,24 @@ +import _ from 'lodash' +import qs from 'qs' + +import { TC_REVIEWS_API_BASE_URL } from '../config/constants' + +import { axiosInstance } from './axiosWithAuth' + +/** + * 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(`${TC_REVIEWS_API_BASE_URL}/ai-review/templates${queryString}`) + return _.get(response, 'data', []) + } catch (error) { + console.error('Error fetching AI review templates:', 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..db7ab510 --- /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..6e9185b2 --- /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'