diff --git a/.circleci/config.yml b/.circleci/config.yml index 1ee64edd..0dd465bb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -160,7 +160,7 @@ workflows: context: org-global filters: &filters-dev branches: - only: ["develop", "pm-2917", "points", "pm-3270", "permissions-hotfix"] + only: ["develop", "pm-2917", "points", "pm-3270", "projects-api-v6", "PM-3813_ai-reviewer-configs"] # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml index 7b9fa483..9cbcf520 100644 --- a/.github/workflows/trivy.yaml +++ b/.github/workflows/trivy.yaml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@v4 - name: Run Trivy scanner in repo mode - uses: aquasecurity/trivy-action@0.33.1 + uses: aquasecurity/trivy-action@0.35.0 with: scan-type: "fs" ignore-unfixed: true diff --git a/config/constants/development.js b/config/constants/development.js index 67b69f47..aa3c8e95 100644 --- a/config/constants/development.js +++ b/config/constants/development.js @@ -22,6 +22,7 @@ module.exports = { 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', + TC_AI_AUTOWRITE_WORKFLOW_ID: process.env.TC_AI_AUTOWRITE_WORKFLOW_ID || 'jdAutowriteWorkflow', 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/local.js b/config/constants/local.js index e90792c2..5e197768 100644 --- a/config/constants/local.js +++ b/config/constants/local.js @@ -36,6 +36,7 @@ module.exports = { ENGAGEMENTS_ROOT_API_URL: `${LOCAL_CHALLENGE_API}/engagements`, APPLICATIONS_API_URL: `${LOCAL_CHALLENGE_API}/engagements/applications`, TC_FINANCE_API_URL: process.env.TC_FINANCE_API_URL || 'http://localhost:3009/v6/finance', + TC_AI_AUTOWRITE_WORKFLOW_ID: process.env.TC_AI_AUTOWRITE_WORKFLOW_ID || 'jdAutowriteWorkflow', CHALLENGE_DEFAULT_REVIEWERS_URL: `${LOCAL_CHALLENGE_API.replace(/\/v6$/, '')}/v6/challenge/default-reviewers`, CHALLENGE_API_VERSION: '1.1.0', CHALLENGE_TIMELINE_TEMPLATES_URL: `${LOCAL_CHALLENGE_API}/timeline-templates`, diff --git a/config/constants/production.js b/config/constants/production.js index 94c3e95e..70c7d52a 100644 --- a/config/constants/production.js +++ b/config/constants/production.js @@ -21,6 +21,7 @@ module.exports = { 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', + TC_AI_AUTOWRITE_WORKFLOW_ID: process.env.TC_AI_AUTOWRITE_WORKFLOW_ID || 'jdAutowriteWorkflow', 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`, @@ -37,9 +38,9 @@ module.exports = { RESOURCE_ROLES_API_URL: `${PROD_API_HOSTNAME}/v6/resource-roles`, SUBMISSIONS_API_URL: `${PROD_API_HOSTNAME}/v6/submissions`, REVIEW_TYPE_API_URL: `${PROD_API_HOSTNAME}/v6/reviewTypes`, - REVIEWS_API_URL: `${PROD_API_HOSTNAME}/v6/reviews`, + REVIEWS_API_URL: `${PROD_API_HOSTNAME}/v6/reviews`, SCORECARDS_API_URL: `${PROD_API_HOSTNAME}/v6/scorecards`, - WORKFLOWS_API_URL: `${PROD_API_HOSTNAME}/v6/workflows`, + WORKFLOWS_API_URL: `${PROD_API_HOSTNAME}/v6/workflows`, SUBMISSION_REVIEW_APP_URL: `https://submission-review.${DOMAIN}/challenges`, STUDIO_URL: `https://studio.${DOMAIN}`, CONNECT_APP_URL: `https://connect.${DOMAIN}`, diff --git a/src/actions/engagements.js b/src/actions/engagements.js index d0a0c55e..8a643cda 100644 --- a/src/actions/engagements.js +++ b/src/actions/engagements.js @@ -4,7 +4,6 @@ import { fetchEngagement, createEngagement as createEngagementAPI, updateEngagement as updateEngagementAPI, - patchEngagement, deleteEngagement as deleteEngagementAPI } from '../services/engagements' import { fetchSkillsByIds } from '../services/skills' @@ -345,7 +344,10 @@ export function updateEngagementDetails (engagementId, engagementDetails, projec } /** - * Partially updates engagement details + * Partially updates engagement details. + * + * The engagements API currently accepts partial engagement payloads through the + * `PUT :id` update route rather than a dedicated `PATCH :id` handler. * @param {String|Number} engagementId * @param {Object} partialDetails * @param {String|Number} projectId @@ -358,7 +360,7 @@ export function partiallyUpdateEngagementDetails (engagementId, partialDetails, }) try { - const response = await patchEngagement(engagementId, partialDetails) + const response = await updateEngagementAPI(engagementId, partialDetails) const [hydratedEngagement] = await hydrateEngagementSkills([_.get(response, 'data', {})]) const updatedDetails = normalizeEngagement(hydratedEngagement || {}) return dispatch({ diff --git a/src/actions/payments.js b/src/actions/payments.js index 02263ff0..d81965a7 100644 --- a/src/actions/payments.js +++ b/src/actions/payments.js @@ -19,6 +19,7 @@ const ENGAGEMENT_PAYMENT_STATUS = 'ON_HOLD_ADMIN' * @param {String} remarks * @param {String|Number} agreementRate * @param {String|Number} amount + * @param {String|Number} hoursWorked * @param {String|Number} billingAccountId * @param {String} paymentStatus */ @@ -30,6 +31,7 @@ export function createMemberPayment ( remarks, agreementRate, amount, + hoursWorked, billingAccountId, paymentStatus = ENGAGEMENT_PAYMENT_STATUS ) { @@ -39,6 +41,7 @@ export function createMemberPayment ( }) const parsedAmount = Number(amount) + const parsedHoursWorked = Number(hoursWorked) const trimmedTitle = typeof paymentTitle === 'string' ? paymentTitle.trim() : (paymentTitle != null ? String(paymentTitle) : '') @@ -60,6 +63,9 @@ export function createMemberPayment ( description: trimmedTitle, externalId: String(assignmentId), attributes, + ...(Number.isFinite(parsedHoursWorked) && parsedHoursWorked > 0 + ? { hoursWorked: parsedHoursWorked } + : {}), ...(paymentStatus ? { status: paymentStatus } : {}), details: [ { diff --git a/src/components/ApplicationsList/index.js b/src/components/ApplicationsList/index.js index 2b1c0b98..ea560ad6 100644 --- a/src/components/ApplicationsList/index.js +++ b/src/components/ApplicationsList/index.js @@ -13,6 +13,13 @@ import Handle from '../Handle' import styles from './ApplicationsList.module.scss' import { PROFILE_URL } from '../../config/constants' import { serializeTentativeAssignmentDate } from '../../util/assignmentDates' +import { + calculateAssignmentRatePerWeek, + sanitizePositiveNumericInput, + toPositiveInteger, + toPositiveNumberWithMaxDecimalPlaces, + toPositiveNumber +} from '../../util/assignmentRates' import { isCapacityLimitError } from '../../util/applicationErrors' import { getCountableAssignments } from '../../util/engagements' @@ -25,8 +32,9 @@ const STATUS_OPTIONS = [ ] const STATUS_UPDATE_OPTIONS = STATUS_OPTIONS.filter(option => option.value !== 'all') +// The shared DateInput uses date-fns tokens; uppercase moment-style tokens prevent the calendar from opening. const INPUT_DATE_FORMAT = 'MM/dd/yyyy' -const INPUT_TIME_FORMAT = 'HH:mm' +const INPUT_TIME_FORMAT = false const CAPACITY_ERROR_MODAL_MESSAGE = 'The required number of members are already assigned to this engagement. If you\'d like to add another member, change the required number of members on the engagement first.' const ANTICIPATED_START_LABELS = { @@ -153,17 +161,6 @@ const normalizeAssignmentStatus = (status) => { .replace(/\s+/g, '_') } -const toValidMoment = (value) => { - if (!value) { - return null - } - if (moment.isMoment(value)) { - return value.isValid() ? value : null - } - const parsed = moment(value) - return parsed.isValid() ? parsed : null -} - const isActiveAssignmentStatus = (status) => { const normalized = normalizeAssignmentStatus(status) return normalized === 'ASSIGNED' || normalized === 'ACTIVE' @@ -182,8 +179,9 @@ const ApplicationsList = ({ const [acceptSuccess, setAcceptSuccess] = useState(null) const [capacityError, setCapacityError] = useState(false) const [acceptStartDate, setAcceptStartDate] = useState(null) - const [acceptEndDate, setAcceptEndDate] = useState(null) - const [acceptRate, setAcceptRate] = useState('') + const [acceptDurationMonths, setAcceptDurationMonths] = useState('') + const [acceptRatePerHour, setAcceptRatePerHour] = useState('') + const [acceptStandardHoursPerWeek, setAcceptStandardHoursPerWeek] = useState('') const [acceptOtherRemarks, setAcceptOtherRemarks] = useState('') const [acceptErrors, setAcceptErrors] = useState({}) const [isAccepting, setIsAccepting] = useState(false) @@ -193,34 +191,9 @@ const ApplicationsList = ({ const acceptHandleColor = Number.isFinite(acceptRating) ? undefined : '#000' const acceptName = getApplicationName(acceptApplication) || 'Selected applicant' const acceptSuccessLabel = acceptSuccess ? acceptSuccess.memberLabel : null - const today = moment().startOf('day') - const parsedAcceptStart = acceptStartDate ? moment(acceptStartDate) : null - const parsedAcceptStartDay = parsedAcceptStart && parsedAcceptStart.isValid() - ? parsedAcceptStart.clone().startOf('day') - : null - const minEndDay = parsedAcceptStartDay && parsedAcceptStartDay.isAfter(today) ? parsedAcceptStartDay : today - const isAcceptStartDateValid = (current) => { - const currentMoment = toValidMoment(current) - if (!currentMoment) { - return false - } - return currentMoment.isSameOrAfter(today, 'day') - } - const isAcceptEndDateValid = (current) => { - const currentMoment = toValidMoment(current) - if (!currentMoment) { - return false - } - return currentMoment.isSameOrAfter(minEndDay, 'day') - } - const getMinStartDateTime = () => moment().toDate() - const getMinEndDateTime = () => { - const now = moment() - if (parsedAcceptStart && parsedAcceptStart.isValid() && parsedAcceptStart.isAfter(now)) { - return parsedAcceptStart.toDate() - } - return now.toDate() - } + const acceptAssignmentRate = useMemo(() => { + return calculateAssignmentRatePerWeek(acceptRatePerHour, acceptStandardHoursPerWeek) + }, [acceptRatePerHour, acceptStandardHoursPerWeek]) const acceptSubtitle = acceptHandle ? (
{ setAcceptApplication(null) setAcceptStartDate(null) - setAcceptEndDate(null) - setAcceptRate('') + setAcceptDurationMonths('') + setAcceptRatePerHour('') + setAcceptStandardHoursPerWeek('') setAcceptOtherRemarks('') setAcceptErrors({}) setIsAccepting(false) @@ -316,8 +290,9 @@ const ApplicationsList = ({ const openAcceptModal = (application) => { setAcceptApplication(application) setAcceptStartDate(null) - setAcceptEndDate(null) - setAcceptRate('') + setAcceptDurationMonths('') + setAcceptRatePerHour('') + setAcceptStandardHoursPerWeek('') setAcceptOtherRemarks('') setAcceptErrors({}) setIsAccepting(false) @@ -335,21 +310,25 @@ const ApplicationsList = ({ const nextErrors = {} const parsedStart = acceptStartDate ? moment(acceptStartDate) : null - const parsedEnd = acceptEndDate ? moment(acceptEndDate) : null - const normalizedRate = acceptRate != null ? String(acceptRate).trim() : '' + const parsedDurationMonths = toPositiveInteger(acceptDurationMonths) + const parsedRatePerHour = toPositiveNumber(acceptRatePerHour) + const parsedStandardHoursPerWeek = toPositiveNumberWithMaxDecimalPlaces( + acceptStandardHoursPerWeek, + 2 + ) const normalizedOtherRemarks = acceptOtherRemarks != null ? String(acceptOtherRemarks).trim() : '' if (!parsedStart || !parsedStart.isValid()) { - nextErrors.startDate = 'Start date is required.' + nextErrors.startDate = 'Billing start date is required.' } - if (!parsedEnd || !parsedEnd.isValid()) { - nextErrors.endDate = 'End date is required.' + if (parsedDurationMonths === null) { + nextErrors.durationMonths = 'Duration must be a positive whole number.' } - if (!normalizedRate) { - nextErrors.rate = 'Assignment rate is required.' + if (parsedRatePerHour === null) { + nextErrors.ratePerHour = 'Rate per hour must be a positive number.' } - if (parsedStart && parsedEnd && parsedStart.isValid() && parsedEnd.isValid() && parsedEnd.isBefore(parsedStart)) { - nextErrors.endDate = 'End date must be after start date.' + if (parsedStandardHoursPerWeek === null) { + nextErrors.standardHoursPerWeek = 'Standard hours per week must be a positive number with up to 2 decimal places.' } if (Object.keys(nextErrors).length > 0) { @@ -360,11 +339,12 @@ const ApplicationsList = ({ setIsAccepting(true) try { const startDate = serializeTentativeAssignmentDate(parsedStart) - const endDate = serializeTentativeAssignmentDate(parsedEnd) await onUpdateStatus(acceptApplication.id, 'SELECTED', { startDate, - endDate, - agreementRate: normalizedRate, + durationMonths: parsedDurationMonths, + ratePerHour: parsedRatePerHour.toString(), + standardHoursPerWeek: parsedStandardHoursPerWeek, + agreementRate: acceptAssignmentRate, ...(normalizedOtherRemarks ? { otherRemarks: normalizedOtherRemarks } : {}) }) const memberHandle = getApplicationHandle(acceptApplication) @@ -434,7 +414,7 @@ const ApplicationsList = ({
{ setAcceptStartDate(value) if (acceptErrors.startDate) { @@ -458,50 +436,84 @@ const ApplicationsList = ({
- { - setAcceptEndDate(value) - if (acceptErrors.endDate) { - setAcceptErrors(prev => ({ ...prev, endDate: '' })) + { + setAcceptDurationMonths(sanitizePositiveNumericInput(event.target.value)) + if (acceptErrors.durationMonths) { + setAcceptErrors(prev => ({ ...prev, durationMonths: '' })) } }} /> - {acceptErrors.endDate && ( -
{acceptErrors.endDate}
+ {acceptErrors.durationMonths && ( +
{acceptErrors.durationMonths}
)}
-
+
{ - setAcceptRate(event.target.value) - if (acceptErrors.rate) { - setAcceptErrors(prev => ({ ...prev, rate: '' })) + setAcceptRatePerHour(sanitizePositiveNumericInput(event.target.value)) + if (acceptErrors.ratePerHour) { + setAcceptErrors(prev => ({ ...prev, ratePerHour: '' })) } }} /> - {acceptErrors.rate && ( -
{acceptErrors.rate}
+ {acceptErrors.ratePerHour && ( +
{acceptErrors.ratePerHour}
)}
+
+ + { + setAcceptStandardHoursPerWeek( + sanitizePositiveNumericInput(event.target.value, 2) + ) + if (acceptErrors.standardHoursPerWeek) { + setAcceptErrors(prev => ({ ...prev, standardHoursPerWeek: '' })) + } + }} + /> + {acceptErrors.standardHoursPerWeek && ( +
{acceptErrors.standardHoursPerWeek}
+ )} +
+
+ + +