From 2550fee25fa5a501f5b8f7f5c738f66e49f66aa6 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Sun, 20 Sep 2026 23:08:35 +0300 Subject: [PATCH 01/22] Add button for admins to rebuild recissions --- .../AiReviewsTable/AiReviewsTable.tsx | 52 +++++++++++++++++++ .../src/lib/hooks/useFetchAiWorkflowRuns.ts | 19 +++++++ 2 files changed, 71 insertions(+) diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx index 35828dd3b..d4ae927d9 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -22,6 +22,7 @@ import { getAiWorkflowRunsCacheKey, queueAiWorkflowRuns, QueueAiWorkflowRunsResponse, + rebuildSubmissionDecision, retriggerAiWorkflowRun, useFetchAiWorkflowsRuns, useRolePermissions, @@ -39,6 +40,7 @@ import { ChallengeDetailContextModel, } from '../../models' import { ChallengeDetailContext } from '../../contexts' +import { getAiReviewDecisionsCacheKey } from '../../services/aiReview.service' import { AiWorkflowRunStatus } from './AiWorkflowRunStatus' import styles from './AiReviewsTable.module.scss' @@ -334,6 +336,7 @@ const AiReviewsTable: FC = props => { const { mutate }: FullConfiguration = useSWRConfig() const [, setRerunningRunId] = useState(undefined) const [queueingRuns, setQueueingRuns] = useState(false) + const [rebuildingDecision, setRebuildingDecision] = useState(false) /** * Only Copilot, Project Manager, and Admin can see WHO performed the action. @@ -386,6 +389,25 @@ const AiReviewsTable: FC = props => { * - a workflow with no run at all can be queued manually * - an existing (finished) run can be re-run */ + const handleRebuildDecision = useCallback(async (): Promise => { + if (!isAdmin || !props.submission.id) { + return + } + + setRebuildingDecision(true) + try { + await rebuildSubmissionDecision(props.submission.id) + await mutate(getAiReviewDecisionsCacheKey(aiReviewConfig?.id)) + await mutate(getAiWorkflowRunsCacheKey(props.submission.id)) + toast.success('AI decision rebuild triggered successfully.') + } catch (error) { + handleError(error as Error) + toast.error('Failed to trigger AI decision rebuild.') + } finally { + setRebuildingDecision(false) + } + }, [aiReviewConfig?.id, isAdmin, mutate, props.submission.id]) + const buildRowAction = useCallback((row: AiReviewerRow): ReactNode => { if (!isAdmin || row.run?.id === '-1') { return undefined @@ -639,6 +661,36 @@ const AiReviewsTable: FC = props => { return (
+ {isAdmin && currentDecision && ( +
+ + + +
+ )} + {currentDecision?.submissionLocked && lockMessage && (
diff --git a/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts b/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts index 34993e41f..a894576fc 100644 --- a/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts +++ b/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts @@ -318,3 +318,22 @@ export const queueAiWorkflowRuns = async ( { ignoreAiPhaseState, submissionId }, ) ) + +interface RebuildSubmissionDecisionRequest { + submissionId: string +} + +export interface RebuildSubmissionDecisionResponse { + submissionId: string + rebuilt: boolean + message: string +} + +export const rebuildSubmissionDecision = async ( + submissionId: string, +): Promise => ( + xhrPostAsync( + `${TC_API_BASE_URL}/workflows/runs/rebuild-decision`, + { submissionId }, + ) +) From 9322839eb6def363c1eedaa219346716765eea65 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Sun, 20 Sep 2026 23:28:07 +0300 Subject: [PATCH 02/22] Review feedback --- .../AiReviewsTable/AiReviewsTable.module.scss | 21 +++++++++++++++++++ .../AiReviewsTable/AiReviewsTable.tsx | 20 ++++++------------ 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss index dd3991085..21d56ff6f 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss @@ -42,6 +42,27 @@ cursor: pointer; } +.decisionRebuildButton { + align-items: center; + background: #fff; + border: 1px solid #d1d5db; + border-radius: 6px; + cursor: pointer; + display: inline-flex; + justify-content: center; + padding: 6px; + + &:disabled, + &.disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +.decisionRebuildIcon { + color: $black-80; +} + .reviewsTable { width: 100%; border-collapse: collapse; diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx index d4ae927d9..21e629b5a 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -397,7 +397,9 @@ const AiReviewsTable: FC = props => { setRebuildingDecision(true) try { await rebuildSubmissionDecision(props.submission.id) - await mutate(getAiReviewDecisionsCacheKey(aiReviewConfig?.id)) + if (aiReviewConfig?.id) { + await mutate(getAiReviewDecisionsCacheKey(aiReviewConfig.id)) + } await mutate(getAiWorkflowRunsCacheKey(props.submission.id)) toast.success('AI decision rebuild triggered successfully.') } catch (error) { @@ -668,23 +670,13 @@ const AiReviewsTable: FC = props => { type='button' onClick={handleRebuildDecision} disabled={rebuildingDecision} - className={styles.reRunIcon} - style={{ - alignItems: 'center', - background: '#fff', - border: '1px solid #d1d5db', - borderRadius: 6, - cursor: rebuildingDecision ? 'not-allowed' : 'pointer', - display: 'inline-flex', - justifyContent: 'center', - padding: 6, - }} + className={classNames(styles.decisionRebuildButton, rebuildingDecision && styles.disabled)} aria-label={rebuildingDecision ? 'Rebuilding decision' : 'Rebuild decision'} > {rebuildingDecision ? ( - + ) : ( - + )} From d34df5d67941a54a0c0aac77ba806e38c7953c9e Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Sun, 20 Sep 2026 23:58:47 +0300 Subject: [PATCH 03/22] lint --- .../src/lib/components/AiReviewsTable/AiReviewsTable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx index 21e629b5a..0dad9420d 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -398,8 +398,8 @@ const AiReviewsTable: FC = props => { try { await rebuildSubmissionDecision(props.submission.id) if (aiReviewConfig?.id) { - await mutate(getAiReviewDecisionsCacheKey(aiReviewConfig.id)) - } + await mutate(getAiReviewDecisionsCacheKey(aiReviewConfig.id)) + } await mutate(getAiWorkflowRunsCacheKey(props.submission.id)) toast.success('AI decision rebuild triggered successfully.') } catch (error) { From 6174141682882ac41887a431679efce67cb1c863 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 21 Sep 2026 00:07:37 +0300 Subject: [PATCH 04/22] lint --- .../review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx index 0dad9420d..e3c754fbd 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -400,6 +400,7 @@ const AiReviewsTable: FC = props => { if (aiReviewConfig?.id) { await mutate(getAiReviewDecisionsCacheKey(aiReviewConfig.id)) } + await mutate(getAiWorkflowRunsCacheKey(props.submission.id)) toast.success('AI decision rebuild triggered successfully.') } catch (error) { From 63fae376b901762916f0b9566f9a78fad505126c Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 21 Sep 2026 09:37:06 +0300 Subject: [PATCH 05/22] Show timeout status --- .../AiReviewsTable/AiReviewsTable.module.scss | 3 ++- .../AiReviewsTable/AiReviewsTable.tsx | 8 ++++++-- .../AiReviewsTable/AiWorkflowRunStatus.tsx | 17 ++++++++++++++--- .../AiReviewsTable/StatusLabel.module.scss | 6 ++++-- .../components/AiReviewsTable/StatusLabel.tsx | 2 +- .../AiReviewViewer/AiReviewViewer.tsx | 12 +++++++++++- 6 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss index 21d56ff6f..535505f14 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss @@ -149,7 +149,8 @@ } .row-failed, -.row-failed-score { +.row-failed-score, +.row-timeout { .resultCol { color: $red-140; } diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx index e3c754fbd..9ff020d64 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -58,7 +58,7 @@ interface AiReviewerRow { reviewDate?: string run?: Pick score?: number - status?: 'failed' | 'failed-score' | 'passed' | 'pending' | 'cancelled' + status?: 'failed' | 'failed-score' | 'passed' | 'pending' | 'cancelled' | 'timeout' title: string weight?: number workflowId?: string @@ -73,7 +73,7 @@ function normalizeStatus( runStatus?: string | null, score?: number | null, minScore?: number, -): 'failed' | 'failed-score' | 'passed' | 'pending' | 'cancelled' { +): 'failed' | 'failed-score' | 'passed' | 'pending' | 'cancelled' | 'timeout' { if (!runStatus) { return 'pending' } @@ -82,6 +82,10 @@ function normalizeStatus( return 'cancelled' } + if (runStatus === AiWorkflowRunStatusEnum.TIMEOUT) { + return 'timeout' + } + if (aiRunInProgress({ status: runStatus as AiWorkflowRunStatusEnum })) { return 'pending' } diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx index 19166d027..8bc12aefb 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiWorkflowRunStatus.tsx @@ -8,7 +8,7 @@ import StatusLabel from './StatusLabel' interface AiWorkflowRunStatusProps { run?: Pick - status?: 'passed' | 'pending' | 'failed-score' | 'failed' | 'cancelled' | 'human-override' + status?: 'passed' | 'pending' | 'failed-score' | 'failed' | 'cancelled' | 'timeout' | 'human-override' score?: number hideLabel?: boolean showScore?: boolean @@ -17,15 +17,16 @@ interface AiWorkflowRunStatusProps { const aiRunStatus = ( run: Pick, -): 'pending' | 'failed' | 'cancelled' | 'passed' | 'failed-score' => { +): 'pending' | 'failed' | 'cancelled' | 'timeout' | 'passed' | 'failed-score' => { const isInProgress = aiRunInProgress(run) const isFailed = aiRunFailed(run) const isCancelled = run.status === 'CANCELLED' + const isTimedOut = run.status === 'TIMEOUT' const isPassing = ( run.status === 'SUCCESS' && run.score >= (run.workflow.scorecard?.minimumPassingScore ?? 0) ) - return isInProgress ? 'pending' : isCancelled ? 'cancelled' : isFailed ? 'failed' : ( + return isInProgress ? 'pending' : isCancelled ? 'cancelled' : isTimedOut ? 'timeout' : isFailed ? 'failed' : ( isPassing ? 'passed' : 'failed-score' ) } @@ -98,6 +99,16 @@ export const AiWorkflowRunStatus: FC = props => { action={props.action} /> )} + {displayStatus === 'timeout' && ( + } + hideLabel={props.hideLabel} + status={displayStatus} + label='Timeout' + score={score} + action={props.action} + /> + )} {displayStatus === 'human-override' && ( } diff --git a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss index 107b2b86b..13091cbdd 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss +++ b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss @@ -17,7 +17,8 @@ justify-content: center; &.failed, - &.failed-score { + &.failed-score, + &.timeout { color: #C1294F; } @@ -47,7 +48,8 @@ .score { font-size: 14px; - &.failed-score { + &.failed-score, + &.timeout { color: #C1294F; } } diff --git a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx index 404fa19ca..1b0ed7f53 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx @@ -8,7 +8,7 @@ interface StatusLabelProps { hideLabel?: boolean label?: string score?: number - status: 'pending' | 'failed' | 'passed' | 'failed-score' | 'human-override' + status: 'pending' | 'failed' | 'passed' | 'failed-score' | 'timeout' | 'human-override' action?: ReactNode isAiIcon?: boolean } diff --git a/src/apps/review/src/pages/reviews/components/AiReviewViewer/AiReviewViewer.tsx b/src/apps/review/src/pages/reviews/components/AiReviewViewer/AiReviewViewer.tsx index 68ac83b8c..ff6cd69c3 100644 --- a/src/apps/review/src/pages/reviews/components/AiReviewViewer/AiReviewViewer.tsx +++ b/src/apps/review/src/pages/reviews/components/AiReviewViewer/AiReviewViewer.tsx @@ -38,6 +38,7 @@ const AiReviewViewer: FC = () => { const navigate = useAppNavigate() const workflowRunIsFailed = [ AiWorkflowRunStatusEnum.FAILURE, + AiWorkflowRunStatusEnum.TIMEOUT, ].includes(workflowRun?.status as AiWorkflowRunStatusEnum) const tabItems: SelectOption[] = [ @@ -65,9 +66,18 @@ const AiReviewViewer: FC = () => { workflowRun && [ AiWorkflowRunStatusEnum.CANCELLED, AiWorkflowRunStatusEnum.FAILURE, + AiWorkflowRunStatusEnum.TIMEOUT, ].includes(workflowRun.status) ), [workflowRun]) + const failedRunMessage = useMemo(() => { + if (workflowRun?.status === AiWorkflowRunStatusEnum.TIMEOUT) { + return 'AI run timed out - no scorecard results are available' + } + + return 'AI run failed - no scorecard results are available' + }, [workflowRun?.status]) + const back = useCallback(async (e?: React.MouseEvent) => { e?.preventDefault() try { @@ -103,7 +113,7 @@ const AiReviewViewer: FC = () => { {isFailedRun ? (
- AI run failed - no scorecard results are available + {failedRunMessage}
) : ( <> From 37c29906c23fe2e469a116ee906e977404a16320 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 21 Sep 2026 11:21:21 +0300 Subject: [PATCH 06/22] fix token render --- .../reviews/components/ScorecardHeader/ScorecardHeader.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apps/review/src/pages/reviews/components/ScorecardHeader/ScorecardHeader.tsx b/src/apps/review/src/pages/reviews/components/ScorecardHeader/ScorecardHeader.tsx index 9b0e05187..2ebf0697a 100644 --- a/src/apps/review/src/pages/reviews/components/ScorecardHeader/ScorecardHeader.tsx +++ b/src/apps/review/src/pages/reviews/components/ScorecardHeader/ScorecardHeader.tsx @@ -143,7 +143,7 @@ const ScorecardHeader: FC = () => { )} - {isAdmin && workflowRun.usage?.cacheCreationInputTokens && ( + {isAdmin && !!workflowRun.usage?.cacheCreationInputTokens && ( @@ -153,7 +153,7 @@ const ScorecardHeader: FC = () => { )} - {isAdmin && workflowRun.usage?.cacheCreationInputTokens && ( + {isAdmin && !!workflowRun.usage?.cacheCreationInputTokens && ( From 0e5e8502f42a60ad5f02f5cf3eeda16c1dcf99ee Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 21 Sep 2026 08:02:28 +1000 Subject: [PATCH 07/22] PM-6392 Redesign the Sales dashboard for an executive view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table: show ten columns in the PM-6392 order (Stage, Opportunity Name, Account Name, Subcontracting End Customer, Reporting SMU, Amount, Expected Revenue, Created Date, Close Date, Opportunity Owner) and drop Reporting Account, Close Month, Expected Revenue (Converted), Amount (Converted) and Forecast Alert. Selection and ordering are a display concern, so the API response and the WIN contract are untouched, a column the report adds later is kept behind the ordered ones, and each column keeps the cell index it has in row.cells. Numeric cells are right-aligned and non-wrapping, dates non-wrapping, and padding and type sizes are tightened so the table fits a laptop width. Summary: four fixed cards below the title — Total Opportunities, Total Amount, Total Expected Revenue and Total WON SOW Signed — reading the snapshot-wide summary, with an em dash for a column the report does not provide. Stage breakdown: a compact grid of clickable tiles, one per stage, each showing its count, Amount and Expected Revenue, ordered down the pipeline. A tile drills the table into that stage via the new Reports API drilldown, which leaves the summary intact so the breakdown does not collapse to the selected stage; the tile is highlighted and aria-pressed, and clicking it again or Clear stage releases it. Layout: the stage breakdown and the date range filter now share a two-column row beneath the cards, stacking below 1100px. Co-Authored-By: Claude Opus 5 (1M context) --- src/apps/sales/README.md | 109 +++++-- src/apps/sales/src/SalesPage.module.scss | 100 ++++--- src/apps/sales/src/SalesPage.spec.tsx | 141 ++++++++- src/apps/sales/src/SalesPage.tsx | 360 ++++++++++++++--------- src/apps/sales/src/sales.models.ts | 14 + src/apps/sales/src/sales.utils.spec.ts | 166 ++++++++++- src/apps/sales/src/sales.utils.ts | 206 ++++++++++++- 7 files changed, 889 insertions(+), 207 deletions(-) diff --git a/src/apps/sales/README.md b/src/apps/sales/README.md index 5bf0f858c..b6c85fdc3 100644 --- a/src/apps/sales/README.md +++ b/src/apps/sales/README.md @@ -1,4 +1,4 @@ -# Sales (PM-6343, PM-6363, PM-6364) +# Sales (PM-6343, PM-6363, PM-6364, PM-6392) Read-only Salesforce reporting for Administrators and Talent Managers. Available at `sales.topcoder.com` / `sales.topcoder-dev.com`, `/sales` on the combined host, @@ -17,11 +17,76 @@ 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) +## Dashboard layout (PM-6392) -A date range panel at the top of the page, headed inside its box like the -report panel, filters the report by **Created Date** for pipeline generation, -or by **Close Date** for revenue projection. +The page reads as an executive dashboard rather than one long column: + +1. Page title and refresh controls. +2. Four summary statistic cards, left to right. +3. A two-column row: the stage breakdown on the left, the date range filter on + the right, each about half the width. Below 1100px they stack. +4. The opportunity table. + +## Summary statistic cards (PM-6392) + +The four cards report **Total Opportunities**, **Total Amount**, **Total +Expected Revenue** and **Total WON SOW Signed**, in that fixed order. They read +the API's snapshot-wide `summary`, so they describe every record the current +search, column filter and date range match, not the visible page. + +Total Amount and Total Expected Revenue resolve their column by label, accepting +the converted variant so a report that provides only `Amount (converted)` still +fills the card with the single-currency figure `displayedAmounts` prefers. Total +Amount falls back to the report's first non-converted total if no column is +named Amount. Total WON SOW Signed is the Amount total of the `Won - SOW Signed` +stage taken from the breakdown. A card whose column the report does not provide +shows an em dash rather than disappearing, so the row stays four wide. + +The cards deliberately ignore the selected stage: they are the overview the +stage tiles are read against. See the drilldown note below. + +## Stage breakdown (PM-6392) + +The stage breakdown is a compact grid of clickable tiles, one per stage, each +showing that stage's opportunity count, Amount and Expected Revenue. The stages +are ordered down the pipeline — Prospecting, Qualification, Proposal, Contracts, +Closing, Won SOW Signed — and a stage the pipeline adds later follows them, +ordered by value as the API returned it. + +Clicking a tile filters the table below to that stage; the tile is highlighted +and carries `aria-pressed`. Clicking it again, or the **Clear stage** control in +the panel header, removes the filter. The selection travels as the Reports API +`drilldownColumn`/`drilldownValue` pair, which narrows the returned rows only, +so `summary` still covers every stage and the breakdown does not collapse to the +one stage being examined. The stage selection is independent of the report's own +search and Filter field controls, and clearing those leaves it in place, exactly +as it leaves the date range in place. + +## Table columns (PM-6392) + +The table shows ten columns, in this order: Stage, Opportunity Name, Account +Name, Subcontracting End Customer, Reporting SMU, Amount, Expected Revenue, +Created Date, Close Date, Opportunity Owner. Reporting Account, Close Month, +Expected Revenue (Converted), Amount (Converted) and Forecast Alert are not +shown. + +Selection and ordering are a **display** concern in `sales.utils.ts`: columns are +matched by label, the API response and the WIN contract are unchanged, and a +column the Salesforce report adds later is kept and shown behind the ordered +ones rather than silently dropped. Each displayed column keeps the cell index it +has in `row.cells`, so reordering headers never reorders row data. The Filter +field dropdown offers the displayed columns only. + +Currency and numeric cells are right-aligned and never wrap, date cells never +wrap, and padding and font sizes are tightened so the ten columns fit a standard +laptop width. The focusable horizontal scroll region remains as a fallback for +narrow windows and unusually wide values. + +## Date range filter (PM-6364, moved in PM-6392) + +The date range panel, now the right half of the two-column row, 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 @@ -37,24 +102,22 @@ 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. -Each tile shows only its label and value. A total whose converted counterpart -the report also provides, such as Amount beside Amount (converted), is hidden -for now as redundant. Totals show their shared currency, and a single-currency -total the report leaves uncoded is shown in US dollars to match the converted -columns; 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 +## Filtered totals (PM-6364, restated in PM-6392) + +The API computes every total over all matching records, so they never describe +only the visible page. A total whose converted counterpart the report also +provides, such as Amount beside Amount (converted), is treated as redundant and +the converted figure is used. Totals show their shared currency, and a +single-currency total the report leaves uncoded is shown in US dollars to match +the converted columns; a total that sums different currencies is rendered as a +plain number and labelled as mixed. The cards and the stage breakdown are hidden +when the API returns no `summary`, and a `summary` whose buckets predate the +PM-6392 per-bucket `amounts` still renders, using the primary bucket total. + +Report metadata determines every available column, including grouped Stage. +Search and column substring filters apply automatically as the user types +(debounced); a stage tile applies at once, because a click is already a +deliberate single action. 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. diff --git a/src/apps/sales/src/SalesPage.module.scss b/src/apps/sales/src/SalesPage.module.scss index f651d12ac..9dd2a8b7b 100644 --- a/src/apps/sales/src/SalesPage.module.scss +++ b/src/apps/sales/src/SalesPage.module.scss @@ -4,7 +4,7 @@ width: 100%; max-width: 1800px; margin: 0 auto; - padding: 40px 48px 64px; + padding: 32px 32px 56px; min-height: 70vh; } @@ -28,50 +28,71 @@ .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; } +.status { margin: 16px 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; } +.panelHeader { padding: 16px 20px; border-bottom: 1px solid $tc-2026-border; } +.panelHeader h2 { font-size: 20px; line-height: 28px; } +.panelHint { margin-top: 4px; color: $tc-2026-muted; font-size: 13px; } +.panelNote { padding: 0 20px 16px; font-size: 13px; color: $tc-2026-muted; } +.filters { padding: 16px 20px; display: flex; align-items: flex-end; flex-wrap: wrap; gap: 12px; background: $tc-2026-canvas; } +.filterField { display: flex; flex-direction: column; gap: 4px; flex: 1 1 180px; min-width: 140px; } +.filterField label { font-size: 13px; line-height: 20px; font-weight: 700; } +.filterField input, .filterField select, .pageControls select { background: $tc-2026-surface; border: 1px solid $tc-2026-border-strong; padding: 6px 10px; min-height: 36px; color: $tc-2026-body; font-weight: 400; max-width: 100%; } +.filterField input, .filterField select { width: 100%; height: 36px; box-sizing: border-box; font-size: 14px; line-height: 20px; } .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; } -.dateHint { max-width: 820px; } +.filterActions { min-height: 36px; } .dateStatus { flex-basis: 100%; 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; } + +/* Four summary statistic cards, left to right, immediately below the page title. */ +.metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 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: 14px 18px; } +.metricLabel { font-size: 11px; font-weight: 700; letter-spacing: 1.2px; text-transform: uppercase; color: $tc-2026-muted; } +.metricValue { font-size: 26px; line-height: 34px; font-weight: 700; margin-top: 4px; overflow-wrap: anywhere; } +.metricNote { margin-top: 4px; font-size: 12px; color: $tc-2026-muted; } + +/* Stage breakdown and date range filter share the width beneath the cards. */ +.dashboard { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; margin-bottom: 16px; } +.stages { list-style: none; display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; padding: 12px; } +.stage { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + gap: 6px; + text-align: left; + padding: 10px 12px; + cursor: pointer; + background: $tc-2026-canvas; + border: 1px solid $tc-2026-border; + border-radius: $tc-2026-radius-md; + color: $tc-2026-body; +} +.stage:hover, .stage:focus-visible { border-color: $tc-2026-teal; } +.stageSelected { background: $tc-2026-surface; border-color: $tc-2026-teal; box-shadow: inset 3px 0 0 $tc-2026-teal; } +.stageName { font-weight: 700; font-size: 14px; line-height: 20px; overflow-wrap: anywhere; } +.stageMetrics { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 11px; color: $tc-2026-muted; } +.stageMetrics > span { display: flex; flex-direction: column; } +.stageValue { font-size: 14px; line-height: 20px; font-weight: 700; color: $tc-2026-body; } + +/* A dense table keeps the ten PM-6392 columns inside a laptop viewport. */ .tableScroll { overflow-x: auto; width: 100%; } -.table { width: 100%; border-collapse: collapse; text-align: left; } +.table { width: 100%; border-collapse: collapse; text-align: left; font-size: 13px; } .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 { padding: 10px 12px; width: 100%; text-align: left; color: $tc-2026-body; font-weight: 700; font-size: 12px; 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 td { border-bottom: 1px solid $tc-2026-border; padding: 8px 12px; max-width: 260px; overflow-wrap: anywhere; vertical-align: top; line-height: 18px; } +.table th.numeric, .table td.numeric { text-align: right; white-space: nowrap; } +.table th.numeric button { text-align: right; } +.table td.nowrap { white-space: nowrap; } .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; } +.pagination { padding: 12px 20px; 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, .warning { padding: 16px 20px; margin-bottom: 16px; 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; } @@ -79,8 +100,16 @@ .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: 1300px) { + .metrics { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 1100px) { + .dashboard { grid-template-columns: 1fr; } +} + @media (max-width: 900px) { - .page { padding: 24px; } + .page { padding: 24px 16px 40px; } .header { align-items: flex-start; flex-wrap: wrap; } .pageControls { gap: 8px; } } @@ -89,10 +118,11 @@ .page { padding: 20px 12px 40px; } .header h1 { font-size: 36px; line-height: 44px; } .headerActions { width: 100%; justify-content: space-between; } - .panelHeader, .filters, .pagination, .metric, .breakdown { padding: 16px; } + .metrics { grid-template-columns: 1fr; } + .stages { grid-template-columns: 1fr; } + .panelHeader, .filters, .pagination, .metric { padding: 12px; } .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 index 781e4be8a..e2df592f1 100644 --- a/src/apps/sales/src/SalesPage.spec.tsx +++ b/src/apps/sales/src/SalesPage.spec.tsx @@ -98,7 +98,45 @@ function fixture(): SalesReport { }], groups: [{ amountColumnId: 'AMOUNT', - buckets: [{ count: 18, label: 'Proposal', total: 900000 }], + buckets: [{ + amounts: [{ + columnId: 'AMOUNT', + count: 18, + currencyCode: 'USD', + label: 'Amount', + mixedCurrency: false, + total: 900000, + }, { + columnId: 'EXP_AMOUNT', + count: 18, + currencyCode: 'USD', + label: 'Expected Revenue', + mixedCurrency: false, + total: 450000, + }], + count: 18, + label: 'Proposal', + total: 900000, + }, { + amounts: [{ + columnId: 'AMOUNT', + count: 6, + currencyCode: 'USD', + label: 'Amount', + mixedCurrency: false, + total: 87654, + }, { + columnId: 'EXP_AMOUNT', + count: 6, + currencyCode: 'USD', + label: 'Expected Revenue', + mixedCurrency: false, + total: 87654, + }], + count: 6, + label: 'Won - SOW Signed', + total: 87654, + }], columnId: 'STAGE_NAME', currencyCode: 'USD', label: 'Stage', @@ -315,33 +353,108 @@ describe('Sales page', () => { await screen.findByText('No date range applied.') }) - it('shows totals for every matching record without per-tile record counts', async () => { + it('shows the four summary cards over every matching record', async () => { render() await screen.findByText('Example opportunity') + const cards = ['Total Opportunities', 'Total Amount', 'Total Expected Revenue', 'Total WON SOW Signed'] + cards.forEach(card => expect(screen.getByText(card)) + .toBeInTheDocument()) + expect(screen.getByText('30')) + .toBeInTheDocument() + // The plain Amount is redundant beside Amount (converted), so the card reads the converted total. expect(screen.getByText('$1,234,567')) .toBeInTheDocument() - // The plain Amount is redundant beside Amount (converted), so its tile is hidden. expect(screen.queryByText('$987,654')) .not.toBeInTheDocument() // An uncoded single-currency total reads in dollars like the converted columns. expect(screen.getByText('$555,555')) .toBeInTheDocument() - expect(screen.getByText('2,468')) - .toBeInTheDocument() - expect(screen.getByText('Totals mix currencies.')) - .toBeInTheDocument() - expect(screen.queryByText('Matching records')) - .not.toBeInTheDocument() - expect(screen.queryByText(/records with a value/)) + // The WON SOW Signed card reports the signed Amount from the stage breakdown. + expect(screen.getAllByText('$87,654').length) + .toBeGreaterThan(0) + // The removed columns no longer earn a tile of their own. + expect(screen.queryByText('Local fee')) .not.toBeInTheDocument() + }) + + it('breaks the pipeline down by stage with both totals, in pipeline order', async () => { + render() + await screen.findByText('Example opportunity') expect(screen.getByText('Stage breakdown')) .toBeInTheDocument() - expect(screen.getByText('18 records')) - .toBeInTheDocument() + const stages = screen.getAllByRole('button', { pressed: false }) + .filter(button => button.textContent?.includes('Opportunities')) + expect(stages.map(button => button.textContent)) + .toEqual([ + expect.stringContaining('Proposal'), + expect.stringContaining('Won - SOW Signed'), + ]) + expect(stages[0].textContent) + .toContain('$450,000') expect(screen.getByText('2 further values not shown.')) .toBeInTheDocument() }) + it('drills the table into a stage and releases it when the stage is clicked again', async () => { + render() + await screen.findByText('Example opportunity') + const stage = screen.getAllByRole('button', { pressed: false }) + .find(button => button.textContent?.includes('Won - SOW Signed')) as HTMLElement + fireEvent.click(stage) + await waitFor(() => expect(fetchReport) + .toHaveBeenLastCalledWith(expect.objectContaining({ + drilldownColumn: 'STAGE_NAME', drilldownValue: 'Won - SOW Signed', page: 1, + }), expect.any(AbortSignal))) + expect(await screen.findByRole('button', { name: /Won - SOW Signed/, pressed: true })) + .toBeInTheDocument() + // The breakdown is still complete, because the drilldown never narrows the summary. + expect(screen.getAllByRole('button', { pressed: false }) + .some(button => button.textContent?.includes('Proposal'))) + .toBe(true) + fireEvent.click(screen.getByRole('button', { name: /Won - SOW Signed/, pressed: true })) + await waitFor(() => expect(fetchReport) + .toHaveBeenLastCalledWith(expect.objectContaining({ + drilldownColumn: undefined, drilldownValue: undefined, + }), expect.any(AbortSignal))) + }) + + it('shows only the PM-6392 columns, in order, without disturbing the row data', async () => { + fetchReport.mockResolvedValue({ + ...fixture(), + columns: [ + { dataType: 'string', id: 'ACCOUNT', label: 'Reporting Account' }, + { dataType: 'date', id: 'CLOSE_DATE', label: 'Close Date' }, + { dataType: 'picklist', id: 'STAGE_NAME', label: 'Stage' }, + { dataType: 'currency', id: 'AMOUNT', label: 'Amount' }, + ], + rows: [{ + cells: [ + { label: 'Hidden account', value: 'Hidden account' }, + { label: '9/30/2026', value: '2026-09-30' }, + { label: 'Proposal', value: 'Proposal' }, + { currencyCode: 'USD', label: '$1,000', value: 1000 }, + ], + id: '0:0', + }], + }) + render() + await screen.findByText('$1,000') + expect(screen.getAllByRole('columnheader') + .map(header => header.textContent?.replace(/[\u2191\u2193\u2195]/g, '') + .trim())) + .toEqual(['Stage', 'Amount', 'Close Date']) + expect(screen.queryByText('Hidden account')) + .not.toBeInTheDocument() + // The cells still follow their own column, not the header position. + const cells = screen.getAllByRole('cell') + .map(cell => cell.textContent) + expect(cells) + .toEqual(['Proposal', '$1,000', '9/30/2026']) + // A removed column is no longer offered as a filter field either. + expect([...(screen.getByLabelText('Filter field') as HTMLSelectElement).options].map(o => o.text)) + .toEqual(['Choose a field', 'Stage', 'Amount', 'Close Date']) + }) + it('disables the range and hides totals for a report that provides neither', async () => { fetchReport.mockResolvedValue({ ...fixture(), @@ -355,7 +468,9 @@ describe('Sales page', () => { .toBeDisabled() expect(screen.getByLabelText('From date')) .toBeDisabled() - expect(screen.queryByText('Opportunities')) + expect(screen.queryByText('Total Opportunities')) + .not.toBeInTheDocument() + expect(screen.queryByText('Stage breakdown')) .not.toBeInTheDocument() }) diff --git a/src/apps/sales/src/SalesPage.tsx b/src/apps/sales/src/SalesPage.tsx index 40c5b6f60..886b36a35 100644 --- a/src/apps/sales/src/SalesPage.tsx +++ b/src/apps/sales/src/SalesPage.tsx @@ -7,27 +7,42 @@ import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from import { Button, IconOutline, LoadingSpinner, PageTitle } from '~/libs/ui' import { OpportunityModal } from './OpportunityModal' -import { SalesQuery, SalesReport, toOpportunityId } from './sales.models' +import { SalesQuery, SalesReport, SalesSummaryAmount, toOpportunityId } from './sales.models' import { fetchSalesReport, salesErrorMessage } from './sales.service' import { + amountTotal, dateColumns, dateRangeError, defaultDateColumn, - displayedAmounts, + displayedColumns, + expectedRevenueTotal, formatSummaryAmount, + stageBreakdown, withDateRange, + withDrilldown, + wonSowSignedTotal, } from './sales.utils' import styles from './SalesPage.module.scss' import './sales.scss' const initialQuery: SalesQuery = { page: 1, perPage: 25 } const filterDebounceMs = 400 +/** Column types whose cells read as compact, right-aligned figures. */ +const numericTypes = ['currency', 'double', 'int', 'percent'] +/** Column types whose cells must not wrap, so a date never costs two lines. */ +const dateTypes = ['date', 'datetime'] interface SelectedOpportunity { id: string name: string } +/** The stage a tile click drills the table into, identified by its column and exact label. */ +interface SelectedStage { + columnId: string + label: string +} + /** * Merges search and column filter controls into the report query. * @param current Active report query. @@ -54,10 +69,27 @@ function withFilters(current: SalesQuery, search: string, filterColumn: string, return { ...current, ...next, page: 1 } } +/** + * Renders one summary statistic card, keeping a missing column visible as an explicit dash. + * @param props Card label and the total it reports, which is undefined when the report omits the column. + * @returns The card, always in the same position so the four read as a fixed row. + * @throws Does not throw. + */ +const SummaryCard: FC<{ amount?: SalesSummaryAmount; label: string; value?: string }> = props => ( +
+

{props.label}

+

+ {props.value ?? (props.amount ? formatSummaryAmount(props.amount) : '—')} +

+ {props.amount?.mixedCurrency &&

Totals mix currencies.

} +
+) + /** * 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. + * @returns An executive dashboard: four snapshot-wide summary cards, a clickable stage + * breakdown beside the Created/Close date range filter, and a compact report table with + * server-side view controls and live refresh. * @throws Does not throw request failures; shows inline recovery and stale-data status. */ const SalesPage: FC = () => { @@ -69,6 +101,7 @@ const SalesPage: FC = () => { const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') const [dateError, setDateError] = useState('') + const [stage, setStage] = useState() const [report, setReport] = useState() const [error, setError] = useState('') const [loading, setLoading] = useState(true) @@ -139,6 +172,12 @@ const SalesPage: FC = () => { return () => window.clearTimeout(timer) }, [search, filterColumn, filterValue]) + useEffect(() => { + // A tile click is a deliberate, single action, so it applies at once + // rather than after the pause the typed controls need. + setQuery(current => withDrilldown(current, stage?.columnId ?? '', stage?.label ?? '')) + }, [stage]) + /** @param event Filter form submission. @returns Nothing; applies pending controls immediately. Does not throw. */ function applyFilters(event: FormEvent): void { event.preventDefault() @@ -150,13 +189,16 @@ const SalesPage: FC = () => { 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. + // The date range and the stage selection are their own sections with + // their own resets, so clearing the report filters must not empty them + // behind the user's back. setQuery(current => ({ ...initialQuery, dateColumn: current.dateColumn, dateFrom: current.dateFrom, dateTo: current.dateTo, + drilldownColumn: current.drilldownColumn, + drilldownValue: current.drilldownValue, perPage: current.perPage, })) } @@ -211,9 +253,25 @@ const SalesPage: FC = () => { setQuery(current => withDateRange(current, '', '', '')) } + /** + * Drills the table into a stage, or releases it when that stage is already selected. + * @param columnId Stage column the breakdown was built from. + * @param label Exact stage label the tile reports. + * @returns Nothing; the summary keeps describing every stage either way. Does not throw. + */ + function toggleStage(columnId: string, label: string): void { + setStage(current => ( + current?.columnId === columnId && current.label === label ? undefined : { columnId, label } + )) + } + const rangeApplied = !!query.dateColumn const summary = report?.summary - const shownAmounts = displayedAmounts(summary?.amounts ?? []) + const columns = useMemo(() => displayedColumns(report), [report]) + const breakdown = useMemo(() => stageBreakdown(summary), [summary]) + const totalAmount = summary && amountTotal(summary.amounts) + const totalExpectedRevenue = summary && expectedRevenueTotal(summary.amounts) + const totalWonSowSigned = wonSowSignedTotal(breakdown) 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) @@ -271,131 +329,154 @@ const SalesPage: FC = () => {
)} -
-
-
-

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}` : ''} - . - + {!breakdown.stages.length && ( +

  • No matching records.

  • + )} + + {breakdown.otherStages > 0 && ( +

    + {`${breakdown.otherStages.toLocaleString()} further values not shown.`} +

    )} - {!dateError && !rangeApplied && No date range applied.} -

    -
    -
    + + )} - {summary && ( -
    -
    -
    -

    Opportunities

    -

    {summary.recordCount.toLocaleString()}

    +
    +
    +
    +

    Date range filter

    +

    + Filter by Created Date for pipeline generation, or by Close Date for revenue + projections. +

    - {shownAmounts.map(amount => ( -
    -

    {amount.label}

    -

    {formatSummaryAmount(amount)}

    - {amount.mixedCurrency && ( -

    Totals mix 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, - })} - -
    • +
      +
      + + +
      +
      + + 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.} +

      +
    - )} +

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

    -

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

    +

    + {stage + ? `Showing the ${stage.label || '—'} stage. Salesforce is the source of truth.` + : 'Salesforce is the source of truth. Changes are made there.'} +

    {report && ( @@ -428,8 +509,8 @@ const SalesPage: FC = () => { value={filterColumn} > - {report?.columns.map(column => ( - + {columns.map(entry => ( + ))}
    @@ -468,22 +549,25 @@ const SalesPage: FC = () => { {report.reportName} - {report.columns.map(column => ( + {columns.map(entry => ( + ), +}), { virtual: true }) +jest.mock('../lib', () => ({ PageWrapper: (props: any) =>
    {props.children}
    })) +jest.mock('./website-publication.service', () => ({ + getPublication: jest.fn(), + getPublicationConfig: jest.fn(), + publicationEndpoint: 'https://cms.test/api/website-publication', + publicationFinished: (status: string) => ['success', 'failed'].includes(status), + publishWebsite: jest.fn(), +})) + +const release = { + environment: 'production' as const, + pipelineId: 'publication-1', + status: 'running', + url: 'https://app.circleci.com/pipelines/github/topcoder-platform/topcoder-website/42', +} + +describe('WebsitePublicationPage', () => { + beforeEach(() => { + jest.clearAllMocks() + sessionStorage.clear(); + (getPublicationConfig as jest.Mock).mockResolvedValue({ enabled: true, environment: 'production' }); + (publishWebsite as jest.Mock).mockResolvedValue({ ...release, status: 'pending' }); + (getPublication as jest.Mock).mockResolvedValue(release) + }) + + it('shows the actual target environment and disables repeated clicks while publishing', async () => { + render() + await screen.findByText('Production website') + fireEvent.click(screen.getByRole('button', { name: 'Publish Website' })) + await screen.findByText('Publication status: running') + expect(screen.getByRole('button', { name: 'Publishing…' })) + .toBeDisabled() + expect(publishWebsite) + .toHaveBeenCalledTimes(1) + expect(getPublication) + .toHaveBeenCalledWith('publication-1') + expect(sessionStorage.getItem('website-publication:https://cms.test/api/website-publication')) + .toBe('publication-1') + }) + + it('resumes a pending pipeline after reload and reports a failed deployment', async () => { + sessionStorage.setItem('website-publication:https://cms.test/api/website-publication', 'publication-1'); + (getPublication as jest.Mock).mockResolvedValue({ ...release, status: 'failed' }) + render() + await screen.findByText('Publication status: failed') + expect(publishWebsite).not.toHaveBeenCalled() + expect(screen.getByRole('link', { name: 'View publication details' })) + .toHaveAttribute('href', release.url) + await waitFor(() => expect(screen.getByRole('button', { name: 'Publish Website' })) + .toBeEnabled()) + expect(sessionStorage.getItem('website-publication:https://cms.test/api/website-publication')) + .toBeNull() + }) + + it('keeps publishing disabled when the server has not been configured', async () => { + (getPublicationConfig as jest.Mock).mockResolvedValue({ enabled: false, environment: 'dev' }) + render() + await screen.findByText('Website publishing has not been enabled for this environment.') + expect(screen.getByRole('button', { name: 'Publish Website' })) + .toBeDisabled() + }) + + it('reports submission failures without claiming the website was published', async () => { + (publishWebsite as jest.Mock).mockRejectedValue(new Error('Check CircleCI before trying again.')) + render() + await screen.findByText('Production website') + fireEvent.click(screen.getByRole('button', { name: 'Publish Website' })) + expect(await screen.findByRole('alert')) + .toHaveTextContent('Check CircleCI before trying again.') + expect(screen.queryByText('Website publication completed.')).not.toBeInTheDocument() + expect(getPublication).not.toHaveBeenCalled() + }) +}) diff --git a/src/apps/admin/src/website/WebsitePublicationPage.tsx b/src/apps/admin/src/website/WebsitePublicationPage.tsx new file mode 100644 index 000000000..fbd0c9e97 --- /dev/null +++ b/src/apps/admin/src/website/WebsitePublicationPage.tsx @@ -0,0 +1,130 @@ +import { FC, useEffect, useRef, useState } from 'react' + +import { Button } from '~/libs/ui' + +import { PageWrapper } from '../lib' + +import { + getPublication, + getPublicationConfig, + publicationEndpoint, + publicationFinished, + publishWebsite, + WebsitePublication, + WebsitePublicationConfig, +} from './website-publication.service' +import styles from './WebsitePublicationPage.module.scss' + +const storageKey = `website-publication:${publicationEndpoint}` + +/** Formats an API failure for editors without exposing request headers or credentials. */ +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unable to reach website publishing. Please try again.' +} + +/** + * Gives administrators a content publication action and polls the resulting release through completion. + * Keeps the pipeline ID for this browser session so reloading resumes observation of an active release. + */ +export const WebsitePublicationPage: FC = () => { + const [config, setConfig] = useState() + const [publication, setPublication] = useState() + const [pipelineId, setPipelineId] = useState(() => sessionStorage.getItem(storageKey) || '') + const [error, setError] = useState('') + const [submitting, setSubmitting] = useState(false) + const submittingRef = useRef(false) + + useEffect(() => { + let mounted = true + getPublicationConfig() + .then(value => { if (mounted) setConfig(value) }) + .catch(reason => { if (mounted) setError(errorMessage(reason)) }) + return () => { mounted = false } + }, []) + + useEffect(() => { + if (!pipelineId) return undefined + let mounted = true + let timer: ReturnType + const poll = async (): Promise => { + try { + const result = await getPublication(pipelineId) + if (!mounted) return + setPublication(result) + setError('') + if (publicationFinished(result.status)) { + sessionStorage.removeItem(storageKey) + setPipelineId('') + return + } + } catch (reason) { + if (!mounted) return + setError(errorMessage(reason)) + } + + if (mounted) timer = setTimeout(poll, 5000) + } + + poll() + return () => { + mounted = false + clearTimeout(timer) + } + }, [pipelineId]) + + /** Queues one release and starts polling its pipeline; repeated clicks are ignored while pending. */ + async function handlePublish(): Promise { + if (submittingRef.current || pipelineId) return + submittingRef.current = true + setSubmitting(true) + setError('') + try { + const result = await publishWebsite() + sessionStorage.setItem(storageKey, result.pipelineId) + setPublication(result) + setPipelineId(result.pipelineId) + } catch (reason) { + setError(errorMessage(reason)) + } finally { + setSubmitting(false) + submittingRef.current = false + } + } + + return ( + +
    +

    + {config ? `${config.environment === 'production' ? 'Production' : 'Development'} website` + : 'Loading publishing settings…'} +

    +

    + Publish approved content already promoted and published in this environment’s CMS. + Changed pages and pages using changed shared content will be rebuilt. + Search and redirects will also be updated. +

    + + {config && !config.enabled && ( +

    Website publishing has not been enabled for this environment.

    + )} + {error &&

    {error}

    } + {publication && ( +
    +

    + {publication.status === 'success' + ? 'Website publication completed.' + : `Publication status: ${publication.status.replaceAll('_', ' ')}`} +

    + View publication details +
    + )} +
    +
    + ) +} diff --git a/src/apps/admin/src/website/website-publication.service.ts b/src/apps/admin/src/website/website-publication.service.ts new file mode 100644 index 000000000..50c309d6a --- /dev/null +++ b/src/apps/admin/src/website/website-publication.service.ts @@ -0,0 +1,38 @@ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync, xhrPostAsync } from '~/libs/core' + +export interface WebsitePublicationConfig { + enabled: boolean + environment: 'dev' | 'production' +} + +export interface WebsitePublication { + environment: 'dev' | 'production' + pipelineId: string + status: string + url: string +} + +export const publicationEndpoint = EnvironmentConfig.ENV === 'prod' + ? 'https://cms.topcoder.com/api/website-publication' + : 'https://cms.topcoder-dev.com/api/website-publication' + +/** Reads publishing availability for the current app environment using the signed-in user's JWT. */ +export function getPublicationConfig(): Promise { + return xhrGetAsync(publicationEndpoint) +} + +/** Queues the server-selected environment's content pipeline; rejects on authorization or service errors. */ +export function publishWebsite(): Promise { + return xhrPostAsync, WebsitePublication>(publicationEndpoint, {}) +} + +/** Reads the given pipeline's verified workflow status; the server checks project and environment ownership. */ +export function getPublication(pipelineId: string): Promise { + return xhrGetAsync(`${publicationEndpoint}?pipelineId=${encodeURIComponent(pipelineId)}`) +} + +/** Returns whether CircleCI has reported a terminal workflow state for the supplied publication. */ +export function publicationFinished(status?: string): boolean { + return !!status && ['success', 'failed', 'error', 'canceled', 'unauthorized', 'not_run'].includes(status) +} diff --git a/src/apps/campus/src/campus.routes.spec.tsx b/src/apps/campus/src/campus.routes.spec.tsx index b42d5d4f3..1bd1bddc8 100644 --- a/src/apps/campus/src/campus.routes.spec.tsx +++ b/src/apps/campus/src/campus.routes.spec.tsx @@ -1,21 +1,12 @@ -/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ -import { render, screen } from '@testing-library/react' -import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' - -import { campusRoutes, rootRoute } from './campus.routes' +import { campusRoutes } from './campus.routes' jest.mock('~/config', () => ({ - EnvironmentConfig: { - SUBDOMAIN: 'campus', - }, -}), { - virtual: true, -}) - -jest.mock('~/config/constants', () => ({ AppSubdomain: { campus: 'campus', }, + EnvironmentConfig: { + SUBDOMAIN: 'campus', + }, ToolTitle: { campus: 'Campus', }, @@ -29,28 +20,19 @@ jest.mock('~/libs/core', () => ({ virtual: true, }) -const LocationViewer = (): JSX.Element => { - const location = useLocation() - - return
    {location.pathname}
    -} - describe('campus routes', () => { - it('redirects the campus root to /mecw when groupName is missing', async () => { - const campusAppRoute = campusRoutes[0] - const campusChildRoutes = campusAppRoute.children || [] - const fallbackRoute = campusChildRoutes.find(route => route.route === '') + it('serves the campus homepage on the campus root', () => { + const campusChildRoutes = campusRoutes[0].children || [] + const homeRoute = campusChildRoutes.find(route => route.route === '') + + expect(homeRoute?.id) + .toBe('Campus Home') + }) - render( - - - } path={`${rootRoute}/mecw`} /> - - - , - ) + it('serves the leaderboard for a group name', () => { + const campusChildRoutes = campusRoutes[0].children || [] - expect((await screen.findByTestId('location-pathname')).textContent) - .toBe('/mecw') + expect(campusChildRoutes.find(route => route.route === ':groupName')?.id) + .toBe('Campus Leaderboard') }) }) diff --git a/src/apps/campus/src/campus.routes.tsx b/src/apps/campus/src/campus.routes.tsx index 6426a9b61..534b34989 100644 --- a/src/apps/campus/src/campus.routes.tsx +++ b/src/apps/campus/src/campus.routes.tsx @@ -1,9 +1,11 @@ -import { Navigate } from 'react-router-dom' - import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core' import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config' const CampusApp: LazyLoadedComponent = lazyLoad(() => import('./CampusApp')) +const CampusHomePage: LazyLoadedComponent = lazyLoad( + () => import('./pages/home'), + 'CampusHomePage', +) const CampusLeaderboardPage: LazyLoadedComponent = lazyLoad( () => import('./pages/leaderboard'), 'CampusLeaderboardPage', @@ -20,7 +22,9 @@ export const campusRoutes: ReadonlyArray = [ authRequired: true, children: [ { - element: , + // Campus program homepage listing the participating universities + element: , + id: 'Campus Home', route: '', }, { diff --git a/src/apps/campus/src/lib/components/index.ts b/src/apps/campus/src/lib/components/index.ts index 683964206..6d96b1481 100644 --- a/src/apps/campus/src/lib/components/index.ts +++ b/src/apps/campus/src/lib/components/index.ts @@ -1,2 +1,3 @@ export * from './member-avatar' export * from './stat-card' +export * from './university-card' diff --git a/src/apps/campus/src/lib/components/university-card/UniversityCard.module.scss b/src/apps/campus/src/lib/components/university-card/UniversityCard.module.scss new file mode 100644 index 000000000..f376fa2b7 --- /dev/null +++ b/src/apps/campus/src/lib/components/university-card/UniversityCard.module.scss @@ -0,0 +1,71 @@ +@import '@libs/ui/styles/includes'; + +.universityCard { + border: 1px solid var(--TableBorderColor); + border-radius: 8px; + color: var(--FontColor); + display: flex; + flex-direction: column; + gap: $sp-4; + min-width: 0; + padding: $sp-6; + transition: box-shadow 0.15s ease-in-out; + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + } + + @include ltemd { + padding: $sp-4; + } +} + +.logo { + align-items: center; + display: flex; + height: 60px; + + img { + max-height: 100%; + max-width: 100%; + object-fit: contain; + } +} + +.logoFallback { + align-items: center; + background-color: var(--TableRowBorderColor); + border-radius: 8px; + display: flex; + font-family: 'Figtree', sans-serif; + font-size: 26px; + font-weight: 700; + height: 60px; + justify-content: center; + width: 60px; +} + +.name { + flex: 1 0 auto; + font-family: 'Figtree', sans-serif; + font-size: 20px; + font-weight: 700; + line-height: 26px; +} + +.cta { + align-items: center; + color: var(--Link); + display: flex; + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + font-weight: 700; + gap: $sp-1; + line-height: 22px; + + svg { + flex: 0 0 auto; + height: 20px; + width: 20px; + } +} diff --git a/src/apps/campus/src/lib/components/university-card/UniversityCard.tsx b/src/apps/campus/src/lib/components/university-card/UniversityCard.tsx new file mode 100644 index 000000000..5cb649d33 --- /dev/null +++ b/src/apps/campus/src/lib/components/university-card/UniversityCard.tsx @@ -0,0 +1,44 @@ +/** + * Bordered card linking a university to its campus leaderboard. + */ +import { FC, useState } from 'react' +import { Link } from 'react-router-dom' + +import { IconOutline } from '~/libs/ui' + +import { CampusUniversity } from '../../config' + +import styles from './UniversityCard.module.scss' + +interface UniversityCardProps { + readonly to: string + readonly university: CampusUniversity +} + +export const UniversityCard: FC = (props: UniversityCardProps) => { + const [logoFailed, setLogoFailed] = useState(false) + const { logoUrl, name }: CampusUniversity = props.university + + return ( + +
    + {logoUrl && !logoFailed ? ( + {name} + ) : ( + {name.charAt(0)} + )} +
    +
    {name}
    + + View Leaderboard + + + + ) +} + +export default UniversityCard diff --git a/src/apps/campus/src/lib/components/university-card/index.ts b/src/apps/campus/src/lib/components/university-card/index.ts new file mode 100644 index 000000000..d8f9806b5 --- /dev/null +++ b/src/apps/campus/src/lib/components/university-card/index.ts @@ -0,0 +1 @@ +export { UniversityCard } from './UniversityCard' diff --git a/src/apps/campus/src/lib/config/campus-universities.config.ts b/src/apps/campus/src/lib/config/campus-universities.config.ts new file mode 100644 index 000000000..52f9e53c8 --- /dev/null +++ b/src/apps/campus/src/lib/config/campus-universities.config.ts @@ -0,0 +1,74 @@ +/** + * Universities listed on the campus homepage. + * + * Names and logos mirror the university landing pages in the Payload CMS `website` space + * (`/universities/*`, `/lpu`, `/brown-university`); group ids come from each page's join group modal. + */ + +export interface CampusUniversity { + /** Campus group the leaderboard is built for (name, id or legacy id are all accepted). */ + groupId: string + groupName: string + logoUrl?: string + name: string +} + +export const CAMPUS_UNIVERSITIES: ReadonlyArray = [ + { + groupId: '41b00495-659d-4610-ba8e-62543d6c8168', + groupName: 'mecw', + logoUrl: 'https://assets.topcoder-dev.com/media/contentful/images.ctfassets.net/' + + '96/96c272a3d61609101d2aec2bad4dd3e397bd3202d7454eb410c3824d0f18badf/' + + 'Logo_lockup__1_-96c272a3d61609101d2aec2bad4dd3e397bd3202d7454eb410c3824d0f18badf.svg', + name: 'Mahendra Engineering College for Women', + }, + { + groupId: '0f241463-8f48-4248-85a1-ba0fae4ac226', + groupName: 'mahendra', + logoUrl: 'https://assets.topcoder-dev.com/media/mahendra%20-%20logos.svg', + name: 'Mahendra Institutions', + }, + { + groupId: '9ff2c7f8-8f77-47a7-96ed-891baf2ebd0c', + groupName: 'skct', + logoUrl: 'https://assets.topcoder-dev.com/media/skct%20-%20logos.svg', + name: 'Sri Krishna College of Technology', + }, + { + groupId: 'a4a6e38e-a14f-44b1-8036-9dbcd713ad77', + groupName: 'skcet', + logoUrl: 'https://assets.topcoder-dev.com/media/skcet%20-%20logos.svg', + name: 'Sri Krishna College of Engineering And Technology', + }, + { + groupId: '582ba94b-0787-43b2-89a4-afd0a7b4eb31', + groupName: 'sathyabama', + logoUrl: 'https://assets.topcoder-dev.com/media/sathyabama%20-%20logos.svg', + name: 'Sathyabama Institute of Science And Technology', + }, + { + groupId: 'f224c94d-85c8-4b36-be25-3d7d4caf0ffd', + groupName: 'lnctbhopal', + logoUrl: 'https://assets.topcoder-dev.com/media/lnct%20-%20logos.svg', + name: 'Lakshmi Narain College of Technology', + }, + { + groupId: 'f3518838-62b7-408f-a269-c439f6ef96d1', + groupName: 'kluniversity', + logoUrl: 'https://assets.topcoder-dev.com/media/kl%20-%20logos.svg', + name: 'K L University', + }, + { + groupId: 'da3a64f6-4478-47dc-b051-75d33a0ac759', + groupName: 'lpu', + logoUrl: 'https://assets.topcoder-dev.com/media/contentful/images.ctfassets.net/6e/' + + '6e12c8c6d43382a9bd4b17104c0124452ec6b734d46cc6c4e4004224b0823476/' + + 'image_1-6e12c8c6d43382a9bd4b17104c0124452ec6b734d46cc6c4e4004224b0823476.svg', + name: 'LPU', + }, + { + groupId: '8e071d83-d2a5-49c2-8e3f-fa2a42881168', + groupName: 'brown-university', + name: 'Brown University', + }, +] diff --git a/src/apps/campus/src/lib/config/index.ts b/src/apps/campus/src/lib/config/index.ts new file mode 100644 index 000000000..284e20f28 --- /dev/null +++ b/src/apps/campus/src/lib/config/index.ts @@ -0,0 +1 @@ +export * from './campus-universities.config' diff --git a/src/apps/campus/src/lib/hooks/index.ts b/src/apps/campus/src/lib/hooks/index.ts index 55f29ec92..717937f41 100644 --- a/src/apps/campus/src/lib/hooks/index.ts +++ b/src/apps/campus/src/lib/hooks/index.ts @@ -1 +1,2 @@ export * from './use-campus-leaderboard' +export * from './use-can-access-campus-home' diff --git a/src/apps/campus/src/lib/hooks/use-can-access-campus-home.ts b/src/apps/campus/src/lib/hooks/use-can-access-campus-home.ts new file mode 100644 index 000000000..d212e0f5f --- /dev/null +++ b/src/apps/campus/src/lib/hooks/use-can-access-campus-home.ts @@ -0,0 +1,24 @@ +import { useMemo } from 'react' + +import { UserProfile, UserRole } from '~/libs/core' + +/** Roles that can always browse the campus homepage. */ +const CAMPUS_HOME_ROLES: ReadonlyArray = [ + UserRole.administrator, + UserRole.projectManager, + UserRole.talentManager, +] + +/** + * Whether the user may browse the campus homepage: wipro users, admins, PMs and TMs. + * + * @param profile signed in user profile, when available. + * @returns true when the homepage can be shown. + */ +export const useCanAccessCampusHome = (profile?: UserProfile): boolean => useMemo(() => ( + !!profile && ( + !!profile.email?.toLowerCase() + .endsWith('@wipro.com') + || !!profile.roles?.some(role => CAMPUS_HOME_ROLES.includes(role)) + ) +), [profile]) diff --git a/src/apps/campus/src/pages/home/CampusHomePage.module.scss b/src/apps/campus/src/pages/home/CampusHomePage.module.scss new file mode 100644 index 000000000..6853d793a --- /dev/null +++ b/src/apps/campus/src/pages/home/CampusHomePage.module.scss @@ -0,0 +1,44 @@ +@import '@libs/ui/styles/includes'; + +$section-gap: 40px; +$card-gap: 35px; + +.header { + margin-top: $section-gap; + margin-bottom: $section-gap; +} + +.title { + color: var(--FontColor); + font-family: 'Figtree', sans-serif; + font-size: 33px; + font-weight: 700; + letter-spacing: normal; + line-height: 38px; + margin-bottom: $sp-2; + text-transform: none; +} + +.subtitle { + color: var(--SubtitleColor); + font-family: 'Nunito Sans', sans-serif; + font-size: 18px; + font-weight: 400; + line-height: 25px; +} + +.cards { + display: grid; + gap: $card-gap; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: $section-gap; + + @include ltelg { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @include ltemd { + gap: $sp-4; + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/src/apps/campus/src/pages/home/CampusHomePage.spec.tsx b/src/apps/campus/src/pages/home/CampusHomePage.spec.tsx new file mode 100644 index 000000000..11f66c6fe --- /dev/null +++ b/src/apps/campus/src/pages/home/CampusHomePage.spec.tsx @@ -0,0 +1,111 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import type { PropsWithChildren } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' + +import { profileContext } from '~/libs/core' + +import { CAMPUS_UNIVERSITIES } from '../../lib/config' + +import { CampusHomePage } from './CampusHomePage' + +jest.mock('~/config', () => ({ + AppSubdomain: { campus: 'campus' }, + EnvironmentConfig: { SUBDOMAIN: 'campus' }, + ToolTitle: { campus: 'Campus' }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + lazyLoad: () => (): undefined => undefined, + profileContext: jest.requireActual('react') + .createContext({}), + UserRole: { + administrator: 'administrator', + projectManager: 'Project Manager', + talentManager: 'Talent Manager', + }, +}), { virtual: true }) + +jest.mock('~/libs/shared', () => ({ + RestrictedPage: (): JSX.Element =>
    Thanks for visiting
    , +}), { virtual: true }) + +jest.mock('~/libs/ui', () => ({ + ContentLayout: (props: PropsWithChildren): JSX.Element =>
    {props.children}
    , + IconOutline: { ChevronRightIcon: (): JSX.Element => }, + PageTitle: (): undefined => undefined, +}), { virtual: true }) + +interface TestProfile { + email: string + roles: string[] +} + +const LocationViewer = (): JSX.Element => { + const location = useLocation() + + return
    {location.pathname}
    +} + +function renderPage(profile?: TestProfile): void { + render( + + + + } path='/' /> + } path='/:groupName' /> + + + , + ) +} + +const wiproUser: TestProfile = { email: 'jane.doe@Wipro.com', roles: ['Topcoder User'] } + +describe('CampusHomePage', () => { + it('renders a card for every campus university', () => { + renderPage(wiproUser) + + CAMPUS_UNIVERSITIES.forEach(university => { + expect(screen.getByText(university.name)) + .toBeInTheDocument() + }) + expect(screen.getAllByText('View Leaderboard')) + .toHaveLength(CAMPUS_UNIVERSITIES.length) + expect(screen.queryByText('Justin University')) + .not.toBeInTheDocument() + }) + + it('opens the university leaderboard when a card is clicked', () => { + renderPage(wiproUser) + + const university = CAMPUS_UNIVERSITIES[0] + fireEvent.click(screen.getByText(university.name)) + + expect(screen.getByTestId('location-pathname').textContent) + .toBe(`/${university.groupName}`) + }) + + it.each(['administrator', 'Project Manager', 'Talent Manager'])( + 'lets %s users in regardless of their email', + role => { + renderPage({ email: 'someone@example.com', roles: ['Topcoder User', role] }) + + expect(screen.getAllByText('View Leaderboard')) + .toHaveLength(CAMPUS_UNIVERSITIES.length) + }, + ) + + it.each([ + ['a regular member', 'someone@example.com'], + ['a lookalike wipro domain', 'someone@wipro.com.example.com'], + ])('shows the restricted page to %s', (_, email) => { + renderPage({ email, roles: ['Topcoder User'] }) + + expect(screen.getByText('Thanks for visiting')) + .toBeInTheDocument() + expect(screen.queryByText('View Leaderboard')) + .not.toBeInTheDocument() + }) +}) diff --git a/src/apps/campus/src/pages/home/CampusHomePage.tsx b/src/apps/campus/src/pages/home/CampusHomePage.tsx new file mode 100644 index 000000000..833fdbdba --- /dev/null +++ b/src/apps/campus/src/pages/home/CampusHomePage.tsx @@ -0,0 +1,49 @@ +/** + * Campus program homepage listing the participating universities. + */ +import { FC, useContext } from 'react' + +import { profileContext, ProfileContextData } from '~/libs/core' +import { RestrictedPage } from '~/libs/shared' +import { ContentLayout, PageTitle } from '~/libs/ui' + +import { rootRoute } from '../../campus.routes' +import { useCanAccessCampusHome } from '../../lib/hooks' +import { CampusUniversity, CAMPUS_UNIVERSITIES } from '../../lib/config' +import { UniversityCard } from '../../lib/components' + +import styles from './CampusHomePage.module.scss' + +export const CampusHomePage: FC = () => { + const { profile }: ProfileContextData = useContext(profileContext) + const canAccess: boolean = useCanAccessCampusHome(profile) + + if (!canAccess) { + return + } + + return ( + + Campus Program + +
    +

    Campus Program

    +

    + Select a university to track the participation and performance of its members. +

    +
    + +
    + {CAMPUS_UNIVERSITIES.map((university: CampusUniversity) => ( + + ))} +
    +
    + ) +} + +export default CampusHomePage diff --git a/src/apps/campus/src/pages/home/index.ts b/src/apps/campus/src/pages/home/index.ts new file mode 100644 index 000000000..56944b62d --- /dev/null +++ b/src/apps/campus/src/pages/home/index.ts @@ -0,0 +1 @@ +export { default as CampusHomePage } from './CampusHomePage' diff --git a/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx b/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx index bf04de309..16fa03935 100644 --- a/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx +++ b/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react/no-unused-prop-types */ import type { FC, ReactNode } from 'react' import { useCallback, useMemo } from 'react' import ReactMarkdown, { type Components, type Options as ReactMarkdownOptions } from 'react-markdown' @@ -66,6 +67,11 @@ interface AssignmentCardProps { contactEmail?: string onViewPayments: () => void onDocumentExperience: () => void + /** + * Opens the timesheet for this engagement and assignment. Rendered alongside the other + * assigned-only actions, so it appears exactly when the member is actually on the engagement. + */ + onOpenTimesheet?: () => void onAcceptOffer?: () => void onRejectOffer?: () => void onContactTalentManager: (contactEmail?: string) => void @@ -299,6 +305,15 @@ const AssignmentCard: FC = (props: AssignmentCardProps) => textWrap className={styles.actionButton} /> + {/* {props.onOpenTimesheet && ( +
    + ) +} + +export default EngagementManagers diff --git a/src/apps/engagements/src/components/engagement-managers/index.ts b/src/apps/engagements/src/components/engagement-managers/index.ts new file mode 100644 index 000000000..e9b18564b --- /dev/null +++ b/src/apps/engagements/src/components/engagement-managers/index.ts @@ -0,0 +1 @@ +export { default as EngagementManagers } from './EngagementManagers' diff --git a/src/apps/engagements/src/components/engagements-tabs/EngagementsTabs.tsx b/src/apps/engagements/src/components/engagements-tabs/EngagementsTabs.tsx index f4b53557f..680f0071c 100644 --- a/src/apps/engagements/src/components/engagements-tabs/EngagementsTabs.tsx +++ b/src/apps/engagements/src/components/engagements-tabs/EngagementsTabs.tsx @@ -1,14 +1,18 @@ import { FC, useCallback, useMemo } from 'react' import { useNavigate } from 'react-router-dom' -import { useProfileContext } from '~/libs/core' import { TabsNavbar, TabsNavItem } from '~/libs/ui' +import { + useFetchEngagementTimesheets, + UseFetchEngagementTimesheetsResult, +} from '~/apps/work/src/lib' import { rootRoute } from '../../engagements.routes' +import { AuthCtx, useAuth } from '../../lib/utils/auth' import styles from './EngagementsTabs.module.scss' -export type EngagementsTab = 'opportunities' | 'applications' | 'assignments' +export type EngagementsTab = 'opportunities' | 'applications' | 'assignments' | 'timesheets' interface EngagementsTabsProps { activeTab: EngagementsTab @@ -16,23 +20,31 @@ interface EngagementsTabsProps { const EngagementsTabs: FC = (props: EngagementsTabsProps) => { const navigate = useNavigate() - const profileContext = useProfileContext() - const isLoggedIn = profileContext.isLoggedIn + const authCtx: AuthCtx = useAuth() + const { timesheets }: UseFetchEngagementTimesheetsResult = useFetchEngagementTimesheets() + const isAdminOrManager = authCtx.isAdmin || authCtx.isTm || timesheets.length const tabsConfig = useMemo[]>(() => { const tabs: TabsNavItem[] = [ { id: 'opportunities', title: 'Engagement Opportunities' }, ] - if (isLoggedIn) { + if (authCtx.isLoggedIn) { tabs.push( { id: 'applications', title: 'My Applications' }, { id: 'assignments', title: 'My Assignments' }, ) } + // if (isAdminOrManager) { + // tabs.push({ + // id: 'timesheets', + // title: 'Timesheets', + // }) + // } + return tabs - }, [isLoggedIn]) + }, [authCtx.isLoggedIn, isAdminOrManager]) const activeTab = useMemo( () => (tabsConfig.some(tab => tab.id === props.activeTab) ? props.activeTab : 'opportunities'), @@ -50,6 +62,11 @@ const EngagementsTabs: FC = (props: EngagementsTabsProps) return } + if (tabId === 'timesheets') { + navigate(`${rootRoute}/timesheets`) + return + } + navigate(rootRoute || '/') }, [navigate]) diff --git a/src/apps/engagements/src/components/index.ts b/src/apps/engagements/src/components/index.ts index 59fe18061..b8064ed83 100644 --- a/src/apps/engagements/src/components/index.ts +++ b/src/apps/engagements/src/components/index.ts @@ -11,3 +11,9 @@ export * from './assignment-offer-modal' export * from './assignment-card' export * from './engagements-tabs' export * from './terms-agreement-modal' +export * from './engagement-managers' +export * from './timesheet-grid' +export * from './timesheet-approve-modal' +export * from './timesheet-audit-modal' +export * from './timesheet-reason-modal' +export * from './timesheet-submit-modal' diff --git a/src/apps/engagements/src/components/timesheet-approve-modal/TimesheetApproveModal.module.scss b/src/apps/engagements/src/components/timesheet-approve-modal/TimesheetApproveModal.module.scss new file mode 100644 index 000000000..2188b9089 --- /dev/null +++ b/src/apps/engagements/src/components/timesheet-approve-modal/TimesheetApproveModal.module.scss @@ -0,0 +1,22 @@ +@import '@libs/ui/styles/includes'; + +.body { + display: grid; + gap: 16px; +} + +.summary { + margin: 0; +} + +.field { + display: grid; + gap: 6px; + + textarea { + padding: 8px; + border: 1px solid $black-40; + border-radius: 6px; + resize: vertical; + } +} diff --git a/src/apps/engagements/src/components/timesheet-approve-modal/TimesheetApproveModal.tsx b/src/apps/engagements/src/components/timesheet-approve-modal/TimesheetApproveModal.tsx new file mode 100644 index 000000000..648d70296 --- /dev/null +++ b/src/apps/engagements/src/components/timesheet-approve-modal/TimesheetApproveModal.tsx @@ -0,0 +1,114 @@ +import { FC, FocusEvent, useEffect, useState } from 'react' + +import { BaseModal, Button } from '~/libs/ui' + +import { formatHoursLabel } from '../../lib/utils' + +import styles from './TimesheetApproveModal.module.scss' + +interface TimesheetApproveModalProps { + open: boolean + entryCount: number + /** Exact decimal total, e.g. `42.50`. */ + totalHours: string + isApproving?: boolean + /** + * True when an administrator is approving on a manager's behalf: the API then requires an override + * reason, and the dialog says so rather than failing the request. + */ + requiresOverrideReason?: boolean + onCancel: () => void + onConfirm: (approvalComment: string, overrideReason?: string) => void +} + +/** + * Approval confirmation. + * + * The comment is required by the API and gates the button here, because an approval with no comment is + * a decision nobody can later explain. + */ +const TimesheetApproveModal: FC = (props: TimesheetApproveModalProps) => { + const [approvalComment, setApprovalComment] = useState('') + const [overrideReason, setOverrideReason] = useState('') + + useEffect(() => { + if (props.open) { + setApprovalComment('') + setOverrideReason('') + } + }, [props.open]) + + const missingOverrideReason = Boolean(props.requiresOverrideReason) && !overrideReason.trim() + const canApprove = Boolean(approvalComment.trim()) && !missingOverrideReason + + return ( + +