From 534f70259a33f07ce9d9f93852693b31c3c40e3c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 9 Jan 2026 17:18:13 +1100 Subject: [PATCH 01/30] Initial engagements work --- config/constants/development.js | 1 + config/constants/local.js | 1 + config/constants/production.js | 1 + src/actions/applications.js | 111 ++++ src/actions/engagements.js | 227 +++++++++ .../ApplicationDetail.module.scss | 127 +++++ src/components/ApplicationDetail/index.js | 201 ++++++++ .../ApplicationsList.module.scss | 118 +++++ src/components/ApplicationsList/index.js | 203 ++++++++ .../EngagementEditor.module.scss | 144 ++++++ src/components/EngagementEditor/index.js | 477 ++++++++++++++++++ .../EngagementsList.module.scss | 86 ++++ src/components/EngagementsList/index.js | 240 +++++++++ src/components/Tab/index.js | 162 ++---- src/config/constants.js | 35 ++ src/containers/ApplicationsList/index.js | 139 +++++ src/containers/EngagementEditor/index.js | 387 ++++++++++++++ src/containers/EngagementsList/index.js | 102 ++++ src/containers/Tab/index.js | 95 +++- src/reducers/applications.js | 127 +++++ src/reducers/engagements.js | 173 +++++++ src/reducers/index.js | 4 + src/routes.js | 33 ++ src/services/engagements.js | 105 ++++ 24 files changed, 3166 insertions(+), 133 deletions(-) create mode 100644 src/actions/applications.js create mode 100644 src/actions/engagements.js create mode 100644 src/components/ApplicationDetail/ApplicationDetail.module.scss create mode 100644 src/components/ApplicationDetail/index.js create mode 100644 src/components/ApplicationsList/ApplicationsList.module.scss create mode 100644 src/components/ApplicationsList/index.js create mode 100644 src/components/EngagementEditor/EngagementEditor.module.scss create mode 100644 src/components/EngagementEditor/index.js create mode 100644 src/components/EngagementsList/EngagementsList.module.scss create mode 100644 src/components/EngagementsList/index.js create mode 100644 src/containers/ApplicationsList/index.js create mode 100644 src/containers/EngagementEditor/index.js create mode 100644 src/containers/EngagementsList/index.js create mode 100644 src/reducers/applications.js create mode 100644 src/reducers/engagements.js create mode 100644 src/services/engagements.js diff --git a/config/constants/development.js b/config/constants/development.js index 06fc24b3..3d1bc7d6 100644 --- a/config/constants/development.js +++ b/config/constants/development.js @@ -15,6 +15,7 @@ module.exports = { COMMUNITY_APP_URL: `https://www.${DOMAIN}`, MEMBER_API_URL: `${DEV_API_HOSTNAME}/v6/members`, CHALLENGE_API_URL: `${DEV_API_HOSTNAME}/v6/challenges`, + ENGAGEMENTS_API_URL: `${DEV_API_HOSTNAME}/v6/engagements`, 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 38146249..84631181 100644 --- a/config/constants/local.js +++ b/config/constants/local.js @@ -30,6 +30,7 @@ module.exports = { // Local service URLs MEMBER_API_URL: `${LOCAL_MEMBER_API}/members`, CHALLENGE_API_URL: `${LOCAL_CHALLENGE_API}/challenges`, + ENGAGEMENTS_API_URL: `${LOCAL_CHALLENGE_API}/engagements`, 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 9631100a..a9f42cbe 100644 --- a/config/constants/production.js +++ b/config/constants/production.js @@ -14,6 +14,7 @@ module.exports = { COMMUNITY_APP_URL: `https://www.${DOMAIN}`, MEMBER_API_URL: `${PROD_API_HOSTNAME}/v6/members`, CHALLENGE_API_URL: `${PROD_API_HOSTNAME}/v6/challenges`, + ENGAGEMENTS_API_URL: `${PROD_API_HOSTNAME}/v6/engagements`, 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/src/actions/applications.js b/src/actions/applications.js new file mode 100644 index 00000000..25bbe8f0 --- /dev/null +++ b/src/actions/applications.js @@ -0,0 +1,111 @@ +import _ from 'lodash' +import { + fetchApplications, + fetchApplication, + updateApplicationStatus as updateApplicationStatusAPI +} from '../services/engagements' +import { + LOAD_APPLICATIONS_PENDING, + LOAD_APPLICATIONS_SUCCESS, + LOAD_APPLICATIONS_FAILURE, + LOAD_APPLICATION_DETAILS_PENDING, + LOAD_APPLICATION_DETAILS_SUCCESS, + LOAD_APPLICATION_DETAILS_FAILURE, + UPDATE_APPLICATION_STATUS_PENDING, + UPDATE_APPLICATION_STATUS_SUCCESS, + UPDATE_APPLICATION_STATUS_FAILURE +} from '../config/constants' + +/** + * Loads applications for an engagement + * @param {String|Number} engagementId + * @param {String} statusFilter + */ +export function loadApplications (engagementId, statusFilter = 'all') { + return async (dispatch) => { + dispatch({ + type: LOAD_APPLICATIONS_PENDING + }) + + const filters = {} + if (statusFilter && statusFilter !== 'all') { + filters.status = statusFilter + } + + try { + const response = await fetchApplications(engagementId, filters) + return dispatch({ + type: LOAD_APPLICATIONS_SUCCESS, + applications: _.get(response, 'data', []) + }) + } catch (error) { + dispatch({ + type: LOAD_APPLICATIONS_FAILURE, + error + }) + return Promise.reject(error) + } + } +} + +/** + * Loads application details + * @param {String|Number} engagementId + * @param {String|Number} applicationId + */ +export function loadApplicationDetails (engagementId, applicationId) { + return async (dispatch) => { + if (!applicationId) { + return dispatch({ + type: LOAD_APPLICATION_DETAILS_SUCCESS, + applicationDetails: {} + }) + } + + dispatch({ + type: LOAD_APPLICATION_DETAILS_PENDING + }) + + try { + const response = await fetchApplication(engagementId, applicationId) + return dispatch({ + type: LOAD_APPLICATION_DETAILS_SUCCESS, + applicationDetails: _.get(response, 'data', {}) + }) + } catch (error) { + dispatch({ + type: LOAD_APPLICATION_DETAILS_FAILURE, + error + }) + return Promise.reject(error) + } + } +} + +/** + * Updates application status + * @param {String|Number} engagementId + * @param {String|Number} applicationId + * @param {String} status + */ +export function updateApplicationStatus (engagementId, applicationId, status) { + return async (dispatch) => { + dispatch({ + type: UPDATE_APPLICATION_STATUS_PENDING + }) + + try { + const response = await updateApplicationStatusAPI(engagementId, applicationId, status) + return dispatch({ + type: UPDATE_APPLICATION_STATUS_SUCCESS, + application: _.get(response, 'data', {}) + }) + } catch (error) { + dispatch({ + type: UPDATE_APPLICATION_STATUS_FAILURE, + error + }) + return Promise.reject(error) + } + } +} diff --git a/src/actions/engagements.js b/src/actions/engagements.js new file mode 100644 index 00000000..0da7fe48 --- /dev/null +++ b/src/actions/engagements.js @@ -0,0 +1,227 @@ +import _ from 'lodash' +import { + fetchEngagements, + fetchEngagement, + createEngagement as createEngagementAPI, + updateEngagement as updateEngagementAPI, + patchEngagement, + deleteEngagement as deleteEngagementAPI +} from '../services/engagements' +import { + LOAD_ENGAGEMENTS_PENDING, + LOAD_ENGAGEMENTS_SUCCESS, + LOAD_ENGAGEMENTS_FAILURE, + LOAD_ENGAGEMENT_DETAILS_PENDING, + LOAD_ENGAGEMENT_DETAILS_SUCCESS, + LOAD_ENGAGEMENT_DETAILS_FAILURE, + CREATE_ENGAGEMENT_PENDING, + CREATE_ENGAGEMENT_SUCCESS, + CREATE_ENGAGEMENT_FAILURE, + UPDATE_ENGAGEMENT_DETAILS_PENDING, + UPDATE_ENGAGEMENT_DETAILS_SUCCESS, + UPDATE_ENGAGEMENT_DETAILS_FAILURE, + DELETE_ENGAGEMENT_PENDING, + DELETE_ENGAGEMENT_SUCCESS, + DELETE_ENGAGEMENT_FAILURE +} from '../config/constants' + +/** + * Loads engagements for a project + * @param {String|Number} projectId + * @param {String} status + * @param {String} filterName + */ +export function loadEngagements (projectId, status = 'all', filterName = '') { + return async (dispatch) => { + dispatch({ + type: LOAD_ENGAGEMENTS_PENDING + }) + + const filters = {} + if (projectId) { + filters.projectId = projectId + } + if (status && status !== 'all') { + filters.status = status + } + if (!_.isEmpty(filterName)) { + filters.title = filterName + } + + try { + const response = await fetchEngagements(filters) + dispatch({ + type: LOAD_ENGAGEMENTS_SUCCESS, + engagements: _.get(response, 'data', []) + }) + } catch (error) { + dispatch({ + type: LOAD_ENGAGEMENTS_FAILURE, + error + }) + } + } +} + +/** + * Loads engagement details + * @param {String|Number} projectId + * @param {String|Number} engagementId + */ +export function loadEngagementDetails (projectId, engagementId) { + return async (dispatch) => { + void projectId + if (!engagementId) { + return dispatch({ + type: LOAD_ENGAGEMENT_DETAILS_SUCCESS, + engagementDetails: {} + }) + } + + dispatch({ + type: LOAD_ENGAGEMENT_DETAILS_PENDING + }) + + try { + const response = await fetchEngagement(engagementId) + return dispatch({ + type: LOAD_ENGAGEMENT_DETAILS_SUCCESS, + engagementDetails: _.get(response, 'data', {}) + }) + } catch (error) { + dispatch({ + type: LOAD_ENGAGEMENT_DETAILS_FAILURE, + error + }) + return Promise.reject(error) + } + } +} + +/** + * Creates engagement + * @param {Object} engagementDetails + * @param {String|Number} projectId + */ +export function createEngagement (engagementDetails, projectId) { + return async (dispatch) => { + dispatch({ + type: CREATE_ENGAGEMENT_PENDING + }) + + if (!projectId) { + const error = new Error('Project ID is required to create engagement.') + dispatch({ + type: CREATE_ENGAGEMENT_FAILURE, + error + }) + return Promise.reject(error) + } + + const payload = { + ...engagementDetails, + projectId + } + + try { + const response = await createEngagementAPI(payload) + return dispatch({ + type: CREATE_ENGAGEMENT_SUCCESS, + engagementDetails: _.get(response, 'data', {}) + }) + } catch (error) { + dispatch({ + type: CREATE_ENGAGEMENT_FAILURE, + error + }) + return Promise.reject(error) + } + } +} + +/** + * Updates engagement details + * @param {String|Number} engagementId + * @param {Object} engagementDetails + * @param {String|Number} projectId + */ +export function updateEngagementDetails (engagementId, engagementDetails, projectId) { + return async (dispatch) => { + void projectId + dispatch({ + type: UPDATE_ENGAGEMENT_DETAILS_PENDING + }) + + try { + const response = await updateEngagementAPI(engagementId, engagementDetails) + return dispatch({ + type: UPDATE_ENGAGEMENT_DETAILS_SUCCESS, + engagementDetails: _.get(response, 'data', {}) + }) + } catch (error) { + dispatch({ + type: UPDATE_ENGAGEMENT_DETAILS_FAILURE, + error + }) + return Promise.reject(error) + } + } +} + +/** + * Partially updates engagement details + * @param {String|Number} engagementId + * @param {Object} partialDetails + * @param {String|Number} projectId + */ +export function partiallyUpdateEngagementDetails (engagementId, partialDetails, projectId) { + return async (dispatch) => { + void projectId + dispatch({ + type: UPDATE_ENGAGEMENT_DETAILS_PENDING + }) + + try { + const response = await patchEngagement(engagementId, partialDetails) + return dispatch({ + type: UPDATE_ENGAGEMENT_DETAILS_SUCCESS, + engagementDetails: _.get(response, 'data', {}) + }) + } catch (error) { + dispatch({ + type: UPDATE_ENGAGEMENT_DETAILS_FAILURE, + error + }) + return Promise.reject(error) + } + } +} + +/** + * Deletes engagement + * @param {String|Number} engagementId + * @param {String|Number} projectId + */ +export function deleteEngagement (engagementId, projectId) { + return async (dispatch) => { + void projectId + dispatch({ + type: DELETE_ENGAGEMENT_PENDING + }) + + try { + const response = await deleteEngagementAPI(engagementId) + return dispatch({ + type: DELETE_ENGAGEMENT_SUCCESS, + engagementDetails: _.get(response, 'data', {}), + engagementId + }) + } catch (error) { + dispatch({ + type: DELETE_ENGAGEMENT_FAILURE, + error + }) + return Promise.reject(error) + } + } +} diff --git a/src/components/ApplicationDetail/ApplicationDetail.module.scss b/src/components/ApplicationDetail/ApplicationDetail.module.scss new file mode 100644 index 00000000..9b02c0cc --- /dev/null +++ b/src/components/ApplicationDetail/ApplicationDetail.module.scss @@ -0,0 +1,127 @@ +@use '../../styles/includes' as *; + +.container { + width: 100%; + max-width: 900px; +} + +.header { + margin-bottom: 20px; +} + +.title { + font-size: 24px; + font-weight: 700; + color: $challenges-title; +} + +.subtitle { + margin-top: 6px; + color: $tc-gray-70; + font-size: 14px; +} + +.section { + margin-bottom: 24px; +} + +.sectionTitle { + font-size: 16px; + font-weight: 600; + color: $tc-gray-80; + margin-bottom: 12px; +} + +.detailGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px 20px; +} + +.detailItem { + font-size: 14px; +} + +.label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.4px; + color: $tc-gray-60; + margin-bottom: 4px; +} + +.value { + color: $tc-gray-80; +} + +.status { + padding: 4px 8px; + border-radius: 12px; + font-size: 12px; + display: inline-block; + text-transform: capitalize; +} + +.statusSubmitted { + background: $tc-gray-20; + color: $tc-gray-80; +} + +.statusUnderReview { + background: $tc-yellow; + color: $tc-gray-90; +} + +.statusAccepted { + background: $tc-green-20; + color: $tc-gray-90; +} + +.statusRejected { + background: $tc-red; + color: $white; +} + +.statusControl { + min-width: 200px; +} + +.links { + display: flex; + flex-direction: column; + gap: 12px; + font-size: 14px; +} + +.linkRow { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.linkLabel { + min-width: 80px; + font-weight: 600; + color: $tc-gray-70; +} + +.linkList { + display: flex; + flex-direction: column; + gap: 6px; +} + +.coverLetter { + background: $tc-gray-10; + border-radius: 6px; + padding: 12px; + font-size: 14px; + color: $tc-gray-80; + white-space: pre-wrap; +} + +.footer { + display: flex; + justify-content: flex-end; + margin-top: 24px; +} diff --git a/src/components/ApplicationDetail/index.js b/src/components/ApplicationDetail/index.js new file mode 100644 index 00000000..af30a05d --- /dev/null +++ b/src/components/ApplicationDetail/index.js @@ -0,0 +1,201 @@ +import React from 'react' +import PropTypes from 'prop-types' +import moment from 'moment-timezone' +import Modal from '../Modal' +import Select from '../Select' +import { OutlineButton } from '../Buttons' +import styles from './ApplicationDetail.module.scss' + +const STATUS_OPTIONS = [ + { label: 'Submitted', value: 'submitted' }, + { label: 'Under Review', value: 'under_review' }, + { label: 'Accepted', value: 'accepted' }, + { label: 'Rejected', value: 'rejected' } +] + +const formatDateTime = (value) => { + if (!value) { + return '-' + } + return moment(value).format('MMM DD, YYYY HH:mm') +} + +const getStatusClass = (status) => { + const normalized = (status || '').toString().toLowerCase().replace(/\s+/g, '_') + if (normalized === 'submitted') { + return styles.statusSubmitted + } + if (normalized === 'under_review') { + return styles.statusUnderReview + } + if (normalized === 'accepted') { + return styles.statusAccepted + } + if (normalized === 'rejected') { + return styles.statusRejected + } + return styles.statusSubmitted +} + +const getStatusLabel = (status) => { + const match = STATUS_OPTIONS.find(option => option.value === status) + if (match) { + return match.label + } + if (!status) { + return '-' + } + return status.toString().replace(/_/g, ' ') +} + +const ApplicationDetail = ({ application, engagement, canManage, onUpdateStatus, onClose }) => { + if (!application) { + return null + } + + const statusLabel = getStatusLabel(application.status) + const statusClass = getStatusClass(application.status) + const statusOption = STATUS_OPTIONS.find(option => option.value === application.status) || null + const portfolioUrls = Array.isArray(application.portfolioUrls) ? application.portfolioUrls : [] + + return ( + +
+
+
Application Details
+ {engagement && engagement.title && ( +
{engagement.title}
+ )} +
+ +
+
Applicant
+
+
+
Name
+
{application.name || '-'}
+
+
+
Email
+
{application.email || '-'}
+
+
+
Address
+
{application.address || '-'}
+
+
+
+ +
+
Application
+
+
+
Applied Date
+
{formatDateTime(application.createdAt)}
+
+
+
Years of Experience
+
+ {application.yearsOfExperience != null ? application.yearsOfExperience : '-'} +
+
+
+
Availability
+
{application.availability || '-'}
+
+
+
Status
+
+ {statusLabel} +
+
+ {canManage && ( +
+
Update Status
+
+ setStatusFilter(option || STATUS_OPTIONS[0])} + isClearable={false} + /> +
+ + {filteredApplications.length === 0 ? ( +
No applications found.
+ ) : ( + + + + + + + + + + + + + + {filteredApplications.map((application) => { + const statusLabel = getStatusLabel(application.status) + const statusClass = getStatusClass(application.status) + const statusOption = STATUS_UPDATE_OPTIONS.find(option => option.value === application.status) || null + + return ( + + + + + + + + + + ) + })} + +
Applicant NameEmailApplied DateYears of ExperienceAvailabilityStatusActions
{application.name || '-'}{application.email || '-'}{formatDateTime(application.createdAt)}{application.yearsOfExperience != null ? application.yearsOfExperience : '-'}{application.availability || '-'} + + {statusLabel} + + +
+ setSelectedApplication(application)} + /> + {canManage && ( +
+
+ )} +
+ ) +} + +ApplicationsList.defaultProps = { + applications: [], + engagement: null, + isLoading: false, + canManage: false, + onUpdateStatus: () => {} +} + +ApplicationsList.propTypes = { + applications: PropTypes.arrayOf(PropTypes.shape()), + engagement: PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + title: PropTypes.string, + description: PropTypes.string, + applicationDeadline: PropTypes.any + }), + projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + isLoading: PropTypes.bool, + canManage: PropTypes.bool, + onUpdateStatus: PropTypes.func +} + +export default ApplicationsList diff --git a/src/components/EngagementEditor/EngagementEditor.module.scss b/src/components/EngagementEditor/EngagementEditor.module.scss new file mode 100644 index 00000000..dafad9eb --- /dev/null +++ b/src/components/EngagementEditor/EngagementEditor.module.scss @@ -0,0 +1,144 @@ +@use '../../styles/includes' as *; + +.wrapper { + box-sizing: border-box; + display: flex; + flex-direction: column; + margin-bottom: 30px; + position: relative; +} + +.topContainer { + margin-top: 30px; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 20px; +} + +.leftContainer { + display: flex; + align-items: center; +} + +.title { + font-size: 24px; + font-weight: 700; + line-height: 29px; + margin-right: 5px; + color: $challenges-title; +} + +.textRequired { + @include roboto-medium(); + + color: $tc-red; + font-size: 12px; + font-weight: 500; + line-height: 14px; + align-self: flex-end; + margin-top: 30px; + margin-right: 30px; + margin-bottom: 2px; +} + +.container { + width: 100%; + + .formContainer { + box-sizing: border-box; + margin: 0 30px 30px 30px; + @include form-container-default(); + + form { + width: 100%; + } + } +} + +.row { + display: flex; + align-items: flex-start; + margin: 20px 30px; +} + +.field { + &.col1 { + max-width: 185px; + min-width: 185px; + margin-right: 14px; + margin-top: 8px; + display: flex; + align-items: center; + + span { + color: $tc-red; + } + } + + &.col2 { + width: 100%; + display: flex; + flex-direction: column; + } +} + +.input, +.textarea, +.selectInput { + width: 100%; + padding: 10px 12px; + border: 1px solid $tc-gray-30; + border-radius: 4px; + font-size: 14px; +} + +.textarea { + min-height: 140px; +} + +.inlineFields { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.inlineField { + flex: 1 1 200px; + min-width: 200px; +} + +.durationDivider { + margin: 10px 0; + color: $tc-gray-60; + font-size: 12px; +} + +.error { + color: $tc-red; + font-size: 12px; + margin-top: 6px; +} + +.actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-left: auto; +} + +.buttonGroup { + display: flex; + gap: 12px; +} + +.editor { + border: 1px solid $tc-gray-30; + border-radius: 4px; + overflow: hidden; +} + +.readOnlyValue { + padding: 10px 0; + color: $tc-gray-80; +} diff --git a/src/components/EngagementEditor/index.js b/src/components/EngagementEditor/index.js new file mode 100644 index 00000000..d188fb74 --- /dev/null +++ b/src/components/EngagementEditor/index.js @@ -0,0 +1,477 @@ +import React, { useMemo } from 'react' +import PropTypes from 'prop-types' +import { Helmet } from 'react-helmet' +import moment from 'moment-timezone' +import cn from 'classnames' +import { PrimaryButton, OutlineButton } from '../Buttons' +import TuiEditor from '../TuiEditor' +import DateInput from '../DateInput' +import Select from '../Select' +import SkillsField from '../ChallengeEditor/SkillsField' +import ConfirmationModal from '../Modal/ConfirmationModal' +import Loader from '../Loader' +import styles from './EngagementEditor.module.scss' + +const ANY_OPTION = { label: 'Any', value: 'Any' } + +const normalizeAnySelection = (selectedOptions) => { + if (!selectedOptions || !selectedOptions.length) { + return [] + } + const hasAny = selectedOptions.some(option => option.value === ANY_OPTION.value) + if (hasAny) { + return [ANY_OPTION] + } + return selectedOptions +} + +const EngagementEditor = ({ + engagement, + isNew, + isLoading, + isSaving, + canEdit, + submitTriggered, + validationErrors, + showDeleteModal, + onToggleDelete, + onUpdateInput, + onUpdateDescription, + onUpdateSkills, + onUpdateDate, + onSaveDraft, + onSavePublish, + onCancel, + onDelete +}) => { + const timeZoneOptions = useMemo(() => { + const zones = moment.tz.names().map(zone => ({ + label: zone, + value: zone + })) + return [ANY_OPTION, ...zones] + }, []) + + const countryOptions = useMemo(() => { + const displayNames = typeof Intl !== 'undefined' && Intl.DisplayNames + ? new Intl.DisplayNames(['en'], { type: 'region' }) + : null + const countries = moment.tz.countries().map(code => ({ + label: displayNames ? displayNames.of(code) : code, + value: code + })) + countries.sort((a, b) => a.label.localeCompare(b.label)) + return [ANY_OPTION, ...countries] + }, []) + + const countryOptionsByValue = useMemo(() => { + return countryOptions.reduce((acc, option) => { + acc[option.value] = option + return acc + }, {}) + }, [countryOptions]) + + const selectedTimeZones = (engagement.timezones || []).map(zone => ({ + label: zone, + value: zone + })) + + const selectedCountries = (engagement.countries || []).map(code => { + return countryOptionsByValue[code] || { label: code, value: code } + }) + + if (isLoading) { + return + } + + return ( +
+ + {showDeleteModal && ( + + )} +
+
+
+ {isNew ? 'New Engagement' : (engagement.title || 'Engagement')} +
+
+
+ {engagement.id && canEdit && ( + + )} + + {canEdit && ( + + )} + {canEdit && ( + + )} +
+
+
* Required
+
+
+
+
+
+ +
+
+ {canEdit ? ( + + ) : ( +
{engagement.title || '-'}
+ )} + {submitTriggered && validationErrors.title && ( +
{validationErrors.title}
+ )} +
+
+ +
+
+ +
+
+ {canEdit ? ( + + ) : ( +
{engagement.description || '-'}
+ )} + {submitTriggered && validationErrors.description && ( +
{validationErrors.description}
+ )} +
+
+ +
+
+ +
+
+
+
+ Start Date + {canEdit ? ( + onUpdateDate('startDate', value)} + /> + ) : ( +
+ {engagement.startDate ? moment(engagement.startDate).format('MMM DD, YYYY') : '-'} +
+ )} +
+
+ End Date + {canEdit ? ( + onUpdateDate('endDate', value)} + /> + ) : ( +
+ {engagement.endDate ? moment(engagement.endDate).format('MMM DD, YYYY') : '-'} +
+ )} +
+
+
or
+
+
+ Duration + {canEdit ? ( + + ) : ( +
{engagement.durationAmount || '-'}
+ )} +
+
+ Unit + {canEdit ? ( + { + const normalized = normalizeAnySelection(values) + onUpdateInput({ + target: { + name: 'timezones', + value: normalized.map(option => option.value) + } + }) + }} + /> + ) : ( +
+ {(engagement.timezones || []).length ? engagement.timezones.join(', ') : 'Any'} +
+ )} +
+
+ +
+
+ +
+
+ {canEdit ? ( + ({ + label: status, + value: status + }))} + onChange={(option) => onUpdateInput({ + target: { + name: 'status', + value: option ? option.value : '' + } + })} + isClearable={false} + /> + ) : ( +
{engagement.status || '-'}
+ )} + {submitTriggered && validationErrors.status && ( +
{validationErrors.status}
+ )} +
+
+ +
+
+
+ ) +} + +EngagementEditor.defaultProps = { + engagement: { + title: '', + description: '', + timezones: [], + countries: [], + skills: [], + durationAmount: '', + durationUnit: 'weeks', + status: 'Open' + }, + isNew: true, + isLoading: false, + isSaving: false, + canEdit: true, + submitTriggered: false, + validationErrors: {}, + showDeleteModal: false, + onToggleDelete: () => {}, + onUpdateInput: () => {}, + onUpdateDescription: () => {}, + onUpdateSkills: () => {}, + onUpdateDate: () => {}, + onSaveDraft: () => {}, + onSavePublish: () => {}, + onCancel: () => {}, + onDelete: () => {} +} + +EngagementEditor.propTypes = { + engagement: PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + title: PropTypes.string, + description: PropTypes.string, + startDate: PropTypes.any, + endDate: PropTypes.any, + durationAmount: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + durationUnit: PropTypes.string, + timezones: PropTypes.arrayOf(PropTypes.string), + countries: PropTypes.arrayOf(PropTypes.string), + skills: PropTypes.arrayOf(PropTypes.shape()), + applicationDeadline: PropTypes.any, + status: PropTypes.string + }), + isNew: PropTypes.bool, + isLoading: PropTypes.bool, + isSaving: PropTypes.bool, + canEdit: PropTypes.bool, + submitTriggered: PropTypes.bool, + validationErrors: PropTypes.shape({ + title: PropTypes.string, + description: PropTypes.string, + duration: PropTypes.string, + applicationDeadline: PropTypes.string, + skills: PropTypes.string, + status: PropTypes.string + }), + showDeleteModal: PropTypes.bool, + onToggleDelete: PropTypes.func, + onUpdateInput: PropTypes.func, + onUpdateDescription: PropTypes.func, + onUpdateSkills: PropTypes.func, + onUpdateDate: PropTypes.func, + onSaveDraft: PropTypes.func, + onSavePublish: PropTypes.func, + onCancel: PropTypes.func, + onDelete: PropTypes.func +} + +export default EngagementEditor diff --git a/src/components/EngagementsList/EngagementsList.module.scss b/src/components/EngagementsList/EngagementsList.module.scss new file mode 100644 index 00000000..89595fd1 --- /dev/null +++ b/src/components/EngagementsList/EngagementsList.module.scss @@ -0,0 +1,86 @@ +@use '../../styles/includes' as *; + +.container { + padding: 20px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.title { + font-size: 24px; + font-weight: 700; + color: $challenges-title; +} + +.filters { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.filterInput { + min-width: 200px; +} + +.table { + width: 100%; + border-collapse: collapse; + background: $white; + border-radius: 6px; + overflow: hidden; +} + +.table th, +.table td { + text-align: left; + padding: 12px 16px; + border-bottom: 1px solid $tc-gray-20; + font-size: 14px; +} + +.table th { + background: $tc-gray-10; + font-weight: 600; +} + +.status { + padding: 4px 8px; + border-radius: 12px; + font-size: 12px; + display: inline-block; +} + +.statusOpen { + background: $tc-green-20; + color: $tc-gray-90; +} + +.statusPendingAssignment { + background: $tc-yellow; + color: $tc-gray-90; +} + +.statusClosed { + background: $tc-gray-30; + color: $tc-gray-80; +} + +.actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.emptyState { + padding: 40px; + text-align: center; + color: $tc-gray-70; +} diff --git a/src/components/EngagementsList/index.js b/src/components/EngagementsList/index.js new file mode 100644 index 00000000..35bd67ac --- /dev/null +++ b/src/components/EngagementsList/index.js @@ -0,0 +1,240 @@ +import React, { useMemo, useState } from 'react' +import PropTypes from 'prop-types' +import moment from 'moment-timezone' +import { PrimaryButton, OutlineButton } from '../Buttons' +import ConfirmationModal from '../Modal/ConfirmationModal' +import Loader from '../Loader' +import Select from '../Select' +import styles from './EngagementsList.module.scss' + +const STATUS_OPTIONS = [ + { label: 'All', value: 'all' }, + { label: 'Open', value: 'Open' }, + { label: 'Pending Assignment', value: 'Pending Assignment' }, + { label: 'Closed', value: 'Closed' } +] + +const SORT_OPTIONS = [ + { label: 'Application Deadline', value: 'deadline' }, + { label: 'Created Date', value: 'createdAt' } +] + +const SORT_ORDER_OPTIONS = [ + { label: 'Ascending', value: 'asc' }, + { label: 'Descending', value: 'desc' } +] + +const formatDate = (value) => { + if (!value) { + return '-' + } + return moment(value).format('MMM DD, YYYY') +} + +const getSortValue = (engagement, sortBy) => { + if (sortBy === 'deadline') { + return engagement.applicationDeadline || engagement.application_deadline || null + } + return engagement.createdAt || engagement.createdOn || engagement.created || null +} + +const EngagementsList = ({ + engagements, + projectId, + projectDetail, + isLoading, + canManage, + onDeleteEngagement +}) => { + const [searchText, setSearchText] = useState('') + const [statusFilter, setStatusFilter] = useState(STATUS_OPTIONS[0]) + const [sortBy, setSortBy] = useState(SORT_OPTIONS[0]) + const [sortOrder, setSortOrder] = useState(SORT_ORDER_OPTIONS[0]) + const [pendingDelete, setPendingDelete] = useState(null) + const [isDeleting, setIsDeleting] = useState(false) + + const filteredEngagements = useMemo(() => { + 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)) + } + const sorted = [...results].sort((a, b) => { + const valueA = getSortValue(a, sortBy.value) + const valueB = getSortValue(b, sortBy.value) + const dateA = valueA ? new Date(valueA).getTime() : 0 + const dateB = valueB ? new Date(valueB).getTime() : 0 + return sortOrder.value === 'asc' ? dateA - dateB : dateB - dateA + }) + return sorted + }, [engagements, statusFilter, searchText, sortBy, sortOrder]) + + const handleDelete = async () => { + if (!pendingDelete) { + return + } + try { + setIsDeleting(true) + await onDeleteEngagement(pendingDelete.id, projectId) + setIsDeleting(false) + setPendingDelete(null) + } catch (error) { + setIsDeleting(false) + } + } + + if (isLoading) { + return + } + + return ( +
+ {pendingDelete && ( + setPendingDelete(null)} + onConfirm={handleDelete} + /> + )} +
+
+ {projectDetail && projectDetail.name ? `${projectDetail.name} Engagements` : 'Engagements'} +
+ {canManage && ( + + )} +
+
+ setSearchText(event.target.value)} + placeholder='Search by title' + /> + setSortBy(option || SORT_OPTIONS[0])} + isClearable={false} + /> + + {readOnly ? ( +
{existingSkills || '-'}
+ ) : ( + setSearchText(event.target.value)} - placeholder='Search by title' - /> - setSortBy(option || SORT_OPTIONS[0])} - isClearable={false} - /> - setSearchText(event.target.value)} + placeholder='Search by title' + /> +
+
+ setSortBy(option || SORT_OPTIONS[0])} + isClearable={false} + /> +
+
+ - ) : ( -
{engagement.durationAmount || '-'}
- )} -
-
- Unit - {canEdit ? ( - + ) : ( +
{engagement.durationAmount || '-'}
+ )} +
+
+ Unit + {canEdit ? ( + + ) : ( +
+ {engagement.durationWeeks ? `${engagement.durationWeeks} weeks` : '-'}
-
- {showDurationFields && ( - <> -
or
-
-
- Duration - {canEdit ? ( - - ) : ( -
{engagement.durationAmount || '-'}
- )} -
-
- Unit - {canEdit ? ( - onUpdateInput({ + target: { + name: 'role', + value: option && option.value ? option.label : null + } + })} + isClearable + /> + ) : ( +
{roleLabel || 'Not specified'}
+ )} +
+
+ +
+
+ +
+
+ {canEdit ? ( + + ) : ( +
+ {engagement.compensationRange || 'Not specified'} +
)}
@@ -425,8 +441,10 @@ EngagementEditor.defaultProps = { timezones: [], countries: [], skills: [], - durationAmount: '', - durationUnit: 'weeks', + durationWeeks: '', + role: null, + workload: null, + compensationRange: '', status: 'Open' }, isNew: true, @@ -441,7 +459,6 @@ EngagementEditor.defaultProps = { onUpdateDescription: () => {}, onUpdateSkills: () => {}, onUpdateDate: () => {}, - onSaveDraft: () => {}, onSavePublish: () => {}, onCancel: () => {}, onDelete: () => {} @@ -452,10 +469,10 @@ EngagementEditor.propTypes = { id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), title: PropTypes.string, description: PropTypes.string, - startDate: PropTypes.any, - endDate: PropTypes.any, - durationAmount: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - durationUnit: PropTypes.string, + durationWeeks: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + role: PropTypes.string, + workload: PropTypes.string, + compensationRange: PropTypes.string, timezones: PropTypes.arrayOf(PropTypes.string), countries: PropTypes.arrayOf(PropTypes.string), skills: PropTypes.arrayOf(PropTypes.shape()), @@ -470,7 +487,7 @@ EngagementEditor.propTypes = { validationErrors: PropTypes.shape({ title: PropTypes.string, description: PropTypes.string, - duration: PropTypes.string, + durationWeeks: PropTypes.string, applicationDeadline: PropTypes.string, skills: PropTypes.string, status: PropTypes.string @@ -481,7 +498,6 @@ EngagementEditor.propTypes = { onUpdateDescription: PropTypes.func, onUpdateSkills: PropTypes.func, onUpdateDate: PropTypes.func, - onSaveDraft: PropTypes.func, onSavePublish: PropTypes.func, onCancel: PropTypes.func, onDelete: PropTypes.func diff --git a/src/components/Select/styles.js b/src/components/Select/styles.js index 7a0c05b1..d7f0d239 100644 --- a/src/components/Select/styles.js +++ b/src/components/Select/styles.js @@ -32,6 +32,10 @@ export default { margin: 0, padding: 0 }), + menuPortal: (provided) => ({ + ...provided, + zIndex: 10000 + }), menuList: (provided) => ({ ...provided, padding: 0 diff --git a/src/containers/EngagementEditor/index.js b/src/containers/EngagementEditor/index.js index a34138ed..a93ed3aa 100644 --- a/src/containers/EngagementEditor/index.js +++ b/src/containers/EngagementEditor/index.js @@ -17,6 +17,10 @@ import { checkAdmin, checkManager } from '../../util/tc' import { PROJECT_ROLES } from '../../config/constants' import { normalizeEngagement as normalizeEngagementShape, + fromEngagementRoleApi, + fromEngagementWorkloadApi, + toEngagementRoleApi, + toEngagementWorkloadApi, toEngagementStatusApi } from '../../util/engagements' @@ -24,10 +28,10 @@ const getEmptyEngagement = () => ({ id: null, title: '', description: '', - startDate: null, - endDate: null, - durationAmount: '', - durationUnit: 'weeks', + durationWeeks: '', + role: null, + workload: null, + compensationRange: '', timezones: [], countries: [], skills: [], @@ -50,7 +54,6 @@ class EngagementEditorContainer extends Component { this.onUpdateDescription = this.onUpdateDescription.bind(this) this.onUpdateSkills = this.onUpdateSkills.bind(this) this.onUpdateDate = this.onUpdateDate.bind(this) - this.onSaveDraft = this.onSaveDraft.bind(this) this.onSavePublish = this.onSavePublish.bind(this) this.onCancel = this.onCancel.bind(this) this.onDelete = this.onDelete.bind(this) @@ -88,10 +91,12 @@ class EngagementEditorContainer extends Component { } } + const nextEngagementDetailsId = _.get(nextProps.engagementDetails, 'id', null) if ( - nextProps.engagementDetails && - nextProps.engagementDetails.id && - nextProps.engagementDetails.id !== this.state.engagement.id + nextEngagementId && + nextEngagementDetailsId && + `${nextEngagementDetailsId}` === `${nextEngagementId}` && + nextEngagementDetailsId !== this.state.engagement.id ) { this.setState({ engagement: this.normalizeEngagement(nextProps.engagementDetails), @@ -110,14 +115,21 @@ class EngagementEditorContainer extends Component { normalizeEngagement (details) { const normalized = normalizeEngagementShape(details) const duration = normalized.duration || {} + const rawDurationWeeks = normalized.durationWeeks != null && normalized.durationWeeks !== '' + ? normalized.durationWeeks + : duration.unit === 'weeks' && duration.amount != null && duration.amount !== '' + ? duration.amount + : '' + const parsedDurationWeeks = rawDurationWeeks !== '' ? parseInt(rawDurationWeeks, 10) : '' + const durationWeeks = Number.isNaN(parsedDurationWeeks) ? '' : parsedDurationWeeks return { ...getEmptyEngagement(), ...normalized, - startDate: normalized.startDate ? moment(normalized.startDate).toDate() : null, - endDate: normalized.endDate ? moment(normalized.endDate).toDate() : null, + durationWeeks, + role: fromEngagementRoleApi(normalized.role), + workload: fromEngagementWorkloadApi(normalized.workload), + compensationRange: normalized.compensationRange || '', applicationDeadline: normalized.applicationDeadline ? moment(normalized.applicationDeadline).toDate() : null, - durationAmount: normalized.durationAmount || duration.amount || '', - durationUnit: normalized.durationUnit || duration.unit || 'weeks', timezones: normalized.timezones || [], countries: normalized.countries || [], skills: normalized.skills || [] @@ -182,10 +194,16 @@ class EngagementEditorContainer extends Component { errors.description = 'Description is required' } - const hasDateRange = Boolean(engagement.startDate && engagement.endDate) - const hasDuration = Boolean(engagement.durationAmount) - if (!hasDateRange && !hasDuration) { - errors.duration = 'Start date and end date are required' + const durationValue = engagement.durationWeeks + const parsedDurationWeeks = Number(durationValue) + if ( + durationValue === '' || + durationValue == null || + Number.isNaN(parsedDurationWeeks) || + !Number.isInteger(parsedDurationWeeks) || + parsedDurationWeeks < 4 + ) { + errors.durationWeeks = 'Duration must be at least 4 weeks' } if (!engagement.applicationDeadline) { @@ -204,8 +222,6 @@ class EngagementEditorContainer extends Component { } buildPayload (engagement, isDraft) { - const hasDateRange = Boolean(engagement.startDate && engagement.endDate) - const hasDuration = Boolean(engagement.durationAmount) const status = engagement.status || (isDraft ? 'Open' : '') const requiredSkills = (engagement.skills || []) .map((skill) => { @@ -231,23 +247,26 @@ class EngagementEditorContainer extends Component { status: toEngagementStatusApi(status) } - if (hasDateRange) { - payload.durationStartDate = moment(engagement.startDate).toISOString() - payload.durationEndDate = moment(engagement.endDate).toISOString() - } else if (hasDuration) { - const amount = Number(engagement.durationAmount) - if ((engagement.durationUnit || 'weeks') === 'months') { - payload.durationMonths = amount - } else { - payload.durationWeeks = amount + if (engagement.durationWeeks !== '' && engagement.durationWeeks != null) { + const durationWeeks = parseInt(engagement.durationWeeks, 10) + if (!Number.isNaN(durationWeeks)) { + payload.durationWeeks = durationWeeks } } - return payload - } + if (engagement.role) { + payload.role = toEngagementRoleApi(engagement.role) + } - async onSaveDraft () { - await this.onSave(true) + if (engagement.workload) { + payload.workload = toEngagementWorkloadApi(engagement.workload) + } + + if (engagement.compensationRange) { + payload.compensationRange = engagement.compensationRange + } + + return payload } async onSavePublish () { @@ -348,7 +367,6 @@ class EngagementEditorContainer extends Component { onUpdateDescription={this.onUpdateDescription} onUpdateSkills={this.onUpdateSkills} onUpdateDate={this.onUpdateDate} - onSaveDraft={this.onSaveDraft} onSavePublish={this.onSavePublish} onCancel={this.onCancel} onDelete={this.onDelete} diff --git a/src/util/engagements.js b/src/util/engagements.js index 50a99003..24d8454c 100644 --- a/src/util/engagements.js +++ b/src/util/engagements.js @@ -10,6 +10,30 @@ const STATUS_TO_API = { Closed: 'CLOSED' } +const ROLE_TO_API = { + designer: 'DESIGNER', + 'software-developer': 'SOFTWARE_DEVELOPER', + 'data-scientist': 'DATA_SCIENTIST', + 'data-engineer': 'DATA_ENGINEER' +} + +const ROLE_FROM_API = { + DESIGNER: 'Designer', + SOFTWARE_DEVELOPER: 'Software Developer', + DATA_SCIENTIST: 'Data Scientist', + DATA_ENGINEER: 'Data Engineer' +} + +const WORKLOAD_TO_API = { + fulltime: 'FULL_TIME', + fractional: 'FRACTIONAL' +} + +const WORKLOAD_FROM_API = { + FULL_TIME: 'Full-Time', + FRACTIONAL: 'Fractional' +} + export const toEngagementStatusApi = (status) => { if (!status) { return status @@ -33,6 +57,52 @@ export const fromEngagementStatusApi = (status) => { return STATUS_LABELS[normalized] || status } +export const toEngagementRoleApi = (role) => { + if (!role) { + return role + } + const normalized = role.toString().trim() + if (ROLE_TO_API[normalized]) { + return ROLE_TO_API[normalized] + } + const upper = normalized.toUpperCase().replace(/[\s-]+/g, '_') + if (ROLE_FROM_API[upper]) { + return upper + } + return role +} + +export const fromEngagementRoleApi = (role) => { + if (!role) { + return role + } + const normalized = role.toString().trim().toUpperCase().replace(/[\s-]+/g, '_') + return ROLE_FROM_API[normalized] || role +} + +export const toEngagementWorkloadApi = (workload) => { + if (!workload) { + return workload + } + const normalized = workload.toString().trim() + if (WORKLOAD_TO_API[normalized]) { + return WORKLOAD_TO_API[normalized] + } + const upper = normalized.toUpperCase().replace(/[\s-]+/g, '_') + if (WORKLOAD_FROM_API[upper]) { + return upper + } + return workload +} + +export const fromEngagementWorkloadApi = (workload) => { + if (!workload) { + return workload + } + const normalized = workload.toString().trim().toUpperCase().replace(/[\s-]+/g, '_') + return WORKLOAD_FROM_API[normalized] || workload +} + const normalizeSkill = (skill) => { if (!skill) { return null @@ -53,6 +123,10 @@ export const normalizeEngagement = (engagement = {}) => { return engagement } + const role = fromEngagementRoleApi(engagement.role) + const workload = fromEngagementWorkloadApi(engagement.workload) + const compensationRange = engagement.compensationRange || '' + const durationWeeks = engagement.durationWeeks const durationMonths = engagement.durationMonths let durationAmount = engagement.durationAmount @@ -87,15 +161,25 @@ export const normalizeEngagement = (engagement = {}) => { skills = requiredSkills.map(normalizeSkill).filter(Boolean) } + const normalizedDurationWeeks = durationWeeks != null && durationWeeks !== '' + ? durationWeeks + : durationUnit === 'weeks' && durationAmount != null && durationAmount !== '' + ? durationAmount + : '' + return { ...engagement, startDate, endDate, durationAmount, durationUnit, + durationWeeks: normalizedDurationWeeks, timezones, skills, - status: fromEngagementStatusApi(engagement.status) + status: fromEngagementStatusApi(engagement.status), + role, + workload, + compensationRange } } From 11fa6da68f86d792682c4e276f7f857bcfd1af7b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 14 Jan 2026 11:38:32 +1100 Subject: [PATCH 07/30] Navigation and usability tweaks for engagements --- .../EngagementsList.module.scss | 15 +++++++ src/components/EngagementsList/index.js | 42 +++++++++++++++---- src/components/Tab/Tab.module.scss | 4 ++ src/components/Tab/index.js | 32 ++++++++++++-- src/containers/Tab/index.js | 17 +++++++- 5 files changed, 98 insertions(+), 12 deletions(-) diff --git a/src/components/EngagementsList/EngagementsList.module.scss b/src/components/EngagementsList/EngagementsList.module.scss index 2129086a..16bf1dc7 100644 --- a/src/components/EngagementsList/EngagementsList.module.scss +++ b/src/components/EngagementsList/EngagementsList.module.scss @@ -90,6 +90,21 @@ flex-wrap: wrap; } +.applicationsLink { + color: $link-color; + text-decoration: none; + font-weight: 600; + + &:hover, + &:focus { + text-decoration: underline; + } + + &:visited { + color: $link-color; + } +} + .emptyState { padding: 40px; text-align: center; diff --git a/src/components/EngagementsList/index.js b/src/components/EngagementsList/index.js index 8546430b..5443d854 100644 --- a/src/components/EngagementsList/index.js +++ b/src/components/EngagementsList/index.js @@ -1,6 +1,7 @@ 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 ConfirmationModal from '../Modal/ConfirmationModal' import Loader from '../Loader' @@ -38,6 +39,25 @@ const getSortValue = (engagement, sortBy) => { return engagement.createdAt || engagement.createdOn || engagement.created || null } +const getApplicationsCount = (engagement) => { + if (!engagement) { + return 0 + } + if (typeof engagement.applicationsCount === 'number') { + return engagement.applicationsCount + } + if (typeof engagement.applicationCount === 'number') { + return engagement.applicationCount + } + if (engagement._count && typeof engagement._count.applications === 'number') { + return engagement._count.applications + } + if (Array.isArray(engagement.applications)) { + return engagement.applications.length + } + return 0 +} + const EngagementsList = ({ engagements, projectId, @@ -160,8 +180,8 @@ const EngagementsList = ({ Title Duration Location - Skills Application Deadline + Applications Status Actions @@ -178,7 +198,7 @@ const EngagementsList = ({ : '-' const timezones = (engagement.timezones || []).length ? engagement.timezones.join(', ') : 'Any' const countries = (engagement.countries || []).length ? engagement.countries.join(', ') : 'Any' - const skills = (engagement.skills || []).map(skill => skill.name || skill).join(', ') + const applicationsCount = getApplicationsCount(engagement) const statusClass = engagement.status === 'Open' ? styles.statusOpen : engagement.status === 'Pending Assignment' @@ -190,8 +210,19 @@ const EngagementsList = ({ {engagement.title || '-'} {duration} {`${timezones}${countries !== 'Any' ? ` / ${countries}` : ''}`} - {skills || '-'} {formatDate(engagement.applicationDeadline)} + + {engagement.id ? ( + + {applicationsCount} + + ) : ( + applicationsCount + )} + {engagement.status || '-'} @@ -211,11 +242,6 @@ const EngagementsList = ({ onClick={() => setPendingDelete(engagement)} /> )} -
diff --git a/src/components/Tab/Tab.module.scss b/src/components/Tab/Tab.module.scss index ff041d6d..941650df 100644 --- a/src/components/Tab/Tab.module.scss +++ b/src/components/Tab/Tab.module.scss @@ -81,6 +81,10 @@ } } + .backItem { + color: #2a2a2a; + } + .active { color: #0c0c0c; font-weight: 700; diff --git a/src/components/Tab/index.js b/src/components/Tab/index.js index 90d8addd..d656dbd7 100644 --- a/src/components/Tab/index.js +++ b/src/components/Tab/index.js @@ -7,7 +7,8 @@ const Tab = ({ currentTab, selectTab, projectId, - canViewAssets + canViewAssets, + onBack }) => { const projectTabs = [ { id: 1, label: 'Challenges' }, @@ -24,9 +25,32 @@ const Tab = ({ { id: 5, label: 'TaaS' } ] + const handleBack = () => { + onBack() + } + + const handleBackKeyDown = (event) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return + } + event.preventDefault() + handleBack() + } + return (
    + {projectId && ( +
  • + {'< Back'} +
  • + )} {tabs.map((tab) => (
  • {}, projectId: null, - canViewAssets: true + canViewAssets: true, + onBack: () => {} } Tab.propTypes = { selectTab: PT.func.isRequired, currentTab: PT.number.isRequired, projectId: PT.oneOfType([PT.string, PT.number]), - canViewAssets: PT.bool + canViewAssets: PT.bool, + onBack: PT.func } export default Tab diff --git a/src/containers/Tab/index.js b/src/containers/Tab/index.js index ed627e2a..1ffef396 100644 --- a/src/containers/Tab/index.js +++ b/src/containers/Tab/index.js @@ -19,6 +19,7 @@ class TabContainer extends Component { currentTab: 1 } this.onTabChange = this.onTabChange.bind(this) + this.onBackToHome = this.onBackToHome.bind(this) this.getProjectTabFromPath = this.getProjectTabFromPath.bind(this) this.getTabFromPath = this.getTabFromPath.bind(this) } @@ -143,6 +144,12 @@ class TabContainer extends Component { } } + onBackToHome () { + const { history, resetSidebarActiveParams } = this.props + history.push('/') + resetSidebarActiveParams() + } + onTabChange (tab) { const { history, resetSidebarActiveParams, projectId } = this.props const canViewAssets = this.getCanViewAssets() @@ -186,7 +193,15 @@ class TabContainer extends Component { const { currentTab } = this.state const canViewAssets = this.getCanViewAssets() - return + return ( + + ) } } From 97f497f6aeb175ff0c5258a40d3ca23e2f99c988 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 14 Jan 2026 12:07:28 +1100 Subject: [PATCH 08/30] Add feedback functionality --- src/components/EngagementsList/index.js | 7 + src/containers/EngagementFeedback/index.js | 558 ++++++++++++++++++ .../EngagementFeedback/styles.module.scss | 240 ++++++++ src/routes.js | 8 + src/services/engagements.js | 29 + 5 files changed, 842 insertions(+) create mode 100644 src/containers/EngagementFeedback/index.js create mode 100644 src/containers/EngagementFeedback/styles.module.scss diff --git a/src/components/EngagementsList/index.js b/src/components/EngagementsList/index.js index 5443d854..1a4ba7a7 100644 --- a/src/components/EngagementsList/index.js +++ b/src/components/EngagementsList/index.js @@ -235,6 +235,13 @@ const EngagementsList = ({ type='info' link={`/projects/${projectId}/engagements/${engagement.id}`} /> + {canManage && ( + + )} {canManage && ( { + const resolvedProjectId = useMemo(() => { + const value = projectId || _.get(match, 'params.projectId') + return value ? parseInt(value, 10) : null + }, [projectId, match]) + + const resolvedEngagementId = useMemo(() => { + return engagementId || _.get(match, 'params.engagementId') || null + }, [engagementId, match]) + + const canManage = useMemo(() => { + const isAdmin = checkAdmin(auth.token) + const isManager = checkManager(auth.token) + const members = _.get(projectDetail, 'members', []) + const userId = _.get(auth, 'user.userId') + const isProjectManager = members.some(member => member.userId === userId && member.role === PROJECT_ROLES.MANAGER) + return isAdmin || isManager || isProjectManager + }, [auth, projectDetail]) + + const [feedback, setFeedback] = useState([]) + const [feedbackLoading, setFeedbackLoading] = useState(false) + const [feedbackError, setFeedbackError] = useState('') + const [showAddFeedbackModal, setShowAddFeedbackModal] = useState(false) + const [showGenerateLinkModal, setShowGenerateLinkModal] = useState(false) + const [generatedLink, setGeneratedLink] = useState(null) + const [customerEmail, setCustomerEmail] = useState('') + const [feedbackText, setFeedbackText] = useState('') + const [rating, setRating] = useState('') + const [feedbackFormError, setFeedbackFormError] = useState('') + const [generateError, setGenerateError] = useState('') + const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false) + const [isGeneratingLink, setIsGeneratingLink] = useState(false) + + useEffect(() => { + if (resolvedProjectId) { + loadProject(resolvedProjectId) + } + if (resolvedEngagementId) { + loadEngagementDetails(resolvedProjectId, resolvedEngagementId) + } + }, [resolvedProjectId, resolvedEngagementId, loadProject, loadEngagementDetails]) + + const fetchFeedback = useCallback(async () => { + if (!resolvedEngagementId || !canManage) { + return + } + setFeedbackLoading(true) + setFeedbackError('') + try { + const response = await fetchEngagementFeedback(resolvedEngagementId) + const data = _.get(response, 'data', []) + setFeedback(Array.isArray(data) ? data : []) + } catch (err) { + const errorMessage = _.get(err, 'response.data.message') || (err && err.message) || 'Unable to load feedback.' + setFeedbackError(errorMessage) + toastr.error('Error', errorMessage) + } finally { + setFeedbackLoading(false) + } + }, [resolvedEngagementId, canManage]) + + useEffect(() => { + fetchFeedback() + }, [fetchFeedback]) + + const resetFeedbackForm = useCallback(() => { + setFeedbackText('') + setRating('') + setFeedbackFormError('') + setIsSubmittingFeedback(false) + }, []) + + const resetGenerateForm = useCallback(() => { + setCustomerEmail('') + setGeneratedLink(null) + setGenerateError('') + setIsGeneratingLink(false) + }, []) + + const handleCloseAddFeedbackModal = useCallback(() => { + setShowAddFeedbackModal(false) + resetFeedbackForm() + }, [resetFeedbackForm]) + + const handleCloseGenerateLinkModal = useCallback(() => { + setShowGenerateLinkModal(false) + resetGenerateForm() + }, [resetGenerateForm]) + + const handleAddFeedbackSubmit = useCallback(async (event) => { + event.preventDefault() + if (isSubmittingFeedback) { + return + } + if (!resolvedEngagementId) { + setFeedbackFormError('Engagement is required to submit feedback.') + return + } + const trimmedFeedback = feedbackText.trim() + if (!trimmedFeedback) { + setFeedbackFormError('Feedback is required.') + return + } + if (trimmedFeedback.length > FEEDBACK_TEXT_LIMIT) { + setFeedbackFormError('Feedback must be 2000 characters or less.') + return + } + + let ratingValue = null + if (rating !== '' && rating != null) { + const parsedRating = Number(rating) + if (Number.isNaN(parsedRating) || parsedRating < 1 || parsedRating > 5) { + setFeedbackFormError('Rating must be between 1 and 5.') + return + } + ratingValue = parsedRating + } + + setIsSubmittingFeedback(true) + setFeedbackFormError('') + try { + await createEngagementFeedback(resolvedEngagementId, { + feedbackText: trimmedFeedback, + rating: ratingValue || undefined + }) + toastr.success('Success', 'Feedback submitted successfully.') + handleCloseAddFeedbackModal() + await fetchFeedback() + } catch (err) { + const errorMessage = _.get(err, 'response.data.message') || (err && err.message) || 'Unable to submit feedback.' + setFeedbackFormError(errorMessage) + toastr.error('Error', errorMessage) + } finally { + setIsSubmittingFeedback(false) + } + }, [ + isSubmittingFeedback, + resolvedEngagementId, + feedbackText, + rating, + handleCloseAddFeedbackModal, + fetchFeedback + ]) + + const handleGenerateLinkSubmit = useCallback(async (event) => { + event.preventDefault() + if (isGeneratingLink) { + return + } + if (!resolvedEngagementId) { + setGenerateError('Engagement is required to generate a link.') + return + } + const trimmedEmail = customerEmail.trim() + if (!trimmedEmail) { + setGenerateError('Customer email is required.') + return + } + if (!CUSTOMER_EMAIL_PATTERN.test(trimmedEmail)) { + setGenerateError('Enter a valid email address.') + return + } + + setIsGeneratingLink(true) + setGenerateError('') + try { + const response = await generateEngagementFeedbackLink(resolvedEngagementId, { + customerEmail: trimmedEmail + }) + setGeneratedLink(_.get(response, 'data', null)) + toastr.success('Success', 'Feedback link generated successfully.') + } catch (err) { + const errorMessage = _.get(err, 'response.data.message') || (err && err.message) || 'Unable to generate feedback link.' + setGenerateError(errorMessage) + toastr.error('Error', errorMessage) + } finally { + setIsGeneratingLink(false) + } + }, [isGeneratingLink, resolvedEngagementId, customerEmail]) + + const handleCopyLink = useCallback(async () => { + const feedbackUrl = _.get(generatedLink, 'feedbackUrl') + if (!feedbackUrl) { + return + } + + try { + if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(feedbackUrl) + } else if (typeof document !== 'undefined') { + const textArea = document.createElement('textarea') + textArea.value = feedbackUrl + textArea.setAttribute('readonly', '') + textArea.style.position = 'absolute' + textArea.style.left = '-9999px' + document.body.appendChild(textArea) + textArea.select() + document.execCommand('copy') + document.body.removeChild(textArea) + } + toastr.success('Success', 'Link copied to clipboard.') + } catch (err) { + const errorMessage = (err && err.message) || 'Unable to copy link. Please try again.' + toastr.error('Error', errorMessage) + } + }, [generatedLink]) + + const feedbackTitle = engagementDetails && engagementDetails.title + ? `${engagementDetails.title} Feedback` + : 'Feedback' + + const pendingAssignment = engagementDetails && engagementDetails.status === 'Pending Assignment' + + const renderFeedbackContent = () => { + if (!canManage) { + return ( +
    + Feedback is available to project managers and admins only. +
    + ) + } + + if (feedbackLoading) { + return ( +
    Loading feedback...
    + ) + } + + if (feedbackError) { + return ( +
    + {feedbackError} + +
    + ) + } + + if (!feedback.length) { + return ( +
    No feedback yet.
    + ) + } + + return ( +
    + {feedback.map(item => { + const authorLabel = item.givenByHandle + ? `Topcoder PM: ${item.givenByHandle}` + : `Customer: ${item.givenByEmail || 'Unknown'}` + const createdAt = item.createdAt + ? moment(item.createdAt).format('MMM DD, YYYY') + : '-' + return ( +
    +

    {item.feedbackText}

    +
    + {authorLabel} + {item.rating ? ( + {`Rating: ${item.rating}/5`} + ) : null} + {createdAt} +
    +
    + ) + })} +
    + ) + } + + if (isLoading && !_.get(engagementDetails, 'id')) { + return + } + + if (!_.get(engagementDetails, 'id') && !isLoading) { + return ( +
    +
    Engagement not found.
    +
    + ) + } + + return ( +
    +
    +
    +
    {feedbackTitle}
    + {engagementDetails && engagementDetails.description && ( +
    {engagementDetails.description}
    + )} +
    +
    +
    + Status: + {engagementDetails && engagementDetails.status ? engagementDetails.status : '-'} +
    +
    + Last updated: + + {engagementDetails && engagementDetails.updatedAt + ? moment(engagementDetails.updatedAt).format('MMM DD, YYYY') + : '-'} + +
    +
    +
    + +
    +
    +
    +
    Feedback
    +
    + Capture internal notes and gather customer feedback. +
    +
    + {canManage && ( +
    + setShowAddFeedbackModal(true)} + /> + setShowGenerateLinkModal(true)} + /> +
    + )} +
    + + {pendingAssignment && ( +
    + This engagement has not been assigned yet. Feedback will be available once a member is assigned. +
    + )} + + {renderFeedbackContent()} +
    + + {showAddFeedbackModal && ( + +
    +
    Add Feedback
    +
    +
    + +