diff --git a/.circleci/config.yml b/.circleci/config.yml
index c2a8f9b8..a456a88e 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -9,7 +9,7 @@ parameters:
defaults: &defaults
docker:
- - image: cimg/python:3.11.11-browsers
+ - image: cimg/python:3.12.12-browsers
test_defaults: &test_defaults
docker:
diff --git a/.gitignore b/.gitignore
index 53692ce2..6b18187d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ node_modules
# production
/build
+/dist
# misc
.DS_Store
@@ -32,4 +33,6 @@ yarn-error.log*
# e2e test case
test-automation/temp
-test-automation/test-results
\ No newline at end of file
+test-automation/test-results
+
+dist
\ No newline at end of file
diff --git a/config/constants/development.js b/config/constants/development.js
index 487ef24b..af3244d6 100644
--- a/config/constants/development.js
+++ b/config/constants/development.js
@@ -20,6 +20,8 @@ module.exports = {
ENGAGEMENTS_ROOT_API_URL: `${DEV_API_HOSTNAME}/v6/engagements`,
APPLICATIONS_API_URL: `${DEV_API_HOSTNAME}/v6/engagements/applications`,
TC_FINANCE_API_URL: process.env.TC_FINANCE_API_URL || `${API_V6}/finance`,
+ TC_AI_API_BASE_URL: process.env.TC_AI_API_BASE_URL || `${API_V6}/ai`,
+ TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID: process.env.TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID || 'skillExtractionWorkflow',
CHALLENGE_DEFAULT_REVIEWERS_URL: `${DEV_API_HOSTNAME}/v6/challenge/default-reviewers`,
CHALLENGE_API_VERSION: '1.1.0',
CHALLENGE_TIMELINE_TEMPLATES_URL: `${DEV_API_HOSTNAME}/v6/timeline-templates`,
diff --git a/config/constants/production.js b/config/constants/production.js
index 425d9b46..c4f69daf 100644
--- a/config/constants/production.js
+++ b/config/constants/production.js
@@ -19,6 +19,8 @@ module.exports = {
ENGAGEMENTS_ROOT_API_URL: `${PROD_API_HOSTNAME}/v6/engagements`,
APPLICATIONS_API_URL: `${PROD_API_HOSTNAME}/v6/engagements/applications`,
TC_FINANCE_API_URL: process.env.TC_FINANCE_API_URL || `${API_V6}/finance`,
+ TC_AI_API_BASE_URL: process.env.TC_AI_API_BASE_URL || `${API_V6}/ai`,
+ TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID: process.env.TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID || 'skillExtractionWorkflow',
CHALLENGE_DEFAULT_REVIEWERS_URL: `${PROD_API_HOSTNAME}/v6/challenge/default-reviewers`,
CHALLENGE_API_VERSION: '1.1.0',
CHALLENGE_TIMELINE_TEMPLATES_URL: `${PROD_API_HOSTNAME}/v6/timeline-templates`,
diff --git a/config/webpack.config.js b/config/webpack.config.js
index d47d193f..5fe44c80 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -37,7 +37,6 @@ module.exports = function (webpackEnv) {
const isEnvDevelopment = webpackEnv === 'development'
const isEnvProduction = webpackEnv === 'production'
const WM_DEBUG = /^(1|true|on|yes)$/i.test(String(process.env.WM_DEBUG || ''))
- const reactDevUtilsContextRegExp = /[\\/]react-dev-utils[\\/]/
// Webpack uses `publicPath` to determine where the app is being served from.
// It requires a trailing slash, or the file assets will get an incorrect path.
@@ -150,7 +149,7 @@ module.exports = function (webpackEnv) {
// require.resolve('webpack-dev-server/client') + '?/',
// require.resolve('webpack/hot/dev-server'),
isEnvDevelopment &&
- require.resolve('react-dev-utils/webpackHotDevClient'),
+ path.resolve(__dirname, 'webpackHotDevClient'),
// Finally, this is your app's code:
paths.appIndexJs
// We include the app code last so that if there is a runtime error during
@@ -485,13 +484,6 @@ module.exports = function (webpackEnv) {
// This gives some necessary context to module not found errors, such as
// the requesting resource.
new ModuleNotFoundPlugin(paths.appPath),
- // Ensure the dev client tolerates webpack 5 warning/error objects.
- isEnvDevelopment &&
- new webpack.NormalModuleReplacementPlugin(/\.\/formatWebpackMessages$/, (resource) => {
- if (reactDevUtilsContextRegExp.test(resource.context || '')) {
- resource.request = path.resolve(__dirname, 'formatWebpackMessages')
- }
- }),
// (DefinePlugin already added above with merged env)
// This is necessary to emit hot updates (currently CSS only):
isEnvDevelopment && new webpack.HotModuleReplacementPlugin(),
diff --git a/config/webpackHotDevClient.js b/config/webpackHotDevClient.js
new file mode 100644
index 00000000..a037d3c7
--- /dev/null
+++ b/config/webpackHotDevClient.js
@@ -0,0 +1,18 @@
+'use strict'
+
+var patchedFormatWebpackMessages = require('./formatWebpackMessages')
+var originalFormatWebpackMessages = require('react-dev-utils/formatWebpackMessages')
+
+// webpackHotDevClient requires react-dev-utils/formatWebpackMessages internally.
+// Replace that cached module export before loading the hot client so warnings
+// and errors can be normalized for webpack 5 object payloads.
+if (typeof __webpack_require__ === 'function' && __webpack_require__.c) {
+ Object.keys(__webpack_require__.c).forEach(function(id) {
+ var cachedModule = __webpack_require__.c[id]
+ if (cachedModule && cachedModule.exports === originalFormatWebpackMessages) {
+ cachedModule.exports = patchedFormatWebpackMessages
+ }
+ })
+}
+
+require('react-dev-utils/webpackHotDevClient')
diff --git a/docker/Dockerfile b/docker/Dockerfile
index a74ae735..9b2a260d 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,5 +1,5 @@
# Use Node.js 22 base image
-FROM node:22
+FROM node:22.22.0
RUN useradd -m -s /bin/bash appuser
ARG NODE_ENV
ARG BABEL_ENV
diff --git a/docs/dev.env b/docs/dev.env
deleted file mode 100644
index df09d75b..00000000
--- a/docs/dev.env
+++ /dev/null
@@ -1 +0,0 @@
-PORT=3001
diff --git a/src/actions/engagements.js b/src/actions/engagements.js
index 0cddf1fb..34ebbdee 100644
--- a/src/actions/engagements.js
+++ b/src/actions/engagements.js
@@ -7,6 +7,7 @@ import {
patchEngagement,
deleteEngagement as deleteEngagementAPI
} from '../services/engagements'
+import { fetchProjectById } from '../services/projects'
import { fetchSkillsByIds } from '../services/skills'
import {
normalizeEngagement,
@@ -33,6 +34,8 @@ import {
DELETE_ENGAGEMENT_FAILURE
} from '../config/constants'
+const projectNameCache = {}
+
const getSkillId = (skill) => {
if (!skill) {
return null
@@ -93,6 +96,70 @@ const withSkillDetails = (engagement, skillsMap) => {
}
}
+const getProjectId = (engagement) => {
+ if (!engagement || !engagement.projectId) {
+ return null
+ }
+ return String(engagement.projectId)
+}
+
+const getProjectName = (project) => {
+ if (!project || typeof project !== 'object') {
+ return null
+ }
+ if (typeof project.name === 'string' && project.name.trim()) {
+ return project.name
+ }
+ if (typeof project.projectName === 'string' && project.projectName.trim()) {
+ return project.projectName
+ }
+ return null
+}
+
+const hydrateEngagementProjectNames = async (engagements = []) => {
+ if (!Array.isArray(engagements) || !engagements.length) {
+ return []
+ }
+
+ const projectIds = Array.from(new Set(
+ engagements
+ .map(getProjectId)
+ .filter(Boolean)
+ ))
+
+ if (!projectIds.length) {
+ return engagements
+ }
+
+ const uncachedProjectIds = projectIds.filter((projectId) => !projectNameCache[projectId])
+ if (uncachedProjectIds.length) {
+ const projectNameEntries = await Promise.all(
+ uncachedProjectIds.map(async (projectId) => {
+ try {
+ const project = await fetchProjectById(projectId)
+ return [projectId, getProjectName(project)]
+ } catch (error) {
+ return [projectId, null]
+ }
+ })
+ )
+
+ projectNameEntries.forEach(([projectId, projectName]) => {
+ if (projectName) {
+ projectNameCache[projectId] = projectName
+ }
+ })
+ }
+
+ return engagements.map((engagement) => {
+ const projectId = getProjectId(engagement)
+ return {
+ ...engagement,
+ projectName: (projectId && projectNameCache[projectId]) || engagement.projectName || null
+ }
+ })
+}
+
const hydrateEngagementSkills = async (engagements = []) => {
if (!Array.isArray(engagements) || !engagements.length) {
return []
@@ -206,7 +273,8 @@ export function loadEngagements (projectId, status = 'all', filterName = '', inc
} while (!totalPages || page <= totalPages)
const hydratedEngagements = await hydrateEngagementSkills(engagements)
- const normalizedEngagements = normalizeEngagements(hydratedEngagements)
+ const engagementsWithProjectNames = await hydrateEngagementProjectNames(hydratedEngagements)
+ const normalizedEngagements = normalizeEngagements(engagementsWithProjectNames)
dispatch({
type: LOAD_ENGAGEMENTS_SUCCESS,
engagements: normalizedEngagements
diff --git a/src/components/ApplicationsList/index.js b/src/components/ApplicationsList/index.js
index ff2437eb..15ed98c5 100644
--- a/src/components/ApplicationsList/index.js
+++ b/src/components/ApplicationsList/index.js
@@ -12,6 +12,7 @@ import DateInput from '../DateInput'
import Handle from '../Handle'
import styles from './ApplicationsList.module.scss'
import { PROFILE_URL } from '../../config/constants'
+import { serializeTentativeAssignmentDate } from '../../util/assignmentDates'
const STATUS_OPTIONS = [
{ label: 'All', value: 'all' },
@@ -99,6 +100,21 @@ const getApplicationName = (application) => {
return fullName || application.name || application.email || null
}
+const getApplicationMobileNumber = (application) => {
+ if (!application) {
+ return null
+ }
+
+ const value = [
+ application.mobileNumber,
+ application.mobile_number,
+ application.phoneNumber,
+ application.phone
+ ].find((phoneNumber) => phoneNumber != null && `${phoneNumber}`.trim() !== '')
+
+ return value ? `${value}`.trim() : null
+}
+
const getApplicationRating = (application) => {
if (!application) {
return undefined
@@ -303,9 +319,11 @@ const ApplicationsList = ({
setIsAccepting(true)
try {
+ const startDate = serializeTentativeAssignmentDate(parsedStart)
+ const endDate = serializeTentativeAssignmentDate(parsedEnd)
await onUpdateStatus(acceptApplication.id, 'SELECTED', {
- startDate: parsedStart.toISOString(),
- endDate: parsedEnd.toISOString(),
+ startDate,
+ endDate,
agreementRate: normalizedRate,
...(normalizedOtherRemarks ? { otherRemarks: normalizedOtherRemarks } : {})
})
@@ -362,6 +380,7 @@ const ApplicationsList = ({
value={acceptStartDate}
dateFormat={INPUT_DATE_FORMAT}
timeFormat={INPUT_TIME_FORMAT}
+ preventViewportOverflow
minDateTime={getMinStartDateTime}
isValidDate={isAcceptStartDateValid}
onChange={(value) => {
@@ -385,6 +404,7 @@ const ApplicationsList = ({
value={acceptEndDate}
dateFormat={INPUT_DATE_FORMAT}
timeFormat={INPUT_TIME_FORMAT}
+ preventViewportOverflow
minDateTime={getMinEndDateTime}
isValidDate={isAcceptEndDateValid}
onChange={(value) => {
@@ -500,7 +520,7 @@ const ApplicationsList = ({
Email |
Applied Date |
Years of Experience |
- Availability |
+ Phone Number |
Status |
Actions |
@@ -541,7 +561,7 @@ const ApplicationsList = ({
{application.email || '-'} |
{formatDateTime(application.createdAt)} |
{application.yearsOfExperience != null ? application.yearsOfExperience : '-'} |
- {application.availability || '-'} |
+ {getApplicationMobileNumber(application) || '-'} |
{statusLabel}
diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js
index 661ba2f1..0de3fa0b 100644
--- a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js
+++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js
@@ -93,6 +93,10 @@ class ChallengeReviewerField extends Component {
)
}
+ isPublicOpportunityOpen (reviewer) {
+ return reviewer && reviewer.shouldOpenOpportunity === true
+ }
+
getMissingRequiredPhases () {
const { challenge } = this.props
// Marathon Match does not require review configuration
@@ -747,7 +751,8 @@ class ChallengeReviewerField extends Component {
fixedAmount: currentReviewer.fixedAmount || 0,
baseCoefficient: currentReviewer.baseCoefficient || '0',
incrementalCoefficient: currentReviewer.incrementalCoefficient || 0,
- type: isAI ? undefined : (currentReviewer.type || REVIEW_OPPORTUNITY_TYPES.REGULAR_REVIEW)
+ type: isAI ? undefined : (currentReviewer.type || REVIEW_OPPORTUNITY_TYPES.REGULAR_REVIEW),
+ shouldOpenOpportunity: isAI ? undefined : false
}
if (isAI) {
@@ -972,7 +977,7 @@ class ChallengeReviewerField extends Component {
{
const next = !!e.target.checked
this.handleToggleShouldOpen(index, next)
@@ -987,8 +992,8 @@ class ChallengeReviewerField extends Component {
)}
- {/* Assignment controls when public opportunity is OFF */}
- {!this.isAIReviewer(reviewer) && (reviewer.shouldOpenOpportunity === false) && (
+ {/* Design challenges do not expose public opportunity toggles, so always allow member assignment there. */}
+ {!this.isAIReviewer(reviewer) && (isDesignChallenge || !this.isPublicOpportunityOpen(reviewer)) && (
diff --git a/src/components/ChallengeEditor/Resources/index.js b/src/components/ChallengeEditor/Resources/index.js
index 67ee0afa..8e1f44f9 100644
--- a/src/components/ChallengeEditor/Resources/index.js
+++ b/src/components/ChallengeEditor/Resources/index.js
@@ -461,9 +461,11 @@ export default class Resources extends React.Component {
{sortedResources.map(r => {
return (
- |
- {r.role}
- |
+ {!isDesign && (
+
+ {r.role}
+ |
+ )}
{
searchSkills(inputValue).then(
@@ -22,6 +26,8 @@ const fetchSkills = _.debounce((inputValue, callback) => {
}, AUTOCOMPLETE_DEBOUNCE_TIME_MS)
const SkillsField = ({ readOnly, challenge, onUpdateSkills, embedded }) => {
+ const [isLoadingAI, setIsLoadingAI] = useState(false)
+
const selectedSkills = useMemo(() => (challenge.skills || []).map(skill => ({
label: skill.name,
value: skill.id
@@ -32,29 +38,85 @@ const SkillsField = ({ readOnly, challenge, onUpdateSkills, embedded }) => {
const skillsRequired = normalizedBillingAccountId ? !SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS.includes(normalizedBillingAccountId) : true
const showRequiredError = !readOnly && skillsRequired && challenge.submitTriggered && (!selectedSkills || !selectedSkills.length)
+ // Check if description exists to show AI button
+ const hasDescription = challenge.description && challenge.description.trim().length > 0
+
+ const handleAISuggest = async () => {
+ if (!hasDescription || isLoadingAI) {
+ return
+ }
+
+ setIsLoadingAI(true)
+ try {
+ const result = await extractSkillsFromText(challenge.description)
+ const matches = result.matches || []
+
+ if (matches.length === 0) {
+ toastFailure('No Skills Found', 'No matching standardized skills found based on the description.')
+ } else {
+ // Merge with existing skills, avoiding duplicates
+ const existingSkillIds = new Set((challenge.skills || []).map(s => s.id))
+ const newSkills = matches.filter(skill => !existingSkillIds.has(skill.id))
+
+ if (newSkills.length === 0) {
+ toastSuccess('Skills Already Added', 'All suggested skills are already in your selection.')
+ } else {
+ const updatedSkills = [...(challenge.skills || []), ...newSkills]
+ onUpdateSkills(updatedSkills)
+ toastSuccess('Skills Added', `${newSkills.length} skill(s) were added from AI suggestions.`)
+ }
+ }
+ } catch (error) {
+ console.error('AI skill extraction error:', error)
+ toastFailure('Error', 'Failed to extract skills. Please try again or add skills manually.')
+ } finally {
+ setIsLoadingAI(false)
+ }
+ }
+
if (embedded) {
return (
- {readOnly ? (
- {existingSkills || '-'}
- ) : (
-
- {readOnly ? (
- {existingSkills}
- ) : (
- {
- onUpdateSkills((values || []).map(value => ({
- name: value.label,
- id: value.value
- })))
- }}
- cacheOptions
- loadOptions={fetchSkills}
- />
- )}
+
+ {isLoadingAI && (
+
+
+ Generating skill suggestions...
+
+ )}
+ {readOnly ? (
+ {existingSkills}
+ ) : (
+ <>
+ {
+ onUpdateSkills((values || []).map(value => ({
+ name: value.label,
+ id: value.value
+ })))
+ }}
+ cacheOptions
+ loadOptions={fetchSkills}
+ isDisabled={isLoadingAI}
+ />
+ {hasDescription && (
+
+ )}
+ >
+ )}
+
diff --git a/src/components/ChallengeEditor/SkillsField/styles.module.scss b/src/components/ChallengeEditor/SkillsField/styles.module.scss
index 77f69436..b57b965c 100644
--- a/src/components/ChallengeEditor/SkillsField/styles.module.scss
+++ b/src/components/ChallengeEditor/SkillsField/styles.module.scss
@@ -51,12 +51,54 @@
}
}
+.skillsFieldWrapper {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ width: 100%;
+}
+
+.aiSuggestButton {
+ align-self: flex-start;
+ min-width: 120px;
+}
+
+.loadingOverlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(255, 255, 255, 0.9);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ z-index: 10;
+ border-radius: 4px;
+ gap: 12px;
+}
+
+.loadingText {
+ font-size: 14px;
+ color: $tc-gray-80;
+ font-weight: 500;
+}
+
.embeddedWrapper {
display: flex;
flex-direction: column;
width: 100%;
}
+.embeddedContent {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
.embeddedError {
color: $tc-red;
font-size: 12px;
diff --git a/src/components/ChallengeEditor/index.js b/src/components/ChallengeEditor/index.js
index f5c764ca..88297520 100644
--- a/src/components/ChallengeEditor/index.js
+++ b/src/components/ChallengeEditor/index.js
@@ -243,6 +243,14 @@ class ChallengeEditor extends Component {
const isOpenAdvanceSettings = challengeDetail.groups.length > 0 || isRequiredNda
if (!challengeDetail.reviewers) {
challengeDetail.reviewers = []
+ } else if (challengeDetail.trackId === DES_TRACK_ID) {
+ // Design challenges do not expose public review opportunities in UI.
+ // Normalize member reviewers so assignment controls remain available.
+ challengeDetail.reviewers = challengeDetail.reviewers.map(reviewer => (
+ reviewer && reviewer.isMemberReview !== false
+ ? { ...reviewer, shouldOpenOpportunity: false }
+ : reviewer
+ ))
}
setState({
challenge: challengeDetail,
diff --git a/src/components/DateInput/index.js b/src/components/DateInput/index.js
index 9efc5a22..3d2aa059 100644
--- a/src/components/DateInput/index.js
+++ b/src/components/DateInput/index.js
@@ -5,6 +5,9 @@ import 'rc-time-picker/assets/index.css'
import DateTime from '@nateradebaugh/react-datetime'
import '@nateradebaugh/react-datetime/scss/styles.scss'
+const VIEWPORT_PADDING = 8
+const MAX_POPOVER_ADJUST_ATTEMPTS = 8
+
const DateInput = forwardRef(({
onChange,
value,
@@ -13,7 +16,8 @@ const DateInput = forwardRef(({
timeFormat,
className,
minDateTime,
- inputId
+ inputId,
+ preventViewportOverflow
}, ref) => {
const [localValue, setLocalValue] = useState(value)
const latestValueRef = useRef(value)
@@ -61,6 +65,52 @@ const DateInput = forwardRef(({
return valueAsDate.getTime() < minAsDate.getTime() ? minAsDate : valueAsDate
}
+ const adjustPopoverToViewport = () => {
+ if (!preventViewportOverflow || typeof window === 'undefined' || typeof document === 'undefined') {
+ return
+ }
+
+ let attempts = 0
+ const adjust = () => {
+ attempts += 1
+ const popovers = Array.from(document.querySelectorAll('[data-reach-popover]'))
+ .filter(popover => popover.querySelector('.rdtPicker'))
+
+ if (popovers.length === 0) {
+ if (attempts < MAX_POPOVER_ADJUST_ATTEMPTS) {
+ window.requestAnimationFrame(adjust)
+ }
+ return
+ }
+
+ const popover = popovers[popovers.length - 1]
+ popover.style.transform = ''
+ popover.style.maxHeight = ''
+ popover.style.overflowY = ''
+
+ const initialRect = popover.getBoundingClientRect()
+ const maxBottom = window.innerHeight - VIEWPORT_PADDING
+
+ if (initialRect.bottom > maxBottom) {
+ const overflow = initialRect.bottom - maxBottom
+ const maxShift = Math.max(0, initialRect.top - VIEWPORT_PADDING)
+ const shift = Math.min(overflow, maxShift)
+ if (shift > 0) {
+ popover.style.transform = `translateY(-${Math.ceil(shift)}px)`
+ }
+ }
+
+ const adjustedRect = popover.getBoundingClientRect()
+ const maxHeight = Math.floor(window.innerHeight - VIEWPORT_PADDING - Math.max(adjustedRect.top, VIEWPORT_PADDING))
+ if (maxHeight > 0 && adjustedRect.height > maxHeight) {
+ popover.style.maxHeight = `${maxHeight}px`
+ popover.style.overflowY = 'auto'
+ }
+ }
+
+ window.requestAnimationFrame(adjust)
+ }
+
return (
+
-
+
{
return null
}
-const getTermsAccepted = (member) => {
+const getAssignmentRemarks = (member) => {
if (!member || typeof member !== 'object') {
- return null
+ return ''
}
- const value = member.termsAccepted != null
- ? member.termsAccepted
- : member.terms_accepted
+ const value = member.otherRemarks != null
+ ? member.otherRemarks
+ : member.other_remarks != null
+ ? member.other_remarks
+ : member.remarks
if (value == null) {
- return null
- }
- if (typeof value === 'boolean') {
- return value
- }
- if (typeof value === 'number') {
- return value !== 0
+ return ''
}
if (typeof value === 'string') {
- const normalized = value.trim().toLowerCase()
- if (['true', 'yes', '1'].includes(normalized)) {
- return true
- }
- if (['false', 'no', '0'].includes(normalized)) {
- return false
- }
+ return value.trim()
+ }
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return String(value)
}
- return Boolean(value)
+ return ''
}
const EngagementPayment = ({
engagement,
+ projectName,
assignedMembers,
isLoading,
isPaymentProcessing,
paymentsByAssignment,
terminatingAssignments,
+ completingAssignments,
projectId,
engagementId,
showPaymentModal,
@@ -198,9 +191,11 @@ const EngagementPayment = ({
onOpenPaymentModal,
onClosePaymentModal,
onSubmitPayment,
- onTerminateAssignment
+ onTerminateAssignment,
+ onCompleteAssignment
}) => {
const [paymentHistoryMember, setPaymentHistoryMember] = useState(null)
+ const [completionMember, setCompletionMember] = useState(null)
const [terminationMember, setTerminationMember] = useState(null)
const [terminationReason, setTerminationReason] = useState('')
if (isLoading) {
@@ -237,11 +232,29 @@ const EngagementPayment = ({
setTerminationReason('')
}
+ const closeCompletionModal = () => {
+ setCompletionMember(null)
+ }
+
+ const openCompletionModal = (member) => {
+ setCompletionMember(member)
+ }
+
const openTerminationModal = (member) => {
setTerminationMember(member)
setTerminationReason('')
}
+ const submitCompletion = async () => {
+ if (!completionMember) {
+ return
+ }
+ const wasSuccessful = await onCompleteAssignment(completionMember)
+ if (wasSuccessful) {
+ closeCompletionModal()
+ }
+ }
+
const submitTermination = async () => {
if (!terminationMember) {
return
@@ -330,6 +343,15 @@ const EngagementPayment = ({
? Boolean(terminatingAssignments[terminationAssignmentId])
: false
const isTerminationReasonValid = Boolean(terminationReason.trim())
+ const completionAssignmentId = completionMember && completionMember.assignmentId != null
+ ? String(completionMember.assignmentId)
+ : null
+ const completionHandle = completionMember
+ ? (completionMember.handle || completionMember.memberHandle || '-')
+ : '-'
+ const isCompletionProcessing = completionAssignmentId && completingAssignments
+ ? Boolean(completingAssignments[completionAssignmentId])
+ : false
return (
@@ -369,7 +391,10 @@ const EngagementPayment = ({
const isRowTerminating = assignmentKey && terminatingAssignments
? Boolean(terminatingAssignments[assignmentKey])
: false
- const termsAccepted = getTermsAccepted(member)
+ const isRowCompleting = assignmentKey && completingAssignments
+ ? Boolean(completingAssignments[assignmentKey])
+ : false
+ const assignmentRemarks = getAssignmentRemarks(member)
const assignmentRate = getAssignmentRate(member)
const startDate = formatDate(getAssignmentDate(member, 'start'))
const endDate = formatDate(getAssignmentDate(member, 'end'))
@@ -398,15 +423,9 @@ const EngagementPayment = ({
)}
- Terms Accepted
+ Remarks
- {termsAccepted ? (
-
-
-
- ) : (
- '-'
- )}
+ {assignmentRemarks || '-'}
@@ -414,11 +433,11 @@ const EngagementPayment = ({
{rateDisplay}
- Start
+ Tentative Start
{startDate}
- End
+ Tentative End
{endDate}
@@ -442,12 +461,19 @@ const EngagementPayment = ({
onClick={() => onOpenPaymentModal(member)}
disabled={!canPay}
/>
+ openCompletionModal(member)}
+ disabled={!hasAssignmentId || isRowTerminating || isRowCompleting}
+ />
openTerminationModal(member)}
- disabled={!hasAssignmentId || isRowTerminating}
+ disabled={!hasAssignmentId || isRowTerminating || isRowCompleting}
/>
>
)}
@@ -460,6 +486,31 @@ const EngagementPayment = ({
) : (
No assigned members found.
)}
+ {completionMember && (
+
+
+ Complete Assignment
+
+ {`Are you sure you want to mark the assignment for ${completionHandle} as completed on this engagement?`}
+
+
+
+
+ )}
{terminationMember && (
@@ -518,6 +569,7 @@ const EngagementPayment = ({
{},
onClosePaymentModal: () => {},
onSubmitPayment: () => {},
- onTerminateAssignment: () => {}
+ onTerminateAssignment: () => {},
+ onCompleteAssignment: () => {}
}
EngagementPayment.propTypes = {
@@ -552,6 +607,7 @@ EngagementPayment.propTypes = {
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
title: PropTypes.string
}),
+ projectName: PropTypes.string,
assignedMembers: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
assignmentId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
@@ -573,6 +629,7 @@ EngagementPayment.propTypes = {
error: PropTypes.string
})),
terminatingAssignments: PropTypes.objectOf(PropTypes.bool),
+ completingAssignments: PropTypes.objectOf(PropTypes.bool),
projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
engagementId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
showPaymentModal: PropTypes.bool,
@@ -591,7 +648,8 @@ EngagementPayment.propTypes = {
onOpenPaymentModal: PropTypes.func,
onClosePaymentModal: PropTypes.func,
onSubmitPayment: PropTypes.func,
- onTerminateAssignment: PropTypes.func
+ onTerminateAssignment: PropTypes.func,
+ onCompleteAssignment: PropTypes.func
}
export default EngagementPayment
diff --git a/src/components/EngagementsList/EngagementsList.module.scss b/src/components/EngagementsList/EngagementsList.module.scss
index c62388aa..c8fcea3b 100644
--- a/src/components/EngagementsList/EngagementsList.module.scss
+++ b/src/components/EngagementsList/EngagementsList.module.scss
@@ -60,6 +60,19 @@
min-width: 0;
}
+.filterItem {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.filterLabel {
+ color: $tc-gray-70;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1;
+}
+
.filterInput {
width: 100%;
min-width: 0;
@@ -145,6 +158,10 @@
}
}
+.projectLink {
+ @extend .applicationsLink;
+}
+
.assignedMembersTooltip {
display: flex;
flex-direction: column;
@@ -164,8 +181,8 @@
.filters {
display: grid;
- grid-template-columns: minmax(240px, 2fr) repeat(3, minmax(160px, 1fr));
- align-items: center;
+ grid-template-columns: minmax(240px, 2fr) repeat(2, minmax(160px, 1fr));
+ align-items: end;
}
.filterSearch,
diff --git a/src/components/EngagementsList/index.js b/src/components/EngagementsList/index.js
index ff6cddce..84dde97e 100644
--- a/src/components/EngagementsList/index.js
+++ b/src/components/EngagementsList/index.js
@@ -1,6 +1,5 @@
import React, { useMemo, useState } from 'react'
import PropTypes from 'prop-types'
-import moment from 'moment-timezone'
import { Link } from 'react-router-dom'
import { PrimaryButton, OutlineButton } from '../Buttons'
import Tooltip from '../Tooltip'
@@ -8,7 +7,6 @@ import Loader from '../Loader'
import Select from '../Select'
import { ENGAGEMENTS_APP_URL } from '../../config/constants'
import { getCountableAssignments } from '../../util/engagements'
-import { formatTimeZoneList } from '../../util/timezones'
import styles from './EngagementsList.module.scss'
const STATUS_OPTIONS = [
@@ -19,120 +17,15 @@ const STATUS_OPTIONS = [
{ label: 'Closed', value: 'Closed' }
]
-const SORT_OPTIONS = [
- { label: 'Anticipated Start', value: 'anticipatedStart' },
- { label: 'Created Date', value: 'createdAt' }
-]
-
-const SORT_ORDER_OPTIONS = [
- { label: 'Ascending', value: 'asc' },
- { label: 'Descending', value: 'desc' }
+const VISIBILITY_OPTIONS = [
+ { label: 'All', value: 'all' },
+ { label: 'Public', value: 'public' },
+ { label: 'Private', value: 'private' }
]
const DEFAULT_STATUS_OPTION = STATUS_OPTIONS.find((option) => option.value === 'Open') || STATUS_OPTIONS[0]
-
-const ANTICIPATED_START_LABELS = {
- IMMEDIATE: 'Immediate',
- FEW_DAYS: 'In a few days',
- FEW_WEEKS: 'In a few weeks'
-}
-
-const ANTICIPATED_START_ORDER = {
- Immediate: 1,
- 'In a few days': 2,
- 'In a few weeks': 3,
- ...Object.keys(ANTICIPATED_START_LABELS).reduce((acc, key, index) => {
- acc[key] = index + 1
- return acc
- }, {})
-}
-
-const formatDate = (value) => {
- if (!value) {
- return '-'
- }
- return moment(value).format('MMM DD, YYYY')
-}
-
-const formatAnticipatedStart = (value) => {
- if (!value) {
- return '-'
- }
- return ANTICIPATED_START_LABELS[value] || value
-}
-
-const getSortValue = (engagement, sortBy) => {
- if (sortBy === 'anticipatedStart') {
- const anticipatedStart = engagement.anticipatedStart || engagement.anticipated_start || null
- if (!anticipatedStart) {
- return null
- }
- return ANTICIPATED_START_ORDER[anticipatedStart] || 0
- }
- return engagement.createdAt || engagement.createdOn || engagement.created || null
-}
-
-const getSortComparable = (value) => {
- if (value == null) {
- return null
- }
- if (typeof value === 'number') {
- return value
- }
- const parsed = new Date(value).getTime()
- return Number.isNaN(parsed) ? null : parsed
-}
-
-const getDurationLabel = (engagement) => {
- if (!engagement) {
- return '-'
- }
- const hasDateRange = engagement.startDate && engagement.endDate
- if (hasDateRange) {
- return `${formatDate(engagement.startDate)} - ${formatDate(engagement.endDate)}`
- }
- if (engagement.duration && engagement.duration.amount) {
- return `${engagement.duration.amount} ${engagement.duration.unit}`
- }
- if (engagement.durationAmount) {
- return `${engagement.durationAmount} ${engagement.durationUnit || ''}`.trim()
- }
- return '-'
-}
-
-const getLocationLabel = (engagement) => {
- if (!engagement) {
- return '-'
- }
- const normalizeValues = (values) => (
- Array.isArray(values)
- ? values.map(value => String(value).trim()).filter(Boolean)
- : []
- )
- const isAnyValue = (value) => value.toLowerCase() === 'any'
-
- const rawTimezones = normalizeValues(engagement.timezones)
- const rawCountries = normalizeValues(engagement.countries)
- const hasAnyLocation = rawTimezones.some(isAnyValue) || rawCountries.some(isAnyValue)
- const filteredTimezones = rawTimezones.filter(value => !isAnyValue(value))
- const filteredCountries = rawCountries.filter(value => !isAnyValue(value))
-
- const timezones = formatTimeZoneList(filteredTimezones, '')
- const countryLabel = hasAnyLocation || (!filteredCountries.length && !timezones)
- ? 'Remote'
- : (filteredCountries.length ? filteredCountries.join(', ') : '')
-
- if (timezones && countryLabel) {
- return `${timezones} / ${countryLabel}`
- }
- if (timezones) {
- return timezones
- }
- if (countryLabel) {
- return countryLabel
- }
- return 'Remote'
-}
+const ALL_STATUS_OPTION = STATUS_OPTIONS.find((option) => option.value === 'all') || STATUS_OPTIONS[0]
+const DEFAULT_VISIBILITY_OPTION = VISIBILITY_OPTIONS[0]
const getStatusClass = (status) => {
if (status === 'Open') {
@@ -226,38 +119,63 @@ const getAssignedMemberHandles = (engagement) => {
return []
}
+const getEngagementProjectId = (engagement, fallbackProjectId = null) => {
+ if (engagement && engagement.projectId) {
+ return engagement.projectId
+ }
+ return fallbackProjectId
+}
+
+const getEngagementProjectName = (engagement, fallbackProjectName = null) => {
+ if (engagement && engagement.projectName) {
+ return engagement.projectName
+ }
+ if (engagement && engagement.project && engagement.project.name) {
+ return engagement.project.name
+ }
+ if (fallbackProjectName) {
+ return fallbackProjectName
+ }
+ return null
+}
+
const EngagementsList = ({
engagements,
projectId,
projectDetail,
+ allEngagements,
isLoading,
canManage
}) => {
- const [searchText, setSearchText] = useState('')
- const [statusFilter, setStatusFilter] = useState(DEFAULT_STATUS_OPTION)
- const [sortBy, setSortBy] = useState(SORT_OPTIONS[0])
- const [sortOrder, setSortOrder] = useState(SORT_ORDER_OPTIONS[0])
+ const [searchProjectName, setSearchProjectName] = useState('')
+ const [statusFilter, setStatusFilter] = useState(allEngagements ? ALL_STATUS_OPTION : DEFAULT_STATUS_OPTION)
+ const [visibilityFilter, setVisibilityFilter] = useState(DEFAULT_VISIBILITY_OPTION)
const filteredOpportunities = useMemo(() => {
+ const fallbackProjectName = !allEngagements && projectDetail && projectDetail.name
+ ? projectDetail.name
+ : null
let results = engagements || []
+
if (statusFilter && statusFilter.value !== 'all') {
results = results.filter(engagement => (engagement.status || '') === statusFilter.value)
}
- if (searchText.trim()) {
- const query = searchText.trim().toLowerCase()
- results = results.filter(engagement => (engagement.title || '').toLowerCase().includes(query))
+
+ if (visibilityFilter && visibilityFilter.value !== 'all') {
+ const isPrivate = visibilityFilter.value === 'private'
+ results = results.filter(engagement => Boolean(engagement.isPrivate) === isPrivate)
+ }
+
+ if (searchProjectName.trim()) {
+ const query = searchProjectName.trim().toLowerCase()
+ results = results.filter((engagement) => {
+ const projectName = getEngagementProjectName(engagement, fallbackProjectName) || ''
+ return projectName.toLowerCase().includes(query)
+ })
}
- const sorted = [...results].sort((a, b) => {
- const valueA = getSortValue(a, sortBy.value)
- const valueB = getSortValue(b, sortBy.value)
- const comparableA = getSortComparable(valueA)
- const comparableB = getSortComparable(valueB)
- const normalizedA = comparableA == null ? 0 : comparableA
- const normalizedB = comparableB == null ? 0 : comparableB
- return sortOrder.value === 'asc' ? normalizedA - normalizedB : normalizedB - normalizedA
- })
- return sorted
- }, [engagements, statusFilter, searchText, sortBy, sortOrder])
+
+ return results
+ }, [engagements, statusFilter, visibilityFilter, searchProjectName, projectDetail, allEngagements])
if (isLoading) {
return
@@ -267,9 +185,11 @@ const EngagementsList = ({
- {projectDetail && projectDetail.name ? `${projectDetail.name} Engagements` : 'Engagements'}
+ {allEngagements
+ ? 'All Engagements'
+ : (projectDetail && projectDetail.name ? `${projectDetail.name} Engagements` : 'Engagements')}
- {canManage && (
+ {canManage && projectId && (
setSearchText(event.target.value)}
- placeholder='Search by title'
+ value={searchProjectName}
+ onChange={(event) => setSearchProjectName(event.target.value)}
+ placeholder='Search by project name'
/>
+
setStatusFilter(option || STATUS_OPTIONS[0])}
@@ -298,18 +222,14 @@ const EngagementsList = ({
/>
+
setSortBy(option || SORT_OPTIONS[0])}
- isClearable={false}
- />
-
-
- setSortOrder(option || SORT_ORDER_OPTIONS[0])}
+ inputId='engagement-visibility-filter'
+ options={VISIBILITY_OPTIONS}
+ value={visibilityFilter}
+ onChange={(option) => setVisibilityFilter(option || DEFAULT_VISIBILITY_OPTION)}
isClearable={false}
/>
@@ -321,24 +241,24 @@ const EngagementsList = ({
- | Title |
- Duration |
- Location |
- Anticipated Start |
- {canManage && Applications | }
- {canManage && Visibility | }
- {canManage && Members Required | }
- {canManage && Members Assigned | }
+ Project Name |
+ Engagement Title |
+ Visibility |
Status |
- {canManage && Actions | }
+ Applications |
+ Members Assigned |
+ Actions |
{filteredOpportunities.map((engagement) => {
- const duration = getDurationLabel(engagement)
- const location = getLocationLabel(engagement)
+ const fallbackProjectName = !allEngagements && projectDetail && projectDetail.name
+ ? projectDetail.name
+ : null
const applicationsCount = getApplicationsCount(engagement)
const statusClass = getStatusClass(engagement.status)
+ const engagementProjectId = getEngagementProjectId(engagement, projectId)
+ const projectName = getEngagementProjectName(engagement, fallbackProjectName) || engagementProjectId || '-'
const assignedMembersCount = getAssignedMembersCount(engagement)
const assignedMemberHandles = getAssignedMemberHandles(engagement)
const assignedMembersTooltip = assignedMemberHandles.length ? (
@@ -353,59 +273,55 @@ const EngagementsList = ({
return (
+ |
+ {engagementProjectId ? (
+
+ {projectName}
+
+ ) : (
+ projectName
+ )}
+ |
{engagement.title || '-'} |
- {duration} |
- {location} |
- {formatAnticipatedStart(engagement.anticipatedStart)} |
- {canManage && (
-
- {engagement.id ? (
-
- {applicationsCount}
-
- ) : (
- applicationsCount
- )}
- |
- )}
- {canManage && (
-
- {engagement.isPrivate ? 'Private' : 'Public'}
- |
- )}
- {canManage && (
-
- {engagement.requiredMemberCount != null ? engagement.requiredMemberCount : '-'}
- |
- )}
- {canManage && (
-
- {engagement.id && assignedMembersCount > 0 ? (
-
-
-
- {assignedMembersCount}
-
-
-
- ) : (
- assignedMembersCount
- )}
- |
- )}
+ {engagement.isPrivate ? 'Private' : 'Public'} |
{engagement.status || '-'}
|
- {canManage && (
-
+ |
+ {engagement.id && engagementProjectId ? (
+
+ {applicationsCount}
+
+ ) : (
+ applicationsCount
+ )}
+ |
+
+ {engagement.id && engagementProjectId && assignedMembersCount > 0 ? (
+
+
+
+ {assignedMembersCount}
+
+
+
+ ) : (
+ assignedMembersCount
+ )}
+ |
+
+ {canManage ? (
{engagement.id && (
- |
- )}
+ ) : (
+ '-'
+ )}
+
)
})}
@@ -435,17 +354,20 @@ const EngagementsList = ({
EngagementsList.defaultProps = {
engagements: [],
+ projectId: null,
projectDetail: null,
+ allEngagements: false,
isLoading: false,
canManage: false
}
EngagementsList.propTypes = {
engagements: PropTypes.arrayOf(PropTypes.shape()),
- projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
projectDetail: PropTypes.shape({
name: PropTypes.string
}),
+ allEngagements: PropTypes.bool,
isLoading: PropTypes.bool,
canManage: PropTypes.bool
}
diff --git a/src/components/PaymentForm/index.js b/src/components/PaymentForm/index.js
index fefea775..d525907f 100644
--- a/src/components/PaymentForm/index.js
+++ b/src/components/PaymentForm/index.js
@@ -84,7 +84,33 @@ const formatWeekEndingTitle = (value) => {
if (!value) {
return ''
}
- return `Week ending: ${moment(value).format('MMM DD, YYYY')}`
+ return `Week Ending: ${moment(value).format('MMM DD, YYYY')}`
+}
+
+const normalizeTitleSegment = (value) => {
+ if (value == null) {
+ return ''
+ }
+ return String(value).trim()
+}
+
+const getEngagementName = (engagement) => {
+ if (!engagement || typeof engagement !== 'object') {
+ return ''
+ }
+ return normalizeTitleSegment(
+ engagement.title || engagement.name || engagement.role || ''
+ )
+}
+
+const buildPaymentTitle = (projectName, engagementName, weekEndingTitle) => {
+ return [
+ normalizeTitleSegment(projectName),
+ normalizeTitleSegment(engagementName),
+ normalizeTitleSegment(weekEndingTitle)
+ ]
+ .filter(Boolean)
+ .join(' - ')
}
const getDefaultWeekEndingDate = () => {
@@ -121,7 +147,15 @@ const isWeekEndingSaturday = (value) => {
return parsed.isoWeekday() === 6
}
-const PaymentForm = ({ member, availableMembers, isProcessing, onSubmit, onCancel }) => {
+const PaymentForm = ({
+ member,
+ availableMembers,
+ engagement,
+ projectName,
+ isProcessing,
+ onSubmit,
+ onCancel
+}) => {
const defaultWeekEndingDate = useMemo(() => getDefaultWeekEndingDate(), [])
const weekEndingInputId = useRef(`week-ending-input-${Math.random().toString(36).slice(2, 9)}`)
const [weekEndingDate, setWeekEndingDate] = useState(defaultWeekEndingDate)
@@ -208,7 +242,9 @@ const PaymentForm = ({ member, availableMembers, isProcessing, onSubmit, onCance
const isAmountValid = Number.isFinite(parsedAmount) && parsedAmount > 0
const isWeekEndingValid = isWeekEndingSaturday(weekEndingDate)
const weekEndingTitle = isWeekEndingValid ? formatWeekEndingTitle(weekEndingDate) : ''
- const trimmedTitle = weekEndingTitle.trim()
+ const engagementName = getEngagementName(engagement)
+ const paymentTitle = buildPaymentTitle(projectName, engagementName, weekEndingTitle)
+ const trimmedTitle = paymentTitle.trim()
const isTitleValid = isWeekEndingValid && trimmedTitle.length > 0
const onSubmitForm = (event) => {
@@ -281,7 +317,7 @@ const PaymentForm = ({ member, availableMembers, isProcessing, onSubmit, onCance
className={`${styles.label} ${styles.clickableLabel}`}
htmlFor={weekEndingInputId.current}
>
- Week ending:
+ Week Ending:
- {weekEndingTitle && {weekEndingTitle} }
+ {paymentTitle && {paymentTitle} }
{titleError && {titleError} }
@@ -339,6 +375,8 @@ const PaymentForm = ({ member, availableMembers, isProcessing, onSubmit, onCance
PaymentForm.defaultProps = {
member: null,
availableMembers: [],
+ engagement: null,
+ projectName: '',
isProcessing: false,
onSubmit: () => {},
onCancel: () => {}
@@ -355,6 +393,12 @@ PaymentForm.propTypes = {
handle: PropTypes.string,
agreementRate: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
})),
+ engagement: PropTypes.shape({
+ title: PropTypes.string,
+ name: PropTypes.string,
+ role: PropTypes.string
+ }),
+ projectName: PropTypes.string,
isProcessing: PropTypes.bool,
onSubmit: PropTypes.func,
onCancel: PropTypes.func
diff --git a/src/components/Tab/index.js b/src/components/Tab/index.js
index 088d737d..21f821b8 100644
--- a/src/components/Tab/index.js
+++ b/src/components/Tab/index.js
@@ -8,11 +8,12 @@ const Tab = ({
selectTab,
projectId,
canViewAssets,
+ canViewEngagements,
onBack
}) => {
const projectTabs = [
{ id: 1, label: 'Challenges' },
- { id: 2, label: 'Engagements' },
+ ...(canViewEngagements ? [{ id: 2, label: 'Engagements' }] : []),
...(canViewAssets ? [{ id: 3, label: 'Assets' }] : [])
]
const tabs = projectId
@@ -20,10 +21,11 @@ const Tab = ({
: [
{ id: 1, label: 'All Work' },
{ id: 2, label: 'Projects' },
- { id: 3, label: 'Users' },
- { id: 4, label: 'Self-Service' },
- { id: 5, label: 'TaaS' },
- { id: 6, label: 'Groups' }
+ ...(canViewEngagements ? [{ id: 3, label: 'Engagements' }] : []),
+ { id: 4, label: 'Users' },
+ { id: 5, label: 'Self-Service' },
+ { id: 6, label: 'TaaS' },
+ { id: 7, label: 'Groups' }
]
const handleBack = () => {
@@ -85,6 +87,7 @@ Tab.defaultProps = {
selectTab: () => {},
projectId: null,
canViewAssets: true,
+ canViewEngagements: false,
onBack: () => {}
}
@@ -93,6 +96,7 @@ Tab.propTypes = {
currentTab: PT.number.isRequired,
projectId: PT.oneOfType([PT.string, PT.number]),
canViewAssets: PT.bool,
+ canViewEngagements: PT.bool,
onBack: PT.func
}
diff --git a/src/config/constants.js b/src/config/constants.js
index 8e39b510..ba943753 100644
--- a/src/config/constants.js
+++ b/src/config/constants.js
@@ -41,6 +41,8 @@ export const {
SALESFORCE_BILLING_ACCOUNT_LINK,
PROFILE_URL,
TC_FINANCE_API_URL,
+ TC_AI_API_BASE_URL,
+ TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID,
ENGAGEMENTS_APP_URL
} = process.env
@@ -48,6 +50,12 @@ export const CREATE_FORUM_TYPE_IDS = typeof process.env.CREATE_FORUM_TYPE_IDS ==
export const PROJECTS_API_URL = process.env.PROJECTS_API_URL || process.env.PROJECT_API_URL
export const SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS = ['80000062']
+/**
+ * AI Workflow Service config
+ */
+export const AI_WORKFLOW_POLL_INTERVAL = 1000 // 1 second in milliseconds
+export const AI_WORKFLOW_POLL_TIMEOUT = 5 * 60000 // 5 * 60 seconds in milliseconds
+
/**
* Filepicker config
*/
@@ -266,7 +274,7 @@ export const BULK_SEARCH_MEMBERS_PENDING = 'BULK_SEARCH_MEMBERS_PENDING'
export const BULK_SEARCH_MEMBERS_PROGRESS = 'BULK_SEARCH_MEMBERS_PROGRESS'
export const BULK_SEARCH_MEMBERS_SUCCESS = 'BULK_SEARCH_MEMBERS_SUCCESS'
export const BULK_SEARCH_MEMBERS_FAILURE = 'BULK_SEARCH_MEMBERS_FAILURE'
-export const BULK_SEARCH_MEMBERS_CHUNK_SIZE = 8
+export const BULK_SEARCH_MEMBERS_CHUNK_SIZE = 7
export const BULK_CREATE_GROUP_PENDING = 'BULK_CREATE_GROUP_PENDING'
export const BULK_CREATE_GROUP_SUCCESS = 'BULK_CREATE_GROUP_SUCCESS'
diff --git a/src/containers/EngagementPayment/index.js b/src/containers/EngagementPayment/index.js
index 802c2c1c..51174aef 100644
--- a/src/containers/EngagementPayment/index.js
+++ b/src/containers/EngagementPayment/index.js
@@ -125,6 +125,68 @@ const normalizeEngagement = (details) => {
}
}
+const buildAssignmentPatch = (assignmentUpdate = {}, fallback = {}) => {
+ const patch = {
+ status: assignmentUpdate.status || fallback.status,
+ terminationReason: Object.prototype.hasOwnProperty.call(assignmentUpdate, 'terminationReason')
+ ? assignmentUpdate.terminationReason
+ : Object.prototype.hasOwnProperty.call(assignmentUpdate, 'termination_reason')
+ ? assignmentUpdate.termination_reason
+ : fallback.terminationReason,
+ otherRemarks: Object.prototype.hasOwnProperty.call(assignmentUpdate, 'otherRemarks')
+ ? assignmentUpdate.otherRemarks
+ : Object.prototype.hasOwnProperty.call(assignmentUpdate, 'other_remarks')
+ ? assignmentUpdate.other_remarks
+ : fallback.otherRemarks,
+ startDate: assignmentUpdate.startDate || assignmentUpdate.start_date || fallback.startDate,
+ endDate: assignmentUpdate.endDate || assignmentUpdate.end_date || fallback.endDate,
+ updatedAt: assignmentUpdate.updatedAt || fallback.updatedAt
+ }
+ return Object.keys(patch).reduce((acc, key) => {
+ if (!_.isUndefined(patch[key])) {
+ acc[key] = patch[key]
+ }
+ return acc
+ }, {})
+}
+
+const applyAssignmentUpdate = (engagement, assignmentId, assignmentUpdate = {}, fallback = {}) => {
+ if (!engagement || typeof engagement !== 'object') {
+ return engagement
+ }
+ if (_.isNil(assignmentId) || assignmentId === '') {
+ return engagement
+ }
+ const assignments = Array.isArray(engagement.assignments) ? engagement.assignments : []
+ if (!assignments.length) {
+ return engagement
+ }
+
+ const assignmentIdText = `${assignmentId}`
+ const assignmentPatch = buildAssignmentPatch(assignmentUpdate, fallback)
+ let wasUpdated = false
+
+ const updatedAssignments = assignments.map((assignment) => {
+ if (`${_.get(assignment, 'id', '')}` !== assignmentIdText) {
+ return assignment
+ }
+ wasUpdated = true
+ return {
+ ...assignment,
+ ...assignmentPatch
+ }
+ })
+
+ if (!wasUpdated) {
+ return engagement
+ }
+
+ return normalizeEngagement({
+ ...engagement,
+ assignments: updatedAssignments
+ })
+}
+
class EngagementPaymentContainer extends Component {
constructor (props) {
super(props)
@@ -133,7 +195,9 @@ class EngagementPaymentContainer extends Component {
showPaymentModal: false,
selectedMember: null,
memberIdLookup: {},
- terminatingAssignments: {}
+ terminatingAssignments: {},
+ completingAssignments: {},
+ lastSyncedEngagementDetails: null
}
this.onOpenPaymentModal = this.onOpenPaymentModal.bind(this)
@@ -143,19 +207,28 @@ class EngagementPaymentContainer extends Component {
this.fetchPaymentsForAssignments = this.fetchPaymentsForAssignments.bind(this)
this.getPaymentEntries = this.getPaymentEntries.bind(this)
this.onTerminateAssignment = this.onTerminateAssignment.bind(this)
+ this.onCompleteAssignment = this.onCompleteAssignment.bind(this)
}
static getDerivedStateFromProps (nextProps, prevState) {
const engagementId = getEngagementIdFromProps(nextProps)
- const nextEngagementDetailsId = _.get(nextProps.engagementDetails, 'id', null)
+ const nextEngagementDetails = nextProps.engagementDetails
+ const nextEngagementDetailsId = _.get(nextEngagementDetails, 'id', null)
if (
engagementId &&
nextEngagementDetailsId &&
`${nextEngagementDetailsId}` === `${engagementId}` &&
- `${prevState.engagement.id}` !== `${nextEngagementDetailsId}`
+ prevState.lastSyncedEngagementDetails !== nextEngagementDetails
) {
- return { engagement: normalizeEngagement(nextProps.engagementDetails) }
+ const normalizedEngagement = normalizeEngagement(nextEngagementDetails)
+ if (_.isEqual(prevState.engagement, normalizedEngagement)) {
+ return { lastSyncedEngagementDetails: nextEngagementDetails }
+ }
+ return {
+ engagement: normalizedEngagement,
+ lastSyncedEngagementDetails: nextEngagementDetails
+ }
}
return null
@@ -370,13 +443,21 @@ class EngagementPaymentContainer extends Component {
}))
try {
- await updateEngagementAssignmentStatus(
+ const response = await updateEngagementAssignmentStatus(
this.getEngagementId(),
assignmentId,
'TERMINATED',
terminationReason
)
- await this.props.loadEngagementDetails(this.getProjectId(), this.getEngagementId())
+ const assignmentUpdate = _.get(response, 'data', response)
+ this.setState((prevState) => ({
+ engagement: applyAssignmentUpdate(
+ prevState.engagement,
+ assignmentId,
+ assignmentUpdate,
+ { status: 'TERMINATED', terminationReason }
+ )
+ }))
toastSuccess('Success', `Assignment for ${memberHandle} terminated.`)
return true
} catch (error) {
@@ -391,6 +472,50 @@ class EngagementPaymentContainer extends Component {
}
}
+ async onCompleteAssignment (member) {
+ const assignmentId = _.get(member, 'assignmentId', null)
+ if (_.isNil(assignmentId) || assignmentId === '') {
+ toastFailure('Error', 'Assignment ID is required to complete an assignment')
+ return false
+ }
+
+ const memberHandle = getMemberHandle(member) || 'this member'
+ this.setState((prevState) => ({
+ completingAssignments: {
+ ...prevState.completingAssignments,
+ [assignmentId]: true
+ }
+ }))
+
+ try {
+ const response = await updateEngagementAssignmentStatus(
+ this.getEngagementId(),
+ assignmentId,
+ 'COMPLETED'
+ )
+ const assignmentUpdate = _.get(response, 'data', response)
+ this.setState((prevState) => ({
+ engagement: applyAssignmentUpdate(
+ prevState.engagement,
+ assignmentId,
+ assignmentUpdate,
+ { status: 'COMPLETED' }
+ )
+ }))
+ toastSuccess('Success', `Assignment for ${memberHandle} marked as completed.`)
+ return true
+ } catch (error) {
+ toastFailure('Error', (error && error.message) || 'Failed to complete assignment')
+ return false
+ } finally {
+ this.setState((prevState) => {
+ const next = { ...prevState.completingAssignments }
+ delete next[assignmentId]
+ return { completingAssignments: next }
+ })
+ }
+ }
+
getPaymentEntries (engagement) {
if (!engagement) {
return []
@@ -454,19 +579,22 @@ class EngagementPaymentContainer extends Component {
render () {
const projectId = this.getProjectId()
const engagementId = this.getEngagementId()
- const { isLoading, payments, paymentsByAssignment } = this.props
+ const { isLoading, payments, paymentsByAssignment, projectDetail } = this.props
const assignedMembersForPayment = this.getAssignedMembersForPayment()
const isPaymentProcessing = Boolean(payments && payments.isProcessing)
const shouldShowPaymentModal = this.state.showPaymentModal && this.state.selectedMember
+ const projectName = _.get(projectDetail, 'name', '')
return (
)
}
@@ -500,6 +629,7 @@ EngagementPaymentContainer.propTypes = {
error: PropTypes.string
})),
projectDetail: PropTypes.shape({
+ name: PropTypes.string,
billingAccountId: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
}),
currentBillingAccount: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
diff --git a/src/containers/EngagementsList/index.js b/src/containers/EngagementsList/index.js
index 363a1c66..a1b5f5b8 100644
--- a/src/containers/EngagementsList/index.js
+++ b/src/containers/EngagementsList/index.js
@@ -34,11 +34,13 @@ class EngagementsListContainer extends Component {
loadData () {
const projectId = this.getProjectId()
- const { loadProject, loadEngagements } = this.props
- if (!projectId) {
+ const { loadProject, loadEngagements, allEngagements } = this.props
+ if (projectId) {
+ loadProject(projectId)
+ }
+ if (!projectId && !allEngagements) {
return
}
- loadProject(projectId)
loadEngagements(projectId, 'all', '', this.canIncludePrivate())
}
@@ -62,6 +64,7 @@ class EngagementsListContainer extends Component {
engagements={this.props.engagements}
projectId={projectId}
projectDetail={this.props.projectDetail}
+ allEngagements={this.props.allEngagements}
isLoading={this.props.isLoading}
canManage={this.canManage()}
currentUser={this.props.auth.user}
@@ -71,6 +74,7 @@ class EngagementsListContainer extends Component {
}
EngagementsListContainer.propTypes = {
+ allEngagements: PropTypes.bool,
projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
match: PropTypes.shape({
params: PropTypes.shape({
@@ -93,6 +97,10 @@ EngagementsListContainer.propTypes = {
loadProject: PropTypes.func.isRequired
}
+EngagementsListContainer.defaultProps = {
+ allEngagements: false
+}
+
const mapStateToProps = (state) => ({
engagements: state.engagements.engagements,
isLoading: state.engagements.isLoading,
diff --git a/src/containers/Tab/index.js b/src/containers/Tab/index.js
index 666c9262..0a056f7f 100644
--- a/src/containers/Tab/index.js
+++ b/src/containers/Tab/index.js
@@ -9,7 +9,7 @@ import {
resetSidebarActiveParams,
unloadProjects
} from '../../actions/sidebar'
-import { checkAdmin, checkCopilot } from '../../util/tc'
+import { checkAdmin, checkCopilot, checkAdminOrTalentManager } from '../../util/tc'
class TabContainer extends Component {
constructor (props) {
@@ -31,6 +31,13 @@ class TabContainer extends Component {
return !!resolvedToken && (checkAdmin(resolvedToken) || checkCopilot(resolvedToken))
}
+ getCanViewEngagements (props = this.props) {
+ const { token: currentToken } = this.props
+ const { token } = props
+ const resolvedToken = token || currentToken
+ return !!resolvedToken && checkAdminOrTalentManager(resolvedToken)
+ }
+
componentDidMount () {
const {
projectId,
@@ -55,7 +62,10 @@ class TabContainer extends Component {
}
const canViewAssets = this.getCanViewAssets()
- this.setState({ currentTab: this.getTabFromPath(history.location.pathname, projectId, canViewAssets) })
+ const canViewEngagements = this.getCanViewEngagements()
+ this.setState({
+ currentTab: this.getTabFromPath(history.location.pathname, projectId, canViewAssets, canViewEngagements)
+ })
}
componentWillReceiveProps (nextProps) {
@@ -66,12 +76,16 @@ class TabContainer extends Component {
}
const canViewAssets = this.getCanViewAssets(nextProps)
- this.setState({ currentTab: this.getTabFromPath(nextProps.history.location.pathname, projectId, canViewAssets) })
+ const canViewEngagements = this.getCanViewEngagements(nextProps)
+ this.setState({
+ currentTab: this.getTabFromPath(nextProps.history.location.pathname, projectId, canViewAssets, canViewEngagements)
+ })
if (
isLoading ||
// do not fetch projects for users or groups page
nextProps.history.location.pathname === '/users' ||
- nextProps.history.location.pathname === '/groups'
+ nextProps.history.location.pathname === '/groups' ||
+ nextProps.history.location.pathname === '/engagements'
) {
return
}
@@ -100,12 +114,12 @@ class TabContainer extends Component {
this.loadProjects(nextProps)
}
- getProjectTabFromPath (pathname, projectId, canViewAssets = true) {
+ getProjectTabFromPath (pathname, projectId, canViewAssets = true, canViewEngagements = false) {
if (!projectId) {
return 0
}
if (pathname.includes(`/projects/${projectId}/engagements`)) {
- return 2
+ return canViewEngagements ? 2 : 0
}
if (pathname.includes(`/projects/${projectId}/assets`)) {
return canViewAssets ? 3 : 0
@@ -116,9 +130,9 @@ class TabContainer extends Component {
return 0
}
- getTabFromPath (pathname, projectId, canViewAssets = true) {
+ getTabFromPath (pathname, projectId, canViewAssets = true, canViewEngagements = false) {
if (projectId) {
- return this.getProjectTabFromPath(pathname, projectId, canViewAssets)
+ return this.getProjectTabFromPath(pathname, projectId, canViewAssets, canViewEngagements)
}
if (pathname === '/') {
return 1
@@ -126,17 +140,20 @@ class TabContainer extends Component {
if (pathname === '/projects') {
return 2
}
+ if (pathname === '/engagements') {
+ return canViewEngagements ? 3 : 0
+ }
if (pathname === '/users') {
- return 3
+ return 4
}
if (pathname === '/self-service') {
- return 4
+ return 5
}
if (pathname === '/taas') {
- return 5
+ return 6
}
if (pathname === '/groups') {
- return 6
+ return 7
}
return 0
}
@@ -161,14 +178,15 @@ class TabContainer extends Component {
onTabChange (tab) {
const { history, resetSidebarActiveParams, projectId } = this.props
const canViewAssets = this.getCanViewAssets()
+ const canViewEngagements = this.getCanViewEngagements()
if (projectId) {
- if (tab === 3 && !canViewAssets) {
+ if ((tab === 2 && !canViewEngagements) || (tab === 3 && !canViewAssets)) {
return
}
if (tab === 1) {
history.push(`/projects/${projectId}/challenges`)
this.setState({ currentTab: 1 })
- } else if (tab === 2) {
+ } else if (tab === 2 && canViewEngagements) {
history.push(`/projects/${projectId}/engagements`)
this.setState({ currentTab: 2 })
} else if (tab === 3) {
@@ -182,19 +200,22 @@ class TabContainer extends Component {
history.push('/projects')
this.props.unloadProjects()
this.setState({ currentTab: 2 })
- } else if (tab === 3) {
- history.push('/users')
+ } else if (tab === 3 && canViewEngagements) {
+ history.push('/engagements')
this.setState({ currentTab: 3 })
} else if (tab === 4) {
- history.push('/self-service')
+ history.push('/users')
this.setState({ currentTab: 4 })
} else if (tab === 5) {
- history.push('/taas')
- this.props.unloadProjects()
+ history.push('/self-service')
this.setState({ currentTab: 5 })
} else if (tab === 6) {
- history.push('/groups')
+ history.push('/taas')
+ this.props.unloadProjects()
this.setState({ currentTab: 6 })
+ } else if (tab === 7) {
+ history.push('/groups')
+ this.setState({ currentTab: 7 })
}
resetSidebarActiveParams()
@@ -203,6 +224,7 @@ class TabContainer extends Component {
render () {
const { currentTab } = this.state
const canViewAssets = this.getCanViewAssets()
+ const canViewEngagements = this.getCanViewEngagements()
return (
)
diff --git a/src/routes.js b/src/routes.js
index 433c9925..c485bb53 100644
--- a/src/routes.js
+++ b/src/routes.js
@@ -33,7 +33,7 @@ import {
checkAdmin,
checkCopilot,
checkManager,
- checkAdminOrPmOrTaskManager
+ checkAdminOrTalentManager
} from './util/tc'
import Users from './containers/Users'
import Groups from './containers/Groups'
@@ -124,7 +124,7 @@ class Routes extends React.Component {
const isReadOnly = checkReadOnlyRoles(this.props.token)
const isCopilot = checkCopilot(this.props.token)
const isAdmin = checkAdmin(this.props.token)
- const canManageEngagements = checkAdminOrPmOrTaskManager(this.props.token, null)
+ const canAccessEngagements = checkAdminOrTalentManager(this.props.token)
return (
@@ -156,6 +156,29 @@ class Routes extends React.Component {
)()}
/>
+ {canAccessEngagements && (
+ renderApp(
+ ,
+ ,
+ ,
+
+ )()}
+ />
+ )}
+ {!canAccessEngagements && (
+ renderApp(
+ ,
+ ,
+ ,
+
+ )()}
+ />
+ )}
renderApp(
,
@@ -262,14 +285,16 @@ class Routes extends React.Component {
}
/>
)}
- renderApp(
- ,
- ,
- ,
-
- )()} />
- {canManageEngagements && (
+ {canAccessEngagements && (
+ renderApp(
+ ,
+ ,
+ ,
+
+ )()} />
+ )}
+ {canAccessEngagements && (
renderApp(
,
@@ -278,87 +303,68 @@ class Routes extends React.Component {
)()} />
)}
- {!canManageEngagements && (
- renderApp(
- ,
+ ,
+ ,
- ,
- ,
)()} />
)}
- renderApp(
- ,
- ,
- ,
-
- )()} />
- renderApp(
- ,
- ,
- ,
-
- )()} />
- renderApp(
- ,
- ,
- ,
-
- )()} />
- {canManageEngagements && (
- renderApp(
- ,
+ ,
,
,
)()} />
)}
- {!canManageEngagements && (
- renderApp(
- ,
+ ,
+ ,
- ,
+
+ )()} />
+ )}
+ {canAccessEngagements && (
+ renderApp(
+ ,
+ ,
,
)()} />
)}
- renderApp(
- ,
- ,
- ,
-
- )()} />
- {canManageEngagements && (
+ {canAccessEngagements && (
+ renderApp(
+ ,
+ ,
+ ,
+
+ )()} />
+ )}
+ {canAccessEngagements && (
renderApp(
,
@@ -367,12 +373,12 @@ class Routes extends React.Component {
)()} />
)}
- {!canManageEngagements && (
- renderApp(
,
,
,
diff --git a/src/services/workflowAI.js b/src/services/workflowAI.js
new file mode 100644
index 00000000..6b94d82d
--- /dev/null
+++ b/src/services/workflowAI.js
@@ -0,0 +1,142 @@
+import {
+ TC_AI_API_BASE_URL,
+ TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID,
+ AI_WORKFLOW_POLL_INTERVAL,
+ AI_WORKFLOW_POLL_TIMEOUT
+} from '../config/constants'
+
+import { axiosInstance } from './axiosWithAuth'
+
+/**
+ * Start an AI workflow run
+ *
+ * @param {String} workflowId - The ID of the workflow to run
+ * @param {String} input - The input data for the workflow
+ * @returns {Promise} - The run ID
+ */
+async function startWorkflowRun (workflowId, input) {
+ try {
+ // Step 1: Create the run
+ const runResponse = await axiosInstance.post(
+ `${TC_AI_API_BASE_URL}/workflows/${workflowId}/create-run`
+ )
+ const runId = runResponse.data && runResponse.data.runId
+
+ if (!runId) {
+ throw new Error('No runId returned from workflow creation')
+ }
+
+ // Step 2: Start the run with input
+ await axiosInstance.post(
+ `${TC_AI_API_BASE_URL}/workflows/${workflowId}/start?runId=${runId}`,
+ { inputData: { jobDescription: input } }
+ )
+
+ return runId
+ } catch (error) {
+ console.error('Failed to start workflow run:', error.message)
+ throw error
+ }
+}
+
+/**
+ * Poll for workflow run status
+ *
+ * @param {String} workflowId - The ID of the workflow
+ * @param {String} runId - The ID of the run to check
+ * @param {Number} maxAttempts - Maximum polling attempts
+ * @returns {Promise | |