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 || '-'}
- ) : ( - { + onUpdateSkills((values || []).map(value => ({ + name: value.label, + id: value.value + }))) + }} + cacheOptions + loadOptions={fetchSkills} + isDisabled={isLoadingAI} + /> + {hasDescription && ( + + )} + + )} +
{showRequiredError && (
Select at least one skill
)} @@ -70,25 +132,45 @@ const SkillsField = ({ readOnly, challenge, onUpdateSkills, embedded }) => {
- {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' />
+ setSortBy(option || SORT_OPTIONS[0])} - isClearable={false} - /> -
-
-