From 425f563b5932913362138e7d9da54f41f4cf1462 Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Tue, 2 Jun 2026 09:24:56 +0300
Subject: [PATCH 01/30] PM-5203 - allow manager to update ai score
---
.../ChallengeDetailsContent.tsx | 11 +
.../TabContentAiApproval.module.scss | 111 +++++++++
.../TabContentAiApproval.tsx | 211 ++++++++++++++++++
.../review/src/lib/models/AiReview.model.ts | 3 +
.../src/lib/services/aiReview.service.ts | 23 +-
5 files changed, 358 insertions(+), 1 deletion(-)
create mode 100644 src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss
create mode 100644 src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
index 3d1f95c08..33773f8a9 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
@@ -35,6 +35,7 @@ import {
} from '../../utils/reviewPhaseGuards'
import TabContentApproval from './TabContentApproval'
+import TabContentAiApproval from './TabContentAiApproval'
import TabContentCheckpoint from './TabContentCheckpoint'
import TabContentIterativeReview from './TabContentIterativeReview'
import TabContentRegistration from './TabContentRegistration'
@@ -497,6 +498,16 @@ export const ChallengeDetailsContent: FC = (props: Props) => {
}
if (selectedTabNormalized === 'approval') {
+ if (aiReviewConfig?.mode === 'AI_ONLY') {
+ return (
+
+ )
+ }
+
return (
void
+}
+
+const SubmissionApprovalRow: FC = ({
+ submission,
+ decision,
+ aiReviewers,
+ isPrivilegedRole,
+ isApprovalPhaseOpen,
+ onSaved,
+}) => {
+ const [managerComment, setManagerComment] = useState(decision?.managerComment ?? '')
+ const [isSaving, setIsSaving] = useState(false)
+ const canEdit = isPrivilegedRole && isApprovalPhaseOpen
+
+ const handleSave = useCallback(async () => {
+ if (!decision?.id) return
+ setIsSaving(true)
+ try {
+ const updated = await patchAiReviewDecision(decision.id, {
+ managerComment: managerComment.trim() || null,
+ })
+ onSaved(updated)
+ toast.success('Manager comment saved.')
+ } catch (err) {
+ toast.error('Failed to save manager comment.')
+ } finally {
+ setIsSaving(false)
+ }
+ }, [decision?.id, managerComment, onSaved])
+
+ const submittedDate = submission.created
+ ? moment(submission.created).format(TABLE_DATE_FORMAT)
+ : '-'
+
+ return (
+
+
+
+ {submission.id}
+
+ {submittedDate}
+ {decision && (
+
+ AI Score: {' '}
+ {decision.totalScore != null
+ ? decision.totalScore.toFixed(2)
+ : '-'}
+ {decision.status === 'HUMAN_OVERRIDE' && (
+ (Override)
+ )}
+
+ )}
+
+
+
+
+ {decision && canEdit && (
+
+
+ Manager Comment
+
+
+ )}
+
+ {decision?.managerComment && !canEdit && (
+
+
Manager Comment:
+
{decision.managerComment}
+
+ )}
+
+ )
+}
+
+export const TabContentAiApproval: FC = ({
+ submissions,
+ isLoading,
+ isActiveChallenge,
+}) => {
+ const {
+ challengeInfo,
+ aiReviewConfig,
+ aiReviewDecisionsBySubmissionId,
+ }: ChallengeDetailContextModel = useContext(ChallengeDetailContext)
+ const { isPrivilegedRole }: useRoleProps = useRole()
+
+ const aiReviewers = useMemo<{ aiWorkflowId: string }[]>(
+ () => (challengeInfo?.reviewers?.filter(r => !!r.aiWorkflowId) as { aiWorkflowId: string }[]) ?? [],
+ [challengeInfo?.reviewers],
+ )
+
+ const isApprovalPhaseOpen = useMemo(
+ () => (challengeInfo?.phases ?? []).some(
+ p => (p.name || '').toLowerCase() === 'approval' && Boolean(p.isOpen),
+ ),
+ [challengeInfo?.phases],
+ )
+
+ // Local copy of decisions to allow optimistic updates after PATCH
+ const [localDecisionOverrides, setLocalDecisionOverrides] = useState<
+ Record
+ >({})
+
+ const getDecision = useCallback(
+ (submissionId: string): AiReviewDecision | undefined =>
+ localDecisionOverrides[submissionId] ?? aiReviewDecisionsBySubmissionId[submissionId],
+ [aiReviewDecisionsBySubmissionId, localDecisionOverrides],
+ )
+
+ const handleDecisionSaved = useCallback((updated: AiReviewDecision) => {
+ setLocalDecisionOverrides(prev => ({
+ ...prev,
+ [updated.submissionId]: updated,
+ }))
+ }, [])
+
+ const contestSubmissions = useMemo(
+ () => submissions.filter(s => (s.type || '').toLowerCase() === 'contestsubmission'),
+ [submissions],
+ )
+
+ if (isLoading) {
+ return
+ }
+
+ if (contestSubmissions.length === 0) {
+ return
+ }
+
+ return (
+
+
+ Review the AI scorecards below.
+ {isPrivilegedRole && isApprovalPhaseOpen && (
+ <> You may add a manager comment to any submission before the Approval phase closes.>
+ )}
+
+ {contestSubmissions.map(submission => (
+
+ ))}
+
+ )
+}
+
+export default TabContentAiApproval
diff --git a/src/apps/review/src/lib/models/AiReview.model.ts b/src/apps/review/src/lib/models/AiReview.model.ts
index bde95b870..092941e7b 100644
--- a/src/apps/review/src/lib/models/AiReview.model.ts
+++ b/src/apps/review/src/lib/models/AiReview.model.ts
@@ -51,6 +51,8 @@ export interface AiReviewDecisionBreakdownWorkflow {
runId: string | null
runStatus: string | null
runScore: number | null
+ managerScore?: number | null
+ managerComment?: string | null
}
export interface AiReviewDecisionBreakdown {
@@ -73,6 +75,7 @@ export interface AiReviewDecision {
breakdown: AiReviewDecisionBreakdown | null
isFinal: boolean
finalizedAt: string | null
+ managerComment: string | null
createdAt: string
updatedAt: string
escalations?: AiReviewDecisionEscalation[]
diff --git a/src/apps/review/src/lib/services/aiReview.service.ts b/src/apps/review/src/lib/services/aiReview.service.ts
index efb41ac51..50a997f62 100644
--- a/src/apps/review/src/lib/services/aiReview.service.ts
+++ b/src/apps/review/src/lib/services/aiReview.service.ts
@@ -1,5 +1,5 @@
import { EnvironmentConfig } from '~/config'
-import { xhrGetAsync } from '~/libs/core'
+import { xhrGetAsync, xhrPatchAsync } from '~/libs/core'
import { AiReviewConfig, AiReviewDecision } from '../models'
@@ -20,3 +20,24 @@ export const getAiReviewDecisionsCacheKey = (configId?: string): string => (
export const fetchAiReviewDecisions = async (configId: string): Promise => (
xhrGetAsync(getAiReviewDecisionsCacheKey(configId))
)
+
+export interface WorkflowManagerOverride {
+ workflowId: string
+ managerScore?: number | null
+ workflowComment?: string | null
+}
+
+export interface PatchAiReviewDecisionPayload {
+ managerComment?: string | null
+ workflowOverrides?: WorkflowManagerOverride[]
+}
+
+export const patchAiReviewDecision = async (
+ decisionId: string,
+ payload: PatchAiReviewDecisionPayload,
+): Promise => (
+ xhrPatchAsync(
+ `${v6BaseUrl}/ai-review/decisions/${decisionId}`,
+ payload,
+ )
+)
From f42a7a9f3dabc01e8eff93f443e78ca3c5ee26fd Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Tue, 2 Jun 2026 10:08:23 +0300
Subject: [PATCH 02/30] lint
---
.../ChallengeDetailsContent.tsx | 3 +-
.../TabContentAiApproval.tsx | 83 ++++++++-----------
2 files changed, 38 insertions(+), 48 deletions(-)
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
index 33773f8a9..fbeae7c54 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable complexity */
/**
* Challenge Details Content.
*/
@@ -34,8 +35,8 @@ import {
shouldIncludeInReviewPhase,
} from '../../utils/reviewPhaseGuards'
-import TabContentApproval from './TabContentApproval'
import TabContentAiApproval from './TabContentAiApproval'
+import TabContentApproval from './TabContentApproval'
import TabContentCheckpoint from './TabContentCheckpoint'
import TabContentIterativeReview from './TabContentIterativeReview'
import TabContentRegistration from './TabContentRegistration'
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
index 540f5db6f..27e3afe09 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
@@ -3,7 +3,7 @@
* Allows copilots/admins to review AI scorecards and add manager comments
* or score overrides before the challenge is finalized.
*/
-import { FC, useCallback, useContext, useMemo, useState } from 'react'
+import { ChangeEvent, FC, useCallback, useContext, useMemo, useState } from 'react'
import { toast } from 'react-toastify'
import moment from 'moment'
@@ -26,7 +26,6 @@ import styles from './TabContentAiApproval.module.scss'
interface Props {
submissions: BackendSubmission[]
isLoading: boolean
- isActiveChallenge: boolean
}
interface SubmissionApprovalRowProps {
@@ -38,52 +37,47 @@ interface SubmissionApprovalRowProps {
onSaved: (updated: AiReviewDecision) => void
}
-const SubmissionApprovalRow: FC = ({
- submission,
- decision,
- aiReviewers,
- isPrivilegedRole,
- isApprovalPhaseOpen,
- onSaved,
-}) => {
- const [managerComment, setManagerComment] = useState(decision?.managerComment ?? '')
+const SubmissionApprovalRow: FC = (props: SubmissionApprovalRowProps) => {
+ const [managerComment, setManagerComment] = useState(props.decision?.managerComment ?? '')
const [isSaving, setIsSaving] = useState(false)
- const canEdit = isPrivilegedRole && isApprovalPhaseOpen
+ const canEdit = props.isPrivilegedRole && props.isApprovalPhaseOpen
const handleSave = useCallback(async () => {
- if (!decision?.id) return
+ if (!props.decision?.id) return
setIsSaving(true)
try {
- const updated = await patchAiReviewDecision(decision.id, {
- managerComment: managerComment.trim() || null,
+ const updated = await patchAiReviewDecision(props.decision.id, {
+ managerComment: managerComment.trim() || undefined,
})
- onSaved(updated)
+ props.onSaved(updated)
toast.success('Manager comment saved.')
} catch (err) {
toast.error('Failed to save manager comment.')
} finally {
setIsSaving(false)
}
- }, [decision?.id, managerComment, onSaved])
+ }, [props.decision?.id, managerComment, props.onSaved])
- const submittedDate = submission.created
- ? moment(submission.created).format(TABLE_DATE_FORMAT)
+ const submittedDate = props.submission.createdAt
+ ? moment(props.submission.createdAt)
+ .format(TABLE_DATE_FORMAT)
: '-'
return (
- {submission.id}
+ {props.submission.id}
{submittedDate}
- {decision && (
+ {props.decision && (
- AI Score: {' '}
- {decision.totalScore != null
- ? decision.totalScore.toFixed(2)
+ AI Score:
+ {' '}
+ {props.decision.totalScore !== undefined && props.decision.totalScore !== null
+ ? props.decision.totalScore.toFixed(2)
: '-'}
- {decision.status === 'HUMAN_OVERRIDE' && (
+ {props.decision.status === 'HUMAN_OVERRIDE' && (
(Override)
)}
@@ -92,21 +86,23 @@ const SubmissionApprovalRow: FC
= ({
- {decision && canEdit && (
+ {props.decision && canEdit && (
-
+
Manager Comment
)}
- {decision?.managerComment && !canEdit && (
+ {props.decision?.managerComment && !canEdit && (
Manager Comment:
-
{decision.managerComment}
+
{props.decision.managerComment}
)}
)
}
-export const TabContentAiApproval: FC
= ({
- submissions,
- isLoading,
- isActiveChallenge,
-}) => {
+export const TabContentAiApproval: FC = props => {
const {
challengeInfo,
- aiReviewConfig,
aiReviewDecisionsBySubmissionId,
}: ChallengeDetailContextModel = useContext(ChallengeDetailContext)
const { isPrivilegedRole }: useRoleProps = useRole()
@@ -159,11 +150,9 @@ export const TabContentAiApproval: FC = ({
Record
>({})
- const getDecision = useCallback(
- (submissionId: string): AiReviewDecision | undefined =>
- localDecisionOverrides[submissionId] ?? aiReviewDecisionsBySubmissionId[submissionId],
- [aiReviewDecisionsBySubmissionId, localDecisionOverrides],
- )
+ const getDecision = useCallback((submissionId: string): AiReviewDecision | undefined => (
+ localDecisionOverrides[submissionId] ?? aiReviewDecisionsBySubmissionId[submissionId]
+ ), [aiReviewDecisionsBySubmissionId, localDecisionOverrides])
const handleDecisionSaved = useCallback((updated: AiReviewDecision) => {
setLocalDecisionOverrides(prev => ({
@@ -173,11 +162,11 @@ export const TabContentAiApproval: FC = ({
}, [])
const contestSubmissions = useMemo(
- () => submissions.filter(s => (s.type || '').toLowerCase() === 'contestsubmission'),
- [submissions],
+ () => props.submissions.filter(s => (s.type || '').toLowerCase() === 'contestsubmission'),
+ [props.submissions],
)
- if (isLoading) {
+ if (props.isLoading) {
return
}
From 5b5f4b0a49b5c054d71fabd9644c5713e56a3c2e Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Tue, 2 Jun 2026 10:18:46 +0300
Subject: [PATCH 03/30] lint
---
.../ChallengeDetailsContent/ChallengeDetailsContent.tsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
index fbeae7c54..12b7bf16c 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
@@ -504,7 +504,6 @@ export const ChallengeDetailsContent: FC = (props: Props) => {
)
}
From 8323301d640e285cdb2021acd990caa5f12dd71a Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Tue, 2 Jun 2026 15:34:18 +0300
Subject: [PATCH 04/30] PM-5203 - manager score override
---
.../TabContentAiApproval.module.scss | 79 +++++++++
.../TabContentAiApproval.tsx | 162 +++++++++++++++++-
.../CollapsibleAiReviewsRow.tsx | 20 ++-
.../SubmissionHistoryModal.tsx | 17 +-
.../ReviewsSidebar/ReviewsSidebar.tsx | 9 +-
5 files changed, 276 insertions(+), 11 deletions(-)
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss
index bf01d5697..1e6aab28a 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss
@@ -53,6 +53,75 @@
gap: 8px;
}
+.workflowOverridesSection {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 10px;
+ border: 1px solid #e5e5e5;
+ border-radius: 4px;
+ background: #fff;
+}
+
+.workflowOverridesTitle {
+ font-size: 13px;
+ font-weight: 600;
+ color: #444;
+}
+
+.workflowOverrideRow {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 8px;
+ border: 1px solid #eee;
+ border-radius: 4px;
+ background: #fcfcfc;
+}
+
+.workflowMeta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ align-items: center;
+}
+
+.workflowId {
+ font-family: monospace;
+ font-size: 12px;
+ color: #333;
+}
+
+.workflowRunScore {
+ font-size: 12px;
+ color: #666;
+}
+
+.workflowInputLabel {
+ font-size: 12px;
+ font-weight: 600;
+ color: #555;
+}
+
+.workflowScoreInput,
+.workflowCommentInput {
+ width: 100%;
+ padding: 8px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ font-size: 13px;
+
+ &:focus {
+ outline: none;
+ border-color: #0d69d4;
+ box-shadow: 0 0 0 2px rgba(13, 105, 212, 0.2);
+ }
+}
+
+.workflowCommentInput {
+ resize: vertical;
+}
+
.managerCommentLabel {
font-size: 13px;
font-weight: 600;
@@ -109,3 +178,13 @@
border-left: 3px solid #e87722;
}
}
+
+.workflowOverridesReadOnly {
+ margin-top: 12px;
+ font-size: 13px;
+ color: #333;
+
+ p {
+ margin: 4px 0 0;
+ }
+}
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
index 27e3afe09..0af8052d3 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
@@ -3,7 +3,15 @@
* Allows copilots/admins to review AI scorecards and add manager comments
* or score overrides before the challenge is finalized.
*/
-import { ChangeEvent, FC, useCallback, useContext, useMemo, useState } from 'react'
+import {
+ ChangeEvent,
+ FC,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react'
import { toast } from 'react-toastify'
import moment from 'moment'
@@ -37,26 +45,87 @@ interface SubmissionApprovalRowProps {
onSaved: (updated: AiReviewDecision) => void
}
+interface WorkflowOverrideState {
+ managerScoreInput: string
+ workflowComment: string
+}
+
+const getWorkflowOverrideState = (
+ decision: AiReviewDecision | undefined,
+): Record => {
+ const workflows = decision?.breakdown?.workflows ?? []
+
+ return workflows.reduce>((acc, workflow) => {
+ acc[workflow.workflowId] = {
+ managerScoreInput:
+ workflow.managerScore === null || workflow.managerScore === undefined
+ ? ''
+ : String(workflow.managerScore),
+ workflowComment: workflow.managerComment ?? '',
+ }
+ return acc
+ }, {})
+}
+
const SubmissionApprovalRow: FC = (props: SubmissionApprovalRowProps) => {
const [managerComment, setManagerComment] = useState(props.decision?.managerComment ?? '')
+ const [workflowOverrides, setWorkflowOverrides] = useState>(
+ () => getWorkflowOverrideState(props.decision),
+ )
const [isSaving, setIsSaving] = useState(false)
const canEdit = props.isPrivilegedRole && props.isApprovalPhaseOpen
+ const workflows = props.decision?.breakdown?.workflows ?? []
+
+ useEffect(() => {
+ setManagerComment(props.decision?.managerComment ?? '')
+ setWorkflowOverrides(getWorkflowOverrideState(props.decision))
+ }, [props.decision?.id, props.decision?.updatedAt, props.decision?.managerComment])
const handleSave = useCallback(async () => {
if (!props.decision?.id) return
+
+ const payloadOverrides = [] as {
+ workflowId: string
+ managerScore?: number | null
+ workflowComment?: string | null
+ }[]
+
+ for (const workflow of workflows) {
+ const override = workflowOverrides[workflow.workflowId]
+ const scoreInput = override?.managerScoreInput?.trim() ?? ''
+ let parsedScore: number | null
+
+ if (!scoreInput) {
+ parsedScore = null
+ } else {
+ parsedScore = Number(scoreInput)
+ if (!Number.isFinite(parsedScore)) {
+ toast.error(`Invalid manager score for workflow ${workflow.workflowId}.`)
+ return
+ }
+ }
+
+ payloadOverrides.push({
+ workflowId: workflow.workflowId,
+ managerScore: parsedScore,
+ workflowComment: override?.workflowComment?.trim() || null,
+ })
+ }
+
setIsSaving(true)
try {
const updated = await patchAiReviewDecision(props.decision.id, {
managerComment: managerComment.trim() || undefined,
+ workflowOverrides: payloadOverrides,
})
props.onSaved(updated)
- toast.success('Manager comment saved.')
+ toast.success('Manager comment and score overrides saved.')
} catch (err) {
- toast.error('Failed to save manager comment.')
+ toast.error('Failed to save manager comment and score overrides.')
} finally {
setIsSaving(false)
}
- }, [props.decision?.id, managerComment, props.onSaved])
+ }, [props.decision?.id, managerComment, props.onSaved, workflowOverrides, workflows])
const submittedDate = props.submission.createdAt
? moment(props.submission.createdAt)
@@ -92,6 +161,69 @@ const SubmissionApprovalRow: FC = (props: Submission
{props.decision && canEdit && (
+ {workflows.length > 0 && (
+
+
Workflow Score Overrides
+ {workflows.map(workflow => {
+ const override = workflowOverrides[workflow.workflowId]
+
+ return (
+
+
+ {workflow.workflowId}
+
+ Run Score:
+ {' '}
+ {workflow.runScore ?? '-'}
+
+
+
+ Manager Score
+
+
): void {
+ const value = e.target.value
+ setWorkflowOverrides(prev => ({
+ ...prev,
+ [workflow.workflowId]: {
+ managerScoreInput: value,
+ workflowComment: prev[workflow.workflowId]?.workflowComment ?? '',
+ },
+ }))
+ }}
+ placeholder='Leave empty to clear override'
+ />
+
+ Workflow Comment
+
+
+ )
+ })}
+
+ )}
+
Manager Comment
@@ -111,11 +243,27 @@ const SubmissionApprovalRow: FC
= (props: Submission
disabled={isSaving}
onClick={handleSave}
>
- {isSaving ? 'Saving…' : 'Save Comment'}
+ {isSaving ? 'Saving...' : 'Save Changes'}
)}
+ {!canEdit && workflows.some(workflow => workflow.managerScore !== undefined && workflow.managerScore !== null) && (
+
+
Manager Score Overrides:
+ {workflows
+ .filter(workflow => workflow.managerScore !== undefined && workflow.managerScore !== null)
+ .map(workflow => (
+
+ {workflow.workflowId}
+ :
+ {' '}
+ {workflow.managerScore}
+
+ ))}
+
+ )}
+
{props.decision?.managerComment && !canEdit && (
Manager Comment:
@@ -162,7 +310,7 @@ export const TabContentAiApproval: FC
= props => {
}, [])
const contestSubmissions = useMemo(
- () => props.submissions.filter(s => (s.type || '').toLowerCase() === 'contestsubmission'),
+ () => props.submissions.filter(s => (s.type || '').toUpperCase() === 'CONTEST_SUBMISSION'),
[props.submissions],
)
@@ -179,7 +327,7 @@ export const TabContentAiApproval: FC = props => {
Review the AI scorecards below.
{isPrivilegedRole && isApprovalPhaseOpen && (
- <> You may add a manager comment to any submission before the Approval phase closes.>
+ <> You may add a manager comment and workflow score overrides before the Approval phase closes.>
)}
{contestSubmissions.map(submission => (
diff --git a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx
index 5b5aa9011..d1e94fe7e 100644
--- a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx
+++ b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx
@@ -28,6 +28,8 @@ interface CollapsibleAiReviewsRowProps {
export function normalizeDecisionStatus(
status?: AiReviewDecisionStatus,
+ totalScore?: number | null,
+ minPassingThreshold?: number | null,
): 'passed' | 'failed-score' | 'pending' | 'failed' | 'human-override' {
if (!status || status === 'PENDING') {
return 'pending'
@@ -46,6 +48,13 @@ export function normalizeDecisionStatus(
}
if (status === 'HUMAN_OVERRIDE') {
+ if (
+ typeof totalScore === 'number'
+ && typeof minPassingThreshold === 'number'
+ ) {
+ return totalScore >= minPassingThreshold ? 'passed' : 'failed-score'
+ }
+
return 'human-override'
}
@@ -108,9 +117,16 @@ const CollapsibleAiReviewsRow: FC = props => {
[aiReviewDecisionsBySubmissionId, props.submission.id],
)
+ const minPassingThreshold = currentDecision?.breakdown?.minPassingThreshold
+ ?? aiReviewConfig?.minPassingThreshold
+
const normalizedStatus = useMemo(
- () => normalizeDecisionStatus(currentDecision?.status),
- [currentDecision?.status],
+ () => normalizeDecisionStatus(
+ currentDecision?.status,
+ currentDecision?.totalScore,
+ minPassingThreshold,
+ ),
+ [currentDecision?.status, currentDecision?.totalScore, minPassingThreshold],
)
const resourceMemberIdMapping = challengeDetailContext.resourceMemberIdMapping
diff --git a/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx b/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx
index 52beb361c..178212b39 100644
--- a/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx
+++ b/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx
@@ -104,6 +104,8 @@ function formatScore(value?: number | null): string {
function normalizeDecisionStatus(
status?: string | null,
+ totalScore?: number | null,
+ minPassingThreshold?: number | null,
): 'passed' | 'failed-score' | 'pending' | 'failed' | 'human-override' {
if (!status || status === 'PENDING') {
return 'pending'
@@ -122,6 +124,13 @@ function normalizeDecisionStatus(
}
if (status === 'HUMAN_OVERRIDE') {
+ if (
+ typeof totalScore === 'number'
+ && typeof minPassingThreshold === 'number'
+ ) {
+ return totalScore >= minPassingThreshold ? 'passed' : 'failed-score'
+ }
+
return 'human-override'
}
@@ -294,7 +303,13 @@ export const SubmissionHistoryModal: FC = (props: S
= challengeDetailContext.aiReviewDecisionsBySubmissionId
const currentDecision = aiReviewDecisionsBySubmissionId[submission.id]
const hasDecisionScore = currentDecision?.totalScore !== null && currentDecision?.totalScore !== undefined
- const normalizedStatus = normalizeDecisionStatus(currentDecision?.status ?? undefined)
+ const minPassingThreshold = currentDecision?.breakdown?.minPassingThreshold
+ ?? aiReviewConfig?.minPassingThreshold
+ const normalizedStatus = normalizeDecisionStatus(
+ currentDecision?.status ?? undefined,
+ currentDecision?.totalScore,
+ minPassingThreshold,
+ )
return (
diff --git a/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx b/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx
index df0f254e9..edf91c350 100644
--- a/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx
+++ b/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx
@@ -59,7 +59,14 @@ const ReviewsSidebar: FC = props => {
= currentDecision?.totalScore !== null
&& currentDecision?.totalScore !== undefined
- const overallStatus = normalizeDecisionStatus(currentDecision?.status)
+ const minPassingThreshold = currentDecision?.breakdown?.minPassingThreshold
+ ?? aiReviewConfig?.minPassingThreshold
+
+ const overallStatus = normalizeDecisionStatus(
+ currentDecision?.status,
+ currentDecision?.totalScore,
+ minPassingThreshold,
+ )
const overallScore = currentDecision?.totalScore
return (
From 6d70d38834038fc29695f8eaf50e64329ea0d2e6 Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Tue, 2 Jun 2026 15:58:45 +0300
Subject: [PATCH 05/30] lint
---
.../TabContentAiApproval.tsx | 66 +++++++++++--------
1 file changed, 40 insertions(+), 26 deletions(-)
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
index 0af8052d3..8a3a1e9c4 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable complexity */
/**
* Approval tab content for AI Only challenges.
* Allows copilots/admins to review AI scorecards and add manager comments
@@ -86,17 +87,17 @@ const SubmissionApprovalRow: FC = (props: Submission
const payloadOverrides = [] as {
workflowId: string
- managerScore?: number | null
- workflowComment?: string | null
+ managerScore?: number | undefined
+ workflowComment?: string | undefined
}[]
for (const workflow of workflows) {
const override = workflowOverrides[workflow.workflowId]
const scoreInput = override?.managerScoreInput?.trim() ?? ''
- let parsedScore: number | null
+ let parsedScore: number | undefined
if (!scoreInput) {
- parsedScore = null
+ parsedScore = undefined
} else {
parsedScore = Number(scoreInput)
if (!Number.isFinite(parsedScore)) {
@@ -106,9 +107,9 @@ const SubmissionApprovalRow: FC = (props: Submission
}
payloadOverrides.push({
- workflowId: workflow.workflowId,
managerScore: parsedScore,
- workflowComment: override?.workflowComment?.trim() || null,
+ workflowComment: override?.workflowComment?.trim() || undefined,
+ workflowId: workflow.workflowId,
})
}
@@ -177,7 +178,10 @@ const SubmissionApprovalRow: FC = (props: Submission
{workflow.runScore ?? '-'}
-
+
Manager Score
= (props: Submission
...prev,
[workflow.workflowId]: {
managerScoreInput: value,
- workflowComment: prev[workflow.workflowId]?.workflowComment ?? '',
+ workflowComment: (
+ prev[workflow.workflowId]?.workflowComment ?? ''
+ ),
},
}))
}}
placeholder='Leave empty to clear override'
/>
-
+
Workflow Comment
)}
- {!canEdit && workflows.some(workflow => workflow.managerScore !== undefined && workflow.managerScore !== null) && (
-
-
Manager Score Overrides:
- {workflows
- .filter(workflow => workflow.managerScore !== undefined && workflow.managerScore !== null)
- .map(workflow => (
-
- {workflow.workflowId}
- :
- {' '}
- {workflow.managerScore}
-
- ))}
-
- )}
+ {!canEdit
+ && workflows.some(workflow => workflow.managerScore !== undefined && workflow.managerScore !== null)
+ && (
+
+
Manager Score Overrides:
+ {workflows
+ .filter(workflow => workflow.managerScore !== undefined && workflow.managerScore !== null)
+ .map(workflow => (
+
+ {workflow.workflowId}
+ :
+ {' '}
+ {workflow.managerScore}
+
+ ))}
+
+ )}
{props.decision?.managerComment && !canEdit && (
From bc0becd0dec37992177a7d4091fecd3b0ef08c02 Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Wed, 3 Jun 2026 10:33:07 +0300
Subject: [PATCH 06/30] PM-5203 - filter latest submissions
---
.../ChallengeDetailsContent.tsx | 19 +++++++++++-
.../TabContentAiApproval.tsx | 29 +++++++++++++------
2 files changed, 38 insertions(+), 10 deletions(-)
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
index 12b7bf16c..f0e0bd3db 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
@@ -280,6 +280,21 @@ export const ChallengeDetailsContent: FC = (props: Props) => {
: undefined),
[challengeInfo?.phases, props.selectedPhaseId],
)
+ const isFuturePhase = useMemo(() => {
+ if (!props.isActiveChallenge) return false
+ if (!selectedPhase) return false
+ const isOpen = Boolean((selectedPhase as { isOpen?: boolean }).isOpen)
+ const hasStarted = Boolean(selectedPhase.actualStartDate)
+ // If phase is not open and hasn't actually started, consider it future
+ if (!isOpen && !hasStarted) return true
+ // Fallback to scheduled start in the future if available
+ const startMs = Date.parse(selectedPhase.actualStartDate || selectedPhase.scheduledStartDate || '')
+ if (Number.isFinite(startMs)) {
+ return startMs > Date.now()
+ }
+
+ return false
+ }, [actionChallengeRole, selectedPhase, props.isActiveChallenge])
const isFuturePhaseForSubmitter = useMemo(() => {
if (!props.isActiveChallenge) return false
if (actionChallengeRole !== SUBMITTER) return false
@@ -500,7 +515,9 @@ export const ChallengeDetailsContent: FC = (props: Props) => {
if (selectedTabNormalized === 'approval') {
if (aiReviewConfig?.mode === 'AI_ONLY') {
- return (
+ return isFuturePhase ? (
+
+ ) : (
void
@@ -77,6 +79,17 @@ const SubmissionApprovalRow: FC = (props: Submission
const canEdit = props.isPrivilegedRole && props.isApprovalPhaseOpen
const workflows = props.decision?.breakdown?.workflows ?? []
+ const workflowNameById = useMemo>(() => {
+ const configWorkflows = props.aiReviewConfig?.workflows ?? []
+ return configWorkflows.reduce>((acc, cw) => {
+ if (cw.workflowId && cw.workflow?.name) {
+ acc[cw.workflowId] = cw.workflow.name
+ }
+
+ return acc
+ }, {})
+ }, [props.aiReviewConfig?.workflows])
+
useEffect(() => {
setManagerComment(props.decision?.managerComment ?? '')
setWorkflowOverrides(getWorkflowOverrideState(props.decision))
@@ -154,12 +167,6 @@ const SubmissionApprovalRow: FC = (props: Submission
)}
-
-
{props.decision && canEdit && (
{workflows.length > 0 && (
@@ -171,7 +178,9 @@ const SubmissionApprovalRow: FC
= (props: Submission
return (
-
{workflow.workflowId}
+
+ {workflowNameById[workflow.workflowId] || workflow.workflowId}
+
Run Score:
{' '}
@@ -269,7 +278,7 @@ const SubmissionApprovalRow: FC = (props: Submission
.filter(workflow => workflow.managerScore !== undefined && workflow.managerScore !== null)
.map(workflow => (
- {workflow.workflowId}
+ {workflowNameById[workflow.workflowId] || workflow.workflowId}
:
{' '}
{workflow.managerScore}
@@ -291,6 +300,7 @@ const SubmissionApprovalRow: FC = (props: Submission
export const TabContentAiApproval: FC = props => {
const {
challengeInfo,
+ aiReviewConfig,
aiReviewDecisionsBySubmissionId,
}: ChallengeDetailContextModel = useContext(ChallengeDetailContext)
const { isPrivilegedRole }: useRoleProps = useRole()
@@ -324,7 +334,7 @@ export const TabContentAiApproval: FC = props => {
}, [])
const contestSubmissions = useMemo(
- () => props.submissions.filter(s => (s.type || '').toUpperCase() === 'CONTEST_SUBMISSION'),
+ () => props.submissions.filter(s => (s.type || '').toUpperCase() === 'CONTEST_SUBMISSION' && s.isLatest),
[props.submissions],
)
@@ -350,6 +360,7 @@ export const TabContentAiApproval: FC = props => {
submission={submission}
decision={getDecision(submission.id)}
aiReviewers={aiReviewers}
+ aiReviewConfig={aiReviewConfig}
isPrivilegedRole={isPrivilegedRole}
isApprovalPhaseOpen={isApprovalPhaseOpen}
onSaved={handleDecisionSaved}
From c97499991a23d03b3224405a6a27e71a95da360d Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Wed, 3 Jun 2026 11:07:19 +0300
Subject: [PATCH 07/30] lint
---
.../ChallengeDetailsContent/TabContentAiApproval.tsx | 8 --------
1 file changed, 8 deletions(-)
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
index ee858211f..0eea5e865 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
@@ -25,7 +25,6 @@ import {
ChallengeDetailContextModel,
} from '../../models'
import { ChallengeDetailContext } from '../../contexts'
-import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow'
import { TableNoRecord } from '../TableNoRecord'
import { useRole, useRoleProps } from '../../hooks'
import { TABLE_DATE_FORMAT } from '../../../config/index.config'
@@ -41,7 +40,6 @@ interface Props {
interface SubmissionApprovalRowProps {
submission: BackendSubmission
decision: AiReviewDecision | undefined
- aiReviewers: { aiWorkflowId: string }[]
aiReviewConfig?: AiReviewConfig
isPrivilegedRole: boolean
isApprovalPhaseOpen: boolean
@@ -305,11 +303,6 @@ export const TabContentAiApproval: FC = props => {
}: ChallengeDetailContextModel = useContext(ChallengeDetailContext)
const { isPrivilegedRole }: useRoleProps = useRole()
- const aiReviewers = useMemo<{ aiWorkflowId: string }[]>(
- () => (challengeInfo?.reviewers?.filter(r => !!r.aiWorkflowId) as { aiWorkflowId: string }[]) ?? [],
- [challengeInfo?.reviewers],
- )
-
const isApprovalPhaseOpen = useMemo(
() => (challengeInfo?.phases ?? []).some(
p => (p.name || '').toLowerCase() === 'approval' && Boolean(p.isOpen),
@@ -359,7 +352,6 @@ export const TabContentAiApproval: FC = props => {
key={submission.id}
submission={submission}
decision={getDecision(submission.id)}
- aiReviewers={aiReviewers}
aiReviewConfig={aiReviewConfig}
isPrivilegedRole={isPrivilegedRole}
isApprovalPhaseOpen={isApprovalPhaseOpen}
From f95d972e656b1b332b8895322f9638a06f2f780b Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Wed, 3 Jun 2026 14:17:59 +0300
Subject: [PATCH 08/30] Improve UI for approval tab content
---
.../TabContentAiApproval.module.scss | 310 ++++---
.../TabContentAiApproval.tsx | 822 ++++++++++++------
2 files changed, 764 insertions(+), 368 deletions(-)
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss
index 1e6aab28a..d73aaf31d 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss
@@ -1,190 +1,274 @@
+@import '@libs/ui/styles/includes';
+
.container {
display: flex;
flex-direction: column;
- gap: 24px;
- padding: 16px 0;
}
.approvalHint {
font-size: 14px;
- color: #555;
- margin-bottom: 8px;
+ color: var(--GrayFontColor);
+ margin-bottom: $sp-4;
+ padding: 0 $sp-2;
}
-.submissionRow {
- border: 1px solid #e0e0e0;
- border-radius: 6px;
- padding: 16px;
- background: #fafafa;
+.submissionId {
+ font-family: monospace;
+ font-weight: 600;
+ color: $link-blue-dark;
}
-.submissionHeader {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 16px;
- margin-bottom: 12px;
- font-size: 14px;
+.scoreValue {
+ font-weight: 600;
+ color: var(--black-100);
}
-.submissionId {
- font-family: monospace;
+// Status badges
+.statusBadge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: $sp-1 $sp-2;
+ border-radius: 999px;
+ font-size: 12px;
font-weight: 600;
- color: #333;
}
-.submissionDate {
- color: #777;
+.statusPending {
+ background-color: $orange-25;
+ color: $orange-140;
}
-.submissionScore {
- color: #333;
+.statusPassed {
+ background-color: $green-25;
+ color: $green-140;
}
-.overrideBadge {
- font-style: italic;
- color: #e87722;
+.statusFailed {
+ background-color: $red-25;
+ color: $red-140;
}
-.managerCommentSection {
- margin-top: 16px;
- display: flex;
- flex-direction: column;
- gap: 8px;
+.statusError {
+ background-color: $red-25;
+ color: $red-140;
}
-.workflowOverridesSection {
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: 10px;
- border: 1px solid #e5e5e5;
+.statusOverride {
+ background-color: $purple-25;
+ color: $purple-100;
+}
+
+// Actions cell
+.actionsCell {
+ display: inline-flex;
+ align-items: center;
+ gap: $sp-2;
+}
+
+.expandButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: $sp-1;
+ border: 1px solid $black-20;
border-radius: 4px;
- background: #fff;
+ background: white;
+ cursor: pointer;
+ color: $link-blue-dark;
+ transition: all 0.15s;
+
+ &:hover {
+ background: $black-10;
+ border-color: $link-blue-dark;
+ }
+
+ svg {
+ width: 16px;
+ height: 16px;
+ }
}
-.workflowOverridesTitle {
- font-size: 13px;
+.saveButton {
+ padding: $sp-1 $sp-3;
+ background: $link-blue-dark;
+ color: #fff;
+ border: none;
+ border-radius: 4px;
+ font-size: 12px;
font-weight: 600;
- color: #444;
+ cursor: pointer;
+ transition: background 0.15s;
+
+ &:hover:not(:disabled) {
+ background: $blue-110;
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
}
-.workflowOverrideRow {
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding: 8px;
- border: 1px solid #eee;
- border-radius: 4px;
- background: #fcfcfc;
+// Edit section (expanded row content)
+.editSection {
+ padding: $sp-4;
+ background: $black-10;
+ border-radius: 6px;
+ margin: $sp-2 0;
+}
+
+.editSectionTitle {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--black-100);
+ margin-bottom: $sp-4;
}
-.workflowMeta {
+.workflowsGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: $sp-4;
+ margin-bottom: $sp-4;
+}
+
+.workflowCard {
+ background: white;
+ border: 1px solid $black-20;
+ border-radius: 6px;
+ padding: $sp-3;
+}
+
+.workflowHeader {
display: flex;
- flex-wrap: wrap;
- gap: 10px;
+ justify-content: space-between;
align-items: center;
+ margin-bottom: $sp-3;
+ padding-bottom: $sp-2;
+ border-bottom: 1px solid $black-20;
}
-.workflowId {
- font-family: monospace;
- font-size: 12px;
- color: #333;
+.workflowName {
+ font-weight: 600;
+ font-size: 13px;
+ color: var(--black-100);
}
.workflowRunScore {
font-size: 12px;
- color: #666;
+ color: var(--GrayFontColor);
}
-.workflowInputLabel {
+.workflowInputs {
+ display: flex;
+ flex-direction: column;
+ gap: $sp-3;
+}
+
+.inputLabel {
+ display: flex;
+ flex-direction: column;
+ gap: $sp-1;
font-size: 12px;
font-weight: 600;
- color: #555;
+ color: var(--GrayFontColor);
}
-.workflowScoreInput,
-.workflowCommentInput {
+.scoreInput,
+.commentInput {
width: 100%;
- padding: 8px;
- border: 1px solid #ccc;
+ padding: $sp-2;
+ border: 1px solid $black-20;
border-radius: 4px;
font-size: 13px;
+ font-family: inherit;
&:focus {
outline: none;
- border-color: #0d69d4;
- box-shadow: 0 0 0 2px rgba(13, 105, 212, 0.2);
+ border-color: $link-blue-dark;
+ box-shadow: 0 0 0 2px rgba(13, 105, 212, 0.15);
+ }
+
+ &::placeholder {
+ color: $black-60;
}
}
-.workflowCommentInput {
+.commentInput {
resize: vertical;
+ min-height: 60px;
}
-.managerCommentLabel {
- font-size: 13px;
- font-weight: 600;
- color: #444;
+.managerCommentSection {
+ margin-top: $sp-2;
}
-.managerCommentInput {
- width: 100%;
- padding: 8px;
- border: 1px solid #ccc;
- border-radius: 4px;
- font-size: 14px;
- resize: vertical;
-
- &:focus {
- outline: none;
- border-color: #0d69d4;
- box-shadow: 0 0 0 2px rgba(13, 105, 212, 0.2);
+// Confirmation modal
+.confirmContent {
+ p {
+ margin-bottom: $sp-4;
+ font-size: 14px;
+ color: var(--black-100);
}
}
-.saveButton {
- align-self: flex-start;
- padding: 6px 16px;
- background: #0d69d4;
- color: #fff;
- border: none;
- border-radius: 4px;
- font-size: 14px;
- cursor: pointer;
- transition: background 0.15s;
+.confirmComment {
+ margin-top: $sp-2;
+}
- &:hover:not(:disabled) {
- background: #095bb3;
+// Expand content wrapper
+.expandContent {
+ display: flex;
+ flex-direction: column;
+ gap: $sp-4;
+}
+
+// AI Reviews expandable section
+.aiReviews {
+ :global(.trigger) {
+ width: fit-content;
+ margin-left: auto;
}
- &:disabled {
- opacity: 0.6;
- cursor: not-allowed;
+ :global(.reviews-table) {
+ margin-left: auto;
+ width: 60%;
+ margin-bottom: -9px;
+
+ @include ltelg {
+ width: auto;
+ margin-left: -1*$sp-4;
+ }
}
}
-.managerCommentReadOnly {
- margin-top: 12px;
- font-size: 14px;
- color: #333;
+// Read-only display for non-editors
+.readOnlySection {
+ margin-top: $sp-3;
+ padding: $sp-3;
+ background: $black-10;
+ border-radius: 4px;
+}
- p {
- margin-top: 4px;
- white-space: pre-wrap;
- background: #f5f5f5;
- padding: 8px;
- border-radius: 4px;
- border-left: 3px solid #e87722;
- }
+.readOnlyTitle {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--black-100);
+ margin-bottom: $sp-2;
}
-.workflowOverridesReadOnly {
- margin-top: 12px;
+.readOnlyContent {
font-size: 13px;
- color: #333;
+ color: var(--GrayFontColor);
+ white-space: pre-wrap;
+}
+
+.overridesList {
+ margin-top: $sp-2;
p {
- margin: 4px 0 0;
+ margin: $sp-1 0;
+ font-size: 13px;
+ color: var(--GrayFontColor);
}
}
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
index 0eea5e865..4de6bf87f 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
@@ -1,34 +1,43 @@
/* eslint-disable complexity */
/**
* Approval tab content for AI Only challenges.
- * Allows copilots/admins to review AI scorecards and add manager comments
- * or score overrides before the challenge is finalized.
+ * Renders submissions in a table format consistent with other tabs.
+ * Allows admins/copilots/PMs/TMs to edit decision scores and workflow scores.
*/
import {
ChangeEvent,
FC,
useCallback,
useContext,
- useEffect,
useMemo,
useState,
} from 'react'
import { toast } from 'react-toastify'
+import classNames from 'classnames'
import moment from 'moment'
import { TableLoading } from '~/apps/admin/src/lib'
+import {
+ BaseModal,
+ Button,
+ IconOutline,
+ Table,
+ TableColumn,
+} from '~/libs/ui'
+import { TABLE_DATE_FORMAT } from '../../../config/index.config'
+import { ChallengeDetailContext } from '../../contexts'
+import { useRole, useRoleProps } from '../../hooks'
import {
- AiReviewConfig,
AiReviewDecision,
+ AiReviewDecisionBreakdownWorkflow,
BackendSubmission,
ChallengeDetailContextModel,
} from '../../models'
-import { ChallengeDetailContext } from '../../contexts'
+import { patchAiReviewDecision, WorkflowManagerOverride } from '../../services/aiReview.service'
+import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow'
import { TableNoRecord } from '../TableNoRecord'
-import { useRole, useRoleProps } from '../../hooks'
-import { TABLE_DATE_FORMAT } from '../../../config/index.config'
-import { patchAiReviewDecision } from '../../services/aiReview.service'
+import { TableWrapper } from '../TableWrapper'
import styles from './TabContentAiApproval.module.scss'
@@ -37,48 +46,142 @@ interface Props {
isLoading: boolean
}
-interface SubmissionApprovalRowProps {
+interface EditableDecisionState {
+ managerComment: string
+ workflows: Record
+}
+
+interface SubmissionRowData {
submission: BackendSubmission
decision: AiReviewDecision | undefined
- aiReviewConfig?: AiReviewConfig
- isPrivilegedRole: boolean
- isApprovalPhaseOpen: boolean
- onSaved: (updated: AiReviewDecision) => void
}
-interface WorkflowOverrideState {
- managerScoreInput: string
- workflowComment: string
+function formatScore(score: number | null | undefined): string {
+ if (score === null || score === undefined) {
+ return '-'
+ }
+
+ return Number.isInteger(score) ? `${score}` : score.toFixed(2)
}
-const getWorkflowOverrideState = (
- decision: AiReviewDecision | undefined,
-): Record => {
+function getInitialEditState(decision: AiReviewDecision | undefined): EditableDecisionState {
const workflows = decision?.breakdown?.workflows ?? []
- return workflows.reduce>((acc, workflow) => {
- acc[workflow.workflowId] = {
- managerScoreInput:
- workflow.managerScore === null || workflow.managerScore === undefined
- ? ''
- : String(workflow.managerScore),
- workflowComment: workflow.managerComment ?? '',
+ return {
+ managerComment: decision?.managerComment ?? '',
+ workflows: workflows.reduce>((acc, wf) => {
+ acc[wf.workflowId] = {
+ managerComment: wf.managerComment ?? '',
+ managerScore: wf.managerScore !== undefined && wf.managerScore !== null
+ ? String(wf.managerScore)
+ : '',
+ }
+
+ return acc
+ }, {}),
+ }
+}
+
+function hasChanges(
+ original: AiReviewDecision | undefined,
+ edited: EditableDecisionState,
+): boolean {
+ if (!original) {
+ return false
+ }
+
+ const originalComment = original.managerComment ?? ''
+
+ if (edited.managerComment.trim() !== originalComment) {
+ return true
+ }
+
+ const workflows = original.breakdown?.workflows ?? []
+
+ for (const wf of workflows) {
+ const editedWf = edited.workflows[wf.workflowId]
+
+ if (!editedWf) {
+ // eslint-disable-next-line no-continue
+ continue
+ }
+
+ const originalScore = wf.managerScore !== undefined && wf.managerScore !== null
+ ? String(wf.managerScore)
+ : ''
+ const originalWfComment = wf.managerComment ?? ''
+
+ if (editedWf.managerScore.trim() !== originalScore) {
+ return true
}
- return acc
- }, {})
+
+ if (editedWf.managerComment.trim() !== originalWfComment) {
+ return true
+ }
+ }
+
+ return false
}
-const SubmissionApprovalRow: FC = (props: SubmissionApprovalRowProps) => {
- const [managerComment, setManagerComment] = useState(props.decision?.managerComment ?? '')
- const [workflowOverrides, setWorkflowOverrides] = useState>(
- () => getWorkflowOverrideState(props.decision),
+export const TabContentAiApproval: FC = (props: Props) => {
+ const {
+ aiReviewConfig,
+ aiReviewDecisionsBySubmissionId,
+ challengeInfo,
+ }: ChallengeDetailContextModel = useContext(ChallengeDetailContext)
+ const { isPrivilegedRole }: useRoleProps = useRole()
+
+ const aiReviewers = useMemo<{ aiWorkflowId: string }[]>(
+ () => (challengeInfo?.reviewers?.filter(r => !!r.aiWorkflowId) as { aiWorkflowId: string }[]) ?? [],
+ [challengeInfo?.reviewers],
+ )
+
+ const isApprovalPhaseOpen = useMemo(
+ () => (challengeInfo?.phases ?? []).some(
+ p => (p.name || '').toLowerCase() === 'approval' && Boolean(p.isOpen),
+ ),
+ [challengeInfo?.phases],
)
- const [isSaving, setIsSaving] = useState(false)
- const canEdit = props.isPrivilegedRole && props.isApprovalPhaseOpen
- const workflows = props.decision?.breakdown?.workflows ?? []
+
+ const canEdit = isPrivilegedRole && isApprovalPhaseOpen
+
+ const [localDecisionOverrides, setLocalDecisionOverrides] = useState>({})
+ const [editStates, setEditStates] = useState>({})
+ const [expandedRows, setExpandedRows] = useState>(new Set())
+ const [savingSubmissionId, setSavingSubmissionId] = useState(undefined)
+ const [confirmModal, setConfirmModal] = useState<{
+ submissionId: string
+ decision: AiReviewDecision
+ } | undefined>(undefined)
+
+ const getDecision = useCallback((submissionId: string): AiReviewDecision | undefined => (
+ localDecisionOverrides[submissionId] ?? aiReviewDecisionsBySubmissionId[submissionId]
+ ), [aiReviewDecisionsBySubmissionId, localDecisionOverrides])
+
+ const getEditState = useCallback((submissionId: string): EditableDecisionState => {
+ if (editStates[submissionId]) {
+ return editStates[submissionId]
+ }
+
+ return getInitialEditState(getDecision(submissionId))
+ }, [editStates, getDecision])
+
+ const updateEditState = useCallback((
+ submissionId: string,
+ updater: (prev: EditableDecisionState) => EditableDecisionState,
+ ): void => {
+ setEditStates(prev => ({
+ ...prev,
+ [submissionId]: updater(prev[submissionId] ?? getInitialEditState(getDecision(submissionId))),
+ }))
+ }, [getDecision])
const workflowNameById = useMemo>(() => {
- const configWorkflows = props.aiReviewConfig?.workflows ?? []
+ const configWorkflows = aiReviewConfig?.workflows ?? []
+
return configWorkflows.reduce>((acc, cw) => {
if (cw.workflowId && cw.workflow?.name) {
acc[cw.workflowId] = cw.workflow.name
@@ -86,250 +189,417 @@ const SubmissionApprovalRow: FC = (props: Submission
return acc
}, {})
- }, [props.aiReviewConfig?.workflows])
+ }, [aiReviewConfig?.workflows])
+
+ const contestSubmissions = useMemo(
+ () => props.submissions.filter(s => (s.type || '').toUpperCase() === 'CONTEST_SUBMISSION' && s.isLatest),
+ [props.submissions],
+ )
+
+ const tableData = useMemo(
+ () => contestSubmissions.map(submission => ({
+ decision: getDecision(submission.id),
+ submission,
+ })),
+ [contestSubmissions, getDecision],
+ )
+
+ const toggleRowExpansion = useCallback((submissionId: string): void => {
+ setExpandedRows(prev => {
+ const next = new Set(prev)
+
+ if (next.has(submissionId)) {
+ next.delete(submissionId)
+ } else {
+ next.add(submissionId)
+ }
+
+ return next
+ })
+ }, [])
+
+ const handleSaveClick = useCallback((submissionId: string, decision: AiReviewDecision): void => {
+ setConfirmModal({ decision, submissionId })
+ }, [])
- useEffect(() => {
- setManagerComment(props.decision?.managerComment ?? '')
- setWorkflowOverrides(getWorkflowOverrideState(props.decision))
- }, [props.decision?.id, props.decision?.updatedAt, props.decision?.managerComment])
+ const handleConfirmSave = useCallback(async (): Promise => {
+ if (!confirmModal) {
+ return
+ }
- const handleSave = useCallback(async () => {
- if (!props.decision?.id) return
+ const decision: AiReviewDecision = confirmModal.decision
+ const submissionId: string = confirmModal.submissionId
+ const editState: EditableDecisionState = getEditState(submissionId)
+ const workflows = decision.breakdown?.workflows ?? []
- const payloadOverrides = [] as {
- workflowId: string
- managerScore?: number | undefined
- workflowComment?: string | undefined
- }[]
+ const payloadOverrides: WorkflowManagerOverride[] = []
for (const workflow of workflows) {
- const override = workflowOverrides[workflow.workflowId]
- const scoreInput = override?.managerScoreInput?.trim() ?? ''
+ const override = editState.workflows[workflow.workflowId]
+ const scoreInput = override?.managerScore?.trim() ?? ''
let parsedScore: number | undefined
if (!scoreInput) {
parsedScore = undefined
} else {
parsedScore = Number(scoreInput)
+
if (!Number.isFinite(parsedScore)) {
- toast.error(`Invalid manager score for workflow ${workflow.workflowId}.`)
+ const wfName = workflowNameById[workflow.workflowId] || workflow.workflowId
+ toast.error(`Invalid manager score for workflow ${wfName}.`)
+
return
}
}
payloadOverrides.push({
managerScore: parsedScore,
- workflowComment: override?.workflowComment?.trim() || undefined,
+ workflowComment: override?.managerComment?.trim() || undefined,
workflowId: workflow.workflowId,
})
}
- setIsSaving(true)
+ setSavingSubmissionId(submissionId)
+ setConfirmModal(undefined)
+
try {
- const updated = await patchAiReviewDecision(props.decision.id, {
- managerComment: managerComment.trim() || undefined,
+ const updated = await patchAiReviewDecision(decision.id, {
+ managerComment: editState.managerComment.trim() || undefined,
workflowOverrides: payloadOverrides,
})
- props.onSaved(updated)
- toast.success('Manager comment and score overrides saved.')
- } catch (err) {
- toast.error('Failed to save manager comment and score overrides.')
+
+ setLocalDecisionOverrides(prev => ({
+ ...prev,
+ [updated.submissionId]: updated,
+ }))
+ setEditStates(prev => {
+ const next = { ...prev }
+ delete next[submissionId]
+
+ return next
+ })
+ toast.success('Changes saved successfully.')
+ } catch {
+ toast.error('Failed to save changes.')
} finally {
- setIsSaving(false)
+ setSavingSubmissionId(undefined)
}
- }, [props.decision?.id, managerComment, props.onSaved, workflowOverrides, workflows])
+ }, [confirmModal, getEditState, workflowNameById])
- const submittedDate = props.submission.createdAt
- ? moment(props.submission.createdAt)
- .format(TABLE_DATE_FORMAT)
- : '-'
+ const handleCancelSave = useCallback((): void => {
+ setConfirmModal(undefined)
+ }, [])
- return (
-
-
-
- {props.submission.id}
-
- {submittedDate}
- {props.decision && (
-
- AI Score:
- {' '}
- {props.decision.totalScore !== undefined && props.decision.totalScore !== null
- ? props.decision.totalScore.toFixed(2)
- : '-'}
- {props.decision.status === 'HUMAN_OVERRIDE' && (
- (Override)
- )}
-
- )}
-
-
- {props.decision && canEdit && (
-
- {workflows.length > 0 && (
-
-
Workflow Score Overrides
- {workflows.map(workflow => {
- const override = workflowOverrides[workflow.workflowId]
-
- return (
-
-
-
- {workflowNameById[workflow.workflowId] || workflow.workflowId}
-
-
- Run Score:
- {' '}
- {workflow.runScore ?? '-'}
-
-
-
- Manager Score
-
-
): void {
- const value = e.target.value
- setWorkflowOverrides(prev => ({
- ...prev,
- [workflow.workflowId]: {
- managerScoreInput: value,
- workflowComment: (
- prev[workflow.workflowId]?.workflowComment ?? ''
- ),
- },
- }))
- }}
- placeholder='Leave empty to clear override'
- />
-
- Workflow Comment
-
-
- )
- })}
-
- )}
+ const handleToggleClick = useCallback((submissionId: string) => (): void => {
+ toggleRowExpansion(submissionId)
+ }, [toggleRowExpansion])
-
- Manager Comment
-
-
- )}
+ const handleSaveButtonClick = useCallback(
+ (submissionId: string, decision: AiReviewDecision) => (): void => {
+ handleSaveClick(submissionId, decision)
+ },
+ [handleSaveClick],
+ )
- {!canEdit
- && workflows.some(workflow => workflow.managerScore !== undefined && workflow.managerScore !== null)
- && (
-
-
Manager Score Overrides:
- {workflows
- .filter(workflow => workflow.managerScore !== undefined && workflow.managerScore !== null)
- .map(workflow => (
-
- {workflowNameById[workflow.workflowId] || workflow.workflowId}
- :
- {' '}
- {workflow.managerScore}
-
- ))}
-
- )}
+ const handleScoreChange = useCallback(
+ (submissionId: string, workflowId: string) => (e: ChangeEvent
): void => {
+ const val = e.target.value
+ updateEditState(submissionId, prev => ({
+ ...prev,
+ workflows: {
+ ...prev.workflows,
+ [workflowId]: {
+ ...prev.workflows[workflowId],
+ managerScore: val,
+ },
+ },
+ }))
+ },
+ [updateEditState],
+ )
- {props.decision?.managerComment && !canEdit && (
-
-
Manager Comment:
-
{props.decision.managerComment}
-
- )}
-
+ const handleWorkflowCommentChange = useCallback(
+ (submissionId: string, workflowId: string) => (e: ChangeEvent): void => {
+ const val = e.target.value
+ updateEditState(submissionId, prev => ({
+ ...prev,
+ workflows: {
+ ...prev.workflows,
+ [workflowId]: {
+ ...prev.workflows[workflowId],
+ managerComment: val,
+ },
+ },
+ }))
+ },
+ [updateEditState],
)
-}
-export const TabContentAiApproval: FC = props => {
- const {
- challengeInfo,
- aiReviewConfig,
- aiReviewDecisionsBySubmissionId,
- }: ChallengeDetailContextModel = useContext(ChallengeDetailContext)
- const { isPrivilegedRole }: useRoleProps = useRole()
+ const handleManagerCommentChange = useCallback(
+ (submissionId: string) => (e: ChangeEvent): void => {
+ const val = e.target.value
+ updateEditState(submissionId, prev => ({
+ ...prev,
+ managerComment: val,
+ }))
+ },
+ [updateEditState],
+ )
- const isApprovalPhaseOpen = useMemo(
- () => (challengeInfo?.phases ?? []).some(
- p => (p.name || '').toLowerCase() === 'approval' && Boolean(p.isOpen),
- ),
- [challengeInfo?.phases],
+ const handleConfirmCommentChange = useCallback(
+ (e: ChangeEvent): void => {
+ if (!confirmModal) {
+ return
+ }
+
+ const val = e.target.value
+ updateEditState(confirmModal.submissionId, prev => ({
+ ...prev,
+ managerComment: val,
+ }))
+ },
+ [confirmModal, updateEditState],
)
- // Local copy of decisions to allow optimistic updates after PATCH
- const [localDecisionOverrides, setLocalDecisionOverrides] = useState<
- Record
- >({})
+ const columns = useMemo[]>(() => {
+ const cols: TableColumn[] = [
+ {
+ columnId: 'submission-id',
+ label: 'Submission ID',
+ renderer: (row: SubmissionRowData) => (
+
+ {row.submission.id}
+
+ ),
+ type: 'element',
+ },
+ {
+ columnId: 'submitted-date',
+ label: 'Submitted',
+ renderer: (row: SubmissionRowData) => {
+ const date = row.submission.createdAt
+ ? moment(row.submission.createdAt)
+ .format(TABLE_DATE_FORMAT)
+ : '-'
+
+ return {date}
+ },
+ type: 'element',
+ },
+ {
+ columnId: 'status',
+ label: 'Status',
+ renderer: (row: SubmissionRowData) => {
+ const status = row.decision?.status ?? 'PENDING'
+ const statusMap: Record = {
+ ERROR: { className: styles.statusError, label: 'Error' },
+ FAILED: { className: styles.statusFailed, label: 'Failed' },
+ HUMAN_OVERRIDE: { className: styles.statusOverride, label: 'Override' },
+ PASSED: { className: styles.statusPassed, label: 'Passed' },
+ PENDING: { className: styles.statusPending, label: 'Pending' },
+ }
+ const config = statusMap[status] ?? statusMap.PENDING
+
+ return (
+
+ {config.label}
+
+ )
+ },
+ type: 'element',
+ },
+ {
+ columnId: 'ai-score',
+ label: 'AI Score',
+ renderer: (row: SubmissionRowData) => (
+
+ {formatScore(row.decision?.totalScore)}
+
+ ),
+ type: 'element',
+ },
+ ]
+
+ if (canEdit) {
+ cols.push({
+ columnId: 'actions',
+ label: 'Actions',
+ renderer: (row: SubmissionRowData) => {
+ if (!row.decision) {
+ return -
+ }
+
+ const editState = getEditState(row.submission.id)
+ const hasUnsavedChanges = hasChanges(row.decision, editState)
+ const isSaving = savingSubmissionId === row.submission.id
+ const isExpanded = expandedRows.has(row.submission.id)
+
+ return (
+
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ {hasUnsavedChanges && (
+
+ {isSaving ? 'Saving...' : 'Save'}
+
+ )}
+
+ )
+ },
+ type: 'element',
+ })
+ }
- const getDecision = useCallback((submissionId: string): AiReviewDecision | undefined => (
- localDecisionOverrides[submissionId] ?? aiReviewDecisionsBySubmissionId[submissionId]
- ), [aiReviewDecisionsBySubmissionId, localDecisionOverrides])
+ cols.push({
+ columnId: 'ai-reviews-expand',
+ isExpand: true,
+ label: '',
+ renderer: (row: SubmissionRowData) => {
+ const isExpanded = expandedRows.has(row.submission.id)
+ const editState = getEditState(row.submission.id)
+ const workflows = row.decision?.breakdown?.workflows ?? []
+
+ return (
+
+
+
+ {canEdit && isExpanded && row.decision && (
+
+
+ Edit Scores & Comments
+
+
+ {workflows.length > 0 && (
+
+ {workflows.map((wf: AiReviewDecisionBreakdownWorkflow) => {
+ const wfEdit = editState.workflows[wf.workflowId]
+ ?? { managerComment: '', managerScore: '' }
+ const workflowName = workflowNameById[wf.workflowId] || wf.workflowId
+
+ return (
+
+
+ {workflowName}
+
+ Run Score:
+ {' '}
+ {formatScore(wf.runScore)}
+
+
+
+
+ Manager Score Override
+
+
+
+ Workflow Comment
+
+
+
+
+ )
+ })}
+
+ )}
- const handleDecisionSaved = useCallback((updated: AiReviewDecision) => {
- setLocalDecisionOverrides(prev => ({
- ...prev,
- [updated.submissionId]: updated,
- }))
- }, [])
+
+
+ Manager Comment
+
+
+
+
+ )}
- const contestSubmissions = useMemo
(
- () => props.submissions.filter(s => (s.type || '').toUpperCase() === 'CONTEST_SUBMISSION' && s.isLatest),
- [props.submissions],
- )
+ {!canEdit && row.decision?.managerComment && (
+
+
Manager Comment
+
{row.decision.managerComment}
+
+ )}
+
+ {!canEdit && workflows.some(wf => wf.managerScore !== undefined) && (
+
+
Manager Score Overrides
+
+ {workflows
+ .filter(wf => wf.managerScore !== undefined)
+ .map(wf => (
+
+ {workflowNameById[wf.workflowId] || wf.workflowId}
+ :
+ {' '}
+ {wf.managerScore}
+
+ ))}
+
+
+ )}
+
+ )
+ },
+ type: 'element',
+ })
+
+ return cols
+ }, [
+ aiReviewers,
+ canEdit,
+ expandedRows,
+ getEditState,
+ handleManagerCommentChange,
+ handleSaveButtonClick,
+ handleScoreChange,
+ handleToggleClick,
+ handleWorkflowCommentChange,
+ savingSubmissionId,
+ workflowNameById,
+ ])
if (props.isLoading) {
return
@@ -340,25 +610,67 @@ export const TabContentAiApproval: FC = props => {
}
return (
-
+
Review the AI scorecards below.
- {isPrivilegedRole && isApprovalPhaseOpen && (
- <> You may add a manager comment and workflow score overrides before the Approval phase closes.>
+ {canEdit && (
+ <>
+ {' '}
+ Click the edit icon to modify scores and add manager comments.
+ >
)}
- {contestSubmissions.map(submission => (
-
- ))}
-
+
+
+
+ {confirmModal && (
+
+
+ Cancel
+
+
+ Save Changes
+
+ >
+ )}
+ >
+
+
Are you sure you want to save these changes?
+
+
+ Manager Comment (for this save):
+
+
+
+
+
+ )}
+
)
}
From 799d1f9380ee22619c3452abef33f1cbab30c6b7 Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Wed, 3 Jun 2026 14:39:47 +0300
Subject: [PATCH 09/30] PM-5203 - approval UI
---
.../AiReviewsTable/AiReviewsTable.module.scss | 38 +-
.../AiReviewsTable/AiReviewsTable.tsx | 77 ++++
.../TabContentAiApproval.module.scss | 175 +++------
.../TabContentAiApproval.tsx | 362 ++++++------------
.../CollapsibleAiReviewsRow.tsx | 14 +-
5 files changed, 294 insertions(+), 372 deletions(-)
diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss
index c375f2ea7..1018c6897 100644
--- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss
+++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss
@@ -216,4 +216,40 @@
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255,255,255,0.3);
-}
\ No newline at end of file
+}
+
+// Override column
+.overrideCol {
+ text-align: center;
+ min-width: 90px;
+}
+
+.overrideInput {
+ width: 80px;
+ padding: 4px 8px;
+ border: 1px solid $black-20;
+ border-radius: 4px;
+ font-size: 13px;
+ font-family: inherit;
+ text-align: center;
+
+ &:focus {
+ outline: none;
+ border-color: $link-blue-dark;
+ box-shadow: 0 0 0 2px rgba(13, 105, 212, 0.15);
+ }
+
+ &::placeholder {
+ color: $black-40;
+ font-size: 11px;
+ }
+}
+
+.overrideValue {
+ color: $black-40;
+}
+
+.hasOverride {
+ color: $orange-100;
+ font-weight: 600;
+}
diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
index 2c249d7bd..3009080c4 100644
--- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
+++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
@@ -41,11 +41,18 @@ import styles from './AiReviewsTable.module.scss'
interface AiReviewsTableProps {
submission: Pick
aiReviewers?: { aiWorkflowId: string }[]
+ /** Enable editing mode for manager score overrides */
+ editMode?: boolean
+ /** Current edited scores by workflowId */
+ editedScores?: Record
+ /** Callback when a score is changed */
+ onScoreChange?: (workflowId: string, value: string) => void
}
interface AiReviewerRow {
id: string
isGating?: boolean
+ managerScore?: number | null
minScore?: number
reviewDate?: string
run?: Pick
@@ -228,6 +235,7 @@ const AiReviewsTable: FC = props => {
return {
id: workflowId,
isGating: fromDecision?.isGating ?? configured?.isGating,
+ managerScore: fromDecision?.managerScore,
minScore,
reviewDate: run?.completedAt,
run,
@@ -504,6 +512,42 @@ const AiReviewsTable: FC = props => {
/>
+
+ {(props.editMode || hasConfig) && (
+
+
Override
+
+ {row.workflowId && props.editMode && props.onScoreChange ? (
+ ,
+ ) {
+ if (props.onScoreChange && row.workflowId) {
+ props.onScoreChange(row.workflowId, e.target.value)
+ }
+ }}
+ placeholder='Override'
+ />
+ ) : (
+
+ {row.managerScore !== null && row.managerScore !== undefined
+ ? formatScore(row.managerScore)
+ : '-'}
+
+ )}
+
+
+ )}
))}
@@ -537,6 +581,7 @@ const AiReviewsTable: FC = props => {
Review Date
Score
Result
+ {(props.editMode || hasConfig) && Override }
@@ -611,6 +656,38 @@ const AiReviewsTable: FC = props => {
}
/>
+ {(props.editMode || hasConfig) && (
+
+ {row.workflowId && props.editMode && props.onScoreChange ? (
+ ,
+ ) {
+ if (props.onScoreChange && row.workflowId) {
+ props.onScoreChange(row.workflowId, e.target.value)
+ }
+ }}
+ placeholder='Override'
+ />
+ ) : (
+
+ {row.managerScore !== null && row.managerScore !== undefined
+ ? formatScore(row.managerScore)
+ : '-'}
+
+ )}
+
+ )}
))}
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss
index d73aaf31d..f4fc9a5f5 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss
@@ -66,32 +66,35 @@
gap: $sp-2;
}
-.expandButton {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: $sp-1;
- border: 1px solid $black-20;
+.editButton {
+ padding: $sp-1 $sp-2;
+ border: 1px solid $link-blue-dark;
border-radius: 4px;
background: white;
- cursor: pointer;
color: $link-blue-dark;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
transition: all 0.15s;
&:hover {
- background: $black-10;
- border-color: $link-blue-dark;
+ background: $blue-10;
}
+}
+
+.editButtonActive {
+ background: $black-10;
+ border-color: $black-40;
+ color: $black-80;
- svg {
- width: 16px;
- height: 16px;
+ &:hover {
+ background: $black-20;
}
}
.saveButton {
padding: $sp-1 $sp-3;
- background: $link-blue-dark;
+ background: $green-1;
color: #fff;
border: none;
border-radius: 4px;
@@ -101,7 +104,7 @@
transition: background 0.15s;
&:hover:not(:disabled) {
- background: $blue-110;
+ background: $green-140;
}
&:disabled {
@@ -110,71 +113,58 @@
}
}
-// Edit section (expanded row content)
-.editSection {
- padding: $sp-4;
- background: $black-10;
- border-radius: 6px;
- margin: $sp-2 0;
-}
-
-.editSectionTitle {
- font-size: 14px;
- font-weight: 600;
- color: var(--black-100);
- margin-bottom: $sp-4;
-}
-
-.workflowsGrid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
- gap: $sp-4;
- margin-bottom: $sp-4;
-}
-
-.workflowCard {
- background: white;
- border: 1px solid $black-20;
- border-radius: 6px;
- padding: $sp-3;
+// Expand content wrapper
+.expandContent {
+ display: flex;
+ flex-direction: column;
+ gap: $sp-3;
}
-.workflowHeader {
+// Manager comment display
+.managerCommentDisplay {
display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: $sp-3;
- padding-bottom: $sp-2;
- border-bottom: 1px solid $black-20;
+ gap: $sp-2;
+ padding: $sp-2 $sp-3;
+ background: $orange-25;
+ border-radius: 4px;
+ border-left: 3px solid $orange-100;
}
-.workflowName {
+.managerCommentLabel {
+ font-size: 12px;
font-weight: 600;
+ color: $orange-100;
+ white-space: nowrap;
+}
+
+.managerCommentText {
font-size: 13px;
color: var(--black-100);
+ white-space: pre-wrap;
}
-.workflowRunScore {
- font-size: 12px;
- color: var(--GrayFontColor);
+// Confirmation modal
+.confirmContent {
+ p {
+ margin-bottom: $sp-4;
+ font-size: 14px;
+ color: var(--black-100);
+ }
}
-.workflowInputs {
- display: flex;
- flex-direction: column;
- gap: $sp-3;
+.confirmComment {
+ margin-top: $sp-2;
}
.inputLabel {
display: flex;
flex-direction: column;
gap: $sp-1;
- font-size: 12px;
+ font-size: 13px;
font-weight: 600;
color: var(--GrayFontColor);
}
-.scoreInput,
.commentInput {
width: 100%;
padding: $sp-2;
@@ -182,6 +172,8 @@
border-radius: 4px;
font-size: 13px;
font-family: inherit;
+ resize: vertical;
+ min-height: 80px;
&:focus {
outline: none;
@@ -190,39 +182,10 @@
}
&::placeholder {
- color: $black-60;
- }
-}
-
-.commentInput {
- resize: vertical;
- min-height: 60px;
-}
-
-.managerCommentSection {
- margin-top: $sp-2;
-}
-
-// Confirmation modal
-.confirmContent {
- p {
- margin-bottom: $sp-4;
- font-size: 14px;
- color: var(--black-100);
+ color: $black-40;
}
}
-.confirmComment {
- margin-top: $sp-2;
-}
-
-// Expand content wrapper
-.expandContent {
- display: flex;
- flex-direction: column;
- gap: $sp-4;
-}
-
// AI Reviews expandable section
.aiReviews {
:global(.trigger) {
@@ -232,43 +195,7 @@
:global(.reviews-table) {
margin-left: auto;
- width: 60%;
+ width: 100%;
margin-bottom: -9px;
-
- @include ltelg {
- width: auto;
- margin-left: -1*$sp-4;
- }
- }
-}
-
-// Read-only display for non-editors
-.readOnlySection {
- margin-top: $sp-3;
- padding: $sp-3;
- background: $black-10;
- border-radius: 4px;
-}
-
-.readOnlyTitle {
- font-size: 13px;
- font-weight: 600;
- color: var(--black-100);
- margin-bottom: $sp-2;
-}
-
-.readOnlyContent {
- font-size: 13px;
- color: var(--GrayFontColor);
- white-space: pre-wrap;
-}
-
-.overridesList {
- margin-top: $sp-2;
-
- p {
- margin: $sp-1 0;
- font-size: 13px;
- color: var(--GrayFontColor);
}
}
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
index 4de6bf87f..32dd5cd0b 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
@@ -2,7 +2,7 @@
/**
* Approval tab content for AI Only challenges.
* Renders submissions in a table format consistent with other tabs.
- * Allows admins/copilots/PMs/TMs to edit decision scores and workflow scores.
+ * Allows admins/copilots/PMs/TMs to edit decision scores via the AiReviewsTable.
*/
import {
ChangeEvent,
@@ -20,7 +20,6 @@ import { TableLoading } from '~/apps/admin/src/lib'
import {
BaseModal,
Button,
- IconOutline,
Table,
TableColumn,
} from '~/libs/ui'
@@ -30,7 +29,6 @@ import { ChallengeDetailContext } from '../../contexts'
import { useRole, useRoleProps } from '../../hooks'
import {
AiReviewDecision,
- AiReviewDecisionBreakdownWorkflow,
BackendSubmission,
ChallengeDetailContextModel,
} from '../../models'
@@ -46,12 +44,8 @@ interface Props {
isLoading: boolean
}
-interface EditableDecisionState {
- managerComment: string
- workflows: Record
+interface EditableScores {
+ workflows: Record
}
interface SubmissionRowData {
@@ -67,58 +61,37 @@ function formatScore(score: number | null | undefined): string {
return Number.isInteger(score) ? `${score}` : score.toFixed(2)
}
-function getInitialEditState(decision: AiReviewDecision | undefined): EditableDecisionState {
+function getInitialScores(decision: AiReviewDecision | undefined): EditableScores {
const workflows = decision?.breakdown?.workflows ?? []
return {
- managerComment: decision?.managerComment ?? '',
- workflows: workflows.reduce>((acc, wf) => {
- acc[wf.workflowId] = {
- managerComment: wf.managerComment ?? '',
- managerScore: wf.managerScore !== undefined && wf.managerScore !== null
- ? String(wf.managerScore)
- : '',
- }
+ workflows: workflows.reduce>((acc, wf) => {
+ acc[wf.workflowId] = wf.managerScore !== undefined && wf.managerScore !== null
+ ? String(wf.managerScore)
+ : ''
return acc
}, {}),
}
}
-function hasChanges(
+function hasScoreChanges(
original: AiReviewDecision | undefined,
- edited: EditableDecisionState,
+ edited: EditableScores,
): boolean {
if (!original) {
return false
}
- const originalComment = original.managerComment ?? ''
-
- if (edited.managerComment.trim() !== originalComment) {
- return true
- }
-
const workflows = original.breakdown?.workflows ?? []
for (const wf of workflows) {
- const editedWf = edited.workflows[wf.workflowId]
-
- if (!editedWf) {
- // eslint-disable-next-line no-continue
- continue
- }
-
+ const editedScore = edited.workflows[wf.workflowId] ?? ''
const originalScore = wf.managerScore !== undefined && wf.managerScore !== null
? String(wf.managerScore)
: ''
- const originalWfComment = wf.managerComment ?? ''
-
- if (editedWf.managerScore.trim() !== originalScore) {
- return true
- }
- if (editedWf.managerComment.trim() !== originalWfComment) {
+ if (editedScore.trim() !== originalScore) {
return true
}
}
@@ -149,36 +122,15 @@ export const TabContentAiApproval: FC = (props: Props) => {
const canEdit = isPrivilegedRole && isApprovalPhaseOpen
const [localDecisionOverrides, setLocalDecisionOverrides] = useState>({})
- const [editStates, setEditStates] = useState>({})
- const [expandedRows, setExpandedRows] = useState>(new Set())
+ const [editScores, setEditScores] = useState>({})
+ const [editingRows, setEditingRows] = useState>(new Set())
const [savingSubmissionId, setSavingSubmissionId] = useState(undefined)
const [confirmModal, setConfirmModal] = useState<{
submissionId: string
decision: AiReviewDecision
+ managerComment: string
} | undefined>(undefined)
- const getDecision = useCallback((submissionId: string): AiReviewDecision | undefined => (
- localDecisionOverrides[submissionId] ?? aiReviewDecisionsBySubmissionId[submissionId]
- ), [aiReviewDecisionsBySubmissionId, localDecisionOverrides])
-
- const getEditState = useCallback((submissionId: string): EditableDecisionState => {
- if (editStates[submissionId]) {
- return editStates[submissionId]
- }
-
- return getInitialEditState(getDecision(submissionId))
- }, [editStates, getDecision])
-
- const updateEditState = useCallback((
- submissionId: string,
- updater: (prev: EditableDecisionState) => EditableDecisionState,
- ): void => {
- setEditStates(prev => ({
- ...prev,
- [submissionId]: updater(prev[submissionId] ?? getInitialEditState(getDecision(submissionId))),
- }))
- }, [getDecision])
-
const workflowNameById = useMemo>(() => {
const configWorkflows = aiReviewConfig?.workflows ?? []
@@ -191,6 +143,39 @@ export const TabContentAiApproval: FC = (props: Props) => {
}, {})
}, [aiReviewConfig?.workflows])
+ const getDecision = useCallback((submissionId: string): AiReviewDecision | undefined => (
+ localDecisionOverrides[submissionId] ?? aiReviewDecisionsBySubmissionId[submissionId]
+ ), [aiReviewDecisionsBySubmissionId, localDecisionOverrides])
+
+ const getScores = useCallback((submissionId: string): EditableScores => {
+ if (editScores[submissionId]) {
+ return editScores[submissionId]
+ }
+
+ return getInitialScores(getDecision(submissionId))
+ }, [editScores, getDecision])
+
+ const updateScores = useCallback((
+ submissionId: string,
+ workflowId: string,
+ value: string,
+ ): void => {
+ setEditScores(prev => {
+ const current = prev[submissionId] ?? getInitialScores(getDecision(submissionId))
+
+ return {
+ ...prev,
+ [submissionId]: {
+ ...current,
+ workflows: {
+ ...current.workflows,
+ [workflowId]: value,
+ },
+ },
+ }
+ })
+ }, [getDecision])
+
const contestSubmissions = useMemo(
() => props.submissions.filter(s => (s.type || '').toUpperCase() === 'CONTEST_SUBMISSION' && s.isLatest),
[props.submissions],
@@ -204,12 +189,19 @@ export const TabContentAiApproval: FC = (props: Props) => {
[contestSubmissions, getDecision],
)
- const toggleRowExpansion = useCallback((submissionId: string): void => {
- setExpandedRows(prev => {
+ const toggleEditMode = useCallback((submissionId: string): void => {
+ setEditingRows(prev => {
const next = new Set(prev)
if (next.has(submissionId)) {
next.delete(submissionId)
+ // Clear edit state when exiting edit mode
+ setEditScores(prevScores => {
+ const nextScores = { ...prevScores }
+ delete nextScores[submissionId]
+
+ return nextScores
+ })
} else {
next.add(submissionId)
}
@@ -219,7 +211,7 @@ export const TabContentAiApproval: FC = (props: Props) => {
}, [])
const handleSaveClick = useCallback((submissionId: string, decision: AiReviewDecision): void => {
- setConfirmModal({ decision, submissionId })
+ setConfirmModal({ decision, managerComment: '', submissionId })
}, [])
const handleConfirmSave = useCallback(async (): Promise => {
@@ -229,14 +221,14 @@ export const TabContentAiApproval: FC = (props: Props) => {
const decision: AiReviewDecision = confirmModal.decision
const submissionId: string = confirmModal.submissionId
- const editState: EditableDecisionState = getEditState(submissionId)
+ const managerComment: string = confirmModal.managerComment
+ const scores: EditableScores = getScores(submissionId)
const workflows = decision.breakdown?.workflows ?? []
const payloadOverrides: WorkflowManagerOverride[] = []
for (const workflow of workflows) {
- const override = editState.workflows[workflow.workflowId]
- const scoreInput = override?.managerScore?.trim() ?? ''
+ const scoreInput = scores.workflows[workflow.workflowId]?.trim() ?? ''
let parsedScore: number | undefined
if (!scoreInput) {
@@ -254,7 +246,6 @@ export const TabContentAiApproval: FC = (props: Props) => {
payloadOverrides.push({
managerScore: parsedScore,
- workflowComment: override?.managerComment?.trim() || undefined,
workflowId: workflow.workflowId,
})
}
@@ -264,7 +255,7 @@ export const TabContentAiApproval: FC = (props: Props) => {
try {
const updated = await patchAiReviewDecision(decision.id, {
- managerComment: editState.managerComment.trim() || undefined,
+ managerComment: managerComment.trim() || undefined,
workflowOverrides: payloadOverrides,
})
@@ -272,27 +263,33 @@ export const TabContentAiApproval: FC = (props: Props) => {
...prev,
[updated.submissionId]: updated,
}))
- setEditStates(prev => {
+ setEditScores(prev => {
const next = { ...prev }
delete next[submissionId]
return next
})
+ setEditingRows(prev => {
+ const next = new Set(prev)
+ next.delete(submissionId)
+
+ return next
+ })
toast.success('Changes saved successfully.')
} catch {
toast.error('Failed to save changes.')
} finally {
setSavingSubmissionId(undefined)
}
- }, [confirmModal, getEditState, workflowNameById])
+ }, [confirmModal, getScores, workflowNameById])
const handleCancelSave = useCallback((): void => {
setConfirmModal(undefined)
}, [])
const handleToggleClick = useCallback((submissionId: string) => (): void => {
- toggleRowExpansion(submissionId)
- }, [toggleRowExpansion])
+ toggleEditMode(submissionId)
+ }, [toggleEditMode])
const handleSaveButtonClick = useCallback(
(submissionId: string, decision: AiReviewDecision) => (): void => {
@@ -302,63 +299,27 @@ export const TabContentAiApproval: FC = (props: Props) => {
)
const handleScoreChange = useCallback(
- (submissionId: string, workflowId: string) => (e: ChangeEvent): void => {
- const val = e.target.value
- updateEditState(submissionId, prev => ({
- ...prev,
- workflows: {
- ...prev.workflows,
- [workflowId]: {
- ...prev.workflows[workflowId],
- managerScore: val,
- },
- },
- }))
+ (submissionId: string) => (workflowId: string, value: string): void => {
+ updateScores(submissionId, workflowId, value)
},
- [updateEditState],
+ [updateScores],
)
- const handleWorkflowCommentChange = useCallback(
- (submissionId: string, workflowId: string) => (e: ChangeEvent): void => {
- const val = e.target.value
- updateEditState(submissionId, prev => ({
- ...prev,
- workflows: {
- ...prev.workflows,
- [workflowId]: {
- ...prev.workflows[workflowId],
- managerComment: val,
- },
- },
- }))
- },
- [updateEditState],
- )
-
- const handleManagerCommentChange = useCallback(
- (submissionId: string) => (e: ChangeEvent): void => {
- const val = e.target.value
- updateEditState(submissionId, prev => ({
- ...prev,
- managerComment: val,
- }))
- },
- [updateEditState],
- )
-
- const handleConfirmCommentChange = useCallback(
+ const handleCommentChange = useCallback(
(e: ChangeEvent): void => {
if (!confirmModal) {
return
}
- const val = e.target.value
- updateEditState(confirmModal.submissionId, prev => ({
- ...prev,
- managerComment: val,
- }))
+ setConfirmModal(prev => {
+ if (!prev) {
+ return undefined
+ }
+
+ return { ...prev, managerComment: e.target.value }
+ })
},
- [confirmModal, updateEditState],
+ [confirmModal],
)
const columns = useMemo[]>(() => {
@@ -429,24 +390,23 @@ export const TabContentAiApproval: FC = (props: Props) => {
return -
}
- const editState = getEditState(row.submission.id)
- const hasUnsavedChanges = hasChanges(row.decision, editState)
+ const scores = getScores(row.submission.id)
+ const hasUnsavedChanges = hasScoreChanges(row.decision, scores)
const isSaving = savingSubmissionId === row.submission.id
- const isExpanded = expandedRows.has(row.submission.id)
+ const isEditing = editingRows.has(row.submission.id)
return (
- {isExpanded ? (
-
- ) : (
-
- )}
+ {isEditing ? 'Cancel' : 'Edit'}
{hasUnsavedChanges && (
= (props: Props) => {
isExpand: true,
label: '',
renderer: (row: SubmissionRowData) => {
- const isExpanded = expandedRows.has(row.submission.id)
- const editState = getEditState(row.submission.id)
- const workflows = row.decision?.breakdown?.workflows ?? []
+ const isEditing = editingRows.has(row.submission.id)
+ const scores = getScores(row.submission.id)
return (
+ {/* Manager comment display */}
+ {row.decision?.managerComment && (
+
+ Manager Comment:
+ {row.decision.managerComment}
+
+ )}
+
+ {/* AI Reviews table with editing support */}
-
- {canEdit && isExpanded && row.decision && (
-
-
- Edit Scores & Comments
-
-
- {workflows.length > 0 && (
-
- {workflows.map((wf: AiReviewDecisionBreakdownWorkflow) => {
- const wfEdit = editState.workflows[wf.workflowId]
- ?? { managerComment: '', managerScore: '' }
- const workflowName = workflowNameById[wf.workflowId] || wf.workflowId
-
- return (
-
-
- {workflowName}
-
- Run Score:
- {' '}
- {formatScore(wf.runScore)}
-
-
-
-
- Manager Score Override
-
-
-
- Workflow Comment
-
-
-
-
- )
- })}
-
- )}
-
-
-
- Manager Comment
-
-
-
-
- )}
-
- {!canEdit && row.decision?.managerComment && (
-
-
Manager Comment
-
{row.decision.managerComment}
-
- )}
-
- {!canEdit && workflows.some(wf => wf.managerScore !== undefined) && (
-
-
Manager Score Overrides
-
- {workflows
- .filter(wf => wf.managerScore !== undefined)
- .map(wf => (
-
- {workflowNameById[wf.workflowId] || wf.workflowId}
- :
- {' '}
- {wf.managerScore}
-
- ))}
-
-
- )}
)
},
@@ -590,15 +463,12 @@ export const TabContentAiApproval: FC = (props: Props) => {
}, [
aiReviewers,
canEdit,
- expandedRows,
- getEditState,
- handleManagerCommentChange,
+ editingRows,
+ getScores,
handleSaveButtonClick,
handleScoreChange,
handleToggleClick,
- handleWorkflowCommentChange,
savingSubmissionId,
- workflowNameById,
])
if (props.isLoading) {
@@ -616,7 +486,7 @@ export const TabContentAiApproval: FC = (props: Props) => {
{canEdit && (
<>
{' '}
- Click the edit icon to modify scores and add manager comments.
+ Click Edit to modify workflow scores.
>
)}
@@ -654,16 +524,16 @@ export const TabContentAiApproval: FC = (props: Props) => {
)}
>
-
Are you sure you want to save these changes?
+
Are you sure you want to save these score changes?
- Manager Comment (for this save):
+ Manager Comment:
diff --git a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx
index d1e94fe7e..806d6cc52 100644
--- a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx
+++ b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx
@@ -24,6 +24,12 @@ interface CollapsibleAiReviewsRowProps {
defaultOpen?: boolean
aiReviewers: { aiWorkflowId: string }[]
submission: Pick
+ /** Enable editing mode for manager score overrides */
+ editMode?: boolean
+ /** Current edited scores by workflowId */
+ editedScores?: Record
+ /** Callback when a score is changed */
+ onScoreChange?: (workflowId: string, value: string) => void
}
export function normalizeDecisionStatus(
@@ -246,7 +252,13 @@ const CollapsibleAiReviewsRow: FC = props => {
{isOpen && portalContainer && createPortal(
,
portalContainer,
)}
From 65ffbc17e484a57a8277bd695cb79ab093ed464d Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Wed, 3 Jun 2026 14:56:36 +0300
Subject: [PATCH 10/30] lint
---
.../review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
index 3009080c4..d748a72ee 100644
--- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
+++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable complexity */
/* eslint-disable max-len */
import { FC, MouseEvent as ReactMouseEvent, useCallback, useContext, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
From 84f686e4cb9cf20964a45769f5e55070ece25490 Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Wed, 3 Jun 2026 16:34:50 +0300
Subject: [PATCH 11/30] PM-5203 - More UI improvements for AI approval
---
.../AiReviewsTable/AiReviewsTable.module.scss | 37 ++++--
.../AiReviewsTable/AiReviewsTable.tsx | 123 ++++++++----------
2 files changed, 76 insertions(+), 84 deletions(-)
diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss
index 1018c6897..c3294b1b3 100644
--- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss
+++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss
@@ -218,16 +218,22 @@
border-top: 1px solid rgba(255,255,255,0.3);
}
-// Override column
-.overrideCol {
- text-align: center;
- min-width: 90px;
+// Score with inline override
+.scoreWithOverride {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.originalScore {
+ color: $black-40;
+ font-size: 12px;
}
.overrideInput {
- width: 80px;
- padding: 4px 8px;
- border: 1px solid $black-20;
+ width: 70px;
+ padding: 4px 6px;
+ border: 1px solid $link-blue-dark;
border-radius: 4px;
font-size: 13px;
font-family: inherit;
@@ -241,15 +247,20 @@
&::placeholder {
color: $black-40;
- font-size: 11px;
+ font-size: 10px;
}
}
-.overrideValue {
- color: $black-40;
+.overriddenScore {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: $orange-120;
+ font-weight: 600;
}
-.hasOverride {
- color: $orange-100;
- font-weight: 600;
+.overrideLabel {
+ font-size: 11px;
+ font-weight: 400;
+ color: $black-40;
}
diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
index d748a72ee..53f1055ce 100644
--- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
+++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
@@ -478,7 +478,32 @@ const AiReviewsTable: FC = props => {
Score
- {typeof row.score === 'number' ? (
+ {row.workflowId && props.editMode && props.onScoreChange ? (
+
+
+ {typeof row.score === 'number' ? formatScore(row.score) : '-'}
+
+ ,
+ ) {
+ if (props.onScoreChange && row.workflowId) {
+ props.onScoreChange(row.workflowId, e.target.value)
+ }
+ }}
+ placeholder='Override'
+ />
+
+ ) : row.managerScore !== null && row.managerScore !== undefined ? (
+
+ {formatScore(row.managerScore)}
+ (override)
+
+ ) : typeof row.score === 'number' ? (
row.workflowId ? (
= props => {
/>
-
- {(props.editMode || hasConfig) && (
-
-
Override
-
- {row.workflowId && props.editMode && props.onScoreChange ? (
- ,
- ) {
- if (props.onScoreChange && row.workflowId) {
- props.onScoreChange(row.workflowId, e.target.value)
- }
- }}
- placeholder='Override'
- />
- ) : (
-
- {row.managerScore !== null && row.managerScore !== undefined
- ? formatScore(row.managerScore)
- : '-'}
-
- )}
-
-
- )}
))}
@@ -582,7 +571,6 @@ const AiReviewsTable: FC = props => {
Review Date
Score
Result
- {(props.editMode || hasConfig) && Override }
@@ -627,7 +615,32 @@ const AiReviewsTable: FC = props => {
)}
- {typeof row.score === 'number' ? (
+ {row.workflowId && props.editMode && props.onScoreChange ? (
+
+
+ {typeof row.score === 'number' ? formatScore(row.score) : '-'}
+
+ ,
+ ) {
+ if (props.onScoreChange && row.workflowId) {
+ props.onScoreChange(row.workflowId, e.target.value)
+ }
+ }}
+ placeholder='Override'
+ />
+
+ ) : row.managerScore !== null && row.managerScore !== undefined ? (
+
+ {formatScore(row.managerScore)}
+ (override)
+
+ ) : typeof row.score === 'number' ? (
row.workflowId ? (
= props => {
}
/>
- {(props.editMode || hasConfig) && (
-
- {row.workflowId && props.editMode && props.onScoreChange ? (
- ,
- ) {
- if (props.onScoreChange && row.workflowId) {
- props.onScoreChange(row.workflowId, e.target.value)
- }
- }}
- placeholder='Override'
- />
- ) : (
-
- {row.managerScore !== null && row.managerScore !== undefined
- ? formatScore(row.managerScore)
- : '-'}
-
- )}
-
- )}
))}
From 9196cb3c371cf3254e72b533c56ecd799d8e7311 Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Thu, 4 Jun 2026 07:19:10 +0300
Subject: [PATCH 12/30] Final score for ai only challenges in work manager
---
src/apps/work/src/lib/utils/challenge.utils.ts | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/src/apps/work/src/lib/utils/challenge.utils.ts b/src/apps/work/src/lib/utils/challenge.utils.ts
index 91a82b63e..69dad2905 100644
--- a/src/apps/work/src/lib/utils/challenge.utils.ts
+++ b/src/apps/work/src/lib/utils/challenge.utils.ts
@@ -20,6 +20,8 @@ interface ScoredSubmissionLike {
finalScore?: number
score?: number
}>
+ initialScore?: string
+ finalScore?: string
reviewSummation?: ReviewSummation[]
submissions?: SubmissionScore[]
}
@@ -515,6 +517,10 @@ export function getSubmissionInitialScore(submission: ScoredSubmissionLike): num
.map(review => toValidScore(review.score ?? review.finalScore)),
)
+ if (submission.initialScore !== null && submission.initialScore !== undefined) {
+ return toValidScore(submission.initialScore) ?? 0
+ }
+
return fallbackInitialScore || 0
}
@@ -571,6 +577,10 @@ export function getSubmissionFinalScore(submission: ScoredSubmissionLike): numbe
return fallbackSummationScore
}
+ if (submission.finalScore !== null && submission.finalScore !== undefined) {
+ return toValidScore(submission.finalScore) ?? 0
+ }
+
return 0
}
From c0647abbd505d5950a7900deb9b807c967061ed9 Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Thu, 4 Jun 2026 09:01:36 +0300
Subject: [PATCH 13/30] Final score in work manager submissions
---
src/apps/work/src/lib/models/Submission.model.ts | 2 ++
src/apps/work/src/lib/services/submissions.service.ts | 2 ++
2 files changed, 4 insertions(+)
diff --git a/src/apps/work/src/lib/models/Submission.model.ts b/src/apps/work/src/lib/models/Submission.model.ts
index 595b399ef..c536320dc 100644
--- a/src/apps/work/src/lib/models/Submission.model.ts
+++ b/src/apps/work/src/lib/models/Submission.model.ts
@@ -70,4 +70,6 @@ export interface Submission {
status?: SubmissionStatus
submissionTime?: string
type?: string
+ initialScore?: string
+ finalScore?: string
}
diff --git a/src/apps/work/src/lib/services/submissions.service.ts b/src/apps/work/src/lib/services/submissions.service.ts
index 61228bb2d..bb0b1bc80 100644
--- a/src/apps/work/src/lib/services/submissions.service.ts
+++ b/src/apps/work/src/lib/services/submissions.service.ts
@@ -371,7 +371,9 @@ function normalizeSubmission(submission: Partial): Submission | unde
createdBy,
email: resolveEmail(submission, registrant),
fileType: toOptionalString(submission.fileType),
+ finalScore: submission.finalScore,
id,
+ initialScore: submission.initialScore,
legacySubmissionId: toOptionalString(submission.legacySubmissionId),
memberHandle: resolveMemberHandle(submission, registrant),
memberId: resolveMemberId(submission, registrant),
From 8d9bfe0e50a86640ce23b26066a57d51d41cdf7d Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Thu, 4 Jun 2026 16:56:06 +0300
Subject: [PATCH 14/30] PM-5203 - Update UI for approval tab
---
.../AiReviewsTable/AiReviewsTable.tsx | 48 ++++++++++--------
.../ChallengeDetailsContent.tsx | 4 +-
.../TabContentAiApproval.module.scss | 1 +
.../TabContentAiApproval.tsx | 50 +++++++++++++++++--
4 files changed, 76 insertions(+), 27 deletions(-)
diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
index 53f1055ce..8516928c2 100644
--- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
+++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
@@ -230,7 +230,7 @@ const AiReviewsTable: FC = props => {
const status = fromDecision
? normalizeStatus(run && aiRunInProgress(run)
? undefined
- : fromDecision.runStatus, fromDecision.runScore, minScore)
+ : fromDecision.runStatus, fromDecision.managerScore ?? fromDecision.runScore, minScore)
: undefined
return {
@@ -240,7 +240,7 @@ const AiReviewsTable: FC = props => {
minScore,
reviewDate: run?.completedAt,
run,
- score: fromDecision?.runScore ?? run?.score,
+ score: fromDecision?.managerScore ?? fromDecision?.runScore ?? run?.score,
status,
title: getConfiguredWorkflowName(configured?.workflow) ?? run?.workflow?.name ?? 'AI Review',
weight: fromDecision?.weightPercent ?? configured?.weightPercent,
@@ -498,18 +498,20 @@ const AiReviewsTable: FC = props => {
placeholder='Override'
/>
- ) : row.managerScore !== null && row.managerScore !== undefined ? (
-
- {formatScore(row.managerScore)}
- (override)
-
) : typeof row.score === 'number' ? (
row.workflowId ? (
-
- {formatScore(row.score)}
-
+ <>
+
+ {formatScore(row.score)}
+
+ {row.managerScore !== null && row.managerScore !== undefined && (
+
+ (override)
+
+ )}
+ >
) : formatScore(row.score)
) : '-'}
@@ -635,18 +637,20 @@ const AiReviewsTable: FC = props => {
placeholder='Override'
/>
- ) : row.managerScore !== null && row.managerScore !== undefined ? (
-
- {formatScore(row.managerScore)}
- (override)
-
) : typeof row.score === 'number' ? (
row.workflowId ? (
-
- {formatScore(row.score)}
-
+ <>
+
+ {formatScore(row.score)}
+
+ {row.managerScore !== null && row.managerScore !== undefined && (
+
+ (override)
+
+ )}
+ >
) : formatScore(row.score)
) : '-'}
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
index f0e0bd3db..e61302fca 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
@@ -520,8 +520,8 @@ export const ChallengeDetailsContent: FC = (props: Props) => {
) : (
+ isLoading={props.isLoadingSubmission} isDownloading={isDownloadingSubmission}
+ downloadSubmission={handleSubmissionDownload} />
)
}
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss
index f4fc9a5f5..c58899ccc 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss
@@ -57,6 +57,7 @@
.statusOverride {
background-color: $purple-25;
color: $purple-100;
+ cursor: default;
}
// Actions cell
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
index 32dd5cd0b..98acb04c7 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
@@ -17,6 +17,7 @@ import classNames from 'classnames'
import moment from 'moment'
import { TableLoading } from '~/apps/admin/src/lib'
+import { IsRemovingType } from '~/apps/admin/src/lib/models'
import {
BaseModal,
Button,
@@ -27,6 +28,9 @@ import {
import { TABLE_DATE_FORMAT } from '../../../config/index.config'
import { ChallengeDetailContext } from '../../contexts'
import { useRole, useRoleProps } from '../../hooks'
+import { useSubmissionDownloadAccess } from '../../hooks/useSubmissionDownloadAccess'
+import { useRolePermissions } from '../../hooks/useRolePermissions'
+import type { UseRolePermissionsResult } from '../../hooks/useRolePermissions'
import {
AiReviewDecision,
BackendSubmission,
@@ -36,12 +40,16 @@ import { patchAiReviewDecision, WorkflowManagerOverride } from '../../services/a
import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow'
import { TableNoRecord } from '../TableNoRecord'
import { TableWrapper } from '../TableWrapper'
+import { renderSubmissionIdCell } from '../common'
+import type { DownloadButtonConfig, SubmissionRow } from '../common/types'
import styles from './TabContentAiApproval.module.scss'
interface Props {
submissions: BackendSubmission[]
isLoading: boolean
+ isDownloading: IsRemovingType
+ downloadSubmission: (submissionId: string) => void
}
interface EditableScores {
@@ -121,6 +129,41 @@ export const TabContentAiApproval: FC = (props: Props) => {
const canEdit = isPrivilegedRole && isApprovalPhaseOpen
+ const {
+ getRestrictionMessageForMember,
+ isSubmissionDownloadRestricted,
+ isSubmissionDownloadRestrictedForMember,
+ restrictionMessage,
+ shouldRestrictSubmitterToOwnSubmission,
+ } = useSubmissionDownloadAccess()
+
+ const {
+ ownedMemberIds,
+ }: UseRolePermissionsResult = useRolePermissions()
+
+ const downloadButtonConfig = useMemo(
+ () => ({
+ downloadSubmission: props.downloadSubmission,
+ getRestrictionMessageForMember,
+ isDownloading: props.isDownloading,
+ isSubmissionDownloadRestricted,
+ isSubmissionDownloadRestrictedForMember,
+ ownedMemberIds,
+ restrictionMessage,
+ shouldRestrictSubmitterToOwnSubmission,
+ }),
+ [
+ props.downloadSubmission,
+ props.isDownloading,
+ getRestrictionMessageForMember,
+ isSubmissionDownloadRestricted,
+ isSubmissionDownloadRestrictedForMember,
+ ownedMemberIds,
+ restrictionMessage,
+ shouldRestrictSubmitterToOwnSubmission,
+ ],
+ )
+
const [localDecisionOverrides, setLocalDecisionOverrides] = useState>({})
const [editScores, setEditScores] = useState>({})
const [editingRows, setEditingRows] = useState>(new Set())
@@ -328,9 +371,10 @@ export const TabContentAiApproval: FC = (props: Props) => {
columnId: 'submission-id',
label: 'Submission ID',
renderer: (row: SubmissionRowData) => (
-
- {row.submission.id}
-
+ renderSubmissionIdCell(
+ row.submission as unknown as SubmissionRow,
+ downloadButtonConfig,
+ )
),
type: 'element',
},
From 45753a236a973c85e622e0b2641cefdea60b12f4 Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Thu, 4 Jun 2026 17:09:36 +0300
Subject: [PATCH 15/30] lint
---
.../ChallengeDetailsContent/ChallengeDetailsContent.tsx | 6 ++++--
.../ChallengeDetailsContent/TabContentAiApproval.tsx | 7 +++++--
2 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
index e61302fca..02da68b9c 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx
@@ -520,8 +520,10 @@ export const ChallengeDetailsContent: FC = (props: Props) => {
) : (
+ isLoading={props.isLoadingSubmission}
+ isDownloading={isDownloadingSubmission}
+ downloadSubmission={handleSubmissionDownload}
+ />
)
}
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
index 98acb04c7..4e3a67918 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
@@ -28,7 +28,10 @@ import {
import { TABLE_DATE_FORMAT } from '../../../config/index.config'
import { ChallengeDetailContext } from '../../contexts'
import { useRole, useRoleProps } from '../../hooks'
-import { useSubmissionDownloadAccess } from '../../hooks/useSubmissionDownloadAccess'
+import {
+ useSubmissionDownloadAccess,
+ UseSubmissionDownloadAccessResult,
+} from '../../hooks/useSubmissionDownloadAccess'
import { useRolePermissions } from '../../hooks/useRolePermissions'
import type { UseRolePermissionsResult } from '../../hooks/useRolePermissions'
import {
@@ -135,7 +138,7 @@ export const TabContentAiApproval: FC = (props: Props) => {
isSubmissionDownloadRestrictedForMember,
restrictionMessage,
shouldRestrictSubmitterToOwnSubmission,
- } = useSubmissionDownloadAccess()
+ }: UseSubmissionDownloadAccessResult = useSubmissionDownloadAccess()
const {
ownedMemberIds,
From 7ae53501127d478d5dcfbf860385cfdd63d9cc29 Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Thu, 4 Jun 2026 18:15:39 +0300
Subject: [PATCH 16/30] keep "override" label after re-run
---
.../AiReviewsTable/AiReviewsTable.tsx | 2 +-
.../TabContentAiApproval.module.scss | 6 ++++
.../TabContentAiApproval.tsx | 29 ++++++++++++++++---
.../src/lib/hooks/useFetchAiWorkflowRuns.ts | 1 +
4 files changed, 33 insertions(+), 5 deletions(-)
diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
index 01c5b2074..7ac5b3da9 100644
--- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
+++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
@@ -263,7 +263,7 @@ const AiReviewsTable: FC = props => {
return {
id: workflowId,
isGating: fromDecision?.isGating ?? configured?.isGating,
- managerScore: fromDecision?.managerScore,
+ managerScore: fromDecision?.managerScore ?? (run?.initialScore ? run.score : undefined),
minScore,
reviewDate: run?.completedAt,
run,
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss
index c58899ccc..2b2cc90e3 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.module.scss
@@ -157,6 +157,12 @@
margin-top: $sp-2;
}
+.confirmError {
+ margin: $sp-1 0 0;
+ font-size: 12px;
+ color: $red-100;
+}
+
.inputLabel {
display: flex;
flex-direction: column;
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
index 4e3a67918..0ac5c96ac 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
@@ -13,6 +13,7 @@ import {
useState,
} from 'react'
import { toast } from 'react-toastify'
+import { mutate } from 'swr'
import classNames from 'classnames'
import moment from 'moment'
@@ -39,7 +40,11 @@ import {
BackendSubmission,
ChallengeDetailContextModel,
} from '../../models'
-import { patchAiReviewDecision, WorkflowManagerOverride } from '../../services/aiReview.service'
+import {
+ getAiReviewDecisionsCacheKey,
+ patchAiReviewDecision,
+ WorkflowManagerOverride,
+} from '../../services/aiReview.service'
import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow'
import { TableNoRecord } from '../TableNoRecord'
import { TableWrapper } from '../TableWrapper'
@@ -267,7 +272,13 @@ export const TabContentAiApproval: FC = (props: Props) => {
const decision: AiReviewDecision = confirmModal.decision
const submissionId: string = confirmModal.submissionId
- const managerComment: string = confirmModal.managerComment
+ const managerComment: string = confirmModal.managerComment.trim()
+
+ if (!managerComment) {
+ toast.error('Manager comment is required.')
+ return
+ }
+
const scores: EditableScores = getScores(submissionId)
const workflows = decision.breakdown?.workflows ?? []
@@ -322,12 +333,16 @@ export const TabContentAiApproval: FC = (props: Props) => {
return next
})
toast.success('Changes saved successfully.')
+
+ if (aiReviewConfig?.id) {
+ await mutate(getAiReviewDecisionsCacheKey(aiReviewConfig.id))
+ }
} catch {
toast.error('Failed to save changes.')
} finally {
setSavingSubmissionId(undefined)
}
- }, [confirmModal, getScores, workflowNameById])
+ }, [confirmModal, getScores, workflowNameById, aiReviewConfig?.id])
const handleCancelSave = useCallback((): void => {
setConfirmModal(undefined)
@@ -564,6 +579,7 @@ export const TabContentAiApproval: FC = (props: Props) => {
Save Changes
@@ -580,9 +596,14 @@ export const TabContentAiApproval: FC = (props: Props) => {
rows={3}
value={confirmModal.managerComment}
onChange={handleCommentChange}
- placeholder='Add a comment explaining the changes (optional)...'
+ placeholder='Add a comment explaining the changes...'
/>
+ {!confirmModal.managerComment.trim() && (
+
+ Manager comment is required.
+
+ )}
diff --git a/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts b/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts
index 7a628d703..09e565770 100644
--- a/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts
+++ b/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts
@@ -43,6 +43,7 @@ export interface AiWorkflowRun {
status: AiWorkflowRunStatusEnum;
gitRunId?: string;
gitRunUrl?: string;
+ initialScore?: number
score: number;
workflow: AiWorkflow
usage: {
From 836e68b3046d67fe47904c398c6df04c9dda7273 Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Thu, 4 Jun 2026 22:00:13 +0300
Subject: [PATCH 17/30] show error if manual reviewers aren't set
---
.../ReviewersField/ReviewersField.spec.tsx | 36 ++++++++++
.../ReviewersField/ReviewersField.tsx | 69 +++++++++++++++++--
2 files changed, 100 insertions(+), 5 deletions(-)
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.spec.tsx
index 799b108eb..304b645f5 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.spec.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.spec.tsx
@@ -27,12 +27,25 @@ jest.mock('./AiReviewTab', () => ({
props: {
hasSubmissions?: boolean
onConfigRemoved?: () => Promise | void
+ onConfigPersisted?: (config: unknown) => void
},
) {
function handleRemoveClick(): void {
props.onConfigRemoved?.()
}
+ function handlePersistClick(): void {
+ props.onConfigPersisted?.({
+ id: 'config-1',
+ challengeId: 'challenge-1',
+ mode: 'AI_GATING',
+ workflows: [],
+ templateId: undefined,
+ minPassingThreshold: 75,
+ autoFinalize: false,
+ })
+ }
+
return (
{props.hasSubmissions
@@ -44,6 +57,12 @@ jest.mock('./AiReviewTab', () => ({
>
Remove AI config
+
+ Persist AI config
+
AI review content
)
@@ -238,6 +257,23 @@ describe('ReviewersField', () => {
expect(screen.getByTestId('ai-review-tab-read-only')).not.toBeNull()
})
+ it('requires manual reviewer configuration when AI Review mode is AI GATING', async () => {
+ const user = userEvent.setup()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByRole('tab', { name: 'AI Review (0)' }))
+ await user.click(screen.getByRole('button', { name: 'Persist AI config' }))
+
+ expect(screen.getByText(
+ 'Manual review configuration is required when AI Review mode is AI GATING.',
+ )).toBeInTheDocument()
+ })
+
it('supports keyboard navigation between review tabs', async () => {
const user = userEvent.setup()
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx
index 8d66375f2..c8dd54265 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx
@@ -2,6 +2,7 @@ import {
FC,
KeyboardEvent,
useCallback,
+ useEffect,
useMemo,
useRef,
useState,
@@ -19,6 +20,7 @@ import {
Reviewer,
} from '../../../../../lib/models'
import {
+ fetchAiReviewConfigByChallenge,
fetchChallenge,
patchChallenge,
} from '../../../../../lib/services'
@@ -52,7 +54,8 @@ function hasReviewerChanges(
export const ReviewersField: FC = (props: ReviewersFieldProps) => {
const formContext = useFormContext()
const [activeTab, setActiveTab] = useState('human')
- const [aiReviewMode, setAiReviewMode] = useState(undefined)
+ const [aiReviewMode, setAiReviewMode] = useState()
+ const [hasLoadedAiConfig, setHasLoadedAiConfig] = useState(false)
const humanTabRef = useRef(null)
const aiTabRef = useRef(null)
@@ -64,6 +67,11 @@ export const ReviewersField: FC = (props: ReviewersFieldPro
control: formContext.control,
name: 'id',
}) as string | undefined
+ const {
+ setError,
+ clearErrors,
+ formState: { errors },
+ } = useFormContext()
const phases = useWatch({
control: formContext.control,
name: 'phases',
@@ -85,6 +93,35 @@ export const ReviewersField: FC = (props: ReviewersFieldPro
name: 'prizeSets',
}) as ChallengeEditorFormData['prizeSets']
+ useEffect(() => {
+ let mounted = true
+
+ if (!challengeId || aiReviewMode !== undefined || hasLoadedAiConfig) {
+ return undefined
+ }
+
+ fetchAiReviewConfigByChallenge(challengeId)
+ .then((config: AiReviewConfig | undefined) => {
+ if (!mounted) {
+ return
+ }
+
+ if (config?.mode) {
+ setAiReviewMode(config.mode)
+ }
+ })
+ .catch(() => undefined)
+ .finally(() => {
+ if (mounted) {
+ setHasLoadedAiConfig(true)
+ }
+ })
+
+ return () => {
+ mounted = false
+ }
+ }, [aiReviewMode, challengeId, hasLoadedAiConfig])
+
const reviewerRows = useMemo(
() => (Array.isArray(reviewers)
? reviewers
@@ -101,6 +138,28 @@ export const ReviewersField: FC = (props: ReviewersFieldPro
)
const humanReviewLabel = `Human Review (${humanReviewersCount})`
const aiReviewLabel = `AI Review (${aiReviewersCount})`
+ const aiGatingManualReviewError = useMemo(
+ () => aiReviewMode !== 'AI_ONLY' && humanReviewersCount === 0
+ ? 'Manual review configuration is required.'
+ : undefined,
+ [aiReviewMode, humanReviewersCount],
+ )
+
+ useEffect(() => {
+ if (!aiGatingManualReviewError) {
+ if (errors.reviewers?.type === 'aiGatingManualReview') {
+ clearErrors('reviewers')
+ }
+
+ return
+ }
+
+ setError('reviewers', {
+ type: 'aiGatingManualReview',
+ message: aiGatingManualReviewError,
+ })
+ }, [aiGatingManualReviewError, clearErrors, errors.reviewers?.type, setError])
+
const hasSubmissions = useMemo(
() => Number(numOfSubmissions || 0) > 0,
[numOfSubmissions],
@@ -282,6 +341,9 @@ export const ReviewersField: FC = (props: ReviewersFieldPro
+ {aiGatingManualReviewError && !errors.reviewers && (
+ {aiGatingManualReviewError}
+ )}
= (props: ReviewersFieldPro
No manual reviewers are needed in AI Only mode.
)}
- {aiReviewMode !== 'AI_ONLY' && (
-
- )}
+
-
Date: Thu, 4 Jun 2026 22:01:59 +0300
Subject: [PATCH 18/30] check initial score
---
.../review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
index 7ac5b3da9..8b24fcc31 100644
--- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
+++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
@@ -263,7 +263,7 @@ const AiReviewsTable: FC
= props => {
return {
id: workflowId,
isGating: fromDecision?.isGating ?? configured?.isGating,
- managerScore: fromDecision?.managerScore ?? (run?.initialScore ? run.score : undefined),
+ managerScore: fromDecision?.managerScore ?? (run?.initialScore !== null && run?.initialScore !== undefined ? run.score : undefined),
minScore,
reviewDate: run?.completedAt,
run,
From a6f2f2af2a9879868b7e7b95fe1dfb67b55c7d9f Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Thu, 4 Jun 2026 22:45:02 +0300
Subject: [PATCH 19/30] lint
---
.../ReviewersField/ReviewersField.spec.tsx | 11 ++++++-----
.../components/ReviewersField/ReviewersField.tsx | 13 ++++++++++---
2 files changed, 16 insertions(+), 8 deletions(-)
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.spec.tsx
index 304b645f5..d5c40fdeb 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.spec.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.spec.tsx
@@ -36,13 +36,13 @@ jest.mock('./AiReviewTab', () => ({
function handlePersistClick(): void {
props.onConfigPersisted?.({
- id: 'config-1',
+ autoFinalize: false,
challengeId: 'challenge-1',
+ id: 'config-1',
+ minPassingThreshold: 75,
mode: 'AI_GATING',
- workflows: [],
templateId: undefined,
- minPassingThreshold: 75,
- autoFinalize: false,
+ workflows: [],
})
}
@@ -271,7 +271,8 @@ describe('ReviewersField', () => {
expect(screen.getByText(
'Manual review configuration is required when AI Review mode is AI GATING.',
- )).toBeInTheDocument()
+ ))
+ .toBeInTheDocument()
})
it('supports keyboard navigation between review tabs', async () => {
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx
index c8dd54265..b340fc2a1 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx
@@ -8,7 +8,10 @@ import {
useState,
} from 'react'
import {
+ FieldErrors,
+ UseFormClearErrors,
useFormContext,
+ UseFormSetError,
useWatch,
} from 'react-hook-form'
import classNames from 'classnames'
@@ -71,6 +74,10 @@ export const ReviewersField: FC = (props: ReviewersFieldPro
setError,
clearErrors,
formState: { errors },
+ }: {
+ setError: UseFormSetError
+ clearErrors: UseFormClearErrors
+ formState: { errors: FieldErrors }
} = useFormContext()
const phases = useWatch({
control: formContext.control,
@@ -139,9 +146,9 @@ export const ReviewersField: FC = (props: ReviewersFieldPro
const humanReviewLabel = `Human Review (${humanReviewersCount})`
const aiReviewLabel = `AI Review (${aiReviewersCount})`
const aiGatingManualReviewError = useMemo(
- () => aiReviewMode !== 'AI_ONLY' && humanReviewersCount === 0
+ () => (aiReviewMode !== 'AI_ONLY' && humanReviewersCount === 0
? 'Manual review configuration is required.'
- : undefined,
+ : undefined),
[aiReviewMode, humanReviewersCount],
)
@@ -155,8 +162,8 @@ export const ReviewersField: FC = (props: ReviewersFieldPro
}
setError('reviewers', {
- type: 'aiGatingManualReview',
message: aiGatingManualReviewError,
+ type: 'aiGatingManualReview',
})
}, [aiGatingManualReviewError, clearErrors, errors.reviewers?.type, setError])
From 8d1bb5be8a69cd6a245463c10ea490386a2cc78b Mon Sep 17 00:00:00 2001
From: Justin Gasper
Date: Mon, 8 Jun 2026 07:45:23 +1000
Subject: [PATCH 20/30] Expose marathon compiler issues
---
.../MarathonMatchScorerSection.module.scss | 55 +++++++++++
.../MarathonMatchScorerSection.tsx | 94 ++++++++++++++++++-
2 files changed, 146 insertions(+), 3 deletions(-)
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss
index e8abe1cf7..593a9fc8b 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss
@@ -67,6 +67,30 @@
color: #b04337;
}
+.errorActionRow {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ justify-content: space-between;
+}
+
+.linkButton {
+ background: transparent;
+ border: 0;
+ color: $link-blue-dark;
+ cursor: pointer;
+ font: inherit;
+ font-weight: 600;
+ padding: 0;
+ text-decoration: underline;
+}
+
+.linkButton:focus {
+ outline: 2px solid $link-blue-dark;
+ outline-offset: 3px;
+}
+
.sectionCard,
.phaseCard,
.summaryCard {
@@ -254,6 +278,37 @@
gap: 16px;
}
+.compilationErrorMeta {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.compilationErrorMeta strong {
+ color: $black-80;
+ font-size: 16px;
+}
+
+.compilationErrorMeta span {
+ color: $black-60;
+ font-size: 13px;
+}
+
+.compilationErrorOutput {
+ background: #101820;
+ border-radius: 8px;
+ color: #f3f7fb;
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+ font-size: 13px;
+ line-height: 1.5;
+ margin: 0;
+ max-height: 420px;
+ overflow: auto;
+ padding: 14px;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
.codeEditor {
border: 1px solid #c9ced3;
border-radius: 10px;
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx
index 047d69261..a40eb6849 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx
@@ -9,7 +9,7 @@ import {
} from 'react'
import classNames from 'classnames'
-import { Button } from '~/libs/ui'
+import { BaseModal, Button } from '~/libs/ui'
import {
ChallengePhase,
@@ -127,6 +127,11 @@ interface LoadTesterByIdOptions {
showErrorToast?: boolean
}
+interface CompilationErrorModalProps {
+ onClose: () => void
+ tester: MarathonMatchTester
+}
+
/**
* Renders the inputs for a single marathon match phase configuration.
* Used by `MarathonMatchScorerSection` for example, provisional, and system phases.
@@ -197,6 +202,47 @@ const PhaseConfigCard: FC = (props: PhaseConfigCardProps)
)
+/**
+ * Displays saved scorer compilation diagnostics for a failed tester build.
+ * @param props Modal visibility, close action, and failed tester details.
+ * @returns The modal body used by `MarathonMatchScorerSection` for FAILED compilation status.
+ */
+const CompilationErrorModal: FC = (
+ props: CompilationErrorModalProps,
+) => (
+
+
+
+ )}
+ >
+
+
+
+ {props.tester.name}
+ {' '}
+ v
+ {props.tester.version}
+
+ {props.tester.className}
+
+
+ {props.tester.compilationError || 'Compilation failed without an error message.'}
+
+
+
+)
+
function getErrorMessage(error: unknown, fallbackMessage: string): string {
if (error instanceof Error && error.message.trim()) {
return error.message
@@ -669,6 +715,7 @@ export const MarathonMatchScorerSection: FC = (
const [testerLoadError, setTesterLoadError] = useState()
const [showNewTesterModal, setShowNewTesterModal] = useState(false)
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
+ const [showCompilationErrorsModal, setShowCompilationErrorsModal] = useState(false)
const phaseOptions = useMemo(
(): PhaseOption[] => phases
@@ -760,6 +807,22 @@ export const MarathonMatchScorerSection: FC = (
setShowNewVersionModal(false)
}, [])
+ /**
+ * Opens the failed scorer compilation diagnostics modal.
+ * @returns void
+ */
+ const handleOpenCompilationErrorsModal = useCallback((): void => {
+ setShowCompilationErrorsModal(true)
+ }, [])
+
+ /**
+ * Closes the failed scorer compilation diagnostics modal.
+ * @returns void
+ */
+ const handleCloseCompilationErrorsModal = useCallback((): void => {
+ setShowCompilationErrorsModal(false)
+ }, [])
+
const loadTesterById = useCallback(
async (
testerId: string,
@@ -1198,6 +1261,14 @@ export const MarathonMatchScorerSection: FC = (
onScorerConfigChange,
])
+ useEffect(() => {
+ if (selectedTester?.compilationStatus === 'FAILED') {
+ return
+ }
+
+ setShowCompilationErrorsModal(false)
+ }, [selectedTester?.compilationStatus])
+
useEffect(() => {
clearPollingTimer()
@@ -1286,8 +1357,16 @@ export const MarathonMatchScorerSection: FC = (
{selectedTester?.compilationStatus === 'FAILED'
? (
-
Scorer compilation failed.
-
{selectedTester.compilationError || 'Compilation failed without an error message.'}
+
+ Scorer compilation failed.
+
+ View compilation errors
+
+
)
: undefined}
@@ -1589,6 +1668,15 @@ export const MarathonMatchScorerSection: FC = (
/>
)
: undefined}
+
+ {showCompilationErrorsModal && selectedTester?.compilationStatus === 'FAILED'
+ ? (
+
+ )
+ : undefined}
)
}
From 906553ecc26023b7cea86abcab137e1ad6f0e8b7 Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Mon, 8 Jun 2026 10:48:42 +0300
Subject: [PATCH 21/30] Update message for no reviewer
---
.../components/ReviewersField/ReviewConfigurationSummary.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewConfigurationSummary.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewConfigurationSummary.tsx
index d2c848f07..4debb0d88 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewConfigurationSummary.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewConfigurationSummary.tsx
@@ -838,7 +838,9 @@ export const ReviewConfigurationSummary: FC = (
>
)
- : No human reviewers configured.
}
+ :
+ {isAiOnlyMode ? 'No manual reviewers are needed in AI Only mode.' : 'No human reviewers configured.'}
+
}
From 6fd59b42f16e5d885ed48b9f2bb9f2561e422b08 Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Mon, 8 Jun 2026 14:42:32 +0300
Subject: [PATCH 22/30] Allow inline score editing in ai scorecard view in
approval phase
---
.../AppealComment/AppealComment.tsx | 30 +---
.../ManagerComment/ManagerComment.tsx | 28 +---
.../AiFeedback/AiFeedback.module.scss | 63 ++++++++
.../AiFeedback/AiFeedback.tsx | 145 ++++++++++++++++--
.../ReviewAppeal/ReviewAppeal.tsx | 29 +---
.../ReviewManagerComment.tsx | 28 +---
.../ScorecardQuestionEdit.tsx | 28 +---
.../ScorecardViewer.context.tsx | 2 +-
.../src/lib/services/scorecards.service.ts | 13 ++
src/apps/review/src/lib/utils/index.ts | 1 +
.../src/lib/utils/scorecardQuestionOptions.ts | 29 ++++
11 files changed, 257 insertions(+), 139 deletions(-)
create mode 100644 src/apps/review/src/lib/utils/scorecardQuestionOptions.ts
diff --git a/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx b/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx
index ff02f379e..2adafc562 100644
--- a/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx
+++ b/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx
@@ -24,10 +24,7 @@ import {
ScorecardQuestion,
SelectOption,
} from '../../models'
-import { formAppealResponseSchema, isAppealsResponsePhase } from '../../utils'
-import {
- QUESTION_YES_NO_OPTIONS,
-} from '../../../config/index.config'
+import { formAppealResponseSchema, getScoreResponseOptions, isAppealsResponsePhase } from '../../utils'
import { ChallengeDetailContext } from '../../contexts'
import styles from './AppealComment.module.scss'
@@ -103,28 +100,9 @@ export const AppealComment: FC = (props: Props) => {
}
}, [addAppealResponse, appealInfo, reviewItem, updatedResponse])
- const responseOptions = useMemo(() => {
- if (scorecardQuestion.type === 'SCALE') {
- const length
- = scorecardQuestion.scaleMax
- - scorecardQuestion.scaleMin
- + 1
- return Array.from(
- new Array(length),
- (x, i) => `${i + scorecardQuestion.scaleMin}`,
- )
- .map(item => ({
- label: item,
- value: item,
- }))
- }
-
- if (scorecardQuestion.type === 'YES_NO') {
- return QUESTION_YES_NO_OPTIONS
- }
-
- return []
- }, [scorecardQuestion])
+ const responseOptions = useMemo(() => (
+ getScoreResponseOptions(scorecardQuestion)
+ ), [scorecardQuestion])
useEffect(() => {
setAppealResponse(data.appealResponse?.content ?? '')
diff --git a/src/apps/review/src/lib/components/ManagerComment/ManagerComment.tsx b/src/apps/review/src/lib/components/ManagerComment/ManagerComment.tsx
index ce099349f..a7ce6ddbe 100644
--- a/src/apps/review/src/lib/components/ManagerComment/ManagerComment.tsx
+++ b/src/apps/review/src/lib/components/ManagerComment/ManagerComment.tsx
@@ -17,8 +17,7 @@ import { yupResolver } from '@hookform/resolvers/yup'
import { MarkdownReview } from '../MarkdownReview'
import { FieldMarkdownEditor } from '../FieldMarkdownEditor'
import { FormManagerComment, ReviewItemInfo, ScorecardQuestion, SelectOption } from '../../models'
-import { formManagerCommentSchema } from '../../utils'
-import { QUESTION_YES_NO_OPTIONS } from '../../../config/index.config'
+import { formManagerCommentSchema, getScoreResponseOptions } from '../../utils'
import styles from './ManagerComment.module.scss'
@@ -69,28 +68,9 @@ export const ManagerComment: FC = (props: Props) => {
)
}, [addManagerComment, reviewItem])
- const responseOptions = useMemo(() => {
- if (scorecardQuestion.type === 'SCALE') {
- const length
- = scorecardQuestion.scaleMax
- - scorecardQuestion.scaleMin
- + 1
- return Array.from(
- new Array(length),
- (x, i) => `${i + scorecardQuestion.scaleMin}`,
- )
- .map(item => ({
- label: item,
- value: item,
- }))
- }
-
- if (scorecardQuestion.type === 'YES_NO') {
- return QUESTION_YES_NO_OPTIONS
- }
-
- return []
- }, [scorecardQuestion])
+ const responseOptions = useMemo(() => (
+ getScoreResponseOptions(scorecardQuestion)
+ ), [scorecardQuestion])
useEffect(() => {
if (reviewItem.managerComment) {
diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.module.scss b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.module.scss
index 67f35f286..32bb68c3c 100644
--- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.module.scss
+++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.module.scss
@@ -55,6 +55,69 @@
}
}
+.feedbackEditRow {
+ display: flex;
+ align-items: center;
+ gap: $sp-2;
+ margin-bottom: $sp-3;
+}
+
+.scoreEditSelect {
+ min-width: 120px;
+ padding: $sp-2 $sp-3;
+ border: 1px solid var(--BorderColor);
+ border-radius: 6px;
+ background: var(--Background);
+ color: var(--FontColor);
+}
+
+.scoreEditTextarea {
+ width: 100%;
+ min-height: 96px;
+ margin-top: $sp-2;
+ padding: $sp-3;
+ border: 1px solid var(--BorderColor);
+ border-radius: 6px;
+ background: var(--Background);
+ color: var(--FontColor);
+ resize: vertical;
+}
+
+.scoreEditTrigger {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ margin-left: $sp-2;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: var(--TextSecondary);
+ cursor: pointer;
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+ }
+}
+
+.scoreEditItem {
+ display: block;
+ width: 100%;
+ text-align: left;
+ border: none;
+ background: transparent;
+ padding: $sp-3 $sp-4;
+ color: var(--FontColor);
+ cursor: pointer;
+
+ &:hover,
+ &:focus {
+ background: var(--Tertiary);
+ }
+}
+
.mdReview {
h1, h2, h3, h4, h5, h6 {
font-size: 14px;
diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx
index 0aa841c61..ada40e760 100644
--- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx
+++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx
@@ -1,13 +1,16 @@
-import { FC, useCallback, useMemo, useState } from 'react'
+import { ChangeEvent, FC, useCallback, useMemo, useState } from 'react'
import { mutate } from 'swr'
import { IconAiReview } from '~/apps/review/src/lib/assets/icons'
import { ReviewsContextModel, ScorecardQuestion } from '~/apps/review/src/lib/models'
-import { createFeedbackComment } from '~/apps/review/src/lib/services'
+import { createFeedbackComment, updateRunItemScore } from '~/apps/review/src/lib/services'
import { useReviewsContext } from '~/apps/review/src/pages/reviews/ReviewsContext'
import { EnvironmentConfig } from '~/config'
-import { Tooltip } from '~/libs/ui'
+import { Tooltip, IconOutline } from '~/libs/ui'
+import { useRole } from '~/apps/review/src/lib/hooks'
+import { handleError } from '~/libs/shared/lib/utils/handle-error'
+import { getScoreResponseOptions } from '~/apps/review/src/lib/utils'
import { ScorecardViewerContextValue, useScorecardViewerContext } from '../../ScorecardViewer.context'
import { ScorecardQuestionRow } from '../ScorecardQuestionRow'
import { ScorecardScore } from '../../ScorecardScore'
@@ -28,7 +31,12 @@ const AiFeedback: FC = props => {
aiFeedbackItems?.find((r: any) => r.scorecardQuestionId === props.question.id)
), [props.question.id, aiFeedbackItems])
const { workflowId, workflowRun }: ReviewsContextModel = useReviewsContext()
+ const { isPrivilegedRole } = useRole()
const [showReply, setShowReply] = useState(false)
+ const [isUpdatingScore, setIsUpdatingScore] = useState(false)
+ const [isEditingScore, setIsEditingScore] = useState(false)
+ const [editedScore, setEditedScore] = useState('')
+ const [editComment, setEditComment] = useState('')
const commentsArr: any[] = (feedback?.comments) || []
@@ -45,12 +53,81 @@ const AiFeedback: FC = props => {
setShowReply(false)
}, [workflowId, workflowRun?.id, workflowRun?.status, feedback?.id])
+ const isYesNo = props.question.type === 'YES_NO'
+ const hasQuestionScoreEditAccess = isPrivilegedRole && !!workflowId && !!workflowRun?.id && !!feedback?.id
+
+ const scoreOptions = useMemo(() => getScoreResponseOptions(props.question), [props.question])
+
+ const handleStartEditing = useCallback(() => {
+ setIsEditingScore(true)
+
+ if (isYesNo) {
+ setEditedScore(feedback?.questionScore ? 'Yes' : 'No')
+ } else {
+ setEditedScore(String(feedback?.questionScore ?? ''))
+ }
+
+ setEditComment('')
+ }, [feedback?.questionScore, isYesNo])
+
+ const handleScoreChange = useCallback((event: ChangeEvent) => {
+ setEditedScore(event.target.value)
+ }, [])
+
+ const handleCommentChange = useCallback((event: ChangeEvent) => {
+ setEditComment(event.target.value)
+ }, [])
+
+ const handleSaveScore = useCallback(async () => {
+ if (!hasQuestionScoreEditAccess || isUpdatingScore) {
+ return
+ }
+
+ if (!workflowId || !workflowRun?.id || !feedback?.id) {
+ return
+ }
+
+ let questionScore = Number(editedScore)
+ if (isYesNo) {
+ if (editedScore === 'Yes') {
+ questionScore = 1
+ } else if (editedScore === 'No') {
+ questionScore = 0
+ }
+ }
+
+ if (!Number.isFinite(questionScore)) {
+ return
+ }
+
+ setIsUpdatingScore(true)
+ const itemsKey = `${EnvironmentConfig.API.V6}/workflows/${workflowId}/runs/${workflowRun.id}/items?[${workflowRun?.status}]`
+
+ if (!editComment.trim()) {
+ handleError(new Error('A comment is required when updating the score.'))
+ setIsUpdatingScore(false)
+ return
+ }
+
+ try {
+ await updateRunItemScore(workflowId, workflowRun.id, feedback.id, {
+ questionScore,
+ comment: editComment.trim(),
+ })
+
+ await mutate(itemsKey)
+ setIsEditingScore(false)
+ } catch (err) {
+ handleError(err)
+ } finally {
+ setIsUpdatingScore(false)
+ }
+ }, [editComment, editedScore, feedback?.id, hasQuestionScoreEditAccess, isYesNo, isUpdatingScore, workflowId, workflowRun?.id, workflowRun?.status])
+
if (!aiFeedbackItems?.length || !feedback) {
return <>>
}
- const isYesNo = props.question.type === 'YES_NO'
-
return (
}
@@ -63,21 +140,57 @@ const AiFeedback: FC = props => {
/>
)}
>
-
+
- {isYesNo && (feedback.questionScore ? 'Yes' : 'No')}
- {!isYesNo && (
-
-
- {feedback.questionScore}
-
-
+ {scoreOptions.map(option => (
+
+ {option.label}
+
+ ))}
+
+ ) : (
+ isYesNo ? (feedback.questionScore ? 'Yes' : 'No') : (
+
+
+ {feedback.questionScore}
+
+
+ )
)}
-
+
+ {hasQuestionScoreEditAccess && (
+
+ {isEditingScore ? : }
+
+ )}
+
+
+ {isEditingScore && (
+
+ )}
= props => {
const [appealResponse, setAppealResponse] = useState(props.appeal.appealResponse?.content || '')
const [updatedResponse, setUpdatedResponse] = useState>()
- const responseOptions = useMemo(() => {
- if (!props.scorecardQuestion) {
- return []
- }
-
- if (props.scorecardQuestion.type === 'SCALE') {
- const length = props.scorecardQuestion.scaleMax - props.scorecardQuestion.scaleMin + 1
- return Array.from(
- new Array(length),
- (x, i) => `${i + props.scorecardQuestion!.scaleMin}`,
- )
- .map(item => ({
- label: item,
- value: item,
- }))
- }
-
- if (props.scorecardQuestion.type === 'YES_NO') {
- return QUESTION_YES_NO_OPTIONS
- }
-
- return []
- }, [props.scorecardQuestion])
+ const responseOptions = useMemo(() => (
+ getScoreResponseOptions(props.scorecardQuestion)
+ ), [props.scorecardQuestion])
const {
handleSubmit,
diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/ReviewResponse/ReviewManagerComment/ReviewManagerComment.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/ReviewResponse/ReviewManagerComment/ReviewManagerComment.tsx
index 5a63d077d..ab6510099 100644
--- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/ReviewResponse/ReviewManagerComment/ReviewManagerComment.tsx
+++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/ReviewResponse/ReviewManagerComment/ReviewManagerComment.tsx
@@ -13,7 +13,7 @@ import { IconPhaseReview } from '~/apps/review/src/lib/assets/icons'
import { FormManagerComment, ReviewItemInfo, ScorecardQuestion, SelectOption } from '../../../../../../models'
import { formManagerCommentSchema } from '../../../../../../utils'
-import { QUESTION_YES_NO_OPTIONS } from '../../../../../../../config/index.config'
+import { getScoreResponseOptions } from '../../../../../../utils'
import { MarkdownReview } from '../../../../../MarkdownReview'
import { FieldMarkdownEditor } from '../../../../../FieldMarkdownEditor'
import { ScorecardViewerContextValue, useScorecardViewerContext } from '../../../ScorecardViewer.context'
@@ -39,29 +39,9 @@ const ReviewManagerComment: FC = props => {
const [comment, setComment] = useState(props.managerComment || '')
const [showCommentForm, setShowCommentForm] = useState(false)
- const responseOptions = useMemo(() => {
- if (!props.scorecardQuestion) {
- return []
- }
-
- if (props.scorecardQuestion.type === 'SCALE') {
- const length = props.scorecardQuestion.scaleMax - props.scorecardQuestion.scaleMin + 1
- return Array.from(
- new Array(length),
- (x, i) => `${i + props.scorecardQuestion!.scaleMin}`,
- )
- .map(item => ({
- label: item,
- value: item,
- }))
- }
-
- if (props.scorecardQuestion.type === 'YES_NO') {
- return QUESTION_YES_NO_OPTIONS
- }
-
- return []
- }, [props.scorecardQuestion])
+ const responseOptions = useMemo(() => (
+ getScoreResponseOptions(props.scorecardQuestion)
+ ), [props.scorecardQuestion])
const {
handleSubmit,
diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/ScorecardQuestionEdit/ScorecardQuestionEdit.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/ScorecardQuestionEdit/ScorecardQuestionEdit.tsx
index c9d51a077..7bc225a88 100644
--- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/ScorecardQuestionEdit/ScorecardQuestionEdit.tsx
+++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/ScorecardQuestionEdit/ScorecardQuestionEdit.tsx
@@ -12,10 +12,8 @@ import classNames from 'classnames'
import { IconOutline } from '~/libs/ui'
import { IconComment } from '~/apps/review/src/lib/assets/icons'
-import {
- QUESTION_RESPONSE_OPTIONS,
- QUESTION_YES_NO_OPTIONS,
-} from '../../../../../../config/index.config'
+import { QUESTION_RESPONSE_OPTIONS } from '../../../../../../config/index.config'
+import { getScoreResponseOptions } from '../../../../../utils'
import {
FormReviews,
ReviewItemInfo,
@@ -60,25 +58,9 @@ export const ScorecardQuestionEdit: FC = props => {
const touched = isTouched || {}
const trigger = formTrigger || ((): Promise => Promise.resolve(true))
- const responseOptions = useMemo(() => {
- if (props.question.type === 'SCALE') {
- const length = props.question.scaleMax - props.question.scaleMin + 1
- return Array.from(
- new Array(length),
- (x, i) => `${i + props.question.scaleMin}`,
- )
- .map(item => ({
- label: item,
- value: item,
- }))
- }
-
- if (props.question.type === 'YES_NO') {
- return QUESTION_YES_NO_OPTIONS
- }
-
- return []
- }, [props.question])
+ const responseOptions = useMemo(() => (
+ getScoreResponseOptions(props.question)
+ ), [props.question])
const {
fields,
diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardViewer.context.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardViewer.context.tsx
index ab86deabf..e7ee603a1 100644
--- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardViewer.context.tsx
+++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardViewer.context.tsx
@@ -146,7 +146,7 @@ export const ScorecardViewerContextProvider: FC = p
aiFeedbackItems: props.aiFeedbackItems,
canAddManagerComment: props.canAddManagerComment,
doDeleteAppeal: props.doDeleteAppeal,
- form: props.isEdit ? reviewFormCtx.form : undefined,
+ form: reviewFormCtx.form,
formErrors: props.isEdit ? reviewFormCtx.form.formState.errors : undefined,
formTrigger: props.isEdit ? reviewFormCtx.form.trigger : undefined,
isEdit: props.isEdit,
diff --git a/src/apps/review/src/lib/services/scorecards.service.ts b/src/apps/review/src/lib/services/scorecards.service.ts
index 896a0ba89..ec84d1d3f 100644
--- a/src/apps/review/src/lib/services/scorecards.service.ts
+++ b/src/apps/review/src/lib/services/scorecards.service.ts
@@ -117,6 +117,19 @@ export const updateRunItemComment = (
body,
)
+export const updateRunItemScore = (
+ workflowId: string,
+ runId: string,
+ feedbackId: string,
+ body: {
+ questionScore: number
+ comment?: string
+ },
+): Promise => xhrPatchAsync(
+ `${EnvironmentConfig.API.V6}/workflows/${workflowId}/runs/${runId}/items/${feedbackId}`,
+ body,
+)
+
export const createFeedbackComment = (
workflowId: string,
runId: string,
diff --git a/src/apps/review/src/lib/utils/index.ts b/src/apps/review/src/lib/utils/index.ts
index 69e62bb3a..7cde335d8 100644
--- a/src/apps/review/src/lib/utils/index.ts
+++ b/src/apps/review/src/lib/utils/index.ts
@@ -11,6 +11,7 @@ export * from './aggregateSubmissionReviews'
export * from './submissionHistory'
export * from './reviewReopen.utils'
export * from './challengeStatus'
+export * from './scorecardQuestionOptions'
export * from './constants'
export * from './screeningReviewDebug'
export * from './reviewMetadataParsing'
diff --git a/src/apps/review/src/lib/utils/scorecardQuestionOptions.ts b/src/apps/review/src/lib/utils/scorecardQuestionOptions.ts
new file mode 100644
index 000000000..9fe0cf33b
--- /dev/null
+++ b/src/apps/review/src/lib/utils/scorecardQuestionOptions.ts
@@ -0,0 +1,29 @@
+import { SelectOption, ScorecardQuestion } from '../models'
+import { QUESTION_YES_NO_OPTIONS } from '../../config/index.config'
+
+export function getScoreResponseOptions(
+ question?: ScorecardQuestion | null,
+): SelectOption[] {
+ if (!question) {
+ return []
+ }
+
+ if (question.type === 'SCALE') {
+ const length = question.scaleMax - question.scaleMin + 1
+
+ if (length <= 0) {
+ return []
+ }
+
+ return Array.from({ length }, (_, index) => ({
+ label: String(question.scaleMin + index),
+ value: String(question.scaleMin + index),
+ }))
+ }
+
+ if (question.type === 'YES_NO') {
+ return QUESTION_YES_NO_OPTIONS
+ }
+
+ return []
+}
From c1c86afe90e7913ef309bee513a6ba4a3a710eff Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Mon, 8 Jun 2026 14:47:37 +0300
Subject: [PATCH 23/30] Remove "edit" in approval tab. this will happen in
scorcard viewer
---
.../TabContentAiApproval.tsx | 373 ++----------------
1 file changed, 35 insertions(+), 338 deletions(-)
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
index 0ac5c96ac..5f76ced74 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx
@@ -5,23 +5,18 @@
* Allows admins/copilots/PMs/TMs to edit decision scores via the AiReviewsTable.
*/
import {
- ChangeEvent,
FC,
useCallback,
useContext,
useMemo,
- useState,
} from 'react'
-import { toast } from 'react-toastify'
-import { mutate } from 'swr'
+import { useNavigate } from 'react-router-dom'
import classNames from 'classnames'
import moment from 'moment'
import { TableLoading } from '~/apps/admin/src/lib'
import { IsRemovingType } from '~/apps/admin/src/lib/models'
import {
- BaseModal,
- Button,
Table,
TableColumn,
} from '~/libs/ui'
@@ -40,11 +35,6 @@ import {
BackendSubmission,
ChallengeDetailContextModel,
} from '../../models'
-import {
- getAiReviewDecisionsCacheKey,
- patchAiReviewDecision,
- WorkflowManagerOverride,
-} from '../../services/aiReview.service'
import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow'
import { TableNoRecord } from '../TableNoRecord'
import { TableWrapper } from '../TableWrapper'
@@ -60,10 +50,6 @@ interface Props {
downloadSubmission: (submissionId: string) => void
}
-interface EditableScores {
- workflows: Record
-}
-
interface SubmissionRowData {
submission: BackendSubmission
decision: AiReviewDecision | undefined
@@ -77,44 +63,6 @@ function formatScore(score: number | null | undefined): string {
return Number.isInteger(score) ? `${score}` : score.toFixed(2)
}
-function getInitialScores(decision: AiReviewDecision | undefined): EditableScores {
- const workflows = decision?.breakdown?.workflows ?? []
-
- return {
- workflows: workflows.reduce>((acc, wf) => {
- acc[wf.workflowId] = wf.managerScore !== undefined && wf.managerScore !== null
- ? String(wf.managerScore)
- : ''
-
- return acc
- }, {}),
- }
-}
-
-function hasScoreChanges(
- original: AiReviewDecision | undefined,
- edited: EditableScores,
-): boolean {
- if (!original) {
- return false
- }
-
- const workflows = original.breakdown?.workflows ?? []
-
- for (const wf of workflows) {
- const editedScore = edited.workflows[wf.workflowId] ?? ''
- const originalScore = wf.managerScore !== undefined && wf.managerScore !== null
- ? String(wf.managerScore)
- : ''
-
- if (editedScore.trim() !== originalScore) {
- return true
- }
- }
-
- return false
-}
-
export const TabContentAiApproval: FC = (props: Props) => {
const {
aiReviewConfig,
@@ -172,15 +120,7 @@ export const TabContentAiApproval: FC = (props: Props) => {
],
)
- const [localDecisionOverrides, setLocalDecisionOverrides] = useState>({})
- const [editScores, setEditScores] = useState>({})
- const [editingRows, setEditingRows] = useState>(new Set())
- const [savingSubmissionId, setSavingSubmissionId] = useState(undefined)
- const [confirmModal, setConfirmModal] = useState<{
- submissionId: string
- decision: AiReviewDecision
- managerComment: string
- } | undefined>(undefined)
+ const navigate = useNavigate()
const workflowNameById = useMemo>(() => {
const configWorkflows = aiReviewConfig?.workflows ?? []
@@ -194,39 +134,6 @@ export const TabContentAiApproval: FC = (props: Props) => {
}, {})
}, [aiReviewConfig?.workflows])
- const getDecision = useCallback((submissionId: string): AiReviewDecision | undefined => (
- localDecisionOverrides[submissionId] ?? aiReviewDecisionsBySubmissionId[submissionId]
- ), [aiReviewDecisionsBySubmissionId, localDecisionOverrides])
-
- const getScores = useCallback((submissionId: string): EditableScores => {
- if (editScores[submissionId]) {
- return editScores[submissionId]
- }
-
- return getInitialScores(getDecision(submissionId))
- }, [editScores, getDecision])
-
- const updateScores = useCallback((
- submissionId: string,
- workflowId: string,
- value: string,
- ): void => {
- setEditScores(prev => {
- const current = prev[submissionId] ?? getInitialScores(getDecision(submissionId))
-
- return {
- ...prev,
- [submissionId]: {
- ...current,
- workflows: {
- ...current.workflows,
- [workflowId]: value,
- },
- },
- }
- })
- }, [getDecision])
-
const contestSubmissions = useMemo(
() => props.submissions.filter(s => (s.type || '').toUpperCase() === 'CONTEST_SUBMISSION' && s.isLatest),
[props.submissions],
@@ -234,153 +141,20 @@ export const TabContentAiApproval: FC = (props: Props) => {
const tableData = useMemo(
() => contestSubmissions.map(submission => ({
- decision: getDecision(submission.id),
+ decision: aiReviewDecisionsBySubmissionId[submission.id],
submission,
})),
- [contestSubmissions, getDecision],
- )
-
- const toggleEditMode = useCallback((submissionId: string): void => {
- setEditingRows(prev => {
- const next = new Set(prev)
-
- if (next.has(submissionId)) {
- next.delete(submissionId)
- // Clear edit state when exiting edit mode
- setEditScores(prevScores => {
- const nextScores = { ...prevScores }
- delete nextScores[submissionId]
-
- return nextScores
- })
- } else {
- next.add(submissionId)
- }
-
- return next
- })
- }, [])
-
- const handleSaveClick = useCallback((submissionId: string, decision: AiReviewDecision): void => {
- setConfirmModal({ decision, managerComment: '', submissionId })
- }, [])
-
- const handleConfirmSave = useCallback(async (): Promise => {
- if (!confirmModal) {
- return
- }
-
- const decision: AiReviewDecision = confirmModal.decision
- const submissionId: string = confirmModal.submissionId
- const managerComment: string = confirmModal.managerComment.trim()
-
- if (!managerComment) {
- toast.error('Manager comment is required.')
- return
- }
-
- const scores: EditableScores = getScores(submissionId)
- const workflows = decision.breakdown?.workflows ?? []
-
- const payloadOverrides: WorkflowManagerOverride[] = []
-
- for (const workflow of workflows) {
- const scoreInput = scores.workflows[workflow.workflowId]?.trim() ?? ''
- let parsedScore: number | undefined
-
- if (!scoreInput) {
- parsedScore = undefined
- } else {
- parsedScore = Number(scoreInput)
-
- if (!Number.isFinite(parsedScore)) {
- const wfName = workflowNameById[workflow.workflowId] || workflow.workflowId
- toast.error(`Invalid manager score for workflow ${wfName}.`)
-
- return
- }
- }
-
- payloadOverrides.push({
- managerScore: parsedScore,
- workflowId: workflow.workflowId,
- })
- }
-
- setSavingSubmissionId(submissionId)
- setConfirmModal(undefined)
-
- try {
- const updated = await patchAiReviewDecision(decision.id, {
- managerComment: managerComment.trim() || undefined,
- workflowOverrides: payloadOverrides,
- })
-
- setLocalDecisionOverrides(prev => ({
- ...prev,
- [updated.submissionId]: updated,
- }))
- setEditScores(prev => {
- const next = { ...prev }
- delete next[submissionId]
-
- return next
- })
- setEditingRows(prev => {
- const next = new Set(prev)
- next.delete(submissionId)
-
- return next
- })
- toast.success('Changes saved successfully.')
-
- if (aiReviewConfig?.id) {
- await mutate(getAiReviewDecisionsCacheKey(aiReviewConfig.id))
- }
- } catch {
- toast.error('Failed to save changes.')
- } finally {
- setSavingSubmissionId(undefined)
- }
- }, [confirmModal, getScores, workflowNameById, aiReviewConfig?.id])
-
- const handleCancelSave = useCallback((): void => {
- setConfirmModal(undefined)
- }, [])
-
- const handleToggleClick = useCallback((submissionId: string) => (): void => {
- toggleEditMode(submissionId)
- }, [toggleEditMode])
-
- const handleSaveButtonClick = useCallback(
- (submissionId: string, decision: AiReviewDecision) => (): void => {
- handleSaveClick(submissionId, decision)
- },
- [handleSaveClick],
+ [contestSubmissions, aiReviewDecisionsBySubmissionId],
)
- const handleScoreChange = useCallback(
- (submissionId: string) => (workflowId: string, value: string): void => {
- updateScores(submissionId, workflowId, value)
+ const handleViewScorecard = useCallback(
+ (submissionId: string, workflowId?: string) => (): void => {
+ const path = workflowId
+ ? `../reviews/${submissionId}?workflowId=${workflowId}`
+ : `../reviews/${submissionId}`
+ navigate(path)
},
- [updateScores],
- )
-
- const handleCommentChange = useCallback(
- (e: ChangeEvent): void => {
- if (!confirmModal) {
- return
- }
-
- setConfirmModal(prev => {
- if (!prev) {
- return undefined
- }
-
- return { ...prev, managerComment: e.target.value }
- })
- },
- [confirmModal],
+ [navigate],
)
const columns = useMemo[]>(() => {
@@ -452,34 +226,18 @@ export const TabContentAiApproval: FC = (props: Props) => {
return -
}
- const scores = getScores(row.submission.id)
- const hasUnsavedChanges = hasScoreChanges(row.decision, scores)
- const isSaving = savingSubmissionId === row.submission.id
- const isEditing = editingRows.has(row.submission.id)
+ const workflowId = row.decision.breakdown?.workflows?.[0]?.workflowId
return (
- {isEditing ? 'Cancel' : 'Edit'}
+ View scorecard
- {hasUnsavedChanges && (
-
- {isSaving ? 'Saving...' : 'Save'}
-
- )}
)
},
@@ -491,33 +249,23 @@ export const TabContentAiApproval: FC = (props: Props) => {
columnId: 'ai-reviews-expand',
isExpand: true,
label: '',
- renderer: (row: SubmissionRowData) => {
- const isEditing = editingRows.has(row.submission.id)
- const scores = getScores(row.submission.id)
-
- return (
-
- {/* Manager comment display */}
- {row.decision?.managerComment && (
-
- Manager Comment:
- {row.decision.managerComment}
-
- )}
-
- {/* AI Reviews table with editing support */}
-
-
- )
- },
+ renderer: (row: SubmissionRowData) => (
+
+ {row.decision?.managerComment && (
+
+ Manager Comment:
+ {row.decision.managerComment}
+
+ )}
+
+
+
+ ),
type: 'element',
})
@@ -525,12 +273,7 @@ export const TabContentAiApproval: FC = (props: Props) => {
}, [
aiReviewers,
canEdit,
- editingRows,
- getScores,
- handleSaveButtonClick,
- handleScoreChange,
- handleToggleClick,
- savingSubmissionId,
+ handleViewScorecard,
])
if (props.isLoading) {
@@ -548,7 +291,7 @@ export const TabContentAiApproval: FC = (props: Props) => {
{canEdit && (
<>
{' '}
- Click Edit to modify workflow scores.
+ Click View scorecard to inspect workflow scores.
>
)}
@@ -562,52 +305,6 @@ export const TabContentAiApproval: FC = (props: Props) => {
removeDefaultSort
/>
- {confirmModal && (
-
-
- Cancel
-
-
- Save Changes
-
- >
- )}
- >
-
-
Are you sure you want to save these score changes?
-
-
- Manager Comment:
-
-
- {!confirmModal.managerComment.trim() && (
-
- Manager comment is required.
-
- )}
-
-
-
- )}
)
}
From f34676ed891916ac5e86d323d827adf846e4907c Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Mon, 8 Jun 2026 14:53:15 +0300
Subject: [PATCH 24/30] Allow score edit only during approval
---
.../ScorecardQuestion/AiFeedback/AiFeedback.tsx | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx
index ada40e760..4dcdaba76 100644
--- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx
+++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx
@@ -30,7 +30,7 @@ const AiFeedback: FC = props => {
const feedback: any = useMemo(() => (
aiFeedbackItems?.find((r: any) => r.scorecardQuestionId === props.question.id)
), [props.question.id, aiFeedbackItems])
- const { workflowId, workflowRun }: ReviewsContextModel = useReviewsContext()
+ const { workflowId, workflowRun, challengeInfo }: ReviewsContextModel = useReviewsContext()
const { isPrivilegedRole } = useRole()
const [showReply, setShowReply] = useState(false)
const [isUpdatingScore, setIsUpdatingScore] = useState(false)
@@ -38,6 +38,13 @@ const AiFeedback: FC = props => {
const [editedScore, setEditedScore] = useState('')
const [editComment, setEditComment] = useState('')
+ const isApprovalPhaseOpen = useMemo(
+ () => (challengeInfo?.phases ?? []).some(
+ (p) => (p.name || '').toLowerCase() === 'approval' && Boolean(p.isOpen),
+ ),
+ [challengeInfo?.phases],
+ )
+
const commentsArr: any[] = (feedback?.comments) || []
const onShowReply = useCallback(() => {
@@ -54,7 +61,11 @@ const AiFeedback: FC = props => {
}, [workflowId, workflowRun?.id, workflowRun?.status, feedback?.id])
const isYesNo = props.question.type === 'YES_NO'
- const hasQuestionScoreEditAccess = isPrivilegedRole && !!workflowId && !!workflowRun?.id && !!feedback?.id
+ const hasQuestionScoreEditAccess = isPrivilegedRole
+ && !!workflowId
+ && !!workflowRun?.id
+ && !!feedback?.id
+ && isApprovalPhaseOpen
const scoreOptions = useMemo(() => getScoreResponseOptions(props.question), [props.question])
From d4d2f4f8121b62d10b061c7c1798e5f89c97c573 Mon Sep 17 00:00:00 2001
From: Vasilica Olariu
Date: Mon, 8 Jun 2026 15:24:36 +0300
Subject: [PATCH 25/30] Use the richtext editor for manager comment
---
.../AiFeedback/AiFeedback.module.scss | 26 +++++--
.../AiFeedback/AiFeedback.tsx | 69 ++++++++++---------
.../AiFeedbackReply/AiFeedbackReply.tsx | 5 +-
3 files changed, 58 insertions(+), 42 deletions(-)
diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.module.scss b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.module.scss
index 32bb68c3c..010e8644c 100644
--- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.module.scss
+++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.module.scss
@@ -58,37 +58,49 @@
.feedbackEditRow {
display: flex;
align-items: center;
- gap: $sp-2;
+ gap: $sp-3;
margin-bottom: $sp-3;
+ flex-wrap: wrap;
+}
+
+.editActions {
+ display: flex;
+ align-items: center;
+ gap: $sp-2;
}
.scoreEditSelect {
- min-width: 120px;
+ min-width: 140px;
padding: $sp-2 $sp-3;
border: 1px solid var(--BorderColor);
- border-radius: 6px;
+ border-radius: 8px;
background: var(--Background);
color: var(--FontColor);
+ font-size: 14px;
+ line-height: 20px;
+ min-height: 40px;
}
.scoreEditTextarea {
width: 100%;
- min-height: 96px;
+ min-height: 112px;
margin-top: $sp-2;
padding: $sp-3;
border: 1px solid var(--BorderColor);
- border-radius: 6px;
+ border-radius: 8px;
background: var(--Background);
color: var(--FontColor);
resize: vertical;
+ font-size: 14px;
+ line-height: 22px;
}
.scoreEditTrigger {
display: inline-flex;
align-items: center;
justify-content: center;
- width: 28px;
- height: 28px;
+ width: 36px;
+ height: 36px;
margin-left: $sp-2;
padding: 0;
border: none;
diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx
index 4dcdaba76..5a419642c 100644
--- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx
+++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx
@@ -4,9 +4,11 @@ import { mutate } from 'swr'
import { IconAiReview } from '~/apps/review/src/lib/assets/icons'
import { ReviewsContextModel, ScorecardQuestion } from '~/apps/review/src/lib/models'
import { createFeedbackComment, updateRunItemScore } from '~/apps/review/src/lib/services'
+import { getAiWorkflowRunsCacheKey } from '~/apps/review/src/lib/hooks/useFetchAiWorkflowRuns'
+import { getAiReviewDecisionsCacheKey } from '~/apps/review/src/lib/services/aiReview.service'
import { useReviewsContext } from '~/apps/review/src/pages/reviews/ReviewsContext'
import { EnvironmentConfig } from '~/config'
-import { Tooltip, IconOutline } from '~/libs/ui'
+import { Tooltip, IconOutline, Button } from '~/libs/ui'
import { useRole } from '~/apps/review/src/lib/hooks'
import { handleError } from '~/libs/shared/lib/utils/handle-error'
@@ -30,13 +32,18 @@ const AiFeedback: FC = props => {
const feedback: any = useMemo(() => (
aiFeedbackItems?.find((r: any) => r.scorecardQuestionId === props.question.id)
), [props.question.id, aiFeedbackItems])
- const { workflowId, workflowRun, challengeInfo }: ReviewsContextModel = useReviewsContext()
+ const {
+ workflowId,
+ workflowRun,
+ challengeInfo,
+ submissionId,
+ aiReviewConfig,
+ }: ReviewsContextModel = useReviewsContext()
const { isPrivilegedRole } = useRole()
const [showReply, setShowReply] = useState(false)
const [isUpdatingScore, setIsUpdatingScore] = useState(false)
const [isEditingScore, setIsEditingScore] = useState(false)
const [editedScore, setEditedScore] = useState('')
- const [editComment, setEditComment] = useState('')
const isApprovalPhaseOpen = useMemo(
() => (challengeInfo?.phases ?? []).some(
@@ -77,19 +84,17 @@ const AiFeedback: FC = props => {
} else {
setEditedScore(String(feedback?.questionScore ?? ''))
}
-
- setEditComment('')
}, [feedback?.questionScore, isYesNo])
const handleScoreChange = useCallback((event: ChangeEvent) => {
setEditedScore(event.target.value)
}, [])
- const handleCommentChange = useCallback((event: ChangeEvent) => {
- setEditComment(event.target.value)
+ const handleCancelEditing = useCallback(() => {
+ setIsEditingScore(false)
}, [])
- const handleSaveScore = useCallback(async () => {
+ const handleSaveScore = useCallback(async (content: string) => {
if (!hasQuestionScoreEditAccess || isUpdatingScore) {
return
}
@@ -114,26 +119,26 @@ const AiFeedback: FC = props => {
setIsUpdatingScore(true)
const itemsKey = `${EnvironmentConfig.API.V6}/workflows/${workflowId}/runs/${workflowRun.id}/items?[${workflowRun?.status}]`
- if (!editComment.trim()) {
- handleError(new Error('A comment is required when updating the score.'))
- setIsUpdatingScore(false)
- return
- }
-
try {
await updateRunItemScore(workflowId, workflowRun.id, feedback.id, {
questionScore,
- comment: editComment.trim(),
+ comment: content.trim(),
})
await mutate(itemsKey)
+ if (submissionId) {
+ await mutate(getAiWorkflowRunsCacheKey(submissionId))
+ }
+ if (aiReviewConfig?.id) {
+ await mutate(getAiReviewDecisionsCacheKey(aiReviewConfig.id))
+ }
setIsEditingScore(false)
} catch (err) {
handleError(err)
} finally {
setIsUpdatingScore(false)
}
- }, [editComment, editedScore, feedback?.id, hasQuestionScoreEditAccess, isYesNo, isUpdatingScore, workflowId, workflowRun?.id, workflowRun?.status])
+ }, [editedScore, feedback?.id, hasQuestionScoreEditAccess, isYesNo, isUpdatingScore, workflowId, workflowRun?.id, workflowRun?.status])
if (!aiFeedbackItems?.length || !feedback) {
return <>>
@@ -180,26 +185,26 @@ const AiFeedback: FC = props => {
)}
- {hasQuestionScoreEditAccess && (
-
- {isEditingScore ? : }
-
+ {hasQuestionScoreEditAccess && !isEditingScore && (
+
+
+
)}
{isEditingScore && (
-