From 6e5416e12ea86adca809d8692facc4dd92c293f4 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 7 Jul 2026 11:11:59 +1000 Subject: [PATCH 01/10] PM-5516: Restore URL F2F iterative review actions What was broken URL First2Finish challenges could render the Iterative Review tab without the logged-in reviewer's action when the row selected for display was keyed differently from the canonical submission. Root cause The F2F limiter only matched row ids and review submission ids, but URL submissions can carry the canonical submission id in legacySubmissionId. Duplicate rows for the same submission also preferred completed reviews even when the pending row belonged to the current reviewer. What was changed Iterative-review row limiting now matches all known submission aliases, including legacySubmissionId. Duplicate row selection now adds priority for rows assigned to the current reviewer's resource ids before rendering the action column. Any added/updated tests Added iterative-review filtering coverage for URL legacy submission id matching and current-reviewer duplicate row priority. Ran the focused Review spec successfully; the full platform-ui test command still fails on unrelated baseline wallet-admin module-resolution failures and existing Work challenge launch expectations. --- .../TabContentIterativeReview.tsx | 36 +++------- .../iterativeReviewFiltering.spec.ts | 55 ++++++++++++++++ .../iterativeReviewFiltering.ts | 65 +++++++++++++++++-- 3 files changed, 125 insertions(+), 31 deletions(-) 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) { From 9c85ad97c5f6befef4eaddefec2aac45b94498af Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 7 Jul 2026 11:24:49 +1000 Subject: [PATCH 02/10] PM-5447: Preserve immediate phase durations What was broken After an active development challenge was changed from a scheduled launch to immediately, the save could still show the non-Design phase-shortening error even though the immediate schedule had already persisted. Root cause (if identifiable) Immediate saves can persist phase durations with leftover seconds because the backend records current seconds and milliseconds. The Work app converted API durations from seconds to whole minutes by truncating, so the next save could send a phase up to 59 seconds shorter and trigger the backend shortening guard. What was changed Round partial API phase durations up to the next whole minute when hydrating the challenge editor form. This preserves or slightly extends the saved phase window instead of submitting a shorter duration on the next save. Any added/updated tests Added a challenge editor schedule mapping regression test for API phase durations with leftover seconds. --- .../lib/utils/challenge-editor.utils.spec.ts | 20 +++++++++++++++++++ .../src/lib/utils/challenge-editor.utils.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) 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[] { From ebd5cd20a0ee73eef96b755730f30c74c478f520 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 7 Jul 2026 11:34:13 +1000 Subject: [PATCH 03/10] PM-5519: Fix achievements card padding What was broken The desktop Achievements card used less bottom padding than top padding, leaving the bottom of the card visually tighter than the top. Root cause (if identifiable) MemberTCAchievements.module.scss set desktop padding to $sp-8 $sp-8 $sp-2, so the top and sides used the standard card spacing while the bottom used a smaller spacing token. What was changed Changed the desktop Achievements card padding to use $sp-8 on all sides while preserving the existing mobile override. Any added/updated tests Added a MemberTCAchievements style regression test that asserts the desktop card uses even padding. --- .../tc-achievements/MemberTCAchievements.module.scss | 2 +- .../tc-achievements/MemberTCAchievements.spec.tsx | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.spec.tsx 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;/) + }) +}) From fca521cf2fbc087269ed0d84b6af1c4f44d8fec4 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 7 Jul 2026 11:44:20 +1000 Subject: [PATCH 04/10] PM-5518: Align member profile link sizing What was broken Member profile left-column toggles for the bio and awards used larger 16px text while nearby add-link actions used the smaller 14px link button size. The collapsed bio preview could also leave the ellipsis on a short extra wrapped line. Root cause (if identifiable) The bio and awards toggles use custom button styles with a hard-coded 16px font size instead of matching the shared add-link typography. The bio truncation threshold was long enough to include an extra sparse line before the See More control. What was changed Updated the bio and awards toggle font size to the 14px spacing token used by the shared link buttons. Shortened the collapsed member bio preview length so the ellipsis appears earlier in the left-column layout and updated the helper documentation. Any added/updated tests Updated the existing getTruncatedBio expectations and added a regression case for screenshot-style profile bio previews. --- .../about-me/AboutMe.module.scss | 2 +- .../about-me/AboutMe.utils.spec.ts | 23 ++++++++++++++++--- .../member-profile/about-me/AboutMe.utils.ts | 9 ++++---- .../CommunityAwards.module.scss | 2 +- 4 files changed, 27 insertions(+), 9 deletions(-) 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; From 5f0409c5f114aa0279c20ab43947c148e4484b34 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 7 Jul 2026 14:22:52 +1000 Subject: [PATCH 05/10] Updates for PM-5245 --- .../BillingAccountLineItemsModal.spec.tsx | 27 +++++++++++++++++++ .../work/src/lib/utils/payment.utils.spec.ts | 6 ++--- src/apps/work/src/lib/utils/payment.utils.ts | 9 +++---- .../project-billing-account.utils.spec.ts | 4 +++ .../utils/project-billing-account.utils.ts | 8 +++--- 5 files changed, 42 insertions(+), 12 deletions(-) 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/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/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 } /** From a0739f15cfb47bd22ffa6a9a25003ae4bcef7ab5 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 7 Jul 2026 10:21:32 +0300 Subject: [PATCH 06/10] Fix default env for showcase media --- src/config/environments/default.env.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts index 2c3e9b2a7..19395eb96 100644 --- a/src/config/environments/default.env.ts +++ b/src/config/environments/default.env.ts @@ -329,7 +329,10 @@ export const FILESTACK = { export const SUBDOMAIN = window.location.hostname.split('.')[0] export const FILESTACK_SHOWCASE_MEDIA_FILE_PICKER_CONTAINER - = getReactEnv('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`, + ) From eca9177efc3d339d87c62c6ad98f6d89cac72651 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 8 Jul 2026 14:06:23 +1000 Subject: [PATCH 07/10] Fixes for 4 decimal place markup calculations --- .../work/src/lib/utils/prize.utils.spec.ts | 6 +-- src/apps/work/src/lib/utils/prize.utils.ts | 14 +++--- .../ChallengeFeeField.spec.tsx | 34 +++++++++++--- .../ChallengeTotalField.spec.tsx | 45 +++++++++++++++++++ 4 files changed, 83 insertions(+), 16 deletions(-) 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/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( Date: Wed, 8 Jul 2026 16:32:36 +1000 Subject: [PATCH 08/10] Initial flexi-talent tab for customer portal --- .../src/config/routes.config.ts | 1 + .../src/customer-portal.routes.tsx | 2 + .../components/NavTabs/config/tabs-config.ts | 4 + .../src/lib/services/flexiTalent.service.ts | 437 ++++++++ .../customer-portal/src/lib/services/index.ts | 1 + .../FlexiTalentPage.module.scss | 892 +++++++++++++++++ .../FlexiTalentPage/FlexiTalentPage.tsx | 93 ++ .../flexi-talent/FlexiTalentPage/index.ts | 1 + .../EngagementsView/EngagementsView.tsx | 708 +++++++++++++ .../components/EngagementsView/index.ts | 1 + .../MemberHistoryModal/MemberHistoryModal.tsx | 345 +++++++ .../components/MemberHistoryModal/index.ts | 1 + .../MembersPlaceholder/MembersPlaceholder.tsx | 23 + .../components/MembersPlaceholder/index.ts | 1 + .../components/MembersView/MembersView.tsx | 938 ++++++++++++++++++ .../components/MembersView/index.ts | 1 + .../flexi-talent/flexi-talent.routes.tsx | 26 + 17 files changed, 3475 insertions(+) create mode 100644 src/apps/customer-portal/src/lib/services/flexiTalent.service.ts create mode 100644 src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.module.scss create mode 100644 src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.tsx create mode 100644 src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/index.ts create mode 100644 src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx create mode 100644 src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/index.ts create mode 100644 src/apps/customer-portal/src/pages/flexi-talent/components/MemberHistoryModal/MemberHistoryModal.tsx create mode 100644 src/apps/customer-portal/src/pages/flexi-talent/components/MemberHistoryModal/index.ts create mode 100644 src/apps/customer-portal/src/pages/flexi-talent/components/MembersPlaceholder/MembersPlaceholder.tsx create mode 100644 src/apps/customer-portal/src/pages/flexi-talent/components/MembersPlaceholder/index.ts create mode 100644 src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.tsx create mode 100644 src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/index.ts create mode 100644 src/apps/customer-portal/src/pages/flexi-talent/flexi-talent.routes.tsx 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..fc352bdb0 --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.module.scss @@ -0,0 +1,892 @@ +@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 { + color: $black-100; + font-size: 22px; + font-weight: 700; + line-height: 28px; + } +} + +.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; + + span { + color: $black-60; + font-size: 12px; + font-weight: 500; + } +} + +.sortButtonActive { + border-color: $turq-160; + color: $black-100; +} + +.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; + } +} + +.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..19bbf8324 --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx @@ -0,0 +1,708 @@ +/* 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 { 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 + +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` +} + +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 [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 debouncedApplySearch = useMemo( + () => debounce((nextSearchText: string): void => { + setAppliedSearchText(nextSearchText) + setPage(1) + setSearchRefreshNonce(nonce => nonce + 1) + }, SEARCH_DEBOUNCE_MS), + [], + ) + + const summaryBuckets = useMemo(() => [ + { + count: summaryData?.total, + id: 'total' as FlexiEngagementBucket, + label: 'Total Engagements', + }, + { + count: summaryData?.active, + id: 'active' as FlexiEngagementBucket, + label: 'Active', + }, + { + count: summaryData?.closed, + id: 'closed' as FlexiEngagementBucket, + label: 'Closed', + }, + ], [summaryData]) + + /** + * 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') + }, []) + + /** + * 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') + + try { + const response = await getFlexiEngagementDetail(row.engagementId) + if (detailGenerationRef.current !== generation) { + return + } + + setDetailData(response) + setDetailState('ready') + } catch { + if (detailGenerationRef.current !== generation) { + return + } + + setDetailErrorMessage(getErrorMessage('Could not load engagement details.')) + setDetailState('error') + } + }, []) + + /** + * 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]) + + 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 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..4b3642665 --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.tsx @@ -0,0 +1,938 @@ +/* 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 { 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, + id: 'total' as FlexiMemberBucket, + label: 'Total Unique Members', + }, + { + count: summaryData?.assignedMembers, + id: 'assigned' as FlexiMemberBucket, + label: 'Assigned', + }, + { + count: summaryData?.completedMembers, + 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 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, + }, +] From 48e76e516efa10bed7eac5e10242e76ecd970869 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 8 Jul 2026 16:33:27 +1000 Subject: [PATCH 09/10] Branch deploy --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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-.*/ From d8f0384ca82b9562a9c20c4babf2f05bf055a60c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 9 Jul 2026 11:05:24 +1000 Subject: [PATCH 10/10] UI tweaks --- .../FlexiTalentPage.module.scss | 133 +++++++++++++++++- .../EngagementsView/EngagementsView.tsx | 132 ++++++++++++++++- .../components/MembersView/MembersView.tsx | 45 +++++- 3 files changed, 295 insertions(+), 15 deletions(-) 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 index fc352bdb0..b83dcd071 100644 --- 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 @@ -139,13 +139,24 @@ } strong { - color: $black-100; 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); @@ -241,11 +252,19 @@ font-weight: 600; line-height: 18px; padding: 7px 12px; +} - span { - color: $black-60; - font-size: 12px; - font-weight: 500; +.sortDirectionIndicator { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: $black-60; + + svg { + width: 16px; + height: 16px; } } @@ -254,6 +273,18 @@ 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; @@ -511,6 +542,98 @@ } } +.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; 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 index 19bbf8324..f09d3c181 100644 --- 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 @@ -13,6 +13,7 @@ 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 { @@ -31,6 +32,8 @@ 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' @@ -127,6 +130,26 @@ function formatMemberCount( 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 } @@ -151,6 +174,7 @@ 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) @@ -173,6 +197,8 @@ export const EngagementsView: FC = () => { 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 => { @@ -186,21 +212,34 @@ export const EngagementsView: FC = () => { 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. * @@ -239,7 +278,8 @@ export const EngagementsView: FC = () => { setDetailData(undefined) setDetailErrorMessage('') setDetailState('loading') - }, []) + resetDescriptionState() + }, [resetDescriptionState]) /** * Loads detail for a selected engagement row. @@ -255,6 +295,7 @@ export const EngagementsView: FC = () => { setDetailData(undefined) setDetailErrorMessage('') setDetailState('loading') + resetDescriptionState() try { const response = await getFlexiEngagementDetail(row.engagementId) @@ -262,6 +303,7 @@ export const EngagementsView: FC = () => { return } + resetDescriptionState() setDetailData(response) setDetailState('ready') } catch { @@ -272,7 +314,7 @@ export const EngagementsView: FC = () => { setDetailErrorMessage(getErrorMessage('Could not load engagement details.')) setDetailState('error') } - }, []) + }, [resetDescriptionState]) /** * Refreshes the current engagement list and auto-selects the first returned row. @@ -370,6 +412,35 @@ export const EngagementsView: FC = () => { 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() @@ -421,6 +492,10 @@ export const EngagementsView: FC = () => { .catch(() => undefined) }, [fetchSelectedEngagementDetail]) + const handleDescriptionToggle = useCallback((): void => { + setIsDescriptionExpanded(currentValue => !currentValue) + }, []) + const renderSummaryCount = useCallback((count: number | undefined): string => { if (isSummaryLoading) { return '--' @@ -458,7 +533,7 @@ export const EngagementsView: FC = () => { type='button' > {bucket.label} - {renderSummaryCount(bucket.count)} + {renderSummaryCount(bucket.count)} ))}
@@ -499,7 +574,18 @@ export const EngagementsView: FC = () => { > Name {sortBy === 'name' && ( - {sortOrder === 'asc' ? 'Asc' : 'Desc'} + <> + + + {sortOrder === 'asc' ? 'sorted ascending' : 'sorted descending'} + + )}
@@ -618,7 +715,30 @@ export const EngagementsView: FC = () => {

Description

-

{detailData.description || 'No description provided.'}

+ {sanitizedDescriptionHtml ? ( + <> +
+ {isDescriptionCollapsible && ( + + )} + + ) : ( +

No description provided.

+ )}
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 index 4b3642665..5d60cb144 100644 --- 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 @@ -13,6 +13,7 @@ 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 { @@ -354,16 +355,19 @@ export const MembersView: FC = props => { 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', }, @@ -620,6 +624,10 @@ export const MembersView: FC = props => { 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) @@ -648,7 +656,7 @@ export const MembersView: FC = props => { type='button' > {bucket.label} - {renderSummaryCount(bucket.count)} + {renderSummaryCount(bucket.count)} ))}
@@ -689,7 +697,18 @@ export const MembersView: FC = props => { > Handle {sortBy === 'handle' && ( - {sortOrder === 'asc' ? 'Asc' : 'Desc'} + <> + + + {sortOrder === 'asc' ? 'sorted ascending' : 'sorted descending'} + + )}
@@ -880,7 +910,14 @@ export const MembersView: FC = props => {

Description

-

{detailData.description || 'No description provided.'}

+ {sanitizedDescriptionHtml ? ( +
+ ) : ( +

No description provided.

+ )}