diff --git a/.circleci/config.yml b/.circleci/config.yml index cbfbc3113..c70035de5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -228,7 +228,7 @@ workflows: branches: only: - dev - - ai-ratings + - flexi-talent tags: only: /^dev-.*/ diff --git a/src/apps/customer-portal/src/config/routes.config.ts b/src/apps/customer-portal/src/config/routes.config.ts index 316357915..d9493115a 100644 --- a/src/apps/customer-portal/src/config/routes.config.ts +++ b/src/apps/customer-portal/src/config/routes.config.ts @@ -9,3 +9,4 @@ export const rootRoute: string : `/${AppSubdomain.customer}` export const talentSearchRouteId = 'talent-search' +export const flexiTalentRouteId = 'flexi-talent' diff --git a/src/apps/customer-portal/src/customer-portal.routes.tsx b/src/apps/customer-portal/src/customer-portal.routes.tsx index 2ee282745..27d744774 100644 --- a/src/apps/customer-portal/src/customer-portal.routes.tsx +++ b/src/apps/customer-portal/src/customer-portal.routes.tsx @@ -14,6 +14,7 @@ import { rootRoute, talentSearchRouteId, } from './config/routes.config' +import { customerPortalFlexiTalentRoutes } from './pages/flexi-talent/flexi-talent.routes' import { customerPortalTalentSearchRoutes } from './pages/talent-search/talent-search.routes' const CustomerPortalApp: LazyLoadedComponent = lazyLoad(() => import('./CustomerPortalApp')) @@ -31,6 +32,7 @@ export const customerPortalRoutes: ReadonlyArray = [ route: '', }, ...customerPortalTalentSearchRoutes, + ...customerPortalFlexiTalentRoutes, ], domain: AppSubdomain.customer, element: , diff --git a/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts index c76f23ed4..033e78776 100644 --- a/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts +++ b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts @@ -2,6 +2,7 @@ import _ from 'lodash' import { TabsNavItem } from '~/libs/ui' import { + flexiTalentRouteId, talentSearchRouteId, } from '~/apps/customer-portal/src/config/routes.config' @@ -11,6 +12,9 @@ export function getTabsConfig(userRoles: string[], isAnonymous: boolean, isUnpri ...(!isUnprivilegedUser ? [{ id: talentSearchRouteId, title: 'Talent Search', + }, { + id: flexiTalentRouteId, + title: 'Flexi-Talent', }] : []), ] diff --git a/src/apps/customer-portal/src/lib/services/flexiTalent.service.ts b/src/apps/customer-portal/src/lib/services/flexiTalent.service.ts new file mode 100644 index 000000000..1d8f703ad --- /dev/null +++ b/src/apps/customer-portal/src/lib/services/flexiTalent.service.ts @@ -0,0 +1,437 @@ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +const BASE_URL = `${EnvironmentConfig.API.V6}/engagements/engagements` + +export type FlexiEngagementBucket = 'total' | 'active' | 'closed' +export type FlexiEngagementSortBy = 'name' | 'memberCount' + +/** + * Member list buckets supported by the Flexi-Talent member endpoints. + */ +export type FlexiMemberBucket = 'total' | 'assigned' | 'completed' + +/** + * Server-side member sort fields supported by the Flexi-Talent member list endpoint. + */ +export type FlexiMemberSortBy = 'handle' | 'time' +export type FlexiSortOrder = 'asc' | 'desc' + +export interface FlexiEngagementListRequest { + bucket: FlexiEngagementBucket + page: number + perPage: number + searchText?: string + sortBy: FlexiEngagementSortBy + sortOrder: FlexiSortOrder +} + +export interface FlexiEngagementSummaryResponse { + total: number + active: number + closed: number +} + +export interface FlexiEngagementListItem { + engagementId: string + projectId: string + projectName?: string + engagementTitle: string + status: string + assignedMemberCount: number + requiredMemberCount?: number | null +} + +export interface FlexiEngagementListResponse { + data: FlexiEngagementListItem[] + page: number + perPage: number + total: number + totalPages: number +} + +export interface FlexiSkillReference { + id: string + name: string +} + +export interface FlexiEngagementAssignmentRow { + assignmentId: string + engagementId: string + projectId: string + memberId: string + memberHandle: string + status: string + displayStatusLabel: string + startDate?: string | null + endDate?: string | null + resolvedEndDate?: string | null + timeLeftDays?: number | null + isOverdue: boolean + durationMonths?: number | null + durationWeeks?: number | null + durationStartDate?: string | null + durationEndDate?: string | null + durationLabel?: string | null +} + +export interface FlexiEngagementDetailResponse { + engagementId: string + projectId: string + projectName?: string + engagementTitle: string + description: string + status: string + requiredMemberCount?: number | null + assignedMemberCount: number + skills: FlexiSkillReference[] + durationMonths?: number | null + durationWeeks?: number | null + durationStartDate?: string | null + durationEndDate?: string | null + durationLabel?: string | null + assignments: FlexiEngagementAssignmentRow[] +} + +export interface FlexiEngagementWorkLinks { + projectUrl?: string + engagementUrl?: string + assigneeDetailsUrl?: string +} + +export type FlexiEngagementDetail = FlexiEngagementDetailResponse & { + workLinks: FlexiEngagementWorkLinks +} + +/** + * Query parameters used to fetch one page of Flexi-Talent members. + */ +export interface FlexiMemberListRequest { + bucket: FlexiMemberBucket + page: number + perPage: number + searchText?: string + sortBy: FlexiMemberSortBy + sortOrder: FlexiSortOrder +} + +/** + * Summary counts returned by the Flexi-Talent member summary endpoint. + */ +export interface FlexiMemberSummaryResponse { + totalUniqueMembers: number + assignedMembers: number + completedMembers: number +} + +/** + * Member row returned by the paginated Flexi-Talent member list endpoint. + */ +export interface FlexiMemberListItem { + memberId: string + handle: string + assignmentId?: string | null + primaryProjectId?: string | null + primaryProjectName?: string | null + primaryEngagementId?: string | null + primaryEngagementTitle?: string | null + isCurrentlyAssigned: boolean + daysRemaining?: number | null + latestCompletedAt?: string | null + status: string + displayStatusLabel: string +} + +/** + * Paginated response returned by the Flexi-Talent member list endpoint. + */ +export interface FlexiMemberListResponse { + data: FlexiMemberListItem[] + page: number + perPage: number + total: number + totalPages: number +} + +/** + * Detail response returned for one selected Flexi-Talent member. + * + * Mirrors `FlexiMemberDetailDto` from engagements-api-v6 with API `Date` + * values represented as serialized ISO strings in the browser. + */ +export interface FlexiMemberDetailResponse { + memberId: string + handle: string + isCurrentlyAssigned: boolean + assignmentId: string + projectId: string + projectName?: string + engagementId: string + engagementTitle: string + description: string + status: string + displayStatusLabel: string + skills: FlexiSkillReference[] + startDate?: string | null + endDate?: string | null + resolvedEndDate?: string | null + timeLeftDays?: number | null + isOverdue: boolean + durationMonths?: number | null + durationWeeks?: number | null + durationStartDate?: string | null + durationEndDate?: string | null + durationLabel?: string | null +} + +/** + * History row returned for one member assignment in backend-defined order. + * + * Mirrors `FlexiMemberHistoryItemDto` from engagements-api-v6. History rows use + * `memberHandle`; they do not expose the detail DTO's `handle` property. + */ +export interface FlexiMemberHistoryItemResponse { + assignmentId: string + memberId: string + memberHandle: string + projectId: string + projectName?: string + engagementId: string + engagementTitle: string + status: string + displayStatusLabel: string + isCurrent: boolean + skills: FlexiSkillReference[] + startDate?: string | null + endDate?: string | null + resolvedEndDate?: string | null + timeLeftDays?: number | null + isOverdue: boolean + completedAt?: string | null + durationMonths?: number | null + durationWeeks?: number | null + durationStartDate?: string | null + durationEndDate?: string | null + durationLabel?: string | null +} + +/** + * Unpaginated history response returned for one selected Flexi-Talent member. + * + * Mirrors `FlexiMemberHistoryDto` from engagements-api-v6 and preserves the + * top-level member identity fields alongside the row collection. + */ +export interface FlexiMemberHistoryResponse { + memberId: string + handle: string + data: FlexiMemberHistoryItemResponse[] +} + +/** + * Normalized Work Manager links exposed on member detail and history rows. + */ +export type FlexiMemberWorkLinks = FlexiEngagementWorkLinks + +/** + * Member detail response with normalized Work Manager links. + */ +export type FlexiMemberDetail = FlexiMemberDetailResponse & { + workLinks: FlexiMemberWorkLinks +} + +/** + * Member history row with normalized Work Manager links. + */ +export type FlexiMemberHistoryItem = FlexiMemberHistoryItemResponse & { + workLinks: FlexiMemberWorkLinks +} + +/** + * Member history response with top-level member identity and Work Manager links + * attached to each assignment row. + */ +export interface FlexiMemberHistory { + memberId: string + handle: string + data: FlexiMemberHistoryItem[] +} + +/** + * Builds Work Manager links for the project, engagement detail, and engagement-scoped assignment page. + * + * @param projectId Work project id returned by engagements-api-v6. + * @param engagementId Engagement id returned by engagements-api-v6. + * @returns Link URLs with entries omitted when the ids required for that destination are missing. + */ +function buildFlexiEngagementWorkLinks( + projectId?: string | null, + engagementId?: string | null, +): FlexiEngagementWorkLinks { + const baseUrl = EnvironmentConfig.ADMIN.WORK_MANAGER_URL.replace(/\/$/, '') + const normalizedProjectId = String(projectId || '') + .trim() + const normalizedEngagementId = String(engagementId || '') + .trim() + + if (!normalizedProjectId) { + return {} + } + + const links: FlexiEngagementWorkLinks = { + projectUrl: `${baseUrl}/projects/${normalizedProjectId}`, + } + + if (normalizedEngagementId) { + links.engagementUrl = `${baseUrl}/projects/${normalizedProjectId}/engagements/${normalizedEngagementId}/view` + links.assigneeDetailsUrl + = `${baseUrl}/projects/${normalizedProjectId}/engagements/${normalizedEngagementId}/assignments` + } + + return links +} + +/** + * Builds Work Manager links for member assignment rows. + * + * @param projectId Work project id returned by engagements-api-v6. + * @param engagementId Engagement id returned by engagements-api-v6. + * @returns Link URLs with entries omitted when the ids required for that destination are missing. + */ +function buildFlexiMemberWorkLinks( + projectId?: string | null, + engagementId?: string | null, +): FlexiMemberWorkLinks { + return buildFlexiEngagementWorkLinks(projectId, engagementId) +} + +/** + * Fetches Flexi-Talent engagement summary counts. + * + * @returns Bucket counts for total, active, and closed engagements. + * @throws Any request error raised by the shared xhr client. + */ +export async function getFlexiEngagementSummary(): Promise { + return xhrGetAsync(`${BASE_URL}/flexi-talent/engagements/summary`) +} + +/** + * Fetches one body-paginated page of Flexi-Talent engagements. + * + * @param params Bucket, search, sort, and pagination query parameters. + * @returns Engagement list rows and top-level pagination fields. + * @throws Any request error raised by the shared xhr client. + */ +export async function getFlexiEngagementList( + params: FlexiEngagementListRequest, +): Promise { + const queryParams = new URLSearchParams({ + bucket: params.bucket, + page: String(params.page), + perPage: String(params.perPage), + sortBy: params.sortBy, + sortOrder: params.sortOrder, + }) + + if (params.searchText !== undefined) { + queryParams.set('searchText', params.searchText) + } + + return xhrGetAsync( + `${BASE_URL}/flexi-talent/engagements?${queryParams.toString()}`, + ) +} + +/** + * Fetches one Flexi-Talent engagement detail and adds safe Work Manager links. + * + * @param engagementId Engagement id for the selected list row. + * @returns Engagement detail, assignment rows, skills, and normalized Work Manager links. + * @throws Any request error raised by the shared xhr client. + */ +export async function getFlexiEngagementDetail(engagementId: string): Promise { + const response = await xhrGetAsync( + `${BASE_URL}/flexi-talent/engagements/${engagementId}`, + ) + + return { + ...response, + workLinks: buildFlexiEngagementWorkLinks(response.projectId, response.engagementId), + } +} + +/** + * Fetches Flexi-Talent member summary counts. + * + * @returns Bucket counts for total unique, assigned, and completed members. + * @throws Any request error raised by the shared xhr client. + */ +export async function getFlexiMemberSummary(): Promise { + return xhrGetAsync(`${BASE_URL}/flexi-talent/members/summary`) +} + +/** + * Fetches one body-paginated page of Flexi-Talent members. + * + * @param params Bucket, handle search, sort, and pagination query parameters. + * @returns Member list rows and top-level pagination fields. + * @throws Any request error raised by the shared xhr client. + */ +export async function getFlexiMemberList( + params: FlexiMemberListRequest, +): Promise { + const queryParams = new URLSearchParams({ + bucket: params.bucket, + page: String(params.page), + perPage: String(params.perPage), + sortBy: params.sortBy, + sortOrder: params.sortOrder, + }) + + if (params.searchText !== undefined) { + queryParams.set('searchText', params.searchText) + } + + return xhrGetAsync( + `${BASE_URL}/flexi-talent/members?${queryParams.toString()}`, + ) +} + +/** + * Fetches one Flexi-Talent member detail and adds safe Work Manager links. + * + * @param memberId Member id for the selected list row. + * @returns Member detail, assignment metadata, skills, timing fields, and normalized Work Manager links. + * @throws Any request error raised by the shared xhr client. + */ +export async function getFlexiMemberDetail(memberId: string): Promise { + const response = await xhrGetAsync( + `${BASE_URL}/flexi-talent/members/${memberId}`, + ) + + return { + ...response, + workLinks: buildFlexiMemberWorkLinks(response.projectId, response.engagementId), + } +} + +/** + * Fetches full ordered assignment history for a Flexi-Talent member. + * + * @param memberId Member id for the selected list row. + * @returns Top-level member identity and backend-ordered assignment rows with normalized Work Manager links. + * @throws Any request error raised by the shared xhr client. + */ +export async function getFlexiMemberHistory(memberId: string): Promise { + const response = await xhrGetAsync( + `${BASE_URL}/flexi-talent/members/${memberId}/history`, + ) + + return { + data: (Array.isArray(response.data) ? response.data : []).map(row => ({ + ...row, + workLinks: buildFlexiMemberWorkLinks(row.projectId, row.engagementId), + })), + handle: response.handle, + memberId: response.memberId, + } +} diff --git a/src/apps/customer-portal/src/lib/services/index.ts b/src/apps/customer-portal/src/lib/services/index.ts index 9e33c22a5..78d032d2b 100644 --- a/src/apps/customer-portal/src/lib/services/index.ts +++ b/src/apps/customer-portal/src/lib/services/index.ts @@ -1 +1,2 @@ export * from './talentSearch.service' +export * from './flexiTalent.service' diff --git a/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.module.scss b/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.module.scss new file mode 100644 index 000000000..b83dcd071 --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.module.scss @@ -0,0 +1,1015 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + font-family: $font-roboto; +} + +:global([class*='ContentLayout-module_content-outer']) { + margin: 0 auto 0 !important; +} + +:global([class*='ContentLayout-module_content__']) { + padding-bottom: 0 !important; +} + +:global([class*='BreadCrumb-module_breadcrumb']) { + display: none; +} + +.subtitle { + color: $black-80; + font-size: 15px; + line-height: 22px; + margin: 8px 0 20px; + max-width: 760px; +} + +.viewSwitcher { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px; + border: 1px solid $black-20; + border-radius: 8px; + background: $tc-white; +} + +.viewSwitcherButton { + min-width: 112px; + min-height: 36px; + padding: 8px 14px; + border: 0; + border-radius: 6px; + background: transparent; + color: $black-80; + cursor: pointer; + font-size: 14px; + font-weight: 500; + line-height: 20px; +} + +.viewSwitcherButtonActive { + background: $turq-160; + color: $tc-white; +} + +.viewStack, +.viewPanel, +.viewVisible { + min-width: 0; +} + +.viewHidden { + display: none; +} + +.engagementGrid { + display: grid; + grid-template-columns: minmax(220px, 280px) minmax(420px, 1fr) minmax(360px, 440px); + gap: 16px; + align-items: start; + min-width: 0; +} + +.pane { + min-width: 0; + background: $tc-white; + border: 1px solid $black-10; + border-radius: 8px; + box-shadow: 0 2px 8px rgba($black-100, 0.06); +} + +.summaryPane, +.listPane, +.detailPane { + padding: 18px; +} + +.paneHeader { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 16px; +} + +.paneEyebrow, +.placeholderEyebrow { + color: $black-60; + font-size: 12px; + font-weight: 700; + line-height: 16px; + margin: 0; + text-transform: uppercase; +} + +.paneTitle { + color: $black-100; + font-size: 18px; + font-weight: 600; + line-height: 26px; + margin: 0; +} + +.bucketList { + display: flex; + flex-direction: column; + gap: 10px; +} + +.bucketButton { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 58px; + width: 100%; + border: 1px solid $black-20; + border-radius: 8px; + background: $tc-white; + color: $black-80; + cursor: pointer; + padding: 12px; + text-align: left; + + span { + font-size: 14px; + font-weight: 500; + line-height: 20px; + } + + strong { + font-size: 22px; + font-weight: 700; + line-height: 28px; + } +} + +.bucketCountTotal { + color: $blue-110; +} + +.bucketCountPositive { + color: $green-1; +} + +.bucketCountMuted { + color: $black-80; +} + +.bucketButtonActive { + border-color: $turq-160; + background: rgba($turq-160, 0.08); +} + +.summaryNote { + color: $black-60; + font-size: 13px; + line-height: 19px; + margin: 16px 0 0; +} + +.listToolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 14px; + background-color: #E0E4E84D; +} + +.searchField { + display: flex; + align-items: center; + gap: 8px; + min-height: 38px; + width: 100%; + border: 1px solid $black-20; + border-radius: 4px; + background: $tc-white; + color: $black-60; + padding: 0 12px; + + input { + flex: 1; + min-width: 0; + border: 0; + color: $black-100; + font-size: 14px; + line-height: 20px; + outline: 0; + + &::placeholder { + color: $black-60; + opacity: 1; + } + } +} + +.searchIcon { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.searchClearButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: 0; + border-radius: 4px; + background: transparent; + color: $black-60; + cursor: pointer; + + svg { + width: 16px; + height: 16px; + } +} + +.sortBar { + display: flex; + align-items: center; + gap: 8px; + margin-top: 14px; + flex-wrap: wrap; +} + +.sortButton { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 34px; + border: 1px solid $black-20; + border-radius: 6px; + background: $tc-white; + color: $black-80; + cursor: pointer; + font-size: 13px; + font-weight: 600; + line-height: 18px; + padding: 7px 12px; +} + +.sortDirectionIndicator { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: $black-60; + + svg { + width: 16px; + height: 16px; + } +} + +.sortButtonActive { + border-color: $turq-160; + color: $black-100; +} + +.visuallyHidden { + position: absolute; + overflow: hidden; + width: 1px; + height: 1px; + margin: -1px; + border: 0; + clip: rect(0 0 0 0); + padding: 0; + white-space: nowrap; +} + +.listMeta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: $black-60; + font-size: 13px; + line-height: 18px; + margin: 14px 0; +} + +.engagementRows, +.memberRows, +.assignmentRows, +.listSkeleton, +.detailSkeleton, +.detailContent { + display: flex; + flex-direction: column; + gap: 12px; +} + +.engagementRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + width: 100%; + min-height: 92px; + border: 1px solid $black-10; + border-radius: 8px; + background: $tc-white; + color: $black-100; + cursor: pointer; + padding: 14px; + text-align: left; +} + +.engagementRowActive { + border-color: $turq-160; + box-shadow: inset 3px 0 0 $turq-160; +} + +.memberRow { + min-height: 98px; +} + +.rowMain { + display: flex; + flex: 1; + flex-direction: column; + gap: 5px; + min-width: 0; + + strong { + color: $black-100; + font-size: 15px; + font-weight: 600; + line-height: 21px; + overflow-wrap: anywhere; + } + + span { + color: $black-60; + font-size: 13px; + line-height: 18px; + overflow-wrap: anywhere; + } +} + +.rowMeta { + display: flex; + align-items: flex-end; + flex-direction: column; + gap: 8px; + flex-shrink: 0; +} + +.statusPill, +.memberPill, +.skillPill, +.detailCapacity { + display: inline-flex; + align-items: center; + min-height: 24px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + line-height: 16px; + padding: 4px 9px; + white-space: nowrap; +} + +.statusPill { + background: rgba($turq-160, 0.1); + color: $turq-160; +} + +.statusPillCurrent { + background: rgba($turq-160, 0.1); + color: $turq-160; +} + +.statusPillCompleted { + background: $black-10; + color: $black-80; +} + +.memberPill, +.detailCapacity, +.memberTimePill { + background: $black-10; + color: $black-80; +} + +.memberTimePill { + display: inline-flex; + align-items: center; + min-height: 24px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + line-height: 16px; + padding: 4px 9px; + white-space: nowrap; +} + +.memberTimePillOverdue { + background: $red-25; + color: $red-120; +} + +.skillPill { + background: #EDF3F8; + color: $black-80; +} + +.paginationWrap { + margin-top: 12px; +} + +.inlineError, +.detailError { + border: 1px solid $red-50; + border-radius: 8px; + background: $red-25; + color: $red-140; +} + +.inlineError { + font-size: 13px; + line-height: 19px; + margin: 12px 0; + padding: 12px; +} + +.emptyState, +.detailEmpty { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 10px; + min-height: 180px; + border: 1px dashed $black-20; + border-radius: 8px; + color: $black-60; + padding: 24px; + text-align: center; + + svg { + width: 30px; + height: 30px; + } + + p { + margin: 0; + } +} + +.detailHeader { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + border-bottom: 1px solid $black-10; + padding-bottom: 16px; + + h3 { + color: $black-100; + font-size: 20px; + font-weight: 700; + line-height: 28px; + margin: 0; + overflow-wrap: anywhere; + } + + p { + color: $black-60; + font-size: 14px; + line-height: 20px; + margin: 0; + overflow-wrap: anywhere; + } +} + +.detailHeaderActions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; +} + +.historyButton { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 32px; + border: 1px solid $black-20; + border-radius: 6px; + background: $tc-white; + color: $turq-160; + cursor: pointer; + font-size: 13px; + font-weight: 700; + line-height: 18px; + padding: 6px 10px; + + svg { + width: 14px; + height: 14px; + } +} + +.detailSection { + display: flex; + flex-direction: column; + gap: 8px; + + h4 { + color: $black-100; + font-size: 15px; + font-weight: 700; + line-height: 21px; + margin: 0; + } + + p { + color: $black-80; + font-size: 14px; + line-height: 21px; + margin: 0; + overflow-wrap: anywhere; + } +} + +.descriptionRichText { + color: $black-80; + font-size: 14px; + line-height: 21px; + overflow-wrap: anywhere; + + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + + p, + ul, + ol, + blockquote, + table, + pre { + margin: 0 0 8px; + } + + ul, + ol { + padding-left: 20px; + } + + li { + margin-bottom: 4px; + } + + a { + color: $turq-160; + font-weight: 700; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + h1, + h2, + h3, + h4, + h5, + h6 { + color: $black-100; + font-size: 15px; + line-height: 21px; + margin: 0 0 8px; + } + + table { + display: block; + overflow-x: auto; + max-width: 100%; + border-collapse: collapse; + } + + th, + td { + border: 1px solid $black-20; + padding: 6px 8px; + text-align: left; + vertical-align: top; + } +} + +.descriptionRichTextCollapsed { + overflow: hidden; + max-height: 147px; +} + +.descriptionToggleButton { + align-self: flex-start; + min-height: 28px; + border: 0; + background: transparent; + color: $turq-160; + cursor: pointer; + font-size: 13px; + font-weight: 700; + line-height: 18px; + padding: 2px 0; + + &:hover { + text-decoration: underline; + } +} + +.skillList, +.workLinks { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.workLinks { + border-bottom: 1px solid $black-10; + padding-bottom: 16px; + + a { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 32px; + border: 1px solid $black-20; + border-radius: 6px; + color: $turq-160; + font-size: 13px; + font-weight: 700; + line-height: 18px; + padding: 6px 10px; + text-decoration: none; + + svg { + width: 14px; + height: 14px; + } + } +} + +.assignmentRow { + display: flex; + flex-direction: column; + gap: 12px; + border: 1px solid $black-10; + border-radius: 8px; + padding: 12px; +} + +.assignmentHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + + strong { + color: $black-100; + font-size: 14px; + line-height: 20px; + overflow-wrap: anywhere; + } +} + +.assignmentMeta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin: 0; + + div { + min-width: 0; + } + + dt { + color: $black-60; + font-size: 11px; + font-weight: 700; + line-height: 15px; + margin: 0 0 2px; + text-transform: uppercase; + } + + dd { + color: $black-100; + font-size: 13px; + line-height: 18px; + margin: 0; + overflow-wrap: anywhere; + } +} + +.overdueText { + color: $red-120 !important; + font-weight: 700; +} + +.detailError { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px; + + svg { + width: 24px; + height: 24px; + flex-shrink: 0; + } + + h3, + p { + margin: 0; + } + + h3 { + color: $red-140; + font-size: 15px; + line-height: 21px; + margin-bottom: 4px; + } + + p { + font-size: 13px; + line-height: 19px; + } +} + +.skeletonBlock { + position: relative; + overflow: hidden; + border-radius: 8px; + background: $black-5; +} + +.skeletonBlock::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient(90deg, transparent, rgba($black-40, 0.2), transparent); + animation: shimmer 1.6s infinite; +} + +.skeletonTitle { + width: 72%; + height: 30px; +} + +.skeletonLine, +.skeletonLineShort { + height: 16px; +} + +.skeletonLine { + width: 100%; +} + +.skeletonLineShort { + width: 56%; +} + +.skeletonCard { + height: 170px; +} + +.skeletonCardSmall { + height: 92px; +} + +.rowSkeleton { + height: 92px; +} + +.membersPlaceholder { + display: grid; + min-height: 360px; + place-items: center; + border: 1px dashed $black-20; + border-radius: 8px; + background: $tc-white; + padding: 32px; +} + +.placeholderPane { + max-width: 460px; + text-align: center; +} + +.placeholderTitle { + color: $black-100; + font-size: 22px; + font-weight: 700; + line-height: 30px; + margin: 8px 0; +} + +.placeholderText { + color: $black-80; + font-size: 14px; + line-height: 21px; + margin: 0; +} + +.historyModalBody { + max-height: 70vh; + overflow-y: auto; +} + +.historyModalTitle { + display: flex; + flex-direction: column; + gap: 2px; + + span { + color: $black-60; + font-size: 12px; + font-weight: 700; + line-height: 16px; + text-transform: uppercase; + } + + strong { + color: $black-100; + font-size: 20px; + line-height: 28px; + overflow-wrap: anywhere; + } +} + +.historyModalHeader { + margin-bottom: 14px; +} + +.historyModalNotice { + border: 1px solid $black-10; + border-radius: 8px; + background: #EDF3F8; + color: $black-80; + font-size: 13px; + line-height: 19px; + margin: 0; + padding: 12px; +} + +.historyCardList, +.modalContainedState { + display: flex; + flex-direction: column; + gap: 12px; +} + +.historyCard { + display: flex; + flex-direction: column; + gap: 14px; + border: 1px solid $black-10; + border-radius: 8px; + background: $tc-white; + padding: 14px; +} + +.historyCardHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + + h4 { + color: $black-100; + font-size: 15px; + font-weight: 700; + line-height: 21px; + margin: 0; + overflow-wrap: anywhere; + } + + p { + color: $black-60; + font-size: 13px; + line-height: 18px; + margin: 2px 0 0; + overflow-wrap: anywhere; + } +} + +.modalContainedState { + align-items: center; + justify-content: center; + min-height: 160px; + border: 1px dashed $black-20; + border-radius: 8px; + color: $black-60; + padding: 20px; + text-align: center; + + svg { + width: 28px; + height: 28px; + } + + p { + margin: 0; + } +} + +.modalContainedError { + display: flex; + align-items: flex-start; + gap: 10px; + border: 1px solid $red-50; + border-radius: 8px; + background: $red-25; + color: $red-140; + padding: 12px; + + svg { + width: 22px; + height: 22px; + flex-shrink: 0; + } + + p { + font-size: 13px; + line-height: 19px; + margin: 0; + } +} + +@keyframes shimmer { + 100% { + transform: translateX(100%); + } +} + +@media (max-width: 1280px) { + .engagementGrid { + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + } + + .detailPane { + grid-column: 1 / -1; + } +} + +@media (max-width: 840px) { + .engagementGrid { + grid-template-columns: 1fr; + } + + .summaryPane, + .listPane, + .detailPane { + padding: 14px; + } + + .engagementRow, + .assignmentHeader, + .historyCardHeader, + .listMeta { + align-items: flex-start; + flex-direction: column; + } + + .detailHeaderActions { + align-items: flex-start; + flex-direction: column; + } + + .rowMeta { + align-items: flex-start; + flex-direction: row; + flex-wrap: wrap; + } + + .assignmentMeta { + grid-template-columns: 1fr; + } + + .viewSwitcher { + width: 100%; + } + + .viewSwitcherButton { + flex: 1; + min-width: 0; + } +} diff --git a/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.tsx b/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.tsx new file mode 100644 index 000000000..57a5c9851 --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.tsx @@ -0,0 +1,93 @@ +import { FC, useCallback, useState } from 'react' +import classNames from 'classnames' + +import { PageWrapper } from '../../../lib' +import { EngagementsView } from '../components/EngagementsView' +import { MembersView } from '../components/MembersView' + +import styles from './FlexiTalentPage.module.scss' + +type FlexiTalentInnerView = 'engagements' | 'members' + +/** + * Flexi-Talent route shell. + * + * The shell owns only the local inner-view switcher so Engagements and Members + * stay mounted while users switch between them, preserving each view's local + * state until the user leaves the top-level `/flexi-talent` route. + */ +export const FlexiTalentPage: FC = () => { + const [activeInnerView, setActiveInnerView] = useState('engagements') + + const handleEngagementsClick = useCallback((): void => { + setActiveInnerView('engagements') + }, []) + + const handleMembersClick = useCallback((): void => { + setActiveInnerView('members') + }, []) + + const rightHeader = ( +
+ + +
+ ) + + return ( + +

+ Monitor Flexi-Talent engagement coverage, assignment status, and Work links in one place. +

+
+
+ +
+
+ +
+
+
+ ) +} + +export default FlexiTalentPage diff --git a/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/index.ts b/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/index.ts new file mode 100644 index 000000000..b536e56d2 --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/index.ts @@ -0,0 +1 @@ +export { default as FlexiTalentPage } from './FlexiTalentPage' diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx b/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx new file mode 100644 index 000000000..f09d3c181 --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx @@ -0,0 +1,828 @@ +/* eslint-disable complexity */ +/* eslint-disable react/jsx-no-bind */ +import { + ChangeEvent, + FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { debounce } from 'lodash' +import classNames from 'classnames' + +import { Pagination } from '~/apps/admin/src/lib/components/common/Pagination' +import { renderRichTextToHtml } from '~/libs/shared/lib/utils/rich-text' +import { IconOutline } from '~/libs/ui' + +import { + FlexiEngagementBucket, + FlexiEngagementDetail, + FlexiEngagementListItem, + FlexiEngagementListResponse, + FlexiEngagementSortBy, + FlexiEngagementSummaryResponse, + FlexiSortOrder, + getFlexiEngagementDetail, + getFlexiEngagementList, + getFlexiEngagementSummary, +} from '../../../../lib' +import styles from '../../FlexiTalentPage/FlexiTalentPage.module.scss' + +const ENGAGEMENTS_PER_PAGE = 10 +const SEARCH_DEBOUNCE_MS = 300 +const DESCRIPTION_COLLAPSED_HEIGHT_PX = 147 +const DESCRIPTION_OVERFLOW_TOLERANCE_PX = 1 + +type DetailState = 'loading' | 'empty' | 'error' | 'ready' + +const EMPTY_LIST_RESPONSE: FlexiEngagementListResponse = { + data: [], + page: 1, + perPage: ENGAGEMENTS_PER_PAGE, + total: 0, + totalPages: 1, +} + +const dateFormatter = new Intl.DateTimeFormat('en-US', { + day: 'numeric', + month: 'short', + year: 'numeric', +}) + +/** + * Formats API dates for compact rail and row display. + * + * @param value ISO date string returned by engagements-api-v6. + * @returns A localized date label, or a fallback when no valid date is present. + */ +function formatDate(value?: string | null): string { + if (!value) { + return 'Not set' + } + + const parsedDate = new Date(value) + if (Number.isNaN(parsedDate.getTime())) { + return 'Not set' + } + + return dateFormatter.format(parsedDate) +} + +/** + * Converts backend enum-style status strings into display labels. + * + * @param status Raw engagement status. + * @returns Title-cased status text. + */ +function formatStatusLabel(status?: string): string { + return String(status || 'Unknown') + .toLowerCase() + .split('_') + .filter(Boolean) + .map(word => `${word.charAt(0) + .toUpperCase()}${word.slice(1)}`) + .join(' ') +} + +/** + * Formats backend timing fields without hiding overdue or negative values. + * + * @param assignment Assignment row returned by the detail endpoint. + * @returns Human-readable timing text for the assignment row. + */ +function formatTimeLeft(assignment: FlexiEngagementDetail['assignments'][number]): string { + const days = assignment.timeLeftDays + if (days === null || days === undefined) { + return assignment.resolvedEndDate + ? `Ends ${formatDate(assignment.resolvedEndDate)}` + : 'No end date' + } + + if (days < 0 || assignment.isOverdue) { + const overdueDays = Math.abs(days) + return `${overdueDays} ${overdueDays === 1 ? 'day' : 'days'} overdue` + } + + if (days === 0) { + return 'Due today' + } + + return `${days} ${days === 1 ? 'day' : 'days'} left` +} + +/** + * Formats the member count shown in engagement list and detail rows. + * + * @param assignedMemberCount Current assigned-member count from the backend. + * @param requiredMemberCount Optional required capacity from the backend. + * @returns Capacity label for UI display. + */ +function formatMemberCount( + assignedMemberCount: number, + requiredMemberCount?: number | null, +): string { + if (requiredMemberCount === null || requiredMemberCount === undefined) { + return `${assignedMemberCount} assigned` + } + + return `${assignedMemberCount} of ${requiredMemberCount} assigned` +} + +/** + * Converts engagement description source into sanitized HTML for detail rail rendering. + * + * @param description Mixed markdown and HTML description text returned by engagements-api-v6. + * @returns Sanitized HTML, or an empty string when the source has no renderable safe content. + */ +function renderEngagementDescriptionHtml(description?: string | null): string { + return renderRichTextToHtml(description || '') +} + +/** + * Checks whether the rendered description is taller than the default collapsed region. + * + * @param element Rendered rich-text description container. + * @returns True when the container needs a See More / See Less toggle. + */ +function isDescriptionOverflowingCollapsedHeight(element: HTMLDivElement): boolean { + return element.scrollHeight > DESCRIPTION_COLLAPSED_HEIGHT_PX + DESCRIPTION_OVERFLOW_TOLERANCE_PX +} + +function getErrorMessage(fallback: string): string { + return fallback +} + +const DetailSkeleton: FC = () => ( +
+
+
+
+
+
+
+) + +/** + * Engagements inner view for Flexi-Talent. + * + * Owns summary, bucket, search, sort, pagination, row selection, and right-rail + * detail state locally so leaving `/flexi-talent` naturally resets the view. + */ +export const EngagementsView: FC = () => { + const summaryGenerationRef = useRef(0) + const listGenerationRef = useRef(0) + const detailGenerationRef = useRef(0) + const descriptionContentRef = useRef(null) + + const [summaryData, setSummaryData] = useState() + const [isSummaryLoading, setIsSummaryLoading] = useState(true) + const [summaryErrorMessage, setSummaryErrorMessage] = useState('') + + const [selectedBucket, setSelectedBucket] = useState('active') + const [rawSearchText, setRawSearchText] = useState('') + const [appliedSearchText, setAppliedSearchText] = useState('') + const [searchRefreshNonce, setSearchRefreshNonce] = useState(0) + const [sortBy, setSortBy] = useState('name') + const [sortOrder, setSortOrder] = useState('asc') + const [page, setPage] = useState(1) + + const [listData, setListData] = useState(EMPTY_LIST_RESPONSE) + const [isListLoading, setIsListLoading] = useState(true) + const [listErrorMessage, setListErrorMessage] = useState('') + + const [selectedEngagementId, setSelectedEngagementId] = useState('') + const [selectedEngagementRow, setSelectedEngagementRow] = useState() + const [detailData, setDetailData] = useState() + const [detailState, setDetailState] = useState('loading') + const [detailErrorMessage, setDetailErrorMessage] = useState('') + const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) + const [isDescriptionCollapsible, setIsDescriptionCollapsible] = useState(false) + + const debouncedApplySearch = useMemo( + () => debounce((nextSearchText: string): void => { + setAppliedSearchText(nextSearchText) + setPage(1) + setSearchRefreshNonce(nonce => nonce + 1) + }, SEARCH_DEBOUNCE_MS), + [], + ) + + const summaryBuckets = useMemo(() => [ + { + count: summaryData?.total, + countClassName: styles.bucketCountTotal, + id: 'total' as FlexiEngagementBucket, + label: 'Total Engagements', + }, + { + count: summaryData?.active, + countClassName: styles.bucketCountPositive, + id: 'active' as FlexiEngagementBucket, + label: 'Active', + }, + { + count: summaryData?.closed, + countClassName: styles.bucketCountMuted, + id: 'closed' as FlexiEngagementBucket, + label: 'Closed', + }, + ], [summaryData]) + + const sanitizedDescriptionHtml = useMemo( + () => renderEngagementDescriptionHtml(detailData?.description), + [detailData?.description], + ) + + const resetDescriptionState = useCallback((): void => { + setIsDescriptionExpanded(false) + setIsDescriptionCollapsible(false) + }, []) + + /** + * Loads bucket counts once for the left rail, independent of list filters. + * + * @returns A promise that resolves after summary state is updated. + */ + const fetchEngagementSummary = useCallback(async (): Promise => { + const generation = summaryGenerationRef.current + 1 + summaryGenerationRef.current = generation + setIsSummaryLoading(true) + setSummaryErrorMessage('') + + try { + const response = await getFlexiEngagementSummary() + if (summaryGenerationRef.current !== generation) { + return + } + + setSummaryData(response) + } catch { + if (summaryGenerationRef.current !== generation) { + return + } + + setSummaryErrorMessage(getErrorMessage('Could not load engagement summary.')) + } finally { + if (summaryGenerationRef.current === generation) { + setIsSummaryLoading(false) + } + } + }, []) + + const prepareRightRailRefresh = useCallback((): void => { + detailGenerationRef.current += 1 + setSelectedEngagementId('') + setSelectedEngagementRow(undefined) + setDetailData(undefined) + setDetailErrorMessage('') + setDetailState('loading') + resetDescriptionState() + }, [resetDescriptionState]) + + /** + * Loads detail for a selected engagement row. + * + * @param row Engagement list row selected by auto-selection or user click. + * @returns A promise that resolves after detail state is updated. + */ + const fetchSelectedEngagementDetail = useCallback(async ( + row: FlexiEngagementListItem, + ): Promise => { + const generation = detailGenerationRef.current + 1 + detailGenerationRef.current = generation + setDetailData(undefined) + setDetailErrorMessage('') + setDetailState('loading') + resetDescriptionState() + + try { + const response = await getFlexiEngagementDetail(row.engagementId) + if (detailGenerationRef.current !== generation) { + return + } + + resetDescriptionState() + setDetailData(response) + setDetailState('ready') + } catch { + if (detailGenerationRef.current !== generation) { + return + } + + setDetailErrorMessage(getErrorMessage('Could not load engagement details.')) + setDetailState('error') + } + }, [resetDescriptionState]) + + /** + * Refreshes the current engagement list and auto-selects the first returned row. + * + * @returns A promise that resolves after list state and any first-row detail fetch are started. + */ + const refreshEngagementList = useCallback(async (): Promise => { + const generation = listGenerationRef.current + 1 + listGenerationRef.current = generation + prepareRightRailRefresh() + setIsListLoading(true) + setListErrorMessage('') + + try { + const response = await getFlexiEngagementList({ + bucket: selectedBucket, + page, + perPage: ENGAGEMENTS_PER_PAGE, + searchText: appliedSearchText, + sortBy, + sortOrder, + }) + if (listGenerationRef.current !== generation) { + return + } + + const nextListData: FlexiEngagementListResponse = { + data: Array.isArray(response.data) ? response.data : [], + page: response.page || page, + perPage: response.perPage || ENGAGEMENTS_PER_PAGE, + total: response.total || 0, + totalPages: Math.max(response.totalPages || 1, 1), + } + + setListData(nextListData) + + const firstRow = nextListData.data[0] + if (!firstRow) { + setSelectedEngagementId('') + setSelectedEngagementRow(undefined) + setDetailData(undefined) + setDetailState('empty') + return + } + + setSelectedEngagementId(firstRow.engagementId) + setSelectedEngagementRow(firstRow) + setDetailState('loading') + fetchSelectedEngagementDetail(firstRow) + .catch(() => undefined) + } catch { + if (listGenerationRef.current !== generation) { + return + } + + setListData({ + ...EMPTY_LIST_RESPONSE, + page, + }) + setListErrorMessage(getErrorMessage('Could not load engagements.')) + setSelectedEngagementId('') + setSelectedEngagementRow(undefined) + setDetailData(undefined) + setDetailState('empty') + } finally { + if (listGenerationRef.current === generation) { + setIsListLoading(false) + } + } + }, [ + appliedSearchText, + fetchSelectedEngagementDetail, + page, + prepareRightRailRefresh, + searchRefreshNonce, + selectedBucket, + sortBy, + sortOrder, + ]) + + useEffect(() => { + fetchEngagementSummary() + .catch(() => undefined) + }, [fetchEngagementSummary]) + + useEffect(() => { + refreshEngagementList() + .catch(() => undefined) + }, [refreshEngagementList]) + + useEffect(() => () => { + debouncedApplySearch.cancel() + summaryGenerationRef.current += 1 + listGenerationRef.current += 1 + detailGenerationRef.current += 1 + }, [debouncedApplySearch]) + + useEffect(() => { + if (detailState !== 'ready' || !sanitizedDescriptionHtml) { + setIsDescriptionCollapsible(false) + return undefined + } + + const measureDescription = (): void => { + const descriptionElement = descriptionContentRef.current + setIsDescriptionCollapsible( + descriptionElement + ? isDescriptionOverflowingCollapsedHeight(descriptionElement) + : false, + ) + } + + if (typeof window === 'undefined') { + measureDescription() + return undefined + } + + const animationFrame = window.requestAnimationFrame(measureDescription) + window.addEventListener('resize', measureDescription) + + return () => { + window.cancelAnimationFrame(animationFrame) + window.removeEventListener('resize', measureDescription) + } + }, [detailState, sanitizedDescriptionHtml]) + + const handleSearchChange = useCallback((event: ChangeEvent): void => { + const nextSearchText = event.target.value || '' + prepareRightRailRefresh() + setRawSearchText(nextSearchText) + debouncedApplySearch(nextSearchText) + }, [debouncedApplySearch, prepareRightRailRefresh]) + + const handleSearchClear = useCallback((): void => { + prepareRightRailRefresh() + setRawSearchText('') + debouncedApplySearch('') + }, [debouncedApplySearch, prepareRightRailRefresh]) + + const handleBucketClick = useCallback((bucket: FlexiEngagementBucket): void => { + if (bucket === selectedBucket) { + return + } + + prepareRightRailRefresh() + setSelectedBucket(bucket) + setPage(1) + }, [prepareRightRailRefresh, selectedBucket]) + + const handleSortClick = useCallback((field: FlexiEngagementSortBy): void => { + prepareRightRailRefresh() + + if (field === sortBy) { + setSortOrder(currentSortOrder => (currentSortOrder === 'asc' ? 'desc' : 'asc')) + return + } + + setSortBy(field) + setSortOrder('asc') + }, [prepareRightRailRefresh, sortBy]) + + const handlePageChange = useCallback((nextPage: number): void => { + if (nextPage === page) { + return + } + + prepareRightRailRefresh() + setPage(nextPage) + }, [page, prepareRightRailRefresh]) + + const handleRowClick = useCallback((row: FlexiEngagementListItem): void => { + setSelectedEngagementId(row.engagementId) + setSelectedEngagementRow(row) + fetchSelectedEngagementDetail(row) + .catch(() => undefined) + }, [fetchSelectedEngagementDetail]) + + const handleDescriptionToggle = useCallback((): void => { + setIsDescriptionExpanded(currentValue => !currentValue) + }, []) + + const renderSummaryCount = useCallback((count: number | undefined): string => { + if (isSummaryLoading) { + return '--' + } + + return String(count ?? 0) + }, [isSummaryLoading]) + + const shouldShowPagination = !isListLoading && !listErrorMessage && listData.totalPages > 1 + const selectedDetailTitle = selectedEngagementRow + ? selectedEngagementRow.engagementTitle + : 'Selected engagement' + + return ( +
+ + +
+
+ +
+ +
+ + +
+ +
+ + {listData.total} + {' engagements'} + + + {'Page '} + {listData.page} + {' of '} + {listData.totalPages} + +
+ + {listErrorMessage && ( +
{listErrorMessage}
+ )} + + {isListLoading && ( +
+
+
+
+
+ )} + + {!isListLoading && !listErrorMessage && listData.data.length === 0 && ( +
+ +

No engagements match the current filters.

+
+ )} + + {!isListLoading && !listErrorMessage && listData.data.length > 0 && ( +
+ {listData.data.map(row => ( + + ))} +
+ )} + + {shouldShowPagination && ( +
+ +
+ )} +
+ + +
+ ) +} + +export default EngagementsView diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/index.ts b/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/index.ts new file mode 100644 index 000000000..f58762c2a --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/index.ts @@ -0,0 +1 @@ +export { default as EngagementsView } from './EngagementsView' diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/MemberHistoryModal/MemberHistoryModal.tsx b/src/apps/customer-portal/src/pages/flexi-talent/components/MemberHistoryModal/MemberHistoryModal.tsx new file mode 100644 index 000000000..030e7e6d6 --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/MemberHistoryModal/MemberHistoryModal.tsx @@ -0,0 +1,345 @@ +/* eslint-disable complexity */ +/* eslint-disable react/jsx-no-bind */ +import { FC, useEffect, useMemo, useRef, useState } from 'react' +import classNames from 'classnames' + +import { BaseModal, IconOutline } from '~/libs/ui' + +import { + FlexiMemberHistoryItem, + FlexiMemberListItem, + FlexiMemberWorkLinks, + getFlexiMemberHistory, +} from '../../../../lib' +import styles from '../../FlexiTalentPage/FlexiTalentPage.module.scss' + +type HistoryState = 'loading' | 'empty' | 'error' | 'ready' + +export interface MemberHistoryModalProps { + member?: FlexiMemberListItem + onClose: () => void + open: boolean +} + +const dateFormatter = new Intl.DateTimeFormat('en-US', { + day: 'numeric', + month: 'short', + year: 'numeric', +}) + +/** + * Formats API dates for the member history modal. + * + * @param value ISO date string returned by engagements-api-v6. + * @returns A localized date label, or a fallback when no valid date is present. + */ +function formatDate(value?: string | null): string { + if (!value) { + return 'Not set' + } + + const parsedDate = new Date(value) + if (Number.isNaN(parsedDate.getTime())) { + return 'Not set' + } + + return dateFormatter.format(parsedDate) +} + +/** + * Returns the current-assignment time-left value from the backend DTO field. + * + * @param row History row returned by the member history endpoint. + * @returns The days-remaining number, or undefined when no timing field is present. + */ +function getRemainingDays(row: FlexiMemberHistoryItem): number | undefined { + const days = row.timeLeftDays + + return days === null || days === undefined ? undefined : days +} + +/** + * Formats current-assignment timing from backend timing fields. + * + * @param row History row returned by the member history endpoint. + * @returns Human-readable current-assignment timing text. + */ +function formatCurrentTiming(row: FlexiMemberHistoryItem): string { + const days = getRemainingDays(row) + if (days === undefined) { + return row.resolvedEndDate + ? `Ends ${formatDate(row.resolvedEndDate)}` + : 'No end date' + } + + if (days < 0 || row.isOverdue) { + const overdueDays = Math.abs(days) + return `${overdueDays} ${overdueDays === 1 ? 'day' : 'days'} overdue` + } + + if (days === 0) { + return 'Due today' + } + + return `${days} ${days === 1 ? 'day' : 'days'} left` +} + +/** + * Formats assignment timing for current and completed history rows. + * + * @param row History row returned by the member history endpoint. + * @returns Human-readable timing text derived from backend fields. + */ +function formatHistoryTiming(row: FlexiMemberHistoryItem): string { + if (row.isCurrent) { + return formatCurrentTiming(row) + } + + if (row.completedAt) { + return `Completed ${formatDate(row.completedAt)}` + } + + if (row.resolvedEndDate) { + return `Resolved ${formatDate(row.resolvedEndDate)}` + } + + return 'Completion date not set' +} + +/** + * Formats backend duration fields for history cards. + * + * @param row History row returned by the member history endpoint. + * @returns Duration label, computed month/week label, or fallback text. + */ +function formatDuration(row: FlexiMemberHistoryItem): string { + if (row.durationLabel) { + return row.durationLabel + } + + const durationParts = [ + row.durationMonths ? `${row.durationMonths} mo` : '', + row.durationWeeks ? `${row.durationWeeks} wk` : '', + ].filter(Boolean) + + if (durationParts.length > 0) { + return durationParts.join(' ') + } + + return 'Not set' +} + +/** + * Detects whether a normalized Work-link collection contains any destinations. + * + * @param workLinks Normalized Work Manager links from the member service. + * @returns True when at least one Work destination can be rendered. + */ +function hasWorkLinks(workLinks: FlexiMemberWorkLinks): boolean { + return Boolean(workLinks.projectUrl || workLinks.engagementUrl || workLinks.assigneeDetailsUrl) +} + +/** + * Detects whether a history row should use overdue timing emphasis. + * + * @param row History row returned by the member history endpoint. + * @returns True when a current assignment is overdue by remaining days or flag. + */ +function isHistoryRowOverdue(row: FlexiMemberHistoryItem): boolean { + const days = getRemainingDays(row) + + return Boolean(row.isCurrent && ((days !== undefined && days < 0) || row.isOverdue)) +} + +/** + * Member assignment history modal for the Flexi-Talent Members view. + * + * Fetches full backend-ordered member history on demand when opened, keeps + * loading/error/data state inside the modal, and renders normalized Work links + * for each returned assignment row. + */ +export const MemberHistoryModal: FC = props => { + const historyGenerationRef = useRef(0) + const memberId = props.member?.memberId || '' + + const [historyState, setHistoryState] = useState('empty') + const [historyData, setHistoryData] = useState([]) + const [historyMemberHandle, setHistoryMemberHandle] = useState('') + const [historyErrorMessage, setHistoryErrorMessage] = useState('') + + useEffect(() => { + if (!props.open || !memberId) { + historyGenerationRef.current += 1 + setHistoryData([]) + setHistoryMemberHandle('') + setHistoryErrorMessage('') + setHistoryState('empty') + return undefined + } + + const generation = historyGenerationRef.current + 1 + historyGenerationRef.current = generation + setHistoryData([]) + setHistoryMemberHandle('') + setHistoryErrorMessage('') + setHistoryState('loading') + + getFlexiMemberHistory(memberId) + .then(response => { + if (historyGenerationRef.current !== generation) { + return + } + + const rows = Array.isArray(response.data) ? response.data : [] + setHistoryMemberHandle(response.handle) + setHistoryData(rows) + setHistoryState(rows.length > 0 ? 'ready' : 'empty') + }) + .catch(() => { + if (historyGenerationRef.current !== generation) { + return + } + + setHistoryErrorMessage('Could not load member history.') + setHistoryState('error') + }) + + return () => { + historyGenerationRef.current += 1 + } + }, [memberId, props.open]) + + const title = useMemo(() => ( +
+ Member History + {historyMemberHandle || props.member?.handle || 'Selected member'} +
+ ), [historyMemberHandle, props.member?.handle]) + + return ( + +
+

+ Active assignments are shown first, followed by past assignments in the backend order. +

+
+ + {historyState === 'loading' && ( +
+
+
+
+ )} + + {historyState === 'error' && ( +
+ +

{historyErrorMessage}

+
+ )} + + {historyState === 'empty' && ( +
+ +

No assignment history was returned for this member.

+
+ )} + + {historyState === 'ready' && ( +
+ {historyData.map((row, index) => ( +
+
+
+

{row.engagementTitle || 'Engagement title unavailable'}

+

{row.projectName || 'Project name unavailable'}

+
+ + {row.displayStatusLabel} + +
+ +
+
+
Timing
+
+ {formatHistoryTiming(row)} +
+
+
+
Duration
+
{formatDuration(row)}
+
+
+
Start
+
{formatDate(row.startDate)}
+
+
+
Resolved End
+
{formatDate(row.resolvedEndDate)}
+
+
+ +
+

Skills

+ {Array.isArray(row.skills) && row.skills.length > 0 ? ( +
+ {row.skills.map(skill => ( + {skill.name} + ))} +
+ ) : ( +

No skills listed.

+ )} +
+ + {hasWorkLinks(row.workLinks) && ( +
+ {row.workLinks.projectUrl && ( + + Project + + + )} + {row.workLinks.engagementUrl && ( + + Engagement + + + )} + {row.workLinks.assigneeDetailsUrl && ( + + Assignee Details + + + )} +
+ )} +
+ ))} +
+ )} + + ) +} + +export default MemberHistoryModal diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/MemberHistoryModal/index.ts b/src/apps/customer-portal/src/pages/flexi-talent/components/MemberHistoryModal/index.ts new file mode 100644 index 000000000..1e0793c9d --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/MemberHistoryModal/index.ts @@ -0,0 +1 @@ +export { default as MemberHistoryModal } from './MemberHistoryModal' diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/MembersPlaceholder/MembersPlaceholder.tsx b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersPlaceholder/MembersPlaceholder.tsx new file mode 100644 index 000000000..2cbcaca2c --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersPlaceholder/MembersPlaceholder.tsx @@ -0,0 +1,23 @@ +import { FC } from 'react' + +import styles from '../../FlexiTalentPage/FlexiTalentPage.module.scss' + +/** + * Members inner-view placeholder. + * + * This keeps the Flexi-Talent switcher contract mounted without calling member + * summary, list, detail, or history endpoints before the follow-up ticket. + */ +export const MembersPlaceholder: FC = () => ( +
+
+

Members

+

Member tracking is coming next.

+

+ The shell is ready for the member summary, list, detail, and history views. +

+
+
+) + +export default MembersPlaceholder diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/MembersPlaceholder/index.ts b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersPlaceholder/index.ts new file mode 100644 index 000000000..77fa981f3 --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersPlaceholder/index.ts @@ -0,0 +1 @@ +export { default as MembersPlaceholder } from './MembersPlaceholder' diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.tsx b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.tsx new file mode 100644 index 000000000..5d60cb144 --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.tsx @@ -0,0 +1,975 @@ +/* eslint-disable complexity */ +/* eslint-disable react/jsx-no-bind */ +import { + ChangeEvent, + FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { debounce } from 'lodash' +import classNames from 'classnames' + +import { Pagination } from '~/apps/admin/src/lib/components/common/Pagination' +import { renderRichTextToHtml } from '~/libs/shared/lib/utils/rich-text' +import { IconOutline } from '~/libs/ui' + +import { + FlexiMemberBucket, + FlexiMemberDetail, + FlexiMemberListItem, + FlexiMemberListResponse, + FlexiMemberSortBy, + FlexiMemberSummaryResponse, + FlexiMemberWorkLinks, + FlexiSortOrder, + getFlexiMemberDetail, + getFlexiMemberList, + getFlexiMemberSummary, +} from '../../../../lib' +import { MemberHistoryModal } from '../MemberHistoryModal' +import styles from '../../FlexiTalentPage/FlexiTalentPage.module.scss' + +const MEMBERS_PER_PAGE = 10 +const SEARCH_DEBOUNCE_MS = 300 + +type DetailState = 'loading' | 'empty' | 'error' | 'ready' + +interface MembersViewProps { + isActive: boolean +} + +interface FlexiMemberTimingFields { + daysRemaining?: number | null + isOverdue?: boolean | null + resolvedEndDate?: string | null + timeLeftDays?: number | null +} + +const EMPTY_LIST_RESPONSE: FlexiMemberListResponse = { + data: [], + page: 1, + perPage: MEMBERS_PER_PAGE, + total: 0, + totalPages: 1, +} + +const dateFormatter = new Intl.DateTimeFormat('en-US', { + day: 'numeric', + month: 'short', + year: 'numeric', +}) + +/** + * Formats API dates for compact rail and row display. + * + * @param value ISO date string returned by engagements-api-v6. + * @returns A localized date label, or a fallback when no valid date is present. + */ +function formatDate(value?: string | null): string { + if (!value) { + return 'Not set' + } + + const parsedDate = new Date(value) + if (Number.isNaN(parsedDate.getTime())) { + return 'Not set' + } + + return dateFormatter.format(parsedDate) +} + +/** + * Builds a one-line assignment context for member list rows. + * + * @param row Member list row returned by the list endpoint. + * @returns Project and engagement context, or fallback text when neither is present. + */ +function formatListContext(row: FlexiMemberListItem): string { + const contextParts = [ + row.primaryProjectName || '', + row.primaryEngagementTitle || '', + ].filter(Boolean) + + return contextParts.length > 0 ? contextParts.join(' - ') : 'Assignment context unavailable' +} + +/** + * Builds a one-line assignment context for member detail rows. + * + * @param detail Member detail returned by the detail endpoint. + * @returns Project and engagement context, or fallback text when neither is present. + */ +function formatDetailContext(detail: FlexiMemberDetail): string { + const contextParts = [ + detail.projectName || '', + detail.engagementTitle || '', + ].filter(Boolean) + + return contextParts.length > 0 ? contextParts.join(' - ') : 'Assignment context unavailable' +} + +/** + * Returns the current-assignment time-left value from backend fields. + * + * @param value Member list or detail row with backend timing fields. + * @returns The days-remaining number, or undefined when no timing field is present. + */ +function getRemainingDays(value: FlexiMemberTimingFields): number | undefined { + const days = value.daysRemaining ?? value.timeLeftDays + + return days === null || days === undefined ? undefined : days +} + +/** + * Formats current-assignment timing from backend timing fields. + * + * @param value Member detail or list row timing fields. + * @returns Human-readable current-assignment timing text. + */ +function formatCurrentTiming(value: FlexiMemberTimingFields): string { + const days = getRemainingDays(value) + if (days === undefined) { + return value.resolvedEndDate + ? `Ends ${formatDate(value.resolvedEndDate)}` + : 'No end date' + } + + if (days < 0 || value.isOverdue) { + const overdueDays = Math.abs(days) + return `${overdueDays} ${overdueDays === 1 ? 'day' : 'days'} overdue` + } + + if (days === 0) { + return 'Due today' + } + + return `${days} ${days === 1 ? 'day' : 'days'} left` +} + +/** + * Formats list-row timing from member API fields. + * + * @param row Member list row returned by the list endpoint. + * @returns Current time-left or completed-date metadata for the row. + */ +function formatListTiming(row: FlexiMemberListItem): string { + if (row.isCurrentlyAssigned) { + const days = row.daysRemaining + if (days === null || days === undefined) { + return 'No end date' + } + + if (days < 0) { + const overdueDays = Math.abs(days) + return `${overdueDays} ${overdueDays === 1 ? 'day' : 'days'} overdue` + } + + if (days === 0) { + return 'Due today' + } + + return `${days} ${days === 1 ? 'day' : 'days'} left` + } + + return row.latestCompletedAt + ? `Completed ${formatDate(row.latestCompletedAt)}` + : 'Completion date not set' +} + +/** + * Detects whether a member list row represents an overdue current assignment. + * + * @param row Member list row returned by the list endpoint. + * @returns True when the current assignment has a negative days-remaining value. + */ +function isListRowOverdue(row: FlexiMemberListItem): boolean { + const days = row.daysRemaining + + return Boolean(row.isCurrentlyAssigned && days !== undefined && days !== null && days < 0) +} + +/** + * Detects whether the selected member detail represents an overdue current assignment. + * + * @param detail Member detail returned by the detail endpoint. + * @returns True when the current assignment has negative remaining days or an overdue flag. + */ +function isDetailOverdue(detail: FlexiMemberDetail): boolean { + const days = getRemainingDays(detail) + + return Boolean(detail.isCurrentlyAssigned && ((days !== undefined && days < 0) || detail.isOverdue)) +} + +/** + * Formats detail timing, preserving current versus completed framing. + * + * Current timing comes from the detail endpoint. Completed timing comes from the + * selected list row because the detail DTO does not expose completion fields. + * + * @param detail Member detail returned by the detail endpoint. + * @param selectedRow Selected list row that owns the detail payload. + * @returns Human-readable timing text for the right rail. + */ +function formatDetailTiming( + detail: FlexiMemberDetail, + selectedRow?: FlexiMemberListItem, +): string { + if (detail.isCurrentlyAssigned) { + return formatCurrentTiming(detail) + } + + const completedAt = selectedRow?.memberId === detail.memberId + ? selectedRow.latestCompletedAt + : undefined + if (completedAt) { + return `Completed ${formatDate(completedAt)}` + } + + return 'Completion date not set' +} + +/** + * Formats backend duration fields for right-rail detail display. + * + * @param detail Member detail returned by the detail endpoint. + * @returns Duration label, computed month/week label, or fallback text. + */ +function formatDuration(detail: FlexiMemberDetail): string { + if (detail.durationLabel) { + return detail.durationLabel + } + + const durationParts = [ + detail.durationMonths ? `${detail.durationMonths} mo` : '', + detail.durationWeeks ? `${detail.durationWeeks} wk` : '', + ].filter(Boolean) + + if (durationParts.length > 0) { + return durationParts.join(' ') + } + + return 'Not set' +} + +/** + * Detects whether a normalized Work-link collection contains any destinations. + * + * @param workLinks Normalized Work Manager links from the member service. + * @returns True when at least one Work destination can be rendered. + */ +function hasWorkLinks(workLinks: FlexiMemberWorkLinks): boolean { + return Boolean(workLinks.projectUrl || workLinks.engagementUrl || workLinks.assigneeDetailsUrl) +} + +/** + * Returns the standard member view error fallback. + * + * @param fallback Static fallback message for the failed request. + * @returns Error message shown in the matching pane. + */ +function getErrorMessage(fallback: string): string { + return fallback +} + +const DetailSkeleton: FC = () => ( +
+
+
+
+
+
+
+) + +/** + * Members inner view for Flexi-Talent. + * + * Owns summary, bucket, handle search, sort, pagination, row selection, right-rail + * detail state, and history modal state locally. Initial requests are deferred + * until the Members tab is activated for the first time. + */ +export const MembersView: FC = props => { + const summaryGenerationRef = useRef(0) + const listGenerationRef = useRef(0) + const detailGenerationRef = useRef(0) + + const [hasActivated, setHasActivated] = useState(false) + + const [summaryData, setSummaryData] = useState() + const [isSummaryLoading, setIsSummaryLoading] = useState(false) + const [summaryErrorMessage, setSummaryErrorMessage] = useState('') + + const [selectedBucket, setSelectedBucket] = useState('total') + const [rawSearchText, setRawSearchText] = useState('') + const [appliedSearchText, setAppliedSearchText] = useState('') + const [searchRefreshNonce, setSearchRefreshNonce] = useState(0) + const [sortBy, setSortBy] = useState('handle') + const [sortOrder, setSortOrder] = useState('asc') + const [page, setPage] = useState(1) + + const [listData, setListData] = useState(EMPTY_LIST_RESPONSE) + const [isListLoading, setIsListLoading] = useState(false) + const [listErrorMessage, setListErrorMessage] = useState('') + + const [selectedMemberId, setSelectedMemberId] = useState('') + const [selectedMemberRow, setSelectedMemberRow] = useState() + const [detailData, setDetailData] = useState() + const [detailState, setDetailState] = useState('empty') + const [detailErrorMessage, setDetailErrorMessage] = useState('') + const [isHistoryModalOpen, setIsHistoryModalOpen] = useState(false) + + const isFirstActivationLoading = props.isActive && !hasActivated + const isEffectiveSummaryLoading = isSummaryLoading || isFirstActivationLoading + const isEffectiveListLoading = isListLoading || isFirstActivationLoading + const effectiveDetailState: DetailState = isFirstActivationLoading + ? 'loading' + : detailState + + useEffect(() => { + if (!props.isActive || hasActivated) { + return + } + + setHasActivated(true) + setIsSummaryLoading(true) + setSummaryErrorMessage('') + setIsListLoading(true) + setListErrorMessage('') + setDetailData(undefined) + setDetailErrorMessage('') + setDetailState('loading') + }, [hasActivated, props.isActive]) + + const debouncedApplySearch = useMemo( + () => debounce((nextSearchText: string): void => { + setAppliedSearchText(nextSearchText) + setPage(1) + setSearchRefreshNonce(nonce => nonce + 1) + }, SEARCH_DEBOUNCE_MS), + [], + ) + + const summaryBuckets = useMemo(() => [ + { + count: summaryData?.totalUniqueMembers, + countClassName: styles.bucketCountTotal, + id: 'total' as FlexiMemberBucket, + label: 'Total Unique Members', + }, + { + count: summaryData?.assignedMembers, + countClassName: styles.bucketCountPositive, + id: 'assigned' as FlexiMemberBucket, + label: 'Assigned', + }, + { + count: summaryData?.completedMembers, + countClassName: styles.bucketCountMuted, + id: 'completed' as FlexiMemberBucket, + label: 'Completed', + }, + ], [summaryData]) + + /** + * Loads member bucket counts once for the left rail, independent of list filters. + * + * @returns A promise that resolves after summary state is updated. + */ + const fetchMemberSummary = useCallback(async (): Promise => { + const generation = summaryGenerationRef.current + 1 + summaryGenerationRef.current = generation + setIsSummaryLoading(true) + setSummaryErrorMessage('') + + try { + const response = await getFlexiMemberSummary() + if (summaryGenerationRef.current !== generation) { + return + } + + setSummaryData(response) + } catch { + if (summaryGenerationRef.current !== generation) { + return + } + + setSummaryErrorMessage(getErrorMessage('Could not load member summary.')) + } finally { + if (summaryGenerationRef.current === generation) { + setIsSummaryLoading(false) + } + } + }, []) + + const prepareRightRailRefresh = useCallback((): void => { + detailGenerationRef.current += 1 + setSelectedMemberId('') + setSelectedMemberRow(undefined) + setDetailData(undefined) + setDetailErrorMessage('') + setDetailState('loading') + setIsHistoryModalOpen(false) + }, []) + + /** + * Loads detail for a selected member row. + * + * @param row Member list row selected by auto-selection or user click. + * @returns A promise that resolves after detail state is updated. + */ + const fetchSelectedMemberDetail = useCallback(async ( + row: FlexiMemberListItem, + ): Promise => { + const generation = detailGenerationRef.current + 1 + detailGenerationRef.current = generation + setDetailData(undefined) + setDetailErrorMessage('') + setDetailState('loading') + + try { + const response = await getFlexiMemberDetail(row.memberId) + if (detailGenerationRef.current !== generation) { + return + } + + setDetailData(response) + setDetailState('ready') + } catch { + if (detailGenerationRef.current !== generation) { + return + } + + setDetailErrorMessage(getErrorMessage('Could not load member details.')) + setDetailState('error') + } + }, []) + + /** + * Refreshes the current member list and auto-selects the first returned row. + * + * @returns A promise that resolves after list state and any first-row detail fetch are started. + */ + const refreshMemberList = useCallback(async (): Promise => { + const generation = listGenerationRef.current + 1 + listGenerationRef.current = generation + prepareRightRailRefresh() + setIsListLoading(true) + setListErrorMessage('') + + try { + const response = await getFlexiMemberList({ + bucket: selectedBucket, + page, + perPage: MEMBERS_PER_PAGE, + searchText: appliedSearchText, + sortBy, + sortOrder, + }) + if (listGenerationRef.current !== generation) { + return + } + + const nextListData: FlexiMemberListResponse = { + data: Array.isArray(response.data) ? response.data : [], + page: response.page || page, + perPage: response.perPage || MEMBERS_PER_PAGE, + total: response.total || 0, + totalPages: Math.max(response.totalPages || 1, 1), + } + + setListData(nextListData) + + const firstRow = nextListData.data[0] + if (!firstRow) { + setSelectedMemberId('') + setSelectedMemberRow(undefined) + setDetailData(undefined) + setDetailState('empty') + return + } + + setSelectedMemberId(firstRow.memberId) + setSelectedMemberRow(firstRow) + setDetailState('loading') + fetchSelectedMemberDetail(firstRow) + .catch(() => undefined) + } catch { + if (listGenerationRef.current !== generation) { + return + } + + setListData({ + ...EMPTY_LIST_RESPONSE, + page, + }) + setListErrorMessage(getErrorMessage('Could not load members.')) + setSelectedMemberId('') + setSelectedMemberRow(undefined) + setDetailData(undefined) + setDetailState('empty') + } finally { + if (listGenerationRef.current === generation) { + setIsListLoading(false) + } + } + }, [ + appliedSearchText, + fetchSelectedMemberDetail, + page, + prepareRightRailRefresh, + searchRefreshNonce, + selectedBucket, + sortBy, + sortOrder, + ]) + + useEffect(() => { + if (!hasActivated) { + return + } + + fetchMemberSummary() + .catch(() => undefined) + }, [fetchMemberSummary, hasActivated]) + + useEffect(() => { + if (!hasActivated) { + return + } + + refreshMemberList() + .catch(() => undefined) + }, [hasActivated, refreshMemberList]) + + useEffect(() => () => { + debouncedApplySearch.cancel() + summaryGenerationRef.current += 1 + listGenerationRef.current += 1 + detailGenerationRef.current += 1 + }, [debouncedApplySearch]) + + const handleSearchChange = useCallback((event: ChangeEvent): void => { + const nextSearchText = event.target.value || '' + prepareRightRailRefresh() + setRawSearchText(nextSearchText) + debouncedApplySearch(nextSearchText) + }, [debouncedApplySearch, prepareRightRailRefresh]) + + const handleSearchClear = useCallback((): void => { + prepareRightRailRefresh() + setRawSearchText('') + debouncedApplySearch('') + }, [debouncedApplySearch, prepareRightRailRefresh]) + + const handleBucketClick = useCallback((bucket: FlexiMemberBucket): void => { + if (bucket === selectedBucket) { + return + } + + prepareRightRailRefresh() + setSelectedBucket(bucket) + setPage(1) + }, [prepareRightRailRefresh, selectedBucket]) + + const handleSortClick = useCallback((field: FlexiMemberSortBy): void => { + prepareRightRailRefresh() + + if (field === sortBy) { + setSortOrder(currentSortOrder => (currentSortOrder === 'asc' ? 'desc' : 'asc')) + return + } + + setSortBy(field) + setSortOrder('asc') + }, [prepareRightRailRefresh, sortBy]) + + const handlePageChange = useCallback((nextPage: number): void => { + if (nextPage === page) { + return + } + + prepareRightRailRefresh() + setPage(nextPage) + }, [page, prepareRightRailRefresh]) + + const handleRowClick = useCallback((row: FlexiMemberListItem): void => { + setSelectedMemberId(row.memberId) + setSelectedMemberRow(row) + fetchSelectedMemberDetail(row) + .catch(() => undefined) + }, [fetchSelectedMemberDetail]) + + const handleHistoryOpen = useCallback((): void => { + if (selectedMemberId) { + setIsHistoryModalOpen(true) + } + }, [selectedMemberId]) + + const handleHistoryClose = useCallback((): void => { + setIsHistoryModalOpen(false) + }, []) + + const renderSummaryCount = useCallback((count: number | undefined): string => { + if (isEffectiveSummaryLoading) { + return '--' + } + + return String(count ?? 0) + }, [isEffectiveSummaryLoading]) + + const shouldShowPagination = !isEffectiveListLoading && !listErrorMessage && listData.totalPages > 1 + const selectedDetailTitle = selectedMemberRow + ? selectedMemberRow.handle + : 'Selected member' + const sanitizedDescriptionHtml = useMemo( + () => renderRichTextToHtml(detailData?.description || ''), + [detailData?.description], + ) + const listTotalLabel = isEffectiveListLoading ? '--' : String(listData.total) + const listPageLabel = isEffectiveListLoading ? '--' : String(listData.page) + const listTotalPagesLabel = isEffectiveListLoading ? '--' : String(listData.totalPages) + + return ( +
+ + +
+
+ +
+ +
+ + +
+ +
+ + {listTotalLabel} + {' members'} + + + {'Page '} + {listPageLabel} + {' of '} + {listTotalPagesLabel} + +
+ + {listErrorMessage && ( +
{listErrorMessage}
+ )} + + {isEffectiveListLoading && ( +
+
+
+
+
+ )} + + {!isEffectiveListLoading && !listErrorMessage && listData.data.length === 0 && ( +
+ +

No members match the current filters.

+
+ )} + + {!isEffectiveListLoading && !listErrorMessage && listData.data.length > 0 && ( +
+ {listData.data.map(row => ( + + ))} +
+ )} + + {shouldShowPagination && ( +
+ +
+ )} +
+ + + + +
+ ) +} + +export default MembersView diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/index.ts b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/index.ts new file mode 100644 index 000000000..9ecab2bf0 --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/index.ts @@ -0,0 +1 @@ +export { default as MembersView } from './MembersView' diff --git a/src/apps/customer-portal/src/pages/flexi-talent/flexi-talent.routes.tsx b/src/apps/customer-portal/src/pages/flexi-talent/flexi-talent.routes.tsx new file mode 100644 index 000000000..efe437abd --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/flexi-talent.routes.tsx @@ -0,0 +1,26 @@ +import { getRoutesContainer, lazyLoad, LazyLoadedComponent } from '~/libs/core' + +import { flexiTalentRouteId } from '../../config/routes.config' + +const FlexiTalentPage: LazyLoadedComponent = lazyLoad( + () => import('./FlexiTalentPage'), + 'FlexiTalentPage', +) + +export const flexiTalentChildRoutes = [ + { + authRequired: true, + element: , + id: 'flexi-talent-page', + route: '', + }, +] + +export const customerPortalFlexiTalentRoutes = [ + { + children: [...flexiTalentChildRoutes], + element: getRoutesContainer(flexiTalentChildRoutes), + id: flexiTalentRouteId, + route: flexiTalentRouteId, + }, +] diff --git a/src/apps/profiles/src/member-profile/about-me/AboutMe.module.scss b/src/apps/profiles/src/member-profile/about-me/AboutMe.module.scss index 83f950524..34ff88d75 100644 --- a/src/apps/profiles/src/member-profile/about-me/AboutMe.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/AboutMe.module.scss @@ -32,7 +32,7 @@ color: $link-blue-dark; cursor: pointer; font-family: $font-roboto; - font-size: 16px; + font-size: $sp-35; font-weight: $font-weight-bold; line-height: 24px; padding: 0; diff --git a/src/apps/profiles/src/member-profile/about-me/AboutMe.utils.spec.ts b/src/apps/profiles/src/member-profile/about-me/AboutMe.utils.spec.ts index 654385253..dcf6c7602 100644 --- a/src/apps/profiles/src/member-profile/about-me/AboutMe.utils.spec.ts +++ b/src/apps/profiles/src/member-profile/about-me/AboutMe.utils.spec.ts @@ -12,7 +12,7 @@ describe('getTruncatedBio', () => { }) }) - it('matches the Figma bio preview length before adding the suffix', () => { + it('matches the collapsed bio preview length before adding the suffix', () => { const bio = [ 'I am a highly skilled JavaScript Developer with a passion for building dynamic and interactive web', 'applications. With several years of experience in the field, I possess a deep understanding of', @@ -20,8 +20,7 @@ describe('getTruncatedBio', () => { ].join(' ') const expectedBio = [ 'I am a highly skilled JavaScript Developer with a passion for building dynamic and interactive web', - 'applications. With several years of experience in the field, I possess a deep understanding of', - 'JavaScript\'s...', + 'applications. With several years of experience in the field, I possess a deep understanding of...', ].join(' ') expect(getTruncatedBio(bio)) @@ -39,6 +38,24 @@ describe('getTruncatedBio', () => { }) }) + it('keeps long profile previews from ending with a sparse extra line', () => { + const bio = [ + 'As a community manager, I work closely with our members to support them and organize events,', + 'create content to engage them. The best part of the job is meeting new members in online or onsite', + 'meetings and helping them connect with the right opportunities.', + ].join(' ') + + expect(getTruncatedBio(bio)) + .toEqual({ + isTruncated: true, + text: [ + 'As a community manager, I work closely with our members to support them and organize events,', + 'create content to engage them. The best part of the job is meeting new members in online or', + 'onsite...', + ].join(' '), + }) + }) + it('uses the configured profile bio preview length by default', () => { const bio = `${'a'.repeat(PROFILE_BIO_TRUNCATION_LENGTH)} more text` diff --git a/src/apps/profiles/src/member-profile/about-me/AboutMe.utils.ts b/src/apps/profiles/src/member-profile/about-me/AboutMe.utils.ts index 7bf3fa15b..96377a393 100644 --- a/src/apps/profiles/src/member-profile/about-me/AboutMe.utils.ts +++ b/src/apps/profiles/src/member-profile/about-me/AboutMe.utils.ts @@ -3,14 +3,15 @@ export interface TruncatedBio { text: string } -export const PROFILE_BIO_TRUNCATION_LENGTH = 206 +export const PROFILE_BIO_TRUNCATION_LENGTH = 195 /** * Returns profile bio text for the collapsed AboutMe view. * - * Used by the member profile page to match the Figma bio preview length while - * preserving full words when possible. The returned text includes the trailing - * three-dot suffix only when the bio exceeds the configured limit. + * Used by the member profile page to keep the collapsed left-column bio + * preview compact while preserving full words when possible. The returned text + * includes the trailing three-dot suffix only when the bio exceeds the + * configured limit. * * @param {string | undefined} bio - The full profile bio from the member profile API. * @param {number} maxLength - Maximum visible characters before the suffix is added. diff --git a/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.module.scss b/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.module.scss index b3dc7f444..1125d7e43 100644 --- a/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.module.scss +++ b/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.module.scss @@ -55,7 +55,7 @@ color: $link-blue-dark; cursor: pointer; font-family: $font-roboto; - font-size: 16px; + font-size: $sp-35; font-weight: $font-weight-bold; line-height: 24px; margin-top: $sp-4; diff --git a/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.module.scss b/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.module.scss index 0a0c18e7f..2eeb18eba 100644 --- a/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.module.scss +++ b/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.module.scss @@ -5,7 +5,7 @@ flex-direction: column; margin-bottom: $sp-8; background-color: $tc-white; - padding: $sp-8 $sp-8 $sp-2; + padding: $sp-8; border-radius: 16px; @include ltelg { diff --git a/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.spec.tsx b/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.spec.tsx new file mode 100644 index 000000000..761db3236 --- /dev/null +++ b/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.spec.tsx @@ -0,0 +1,10 @@ +import { readFileSync } from 'fs' + +const memberTCAchievementsStyles = readFileSync(`${__dirname}/MemberTCAchievements.module.scss`, 'utf8') + +describe('MemberTCAchievements styles', () => { + it('keeps the desktop card padding even at the top and bottom', () => { + expect(memberTCAchievementsStyles) + .toMatch(/\.container \{[\s\S]*?padding: \$sp-8;/) + }) +}) diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentIterativeReview.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentIterativeReview.tsx index 6d8225395..1cb122f40 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentIterativeReview.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentIterativeReview.tsx @@ -28,6 +28,7 @@ import { hasSubmitterPassedThreshold } from '../../utils/reviewScoring' import { filterIterativeReviewRows, + getIterativeReviewSubmissionPriority, limitFirst2FinishIterativeRows, } from './iterativeReviewFiltering' @@ -44,30 +45,6 @@ interface Props { aiReviewers?: { aiWorkflowId: string }[] } -const getSubmissionPriority = (submission: SubmissionInfo): number => { - const review = submission.review - if (!review) { - return 0 - } - - const hasReviewId = Boolean(review.id) - const status = (review.status ?? '').toUpperCase() - - if (hasReviewId && (status === 'COMPLETED' || status === 'SUBMITTED')) { - return 4 - } - - if (hasReviewId && review.reviewProgress) { - return 3 - } - - if (hasReviewId) { - return 2 - } - - return 1 -} - export const TabContentIterativeReview: FC = (props: Props) => { const { aiReviewDecisionsBySubmissionId, @@ -88,6 +65,10 @@ export const TabContentIterativeReview: FC = (props: Props) => { () => new Set((myResources ?? []).map(resource => resource.memberId)), [myResources], ) + const myResourceIds = useMemo>( + () => new Set((myResources ?? []).map(resource => resource.id)), + [myResources], + ) const isChallengeCompleted = useMemo( () => { @@ -183,13 +164,16 @@ export const TabContentIterativeReview: FC = (props: Props) => { return } - if (getSubmissionPriority(submission) > getSubmissionPriority(existing)) { + if ( + getIterativeReviewSubmissionPriority(submission, myResourceIds) + > getIterativeReviewSubmissionPriority(existing, myResourceIds) + ) { map.set(submission.id, submission) } }) return Array.from(map.values()) - }, [filteredRows]) + }, [filteredRows, myResourceIds]) const first2FinishReviewRows = useMemo( () => { if (isPostMortemPhase || !isFirst2FinishChallenge) { diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts index 40498ddc9..9bb0b3d4f 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts @@ -6,6 +6,7 @@ import { import { filterIterativeReviewRows, + getIterativeReviewSubmissionPriority, limitFirst2FinishIterativeRows, } from './iterativeReviewFiltering' @@ -308,6 +309,32 @@ describe('filterIterativeReviewRows', () => { expect(results[0].id) .toBe('synthetic-row-1') }) + + it('keeps a URL submission row when the F2F limiter matches its legacy submission id', () => { + const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer') + const urlSubmissionRow = { + ...createSubmission(iterativeReviewer.id), + id: 'url-submission-row', + isFileSubmission: false, + legacySubmissionId: 'canonical-submission-id', + } + const otherRow = { + ...createSubmission('other-reviewer'), + id: 'other-submission-row', + legacySubmissionId: 'other-canonical-id', + } + + const results = limitFirst2FinishIterativeRows( + [otherRow, urlSubmissionRow], + ['canonical-submission-id'], + { forceSingleRow: true }, + ) + + expect(results) + .toHaveLength(1) + expect(results[0].id) + .toBe('url-submission-row') + }) }) describe('limitFirst2FinishIterativeRows', () => { @@ -348,3 +375,31 @@ describe('limitFirst2FinishIterativeRows', () => { .toBe('submission-earlier') }) }) + +describe('getIterativeReviewSubmissionPriority', () => { + it('prefers the logged-in reviewer row when duplicate F2F rows share a submission id', () => { + const currentReviewerResourceIds = new Set(['current-reviewer-resource']) + const currentReviewerRow = createSubmission('current-reviewer-resource') + const otherReviewerCompletedRow = createSubmission('other-reviewer-resource') + currentReviewerRow.review!.id = 'review-current' + otherReviewerCompletedRow.review!.id = 'review-other' + otherReviewerCompletedRow.review!.status = 'COMPLETED' + + expect(getIterativeReviewSubmissionPriority(currentReviewerRow, currentReviewerResourceIds)) + .toBeGreaterThan( + getIterativeReviewSubmissionPriority(otherReviewerCompletedRow, currentReviewerResourceIds), + ) + }) + + it('keeps the completed-review preference when no row belongs to the logged-in reviewer', () => { + const currentReviewerResourceIds = new Set(['current-reviewer-resource']) + const pendingRow = createSubmission('pending-reviewer-resource') + const completedRow = createSubmission('completed-reviewer-resource') + pendingRow.review!.id = 'review-pending' + completedRow.review!.id = 'review-completed' + completedRow.review!.status = 'COMPLETED' + + expect(getIterativeReviewSubmissionPriority(completedRow, currentReviewerResourceIds)) + .toBeGreaterThan(getIterativeReviewSubmissionPriority(pendingRow, currentReviewerResourceIds)) + }) +}) diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts index 64c26f29e..d6ee96bb6 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts @@ -78,6 +78,63 @@ function normalizeIdentifier(value: unknown): string | undefined { return normalized.length ? normalized : undefined } +/** + * Collect submission identifiers that can refer to the same visible row. + * + * @param submission - Candidate iterative-review row from review data. + * @returns Normalized row, legacy, and review submission ids for matching. + * Used by F2F limiting because URL submissions can be keyed by legacy ids. + */ +function collectSubmissionCandidateIds(submission: SubmissionInfo): Set { + return new Set( + [ + submission.id, + submission.legacySubmissionId, + submission.review?.submissionId, + ] + .map(id => normalizeIdentifier(id)) + .filter((id): id is string => Boolean(id)), + ) +} + +/** + * Rank duplicate iterative-review rows for the same submission. + * + * @param submission - Candidate row built from a submission/reviewer pair. + * @param currentResourceIds - Resource ids assigned to the logged-in reviewer. + * @returns Numeric priority; larger values win during duplicate collapse. + * Used so reviewers see their own pending action when URL/F2F rows share ids. + */ +export function getIterativeReviewSubmissionPriority( + submission: SubmissionInfo, + currentResourceIds: Set = new Set(), +): number { + const review = submission.review + if (!review) { + return 0 + } + + const hasReviewId = Boolean(review.id) + const status = (review.status ?? '').toUpperCase() + const resourcePriority = review.resourceId && currentResourceIds.has(review.resourceId) + ? 10 + : 0 + + if (hasReviewId && (status === 'COMPLETED' || status === 'SUBMITTED')) { + return 4 + resourcePriority + } + + if (hasReviewId && review.reviewProgress) { + return 3 + resourcePriority + } + + if (hasReviewId) { + return 2 + resourcePriority + } + + return 1 +} + /** * Parse sortable date inputs from submission and review payloads. * @@ -376,11 +433,9 @@ export function limitFirst2FinishIterativeRows( } const matchingRows = rows.filter(submission => { - const submissionId = normalizeIdentifier(submission.id) - const reviewSubmissionId = normalizeIdentifier(submission.review?.submissionId) - - return (submissionId ? submissionIds.has(submissionId) : false) - || (reviewSubmissionId ? submissionIds.has(reviewSubmissionId) : false) + const candidateIds = collectSubmissionCandidateIds(submission) + return Array.from(candidateIds) + .some(submissionId => submissionIds.has(submissionId)) }) if (matchingRows.length) { diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx index a61c88f06..274cf83ed 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx @@ -416,6 +416,33 @@ describe('BillingAccountLineItemsModal', () => { .toBeNull() }) + it('derives engagement member payments from markup multipliers greater than one', () => { + renderModal({ + ...baseBillingAccountDetails, + consumedAmounts: [ + { + amount: '1268.76', + date: '2026-06-02T13:10:48.235Z', + externalId: 'assignment-12259-markup', + externalName: 'High Markup Engagement', + externalType: 'ENGAGEMENT', + }, + ], + consumedBudget: 1268.76, + markup: 1.2259, + totalBudgetRemaining: 0, + }) + + expect(screen.getByText('$570.00')) + .toBeTruthy() + expect(screen.getByText('$698.76')) + .toBeTruthy() + expect(screen.queryByText('$1,253.40')) + .toBeNull() + expect(screen.queryByText('$15.36')) + .toBeNull() + }) + it('uses finance engagement payment splits before API-derived member-payment fallbacks', async () => { mockedFetchAssignmentPaymentSplits.mockResolvedValue([ { diff --git a/src/apps/work/src/lib/utils/challenge-editor.utils.spec.ts b/src/apps/work/src/lib/utils/challenge-editor.utils.spec.ts index 1a129b331..42197d6de 100644 --- a/src/apps/work/src/lib/utils/challenge-editor.utils.spec.ts +++ b/src/apps/work/src/lib/utils/challenge-editor.utils.spec.ts @@ -242,6 +242,26 @@ describe('challenge-editor utils submission count mapping', () => { }) describe('challenge-editor utils schedule mapping', () => { + it('rounds API phase durations with leftover seconds up to a whole minute', () => { + const result = transformChallengeToFormData({ + description: 'Public specification', + name: 'Immediate challenge', + phases: [{ + duration: 1295994, + isOpen: true, + name: 'Submission', + phaseId: 'submission-phase', + scheduledEndDate: '2026-07-10T05:26:25.000Z', + scheduledStartDate: '2026-06-25T05:26:30.201Z', + }], + trackId: 'track-id', + typeId: 'type-id', + }) + + expect(result.phases?.[0]?.duration) + .toBe(21600) + }) + it('serializes scheduled phase end dates to the API payload', () => { const formData: Record = { description: 'Public specification', diff --git a/src/apps/work/src/lib/utils/challenge-editor.utils.ts b/src/apps/work/src/lib/utils/challenge-editor.utils.ts index 01103efd1..db694ab75 100644 --- a/src/apps/work/src/lib/utils/challenge-editor.utils.ts +++ b/src/apps/work/src/lib/utils/challenge-editor.utils.ts @@ -835,7 +835,7 @@ function normalizePhaseDurationMinutes(duration: unknown): number { return maxMinutesDuration } - return Math.max(1, Math.trunc(parsedDuration)) + return Math.max(1, Math.ceil(parsedDuration)) } function normalizePhasesForForm(phases: unknown): ChallengePhase[] { diff --git a/src/apps/work/src/lib/utils/payment.utils.spec.ts b/src/apps/work/src/lib/utils/payment.utils.spec.ts index 4c9a32326..58a053996 100644 --- a/src/apps/work/src/lib/utils/payment.utils.spec.ts +++ b/src/apps/work/src/lib/utils/payment.utils.spec.ts @@ -11,11 +11,11 @@ import { } from './payment.utils' describe('payment.utils', () => { - it('calculates payment fees from decimal or whole-number markup values', () => { + it('calculates payment fees from billing-account markup multipliers', () => { expect(calculatePaymentChallengeFee(480, 0.15)) .toBe(72) - expect(calculatePaymentChallengeFee(480, 15)) - .toBe(72) + expect(calculatePaymentChallengeFee(570, 1.2259)) + .toBe(698.76) }) it('reads the persisted payment challenge fee when finance returns it explicitly', () => { diff --git a/src/apps/work/src/lib/utils/payment.utils.ts b/src/apps/work/src/lib/utils/payment.utils.ts index 3cb12f822..cf752be22 100644 --- a/src/apps/work/src/lib/utils/payment.utils.ts +++ b/src/apps/work/src/lib/utils/payment.utils.ts @@ -56,8 +56,9 @@ function getFirstPaymentDetail( /** * Normalizes billing markup into a decimal multiplier for payment fee math. * - * Stored markup can arrive as either a decimal fraction like `0.15` or a - * whole percentage like `15`. Missing or invalid inputs return `undefined`. + * Stored markup is the direct multiplier used by finance and billing-account + * ledger math. Values greater than `1` are valid and must not be converted to + * percentage form. Missing or invalid inputs return `undefined`. * * @param billingMarkup raw billing markup from project billing-account data. * @returns normalized decimal markup, or `undefined` when unavailable. @@ -69,9 +70,7 @@ function normalizeBillingMarkup(billingMarkup: unknown): number | undefined { return undefined } - return parsedMarkup > 1 - ? parsedMarkup / 100 - : parsedMarkup + return parsedMarkup } export function formatCurrency(value: unknown): string { diff --git a/src/apps/work/src/lib/utils/prize.utils.spec.ts b/src/apps/work/src/lib/utils/prize.utils.spec.ts index bdaedaf9d..ebcc9bd7d 100644 --- a/src/apps/work/src/lib/utils/prize.utils.spec.ts +++ b/src/apps/work/src/lib/utils/prize.utils.spec.ts @@ -79,11 +79,11 @@ describe('prize utils challenge total', () => { .toBe(100) }) - it('calculates challenge fee from decimal or whole-number markup values', () => { + it('calculates challenge fee from billing-account markup multipliers', () => { expect(calculateChallengeFee(1560, 0.33)) .toBeCloseTo(514.8, 2) - expect(calculateChallengeFee(1560, 33)) - .toBeCloseTo(514.8, 2) + expect(calculateChallengeFee(394, 1.2229)) + .toBeCloseTo(481.82, 2) }) it('formats usd currency values with two decimal places', () => { diff --git a/src/apps/work/src/lib/utils/prize.utils.ts b/src/apps/work/src/lib/utils/prize.utils.ts index beaeb232c..b793de50d 100644 --- a/src/apps/work/src/lib/utils/prize.utils.ts +++ b/src/apps/work/src/lib/utils/prize.utils.ts @@ -170,8 +170,10 @@ export function calculateChallengeTotal( /** * Normalizes billing markup into a decimal multiplier. * - * Stored markup can arrive as either a decimal fraction like `0.15` or a whole - * percentage like `15`. Missing or invalid inputs return `undefined`. + * Stored markup is the direct multiplier used by challenge billing and + * billing-account ledger math. Values greater than `1` are valid and must not + * be converted to percentage form. Missing or invalid inputs return + * `undefined`. * * @param billingMarkup raw billing markup from challenge billing data. * @returns normalized decimal markup, or `undefined` when unavailable. @@ -182,9 +184,7 @@ function normalizeBillingMarkup(billingMarkup: unknown): number | undefined { return undefined } - return billingMarkup > 1 - ? billingMarkup / 100 - : billingMarkup + return billingMarkup } if (typeof billingMarkup !== 'string') { @@ -201,9 +201,7 @@ function normalizeBillingMarkup(billingMarkup: unknown): number | undefined { return undefined } - return normalizedMarkup > 1 - ? normalizedMarkup / 100 - : normalizedMarkup + return normalizedMarkup } /** diff --git a/src/apps/work/src/lib/utils/project-billing-account.utils.spec.ts b/src/apps/work/src/lib/utils/project-billing-account.utils.spec.ts index 45bc2b080..7954d256d 100644 --- a/src/apps/work/src/lib/utils/project-billing-account.utils.spec.ts +++ b/src/apps/work/src/lib/utils/project-billing-account.utils.spec.ts @@ -109,6 +109,10 @@ describe('project-billing-account challenge gating helpers', () => { .toBe(100.20) expect(calculateMemberPaymentsRemaining(250, 0.25)) .toBe(200) + expect(calculateMemberPaymentAmount(1268.76, 1.2259)) + .toBe(570) + expect(calculateMemberPaymentsRemaining(2225.90, 1.2259)) + .toBe(1000) expect(getCopilotMemberPaymentsBudgetInfo({ budget: 1000, consumedBudget: 500, diff --git a/src/apps/work/src/lib/utils/project-billing-account.utils.ts b/src/apps/work/src/lib/utils/project-billing-account.utils.ts index 8660bbd92..982c0cf41 100644 --- a/src/apps/work/src/lib/utils/project-billing-account.utils.ts +++ b/src/apps/work/src/lib/utils/project-billing-account.utils.ts @@ -66,7 +66,9 @@ function normalizeOptionalNumber(value: unknown): number | undefined { * * @param value Raw markup value from the billing-account API. * @returns A non-negative decimal markup, or `undefined` when unavailable. - * @remarks Whole percentage values such as `15` are normalized to `0.15`. + * @remarks Billing-account APIs store markup as the direct multiplier used by + * finance and billing-account ledger math. Values greater than `1` are valid + * and must not be converted to percentage form. */ function normalizeBillingMarkup(value: unknown): number | undefined { const normalizedValue = normalizeOptionalNumber(value) @@ -75,9 +77,7 @@ function normalizeBillingMarkup(value: unknown): number | undefined { return undefined } - return normalizedValue > 1 - ? normalizedValue / 100 - : normalizedValue + return normalizedValue } /** diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeFeeField/ChallengeFeeField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeFeeField/ChallengeFeeField.spec.tsx index 228f75825..0a4c7633a 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeFeeField/ChallengeFeeField.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeFeeField/ChallengeFeeField.spec.tsx @@ -22,6 +22,7 @@ interface TestHarnessProps { challengeFee?: number defaultPrizeSets: PrizeSet[] markup?: number + reviewers?: ChallengeEditorFormData['reviewers'] } const UpdatePrizeSetsButton: FC = () => { @@ -76,7 +77,7 @@ const TestHarness: FC = props => { description: 'Public specification', name: 'Challenge fee test', prizeSets: props.defaultPrizeSets, - reviewers: [ + reviewers: props.reviewers ?? [ { baseCoefficient: 0.15, incrementalCoefficient: 0, @@ -133,7 +134,7 @@ describe('ChallengeFeeField', () => { .toBeTruthy() }) - it('normalizes whole-number markup percentages from persisted billing data', () => { + it('calculates challenge fee from markup multipliers greater than one', () => { render( { prizes: [ { type: 'USD', - value: 100, + value: 150, + }, + { + type: 'USD', + value: 75, }, ], type: 'PLACEMENT', }, + { + prizes: [ + { + type: 'USD', + value: 100, + }, + ], + type: 'COPILOT', + }, + ]} + markup={1.2229} + reviewers={[ + { + baseCoefficient: 0.13, + incrementalCoefficient: 0.05, + isMemberReview: true, + memberReviewerCount: 2, + phaseId: 'review-phase', + scorecardId: 'scorecard-id', + }, ]} - markup={15} />, ) - expect(screen.getByText('$17.25')) + expect(screen.getByText('$481.82')) .toBeTruthy() }) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTotalField/ChallengeTotalField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTotalField/ChallengeTotalField.spec.tsx index b81ce05d6..02e25dda6 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTotalField/ChallengeTotalField.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTotalField/ChallengeTotalField.spec.tsx @@ -99,6 +99,51 @@ describe('ChallengeTotalField', () => { .toBeTruthy() }) + it('calculates challenge totals from markup multipliers greater than one', () => { + render( + , + ) + + expect(screen.getByText('$875.82')) + .toBeTruthy() + }) + it('falls back to the persisted challenge fee when markup is unavailable', () => { render( ('FILESTACK_SHOWCASE_MEDIA_FILE_PICKER_CONTAINER', 'topcoder-dev-showcase-media') + = getReactEnv('FILESTACK_SHOWCASE_MEDIA_FILE_PICKER_CONTAINER', `topcoder-${ENV}-showcase-media`) export const FILESTACK_SHOWCASE_MEDIA_CDN_URL - = getReactEnv('FILESTACK_SHOWCASE_MEDIA_CDN_URL', 'https://showcase-media.topcoder-dev.com') + = getReactEnv( + 'FILESTACK_SHOWCASE_MEDIA_CDN_URL', + `https://showcase-media.topcoder${ENV === 'prod' ? '' : '-dev'}.com`, + )