diff --git a/docs/wallet-payment-installments.md b/docs/wallet-payment-installments.md new file mode 100644 index 000000000..6a0a9b591 --- /dev/null +++ b/docs/wallet-payment-installments.md @@ -0,0 +1,29 @@ +# Wallet Admin installment amounts + +Finance winnings can contain historical split payments. For PS-616, the two +installments are $2,760 and $920, while each installment repeats the full $3,680 +in `totalAmount`. + +Wallet Admin uses the winning-level `grossAmount` supplied by Finance for the +list, payment details summary and edit form. If that field is unavailable, it +sums the returned installment `grossAmount` values, rounded to cents. It never +adds the repeated `totalAmount` fields or billing markup. Finance returns all +current installment rows, ordered with installment 1 first. Separate payment +rows sharing an installment number are preserved because versions protect +in-place updates rather than identifying historical copies. + +The General Info tab displays each installment's amount and status when there +is more than one installment. The amount is the historical gross face value, +including any cancelled installments; individual statuses remain visible in +the breakdown. Primary-installment status/date behavior and payment mutations +are unchanged. Member Wallet already displays the full `totalAmount`; its +withdrawal flow is unchanged by this display correction. + +Deploy the companion Finance PS-616 change before the platform-ui change to +provide the complete installment data. An older API that only returns the first +installment cannot supply the omitted amount to the UI fallback. Finance CSV +exports use the same winning-level gross summary. + +Regression tests cover the $2,760 + $920 split with and without the summary field, +the amount passed to the details modal, single-installment amounts with billing +markup, and paid/cancelled installment breakdowns. diff --git a/docs/work-showcase-win.md b/docs/work-showcase-win.md new file mode 100644 index 000000000..816b21650 --- /dev/null +++ b/docs/work-showcase-win.md @@ -0,0 +1,57 @@ +# Work showcase and WIN (PM-6329) + +The project editor and showcase form share `project.details.customer`, `smu`, +`smuOther`, and `dealCloseDate`. New showcases start with the current project +values. Existing showcases load those values from the post API response. Saving +a showcase updates its content and these project fields in one projects API +transaction, then refreshes the cached project. + +Project metadata is optional in the project editor and required when saving the +showcase form. SMU accepts APMEA, Europe, Americas1, Americas2, or Others. Others +requires a custom value; selecting a standard SMU clears the stored custom value. +Deal Close Date is a `YYYY-MM-DD` calendar date and is not converted between timezones. + +The showcase form follows this order: + +1. Title +2. Type (required: Open Innovation, Private POD Delivery, Flexi-Talent Supply, AI Data Licensing) +3. Customer / SMU / Deal Close Date +4. Industry/Sector +5. Category/Technology +6. The Challenge +7. The Solution (the existing `content` API field) +8. Business Impact Realised +9. Key Win +10. Post Media +11. Topcoder Challenge Launched +12. Current Status (Delivered, In Delivery, On-Hold, Planned) +13. Owner (a name or handle) + +The Challenge, The Solution and Business Impact Realised use the same Markdown +editor and sanitized rich-text preview. Key Win and Owner are single-line strings +of up to 255 characters. Existing title, taxonomy and solution requirements remain. + +The Work preview modal and the customer portal showcase (`/showcase` cards and +post pages) share `ShowcasePostDetails`. Type, Customer, SMU (the custom value +for Others), Deal Close Date, Key Win, Current Status and Owner appear in a +labelled summary, followed by The Challenge, The Solution and Business Impact +Realised. Anything left blank is hidden, including empty media, challenge, +stats and skills sections. Showcase cards show Type, Customer and Current Status +when they are set. + +**Send to WIN** is unchecked for new posts and retains the saved choice on edits. +Saving with the checkbox selected makes the record available to authorized callers +of `GET /v6/reports/WIN`. This is a pull integration: the success message confirms +availability to WIN. Validation and API failures keep the form open with an error. +Unchecking removes the post from the WIN report after saving. Archived posts and +posts belonging to deleted projects are excluded from the report. + +## Deployment and verification + +Deploy the PM-6329 projects API migration and application before enabling this UI; +deploy the reports API change for WIN callers. No new UI environment variables are needed. + +Use `.nvmrc`, then `yarn lint`, `yarn run build`, and the tests for +`ProjectShowcasePage`, `ProjectEditorForm`, `project-editor.schema`, and +`showcase-post.schema` and `ShowcasePostDetails`. Manually check that editing metadata in either form is +reflected when reopening the other form, and that opting in/out changes the WIN report. diff --git a/infrastructure/sales/README.md b/infrastructure/sales/README.md new file mode 100644 index 000000000..f4c59c9d5 --- /dev/null +++ b/infrastructure/sales/README.md @@ -0,0 +1,39 @@ +# Sales dev route + +`sales.topcoder-dev.com` is served by the shared Platform UI root build that +CircleCI deploys on every `dev` merge, like Work, Customer and Reports. The build +detects the `sales` subdomain at runtime (`EnvironmentConfig.SUBDOMAIN`) and +renders the Sales page at `/`, so no Sales-specific build or upload is needed. + +| Resource | Dev value | +| --- | --- | +| AWS account / region | `811668436784` / `us-east-1` | +| CloudFront distribution | `EFA7R1KH3UX5M` (`d10303ypawq4ub.cloudfront.net`) | +| S3 bucket | `platform-mvp.topcoder-dev.com` (root build, shared with other hosts) | +| Route 53 public hosted zone | `Z2CIRG3R0ZSGFQ` | +| Host | `sales.topcoder-dev.com` | +| Viewer request function | `platform-sales-dev-viewer-request` | + +`dev-viewer-request.js` mirrors the live viewer-request function. It has no Sales +branch: Sales requests pass through unchanged and deep links fall back to the root +`/index.html` through the distribution's 403/404 custom error responses. The +function still routes the Contact and Accounts hosts to their isolated +`contact-app/` and `accounts-preferences-app/` shells. Re-read the live function +before publishing later changes to avoid overwriting newer routing, test with +`aws cloudfront test-function` against the DEVELOPMENT stage, then publish. + +The distribution alias and Route 53 A/AAAA records for the Sales host point at the +same distribution; its ACM certificate covers `*.topcoder-dev.com`. Authentication +continues through the standard Platform UI login. + +The former isolated `sales-app/` S3 prefix (PM-6343) is no longer referenced and +can be deleted once the shared root route is confirmed. + +Deploy the reports-api-v6 PM-6343 branch to dev with its normal `dev-PM-6343` tag +workflow. Server-only Salesforce configuration is documented in that repo's +`SALES.md`. The Sales page uses `/v6/reports/sales`; WIN uses the separate +`/v6/reports/win/sales` endpoint and requires an M2M grant for `reports:sales`. + +Rollback: remove the Sales alias/DNS entries, leaving shared settings intact. +Restore the prior Reports API ECS task revision if rolling back the backend. +No database migration is involved. diff --git a/infrastructure/sales/dev-viewer-request.js b/infrastructure/sales/dev-viewer-request.js new file mode 100644 index 000000000..eb03be60d --- /dev/null +++ b/infrastructure/sales/dev-viewer-request.js @@ -0,0 +1,26 @@ +/** + * Routes the Contact and Accounts dev hosts to their isolated S3 shells. + * Sales and every other Platform UI host use the shared root build deployed by CircleCI. + * @param {Object} event CloudFront viewer-request event containing the URI and Host header. + * @returns {Object} Request with an app-specific SPA fallback; assets and unrelated hosts are preserved. + * @throws No exceptions for valid CloudFront request events; performs no network I/O. + */ +function handler(event) { + var request = event.request; + var host = request.headers.host ? request.headers.host.value.toLowerCase() : ''; + var prefix = ''; + if (host === 'contact.topcoder-dev.com' || host === 'contact.topcoder-dev.com:443') { + prefix = '/contact-app/'; + } else if (host === 'account-settings.topcoder-dev.com' || host === 'account-settings.topcoder-dev.com:443') { + // Keep shared assets available to Accounts pages opened before its isolated deployment. + if (request.uri.indexOf('/static/') === 0 + || ['/global.css', '/favicon.png', '/manifest.json', '/logo_512x512.png', '/robots.txt'].indexOf(request.uri) !== -1) { + return request; + } + prefix = '/accounts-preferences-app/'; + } + if (prefix && request.uri.indexOf(prefix) !== 0) { + request.uri = prefix + 'index.html'; + } + return request; +} diff --git a/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.module.scss b/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.module.scss index 548a0d707..76e310d60 100644 --- a/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.module.scss +++ b/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.module.scss @@ -22,8 +22,12 @@ color: $black-100; padding: $sp-2 $sp-4; + a { + color: inherit; + } + &:hover:not(.disabled) { - background: $turq-160; + background: var(--tc-2026-teal, #{$turq-160}); color: $tc-white; cursor: pointer; } diff --git a/src/apps/admin/src/platform/gamification-admin/src/pages/badge-detail/ManualAwardTab/ManualAwardTab.tsx b/src/apps/admin/src/platform/gamification-admin/src/pages/badge-detail/ManualAwardTab/ManualAwardTab.tsx index b2dd7b27a..9242953a4 100644 --- a/src/apps/admin/src/platform/gamification-admin/src/pages/badge-detail/ManualAwardTab/ManualAwardTab.tsx +++ b/src/apps/admin/src/platform/gamification-admin/src/pages/badge-detail/ManualAwardTab/ManualAwardTab.tsx @@ -65,10 +65,6 @@ const ManualAwardTab: FC = (props: ManualAwardTabProps) =>

Manual Award

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Neque ullamcorper neque sed orci, enim amet, sed. -

= props => { thread: props.threadId, }, }, - fetch: authFetch, + fetch: withStallWatchdog(authFetch), }), [props.resourceId, props.threadId], ) @@ -167,6 +173,32 @@ const ChatThread: FC = props => { transport, }) + const handleRecovered = useCallback((messages: UIMessage[]) => { + chat.setMessages(messages) + props.onThreadActivity?.() + }, [chat, props.onThreadActivity]) + + const { startRecovery, status: recoveryStatus }: UseStreamRecoveryResult + = useStreamRecovery(props.threadId, handleRecovered) + + // `useChat`'s `onError` fires once per request, so re-wiring it here as + // a plain option risks missing an error that lands before this render + // commits. Watching `chat.error` instead catches it regardless of when + // it's set, and `handledErrorRef` stops a still-set error from + // re-triggering recovery on unrelated re-renders. + const handledErrorRef = useRef(undefined) + + useEffect(() => { + if (chat.error && chat.error !== handledErrorRef.current) { + handledErrorRef.current = chat.error + // Mirrors `toChatUIMessages`' empty-parts filter, so this lines + // up with what the recovered thread history will look like. + const baselineMessageCount = chat.messages.filter(message => message.parts.length > 0).length + + startRecovery(baselineMessageCount) + } + }, [chat.error, chat.messages, startRecovery]) + const runtime = useAISDKRuntime(chat) return ( @@ -204,6 +236,19 @@ const ChatThread: FC = props => { /> + {recoveryStatus === 'recovering' && ( +
+ + Still working on that — reconnecting to fetch the response… +
+ )} + + {recoveryStatus === 'timed-out' && ( +
+ This is taking longer than expected. Refresh the page to check whether a response arrived. +
+ )} + { + const response = await baseFetch(input, init) + + if (!response.body) { + return response + } + + const reader = response.body.getReader() + + const watchedBody = new ReadableStream({ + cancel(reason: unknown) { + return reader.cancel(reason) + }, + start(controller: ReadableStreamDefaultController) { + let timeoutId: ReturnType + + function clearWatchdog(): void { + clearTimeout(timeoutId) + } + + function armWatchdog(): void { + clearWatchdog() + timeoutId = setTimeout(() => { + reader.cancel('TopScout stream stalled') + .catch(() => undefined) + controller.error(new Error('TopScout response stalled — no data received in time')) + }, stallTimeoutMs) + } + + function pump(): void { + reader.read() + .then(({ done, value }: ReadableStreamReadResult) => { + if (done) { + clearWatchdog() + controller.close() + return + } + + armWatchdog() + controller.enqueue(value) + pump() + }) + .catch((error: unknown) => { + clearWatchdog() + controller.error(error) + }) + } + + armWatchdog() + pump() + }, + }) + + return new Response(watchedBody, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }) + } +} diff --git a/src/apps/customer-portal/src/pages/assistants/TopScoutPage/lib/use-stream-recovery.ts b/src/apps/customer-portal/src/pages/assistants/TopScoutPage/lib/use-stream-recovery.ts new file mode 100644 index 000000000..3a0b30f37 --- /dev/null +++ b/src/apps/customer-portal/src/pages/assistants/TopScoutPage/lib/use-stream-recovery.ts @@ -0,0 +1,79 @@ +import { useCallback, useRef, useState } from 'react' +import { UIMessage } from 'ai' + +import { fetchChatThreadMessages } from './chat.service' +import { toChatUIMessages } from './convert-messages' + +const POLL_INTERVAL_MS = 4_000 +const POLL_TIMEOUT_MS = 120_000 + +export type StreamRecoveryStatus = 'idle' | 'recovering' | 'timed-out' + +export interface UseStreamRecoveryResult { + status: StreamRecoveryStatus + startRecovery: (baselineMessageCount: number) => void +} + +/** + * Mastra only persists the assistant's message once its run finishes, so + * when the live SSE connection dies mid-response (see stream-watchdog.ts), + * the recovered answer isn't available yet either — it lands in Postgres + * whenever the backend run completes, independent of the client connection. + * This polls thread history until that happens instead of leaving the user + * to keep manually refreshing. + * + * `baselineMessageCount` is the number of non-empty messages the caller + * already has rendered (mirroring toChatUIMessages' empty-parts filter) — + * recovery is considered resolved once the fetched thread has more than + * that many messages and the newest one is from the assistant. + */ +export function useStreamRecovery( + threadId: string, + onRecovered: (messages: UIMessage[]) => void, +): UseStreamRecoveryResult { + const [status, setStatus] = useState('idle') + const runIdRef = useRef(0) + + const startRecovery = useCallback((baselineMessageCount: number) => { + runIdRef.current += 1 + const runId = runIdRef.current + const deadline = Date.now() + POLL_TIMEOUT_MS + setStatus('recovering') + + async function poll(): Promise { + if (runIdRef.current !== runId) { + return + } + + try { + const stored = await fetchChatThreadMessages(threadId) + const messages = toChatUIMessages(stored) + + if (runIdRef.current !== runId) { + return + } + + const newest = messages[messages.length - 1] + + if (messages.length > baselineMessageCount && newest?.role === 'assistant') { + setStatus('idle') + onRecovered(messages) + return + } + } catch (error) { + console.error('TopScout recovery poll failed:', (error as Error).message) + } + + if (Date.now() >= deadline) { + setStatus('timed-out') + return + } + + setTimeout(poll, POLL_INTERVAL_MS) + } + + poll() + }, [onRecovered, threadId]) + + return { startRecovery, status } +} diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss index d92c8a00b..50dcb2a2d 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss @@ -80,6 +80,14 @@ gap: 12px; } +.highlights { + color: $black-80; + font-size: 14px; + font-weight: 600; + line-height: 22px; + overflow-wrap: anywhere; +} + .taxonomy { display: flex; align-items: center; diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx index b2cbd6cf0..c8c5c4820 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx @@ -14,40 +14,52 @@ interface ProjectShowcaseCardProps { post: ProjectShowcasePost } -const ProjectShowcaseCard: FC = props => ( -
-
- {props.post.categories.map(category => ( - - {category.name} - - ))} -
- -

- {props.post.title || 'Untitled'} -

-
- - - {props.post.industries.map(item => item.name) - .join(', ') || '—'} - -
- -
- {renderRichTextToPlainText(props.post.content || '')} -
- -
- -
-
-) +const ProjectShowcaseCard: FC = props => { + const industries = props.post.industries.map(item => item.name) + .join(', ') + const highlights = [props.post.type, props.post.customer, props.post.currentStatus] + .map(value => value?.trim()) + .filter(Boolean) + .join(' · ') + const summary = props.post.content || props.post.challenge || props.post.businessImpact || '' + + return ( +
+
+ {props.post.categories.map(category => ( + + {category.name} + + ))} +
+ +

+ {props.post.title || 'Untitled'} +

+ {highlights &&
{highlights}
} + {industries && ( +
+ + {industries} +
+ )} + + {summary && ( +
+ {renderRichTextToPlainText(summary)} +
+ )} + +
+ +
+
+ ) +} export default ProjectShowcaseCard diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss index e43ee7057..1d671bf57 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss @@ -161,6 +161,14 @@ margin-bottom: 8px; } + img { + display: block; + max-width: 100%; + height: auto; + margin: 0 0 16px; + border-radius: 8px; + } + a { color: $link-blue-dark; font-weight: 700; diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx index b75cb1ed7..e7d6eddcd 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx @@ -6,6 +6,8 @@ import { IconOutline, LinkButton, PageTitle } from '~/libs/ui' import { renderRichTextToHtml } from '~/libs/shared/lib/utils/rich-text' import { textFormatDateLocaleShortString } from '~/libs/shared/lib/utils/text-format' import { + getShowcaseStorySections, + ShowcasePostDetails, useFetchProjectShowcasePost, UseFetchProjectShowcasePostResult, } from '~/apps/work/src/lib' @@ -30,6 +32,12 @@ const ProjectShowcasePostPage: FC = () => { encodeURIComponent(routeParams.projectId as string), ].join('/') + const storySections = useMemo( + () => (post ? getShowcaseStorySections(post) : []), + [post], + ) + const challengeCount = post?.challengeMetadata?.length ?? 0 + const skills = useMemo( () => uniqBy(post?.challengeMetadata?.flatMap(entry => entry.skills), 'id') ?? [], [post?.challengeMetadata], @@ -75,10 +83,12 @@ const ProjectShowcasePostPage: FC = () => { {post?.title}
-
- - {industries} -
+ {industries && ( +
+ + {industries} +
+ )}
Published @@ -90,24 +100,34 @@ const ProjectShowcasePostPage: FC = () => {
-
-
-
Media assets
- -
-
-
Challenges
- -
+ {post && } + {storySections.map(section => ( +
+
{section.label}
+
+
+ ))} + {!!post?.media?.length && ( +
+
Media assets
+ +
+ )} + {!!post?.challengeIds?.length && ( +
+
Topcoder Challenge Launched
+ +
+ )}
+ )}
diff --git a/src/apps/engagements/src/pages/lead-intake/LeadIntakePage.module.scss b/src/apps/engagements/src/pages/lead-intake/LeadIntakePage.module.scss index 24cf80c61..49cb5b894 100644 --- a/src/apps/engagements/src/pages/lead-intake/LeadIntakePage.module.scss +++ b/src/apps/engagements/src/pages/lead-intake/LeadIntakePage.module.scss @@ -9,19 +9,49 @@ $placeholder-color: #6F6F6F; $border-light: #E0E0E0; $brand-teal: #007D79; -.pageOuter { - max-width: none; +.pageContent { + overflow-x: clip; + padding-bottom: 0; + width: 100%; + + > :global([class*='content-outer']) { + background-color: $tc-2026-canvas; + margin: 0; + max-width: none; + padding-bottom: 48px; + padding-left: 0; + padding-right: 0; + padding-top: 0; + width: 100%; + } } .pageInner { max-width: none; + padding: 0; + width: 100%; } .pageHeader { - @include substractPagePaddings; background: $brand-teal; margin-bottom: 24px; - padding: 48px $sp-8; + padding: 48px $sp-10; + width: 100%; + + @include ltemd { + padding-left: $sp-6; + padding-right: $sp-6; + } + + @include ltesm { + padding-left: $sp-4; + padding-right: $sp-4; + } +} + +.pageBody { + padding: 0 $sp-8; + width: 100%; @include ltemd { padding-left: $sp-6; @@ -42,7 +72,6 @@ $brand-teal: #007D79; letter-spacing: 0; line-height: $tc-2026-h2-line-height; margin: 0; - padding-left: $sp-8; text-transform: none; } @@ -82,6 +111,7 @@ $brand-teal: #007D79; .section { background: $tc-2026-surface; + border: none; border-radius: $tc-2026-radius-lg; display: grid; gap: 16px; @@ -189,7 +219,7 @@ $brand-teal: #007D79; } .selectInputPlaceholder :global([class*='textSelected']) { - color: $placeholder-color; + color: $placeholder-color !important; } .selectInputWrapper:not(.selectInputPlaceholder) :global([class*='textSelected']) { @@ -204,6 +234,7 @@ $brand-teal: #007D79; .textareaField::placeholder { color: $placeholder-color; font-size: 16px; + opacity: 1; } .inputField:focus, @@ -338,7 +369,8 @@ $brand-teal: #007D79; } :global([class*='__placeholder']) { - color: $placeholder-color; + color: $placeholder-color !important; + opacity: 1 !important; } :global([class*='__single-value']) { diff --git a/src/apps/engagements/src/pages/lead-intake/LeadIntakePage.tsx b/src/apps/engagements/src/pages/lead-intake/LeadIntakePage.tsx index 23639b0a7..14194f37d 100644 --- a/src/apps/engagements/src/pages/lead-intake/LeadIntakePage.tsx +++ b/src/apps/engagements/src/pages/lead-intake/LeadIntakePage.tsx @@ -570,373 +570,380 @@ const LeadIntakePage: FC = () => { }, []) return ( - +

Engagement Lead Intake

-
- {!submitted && ( -
-
Submit an Engagement Requirement
-

- Use this form to share your Flexi-Talent resource requirement. - No sign-in is required. A Talent Manager will review your submission - and follow up using the work email provided. -

-
- )} - - {submitted && ( -
-
- -
-

Thank you for your submission!

-

- Your engagement requirement has been received. A Talent Manager - will review it and contact you at the email address provided. -

-
- )} - - {submitError && ( -
- -
-

Submission failed

-

{submitError}

+
+
+ {!submitted && ( +
+
Submit an Engagement Requirement
+

+ Use this form to share your Flexi-Talent resource requirement. + No sign-in is required. A Talent Manager will review your submission + and follow up using the work email provided. +

-
- )} - - {!submitted && ( -
-
-
1. Contact & Account
-
- - - -
-
- -
-
2. Role Requirements
-
- - - - - - - -
-
- -
-
3. Engagement Details
-
- - - - - - - -
-
+ )} -
-
4. Commercial & Priority
-
- - - - + {submitted && ( +
+
+
-
- -
+

Thank you for your submission!

+

+ Your engagement requirement has been received. A Talent Manager + will review it and contact you at the email address provided. +

-
- )} + )} + + {submitError && ( +
+ +
+

Submission failed

+

{submitError}

+
+
+ )} + + {!submitted && ( +
+
+
1. Contact & Account
+
+ + + +
+
+ +
+
2. Role Requirements
+
+ + + + + + + +
+
+ +
+
3. Engagement Details
+
+ + + + + + + +
+
+ +
+
4. Commercial & Priority
+
+ + + + +
+
+ +
+
+
+ )} +
) diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md index 1dfa05d2e..80d7e264a 100644 --- a/src/apps/opportunities/README.md +++ b/src/apps/opportunities/README.md @@ -424,6 +424,12 @@ submission history, and Marathon Match dashboard tooltips and accessible data. Thousands separators, valid zero, and negative scorer sentinels retain their established handling. Compact My Submissions columns preserve those full-precision values without overlapping adjacent score or action columns. +Marathon My Submissions combines Test Progress and Test Status into one Test +Status column. Running tests show the available progress bar and percentage; +Passed, Failed, and Cancelled attempts show their terminal badge instead. If +progress is unavailable, the status label or an em dash remains visible. The +seven-column table fits the padded desktop card; narrower tablets retain +horizontal scrolling and mobile uses the existing record cards. The Figma keeps separate Provisional Score and Final Score columns and uses `-` when a final value is not yet available. Winners use Review API's canonical `GET /v6/projectResult` member-and-placement result instead of inferring a @@ -443,7 +449,11 @@ An empty Winners tab reflects the challenge lifecycle: cancelled challenges state that no winners were selected, drafts explain that judging has not run, and active challenges retain the ongoing-review guidance. -Registered members submit without leaving challenge details. My Submissions +Registered members submit without leaving challenge details. The prize card's +Submit a solution action revalidates registration, opens My Submissions, and +scrolls the rendered upload panel below the fixed navigation. The scroll is +instant when reduced motion is preferred; repeated clicks return to the open +form. Selecting ordinary tabs does not trigger this upload scroll. My Submissions also exposes the environment-specific Review App handoff before and after an upload. File-backed rows expose the authorized download action for every challenge track, while authored external-URL rows omit that file action. The @@ -454,8 +464,17 @@ storage, so pending DMZ and quarantined files cannot be downloaded. Work Manager's case-insensitive `submission_type=url` challenge metadata selects the URL experience; every other value, including missing or malformed metadata, retains the standard ZIP experience. URL submissions -require a confirmed absolute HTTP(S) link and send that link directly to -`POST /v6/submissions` without invoking Filestack. ZIP submissions accept one +reuse community-app's Topgear flow and exact Wipro instructions, submission +warning, customer-deliverable note, and Service Now (SNOW) support guidance in +Opportunities-styled cards. Members enter a Wipro SharePoint deliverable link, +click **Set URL**, accept the declaration, and submit. Editing a confirmed link +clears its confirmation and acceptance. URL validation requires an absolute +HTTP(S) link with a deliverable path on `wipro365.sharepoint.com`, +`wipro365-my.sharepoint.com`, or `wipro365-my.sharepoint.com.mcas.ms`, with no +embedded credentials or nonstandard port. Deployments can override these exact +hosts with the pipe-separated `REACT_APP_TOPGEAR_ALLOWED_SUBMISSIONS_DOMAINS` +setting, matching community-app's allowlist configuration. URL submissions send +the confirmed link directly to `POST /v6/submissions` without invoking Filestack. ZIP submissions accept one `.zip` archive up to 500MB and report live upload progress. The browser uploads the archive to Filestack's S3 endpoint using the environment's canonical submissions DMZ bucket, then sends the resulting storage URL to the same Review @@ -465,14 +484,14 @@ member's registration before submission. The active phase selects `CONTEST_SUBMI authoritative for registration, phase, winner, submission-limit, and file validation. Design shows the four expected inner deliverables, while Development, Marathon Match, and Quality Assurance direct members to their -Requirements content in ZIP mode; URL mode replaces file-specific guidance -with link accessibility reminders. Successful submissions expose the created +Requirements content in ZIP mode. Successful submissions expose the created submission ID and refresh challenge and member submission counts without -leaving the confirmation state. The declaration opens the public Topcoder -Terms of Use in a new tab. While either request is active, the detail tabs and -every form action that would unmount the submission are disabled. The explicit -cancel control remains available, aborts its request, clears the selected file -or URL, and then unlocks normal navigation. +leaving the confirmation state. ZIP mode's declaration opens the public Topcoder +Terms of Use; URL mode preserves the exact TopGear declaration and opens +community-app's TopGear terms and conditions URL in a new tab. While either +request is active, the detail tabs and every form action that would unmount the +submission are disabled. The explicit cancel control remains available, aborts +its request, clears the selected file or URL, and then unlocks normal navigation. Marathon Match attempts fall back to Review submission, virus-scan, and scoring lifecycle fields when test metadata is absent, preserving truthful Failed, In progress, and completed states. A superseded scorer's `CANCELLED` status is shown diff --git a/src/apps/opportunities/src/components/ChallengeSidebar.module.scss b/src/apps/opportunities/src/components/ChallengeSidebar.module.scss index 8cddb12f0..fc50cc4cc 100644 --- a/src/apps/opportunities/src/components/ChallengeSidebar.module.scss +++ b/src/apps/opportunities/src/components/ChallengeSidebar.module.scss @@ -224,7 +224,7 @@ } } -.reviewStyleStatus { +.reviewConfigStatus { color: #617178; margin: 0; } diff --git a/src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx b/src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx index 60599feef..f14646421 100644 --- a/src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx +++ b/src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx @@ -112,6 +112,7 @@ const marathonChallenge: ChallengeOpportunity = { function renderSidebar( aiReviewConfig?: ChallengeAiReviewConfig, sidebarChallenge: ChallengeOpportunity = challenge, + showReviewStyle: boolean = true, ): void { render( @@ -120,6 +121,7 @@ function renderSidebar( challenge={sidebarChallenge} onContactTeam={jest.fn()} onShowTerms={jest.fn()} + showReviewStyle={showReviewStyle} /> , ) @@ -136,9 +138,25 @@ describe('ChallengeSidebar Review Style', () => { }) }) - it('shows manual review with the Figma explanation when no AI config exists', () => { + it('does not show review style for non-development challenges', () => { renderSidebar() + expect(screen.queryByRole('heading', { name: 'Review Style' })) + .not.toBeInTheDocument() + }) + + it('does not show review style for anonymous viewers on development challenges', () => { + renderSidebar(undefined, developmentChallenge, false) + + expect(screen.queryByRole('heading', { name: 'Review Style' })) + .not.toBeInTheDocument() + }) + + it('shows manual review when no AI config exists for development challenges', () => { + renderSidebar(undefined, developmentChallenge) + + expect(screen.getByRole('heading', { name: 'Review Style' })) + .toBeInTheDocument() expect(screen.getByText('Manual')) .toBeInTheDocument() expect(screen.getByRole('button', { name: 'About Manual' })) @@ -147,11 +165,6 @@ describe('ChallengeSidebar Review Style', () => { .toBeInTheDocument() expect(screen.queryByText(/Instant Review is/)) .not.toBeInTheDocument() - const heading = screen.getByRole('heading', { name: 'Review Style' }) - expect(heading.querySelector('img')) - .not.toBeNull() - expect(heading.querySelector('svg')) - .toBeNull() }) it('shows AI-only review and the disabled Instant Review state', () => { @@ -160,7 +173,7 @@ describe('ChallengeSidebar Review Style', () => { id: 'ai-only-config', instantReview: false, mode: 'AI_ONLY', - }) + }, developmentChallenge) expect(screen.getByText('AI only')) .toBeInTheDocument() @@ -178,7 +191,7 @@ describe('ChallengeSidebar Review Style', () => { id: 'ai-gating-config', instantReview: true, mode: 'AI_GATING', - }) + }, developmentChallenge) expect(screen.getByText('AI Gating')) .toBeInTheDocument() @@ -188,7 +201,7 @@ describe('ChallengeSidebar Review Style', () => { 'AI performs a preliminary review, then the Community Review Board evaluates submissions that pass.', )) .toBeInTheDocument() - expect(screen.getByText('You will receive AI feedback during the submission phase')) + expect(screen.getByText('You will receive AI feedback during the submission phase.')) .toBeInTheDocument() }) @@ -240,7 +253,7 @@ describe('ChallengeSidebar Review Style', () => { .not.toBeNull() }) - it('uses the dedicated Review App host and hides Review Style for Marathon Matches', () => { + it('uses the dedicated Review App host and hides review style for Marathon Matches', () => { renderSidebar(undefined, marathonChallenge) expect(screen.getByRole('link', { name: 'View Review App' })) diff --git a/src/apps/opportunities/src/components/ChallengeSidebar.tsx b/src/apps/opportunities/src/components/ChallengeSidebar.tsx index 5bab5cbf6..94e4794cc 100644 --- a/src/apps/opportunities/src/components/ChallengeSidebar.tsx +++ b/src/apps/opportunities/src/components/ChallengeSidebar.tsx @@ -9,6 +9,12 @@ import { ChallengeOpportunity, ChallengeTerm, } from '../models' +import { getChallengeTermsDetails } from '../services' +import { + getInstantReviewStyleItem, + getReviewStyleModeItem, + hasAiReviewConfig, +} from '../utils/ai-review-config.utils' import { challengeAllowsStockArt, challengeFileTypes, @@ -33,7 +39,6 @@ import { QA_COMPETITION_TYPES_URL, USABLE_CODE_RULES_URL, } from '../utils/opportunity-learning.utils' -import { getChallengeTermsDetails } from '../services' import programBanner from '../assets/ai-exponential-program.png' import sidebarArrowIcon from '../assets/sidebar-arrow.svg' import sidebarBookIcon from '../assets/sidebar-book.svg' @@ -59,6 +64,7 @@ interface ChallengeSidebarProps { onShowTerms: (term?: ChallengeTerm) => void reviewStyleLoading?: boolean reviewStyleUnavailable?: boolean + showReviewStyle?: boolean } interface SidebarCardProps { @@ -78,29 +84,8 @@ interface ReviewStyleSectionProps { unavailable?: boolean } -interface ReviewStylePresentation { - label: string - tooltip: string -} - -const MANUAL_REVIEW_STYLE: ReviewStylePresentation = { - label: 'Manual', - tooltip: 'Community Review Board performs a thorough review based on scorecards.', -} - -const AI_REVIEW_STYLES: Record = { - AI_GATING: { - label: 'AI Gating', - tooltip: 'AI performs a preliminary review, then the Community Review Board evaluates submissions that pass.', - }, - AI_ONLY: { - label: 'AI only', - tooltip: 'AI will perform a thorough review based on scorecards.', - }, -} - /** - * Renders one Figma review-style bullet and its accessible explanation. + * Renders one review-style bullet with an accessible tooltip. * * @param props member-facing label and tooltip copy. * @returns one review-style list item. @@ -129,16 +114,14 @@ const ReviewStyleItem: FC = props => ( ) /** - * Renders the API-backed Review Style rows shared by every challenge type. + * Renders development-challenge review style above challenge terms. * * @param props AI review configuration and request state. - * @returns Review Style heading with manual or AI-specific detail rows. + * @returns Review mode and instant review details. * @throws Does not throw. */ const ReviewStyleSection: FC = props => { - const presentation = props.config - ? AI_REVIEW_STYLES[props.config.mode] ?? MANUAL_REVIEW_STYLE - : MANUAL_REVIEW_STYLE + const reviewModeItem = getReviewStyleModeItem(props.config) return (
@@ -147,18 +130,15 @@ const ReviewStyleSection: FC = props => { Review Style {props.loading - ?

Loading review style…

+ ?

Loading review configuration…

: props.unavailable - ?

Review style is unavailable.

+ ?

Review configuration is unavailable.

: (
    - - {props.config && ( + + {hasAiReviewConfig(props.config) && ( )}
@@ -509,7 +489,7 @@ export const ChallengeSidebar: FC = props => {
)} - {!marathonMatch && ( + {developmentChallenge && !marathonMatch && props.showReviewStyle && ( ({ - EnvironmentConfig: { URLS: { TERMS_OF_USE: 'https://www.example.com/terms' } }, + EnvironmentConfig: { + TOPGEAR_ALLOWED_SUBMISSIONS_DOMAINS: [ + 'wipro365.sharepoint.com', + 'wipro365-my.sharepoint.com', + 'wipro365-my.sharepoint.com.mcas.ms', + ], + URLS: { + TERMS_OF_USE: 'https://www.example.com/terms', + TOPGEAR_TERMS: 'https://topgear.topcoder.com/challenges/terms/detail/f1d8cca9-ac24-473c-998d-02f499a829cb', + }, + }, }), { virtual: true }) jest.mock('~/libs/cms', () => ({ getSafeCmsLink: jest.fn(), @@ -144,26 +154,92 @@ describe('ChallengeSubmissionUpload', () => { .toBe('The ZIP file must be 500MB or smaller.') }) - it('accepts only trimmed absolute HTTP or HTTPS submission URLs', () => { + it('requires an absolute HTTP or HTTPS submission URL', () => { expect(validateChallengeSubmissionUrl('')) .toBe('Enter the URL to your submission.') expect(validateChallengeSubmissionUrl('deliverables.example.com/result')) .toBe('Enter a valid submission URL.') expect(validateChallengeSubmissionUrl('ftp://deliverables.example.com/result')) .toBe('Enter a URL beginning with http:// or https://.') - expect(validateChallengeSubmissionUrl(' https://deliverables.example.com/result ')) + expect(validateChallengeSubmissionUrl(' https://wipro365.sharepoint.com/result ')) .toBeUndefined() }) - it('renders metadata-selected URL guidance without the ZIP picker', () => { + it.each([ + 'https://wipro365.sharepoint.com/:f:/r/sites/project/deliverables?download=1', + 'https://wipro365-my.sharepoint.com/personal/member/result.docx', + 'https://wipro365-my.sharepoint.com.mcas.ms/personal/member/result.docx', + 'http://wipro365.sharepoint.com/sites/project/result', + ' https://WIPRO365.SHAREPOINT.COM/sites/project/Work%20Summary.docx ', + ])('accepts legacy Wipro SharePoint deliverable links: %s', url => { + expect(validateChallengeSubmissionUrl(url)) + .toBeUndefined() + }) + + it.each([ + 'https://files.example.com/result', + 'https://wipro365.sharepoint.com', + 'https://wipro365.sharepoint.com/?result=1', + 'https://wipro365.sharepoint.com.evil.example/result', + 'https://wipro365Xsharepoint.com/result', + 'https://wipro365.sharepoint.com@evil.example/result', + 'https://user:password@wipro365.sharepoint.com/result', + 'https://wipro365.sharepoint.com:8443/result', + ])('rejects links outside the allowed SharePoint deliverables: %s', url => { + expect(validateChallengeSubmissionUrl(url)) + .toBe('Ensure that you submit a valid Wipro SharePoint link only. ' + + 'The link should point to the outcome/deliverable of the challenge and should reflect the work done. ' + + 'Please check the challenge submission guidelines.') + }) + + it.each([ + 'Development', 'Design', 'Data Science', 'Quality Assurance', + ])('renders the exact Topgear guidance for a metadata-selected URL %s challenge', track => { renderUpload(challengeFixture({ metadata: [{ name: ' SUBMISSION_TYPE ', value: ' URL ' }], + track: { name: track }, })) - expect(screen.getByText('Submit the URL to your solution as described in the requirements.')) + expect(screen.getByText('Enter the URL to your submission.')) .toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'Required Link' })) + expect(screen.getByRole('heading', { name: 'Steps for Submission:' })) .toBeInTheDocument() + expect(screen.getAllByRole('listitem') + .map(item => item.textContent)) + .toEqual([ + 'Upload the outcome/asset/deliverable of the challenge to the repository (Wipro SharePoint folder) ' + + 'as specified by the project team/challenge creator.', + 'Copy the link of the outcome/asset/deliverable that was uploaded. ' + + 'Enter this link in the text box and click on “SET URL”.', + 'Please check the acceptance/confirmation box at the bottom left corner.', + 'Click on the ‘Submit’ option at the bottom right.', + ]) + expect(screen.getByText('Do not submit any irrelevant links').parentElement) + .toHaveTextContent('Ensure that the submission link always reflects the outcome ' + + 'that was delivered as part of the challenge. Do not submit any irrelevant links ' + + 'as the submission link is proof of the work done.') + expect(screen.getByText(/Note: All deliverables\/outcomes/)) + .toHaveTextContent('Note: All deliverables/outcomes should be uploaded to the Wipro SharePoint ' + + 'directory ONLY. For work done directly on customer environment and involving a customer ' + + 'SharePoint/drive/folder link, create a word document and include a brief summary of the work ' + + 'done and list the deliverables/assets created along with the link to the customer ' + + 'SharePoint/drive/folder link and upload the word document ' + + 'to a Wipro SharePoint folder. And submit the link to this Word document as the submission link.') + expect(screen.getByText(/If you are having trouble with the submission/)) + .toHaveTextContent('If you are having trouble with the submission or have any queries, ' + + 'please raise a Service Now (SNOW) ticket under the TopGear category.') + const termsLink = screen.getByRole('link', { name: 'TopGear terms and conditions' }) + expect(termsLink) + .toHaveAttribute( + 'href', + 'https://topgear.topcoder.com/challenges/terms/detail/f1d8cca9-ac24-473c-998d-02f499a829cb', + ) + expect(termsLink) + .toHaveAttribute('target', '_blank') + expect(termsLink.parentElement) + .toHaveTextContent('Submitting your link means you hereby agree to the TopGear terms and conditions ' + + 'and to the extent your submission wins a TopGear challenge, you hereby agree to assign, grant, ' + + 'and transfer to TopGear all right and title to the Winning Submission.') expect(screen.getByLabelText(/Submission URL/)) .toHaveAttribute('type', 'url') expect(screen.getByRole('button', { name: 'Set URL' })) @@ -172,8 +248,21 @@ describe('ChallengeSubmissionUpload', () => { .not.toBeInTheDocument() expect(screen.queryByText('Drop your file(s) here or')) .not.toBeInTheDocument() - expect(screen.getByText('Link directly to your challenge deliverable')) + expect(screen.queryByRole('heading', { name: 'Submission tips' })) + .not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Topcoder Support' })) + .not.toBeInTheDocument() + }) + + it('keeps the file picker and Topcoder declaration for an explicit ZIP challenge', () => { + renderUpload(challengeFixture({ metadata: [{ name: 'submission_type', value: 'zip' }] })) + + expect(screen.getByLabelText(/Upload File/)) .toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Topcoder Terms of Use' })) + .toHaveAttribute('href', 'https://www.example.com/terms') + expect(screen.queryByLabelText(/Submission URL/)) + .not.toBeInTheDocument() }) it('validates and reconfirms an edited URL before enabling its declaration', () => { @@ -192,19 +281,28 @@ describe('ChallengeSubmissionUpload', () => { expect(agreement) .toBeDisabled() - fireEvent.change(urlInput, { target: { value: ' https://files.example.com/result ' } }) + fireEvent.change(urlInput, { target: { value: 'https://files.example.com/result' } }) + fireEvent.click(screen.getByRole('button', { name: 'Set URL' })) + expect(screen.getByRole('alert')) + .toHaveTextContent('Ensure that you submit a valid Wipro SharePoint link only.') + expect(agreement) + .toBeDisabled() + expect(screen.getByRole('button', { name: 'Submit' })) + .toBeDisabled() + + fireEvent.change(urlInput, { target: { value: ' https://wipro365.sharepoint.com/result ' } }) fireEvent.click(screen.getByRole('button', { name: 'Set URL' })) expect(screen.getByText('Ready to submit')) .toBeInTheDocument() - expect(screen.getByText('https://files.example.com/result')) + expect(screen.getByText('https://wipro365.sharepoint.com/result')) .toBeInTheDocument() expect(urlInput) - .toHaveValue('https://files.example.com/result') + .toHaveValue('https://wipro365.sharepoint.com/result') expect(agreement) .not.toBeDisabled() fireEvent.click(agreement) - fireEvent.change(urlInput, { target: { value: 'https://files.example.com/revised' } }) + fireEvent.change(urlInput, { target: { value: 'https://wipro365.sharepoint.com/revised' } }) expect(screen.queryByText('Ready to submit')) .not.toBeInTheDocument() expect(agreement) @@ -222,7 +320,7 @@ describe('ChallengeSubmissionUpload', () => { })) fireEvent.change(screen.getByLabelText(/Submission URL/), { - target: { value: ' https://files.example.com/result ' }, + target: { value: ' https://wipro365.sharepoint.com/result ' }, }) fireEvent.click(screen.getByRole('button', { name: 'Set URL' })) fireEvent.click(screen.getByRole('checkbox', { name: 'I understand and agree' })) @@ -233,7 +331,7 @@ describe('ChallengeSubmissionUpload', () => { 'challenge-id', '123', 'CONTEST_SUBMISSION', - 'https://files.example.com/result', + 'https://wipro365.sharepoint.com/result', expect.any(AbortSignal), )) expect(mockedCreateSubmission) @@ -263,7 +361,7 @@ describe('ChallengeSubmissionUpload', () => { const urlInput = screen.getByLabelText(/Submission URL/) fireEvent.change(urlInput, { - target: { value: 'https://files.example.com/result' }, + target: { value: 'https://wipro365.sharepoint.com/result' }, }) fireEvent.click(screen.getByRole('button', { name: 'Set URL' })) fireEvent.click(screen.getByRole('checkbox', { name: 'I understand and agree' })) @@ -286,7 +384,7 @@ describe('ChallengeSubmissionUpload', () => { ) fireEvent.change(screen.getByLabelText(/Submission URL/), { - target: { value: 'https://files.example.com/result' }, + target: { value: 'https://wipro365.sharepoint.com/result' }, }) fireEvent.click(screen.getByRole('button', { name: 'Set URL' })) fireEvent.click(screen.getByRole('checkbox', { name: 'I understand and agree' })) @@ -312,7 +410,7 @@ describe('ChallengeSubmissionUpload', () => { ) fireEvent.change(screen.getByLabelText(/Submission URL/), { - target: { value: 'https://files.example.com/result' }, + target: { value: 'https://wipro365.sharepoint.com/result' }, }) fireEvent.click(screen.getByRole('button', { name: 'Set URL' })) fireEvent.click(screen.getByRole('checkbox', { name: 'I understand and agree' })) diff --git a/src/apps/opportunities/src/components/ChallengeSubmissionUpload.tsx b/src/apps/opportunities/src/components/ChallengeSubmissionUpload.tsx index 7fbbd7aac..94f96e08d 100644 --- a/src/apps/opportunities/src/components/ChallengeSubmissionUpload.tsx +++ b/src/apps/opportunities/src/components/ChallengeSubmissionUpload.tsx @@ -29,6 +29,9 @@ import { challengeCatalogKey } from './challenge-card.utils' import styles from './ChallengeSubmissionUpload.module.scss' const MAX_SUBMISSION_BYTES = 500 * 1024 * 1024 +const WIPRO_SUBMISSION_LINK_GUIDANCE = 'Ensure that you submit a valid Wipro SharePoint link only. ' + + 'The link should point to the outcome/deliverable of the challenge and should reflect the work done. ' + + 'Please check the challenge submission guidelines.' interface ChallengeSubmissionUploadProps { challenge: ChallengeOpportunity @@ -80,10 +83,12 @@ export function validateChallengeSubmissionFile(file: File): string | undefined } /** - * Validates a member-authored challenge deliverable URL. + * Validates a member-authored deliverable URL against community-app's Topgear rules. + * The Opportunities URL form requires HTTP(S), an allowed Wipro SharePoint host, + * and a deliverable path, without embedded credentials or a nonstandard port. * * @param value URL entered in the submission form. - * @returns member-facing validation failure, or undefined for an absolute HTTP(S) URL. + * @returns member-facing validation failure, or undefined for an allowed deliverable URL. * @throws Does not throw; malformed URLs are returned as validation failures. */ export function validateChallengeSubmissionUrl(value: string): string | undefined { @@ -92,11 +97,16 @@ export function validateChallengeSubmissionUrl(value: string): string | undefine try { const url = new URL(normalizedValue) - if (url.protocol !== 'http:' && url.protocol !== 'https:') { + if (!/^https?:\/\//i.test(normalizedValue)) { return 'Enter a URL beginning with http:// or https://.' } if (!url.hostname) return 'Enter a valid submission URL.' + if (!EnvironmentConfig.TOPGEAR_ALLOWED_SUBMISSIONS_DOMAINS.includes(url.hostname) + || url.username || url.password || url.port || url.pathname === '/') { + return WIPRO_SUBMISSION_LINK_GUIDANCE + } + return undefined } catch { return 'Enter a valid submission URL.' @@ -115,11 +125,11 @@ function formatFileSize(bytes: number): string { } /** - * Renders the Figma upload, progress, declaration, and confirmation states inside - * the active My Submissions challenge tab. + * Renders ZIP uploads or the Topgear URL submission flow inside My Submissions, + * using Opportunities styling and the corresponding instructions and declaration. * * @param props challenge identity, member identity, navigation, legal, support, and cache callbacks. - * @returns accessible DMZ-to-Review-API submission workflow. + * @returns accessible ZIP-to-DMZ or direct-URL Review API submission workflow. * @throws Does not throw; upload and clipboard failures are rendered or toasted in place. */ export const ChallengeSubmissionUpload: FC = props => { @@ -270,8 +280,8 @@ export const ChallengeSubmissionUpload: FC = pro } /** - * Revalidates challenge registration, uploads the accepted archive, and advances - * to the immutable confirmation state. + * Revalidates challenge registration, uploads the accepted archive or submits + * the confirmed URL, and advances to the immutable confirmation state. * * @returns promise settled after the Review API response and cache refresh callback. * @throws Does not throw; request failures restore the ready state with an error message. @@ -355,7 +365,7 @@ export const ChallengeSubmissionUpload: FC = pro } return ( -
+

{urlMode - ? 'Submit the URL to your solution as described in the requirements.' + ? 'Enter the URL to your submission.' : 'Upload your solution files as described in the requirements.'}

@@ -378,14 +388,46 @@ export const ChallengeSubmissionUpload: FC = pro
= pro disabled={uploading} id='challenge-submission-url' onChange={changeUrl} - placeholder='https://example.com/your-solution' + placeholder='URL' + required type='url' value={urlInput} /> @@ -551,7 +580,7 @@ export const ChallengeSubmissionUpload: FC = pro
- Enter an absolute URL beginning with http:// or https://. + {WIPRO_SUBMISSION_LINK_GUIDANCE} {error && (
@@ -1585,7 +1605,6 @@ const SubmissionsTab: FC = props => { <> Current Test Process Test Status - Test Progress Final Score Provisional Score @@ -1633,23 +1652,41 @@ const SubmissionsTab: FC = props => { {progress.process ?? '—'} - - {progress.status ?? '—'} - - - -
- - - - - {progress.progress === undefined - ? '—' - : `${Math.round(progress.progress)}%`} - -
+ {(!progress.status || progress.status === 'In progress') + && progress.progress !== undefined ? ( +
+
+ ) : ( + + {progress.status === 'Passed' && ( + + )} = [ ...homeRoutes, ...adminRoutes, ...reportsRoutes, + ...salesRoutes, ...customerPortalRoutes, ...notFoundRoutes, ] diff --git a/src/apps/review/src/lib/assets/icons/icon-shuffle.svg b/src/apps/review/src/lib/assets/icons/icon-shuffle.svg new file mode 100644 index 000000000..358435155 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-shuffle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.module.scss b/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.module.scss index becea5cc3..beef925ac 100644 --- a/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.module.scss +++ b/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.module.scss @@ -1,8 +1,13 @@ @import '@libs/ui/styles/includes'; .container { - padding-top: $sp-6; display: flex; gap: $sp-4; flex-wrap: wrap; + align-items: center; +} + +.headerActions { + flex-wrap: nowrap; + padding-top: 0; } diff --git a/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.tsx b/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.tsx index 118c76c3e..5de2af259 100644 --- a/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.tsx +++ b/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.tsx @@ -19,7 +19,10 @@ import { useRole, useRoleProps } from '../../hooks' import styles from './ChallengeLinks.module.scss' +type ChallengeLinkAction = 'contactManager' | 'forum' | 'payments' + interface Props { + actions?: ChallengeLinkAction[] className?: string } @@ -47,9 +50,14 @@ export const ChallengeLinks: FC = (props: Props) => { [actionChallengeRole, myResources], ) + const enabledActions = useMemo( + () => new Set(props.actions ?? ['contactManager', 'forum', 'payments']), + [props.actions], + ) + return (
- {canShowContactManagerButton && ( + {enabledActions.has('contactManager') && canShowContactManagerButton && ( )} - {challengeInfo && challengeInfo.discussionsUrl && ( + {enabledActions.has('forum') && challengeInfo && challengeInfo.discussionsUrl && ( = (props: Props) => { Forum )} - {challengeInfo && canShowPaymentsButton && ( + {enabledActions.has('payments') && challengeInfo && canShowPaymentsButton && (
{(!phaseOrderingOptions.isTask || phaseOrderingOptions.isTopgearTask) ? ( diff --git a/src/apps/sales/README.md b/src/apps/sales/README.md new file mode 100644 index 000000000..5f9bd5c6b --- /dev/null +++ b/src/apps/sales/README.md @@ -0,0 +1,76 @@ +# Sales (PM-6343, PM-6363, PM-6364) + +Read-only Salesforce reporting for Administrators and Talent Managers. Available +at `sales.topcoder.com` / `sales.topcoder-dev.com`, `/sales` on the combined host, +and the **Sales** tab inside Work (`/sales` on the Work host, `/work/sales` on the +combined host). Route guards and the Reports API independently enforce access. + +The page calls `GET {REPORTS_API}/sales` with the signed-in user's token, and +`GET {API.V6}/projects/salesforce/opportunities/{id}` for the opportunity popup. +All Salesforce credentials stay in `reports-api-v6` and `projects-api-v6`. No +create, update, delete, export, machine credentials or direct Salesforce API +calls exist in the UI. + +Report cells whose value is a Salesforce opportunity id (the `006` key prefix) +render the opportunity name as a button. Opening it shows a popup with the +opportunity description first, followed by the customer, SMU, close date and +stage when Salesforce provides them, plus a link to the record. The popup closes +with its Close button or the X icon; obsolete lookups are aborted. + +## Date range filter (PM-6364) + +A date range section at the top of the page filters the report by **Created +Date** for pipeline generation, or by **Close Date** for revenue projection. +The Filter type dropdown lists the report's own `date`/`datetime` columns rather +than hard-coded Salesforce field IDs, and opens on the Created Date column when +the report has one. From date and To date are inclusive and either may be left +empty for an open-ended range. + +Unlike the search and column filters, the range is only sent when **Apply +filter** is pressed, and **Reset filter** clears it without disturbing search, +column filters or sorting. Clearing the report filters likewise leaves the range +intact. An inverted range is reported inline and never sent. A report with no +date columns disables the section. + +The Reports API applies the range across the whole received snapshot before +paginating, so a filtered count is the real matching count and not a per-page +figure. + +## Filtered totals (PM-6364) + +Summary tiles above the report show the metrics the current filters produce over +every matching record: opportunity count, a total per numeric column (pipeline +value and revenue projections), and a breakdown per category column such as +Stage. The API computes them, so they never describe only the visible page. +Totals show their shared currency; a total that sums different currencies is +rendered as a plain number and labelled as mixed. Tiles are hidden when the API +returns no `summary`, which keeps the page working against an API that predates +this feature. + +Report metadata determines every displayed column, including grouped Stage. +Search and column substring filters apply automatically as the user types (debounced); headers sort globally +before server pagination. Changing a filter, sort or page size starts at page +one. Clear resets filters and sorting. Null display values use an em dash. +Search sales, Filter field and Contains use persistent labels above equal-height +controls, aligned in the desktop filter row and stacked on mobile. +The semantic HTML table provides keyboard sorting, `aria-sort`, and a focusable +horizontal scroll region for wide reports; the shared Table component performs +client sorting and is intentionally not used for this server-paginated report. + +Refresh preserves filters and sorting. Visible tabs poll every 60 seconds and +refresh on returning from a hidden tab; obsolete requests are aborted and +ignored. Failed refreshes keep the prior snapshot visibly marked as stale; +401/403 responses remove it. Loading, retry, empty and upstream truncation states +are explicit. `allData: false` warns that filters and totals cover only received +rows, because Salesforce Analytics caps report details at 2,000 records. + +The app opts into scoped shared 2026 foundations, using Figtree headings, Nunito +Sans body text, semantic color tokens, shared buttons and loading controls, and +explicitly labelled native filter controls. Source design guidance: +[Topcoder Design System — August 2026](https://www.figma.com/design/C2cA6508RhpjWJDp7MLKbO/Topcoder-Design-System---Aug-2026?node-id=1-54). + +Aggregates and the date range both cover received rows only, so `allData: false` +limits them exactly as it limits the record count. + +Run `nvm use` in platform-ui before `yarn lint`, `LOGICAL_ENV=dev yarn run build`, +and `CI=true yarn test:no-watch --runInBand --watch=false sales`. diff --git a/src/apps/sales/index.ts b/src/apps/sales/index.ts new file mode 100644 index 000000000..eaa1e970e --- /dev/null +++ b/src/apps/sales/index.ts @@ -0,0 +1 @@ +export { salesRoutes } from './src/sales.routes' diff --git a/src/apps/sales/src/OpportunityModal.module.scss b/src/apps/sales/src/OpportunityModal.module.scss new file mode 100644 index 000000000..37b3c02dc --- /dev/null +++ b/src/apps/sales/src/OpportunityModal.module.scss @@ -0,0 +1,60 @@ +@import '@libs/ui/styles/2026/tokens'; + +.body { + display: block; +} + +.state { + padding: 40px 0; + min-height: 140px; + position: relative; +} + +.error { + border: 1px solid $tc-2026-danger; + border-radius: $tc-2026-radius-md; + color: $tc-2026-danger; + padding: 16px; +} + +.sectionTitle { + font-size: 12px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; + color: $tc-2026-muted; +} + +.description { + margin-top: 8px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.details { + margin-top: 24px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; +} + +.detail dt { + font-size: 12px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; + color: $tc-2026-muted; +} + +.detail dd { + margin-top: 4px; + overflow-wrap: anywhere; +} + +.link { + display: inline-block; + margin-top: 24px; + font-weight: 700; + color: $tc-2026-teal; + text-decoration: underline; +} diff --git a/src/apps/sales/src/OpportunityModal.tsx b/src/apps/sales/src/OpportunityModal.tsx new file mode 100644 index 000000000..cfdf38973 --- /dev/null +++ b/src/apps/sales/src/OpportunityModal.tsx @@ -0,0 +1,117 @@ +/* The modal body renders only the opportunity fields the report already exposes. */ +import { FC, useEffect, useState } from 'react' + +import { BaseModal, Button, LoadingSpinner } from '~/libs/ui' + +import { + fetchOpportunity, + opportunityErrorMessage, + SalesOpportunity, +} from './opportunity.service' +import styles from './OpportunityModal.module.scss' + +export interface OpportunityModalProps { + /** Salesforce opportunity id taken from the report cell. */ + opportunityId: string + /** Opportunity name shown while the details are still loading. */ + opportunityName: string + onClose: () => void + open: boolean +} + +type ModalState = + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'ready'; opportunity: SalesOpportunity } + +/** + * Shows the details of one Salesforce opportunity, starting with its description. + * @param props The opportunity to load, whether the popup is open, and the close handler. + * @returns A dialog closed with either the Close button or the X icon. + * @throws Does not throw request failures; shows the sanitized message inline. + */ +export const OpportunityModal: FC = props => { + const [state, setState] = useState({ status: 'loading' }) + + useEffect(() => { + if (!props.open) { + return undefined + } + + const controller = new AbortController() + setState({ status: 'loading' }) + fetchOpportunity(props.opportunityId, controller.signal) + .then(opportunity => { + if (!controller.signal.aborted) { + setState({ opportunity, status: 'ready' }) + } + }) + .catch(error => { + if (!controller.signal.aborted) { + setState({ message: opportunityErrorMessage(error), status: 'error' }) + } + }) + + return () => controller.abort() + }, [props.open, props.opportunityId]) + + const opportunity = state.status === 'ready' ? state.opportunity : undefined + const details: Array<{ label: string; value: string | undefined }> = [ + { label: 'Customer', value: opportunity?.customer }, + { label: 'SMU', value: opportunity?.reportingSmu }, + { label: 'Close Date', value: opportunity?.closeDate }, + { label: 'Stage', value: opportunity?.stageName }, + ].filter(item => !!item.value) + + return ( + Close + )} + onClose={props.onClose} + open={props.open} + size='md' + title={opportunity?.name || props.opportunityName} + > + {state.status === 'loading' && ( +
+ )} + + {state.status === 'error' && ( +
{state.message}
+ )} + + {opportunity && ( + <> +

Description

+

+ {opportunity.description || 'This opportunity does not have a description yet.'} +

+ + {details.length > 0 && ( +
+ {details.map(item => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ )} + + + View in Salesforce + + + )} +
+ ) +} + +export default OpportunityModal diff --git a/src/apps/sales/src/SalesPage.module.scss b/src/apps/sales/src/SalesPage.module.scss new file mode 100644 index 000000000..251a3cd03 --- /dev/null +++ b/src/apps/sales/src/SalesPage.module.scss @@ -0,0 +1,101 @@ +@import '@libs/ui/styles/2026/tokens'; + +.page { + width: 100%; + max-width: 1800px; + margin: 0 auto; + padding: 40px 48px 64px; + min-height: 70vh; +} + +.header, .headerActions, .panelHeader, .status, .pagination, .pageControls, .filterActions { + display: flex; + align-items: center; + gap: 16px; +} + +.header, .panelHeader, .status, .pagination { justify-content: space-between; } + +.eyebrow { + font-size: 12px; + font-weight: 700; + letter-spacing: 1.5px; + color: $tc-2026-muted; + margin-bottom: 8px; +} + +.subtitle { margin-top: 8px; font-size: 16px; color: $tc-2026-muted; } +.readOnly, .count { border-radius: 20px; background: $tc-2026-canvas; padding: 6px 12px; font-weight: 700; } +.readOnly { border: 1px solid $tc-2026-border; color: $tc-2026-muted; } +.refresh { background: $tc-2026-teal !important; border-color: $tc-2026-teal !important; } +.status { margin: 24px 0; color: $tc-2026-muted; font-size: 13px; flex-wrap: wrap; gap: 8px; } +.panel { background: $tc-2026-surface; border: 1px solid $tc-2026-border; border-radius: $tc-2026-radius-lg; overflow: hidden; box-shadow: $tc-2026-shadow-card; } +.panelHeader { padding: 24px; border-bottom: 1px solid $tc-2026-border; } +.panelHeader h2 { font-size: $tc-2026-h4-size; line-height: 32px; } +.panelHeader p { margin-top: 4px; color: $tc-2026-muted; } +.filters { padding: 24px; display: flex; align-items: flex-end; flex-wrap: wrap; gap: 16px; background: $tc-2026-canvas; } +.filterField { display: flex; flex-direction: column; gap: 8px; flex: 1 1 220px; min-width: 160px; } +.filterField label { font-size: 16px; line-height: 24px; font-weight: 700; } +.filterField input, .filterField select, .pageControls select { background: $tc-2026-surface; border: 1px solid $tc-2026-border-strong; padding: 10px 12px; min-height: 40px; color: $tc-2026-body; font-weight: 400; max-width: 100%; } +.filterField input, .filterField select { width: 100%; height: 48px; box-sizing: border-box; font-size: 16px; line-height: 24px; } +.filterField input::placeholder { color: $tc-2026-muted; opacity: 1; } +.filterField input:disabled { background: $tc-2026-border; cursor: not-allowed; } +.filterActions { min-height: 48px; } +.dateFilters { margin-bottom: 24px; } +.dateFieldset { background: $tc-2026-surface; border: 1px solid $tc-2026-border; border-radius: $tc-2026-radius-lg; box-shadow: $tc-2026-shadow-card; padding: 24px; margin: 0; } +.dateLegend { font-size: $tc-2026-h4-size; line-height: 32px; font-weight: 700; padding: 0; } +.dateHint { margin-top: 4px; color: $tc-2026-muted; max-width: 820px; } +.dateControls { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 16px; margin-top: 20px; } +.dateStatus { margin-top: 16px; font-size: 13px; color: $tc-2026-muted; } +.dateError { color: $tc-2026-danger; font-weight: 700; } +.summary { margin-bottom: 24px; display: flex; flex-direction: column; gap: 16px; } +.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; } +.metric { background: $tc-2026-surface; border: 1px solid $tc-2026-border; border-radius: $tc-2026-radius-lg; box-shadow: $tc-2026-shadow-card; padding: 20px 24px; } +.metricLabel { font-size: 12px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; color: $tc-2026-muted; } +.metricValue { font-size: 32px; line-height: 40px; font-weight: 700; margin-top: 8px; overflow-wrap: anywhere; } +.metricNote { margin-top: 8px; font-size: 13px; color: $tc-2026-muted; } +.breakdown { background: $tc-2026-surface; border: 1px solid $tc-2026-border; border-radius: $tc-2026-radius-lg; box-shadow: $tc-2026-shadow-card; padding: 20px 24px; } +.breakdown h3 { font-size: 20px; line-height: 28px; } +.breakdown ul { list-style: none; margin-top: 12px; } +.breakdown li { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px; padding: 10px 0; border-bottom: 1px solid $tc-2026-border; } +.breakdown li:last-child { border-bottom: 0; } +.bucketLabel { font-weight: 700; flex: 1 1 200px; overflow-wrap: anywhere; } +.bucketCount { color: $tc-2026-muted; font-size: 13px; } +.bucketTotal { font-weight: 700; min-width: 120px; text-align: right; } +.tableScroll { overflow-x: auto; width: 100%; } +.table { width: 100%; border-collapse: collapse; text-align: left; } +.table th { background: $tc-2026-canvas; white-space: nowrap; border-bottom: 1px solid $tc-2026-border; } +.table th button { padding: 16px 20px; width: 100%; text-align: left; color: $tc-2026-body; font-weight: 700; border: 0; background: transparent; cursor: pointer; } +.table th button:hover { color: $tc-2026-teal; } +.table td { border-bottom: 1px solid $tc-2026-border; padding: 16px 20px; min-width: 140px; max-width: 420px; overflow-wrap: anywhere; vertical-align: top; } +.table tr:last-child td { border-bottom: 0; } +.opportunityButton { padding: 0; border: 0; background: transparent; text-align: left; color: $tc-2026-teal; font-weight: 700; text-decoration: underline; cursor: pointer; overflow-wrap: anywhere; } +.opportunityButton:hover, .opportunityButton:focus-visible { color: $tc-2026-body; } +.table tbody tr:hover { background: $tc-2026-canvas; } +.pagination { padding: 20px 24px; border-top: 1px solid $tc-2026-border; flex-wrap: wrap; font-size: 13px; } +.pageControls { flex-wrap: wrap; gap: 12px; } +.error, .warning { padding: 20px 24px; margin-bottom: 24px; border-radius: $tc-2026-radius-md; background: $tc-2026-surface; } +.error { display: flex; align-items: center; justify-content: space-between; gap: 16px; border: 1px solid $tc-2026-danger; color: $tc-2026-danger; } +.warning { border: 1px solid $tc-2026-warning; color: $tc-2026-body; } +.empty { padding: 48px 24px; min-height: 180px; text-align: center; position: relative; } +.empty h3 { font-size: 24px; } +.empty p { color: $tc-2026-muted; margin-top: 8px; } +.visuallyHidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } + +@media (max-width: 900px) { + .page { padding: 24px; } + .header { align-items: flex-start; flex-wrap: wrap; } + .pageControls { gap: 8px; } +} + +@media (max-width: 600px) { + .page { padding: 20px 12px 40px; } + .header h1 { font-size: 36px; line-height: 44px; } + .headerActions { width: 100%; justify-content: space-between; } + .panelHeader, .filters, .pagination, .dateFieldset, .metric, .breakdown { padding: 16px; } + .panelHeader { align-items: flex-start; flex-direction: column; } + .filterField { flex-basis: 100%; } + .filterActions { width: 100%; } + .bucketTotal { text-align: left; min-width: 0; } + .error { align-items: flex-start; flex-direction: column; } +} diff --git a/src/apps/sales/src/SalesPage.spec.tsx b/src/apps/sales/src/SalesPage.spec.tsx new file mode 100644 index 000000000..d7b652ae8 --- /dev/null +++ b/src/apps/sales/src/SalesPage.spec.tsx @@ -0,0 +1,379 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { ButtonHTMLAttributes, ReactNode } from 'react' + +import SalesPage from './SalesPage' +import { fetchOpportunity } from './opportunity.service' +import { SalesReport } from './sales.models' +import { fetchSalesReport } from './sales.service' + +jest.mock('./sales.service', () => ({ + fetchSalesReport: jest.fn(), + salesErrorMessage: () => 'Unable to refresh. Please try again.', +})) + +jest.mock('./opportunity.service', () => ({ + fetchOpportunity: jest.fn(), + opportunityErrorMessage: () => 'We could not load the opportunity details. Please try again.', +})) + +jest.mock('~/libs/ui', () => ({ + BaseModal: (props: { buttons?: ReactNode; children: ReactNode; open: boolean; title: ReactNode }) => ( + props.open ? ( +
+

{props.title}

+ {props.children} + {props.buttons} +
+ ) : undefined + ), + Button: (props: ButtonHTMLAttributes) => ( + + ), + IconOutline: { RefreshIcon: () => }, + LoadingSpinner: (props: { message: string }) => {props.message}, + PageTitle: (props: { children: ReactNode }) => {props.children}, +}), { virtual: true }) + +const fetchReport = fetchSalesReport as jest.MockedFunction +const fetchOpportunityDetails = fetchOpportunity as jest.MockedFunction + +/** Creates non-customer Sales test data. @returns A synthetic report page. Does not throw. */ +function fixture(): SalesReport { + return { + allData: true, + columns: [ + { dataType: 'picklist', id: 'STAGE_NAME', label: 'Stage' }, + { dataType: 'string', id: 'NAME', label: 'Opportunity' }, + { dataType: 'currency', id: 'AMOUNT', label: 'Amount' }, + { dataType: 'datetime', id: 'CREATED_DATE', label: 'Created Date' }, + { dataType: 'date', id: 'CLOSE_DATE', label: 'Close Date' }, + ], + page: 1, + perPage: 25, + refreshAfterSeconds: 60, + refreshedAt: '2026-09-16T02:00:00Z', + reportId: 'test-report', + reportName: 'Bookings By Stage', + rows: [{ + cells: [ + { label: 'Proposal', value: 'Proposal' }, + { label: 'Example opportunity', value: 'record-id' }, + { currencyCode: 'USD', label: '$1,000', value: 1000 }, + { label: '9/1/2026', value: '2026-09-01T10:00:00Z' }, + { label: '9/30/2026', value: '2026-09-30' }, + ], + id: '0:0', + }], + sourceRowCount: 30, + summary: { + amounts: [{ + columnId: 'AMOUNT', + count: 28, + currencyCode: 'USD', + label: 'Amount', + mixedCurrency: false, + total: 1234567, + }], + groups: [{ + amountColumnId: 'AMOUNT', + buckets: [{ count: 18, label: 'Proposal', total: 900000 }], + columnId: 'STAGE_NAME', + currencyCode: 'USD', + label: 'Stage', + mixedCurrency: false, + otherBuckets: 2, + }], + recordCount: 30, + }, + total: 30, + totalPages: 2, + } +} + +describe('Sales page', () => { + beforeEach(() => { + fetchOpportunityDetails.mockReset() + fetchReport.mockReset() + .mockResolvedValue(fixture()) + }) + + it('renders live metadata and sends search, column filters, sorting and pagination to the API', async () => { + render() + await screen.findByText('Example opportunity') + fireEvent.change(screen.getByLabelText('Search sales'), { target: { value: 'Example' } }) + fireEvent.change(screen.getByLabelText('Filter field'), { target: { value: 'NAME' } }) + fireEvent.change(screen.getByLabelText('Contains'), { target: { value: 'opportunity' } }) + await waitFor(() => expect(fetchReport) + .toHaveBeenLastCalledWith(expect.objectContaining({ + filterColumn: 'NAME', filterValue: 'opportunity', page: 1, search: 'Example', + }), expect.any(AbortSignal))) + await waitFor(() => expect(screen.getByRole('button', { name: 'Opportunity' })) + .not.toBeDisabled()) + fireEvent.click(screen.getByRole('button', { name: 'Opportunity' })) + await waitFor(() => expect(fetchReport) + .toHaveBeenLastCalledWith( + expect.objectContaining({ sortBy: 'NAME', sortOrder: 'asc' }), + expect.any(AbortSignal), + )) + await waitFor(() => expect(screen.getByRole('button', { name: 'Next' })) + .not.toBeDisabled()) + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + await waitFor(() => expect(fetchReport) + .toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }), expect.any(AbortSignal))) + }) + + it('searches as the user types after a short pause and applies immediately on enter', async () => { + jest.useFakeTimers() + render() + await act(async () => { await Promise.resolve() }) + expect(fetchReport) + .toHaveBeenCalledTimes(1) + fireEvent.change(screen.getByLabelText('Search sales'), { target: { value: 'Tal' } }) + fireEvent.change(screen.getByLabelText('Search sales'), { target: { value: 'Talent' } }) + await act(async () => { jest.advanceTimersByTime(399) }) + expect(fetchReport) + .toHaveBeenCalledTimes(1) + await act(async () => { jest.advanceTimersByTime(1) }) + expect(fetchReport) + .toHaveBeenCalledTimes(2) + expect(fetchReport) + .toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, search: 'Talent' }), expect.any(AbortSignal)) + fireEvent.change(screen.getByLabelText('Search sales'), { target: { value: 'Talent search' } }) + await act(async () => { fireEvent.submit(screen.getByLabelText('Search sales')) }) + expect(fetchReport) + .toHaveBeenLastCalledWith(expect.objectContaining({ search: 'Talent search' }), expect.any(AbortSignal)) + await act(async () => { jest.advanceTimersByTime(400) }) + expect(fetchReport) + .toHaveBeenCalledTimes(3) + expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument() + jest.useRealTimers() + }) + + it('refreshes without losing filters and labels retained data after failure', async () => { + render() + await screen.findByText('Example opportunity') + fetchReport.mockRejectedValueOnce(new Error('upstream')) + fireEvent.click(screen.getByRole('button', { name: 'Refresh' })) + await screen.findByText('Showing previously loaded data') + expect(fetchReport) + .toHaveBeenLastCalledWith(expect.objectContaining({ refresh: true }), expect.any(AbortSignal)) + expect(screen.getByText('Example opportunity')) + .toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + }) + + it('removes previously loaded sales data when authorization is revoked', async () => { + render() + await screen.findByText('Example opportunity') + fetchReport.mockRejectedValueOnce({ response: { status: 403 } }) + fireEvent.click(screen.getByRole('button', { name: 'Refresh' })) + await screen.findByText('Unable to load sales data') + expect(screen.queryByText('Example opportunity')).not.toBeInTheDocument() + }) + + it('shows completeness and empty-result states without rendering upstream markup', async () => { + fetchReport.mockResolvedValueOnce({ ...fixture(), allData: false, rows: [], total: 0, totalPages: 0 }) + render() + await screen.findByText('No sales records found') + expect(screen.getByText(/Salesforce returned a limited set/)) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Next' })) + .toBeDisabled() + }) + + it('refreshes visible tabs every minute and stops polling after unmount', async () => { + jest.useFakeTimers() + const visibility = jest.spyOn(document, 'visibilityState', 'get') + .mockReturnValue('visible') + const view = render() + await act(async () => { await Promise.resolve() }) + expect(fetchReport) + .toHaveBeenCalledTimes(1) + await act(async () => { jest.advanceTimersByTime(60000) }) + expect(fetchReport) + .toHaveBeenCalledTimes(2) + visibility.mockReturnValue('hidden') + await act(async () => { jest.advanceTimersByTime(60000) }) + expect(fetchReport) + .toHaveBeenCalledTimes(2) + visibility.mockReturnValue('visible') + await act(async () => { fireEvent(document, new Event('visibilitychange')) }) + expect(fetchReport) + .toHaveBeenCalledTimes(3) + view.unmount() + await act(async () => { jest.advanceTimersByTime(60000) }) + expect(fetchReport) + .toHaveBeenCalledTimes(3) + visibility.mockRestore() + jest.useRealTimers() + }) + + it('offers the report date fields, defaults to Created Date and applies an inclusive range', async () => { + render() + await screen.findByText('Example opportunity') + const field = screen.getByLabelText('Filter type') as HTMLSelectElement + expect([...field.options].map(option => option.text)) + .toEqual(['Created Date', 'Close Date']) + expect(field.value) + .toBe('CREATED_DATE') + fireEvent.change(field, { target: { value: 'CLOSE_DATE' } }) + fireEvent.change(screen.getByLabelText('From date'), { target: { value: '2026-07-01' } }) + fireEvent.change(screen.getByLabelText('To date'), { target: { value: '2026-09-30' } }) + expect(fetchReport) + .toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('button', { name: 'Apply filter' })) + await waitFor(() => expect(fetchReport) + .toHaveBeenLastCalledWith(expect.objectContaining({ + dateColumn: 'CLOSE_DATE', dateFrom: '2026-07-01', dateTo: '2026-09-30', page: 1, + }), expect.any(AbortSignal))) + await screen.findByText(/Showing records by Close Date from 2026-07-01 through 2026-09-30/) + }) + + it('refuses an inverted range without sending a request and clears the error on reset', async () => { + render() + await screen.findByText('Example opportunity') + fireEvent.change(screen.getByLabelText('From date'), { target: { value: '2026-09-30' } }) + fireEvent.change(screen.getByLabelText('To date'), { target: { value: '2026-09-01' } }) + fireEvent.click(screen.getByRole('button', { name: 'Apply filter' })) + await screen.findByText('The From date must be on or before the To date.') + expect(fetchReport) + .toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('button', { name: 'Reset filter' })) + await waitFor(() => expect(screen.queryByText('The From date must be on or before the To date.')) + .not.toBeInTheDocument()) + expect(screen.getByLabelText('From date')) + .toHaveValue('') + expect(fetchReport) + .toHaveBeenCalledTimes(1) + }) + + it('resets an applied range and keeps the range when report filters are cleared', async () => { + render() + await screen.findByText('Example opportunity') + fireEvent.change(screen.getByLabelText('From date'), { target: { value: '2026-09-01' } }) + fireEvent.click(screen.getByRole('button', { name: 'Apply filter' })) + await waitFor(() => expect(fetchReport) + .toHaveBeenLastCalledWith( + expect.objectContaining({ dateColumn: 'CREATED_DATE', dateFrom: '2026-09-01' }), + expect.any(AbortSignal), + )) + fireEvent.change(screen.getByLabelText('Search sales'), { target: { value: 'Example' } }) + await waitFor(() => expect(fetchReport) + .toHaveBeenLastCalledWith( + expect.objectContaining({ dateFrom: '2026-09-01', search: 'Example' }), + expect.any(AbortSignal), + )) + // Clearing the report filters must not silently empty the separate date range. + fireEvent.click(screen.getByRole('button', { name: 'Clear' })) + await waitFor(() => expect(fetchReport) + .toHaveBeenLastCalledWith( + expect.not.objectContaining({ search: 'Example' }), + expect.any(AbortSignal), + )) + expect(fetchReport) + .toHaveBeenLastCalledWith( + expect.objectContaining({ dateColumn: 'CREATED_DATE', dateFrom: '2026-09-01' }), + expect.any(AbortSignal), + ) + fireEvent.click(screen.getByRole('button', { name: 'Reset filter' })) + await waitFor(() => expect(fetchReport) + .toHaveBeenLastCalledWith( + expect.objectContaining({ dateColumn: undefined, dateFrom: undefined, dateTo: undefined }), + expect.any(AbortSignal), + )) + await screen.findByText('No date range applied.') + }) + + it('shows totals for every matching record rather than the returned page', async () => { + render() + await screen.findByText('Example opportunity') + expect(screen.getByText('$1,234,567')) + .toBeInTheDocument() + expect(screen.getByText('28 of 30 records with a value')) + .toBeInTheDocument() + expect(screen.getByText('Stage breakdown')) + .toBeInTheDocument() + expect(screen.getByText('18 records')) + .toBeInTheDocument() + expect(screen.getByText('2 further values not shown.')) + .toBeInTheDocument() + }) + + it('disables the range and hides totals for a report that provides neither', async () => { + fetchReport.mockResolvedValue({ + ...fixture(), + columns: [{ dataType: 'string', id: 'NAME', label: 'Opportunity' }], + rows: [{ cells: [{ label: 'Example opportunity', value: 'record-id' }], id: '0:0' }], + summary: undefined, + }) + render() + await screen.findByText('Example opportunity') + expect(screen.getByLabelText('Filter type')) + .toBeDisabled() + expect(screen.getByRole('button', { name: 'Apply filter' })) + .toBeDisabled() + expect(screen.queryByText('Opportunities')) + .not.toBeInTheDocument() + }) + + it('opens the opportunity description in a popup and closes it again', async () => { + fetchReport.mockResolvedValue({ + ...fixture(), + rows: [{ + cells: [{ label: 'EMEA - AWS - PS BFSI', value: '006UN00000XamntYAB' }], + id: '0:0', + }], + }) + fetchOpportunityDetails.mockResolvedValue({ + closeDate: '2026-07-31', + description: 'Next AWS MVP for the BFSI practice.', + id: '006UN00000XamntYAB', + name: 'EMEA - AWS - PS BFSI', + url: 'https://topcoder.my.salesforce.com/006UN00000XamntYAB', + }) + + render() + fireEvent.click(await screen.findByRole('button', { name: 'EMEA - AWS - PS BFSI' })) + + expect(fetchOpportunityDetails) + .toHaveBeenCalledWith('006UN00000XamntYAB', expect.any(AbortSignal)) + expect(await screen.findByText('Next AWS MVP for the BFSI practice.')) + .toBeInTheDocument() + expect(screen.getByRole('link', { name: 'View in Salesforce' })) + .toHaveAttribute('href', 'https://topcoder.my.salesforce.com/006UN00000XamntYAB') + + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + }) + + it('explains a failed opportunity lookup inside the popup', async () => { + fetchReport.mockResolvedValue({ + ...fixture(), + rows: [{ + cells: [{ label: 'EMEA - AWS - PS BFSI', value: '006UN00000XamntYAB' }], + id: '0:0', + }], + }) + fetchOpportunityDetails.mockRejectedValue({ response: { status: 404 } }) + + render() + fireEvent.click(await screen.findByRole('button', { name: 'EMEA - AWS - PS BFSI' })) + + expect(await screen.findByRole('alert')) + .toHaveTextContent('We could not load the opportunity details. Please try again.') + }) + + it('leaves cells that do not carry an opportunity id as plain text', async () => { + render() + await screen.findByText('Example opportunity') + + expect(screen.queryByRole('button', { name: 'Example opportunity' })) + .not + .toBeInTheDocument() + }) +}) diff --git a/src/apps/sales/src/SalesPage.tsx b/src/apps/sales/src/SalesPage.tsx new file mode 100644 index 000000000..adb9e0a9b --- /dev/null +++ b/src/apps/sales/src/SalesPage.tsx @@ -0,0 +1,587 @@ +/* Handlers capture the active report column or control value. */ +/* eslint react/jsx-no-bind: ["error", { "allowArrowFunctions": true, "allowFunctions": true }] */ +/* The horizontal report viewport must be focusable for keyboard scrolling. */ +/* eslint jsx-a11y/no-noninteractive-tabindex: ["error", { "roles": ["region"] }] */ +import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { Button, IconOutline, LoadingSpinner, PageTitle } from '~/libs/ui' + +import { OpportunityModal } from './OpportunityModal' +import { SalesQuery, SalesReport, toOpportunityId } from './sales.models' +import { fetchSalesReport, salesErrorMessage } from './sales.service' +import { + dateColumns, + dateRangeError, + defaultDateColumn, + formatSummaryAmount, + withDateRange, +} from './sales.utils' +import styles from './SalesPage.module.scss' +import './sales.scss' + +const initialQuery: SalesQuery = { page: 1, perPage: 25 } +const filterDebounceMs = 400 + +interface SelectedOpportunity { + id: string + name: string +} + +/** + * Merges search and column filter controls into the report query. + * @param current Active report query. + * @param search Raw search input. + * @param filterColumn Selected filter column ID. + * @param filterValue Raw column filter input. + * @returns The current query when nothing changed, otherwise a new query reset to page one. Does not throw. + */ +function withFilters(current: SalesQuery, search: string, filterColumn: string, filterValue: string): SalesQuery { + const value = filterValue.trim() + const next = { + filterColumn: filterColumn && value ? filterColumn : undefined, + filterValue: filterColumn && value ? value : undefined, + search: search.trim() || undefined, + } + if ( + next.search === (current.search || undefined) + && next.filterColumn === current.filterColumn + && next.filterValue === current.filterValue + ) { + return current + } + + return { ...current, ...next, page: 1 } +} + +/** + * Read-only Sales workspace, used on the dedicated host and inside Work. + * @returns An accessible metadata-driven report with a Created/Close date range filter, + * snapshot-wide totals, server-side view controls and live refresh. + * @throws Does not throw request failures; shows inline recovery and stale-data status. + */ +const SalesPage: FC = () => { + const [query, setQuery] = useState(initialQuery) + const [search, setSearch] = useState('') + const [filterColumn, setFilterColumn] = useState('') + const [filterValue, setFilterValue] = useState('') + const [dateColumn, setDateColumn] = useState('') + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') + const [dateError, setDateError] = useState('') + const [report, setReport] = useState() + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + const [refreshVersion, setRefreshVersion] = useState(0) + const [openedOpportunity, setOpenedOpportunity] = useState() + const forceRefresh = useRef(false) + const busy = useRef(false) + + /** + * Schedules a manual server refresh without resetting active filters or sorting. + * @returns Nothing; increments the request version. Does not throw. + */ + const refresh = useCallback((): void => { + if (busy.current) return + forceRefresh.current = true + setRefreshVersion(value => value + 1) + }, []) + + useEffect(() => { + const controller = new AbortController() + const shouldRefresh = forceRefresh.current + forceRefresh.current = false + busy.current = true + setLoading(true) + setError('') + fetchSalesReport({ ...query, refresh: shouldRefresh }, controller.signal) + .then(result => { + if (!controller.signal.aborted) setReport(result) + }) + .catch(failure => { + if (controller.signal.aborted) return + const status = failure?.response?.status + // Do not retain protected data if the session or role is no longer valid. + if (status === 401 || status === 403) setReport(undefined) + setError(salesErrorMessage(failure)) + }) + .finally(() => { + if (!controller.signal.aborted) { + busy.current = false + setLoading(false) + } + }) + return () => { + controller.abort() + busy.current = false + } + }, [query, refreshVersion]) + + useEffect(() => { + /** Refreshes a visible, idle page on the timer or on return to the tab; returns void and does not throw. */ + function refreshVisible(): void { + if (document.visibilityState !== 'visible' || busy.current) return + refresh() + } + + const timer = window.setInterval(refreshVisible, Math.max(60, report?.refreshAfterSeconds ?? 60) * 1000) + document.addEventListener('visibilitychange', refreshVisible) + return () => { + window.clearInterval(timer) + document.removeEventListener('visibilitychange', refreshVisible) + } + }, [refresh, report?.refreshAfterSeconds]) + + useEffect(() => { + const timer = window.setTimeout(() => { + setQuery(current => withFilters(current, search, filterColumn, filterValue)) + }, filterDebounceMs) + return () => window.clearTimeout(timer) + }, [search, filterColumn, filterValue]) + + /** @param event Filter form submission. @returns Nothing; applies pending controls immediately. Does not throw. */ + function applyFilters(event: FormEvent): void { + event.preventDefault() + setQuery(current => withFilters(current, search, filterColumn, filterValue)) + } + + /** Clears filters and sorting after a schema change or empty search; returns void and does not throw. */ + function clearFilters(): void { + setSearch('') + setFilterColumn('') + setFilterValue('') + // The date range is its own section with its own reset, so clearing the + // report filters must not empty it behind the user's back. + setQuery(current => ({ + ...initialQuery, + dateColumn: current.dateColumn, + dateFrom: current.dateFrom, + dateTo: current.dateTo, + perPage: current.perPage, + })) + } + + /** @param id Report column ID. @returns Nothing; toggles global sorting and resets pagination. Does not throw. */ + function sortBy(id: string): void { + setQuery(current => ({ + ...current, + page: 1, + sortBy: id, + sortOrder: current.sortBy === id && current.sortOrder === 'asc' ? 'desc' : 'asc', + })) + } + + const availableDates = useMemo(() => dateColumns(report), [report]) + + useEffect(() => { + // The report defines its own date fields, so the selection follows the + // live schema instead of hard-coded Salesforce column IDs. + if (!availableDates.length) return + if (availableDates.some(column => column.id === dateColumn)) return + setDateColumn(defaultDateColumn(availableDates)) + }, [availableDates, dateColumn]) + + /** @param event Date range submission. @returns Nothing; applies a valid range. Does not throw. */ + function applyDateRange(event: FormEvent): void { + event.preventDefault() + const invalid = dateRangeError(dateColumn, dateFrom, dateTo) + setDateError(invalid) + if (invalid) return + setQuery(current => withDateRange(current, dateColumn, dateFrom, dateTo)) + } + + /** Clears the date range without disturbing search, column filters or sorting. Does not throw. */ + function resetDateRange(): void { + setDateFrom('') + setDateTo('') + setDateError('') + setDateColumn(defaultDateColumn(availableDates)) + setQuery(current => withDateRange(current, '', '', '')) + } + + const rangeApplied = !!query.dateColumn + const summary = report?.summary + const firstRow = report?.total ? (report.page - 1) * report.perPage + 1 : 0 + const lastRow = report ? Math.min(report.page * report.perPage, report.total) : 0 + const updatedAt = report ? new Date(report.refreshedAt) + .toLocaleString() : '' + + return ( +
+ Sales +
+
+

WORK / SALES

+

Sales

+

Your sales pipeline, directly from Salesforce.

+
+
+ Read only + +
+
+ +
+ + {report ? `Last updated ${updatedAt}` : 'Connecting to Salesforce'} + + Refreshes every minute while this page is visible +
+ + {error && ( +
+
+ {report ? 'Showing previously loaded data' : 'Unable to load sales data'} +

{error}

+
+ +
+ )} + + {report && !report.allData && ( +
+ Salesforce returned a limited set of records. Search, filters and totals apply to the + {' '} + {report.sourceRowCount.toLocaleString()} + {' '} + received records. Refine the source report in Salesforce to view a complete result. +
+ )} + +
+
+ Date range filter +

+ Filter by Created Date for pipeline generation, or by Close Date for revenue + projections. Counts and totals below cover every matching record, not just this page. +

+
+
+ + +
+
+ + setDateFrom(event.target.value)} + type='date' + value={dateFrom} + /> +
+
+ + setDateTo(event.target.value)} + type='date' + value={dateTo} + /> +
+
+ + +
+
+

+ {dateError && {dateError}} + {!dateError && rangeApplied && ( + + {`Showing records by ${availableDates + .find(column => column.id === query.dateColumn)?.label ?? query.dateColumn}`} + {query.dateFrom ? ` from ${query.dateFrom}` : ''} + {query.dateTo ? ` through ${query.dateTo}` : ''} + . + + )} + {!dateError && !rangeApplied && No date range applied.} +

+
+
+ + {summary && ( +
+
+
+

Opportunities

+

{summary.recordCount.toLocaleString()}

+

Matching records

+
+ {summary.amounts.map(amount => ( +
+

{amount.label}

+

{formatSummaryAmount(amount)}

+

+ {`${amount.count.toLocaleString()} of `} + {`${summary.recordCount.toLocaleString()} records with a value`} + {amount.mixedCurrency ? ' · totals mixed currencies' : ''} +

+
+ ))} +
+ {summary.groups.map(group => ( +
+

{`${group.label} breakdown`}

+
    + {group.buckets.map(bucket => ( +
  • + {bucket.label || '—'} + + {`${bucket.count.toLocaleString()} records`} + + + {formatSummaryAmount({ + columnId: group.columnId, + count: bucket.count, + currencyCode: group.currencyCode, + label: bucket.label, + mixedCurrency: group.mixedCurrency, + total: bucket.total, + })} + +
  • + ))} + {!group.buckets.length &&
  • No matching records.
  • } +
+ {group.mixedCurrency && ( +

Totals mix currencies.

+ )} + {group.otherBuckets > 0 && ( +

+ {`${group.otherBuckets.toLocaleString()} further values not shown.`} +

+ )} +
+ ))} +
+ )} + +
+
+
+

{report?.reportName || 'Sales report'}

+

Salesforce is the source of truth. Changes are made there.

+
+ {report && ( + + {report.total.toLocaleString()} + {' '} + records + + )} +
+ +
+
+ + setSearch(event.target.value)} + placeholder='Search all report fields' + spellCheck={false} + type='text' + value={search} + /> +
+
+ + +
+
+ + setFilterValue(event.target.value)} + placeholder='Filter value' + spellCheck={false} + type='text' + value={filterValue} + /> +
+
+ +
+
+ + {loading && !report && ( +
+ )} + {report && ( + <> +
+ + + + + {report.columns.map(column => ( + + ))} + + + + {report.rows.map(row => ( + + {row.cells.map((cell, index) => { + const opportunityId = toOpportunityId(cell.value) + const label = cell.label || '—' + + return ( + + ) + })} + + ))} + +
{report.reportName}
+ +
+ {opportunityId && cell.label ? ( + + ) : label} +
+
+ {report.total === 0 && ( +
+

No sales records found

+

+ {query.search || query.filterValue + ? 'Try a different search or clear the filters.' + : 'The Salesforce report does not contain any records yet.'} +

+
+ )} +
+

+ {`Showing ${firstRow}–${lastRow} of ${report.total.toLocaleString()} records`} +

+
+ + + + {`Page ${report.page} of ${Math.max(1, report.totalPages)}`} + +
+
+ + )} +
+ + {openedOpportunity && ( + setOpenedOpportunity(undefined)} + open + opportunityId={openedOpportunity.id} + opportunityName={openedOpportunity.name} + /> + )} +
+ ) +} + +export default SalesPage diff --git a/src/apps/sales/src/opportunity.service.ts b/src/apps/sales/src/opportunity.service.ts new file mode 100644 index 000000000..31b979aec --- /dev/null +++ b/src/apps/sales/src/opportunity.service.ts @@ -0,0 +1,44 @@ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +export interface SalesOpportunity { + id: string + name: string + description?: string + customer?: string + smu?: string + reportingSmu?: string + closeDate?: string + stageName?: string + url: string +} + +/** + * Reads a Salesforce opportunity through the role-protected Projects API. + * @param opportunityId Salesforce opportunity id taken from a report cell. + * @param signal Cancels the request when the popup closes or another row is opened. + * @returns The opportunity details shown in the popup. + * @throws Propagates network, authorization and sanitized Projects API errors. + */ +export function fetchOpportunity( + opportunityId: string, + signal?: AbortSignal, +): Promise { + const url = `${EnvironmentConfig.API.V6}/projects/salesforce/opportunities/${encodeURIComponent(opportunityId)}` + return xhrGetAsync(url, undefined, { signal }) +} + +/** + * Explains an opportunity lookup failure without exposing transport internals. + * @param error Unknown request rejection. + * @returns User-facing recovery guidance shown inside the popup. + * @throws Does not throw. + */ +export function opportunityErrorMessage(error: unknown): string { + const status = (error as { response?: { status?: number } })?.response?.status + if (status === 401) return 'Your session has expired. Sign in again to view opportunity details.' + if (status === 403) return 'You do not have permission to read Salesforce opportunities.' + if (status === 404) return 'This opportunity is no longer available in Salesforce.' + if (status === 503) return 'The Salesforce connection is not configured yet. Contact your administrator.' + return 'We could not load the opportunity details. Please try again.' +} diff --git a/src/apps/sales/src/sales.models.ts b/src/apps/sales/src/sales.models.ts new file mode 100644 index 000000000..271a3953f --- /dev/null +++ b/src/apps/sales/src/sales.models.ts @@ -0,0 +1,89 @@ +/** A numeric report column totalled across every matching row, not only the current page. */ +export interface SalesSummaryAmount { + columnId: string + label: string + total: number + count: number + /** True when contributing rows declared different currencies, making the total a bare sum. */ + mixedCurrency: boolean + currencyCode?: string +} + +/** One distinct value of a category column, such as a pipeline stage. */ +export interface SalesSummaryBucket { + label: string + count: number + total: number +} + +/** A category column broken down into its distinct values, largest total first. */ +export interface SalesSummaryGroup { + columnId: string + label: string + amountColumnId?: string + mixedCurrency: boolean + currencyCode?: string + buckets: SalesSummaryBucket[] + otherBuckets: number +} + +/** Aggregates the API recomputes over every matching row for the active query. */ +export interface SalesSummary { + recordCount: number + amounts: SalesSummaryAmount[] + groups: SalesSummaryGroup[] +} + +/** Metadata-driven report contract shared by Sales and the WIN integration. */ +export interface SalesReport { + reportId: string + reportName: string + columns: Array<{ id: string; label: string; dataType: string }> + rows: Array<{ + id: string + cells: Array<{ label: string; value: string | number | boolean | null; currencyCode?: string }> + }> + allData: boolean + sourceRowCount: number + total: number + page: number + perPage: number + totalPages: number + refreshedAt: string + refreshAfterSeconds: number + summary?: SalesSummary +} + +export interface SalesQuery { + page: number + perPage: number + search?: string + filterColumn?: string + filterValue?: string + /** Date or datetime column the range applies to, such as Created Date or Close Date. */ + dateColumn?: string + /** Inclusive YYYY-MM-DD lower bound; requires dateColumn. */ + dateFrom?: string + /** Inclusive YYYY-MM-DD upper bound; requires dateColumn. */ + dateTo?: string + sortBy?: string + sortOrder?: 'asc' | 'desc' + refresh?: boolean +} + +/** + * Salesforce opportunity record ids: the `006` key prefix followed by 12 or 15 + * case-sensitive alphanumeric characters. Report cells carrying the opportunity + * name expose this id as their value. + */ +const OPPORTUNITY_ID_PATTERN = /^006[a-zA-Z0-9]{12}(?:[a-zA-Z0-9]{3})?$/ + +/** + * Detects the report cell value that identifies a Salesforce opportunity. + * @param value Raw cell value from the sales report. + * @returns The opportunity id when the cell carries one, otherwise undefined. + * @throws Does not throw. + */ +export function toOpportunityId(value: string | number | boolean | null): string | undefined { + return typeof value === 'string' && OPPORTUNITY_ID_PATTERN.test(value) ? value : undefined +} diff --git a/src/apps/sales/src/sales.routes.spec.tsx b/src/apps/sales/src/sales.routes.spec.tsx new file mode 100644 index 000000000..e84c8641c --- /dev/null +++ b/src/apps/sales/src/sales.routes.spec.tsx @@ -0,0 +1,26 @@ +import { UserRole } from '~/libs/core' + +import { salesRoutes } from './sales.routes' + +jest.mock('~/config', () => ({ + AppSubdomain: { sales: 'sales' }, + EnvironmentConfig: { SUBDOMAIN: 'platform-ui' }, + ToolTitle: { sales: 'Sales' }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + lazyLoad: () => (): JSX.Element =>
, + UserRole: { administrator: 'administrator', talentManager: 'Talent Manager' }, +}), { virtual: true }) + +describe('Sales routes', () => { + it('restricts combined-host and dedicated sales access to the two authorized roles', () => { + expect(salesRoutes[0]) + .toMatchObject({ + authRequired: true, + domain: 'sales', + rolesRequired: [UserRole.administrator, UserRole.talentManager], + route: '/sales', + }) + }) +}) diff --git a/src/apps/sales/src/sales.routes.tsx b/src/apps/sales/src/sales.routes.tsx new file mode 100644 index 000000000..757d4116a --- /dev/null +++ b/src/apps/sales/src/sales.routes.tsx @@ -0,0 +1,17 @@ +import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config' +import { lazyLoad, LazyLoadedComponent, PlatformRoute, UserRole } from '~/libs/core' + +const SalesPage: LazyLoadedComponent = lazyLoad(() => import('./SalesPage')) + +/** Dedicated Sales host and combined-host route; the API independently verifies these roles. */ +export const salesRoutes: ReadonlyArray = [ + { + authRequired: true, + domain: AppSubdomain.sales, + element: , + id: ToolTitle.sales, + rolesRequired: [UserRole.administrator, UserRole.talentManager], + route: EnvironmentConfig.SUBDOMAIN === AppSubdomain.sales ? '' : '/sales', + title: ToolTitle.sales, + }, +] diff --git a/src/apps/sales/src/sales.scss b/src/apps/sales/src/sales.scss new file mode 100644 index 000000000..3dafeb7c5 --- /dev/null +++ b/src/apps/sales/src/sales.scss @@ -0,0 +1 @@ +@import '@libs/ui/styles/2026/index'; diff --git a/src/apps/sales/src/sales.service.ts b/src/apps/sales/src/sales.service.ts new file mode 100644 index 000000000..113b08160 --- /dev/null +++ b/src/apps/sales/src/sales.service.ts @@ -0,0 +1,41 @@ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +import { SalesQuery, SalesReport } from './sales.models' + +/** + * Reads Salesforce report data through the role-protected Reports API using the user's token. + * @param query Server-side search, filter, date range, sorting, pagination and refresh options. + * @param signal Cancels obsolete requests when controls change or the page unmounts. + * @returns The current report schema, snapshot-wide totals and one page of rows. + * @throws Propagates network, authorization and sanitized Reports API errors. + */ +export function fetchSalesReport(query: SalesQuery, signal?: AbortSignal): Promise { + const params = new URLSearchParams() + Object.entries(query) + .forEach(([key, value]) => { + if (value !== undefined && value !== '') params.set(key, String(value)) + }) + return xhrGetAsync(`${EnvironmentConfig.REPORTS_API}/sales?${params.toString()}`, undefined, { + signal, + timeout: 120000, + }) +} + +/** + * Explains report failures without exposing transport internals or upstream responses. + * @param error Unknown request rejection. + * @returns User-facing recovery guidance, including explicit authorization failures. + * @throws Does not throw. + */ +export function salesErrorMessage(error: unknown): string { + const status = (error as { response?: { status?: number } })?.response?.status + if (status === 401) return 'Your session has expired. Sign in again to view sales data.' + if (status === 403) return 'Sales data is available to Administrators and Talent Managers.' + if (status === 503) return 'The sales connection is not configured yet. Contact your administrator.' + if (status === 400) { + return 'The report columns have changed. Reset the date range, clear the filters and sorting, then try again.' + } + + return 'We could not refresh the Salesforce report. Please try again.' +} diff --git a/src/apps/sales/src/sales.utils.spec.ts b/src/apps/sales/src/sales.utils.spec.ts new file mode 100644 index 000000000..9c9e11790 --- /dev/null +++ b/src/apps/sales/src/sales.utils.spec.ts @@ -0,0 +1,95 @@ +import { SalesReport } from './sales.models' +import { + dateColumns, + dateRangeError, + defaultDateColumn, + formatSummaryAmount, + withDateRange, +} from './sales.utils' + +/** @returns A synthetic report schema with both pipeline date fields. Does not throw. */ +function report(): SalesReport { + return { + allData: true, + columns: [ + { dataType: 'string', id: 'NAME', label: 'Opportunity' }, + { dataType: 'currency', id: 'AMOUNT', label: 'Amount' }, + { dataType: 'datetime', id: 'CREATED_DATE', label: 'Created Date' }, + { dataType: 'date', id: 'CLOSE_DATE', label: 'Close Date' }, + ], + page: 1, + perPage: 25, + refreshAfterSeconds: 60, + refreshedAt: '2026-09-16T02:00:00Z', + reportId: 'test-report', + reportName: 'Bookings By Stage', + rows: [], + sourceRowCount: 0, + total: 0, + totalPages: 0, + } +} + +describe('Sales date range utilities', () => { + it('offers only date fields and opens on Created Date for pipeline analysis', () => { + const columns = dateColumns(report()) + expect(columns.map(column => column.id)) + .toEqual(['CREATED_DATE', 'CLOSE_DATE']) + expect(defaultDateColumn(columns)) + .toBe('CREATED_DATE') + expect(dateColumns(undefined)) + .toEqual([]) + expect(defaultDateColumn([])) + .toBe('') + expect(defaultDateColumn([{ dataType: 'date', id: 'CLOSE_DATE', label: 'Close Date' }])) + .toBe('CLOSE_DATE') + }) + + it('rejects an inverted range and a bound without a field before any request', () => { + expect(dateRangeError('CLOSE_DATE', '2026-09-30', '2026-09-01')) + .toBe('The From date must be on or before the To date.') + expect(dateRangeError('', '2026-09-01', '')) + .toBe('Choose the date field this range applies to.') + expect(dateRangeError('CLOSE_DATE', '2026-09-01', '2026-09-30')) + .toBe('') + expect(dateRangeError('CLOSE_DATE', '', '')) + .toBe('') + expect(dateRangeError('', '', '')) + .toBe('') + }) + + it('sends a range only once a bound is set and restarts at the first page', () => { + const base = { page: 4, perPage: 25 } + expect(withDateRange(base, 'CLOSE_DATE', '', '')) + .toBe(base) + expect(withDateRange(base, 'CLOSE_DATE', '2026-09-01', '')) + .toEqual({ dateColumn: 'CLOSE_DATE', dateFrom: '2026-09-01', dateTo: undefined, page: 1, perPage: 25 }) + expect(withDateRange(base, 'CLOSE_DATE', '', '2026-09-30')) + .toEqual({ dateColumn: 'CLOSE_DATE', dateFrom: undefined, dateTo: '2026-09-30', page: 1, perPage: 25 }) + const applied = withDateRange(base, 'CLOSE_DATE', '2026-09-01', '2026-09-30') + expect(withDateRange(applied, 'CLOSE_DATE', '2026-09-01', '2026-09-30')) + .toBe(applied) + expect(withDateRange(applied, '', '', '')) + .toMatchObject({ dateColumn: undefined, dateFrom: undefined, dateTo: undefined, page: 1 }) + }) + + it('labels a total with its shared currency and leaves a mixed sum unlabelled', () => { + expect(formatSummaryAmount({ + columnId: 'AMOUNT', count: 2, currencyCode: 'USD', label: 'Amount', mixedCurrency: false, total: 1234.56, + })) + .toBe('$1,235') + expect(formatSummaryAmount({ + columnId: 'AMOUNT', count: 2, label: 'Amount', mixedCurrency: true, total: 1234.56, + })) + .toBe('1,235') + expect(formatSummaryAmount({ + columnId: 'AMOUNT', + count: 0, + currencyCode: 'not-a-currency', + label: 'Amount', + mixedCurrency: false, + total: 0, + })) + .toBe('0') + }) +}) diff --git a/src/apps/sales/src/sales.utils.ts b/src/apps/sales/src/sales.utils.ts new file mode 100644 index 000000000..6fe8ea710 --- /dev/null +++ b/src/apps/sales/src/sales.utils.ts @@ -0,0 +1,87 @@ +import { SalesQuery, SalesReport, SalesSummaryAmount } from './sales.models' + +/** Report column types a date range can be applied to; mirrors the Reports API contract. */ +const dateTypes = ['date', 'datetime'] + +/** Matches the Created Date column so the portal opens on pipeline generation. */ +const createdDatePattern = /creat/i + +export type SalesDateColumn = SalesReport['columns'][number] + +/** + * Lists the report columns a date range can filter on, such as Created Date and Close Date. + * @param report Loaded report, or undefined before the first response. + * @returns Date and datetime columns in report order; empty when the report has none. + * @throws Does not throw. + */ +export function dateColumns(report?: SalesReport): SalesDateColumn[] { + return (report?.columns ?? []).filter(column => dateTypes.includes(column.dataType)) +} + +/** + * Chooses the date column the portal starts on, preferring Created Date for pipeline analysis. + * @param columns Available date columns, in report order. + * @returns The preferred column ID, or an empty string when the report has no date column. + * @throws Does not throw. + */ +export function defaultDateColumn(columns: SalesDateColumn[]): string { + const created = columns.find(column => createdDatePattern.test(column.label)) + return (created ?? columns[0])?.id ?? '' +} + +/** + * Explains why a chosen range cannot be applied, so the request is never sent. + * @param column Selected date column ID. + * @param from Inclusive lower bound, as a YYYY-MM-DD value from a date input. + * @param to Inclusive upper bound, as a YYYY-MM-DD value from a date input. + * @returns A message to display, or an empty string when the range is usable. + * @throws Does not throw. + */ +export function dateRangeError(column: string, from: string, to: string): string { + if (!column && (from || to)) return 'Choose the date field this range applies to.' + if (from && to && from > to) return 'The From date must be on or before the To date.' + return '' +} + +/** + * Merges the date range controls into the report query. + * @param current Active report query. + * @param column Selected date column ID. + * @param from Inclusive lower bound. + * @param to Inclusive upper bound. + * @returns The current query when nothing changed, otherwise a new query reset to page one. Does not throw. + */ +export function withDateRange(current: SalesQuery, column: string, from: string, to: string): SalesQuery { + // The column alone never filters, so it is only sent once a bound is set. + const ranged = !!column && !!(from || to) + const next = { + dateColumn: ranged ? column : undefined, + dateFrom: ranged && from ? from : undefined, + dateTo: ranged && to ? to : undefined, + } + if ( + next.dateColumn === current.dateColumn + && next.dateFrom === current.dateFrom + && next.dateTo === current.dateTo + ) { + return current + } + + return { ...current, ...next, page: 1 } +} + +/** + * Formats a snapshot-wide total for display, using the currency the matching rows agree on. + * @param amount Summary entry for one numeric column. + * @returns A localized currency amount, or a plain number when the rows mix currencies. + * @throws Does not throw for an unexpected currency code; falls back to a plain number. + */ +export function formatSummaryAmount(amount: SalesSummaryAmount): string { + try { + return amount.total.toLocaleString(undefined, amount.currencyCode + ? { currency: amount.currencyCode, maximumFractionDigits: 0, style: 'currency' } + : { maximumFractionDigits: 0 }) + } catch { + return amount.total.toLocaleString(undefined, { maximumFractionDigits: 0 }) + } +} diff --git a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx index b67cccb13..b4b77881c 100644 --- a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx +++ b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.spec.tsx @@ -34,7 +34,9 @@ jest.mock('../../../lib/services/wallet', () => ({ jest.mock('../../../lib', () => ({ FilterBar: (props: any) => mockFilterBar(props), formatIOSDateString: (value: string) => value, - PaymentView: () =>
Payment View
, + PaymentView: (props: any) => ( +
{props.payment.grossAmountNumber}
+ ), })) jest.mock('../../../lib/components/payment-edit/PaymentEdit', () => ({ @@ -51,15 +53,22 @@ jest.mock('../../../lib/components/payments-table/PaymentTable', () => ({
Payment Table
{props.payments.map((payment: any) => ( - +
+ {payment.grossAmount} + + +
))} {props.payments.length > 1 && ( ), IconOutline: { - CheckIcon: () => check-icon, - }, - IconSolid: { - ExclamationIcon: () => exclamation-icon, + CheckCircleIcon: () => check-icon, + ExclamationCircleIcon: () => exclamation-icon, XCircleIcon: () => x-icon, }, }), { virtual: true }) @@ -126,6 +124,41 @@ describe('PaymentView', () => { jest.clearAllMocks() }) + it.each(['PAID', 'CANCELLED'])( + 'shows the gross total and individual installment amounts/statuses when installment 2 is %s', + async secondStatus => { + const splitPayment: Winning = { + ...payment, + details: [2760, 920].map((amount, index) => ({ + currency: 'USD', + datePaid: '2024-03-20T14:19:07.000Z', + grossAmount: String(amount), + id: `installment-${index + 1}`, + installmentNumber: index + 1, + status: index === 0 ? 'PAID' : secondStatus, + totalAmount: '3680', + })), + grossAmount: '$3,680.00', + grossAmountNumber: 3680, + status: 'Paid', + type: 'task payment', + } + + render() + + expect(await screen.findByText('Installments')) + .toBeTruthy() + expect(screen.getByText('$3,680.00')) + .toBeTruthy() + expect(screen.getByText('Installment 1: $2,760.00 — paid')) + .toBeTruthy() + expect(screen.getByText(`Installment 2: $920.00 — ${secondStatus.toLowerCase()}`)) + .toBeTruthy() + await waitFor(() => expect(mockedFetchWinningPaymentDetails) + .toHaveBeenCalled()) + }, + ) + it('renders engagement payment details with tabs and agreement match banner', async () => { render() diff --git a/src/apps/wallet-admin/src/lib/components/payment-view/tabs/PaymentGeneralInfoTab.tsx b/src/apps/wallet-admin/src/lib/components/payment-view/tabs/PaymentGeneralInfoTab.tsx index 78e932629..962d1895e 100644 --- a/src/apps/wallet-admin/src/lib/components/payment-view/tabs/PaymentGeneralInfoTab.tsx +++ b/src/apps/wallet-admin/src/lib/components/payment-view/tabs/PaymentGeneralInfoTab.tsx @@ -1,6 +1,7 @@ import { FC } from 'react' import { Winning } from '../../../models/WinningDetail' +import { formatCurrencyAmount } from '../payment-view.utils' import styles from '../PaymentView.module.scss' interface PaymentGeneralInfoTabProps { @@ -10,6 +11,12 @@ interface PaymentGeneralInfoTabProps { readonly payment: Winning } +/** + * Shows winning metadata and the amount/status of each historical installment. + * @param props Formatted winning and its description/date presentation values. + * @returns General Info content, with a breakdown for multi-installment payments. + * @throws RangeError if a stored installment contains an invalid currency code. + */ const PaymentGeneralInfoTab: FC = (props: PaymentGeneralInfoTabProps) => (
@@ -55,6 +62,23 @@ const PaymentGeneralInfoTab: FC = (props: PaymentGen
)}
+ {props.payment.details.length > 1 && ( +
+ Installments + {props.payment.details.map(installment => ( +

+ Installment + {' '} + {installment.installmentNumber} + {': '} + {formatCurrencyAmount(Number(installment.grossAmount), installment.currency)} + {' — '} + {installment.status.replaceAll('_', ' ') + .toLowerCase()} +

+ ))} +
+ )}
) diff --git a/src/apps/wallet-admin/src/lib/models/WinningDetail.ts b/src/apps/wallet-admin/src/lib/models/WinningDetail.ts index 6432bff4d..6b1a9281a 100644 --- a/src/apps/wallet-admin/src/lib/models/WinningDetail.ts +++ b/src/apps/wallet-admin/src/lib/models/WinningDetail.ts @@ -99,6 +99,8 @@ export interface WinningDetail { category: string title: string description: string + /** Gross member amount across current installments; absent on older Finance deployments. */ + grossAmount?: number externalId: string attributes: { assignmentId?: number | string diff --git a/src/apps/work/README.md b/src/apps/work/README.md index 71fa4afca..0f0031ea9 100644 --- a/src/apps/work/README.md +++ b/src/apps/work/README.md @@ -20,6 +20,14 @@ The Work app provides work management capabilities for: `config/routes.config.ts` contains route ids and the `rootRoute` resolver based on the active subdomain. +## Navigation styles + +The Work subnavigation uses regular black text and a bold active item on desktop +and mobile. Project section tabs use black text with a bold teal active label and +underline. These component styles override the shared theme's general link color. +System Admin dropdown links similarly inherit their menu row color: black by +default and white on a teal hover background. + ## Providers `WorkApp.tsx` composes these providers: @@ -36,3 +44,10 @@ The Work app provides work management capabilities for: Original implementation reference: - `work-manager/` + +## Sales + +The Sales tab opens the read-only Salesforce report for Administrators and Talent +Managers. It is also available on the dedicated Sales host. Search, column +filters, sorting, pagination and refresh use `reports-api-v6`; all edits remain +in Salesforce. See [Sales app documentation](../sales/README.md). diff --git a/src/apps/work/src/config/routes.config.ts b/src/apps/work/src/config/routes.config.ts index 0f52369d1..458c98215 100644 --- a/src/apps/work/src/config/routes.config.ts +++ b/src/apps/work/src/config/routes.config.ts @@ -6,6 +6,7 @@ export const rootRoute: string : `/${AppSubdomain.work}` export const challengesRouteId = 'challenges' +export const salesRouteId = 'sales' export const challengeCreateRouteId = 'challenge-create' export const challengeEditRouteId = 'challenge-edit' export const projectsRouteId = 'projects' diff --git a/src/apps/work/src/lib/components/NavTabs/NavTabs.module.scss b/src/apps/work/src/lib/components/NavTabs/NavTabs.module.scss index 71c250cdc..03f9ca62b 100644 --- a/src/apps/work/src/lib/components/NavTabs/NavTabs.module.scss +++ b/src/apps/work/src/lib/components/NavTabs/NavTabs.module.scss @@ -73,9 +73,15 @@ li { font-family: 'Nunito Sans', sans-serif; + font-weight: 400; margin-left: $sp-8; color: var(--FontColor); + .tabLink { + color: inherit; + font-weight: inherit; + } + &.active { font-weight: 700; } @@ -112,8 +118,7 @@ width: 100%; &.active { - background-color: var(--Actived); - color: var(--invertButtonColor); + color: var(--FontColor); } } } diff --git a/src/apps/work/src/lib/components/NavTabs/config/tabs-config.spec.ts b/src/apps/work/src/lib/components/NavTabs/config/tabs-config.spec.ts index 217430eb5..8bd087b2e 100644 --- a/src/apps/work/src/lib/components/NavTabs/config/tabs-config.spec.ts +++ b/src/apps/work/src/lib/components/NavTabs/config/tabs-config.spec.ts @@ -18,6 +18,16 @@ jest.mock('../../../utils/permissions.utils', () => ({ })) describe('getTabsConfig', () => { + it.each(['administrator', 'Talent Manager'])('shows sales for %s', role => { + expect(getTabsConfig([role], false) + .map(tab => tab.id)) + .toContain('sales') + }) + + it.each(['copilot', 'Project Manager', 'Topcoder User'])('hides sales for %s', role => { + expect(getTabsConfig([role], false) + .map(tab => tab.id)).not.toContain('sales') + }) it('shows the engagements tab for talent managers on the common work page', () => { expect(getTabsConfig(['talent manager'], false) .map(tab => tab.id)) diff --git a/src/apps/work/src/lib/components/NavTabs/config/tabs-config.ts b/src/apps/work/src/lib/components/NavTabs/config/tabs-config.ts index f43b72cf2..a437cbb16 100644 --- a/src/apps/work/src/lib/components/NavTabs/config/tabs-config.ts +++ b/src/apps/work/src/lib/components/NavTabs/config/tabs-config.ts @@ -15,6 +15,7 @@ import { engagementsRouteId, groupsRouteId, projectsRouteId, + salesRouteId, taasRouteId, } from '../../../../config/routes.config' import { canViewAllEngagements } from '../../../utils/permissions.utils' @@ -23,6 +24,13 @@ function hasAnyRole(userRoles: string[], roles: string[]): boolean { return userRoles.some(role => roles.includes(role.toLowerCase())) } +/** + * Builds Work navigation, including Sales for Administrators and Talent Managers. + * @param userRoles Authenticated caller roles. + * @param isAnonymous Whether the visitor has no authenticated profile. + * @returns Visible Work tabs; anonymous visitors receive none. + * @throws Does not throw. + */ export function getTabsConfig(userRoles: string[], isAnonymous: boolean): TabsNavItem[] { if (isAnonymous) { return [] @@ -72,6 +80,10 @@ export function getTabsConfig(userRoles: string[], isAnonymous: boolean): TabsNa const isCopilot = hasAnyRole(userRoles, COPILOT_ROLES) + if (isAdmin || hasAnyRole(userRoles, ['talent manager'])) { + tabs.push({ id: salesRouteId, title: 'Sales' }) + } + if (isAdmin || isCopilot || isManager) { tabs.push({ id: groupsRouteId, diff --git a/src/apps/work/src/lib/components/ProjectListTabs/ProjectListTabs.module.scss b/src/apps/work/src/lib/components/ProjectListTabs/ProjectListTabs.module.scss index f1fc91a77..0003a4f33 100644 --- a/src/apps/work/src/lib/components/ProjectListTabs/ProjectListTabs.module.scss +++ b/src/apps/work/src/lib/components/ProjectListTabs/ProjectListTabs.module.scss @@ -9,25 +9,18 @@ border-bottom: 1px solid #d5d5d5; } -.tabLink { +.container .tabLink { display: inline-block; padding: 10px 0; border-bottom: 2px solid transparent; - color: $black-60; + color: $black-100; font-size: 14px; - font-weight: 500; + font-weight: 400; text-decoration: none; -} - -.tabLink:hover { - color: $black-60; -} - -.active { - color: #2a62d5; - border-bottom-color: #2a62d5; - &:hover { - color: #2a62d5; + &.active { + color: var(--tc-2026-teal, #00797a); + border-bottom-color: var(--tc-2026-teal, #00797a); + font-weight: 700; } } diff --git a/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.module.scss b/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.module.scss new file mode 100644 index 000000000..90e938d39 --- /dev/null +++ b/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.module.scss @@ -0,0 +1,39 @@ +@import '@libs/ui/styles/includes'; + +.details { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: $sp-4 $sp-6; + margin: 0; + padding: $sp-4; + border-radius: 8px; + border: 1px solid $black-20; + background: $black-5; +} + +.item { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.label { + color: $black-80; + font-family: Roboto; + font-size: 12px; + font-weight: 700; + line-height: 16px; + letter-spacing: 1px; + text-transform: uppercase; +} + +.value { + margin: 0; + color: $black-100; + font-family: Roboto; + font-size: 16px; + font-weight: 500; + line-height: 24px; + overflow-wrap: anywhere; +} diff --git a/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.spec.tsx b/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.spec.tsx new file mode 100644 index 000000000..b4baa0d40 --- /dev/null +++ b/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.spec.tsx @@ -0,0 +1,56 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import ShowcasePostDetails, { getShowcaseDetailItems, getShowcaseStorySections } from './ShowcasePostDetails' + +jest.mock('~/libs/shared/lib/utils/text-format', () => ({ + textFormatDateLocaleShortString: (date: Date) => date.toISOString() + .slice(0, 10), +}), { virtual: true }) + +const expectedDate = new Date(2026, 7, 15) + .toISOString() + .slice(0, 10) + +describe('Showcase post details', () => { + it('lists populated summary fields in order and hides missing ones', () => { + expect(getShowcaseDetailItems({ + currentStatus: ' ', + customer: 'Wipro', + dealCloseDate: '2026-08-15', + keyWin: 'Win-win', + smu: 'Others', + smuOther: 'LATAM', + type: 'Private POD Delivery', + })) + .toEqual([ + { label: 'Type', value: 'Private POD Delivery' }, + { label: 'Customer', value: 'Wipro' }, + { label: 'SMU', value: 'LATAM' }, + { label: 'Deal Close Date', value: expectedDate }, + { label: 'Key Win', value: 'Win-win' }, + ]) + }) + + it('returns only populated story sections', () => { + expect(getShowcaseStorySections({ businessImpact: 'Impact', challenge: '', content: 'Solution' }) + .map(section => section.label)) + .toEqual(['The Solution', 'Business Impact Realised']) + }) + + it('renders labelled values and nothing when no fields are populated', () => { + const view = render() + expect(view.container) + .toBeEmptyDOMElement() + view.rerender() + expect(screen.getByText('SMU')) + .toBeInTheDocument() + // Projects stored before the Salesforce naming alignment render the current label. + expect(screen.getByText('AMR1')) + .toBeInTheDocument() + expect(screen.getByText('Owner')) + .toBeInTheDocument() + expect(screen.queryByText('Customer')).not.toBeInTheDocument() + }) +}) diff --git a/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.tsx b/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.tsx new file mode 100644 index 000000000..337f40b3f --- /dev/null +++ b/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.tsx @@ -0,0 +1,115 @@ +import { FC } from 'react' + +import { textFormatDateLocaleShortString } from '~/libs/shared/lib/utils/text-format' + +import { normalizeSmuValue } from '../../constants/showcase.constants' +import { ShowcaseMetadata } from '../../models/ProjectShowcasePost.model' + +import styles from './ShowcasePostDetails.module.scss' + +export interface ShowcasePostDetailItem { + label: string + value: string +} + +export interface ShowcasePostStorySection { + label: string + value: string +} + +/** + * Formats a stored YYYY-MM-DD date without shifting it across time zones. + * @param value Deal close date from the showcase or project metadata. + * @returns A localized short date, the trimmed input when it is not a calendar date, or undefined when empty. + * @throws Does not throw. + */ +function formatDealCloseDate(value: string | undefined): string | undefined { + const trimmed = value?.trim() + if (!trimmed) { + return undefined + } + + const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed) + const date = match + ? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])) + : new Date(trimmed) + + return Number.isNaN(date.getTime()) + ? trimmed + : textFormatDateLocaleShortString(date) +} + +/** + * Collects the populated single-value showcase fields in display order. + * @param data Showcase and shared project metadata. + * @returns Label/value pairs with blank values removed; SMU uses the custom value for Others. + * @throws Does not throw. + */ +export function getShowcaseDetailItems(data: ShowcaseMetadata): ShowcasePostDetailItem[] { + const smu = data.smu === 'Others' + ? data.smuOther?.trim() || data.smu + : normalizeSmuValue(data.smu) + + return [ + { label: 'Type', value: data.type }, + { label: 'Customer', value: data.customer }, + { label: 'SMU', value: smu }, + { label: 'Deal Close Date', value: formatDealCloseDate(data.dealCloseDate) }, + { label: 'Key Win', value: data.keyWin }, + { label: 'Current Status', value: data.currentStatus }, + { label: 'Owner', value: data.owner }, + ] + .map(item => ({ label: item.label, value: item.value?.trim() ?? '' })) + .filter(item => !!item.value) +} + +/** + * Collects the populated long-form showcase sections in display order. + * @param data Showcase rich text fields; content is The Solution. + * @returns Section labels and rich text values with blank sections removed. + * @throws Does not throw. + */ +export function getShowcaseStorySections( + data: Pick & { content?: string }, +): ShowcasePostStorySection[] { + return [ + { label: 'The Challenge', value: data.challenge }, + { label: 'The Solution', value: data.content }, + { label: 'Business Impact Realised', value: data.businessImpact }, + ] + .filter((item): item is ShowcasePostStorySection => !!item.value?.trim()) +} + +export interface ShowcasePostDetailsProps { + data: ShowcaseMetadata + className?: string +} + +/** + * Shows the populated showcase summary fields shared by the Work preview and the customer showcase. + * @param props Showcase metadata and an optional wrapper class. + * @returns A labelled detail grid, or nothing when no summary field is populated. + * @throws Does not throw. + */ +const ShowcasePostDetails: FC = props => { + const items = getShowcaseDetailItems(props.data) + + if (!items.length) { + return <> + } + + return ( +
+ {items.map(item => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ ) +} + +export default ShowcasePostDetails diff --git a/src/apps/work/src/lib/components/ShowcasePostDetails/index.ts b/src/apps/work/src/lib/components/ShowcasePostDetails/index.ts new file mode 100644 index 000000000..01dde5146 --- /dev/null +++ b/src/apps/work/src/lib/components/ShowcasePostDetails/index.ts @@ -0,0 +1,10 @@ +export { + default as ShowcasePostDetails, + getShowcaseDetailItems, + getShowcaseStorySections, +} from './ShowcasePostDetails' +export type { + ShowcasePostDetailItem, + ShowcasePostDetailsProps, + ShowcasePostStorySection, +} from './ShowcasePostDetails' diff --git a/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.module.scss b/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.module.scss index 7f4a2985d..4f67cddae 100644 --- a/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.module.scss +++ b/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.module.scss @@ -133,6 +133,14 @@ padding-left: 20px; } + img { + display: block; + max-width: 100%; + height: auto; + margin: 0 0 16px; + border-radius: 8px; + } + a { color: $link-blue-dark; font-weight: 700; @@ -216,14 +224,6 @@ margin: 0; } -.emptyMessage { - margin: 0; - color: $black-80; - font-family: Roboto; - font-size: 14px; - line-height: 22px; -} - .mediaList { display: flex; flex-wrap: wrap; diff --git a/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.tsx b/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.tsx index d20c1e18e..42e07abdc 100644 --- a/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.tsx +++ b/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.tsx @@ -5,6 +5,9 @@ import { IconOutline } from '~/libs/ui' import { renderRichTextToHtml } from '~/libs/shared/lib/utils/rich-text' import { textFormatDateLocaleShortString } from '~/libs/shared/lib/utils/text-format' +import { ShowcaseMetadata } from '../../models/ProjectShowcasePost.model' +import { getShowcaseStorySections, ShowcasePostDetails } from '../ShowcasePostDetails' + import styles from './ShowcasePostPreview.module.scss' export interface ShowcasePostPreviewChallenge { @@ -16,7 +19,7 @@ export interface ShowcasePostPreviewChallenge { numOfRegistrants?: number } -export interface ShowcasePostPreviewData { +export interface ShowcasePostPreviewData extends ShowcaseMetadata { title: string content: string categories: Array<{ id: string; name: string }> @@ -99,16 +102,21 @@ const ShowcasePostPreview: FC = props => {
-
+ + {getShowcaseStorySections(data) + .map(section => ( +
+
{section.label}
+
+
+ ))} -
-
Media assets
- {data.media.length > 0 ? ( + {data.media.length > 0 && ( +
+
Media assets
    {data.media.map((item, index) => { const key: string = `${item.url}-${index}` @@ -149,14 +157,12 @@ const ShowcasePostPreview: FC = props => { ) })}
- ) : ( -

No media added yet.

- )} -
+
+ )} -
-
Challenges
- {data.challenges.length > 0 ? ( + {data.challenges.length > 0 && ( +
+
Topcoder Challenge Launched
    {data.challenges.map(challenge => { const challengeUrl: string | undefined = getSafeHttpUrl( @@ -223,10 +229,8 @@ const ShowcasePostPreview: FC = props => { ) })}
- ) : ( -

No challenges selected.

- )} -
+
+ )}
-
-
Stats
-
    -
  • - {data.challengeCount} - Challenges -
  • -
  • - {data.registrantsCount} - Registrants -
  • -
  • - {data.countriesCount} - Countries -
  • -
-
+ {data.challengeCount > 0 && ( +
+
Stats
+
    +
  • + {data.challengeCount} + Challenges +
  • +
  • + {data.registrantsCount} + Registrants +
  • +
  • + {data.countriesCount} + Countries +
  • +
+
+ )} -
-
Skills
-

- This showcase includes - {' '} - - {data.skills.length} + {data.skills.length > 0 && ( +

+
Skills
+

+ This showcase includes {' '} - skills. - -

- {data.skills.length > 0 && ( + + {data.skills.length} + {' '} + skills. + +

    {data.skills.map(skill => (
  • {skill.name}
  • ))}
- )} -
+
+ )}
diff --git a/src/apps/work/src/lib/components/form/FormSelectField/FormSelectField.tsx b/src/apps/work/src/lib/components/form/FormSelectField/FormSelectField.tsx index cd71a5e41..b50db572f 100644 --- a/src/apps/work/src/lib/components/form/FormSelectField/FormSelectField.tsx +++ b/src/apps/work/src/lib/components/form/FormSelectField/FormSelectField.tsx @@ -139,6 +139,12 @@ function getSelectComponent( return Select } +/** + * Renders a form select with its visible label bound to the focusable input. + * @param props Field name, label, choices, and optional async/multiple-value behavior. + * @returns A React Select control connected to the surrounding form. + * @throws Requires a parent FormProvider; option-loading errors propagate to the loader. + */ export const FormSelectField: FC = (props: FormSelectFieldProps) => { const formContext = useFormContext() const { @@ -206,7 +212,7 @@ export const FormSelectField: FC = (props: FormSelectField className={styles.select} classNamePrefix='challenge-select' defaultOptions={isAsync} - id={props.name} + inputId={props.name} isClearable={props.isClearable} isDisabled={props.disabled} isMulti={isMulti} diff --git a/src/apps/work/src/lib/components/form/ProjectMetadataFields.module.scss b/src/apps/work/src/lib/components/form/ProjectMetadataFields.module.scss new file mode 100644 index 000000000..aa190f150 --- /dev/null +++ b/src/apps/work/src/lib/components/form/ProjectMetadataFields.module.scss @@ -0,0 +1,22 @@ +@import '@libs/ui/styles/includes'; + +.opportunityStatus { + color: $black-60; + font-size: 12px; + line-height: 1.5; + margin-top: -4px; +} + +.opportunityError { + color: $red-100; +} + +.opportunityLink { + color: $blue-100; + font-weight: 500; + text-decoration: underline; +} + +.opportunityName { + color: $black-80; +} diff --git a/src/apps/work/src/lib/components/form/ProjectMetadataFields.tsx b/src/apps/work/src/lib/components/form/ProjectMetadataFields.tsx new file mode 100644 index 000000000..8de38e1e4 --- /dev/null +++ b/src/apps/work/src/lib/components/form/ProjectMetadataFields.tsx @@ -0,0 +1,207 @@ +import { + FC, + useCallback, + useEffect, + useRef, + useState, +} from 'react' +import { useFormContext } from 'react-hook-form' +import classNames from 'classnames' + +import { isSalesforceOpportunityId } from '../../constants/salesforce.constants' +import { SMU_VALUES } from '../../constants/showcase.constants' +import { + fetchSalesforceOpportunity, + SalesforceOpportunity, + salesforceOpportunityErrorMessage, +} from '../../services/salesforce-opportunities.service' + +import { FormSelectField } from './FormSelectField' +import { FormTextField } from './FormTextField' +import styles from './ProjectMetadataFields.module.scss' + +const lookupDebounceMs = 400 + +interface ProjectMetadataFieldsProps { + className?: string + required?: boolean + /** Enables the Salesforce Opportunity ID lookup that populates the fields below. */ + showSalesforceOpportunity?: boolean +} + +type LookupState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'ready'; opportunity: SalesforceOpportunity } + +/** + * Renders the shared Customer, SMU and Deal Close Date fields in both project forms. + * @param props Optional field styling, whether showcase metadata is required, and + * whether the Salesforce Opportunity ID lookup is offered. + * @returns Fields bound to the surrounding React Hook Form, including custom SMU input. + * @throws Requires a parent FormProvider, like the other work form controls. + */ +export const ProjectMetadataFields: FC = props => { + const formContext = useFormContext() + const smu = formContext.watch('smu') + const opportunityId = formContext.watch('salesforceOpportunityId') + const [lookup, setLookup] = useState({ status: 'idle' }) + // Populating the fields is a response to typing, so reopening a linked + // project never overwrites details that were edited by hand afterwards. + const enteredByUser = useRef(false) + + const setValue = formContext.setValue + + const applyOpportunity = useCallback( + (opportunity: SalesforceOpportunity): void => { + const options = { shouldDirty: true, shouldValidate: true } + + if (opportunity.customer) { + setValue('customer', opportunity.customer, options) + } + + if (opportunity.smu) { + setValue('smu', opportunity.smu, options) + setValue( + 'smuOther', + opportunity.smu === 'Others' ? opportunity.smuOther || '' : '', + options, + ) + } + + if (opportunity.closeDate) { + setValue('dealCloseDate', opportunity.closeDate, options) + } + }, + [setValue], + ) + + useEffect(() => { + if (!props.showSalesforceOpportunity) { + return undefined + } + + const trimmedId = (opportunityId || '').trim() + if (!trimmedId || !isSalesforceOpportunityId(trimmedId)) { + setLookup({ status: 'idle' }) + return undefined + } + + const controller = new AbortController() + const timer = window.setTimeout(() => { + setLookup({ status: 'loading' }) + fetchSalesforceOpportunity(trimmedId, controller.signal) + .then(opportunity => { + if (controller.signal.aborted) { + return + } + + setLookup({ opportunity, status: 'ready' }) + + if (enteredByUser.current) { + applyOpportunity(opportunity) + } + }) + .catch(error => { + if (controller.signal.aborted) { + return + } + + setLookup({ + message: salesforceOpportunityErrorMessage(error), + status: 'error', + }) + }) + }, lookupDebounceMs) + + return () => { + window.clearTimeout(timer) + controller.abort() + } + }, [applyOpportunity, opportunityId, props.showSalesforceOpportunity]) + + /** @param value Raw field input. @returns Nothing; marks the id as user-entered. Does not throw. */ + function handleOpportunityIdChange(): void { + enteredByUser.current = true + } + + return ( + <> + {props.showSalesforceOpportunity && ( + <> + + {lookup.status !== 'idle' && ( +
+ {lookup.status === 'loading' && 'Looking up the Salesforce opportunity…'} + {lookup.status === 'error' && lookup.message} + {lookup.status === 'ready' && ( + <> + + View in Salesforce + + {lookup.opportunity.name && ( + + {` — ${lookup.opportunity.name}`} + + )} + + )} +
+ )} + + )} + + ({ label: value, value }))} + isClearable={!props.required} + required={props.required} + /> + {smu === 'Others' && ( + + )} + + + ) +} diff --git a/src/apps/work/src/lib/components/index.ts b/src/apps/work/src/lib/components/index.ts index cf96cd2af..93daa28ce 100644 --- a/src/apps/work/src/lib/components/index.ts +++ b/src/apps/work/src/lib/components/index.ts @@ -35,6 +35,7 @@ export * from './ProjectsShowcaseFilter' export * from './ProjectsTable' export * from './ResourceAddModal' export * from './ResourcesTable' +export * from './ShowcasePostDetails' export * from './ShowcasePostPreview' export * from './TerminateAssignmentModal' export * from './SubmissionDuplicatesRow' diff --git a/src/apps/work/src/lib/constants/salesforce.constants.ts b/src/apps/work/src/lib/constants/salesforce.constants.ts new file mode 100644 index 000000000..e6e94c892 --- /dev/null +++ b/src/apps/work/src/lib/constants/salesforce.constants.ts @@ -0,0 +1,15 @@ +/** + * Salesforce opportunity record ids: the `006` key prefix followed by 12 or 15 + * case-sensitive alphanumeric characters. + */ +export const SALESFORCE_OPPORTUNITY_ID_PATTERN = /^006[a-zA-Z0-9]{12}(?:[a-zA-Z0-9]{3})?$/ + +/** + * Checks whether a value can be sent to the opportunity lookup endpoint. + * @param opportunityId Raw input from the project form. + * @returns True for a 15 or 18 character Salesforce opportunity id. + * @throws Does not throw. + */ +export function isSalesforceOpportunityId(opportunityId: string | undefined): boolean { + return !!opportunityId && SALESFORCE_OPPORTUNITY_ID_PATTERN.test(opportunityId) +} diff --git a/src/apps/work/src/lib/constants/showcase.constants.ts b/src/apps/work/src/lib/constants/showcase.constants.ts new file mode 100644 index 000000000..7d11a870f --- /dev/null +++ b/src/apps/work/src/lib/constants/showcase.constants.ts @@ -0,0 +1,39 @@ +/** + * SMU options, named to match the Salesforce `Reporting SMU` codes so an + * imported opportunity maps onto a project without translation. + */ +export const SMU_VALUES = ['APME', 'EURP', 'AMR1', 'AMR2', 'Others'] + +/** + * SMU labels used before the Salesforce naming alignment, mapped to their + * current option so existing projects keep rendering a valid selection. + */ +export const LEGACY_SMU_VALUES: Readonly<{ [legacyValue: string]: string }> = { + Americas1: 'AMR1', + Americas2: 'AMR2', + APMEA: 'APME', + Europe: 'EURP', +} + +/** + * Maps a stored SMU label onto a currently supported option. + * @param value SMU value loaded from a project or showcase post. + * @returns The supported option, or the original value when it is not a legacy label. + * @throws Does not throw. + */ +export function normalizeSmuValue(value: string | undefined): string { + if (!value) { + return '' + } + + return LEGACY_SMU_VALUES[value] ?? value +} + +export const SHOWCASE_TYPE_VALUES = [ + 'Open Innovation', + 'Private POD Delivery', + 'Flexi-Talent Supply', + 'AI Data Licensing', +] + +export const SHOWCASE_CURRENT_STATUS_VALUES = ['Delivered', 'In Delivery', 'On-Hold', 'Planned'] diff --git a/src/apps/work/src/lib/models/ChallengeEditor.model.ts b/src/apps/work/src/lib/models/ChallengeEditor.model.ts index 3b39d0c5a..1652ab78f 100644 --- a/src/apps/work/src/lib/models/ChallengeEditor.model.ts +++ b/src/apps/work/src/lib/models/ChallengeEditor.model.ts @@ -37,6 +37,8 @@ export interface ChallengeEditorFormData extends Omit, 'id' | copilot?: string description: string discussionForum?: boolean + /** Pending file type included in final-deliverables metadata when creating or saving. */ + finalDeliverable?: string funChallenge?: boolean wiproAllowed?: boolean groups?: string[] diff --git a/src/apps/work/src/lib/models/Project.model.ts b/src/apps/work/src/lib/models/Project.model.ts index 2ed1d07e5..c450a3008 100644 --- a/src/apps/work/src/lib/models/Project.model.ts +++ b/src/apps/work/src/lib/models/Project.model.ts @@ -8,7 +8,16 @@ import { TaasJob } from './TaasJob.model' export type ProjectStatus = typeof PROJECT_STATUSES[number]['value'] -export interface ProjectDetails extends Record { +export interface ProjectMetadata { + customer?: string + smu?: string + smuOther?: string + dealCloseDate?: string + /** Salesforce opportunity the project details were imported from. */ + salesforceOpportunityId?: string +} + +export interface ProjectDetails extends Record, ProjectMetadata { displayMemberPaymentDetailsToCopilots?: boolean taasDefinition?: { taasJobs?: TaasJob[] diff --git a/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts b/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts index 9b49cd787..fa4d314fa 100644 --- a/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts +++ b/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts @@ -1,6 +1,7 @@ import { SortOrder } from '../utils/sorting.utils' import { PaginationModel } from './Pagination.model' +import { ProjectMetadata } from './Project.model' export interface ProjectShowcasePostTaxonomyItem { id: string @@ -28,7 +29,17 @@ export interface ProjectShowcasePostChallengeMetadataItem { countries: string[] } -export interface ProjectShowcasePost { +export interface ShowcaseMetadata extends ProjectMetadata { + type?: string + challenge?: string + businessImpact?: string + keyWin?: string + currentStatus?: string + owner?: string + sendToWin?: boolean +} + +export interface ProjectShowcasePost extends ShowcaseMetadata { id: string title: string content?: string @@ -38,7 +49,7 @@ export interface ProjectShowcasePost { createdAt: string createdById: number createdByHandle?: string - publishedAt?: number + publishedAt?: number | string publishedBy?: string industries: ProjectShowcasePostTaxonomyItem[] categories: ProjectShowcasePostTaxonomyItem[] diff --git a/src/apps/work/src/lib/models/index.ts b/src/apps/work/src/lib/models/index.ts index cbd7b96dd..c42ac45cb 100644 --- a/src/apps/work/src/lib/models/index.ts +++ b/src/apps/work/src/lib/models/index.ts @@ -18,6 +18,7 @@ export type { ProjectPhaseProduct, Project, ProjectDetails, + ProjectMetadata, ProjectFilters, ProjectStatus as ProjectStatusValue, ProjectType, diff --git a/src/apps/work/src/lib/schemas/project-editor.schema.ts b/src/apps/work/src/lib/schemas/project-editor.schema.ts index dcf9e3a88..cd4cd94a5 100644 --- a/src/apps/work/src/lib/schemas/project-editor.schema.ts +++ b/src/apps/work/src/lib/schemas/project-editor.schema.ts @@ -1,8 +1,11 @@ import * as yup from 'yup' import { PROJECT_STATUS } from '../constants' +import { ProjectMetadata } from '../models/Project.model' -export interface ProjectEditorSchemaData { +import { projectMetadataSchemaFields } from './project-metadata.schema' + +export interface ProjectEditorSchemaData extends ProjectMetadata { billingAccountId?: string name: string description: string @@ -14,12 +17,20 @@ export interface ProjectEditorSchemaData { groups?: string[] } +/** + * Validates project edits and creation, including optional shared showcase metadata. + * @param isEdit Whether an existing project is being edited. + * @param canManage Whether the user can change project status. + * @returns The project form schema; validation rejects invalid or missing required values. + * @throws Does not throw when constructing the schema. + */ export function createProjectEditorSchema( isEdit: boolean, canManage: boolean, ): yup.ObjectSchema { return yup .object({ + ...projectMetadataSchemaFields(false), billingAccountId: yup .string() .optional(), diff --git a/src/apps/work/src/lib/schemas/project-metadata.schema.ts b/src/apps/work/src/lib/schemas/project-metadata.schema.ts new file mode 100644 index 000000000..d676eeb12 --- /dev/null +++ b/src/apps/work/src/lib/schemas/project-metadata.schema.ts @@ -0,0 +1,59 @@ +import * as yup from 'yup' + +import { SALESFORCE_OPPORTUNITY_ID_PATTERN } from '../constants/salesforce.constants' +import { LEGACY_SMU_VALUES, SMU_VALUES } from '../constants/showcase.constants' + +/** + * Checks a date-only value without converting its calendar day to a local timezone. + * @param value Optional YYYY-MM-DD input from the project or showcase form. + * @returns True for empty optional values or an existing calendar date. + * @throws Does not throw. + */ +function isCalendarDate(value: string | undefined): boolean { + if (!value) return true + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false + const date = new Date(`${value}T00:00:00.000Z`) + return Number.isFinite(date.getTime()) && date.toISOString() + .slice(0, 10) === value +} + +/** + * Builds shared project metadata validation for the project editor and showcase form. + * @param required Whether Customer, SMU and Deal Close Date must be populated. + * @returns Yup fields; Other SMU is always required when Others is selected. + * @throws Does not throw; Yup reports invalid input when the form is validated. + */ +export function projectMetadataSchemaFields(required: boolean): { + customer: yup.StringSchema + dealCloseDate: yup.StringSchema + salesforceOpportunityId: yup.StringSchema + smu: yup.StringSchema + smuOther: yup.StringSchema +} { + const text = yup.string() + .trim() + .max(255) + // Projects saved before the Salesforce naming alignment still hold the legacy labels. + const smu = yup.string() + .oneOf([...SMU_VALUES, ...Object.keys(LEGACY_SMU_VALUES), '']) + const date = yup.string() + .test('calendar-date', 'Enter a valid deal close date', isCalendarDate) + return { + customer: required ? text.required('Customer is required') : text.optional(), + dealCloseDate: required ? date.required('Deal Close Date is required') : date.optional(), + salesforceOpportunityId: yup.string() + .trim() + .test( + 'salesforce-opportunity-id', + 'Enter a valid 15 or 18 character Salesforce Opportunity ID', + value => !value || SALESFORCE_OPPORTUNITY_ID_PATTERN.test(value), + ) + .optional(), + smu: required ? smu.required('SMU is required') : smu.optional(), + smuOther: text.when('smu', { + is: 'Others', + otherwise: schema => schema.optional(), + then: schema => schema.required('Other SMU is required'), + }), + } +} diff --git a/src/apps/work/src/lib/schemas/showcase-post.schema.spec.ts b/src/apps/work/src/lib/schemas/showcase-post.schema.spec.ts new file mode 100644 index 000000000..397137259 --- /dev/null +++ b/src/apps/work/src/lib/schemas/showcase-post.schema.spec.ts @@ -0,0 +1,32 @@ +import { showcasePostSchema } from './showcase-post.schema' + +describe('Showcase metadata validation', () => { + const valid = { + categoryIds: ['1'], + content: 'The solution', + customer: 'Customer', + dealCloseDate: '2024-02-29', + industryIds: ['1'], + smu: 'Europe', + title: 'Title', + type: 'Open Innovation', + } + + it('requires showcase metadata and accepts a real leap day', async () => { + await expect(showcasePostSchema.isValid(valid)).resolves.toBe(true) + for (const field of ['customer', 'dealCloseDate', 'smu', 'type']) { + // eslint-disable-next-line no-await-in-loop + await expect(showcasePostSchema.isValid({ ...valid, [field]: '' })).resolves.toBe(false) + } + }) + + it('requires the custom SMU only when Others is selected', async () => { + await expect(showcasePostSchema.isValid({ ...valid, smu: 'Others' })).resolves.toBe(false) + await expect(showcasePostSchema.isValid({ ...valid, smu: 'Others', smuOther: 'Custom' })).resolves.toBe(true) + }) + + it.each(['2026-02-29', '2026-04-31', 'not a date'])('rejects the invalid calendar date %s', async dealCloseDate => { + await expect(showcasePostSchema.isValid({ ...valid, dealCloseDate })).resolves.toBe(false) + }) + +}) diff --git a/src/apps/work/src/lib/schemas/showcase-post.schema.ts b/src/apps/work/src/lib/schemas/showcase-post.schema.ts new file mode 100644 index 000000000..4f0189f92 --- /dev/null +++ b/src/apps/work/src/lib/schemas/showcase-post.schema.ts @@ -0,0 +1,32 @@ +import * as yup from 'yup' + +import { SHOWCASE_CURRENT_STATUS_VALUES, SHOWCASE_TYPE_VALUES } from '../constants/showcase.constants' + +import { projectMetadataSchemaFields } from './project-metadata.schema' + +export const showcasePostSchema = yup.object({ + ...projectMetadataSchemaFields(true), + categoryIds: yup.array() + .of(yup.string()) + .min(1, 'Select at least one category.'), + content: yup.string() + .trim() + .required('The Solution is required.'), + currentStatus: yup.string() + .oneOf([...SHOWCASE_CURRENT_STATUS_VALUES, '']), + industryIds: yup.array() + .of(yup.string()) + .min(1, 'Select at least one industry.'), + keyWin: yup.string() + .trim() + .max(255), + owner: yup.string() + .trim() + .max(255), + title: yup.string() + .trim() + .required('Title is required.'), + type: yup.string() + .oneOf(SHOWCASE_TYPE_VALUES) + .required('Type is required.'), +}) diff --git a/src/apps/work/src/lib/services/index.ts b/src/apps/work/src/lib/services/index.ts index 4c88e547a..85447b60c 100644 --- a/src/apps/work/src/lib/services/index.ts +++ b/src/apps/work/src/lib/services/index.ts @@ -36,6 +36,7 @@ export type { ProjectSummary, } from './projects.service' export * from './resources.service' +export * from './salesforce-opportunities.service' export * from './reviews.service' export * from './skills.service' export * from './submissions.service' diff --git a/src/apps/work/src/lib/services/project-showcase-posts.service.ts b/src/apps/work/src/lib/services/project-showcase-posts.service.ts index 84f5f6c2d..62f825691 100644 --- a/src/apps/work/src/lib/services/project-showcase-posts.service.ts +++ b/src/apps/work/src/lib/services/project-showcase-posts.service.ts @@ -18,6 +18,7 @@ import type { ProjectShowcasePostIndustry, ProjectShowcasePostMedia, ProjectShowcasePostTaxonomyItem, + ShowcaseMetadata, } from '../models' import { fetchMembersByUserIds } from './members.service' @@ -102,6 +103,12 @@ function buildProjectShowcasePostsUrl( return `${urlBase}?${query.toString()}` } +/** + * Fetches a paginated showcase list including shared metadata and owner values. + * @param params Project, pagination, sorting and taxonomy filters. + * @returns Normalized posts and pagination data, enriched with creator handles. + * @throws A normalized error when a required API request fails. + */ export async function fetchProjectShowcasePosts( params: FetchProjectShowcasePostsParams, ): Promise { @@ -110,38 +117,9 @@ export async function fetchProjectShowcasePosts( buildProjectShowcasePostsUrl(params), ) - const posts = (response.data || []).map((post: any) => ({ - categories: Array.isArray(post.categories) - ? post.categories.map((category: any) => ({ - id: String(category.id), - name: String(category.name || ''), - })) - : [], - content: String(post.content || ''), - createdAt: String(post.createdAt || ''), - createdByHandle: post.createdByHandle !== undefined && post.createdByHandle !== null - ? String(post.createdByHandle) - : undefined, - createdById: Number(post.createdById || 0), - id: String(post.id), - industries: Array.isArray(post.industries) - ? post.industries.map((industry: any) => ({ - id: String(industry.id), - name: String(industry.name || ''), - })) - : [], - media: Array.isArray(post.media) - ? post.media.map((mediaItem: any) => ({ - id: String(mediaItem.id || ''), - type: String(mediaItem.type || ''), - url: String(mediaItem.url || ''), - })) - .filter((item: any) => item.url) - : [], - projectId: String(post.projectId || ''), - status: String(post.status || ''), - title: String(post.title || ''), - })) + const posts = (response.data || []) + .map(normalizeProjectShowcasePost) + .filter((post: ProjectShowcasePostDetails | undefined): post is ProjectShowcasePostDetails => !!post) const creatorUserIds: string[] = Array.from(new Set( posts @@ -225,6 +203,12 @@ function normalizeProjectShowcasePostMediaArray(value: unknown): ProjectShowcase .filter(item => item.url) } +/** + * Normalizes API post data and the shared project metadata for forms and previews. + * @param value Raw projects API response. + * @returns A showcase post or undefined when the response is not an object. + * @throws Does not throw for valid JSON responses. + */ function normalizeProjectShowcasePost(value: unknown): ProjectShowcasePostDetails | undefined { if (typeof value !== 'object' || value === null) { return undefined @@ -233,7 +217,9 @@ function normalizeProjectShowcasePost(value: unknown): ProjectShowcasePostDetail const post = value as Record return { + businessImpact: normalizeStringOrUndefined(post.businessImpact), categories: normalizeTaxonomyArray(post.categories), + challenge: normalizeStringOrUndefined(post.challenge), challengeIds: Array.isArray(post.challengeIds) ? post.challengeIds.map((item: any) => String(item)) : [], @@ -242,13 +228,24 @@ function normalizeProjectShowcasePost(value: unknown): ProjectShowcasePostDetail createdAt: normalizeString(post.createdAt), createdByHandle: normalizeStringOrUndefined(post.createdByHandle), createdById: Number(post.createdById || 0), + currentStatus: normalizeStringOrUndefined(post.currentStatus), + customer: normalizeStringOrUndefined(post.customer), + dealCloseDate: normalizeStringOrUndefined(post.dealCloseDate), id: normalizeString(post.id), industries: normalizeTaxonomyArray(post.industries), + keyWin: normalizeStringOrUndefined(post.keyWin), media: normalizeProjectShowcasePostMediaArray(post.media), + owner: normalizeStringOrUndefined(post.owner), projectId: normalizeStringOrUndefined(post.projectId), projectTitle: String(post.projectTitle || ''), + publishedAt: normalizeStringOrUndefined(post.publishedAt), + publishedBy: normalizeStringOrUndefined(post.publishedBy), + sendToWin: post.sendToWin === true, + smu: normalizeStringOrUndefined(post.smu), + smuOther: normalizeStringOrUndefined(post.smuOther), status: normalizeString(post.status), title: normalizeString(post.title), + type: normalizeStringOrUndefined(post.type), } } @@ -272,9 +269,16 @@ export async function fetchProjectShowcasePost( } } +/** + * Creates a showcase and saves its shared project metadata atomically. + * @param projectId Project owning the showcase post. + * @param payload Showcase fields, project metadata, and WIN opt-in. + * @returns The saved showcase post. + * @throws A normalized API error when validation or saving fails. + */ export async function createProjectShowcasePost( projectId: string, - payload: { + payload: ShowcaseMetadata & { title: string content: string industryIds: string[] @@ -300,10 +304,18 @@ export async function createProjectShowcasePost( } } +/** + * Updates supplied post fields and shared project metadata; omitted fields stay unchanged. + * @param projectId Project owning the showcase post. + * @param postId Showcase post to update. + * @param payload Showcase fields, project metadata, and WIN opt-in. + * @returns The saved showcase post. + * @throws A normalized API error when validation or saving fails. + */ export async function updateProjectShowcasePost( projectId: string, postId: string, - payload: { + payload: ShowcaseMetadata & { title?: string content?: string industryIds?: string[] diff --git a/src/apps/work/src/lib/services/salesforce-opportunities.service.ts b/src/apps/work/src/lib/services/salesforce-opportunities.service.ts new file mode 100644 index 000000000..1505e1309 --- /dev/null +++ b/src/apps/work/src/lib/services/salesforce-opportunities.service.ts @@ -0,0 +1,52 @@ +import { xhrGetAsync } from '~/libs/core' + +import { PROJECTS_API_URL } from '../constants' + +export interface SalesforceOpportunity { + id: string + name: string + description?: string + /** Subcontracting End Customer account name. */ + customer?: string + /** Reporting SMU already mapped onto a supported SMU option. */ + smu?: string + /** Raw Reporting SMU, populated only when `smu` is `Others`. */ + smuOther?: string + reportingSmu?: string + /** Close Date as a YYYY-MM-DD calendar date. */ + closeDate?: string + stageName?: string + /** Deep link to the opportunity record in Salesforce. */ + url: string +} + +/** + * Reads a Salesforce opportunity through the role-protected Projects API. + * @param opportunityId 15 or 18 character Salesforce opportunity id. + * @param signal Cancels an obsolete lookup when the input changes or the form unmounts. + * @returns The opportunity fields used to populate project details. + * @throws Propagates network, authorization and sanitized Projects API errors. + */ +export function fetchSalesforceOpportunity( + opportunityId: string, + signal?: AbortSignal, +): Promise { + const url = `${PROJECTS_API_URL}/salesforce/opportunities/${encodeURIComponent(opportunityId)}` + return xhrGetAsync(url, undefined, { signal }) +} + +/** + * Explains an opportunity lookup failure without exposing transport internals. + * @param error Unknown request rejection. + * @returns User-facing recovery guidance for the inline field error. + * @throws Does not throw. + */ +export function salesforceOpportunityErrorMessage(error: unknown): string { + const status = (error as { response?: { status?: number } })?.response?.status + if (status === 400) return 'Enter a valid 15 or 18 character Salesforce Opportunity ID.' + if (status === 401) return 'Your session has expired. Sign in again to look up the opportunity.' + if (status === 403) return 'You do not have permission to read Salesforce opportunities.' + if (status === 404) return 'No Salesforce opportunity was found for that ID.' + if (status === 503) return 'The Salesforce connection is not configured yet. Contact your administrator.' + return 'We could not reach Salesforce. Please try again.' +} 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 9f3ce23ae..bd646fd94 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 @@ -24,6 +24,31 @@ jest.mock('~/config', () => ({ }), }), { virtual: true }) +describe('final deliverables save mapping', () => { + it.each<[string, string[]]>([ + [' PDF ', ['PNG', 'PDF']], + [' png ', ['PNG']], + [' ', ['PNG']], + ])('includes pending input %j without duplicating or losing existing metadata', (finalDeliverable, expected) => { + const formData = transformChallengeToFormData({ + metadata: [ + { name: 'fileTypes', value: '["PNG"]' }, + { name: 'allowStockArt', value: 'true' }, + ], + }) + const saved = transformFormDataToChallenge({ ...formData, finalDeliverable }) + + expect(saved.metadata) + .toEqual(expect.arrayContaining([ + { name: 'fileTypes', value: JSON.stringify(expected) }, + { name: 'allowStockArt', value: 'true' }, + ])) + expect(saved).not.toHaveProperty('finalDeliverable') + expect(transformChallengeToFormData(saved).finalDeliverable) + .toBe('') + }) +}) + describe('formatLastSaved', () => { it('reports when a challenge has not been saved', () => { expect(formatLastSaved()) 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 6c5118310..ea533975d 100644 --- a/src/apps/work/src/lib/utils/challenge-editor.utils.ts +++ b/src/apps/work/src/lib/utils/challenge-editor.utils.ts @@ -36,6 +36,7 @@ import { metadataToBoolean, metadataToGiteaTeams, } from './metadata.utils' +import { commitPendingFinalDeliverable } from './final-deliverables.utils' interface BillingInfo { billingAccountId?: number | string @@ -1031,6 +1032,7 @@ export function transformChallengeToFormData( createdBy: normalizeOptionalString(challenge?.createdBy), description, discussionForum: normalizeOptionalBoolean(challenge?.discussionForum), + finalDeliverable: '', funChallenge: normalizeOptionalBoolean(challenge?.funChallenge) || false, giteaTeams: metadataToGiteaTeams(metadata, GITEA_METADATA_FIELD), groups: normalizeStringArray(challenge?.groups), @@ -1087,7 +1089,7 @@ export function transformFormDataToChallenge( && formData.phases.length > 0 const isSchedulingEnabled = isSchedulingApiEnabled(formData.legacy?.useSchedulingAPI) || hasEditableSchedule - const metadataWithoutMilestone = normalizeMetadataEntries(formData.metadata) + const metadataWithoutMilestone = normalizeMetadataEntries(commitPendingFinalDeliverable(formData).metadata) .filter(metadataEntry => !MILESTONE_METADATA_KEYS.includes(metadataEntry.name)) const metadataWithTestChallenge = booleanToMetadata( metadataWithoutMilestone, diff --git a/src/apps/work/src/lib/utils/final-deliverables.utils.ts b/src/apps/work/src/lib/utils/final-deliverables.utils.ts new file mode 100644 index 000000000..dbc1faf3d --- /dev/null +++ b/src/apps/work/src/lib/utils/final-deliverables.utils.ts @@ -0,0 +1,90 @@ +import { ChallengeEditorFormData } from '../models' + +import { getMetadataValue, setMetadataValue } from './metadata.utils' + +export const FILE_TYPES_METADATA_NAME = 'fileTypes' + +/** + * Normalizes a single final-deliverable label coming from either user input or saved metadata. + * + * @param value raw file-type value from the input or metadata payload. + * @returns the trimmed file-type label, or `undefined` when the value is empty. + * @throws Does not throw. + */ +export function normalizeFileTypeValue(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + + const normalizedValue = value.trim() + + return normalizedValue || undefined +} + +/** + * Parses the persisted `fileTypes` challenge metadata into a unique, display-ready list. + * + * @param value serialized challenge metadata entry for final deliverables. + * @returns normalized file-type labels in the saved order, excluding empty or duplicate values. + * @throws Does not throw; invalid JSON produces an empty list. + */ +export function parseFileTypesMetadata(value: string | undefined): string[] { + if (!value) { + return [] + } + + try { + const parsedValue = JSON.parse(value) as unknown + + if (!Array.isArray(parsedValue)) { + return [] + } + + const addedFileTypes = new Set() + + return parsedValue + .map(item => normalizeFileTypeValue(item)) + .filter((item): item is string => { + if (!item) { + return false + } + + const normalizedKey = item.toLowerCase() + + if (addedFileTypes.has(normalizedKey)) { + return false + } + + addedFileTypes.add(normalizedKey) + + return true + }) + } catch { + return [] + } +} + +/** + * Includes a pending final-deliverable input in create and save payloads. + * + * @param formData editor values, including the unfinished file-type input. + * @returns form values with a unique, trimmed file type in metadata and a cleared input. + * @throws Does not throw; malformed saved metadata is treated as an empty list. + */ +export function commitPendingFinalDeliverable(formData: ChallengeEditorFormData): ChallengeEditorFormData { + const value = normalizeFileTypeValue(formData.finalDeliverable) + if (!value) return formData + + const fileTypes = parseFileTypesMetadata(getMetadataValue(formData.metadata, FILE_TYPES_METADATA_NAME)) + const isDuplicate = fileTypes.some(fileType => fileType.toLowerCase() === value.toLowerCase()) + + return { + ...formData, + finalDeliverable: '', + metadata: isDuplicate ? formData.metadata : setMetadataValue( + formData.metadata, + FILE_TYPES_METADATA_NAME, + JSON.stringify([...fileTypes, value]), + ), + } +} diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 7ce237844..e0a58d3ad 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -92,7 +92,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. The simplified Design Challenge review section fetches defaults for the selected timeline template, resolves the API's phase-name-only defaults against the challenge phases, then repairs missing, duplicate, and stale hidden reviewer rows while exposing the Screening and Checkpoint Screening member selectors. Checkpoint Review, Review, and Approval are private and automatically assigned to the selected copilot during save; Design Challenge creation and saving highlight the Copilot field when no copilot is selected. On the full human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. The full Design reviewer editor keeps the public review opportunity checkbox disabled and unchecked. Screening and Checkpoint Screening member selectors remain available but are optional so a copilot can assign the Screener or Checkpoint Screener after launch. For Design `Challenge` challenges the advanced view also drops the required marker from the Checkpoint Review, Review, and Approval member selectors, because those private phases are assigned to the selected copilot during save. - `Submission Settings`: shown for Design `Challenge` and Design `First2Finish` types, and contains the final-deliverables, stock-art, and submission-limit compatibility fields. - `RegisteredMemberDownloadField`: shown in Advanced Options for every created challenge type. The radio group persists `allowAllRegistrantsToDownloadWinningSubmissions` as the exact string `true` for all challenge registrants or `false` for passing submitters only. New Development challenges default to passing submitters; other new challenges, including Design, default to all registrants. Existing challenges without the metadata retain passing-submitter-only access. -- `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages. +- `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages. Add File Type and Enter append a trimmed, unique file type. The pending input also belongs to the form: create, draft save, autosave, and launch include it in metadata without requiring an Add click, and a successful manual save restores the saved list with an empty input. - `MaximumSubmissionsField`: submission-limit editor with `Unlimited` (the default) and `Limited` modes. Limited mode reveals a numeric count field, and both modes persist the legacy `submissionLimit` JSON metadata contract consumed by challenge and review applications. The selection and count are display-only fields, so they are re-seeded from the persisted metadata on every render; that keeps the saved limit visible after the challenge loads and after a draft save resets the form. Existing limited values are restored without being overwritten, including when a draft-save response omits submission-limit metadata, while missing or malformed metadata is normalized to unlimited after initial resource hydration so copilot restoration completes before autosave/manual-save treats the default as a user change. Once the challenge has at least one contest or checkpoint submission the mode and count become read-only, because review scorecards are created from the limit that applied when members submitted. Limited mode requires a count of at least 1: the challenge editor schema validates the persisted `submissionLimit` metadata and reports a missing count on the visible `Limit count` field, so saving, autosaving, and launching are blocked until the count is entered. The rule is skipped when the limit is not configurable, which keeps non-Design challenges and challenges that already have submissions saveable. - `ChallengeDescriptionField`: public markdown spec editor with a `Copy spec` action that copies the current Markdown in both edit and read-only view modes. diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx index f5811fd10..fb011c8c7 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -92,6 +92,7 @@ import { getMetadataValue, setMetadataValue, } from '../../../../lib/utils/metadata.utils' +import { commitPendingFinalDeliverable } from '../../../../lib/utils/final-deliverables.utils' import { isReviewerAssignmentOptional } from '../../../../lib/utils/reviewer.utils' import { getProjectBillingAccountChallengeErrorMessage, @@ -3359,7 +3360,7 @@ export const ChallengeEditorForm: FC = ( setSaveValidationError(undefined) try { - const formData = getValues() + const formData = commitPendingFinalDeliverable(getValues()) const resolvedProjectBillingAccount = await resolveProjectBillingAccount() const selectedRoundType = getCreateRoundType(formData.roundType, formElementRef.current) const createProjectId = resolveRequiredCreateProjectId(formData.projectId, fallbackProjectId) @@ -3653,7 +3654,7 @@ export const ChallengeEditorForm: FC = ( } const formDataWithProjectBilling = applyProjectBillingToChallengeFormData( - formDataToSave, + commitPendingFinalDeliverable(formDataToSave), resolvedProjectBillingAccount, ) const payload = transformFormDataToChallenge({ diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/FinalDeliverablesField/FinalDeliverablesField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/FinalDeliverablesField/FinalDeliverablesField.spec.tsx index b40f90258..52ecd4951 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/FinalDeliverablesField/FinalDeliverablesField.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/FinalDeliverablesField/FinalDeliverablesField.spec.tsx @@ -5,6 +5,7 @@ import { } from 'react' import { render, + RenderResult, screen, } from '@testing-library/react' import '@testing-library/jest-dom' @@ -16,6 +17,7 @@ import { } from 'react-hook-form' import { ChallengeEditorFormData } from '../../../../../lib/models' +import { commitPendingFinalDeliverable } from '../../../../../lib/utils/final-deliverables.utils' import { FinalDeliverablesField } from './FinalDeliverablesField' @@ -51,6 +53,7 @@ interface TestHarnessProps { name: string value: unknown }> + onSave?: (formData: ChallengeEditorFormData) => void } const MetadataWatcher: FC = () => { @@ -74,15 +77,53 @@ const TestHarness: FC = (props: TestHarnessProps) => { }, }) + /** Submits the editor values using the create/save metadata normalization. */ + function handleSave(): void { + props.onSave?.(commitPendingFinalDeliverable(formMethods.getValues())) + } + return ( + {props.onSave && ( + + )} ) } describe('FinalDeliverablesField', () => { + it('saves a pending file type when Save is clicked and restores it from saved metadata', async () => { + const user = userEvent.setup() + const onSave = jest.fn() + const { unmount }: RenderResult = render( + , + ) + + await user.type(screen.getByRole('textbox'), ' PNG ') + await user.click(screen.getByRole('button', { name: 'Save challenge' })) + + expect(onSave) + .toHaveBeenCalledWith(expect.objectContaining({ + metadata: [ + { name: 'allowStockArt', value: 'true' }, + { name: 'fileTypes', value: '["PNG"]' }, + ], + })) + unmount() + render() + expect(screen.getByText('PNG')) + .toBeInTheDocument() + expect(screen.getByRole('textbox')) + .toHaveValue('') + }) + it('adds unique file types to the saved metadata payload', async () => { const user = userEvent.setup() diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/FinalDeliverablesField/FinalDeliverablesField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/FinalDeliverablesField/FinalDeliverablesField.tsx index 28f482791..5289905a3 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/FinalDeliverablesField/FinalDeliverablesField.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/FinalDeliverablesField/FinalDeliverablesField.tsx @@ -5,7 +5,6 @@ import { MouseEvent, useCallback, useMemo, - useState, } from 'react' import { useFormContext, @@ -23,75 +22,21 @@ import { getMetadataValue, setMetadataValue, } from '../../../../../lib/utils/metadata.utils' +import { + FILE_TYPES_METADATA_NAME, + normalizeFileTypeValue, + parseFileTypesMetadata, +} from '../../../../../lib/utils/final-deliverables.utils' import styles from './FinalDeliverablesField.module.scss' -const FILE_TYPES_METADATA_NAME = 'fileTypes' - -/** - * Normalizes a single final-deliverable label coming from either user input or saved metadata. - * - * @param value raw file-type value from the input or metadata payload. - * @returns the trimmed file-type label, or `undefined` when the value is empty. - */ -function normalizeFileTypeValue(value: unknown): string | undefined { - if (typeof value !== 'string') { - return undefined - } - - const normalizedValue = value.trim() - - return normalizedValue || undefined -} - -/** - * Parses the persisted `fileTypes` challenge metadata into a unique, display-ready list. - * - * @param value serialized challenge metadata entry for final deliverables. - * @returns normalized file-type labels in the saved order, excluding empty or duplicate values. - */ -function parseFileTypesMetadata(value: string | undefined): string[] { - if (!value) { - return [] - } - - try { - const parsedValue = JSON.parse(value) as unknown - - if (!Array.isArray(parsedValue)) { - return [] - } - - const addedFileTypes = new Set() - - return parsedValue - .map(item => normalizeFileTypeValue(item)) - .filter((item): item is string => { - if (!item) { - return false - } - - const normalizedKey = item.toLowerCase() - - if (addedFileTypes.has(normalizedKey)) { - return false - } - - addedFileTypes.add(normalizedKey) - - return true - }) - } catch { - return [] - } -} - /** * Edits the design challenge final-deliverables metadata stored under `fileTypes`. * * The field mirrors the legacy work-manager behavior used on design challenge drafts: * users can add or remove deliverable file types, and the component persists them as the - * JSON-serialized `fileTypes` metadata entry on the challenge form. + * JSON-serialized `fileTypes` metadata entry on the challenge form. The pending input + * also belongs to the form so create and save actions can include it without an Add click. * * @returns the final-deliverables editor UI. */ @@ -102,7 +47,10 @@ export const FinalDeliverablesField: FC = () => { control: dynamicFormControl, name: 'metadata', }) as ChallengeMetadata[] | undefined - const [newFileType, setNewFileType] = useState('') + const newFileType = useWatch({ + control: dynamicFormControl, + name: 'finalDeliverable', + }) as string | undefined const fileTypes = useMemo( () => parseFileTypesMetadata(getMetadataValue(metadata, FILE_TYPES_METADATA_NAME)), @@ -129,8 +77,8 @@ export const FinalDeliverablesField: FC = () => { }, [formContext, metadata]) const handleInputChange = useCallback((event: ChangeEvent): void => { - setNewFileType(event.target.value) - }, []) + formContext.setValue('finalDeliverable', event.target.value, { shouldDirty: true }) + }, [formContext]) const handleAddFileType = useCallback((): void => { if (!normalizedNewFileType || isDuplicateValue) { @@ -141,9 +89,10 @@ export const FinalDeliverablesField: FC = () => { ...fileTypes, normalizedNewFileType, ]) - setNewFileType('') + formContext.setValue('finalDeliverable', '', { shouldDirty: true }) }, [ fileTypes, + formContext, isDuplicateValue, normalizedNewFileType, updateFileTypes, @@ -213,7 +162,7 @@ export const FinalDeliverablesField: FC = () => { onKeyDown={handleInputKeyDown} placeholder='Add final deliverable file type' type='text' - value={newFileType} + value={newFileType || ''} />
+
+ +
+
({ AppSubdomain: { work: 'work' }, EnvironmentConfig: {} }), { virtual: true }) +jest.mock('filestack-js', () => ({ init: jest.fn() })) +jest.mock('react-router-dom', () => ({ useParams: () => ({ projectId: '123' }) })) +jest.mock('~/libs/ui', () => ({ + BaseModal: (props: any): JSX.Element => (props.open ?
{props.children}
: <>), + Button: (props: any): JSX.Element => ( + + ), + LoadingSpinner: (): JSX.Element =>
, + useConfirmationModal: () => ({}), +}), { virtual: true }) +jest.mock('../../../lib/contexts', () => { + const React: typeof import('react') = jest.requireActual('react') + return { WorkAppContext: React.createContext({ isAdmin: true, loginUserInfo: { userId: 42 }, userRoles: [] }) } +}) +jest.mock('../../../lib/components', () => ({ + ErrorMessage: (props: any): JSX.Element =>
{props.message}
, + Pagination: (): JSX.Element =>
, + ProjectPageWrapper: (props: any): JSX.Element => ( +
+ {props.headerActions} + {props.children} +
+ ), + ProjectsShowcaseFilter: (): JSX.Element =>
, + ShowcasePostPreview: (): JSX.Element =>
, +})) +jest.mock('../../../lib/components/form', () => { + const forms: typeof import('react-hook-form') = jest.requireActual('react-hook-form') + return { + FormCheckboxField: (props: any): JSX.Element => { + const form = forms.useFormContext() + return + }, + FormMarkdownEditor: (props: any): JSX.Element => { + const form = forms.useFormContext() + return