31 Commits

Author SHA1 Message Date
4d3f1f9760 Implemented a tooltip on the SR to display the Internal SR 2026-09-29 13:53:38 -03:00
a182609415 Implemented the functionality to add a WL Action 2026-09-28 17:32:32 -03:00
1a3decbeb6 Replaced labels with icons in the SR and Action badges 2026-09-27 19:18:24 -03:00
de4da0eec6 Implemented filters for Service Requests and Actions 2026-09-25 16:37:29 -03:00
cc567c8ff1 Added the Refresh All button to invalidate caches and force requests to be sent again 2026-09-25 15:26:09 -03:00
6a3e9e6adf Optimized requests to retrieve SRs and Actions 2026-09-25 11:08:02 -03:00
86973f7542 Updated the Service Requests badge to display the hours logged on the SR 2026-09-24 19:17:44 -03:00
8371f4e89e Release 1.0.2 2026-09-24 17:26:47 -03:00
451c27e9d7 User profile controls that hide non-sales features from users outside sales 2026-09-24 17:23:50 -03:00
f74071e39e Badge indicator showing whether an opportunity has an associated Workload Workbench Action 2026-09-24 17:00:32 -03:00
f9595b94e0 Badge indicator showing whether an opportunity has an associated Service Request 2026-09-24 14:03:34 -03:00
b8c493fc20 Minor layout responsiveness adjustments 2026-09-23 23:54:22 -03:00
b92d75f558 Added a badge to display the monthly ramp 2026-09-23 23:24:31 -03:00
3ef0d2aafd Visual improvements to debug mode for better information visibility 2026-09-23 18:19:35 -03:00
6c5db94aaa Visual enhancements for dark mode, along with search functionality for service requests and installed base 2026-09-23 10:56:51 -03:00
b4d8d9dbc1 Implemented the customer rate card view, including SKU search 2026-09-22 20:25:41 -03:00
9ac5ffb11d Implemented the Plan Detail view to display consumption overages or forfeitures 2026-09-22 19:17:53 -03:00
ea164cdcd6 Implemented the customer service requests view 2026-09-22 17:55:38 -03:00
3ee8eb6f21 Implemented the customer installed-base view 2026-09-22 16:30:07 -03:00
8d73954362 Repositioned the opportunity and customer control buttons 2026-09-22 12:07:12 -03:00
eed4189a07 Adjustment to the request debug view functionality 2026-09-18 16:46:47 -03:00
ac817a69d4 General source code refactoring 2026-09-17 12:07:01 -03:00
4421b28efe AI-powered summarization and next-step suggestions implemented 2026-09-16 19:12:25 -03:00
003035a3a4 Implemented a retry mechanism for retrieving the SPA token 2026-09-15 12:25:09 -03:00
cd6b60ddcd Release version 1.0.1 2026-09-08 18:58:30 -03:00
5b1a35430a Implementation of the customer consumption display 2026-09-07 16:11:00 -03:00
f0fb99d1ef Implemented the Account Plan request from the SPA 2026-09-07 14:10:29 -03:00
e4cfb3fa9c Implementation of the opportunity product view 2026-09-07 00:36:56 -03:00
7fef10abd3 Bug fix for restoring the Search Opportunities input 2026-09-03 17:59:41 -03:00
6f8b2beea3 Translate README to English 2026-09-02 18:09:24 -03:00
911da359e3 Rename period filter label to Reference 2026-09-02 18:07:10 -03:00
162 changed files with 39380 additions and 10534 deletions

339
README.md
View File

@@ -1,50 +1,339 @@
# Opportunities Extension 1.0.0
# Opportunities Extension 1.0.2
Extensao de navegador para adicionar o atalho "Opportunities Extension" nas paginas Oracle Fusion permitidas, consultar oportunidades e exibi-las em uma tabela filtravel.
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table.
## Estrutura de desenvolvimento
## Development Structure
- `src/content.js`: script unico mantido no projeto.
- `scripts/build.mjs`: gera os pacotes especificos para cada navegador.
- `dist/chromium/`: output para Google Chrome e Microsoft Edge.
- `dist/firefox/`: output para Firefox.
- `src/content/`: page integration, modal shell, theme and content composition.
- `src/background/`: synchronous message registration and background composition.
- `src/features/`: opportunities, products, consumption, AI summaries, preferences and debug.
- `src/auth/`: credential-specific selection, reuse and session recovery.
- `src/services/`: Fusion/SPA operations and background HTTP/JSON transport.
- `src/platform/`: narrow browser messaging, cookies, tabs and storage adapters.
- `src/shared/messages.js`: names of the existing cross-context messages.
- `scripts/build.mjs`: generates browser-specific packages.
- `tests/`: isolated synthetic regression tests and a visual fixture.
- `docs/`: architecture/contracts and incremental refactoring record.
- `dist/chromium/`: output for Google Chrome and Microsoft Edge.
- `dist/firefox/`: output for Firefox.
## CRM Service Request Badges
The header's **Refresh all** button (between theme and Preferences) invalidates
persisted Products and activity caches and clears in-memory data views. It reloads
the selected Reference, fetching fresh products and then the permitted SR/Actions
queries. Customer details, rate cards, consumption charts and AI summaries are
requested again when opened. Preferences, cookies and authentication are preserved.
The button is disabled during invalidation and list reload; product/activity badges
continue to show subsequent background progress. Cache-clear failures are surfaced
instead of silently reusing cached data, and stale product responses cannot restore
an invalidated cache.
On the first Opportunities modal opening in a page session, `requestCurrentUser`
fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls,
Reference changes and modal reopenings reuse that result. Users with `SALES_REP`
do not trigger CRM Service Request or Workload Actions queries or see their badges.
If identity or roles cannot be validated, only these two features stay disabled;
other opportunity features continue. Reload the page to refresh identity/roles.
The unused identity token is discarded and redacted from debug responses.
Consumer token recovery also handles HTTP 401/403, in addition to HTTP 200 with
invalid JSON. It opens the existing SPA session URL in an inactive temporary tab,
preserves the originating window and Firefox cookie store, and retries within
the same five-attempt limit. Debug entries retain the HTTP status and recovery
reason. The auxiliary tab is closed after valid JSON or exhausted retries.
After a completed product-loading cycle with fresh API results, `resquestCrmServiceRequest` fetches all pages of
`CRMServiceRequest` records with `SVC_PENDING_DELIVERY` and `ORA_SVC_MY_SR_TEAM_SRS`,
using the existing general tokenrelay Bearer. It uses the Fusion `searchResources/latest`
query endpoint, independently of the opportunity query version. Calls appear in
the Service Requests debug category with masked credentials.
After a complete successful SR query (HTTP 200 with valid responses on all pages),
its result is reused for subsequent Reference/product cycles in the current page
session. Refresh all explicitly clears it. Failed pages are retried up to five
total attempts each; completed pages are not requested again. Resetting the query
stops further retries from the previous run.
Records are linked by `OpportunityName_Id_c.OptyNumber`. A green SR badge indicates
one or more matches; its tooltip lists each SR number, title, reported hours, owner,
email and contact flags. A red badge indicates no match within that query's scope.
A failed lookup shows `SR ?`, not a false no-match result, and opportunities still load.
Badges follow Ramp Months. Successful SR and Actions responses are persisted in
extension local storage, isolated by tokenrelay principal. The cache uses the same
Preferences lifetime as Products (24 hours by default), anchored to the product
timestamp rather than the activity response time. Actions entries match each
product's update; the SR snapshot matches the latest product update in the cycle.
SR snapshots are stored separately by update timestamp so changing Reference does
not overwrite a previous period's snapshot. Expired snapshots are removed on
subsequent SR cache writes; Refresh all invalidates every snapshot.
No credentials, request headers or debug envelopes are stored in this cache.
Products loaded from cache restore matching activity entries without network calls.
Missing, expired or mismatched entries are not treated as confirmed empty results;
they wait for a real product refresh before a new activity query is allowed.
Empty and entirely failed product cycles do not query activities. In mixed cycles,
only fresh products permit activity API calls on cache misses; an already-loaded
SR result is reused and associated with the new product update.
Activity lookups do not advance the existing Last update timestamp.
Closing and reopening the modal preserves results and does not
reload products or trigger these lookups. Already-running product requests may
finish in the background and update their related activities.
## Workload Actions
The missing Act badge includes a separate plus control that opens an Action form
with Owner, Role, Action, Team, Start date, End Date, Status, Complexity and Notes.
Opening the form loads team members and action lookups using the WWB-TS token.
Team members use the product RevnId; when multiple workloads are available, the
user selects one before its members are requested. Owner options use email for
both label and value; lookup options display name and submit code.
All Action selects support local autocomplete by label or code, arrow-key
navigation and Enter selection. Escape or leaving the field restores the last
selected option; typed search text is never submitted as an option value.
Fields are enabled only after both metadata requests return HTTP 200. Loading,
errors and manual retry are supported, and debug logs mask the credential.
Save validates all required fields and the date range before posting to the WWB
actions endpoint. Dates are sent at midnight UTC in ISO format; notes are an
array of strings (empty when omitted). The current user.id supplies createdBy
and updatedBy. While saving, editing and dismissal are locked to prevent duplicate
submissions. Success closes the form and displays a toast; failure preserves the
form and displays an error toast without automatically retrying the POST.
Cancel or Escape dismisses an idle form. The form is unavailable to SALES_REP
users, like the other Action features.
SR and Action filter badges appear before Status for users allowed to view these
activities. They filter the loaded data without issuing requests and combine with
the existing opportunity filters. The SR below 4h option replaces the other SR
selections and matches at least one linked SR with a numeric `ReportedHours_c < 4`
(including zero, excluding missing values). Action matches use the same ownership
rule as the row badge. Loading/failed lookups are not treated as missing records;
with both positive and negative options selected, all activity states remain
visible. These selections are kept in memory, not persisted.
Workload Actions are queried in the background after the product cycle completes,
and only after SR has completed successfully (or was restored/reused). If SR is
unavailable, Actions are not requested and remain unknown rather than absent.
Each failed workload lookup gets up to five total attempts, stopping on success
or when its session is superseded. Cached products still do not trigger network
lookups. Each distinct `RevnId` uses `requestWlActions` with
the `WWB-TS` credential in `Wwb-Provider-Authorization`. The Act badge beside SR
aggregates all products: green means an action's `owner` matches the general
tokenrelay response's `principal`; red means no matching owner was found. `Act ?`
means ownership could not be confirmed (including a missing principal). Tooltips
retain all returned actions, regardless of owner. Credentials stay in memory only;
the principal is used to scope persisted activity entries.
Queries are shared per product cycle (up to four concurrently). Reference changes
that load products from cache do not refresh activities.
Only the first page is requested for now; `hasNext` is indicated in the tooltip.
The debug panel groups these requests under Actions, with credentials masked.
## Build
Use o Node.js:
Use Node.js:
```bash
npm run build
```
O build recria `dist/chromium` e `dist/firefox` a partir da mesma fonte.
The build updates `dist/chromium` and `dist/firefox` from the same source.
It preserves the output directories and unchanged files, replacing changed files
only after their complete contents have been written to a temporary file. Scripts
are published before the manifest, avoiding missing or partially written scripts
while an unpacked extension is loaded. Reload the extension and the Oracle page
after updating; clear historical extension errors to check for new failures.
Do not edit files under `dist` directly. Both targets use Manifest V3, with a
Chromium service worker and Firefox background scripts. The build composes classic
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
context. It serializes `src/content/styles/redwood.css` followed by the scoped
`src/features/debug/styles.css`, `src/features/opportunities/styles.css`,
`src/features/install-base/styles.css`, `src/features/service-requests/styles.css`
`src/features/consumption/plan-detail.css` and `src/features/ratecard/styles.css` at
build time. There is no
runtime module loader, browser-side eval, ES-module requirement or new dependency.
## Tests and Maintenance
Installed Base and Service Requests include a search input above their tables.
Search filters loaded records across every displayed column, including raw and
formatted numbers/dates, without additional API calls. Matching ignores case
and accents; all whitespace-separated terms must match somewhere in the row.
The result count updates as users type. Clearing the input restores all records;
reopening a dialog starts with an empty search. Search remains available during
loading and retains the entered terms when results arrive. Both themes are supported.
Each Cloud Consumption plan includes a **Rate card** button beside Contract ID.
It opens an animated right-side drawer with skeleton rows, an all-column search
and sortable headers. The drawer reuses the Cloud Consumption header component
with the title **Rate Card**, customer subtitle, and the active plan's Booking
Customer, Sold To Party Name, Contract ID, Header Status, Start Date and End Date.
There is no separate plan-number subtitle or nested Rate Card action; its Close button
dismisses only the drawer. `requestRatecardByPlan` sends the active plan's `subPlanNum`
as the payload's `planNumber`, with `recordsSize: 10000`, to the fixed SPA endpoint.
It uses the existing OCICONS-TS `uiaasHeader.value` in `G2m-Authorization` and is
logged in the Consumption debug category with the existing credential masking.
The eleven requested fields are displayed, including percentage discounts and
USD prices with at least four decimal places. Search includes raw and formatted
values; prices, quantities, percentages and dates sort by value. Only the drawer
table scrolls, horizontally when its available width is below 1100px.
Closing the drawer, changing plans or closing Cloud Consumption discards late UI
updates. Concurrent requests for the same plan are shared; reopening after completion
fetches again. Errors remain local. No new persisted cache, permissions, token
refresh or retries are added. The provided contract requests up to 10000 records;
the response's `searchAfter` is not used without a defined pagination contract.
`tests/ratecard.test.cjs` covers transport, formatting, filtering, sorting and
drawer lifecycle with synthetic responses and DOM, without launching browsers.
Cloud Consumption loads `requestConsumptionPlanDetail` for the active plan after
account plans arrive, independently of the chart. Selecting another plan loads
its `subPlanNum` on demand. The GET uses `active=true` and the raw OCICONS-TS
`uiaasHeader.value` in `G2m-Authorization`, through the existing consumer session.
Projection badges align with the chart container's left edge, while the
Quarterly/Monthly/Weekly/Daily controls stay on the right of the same header row:
red Forfeit, blue Overage.
Hover or keyboard focus reveals an interactive tooltip; clicking pins it for
access to the Order number links. Escape, Close, outside clicks, tab changes and
modal closure dismiss it. Each root line retains its nested `clines` details.
Only HTTPS order links on `accountdashboard.oracle.com` become clickable.
Amounts use the requested non-LC fields, formatted without inferring a local
currency. Details are kept only in the current modal, with in-flight deduplication
and local loading/empty/error states. Failed details do not block the chart.
The existing Consumption debug category includes this request.
The Service Requests action sits immediately after Installed Base below Customer.
Its dialog opens with skeleton rows and lists open SRs for the selected customer.
The background first calls `requestAccountSummary` with ACCP-TS, then uses its
`id` (not `registryId`) in the GET `requestCustomerServiceRequest` URL. The SR
credential is `uiaasHeader.value` from `SPA-ORANGE`, sent verbatim in
`custsr-auth`, without a Bearer prefix, regardless of `uiaasHeader.label`.
Pages of 40 are consolidated using `totalResults`, `count`
and `offset`. Concurrent clicks for the same customer share the in-flight query;
results are not cached, and closed/replaced dialogs ignore late responses.
The table maps Name/Registry ID from `customer`, Product Name from `component`,
and formats status as a badge and dates using the existing date formatter.
Debug contains a Service Requests category; credential headers are masked before
delivery to content. This feature adds no permissions or authentication retries.
The Installed Base action sits beside customer consumption below Customer. It
opens a skeleton dialog, then calls `requestAccountSummary` with ACCP-TS and uses
the returned `registryId` in `requestInstallBase`. The latter uses the SAIB-TS
`uiaasHeader.label` and `uiaasHeader.value`, and loads pages of 25 until `hasMore`
is false. Missing credentials, invalid collections and stalled pagination show
an error rather than partial results. These requests appear in debug; credential
headers in this flow are masked before delivery to the content script. No new
cache, permissions or session recovery logic is introduced. Amounts use two
decimal places without inferring a currency from the separate LC fields.
The opportunities table places product counts below Name and the customer
consumption action below Customer, without a separate Actions column. Compact
columns retain their values; Name and Customer share the remaining width while
Owner is fixed at 132px. Both text cells reserve at least two text lines, and
their controls stay anchored 10px above the row bottom, including while products
load. Longer text can grow the row without overlapping controls. Horizontal
scrolling is reserved for table containers below
1120px. The scoped table stylesheet does not change product-table column widths
or the existing light/dark colors.
Product HTTP requests appear in Debug Requests (Ctrl+D) as soon as they start.
Each entry identifies the opportunity and page offset, stays `running` while
waiting for headers or the response body, and updates with HTTP status, response,
duration or error. Cache hits do not create HTTP entries. Product diagnostics
mask authorization and sensitive response headers before messaging the source
tab/frame; this change adds no timeout, retry or authentication behavior.
Debug Requests opens as a full-page view inside the extension using Ctrl+D, without
opening a browser tab or entering native browser fullscreen. Close, Escape or Ctrl+D
returns to the underlying view. Header/category navigation remains above the
scrollable timeline; expanded details use two columns on wide screens and one on
narrow screens. Theme-specific colors distinguish success, failure, running,
HTTP methods/statuses, request/response sections and JSON tokens. Highlighting
uses safe text nodes only after the existing sensitive-value masking. Compact JSON,
supported protection prefixes and JSON documents wrapped in strings are normalized
and indented, including while sensitive values are visible. Large JSON responses
are captured as bounded, valid JSON previews with an explicit omission notice,
preserving indentation, highlighting and sensitive-field masking. Already incomplete
or invalid JSON remains plain text. The visibility toggle retains its existing
behavior. Focus, expansion and timeline scroll position survive log updates.
Debug tabs group Authentication, Opportunities, Products, AI, Consumption, Installed Base and Service Requests
requests; unknown request types appear under Other. Only categories with entries
are shown. All preserves the complete timeline. Every tab sorts by request start
time, retaining original request IDs and showing start times in collapsed rows.
Counts and running states update without changing the selected category or
collapsing expanded details. Use arrow keys, Home and End to navigate the tabs.
```bash
npm test
npm run check
```
Tests use built-in Node.js modules and synthetic credentials, without real Fusion
requests. The characterization suite verifies manifests, source/output equality,
fiscal dates, payloads, preferences, pagination, cache/progress and both browser API
styles. Architecture tests enforce source inclusion, explicit factory dependencies,
isolated state and unchanged CSS. These complement consumption, session recovery
and AI suites. `check` also fails if generated packages are stale.
There is no separate lint configuration or runtime dependency installation.
See [architecture and contracts](docs/architecture.md) and the
[incremental refactoring record](docs/refactoring.md) for execution boundaries,
authentication, storage, known risks and verification limits.
Mock-based tests do not replace testing the installed extension in each browser.
Without npm, run `node scripts/build.mjs`, `node scripts/test.cjs` and
`node scripts/build.mjs --check`. Development checks were run with Node 24.19.0.
For a local UI smoke test, run `node scripts/preview.mjs` and open the loopback URL
printed by the command. The fixture uses synthetic opportunities, tokens, products,
AI text and consumption; unexpected requests fail closed. It does not authenticate
against Fusion or read real extension storage. `/consumption-trend.html` provides
a focused chart fixture. Stop the server with Ctrl+C when finished.
## Release 1.0.0
- Consulta paginada de oportunidades com filtros por periodo, customer, owner, status, stage e busca textual.
- Ordenacao por coluna, links para oportunidades e contas, copia do numero da oportunidade e indicadores por stage.
- Preferencias de tipos de oportunidade persistidas no navegador.
- Tema claro/escuro baseado no Oracle Redwood.
- Outputs separados para Chromium/Edge e Firefox.
- Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search.
- Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators.
- Opportunity type view preferences persisted in the browser.
- Background product retrieval with configurable caching, progress tracking, and expandable product details.
- Cloud consumption account-plan lookup from each opportunity, displayed in a tabbed detail modal.
- Light and dark themes based on Oracle Redwood.
- Separate outputs for Chromium/Edge and Firefox.
## Como carregar em modo desenvolvimento
## Release 1.0.1
### Chrome ou Microsoft Edge
- Retries the consumer token service when it returns HTTP 200 with an HTML fallback.
- Opens the SPA session page in an inactive temporary tab and closes it as soon as valid JSON is received.
- Limits the recovery flow to five attempts and records the session bootstrap details in debug mode.
1. Abra `chrome://extensions` ou `edge://extensions`.
2. Ative o modo de desenvolvedor.
3. Clique em "Carregar sem compactacao".
4. Selecione a pasta `dist/chromium`.
## Release 1.0.2
- Modularized the extension with regression tests and documented execution boundaries.
- Added AI opportunity summaries with a dedicated, reusable Sales Common access token.
- Added Installed Base and customer Service Requests tables with search and dark-mode support.
- Expanded Cloud Consumption with plan projections, interactive details and a searchable, sortable Rate Card drawer.
- Added Ramp Months, CRM Service Request and Workload Actions badges; SR details include direct links and copy controls.
- Added a one-time current-user lookup before the initial request flow. Users with `SALES_REP` do not query or display CRM SR/Actions.
- Improved debug request grouping, full-page viewing, response formatting and theme contrast.
- Improved compact layouts, customer actions, session recovery and completion-badge fade-out behavior.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses automated, browser-independent tests; installed-browser smoke tests were not performed for this release.
## Loading in Development Mode
### Chrome or Microsoft Edge
1. Open `chrome://extensions` or `edge://extensions`.
2. Enable Developer mode.
3. Click **Load unpacked**.
4. Select the `dist/chromium` folder.
### Firefox
1. Abra `about:debugging#/runtime/this-firefox`.
2. Clique em "Carregar extensao temporaria".
3. Selecione o arquivo `dist/firefox/manifest.json`.
1. Open `about:debugging#/runtime/this-firefox`.
2. Click **Load Temporary Add-on**.
3. Select the `dist/firefox/manifest.json` file.
## Paginas atendidas
## Supported Pages
- `https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome`
- `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome`
Ao encontrar o grupo `#yourapps_groupNode_sales`, a extensao adiciona o tile antes do item `.flat-grid-cell.flat-grid-cell-addicon`. Ao clicar no tile, abre a interface de oportunidades.
When it finds the `#yourapps_groupNode_sales` group, the extension adds the tile before the `.flat-grid-cell.flat-grid-cell-addicon` item. Clicking the tile opens the opportunities interface.

File diff suppressed because it is too large Load Diff

9801
dist/chromium/content.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -2,12 +2,15 @@
"manifest_version": 3,
"name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.0",
"version": "1.0.2",
"permissions": [
"cookies"
"cookies",
"storage"
],
"host_permissions": [
"https://eeho.fa.us2.oraclecloud.com/*"
"https://eeho.fa.us2.oraclecloud.com/*",
"https://spa.oracle.com/*",
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
],
"content_scripts": [
{

File diff suppressed because it is too large Load Diff

9801
dist/firefox/content.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -2,12 +2,15 @@
"manifest_version": 3,
"name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.0",
"version": "1.0.2",
"permissions": [
"cookies"
"cookies",
"storage"
],
"host_permissions": [
"https://eeho.fa.us2.oraclecloud.com/*"
"https://eeho.fa.us2.oraclecloud.com/*",
"https://spa.oracle.com/*",
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
],
"content_scripts": [
{

208
docs/architecture.md Normal file
View File

@@ -0,0 +1,208 @@
# Architecture and Compatibility Contracts
This document describes the integrated modular implementation. Verification and
preexisting risks are tracked in [refactoring.md](refactoring.md).
## Sources, Outputs, and Runtime Boundaries
- Editable sources are factory modules under `src/`; the former monoliths were removed.
- `scripts/sources.cjs` explicitly lists modules for each context. `compose.cjs`
validates syntax, serializes CSS, wraps the modules in an IIFE and calls the
corresponding application factory/start method. `build.mjs` writes two packages.
- `dist/chromium` serves Chrome and Edge; `dist/firefox` serves Firefox. Do not edit outputs.
- Both manifests use V3. Chromium has a classic background service worker; Firefox
uses `background.scripts`. Neither entry point is declared as a module.
- Content scripts run at `document_idle`, in all matching frames on the two
configured Fusion welcome pages. Their code also checks the hostname/path.
- There are no extension UI pages, injected page-context scripts, custom CSP,
declared execution world, or web-accessible resources.
- Permissions remain `cookies` and `storage`, with host access limited to the
configured Fusion host and `spa.oracle.com`.
Content owns the DOM and per-frame state. Background owns privileged cookies,
temporary tabs, extension storage and SPA requests. These contexts communicate
using serializable runtime messages, not shared variables. Page localStorage is
different from extension storage and is not a safe place for credentials.
## Current Ownership and Side Effects
| Context | Responsibilities | State and effects |
| --- | --- | --- |
| Content | Fusion tile and main/preferences/consumption modals | DOM nodes, injected CSS, keyboard/pointer/resize listeners, MutationObserver |
| Content | Opportunity query and presentation | Filter sets, sorting, search, request versions, column formatters, dashboard animation |
| Content | Products UI | Current request ID, product map, expanded rows, progress, last update |
| Content | Consumption UI | Plan state, per-plan/period trend cache, chart navigation and selected series |
| Content | Plan projection details | Per-panel WeakMap, shared in-flight requests, nested tooltip DOM, temporary pointer/resize listeners cleaned up on dismiss/tab change/modal close |
| Content | Rate card | Pure column/filter/sort model, controller with per-plan in-flight sharing, animated drawer with skeleton/search/table; response guards and cleanup on close or plan switch |
| Content | Service Requests UI | Customer-specific in-flight queries, skeleton/table modal, stale-response guards; no persistent cache |
| Content | AI summary | Per-opportunity in-flight state and summary; fresh generation on expansion |
| Content | Authentication/debug | General and scoped token promises, SPA authorizations, request logs, visibility flag |
| Background | Message dispatcher | Synchronously registered listener, asynchronous replies using `return true` |
| Background | Products | Four workers, per-opportunity in-flight map, lazy cache read and serialized writes |
| Background | Cookie/session recovery | Inactive tabs, polling/retry delays, cleanup in `finally` |
| Background | SPA operations | HTTP/JSON parsing, account/plan extraction and debug envelopes |
Closing the main modal hides it rather than destroying it. Many requests can
continue while it is hidden. Version and request-ID checks suppress stale UI
updates; they do not abort network traffic. No AbortController is currently used.
Some listeners have window guards; there is no general disposal lifecycle.
## Authentication and API Routing
Never substitute one token family for another or assume an opaque token is a JWT.
Production tokens are held in memory, not deliberately persisted by the extension.
| Credential | Acquisition | Consumers | Lifetime/recovery |
| --- | --- | --- | --- |
| XSRF | Background cookie lookup, then content `document.cookie` fallback | `x-xsrf-token` for both tokenrelays | Cookie recovery tab on missing/invalid response; existing retry rules |
| Fusion general | GET `/fscmRestApi/tokenrelay` | Opportunity POST and product GET Bearer | Content promise/cache; no expiry-based refresh equivalent to Sales Common |
| Sales Common | GET `/fscmRestApi/tokenrelay?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F` | AI summary Bearer | In-memory `expires_in`, shared in-flight acquisition; at most one summary retry after 401/403 |
| ACCP-TS | SPA consumer service `uiaasHeader.value` | Account summary authorization | Retrieved with the consumer service response; expiry recorded, not automatically acted on |
| OCICONS-TS | SPA consumer service `uiaasHeader.value` | Account plans, consumption trend, plan detail and rate card authorization | Same service response, distinct credential from ACCP-TS |
| SPA-ORANGE | SPA consumer service `uiaasHeader.value` | Customer service requests, fixed `custsr-auth` header with raw value, no Bearer prefix; ignores `uiaasHeader.label` | Optional at consumer acquisition; required only when opening Service Requests; existing in-memory session rules |
Fusion calls use `https://eeho.fa.us2.oraclecloud.com`; SPA calls use
`https://spa.oracle.com/oalcrm/web/api/`. Preserve `credentials: include` and
operation-specific headers rather than unifying all calls under a default token.
| Request | Method/path | Important contract |
| --- | --- | --- |
| requestOpportunities | POST `/crmRestApi/searchResources/11.13.18.05/custom-actions/queries` | RecordSet OR plus EffectiveDate AND, original fields, Preference transient, pages of 15 |
| requestOpptyProducts | GET `/crmRestApi/rest/rv:be91c002-2e5d-4ed2-a37e-e0c837bf141f/en/11.13.18.05:9/opportunities/{number}/child/ChildRevenue` | Encoded identifier, original query/fields/order, count/offset pagination, maximum 250 pages |
| requestGenerateAiSummary | POST `/api/sales-common/gen-ai/v1/summarizations/generate` | `{path,promptCode,object,puid}`; prompt `cx.sales.generate_opportunity_summary`; only summaryText displayed |
| requestConsumerTokenService | GET `g2m-consumer-application/consumerTokenService` | No-store; parse JSON; HTML 200 recovery via inactive SPA UI tab; maximum five HTTP attempts |
| requestAccountSummary | POST `provider-proxy/g2m-account/service/accountsummary` | ACCP-TS; `[{key:"regId",value:partyNumber}]` |
| requestCustomerServiceRequest | GET `provider-proxy/g2m-custsr-provider/service/oscSr/{accountSummaryId}` | SPA-ORANGE; summary `id`, not `registryId`; encoded filter `{hierarchy:"single",status:"open"}`, creationDate descending, pages of 40 using totalResults/count/offset |
| requestAccountPlans | POST `provider-proxy/g2m-consumption/service/v1/account/accountPlans/{id}` | OCICONS-TS; original hierarchy/products/language/planStatus payload |
| requestConsumptionService{Period} | GET `provider-proxy/g2m-consumption/service/v1/plan/trend/{subPlanNum}?quarter=All&timePeriod={period}` | G2m-Authorization; quarterly/monthly/weekly/daily; weekly default |
| requestConsumptionPlanDetail | GET `provider-proxy/g2m-consumption/service/v1/plan/plandetail/{subPlanNum}?active=true` | Raw OCICONS-TS in G2m-Authorization; array of lines with optional nested clines; no retry or persisted cache |
| requestRatecardByPlan | POST `provider-proxy/g2m-consumption/service/v1/ratecard/byPlan` | `{planNumber,recordsSize:10000}`, where planNumber is the selected plan's subPlanNum; raw OCICONS-TS in fixed G2m-Authorization; response must be an array of records; no retries, cursor pagination or persistent cache |
SPA parsing removes BOM and the existing anti-JSON-hijacking prefixes before
JSON.parse. Content uses response.json directly. Do not silently unify these behaviors.
POST retry policies require operation-specific review; do not add generic retries.
## Message Contracts
All names below have the `opportunitiesExtension.` prefix. Unknown messages return
false without a response. Supported background messages return true immediately
and later call sendResponse. `platform/background-api.js` centralizes the existing
callback/promise differences. `platform/content-messages.js` retains the original
cookie callback path as well as general runtime messaging; no browser behavior
was silently changed during extraction.
| Message | Input | Result |
| --- | --- | --- |
| getXsrfToken | type | ok, cookieName, token, matchedCookieNames, lookupDetails; error on failure |
| refreshXsrfCookie | type; sender tab context | ok, refreshed, cookieName, durationMs, error |
| requestOpptyProducts | requestId, accessToken, optyNumbers, cacheMaxAgeMs | ok, requestId, completed, total, failed, cacheHits; error envelope on dispatcher failure |
| opptyProductsProgress | Background to sender tab/frame | requestId, state, counters; per-item optyNumber/items/fromCache/cachedAt or error |
| requestConsumerTokenService | type; sender tab context | ok, authorizations.accountSummary/accountPlans/installBase/customerServiceRequests, debugRequests |
| requestCustomerServiceRequest | partyNumber, authorizations.accountSummary/customerServiceRequests | ok, items (consolidated response.objects), debugRequests; error envelope on failure |
| requestAccountPlans | partyNumber, authorizations | ok, accountPlanId, plans, debugRequests |
| requestConsumptionTrend | subPlanNum, period, authorization | ok, subPlanNum, period, items, debugRequests |
| requestConsumptionPlanDetail | subPlanNum, authorization | ok, subPlanNum, items (including clines), debugRequests; error envelope on failure |
| requestRatecardByPlan | planNumber, authorization | ok, planNumber, items, debugRequests; error envelope on failure |
SPA failures return `{ok:false,error,debugRequests}`. Progress states include
`running`, `complete` and `complete-with-errors`; content additionally tracks idle
and error states. A new content request ID rejects progress from an older job.
Do not expose a caller-provided arbitrary request URL through the background.
## Storage Contracts
| Location/key | Value |
| --- | --- |
| Page localStorage: `opportunities-extension-filter-preferences` | JSON object: period, stages, statuses, customerKeys, ownerKeys, customerSearch, ownerSearch, tableSearch; unset customer/owner selections may be null |
| Page localStorage: `opportunities-extension-theme` | `light` or `dark` |
| Page localStorage: `opportunities-extension-opportunity-type-preferences` | JSON object: opportunityTypeValues, opptyProductsCacheHours |
| Extension storage.local: `opportunitiesExtension.opptyProductsCache.v1` | Object indexed by OptyNumber, each entry `{fetchedAt,items}` |
Product TTL defaults to 24 hours and is capped at 30 days in background. Storage
failures are tolerated for the active view. Recreating background reloads stored
products but does not restore in-flight jobs. Cache TTL does not physically delete
expired entries. Account/container isolation limitations are tracked separately.
## UI Contracts
Preserve labels/IDs/classes, column order, URLs, clipboard behavior, sort/filter
semantics, formatting, theme colors, keyboard shortcuts and row expansion.
WON includes WON_PENDING without rewriting StatusCode. Owner uses first/last name
with the existing fallbacks. Changing reference resets customers/owners to all;
closing/reopening preserves search. Fiscal years begin in June; RENEWALS currently
has no date range and therefore does not load opportunities.
Preserve SVG chart data normalization, animation, tooltips and series selection.
AI text is inserted with textContent. Debug masking and DOM isolation have known
limitations; see the separate security backlog rather than assuming full redaction.
## Module Ownership and Dependencies
`content/main.js` and `background/main.js` only initialize. Each `application.js`
is a composition root: constructs feature-owned state and factories, wires explicit
function dependencies, then exposes `start`. The returned components are available
to isolated tests, not published on the page/global object in production.
Factories do not import or instantiate peers. Features receive service/auth ports;
services/auth receive transport/platform ports; pure models receive data. The
composition roots supply callback notifications for UI updates and debug events,
preserving existing interactions without module-import cycles. Shared messages
are the only source included in both contexts, with distinct instances/state.
Some stateful UI collaborators intentionally share a feature-owned state object;
these objects are created per application, never stored in a global registry.
| Original responsibility | Integrated module(s) | Reason for boundary |
| --- | --- | --- |
| content initialization/tile | `content/main.js`, `fusion-page.js` | Page lifecycle and route guard |
| modal/theme/CSS | `content/modal.js`, `theme.js`, `styles.js`, `styles/redwood.css` | DOM shell separate from operations and cascade |
| fiscal dates/query payload | `features/opportunities/fiscal-periods.js`, `services/fusion/opportunities.js` | Pure date rules vs HTTP operation |
| table/search/filters/formatting/dashboard | `features/opportunities/` | Related presentation responsibilities, explicit state |
| saved settings | `features/preferences/storage.js`, `view.js` | Serialization/fallbacks vs dialog |
| token families | `auth/content-session.js`, `sales-common.js`, `token-relay.js`, `spa-credentials.js` | Different credentials and lifetime policies |
| cookie lookup/recovery, SPA session | `auth/xsrf-*.js`, `consumer-session.js` | Privileged recovery separate from UI |
| product queue/cache | `features/products/background-jobs.js`, `cache.js`, `services/fusion/products.js` | Scheduling, persistence and pagination |
| product progress/details | `features/products/content-jobs.js`, `details.js` | Message consumer vs row rendering |
| AI summary | `features/ai-summary/content.js`, `state.js` | Per-opportunity lifecycle, safe text and auth port |
| consumption | `features/consumption/`, `services/spa/` | Plans, chart model/view/controller vs HTTP operations |
| debug | `features/debug/content-log.js`, `content-view.js` | Retention/formatting vs DOM presentation |
| background dispatcher | `background/messages.js`, `shared/messages.js` | Stable serializable message contracts |
| browser APIs | `platform/background-api.js`, `content-messages.js` | Only APIs actually used, not a universal wrapper |
The background composition deliberately has no DOM dependency. Listener registration
is synchronous, before any storage read or request. Worker restart reconstructs
volatile state and lazily reads the existing product cache; tokens are not persisted.
Content effects (DOM, fetch, storage, animation) enter through the environment at
the composition root. localStorage access stays lazy to retain blocked-storage
fallbacks. Tests may inject effects or individual callback ports without changing
source strings. Time-sensitive modules are tested with isolated clocks.
## Adding a Feature
1. Define its message/API/storage contract and synthetic expected behavior first.
2. Add a factory under the owning feature, or a service under the correct host.
Accept narrow function dependencies; do not call another factory inside it.
3. Add its source path to the appropriate array in `scripts/sources.cjs`. Source
inclusion and duplicate factory checks run automatically.
4. Construct it in that context's `application.js`, supplying auth, platform and
render callbacks explicitly. Extend `background/messages.js` only if crossing
contexts is necessary; do not expose arbitrary destinations.
5. Add a `tests/*.test.cjs` suite using `tests/support/application.cjs` for composed
integration or `vm.Script` for an individual pure factory. Use only synthetic
data and fail unexpected network requests.
6. Run tests, build and `--check`; inspect the local fixture and load both packages
in the target browsers. Record any browser not available for verification.
As a concrete example, adding a new consumption period should first define its
request/data/authorization contract and synthetic tests, then extend the period
allowlist and normalization/presentation through the existing trend message. It
must not add a URL-proxy message or reuse the general Fusion token. These edits
belong in the consumption feature and SPA service, not the bootstrap.
## Source vs Generated Code
Distribution `content.js` and `background.js` are generated bundles and may remain
large. No copy/export/execute JavaScript generator feature was found in the source.
AI summaries are text, not executable JavaScript. No empty generators subsystem
was added. Build serialization uses JSON.stringify for CSS, retains quotes,
backslashes, Unicode and line breaks, and is syntax-checked by vm.Script. Visual
fixtures now load composed scripts normally; they no longer eval rewritten sources.

63
docs/browser-smoke.md Normal file
View File

@@ -0,0 +1,63 @@
# Native Browser Validation
This checklist complements the synthetic tests; it is not evidence of a native
pass until executed and recorded separately for each browser.
## Preconditions
- Use an authorized test profile/account. Do not delete cookies, sign out, change
account security settings or inspect credential values to force failure cases.
- Connect Chrome/Edge through Settings > Computer use for assisted browser tests.
Firefox needs a separate supported/manual test session.
- Run `node scripts/build.mjs --check` and `node scripts/test.cjs` first.
- Load `dist/chromium` using Load unpacked in Chrome/Edge, or load
`dist/firefox/manifest.json` as a temporary Firefox add-on. Verify the source
directory and version 1.0.2; avoid testing a previously installed stale copy.
- Keep debug secrets hidden. Record only status codes, request names, durations
and sanitized errors. Do not export real tokens, response bodies or account data.
- AI generation and tests requiring a deliberately expired session must use an
explicitly authorized test environment. Do not trigger live writes solely to
complete this checklist.
## Checklist
| Check | Expected result |
| --- | --- |
| Package load | No manifest/background/content syntax errors; no additional permissions |
| Both Fusion welcome routes | Exactly one tile before the add-icon item; no tile on unrelated pages |
| Modal open/close/reopen | Same search value and matching results; Esc closes the correct dialog |
| Filters and sorting | Correct customer/owner/stage/status/text combinations; WON includes WON_PENDING |
| Reference change | Customer and owner reset to all; stale responses do not overwrite new results |
| Preferences | Save/reopen retains selected record sets and cache duration; restore original settings afterward |
| Products | Progress completes; badges/counts and expanded rows agree; warm cache avoids redundant product fetches |
| AI in authorized test environment | Summary appears without replacing products; concurrent expansions share scoped-token acquisition; reopen uses valid token |
| Consumption | Dialog opens promptly, weekly chart by default; plan/period changes and series filters work |
| Themes and layout | Light/dark controls work; check long names, WON_PENDING and full update timestamps |
| Debug | Expected operation names/statuses; sensitive values remain hidden |
| Background restart | Close its inspector, allow suspension, then trigger a message; listener responds and stored product cache is reloaded |
| Multiple tabs | Progress remains scoped to requesting frame/job; no duplicate UI/listeners from normal navigation |
| Firefox containers | Test only in authorized containers; record current cookie/cache isolation limitations without silently changing behavior |
| Natural session expiry | When encountered, record recovery outcome and temporary-tab cleanup without revealing cookies or forcing sign-out |
Background interruption does not currently resume in-flight jobs. Do not mistake
that known limitation for a new regression or claim a mocked restart proves native
worker suspension. Any differences must be compared with the pre-refactor behavior.
## Execution Record
2026-09-17 preflight:
| Target | Discovery | Native test result |
| --- | --- | --- |
| Chrome | Executable version 153.0.8010.37; automation not connected | Blocked pending connection |
| Edge | Executable version 153.0.4234.32; automation not connected | Blocked pending connection |
| Firefox | No executable found at the checked standard Program Files path; installation not confirmed | Not run |
The running-process diagnostic was denied by the environment, so browser running
state was not established. Connected targets were Brave and the in-app browser;
neither was substituted for this native matrix. The user chose to connect
Chrome/Edge. All five local suites and the output consistency check passed again.
No real Fusion/SPA calls, runtime changes, release or commit were made in preflight.
For each completed run, append date, browser/version, package path, checklist
outcomes, sanitized errors and remaining gaps. Never mark unexecuted checks passed.

184
docs/refactoring.md Normal file
View File

@@ -0,0 +1,184 @@
# Incremental Refactoring Record
## Stage 1: Characterization Baseline
Stage 1 initially added tests and documentation only, before any runtime extraction.
The following records that historical baseline. The completed integration and
remaining verification gaps are recorded under "Completed Extraction Stages" below.
### Coverage Added
`tests/characterization.test.cjs` contains 22 test cases using Node's built-in
test runner, assert and vm. `tests/support/characterization.cjs` provides isolated
fixtures: no real browser cookies, account identifiers, tokens or network access.
Unexpected fetches reject rather than falling through to global fetch.
- Exact current manifests and source/output equality, with classic-script syntax validation.
- Disallowed-origin initialization and reinjection when the tile already exists.
- Fiscal boundaries, leap years, rolling quarters and unsupported RENEWALS.
- Full opportunity query payload, pagination offsets/counts and stale request versions.
- Filter storage round-trip, WON_PENDING, search, numeric sort, reference reset and malformed preferences.
- Missing-cookie tokenrelay recovery and existing 400/401/403 retry limit.
- Chrome callback and Firefox promise mocks for background dispatch, storage, cookies and tabs.
- Product pagination, default/custom TTL, concurrency limit, in-flight sharing and partial errors.
- Persisted product reads after recreating the background context; storage failures and stalled pages.
- XSRF discovery, recovery timeout, successful recovery and temporary-tab cleanup.
- SPA credential selection, account payloads, encoded identifiers and debug envelopes.
- Invalid messages, network errors, HTTP 401 and HTTP 200 invalid JSON in consumption.
Existing suites remain intact: consumption normalization/requests, consumer-session
recovery, AI summary rendering/state, scoped token reuse/expiry and bounded retry.
Tests for the current security defects are deliberately not encoded as acceptable
behavior. They belong to separately approved fixes.
### Former Temporary Test Seam
The original content IIFE had no exports, so baseline tests initially exposed
functions before initialization with a guarded source marker. That temporary
mechanism is now removed. Tests use the actual application factories through
`tests/support/application.cjs`, without replacing source strings. Background
integration tests still execute the production listener and asynchronous replies.
### Running Checks
```sh
npm test
npm run check
```
Without npm, use the Node commands in package.json individually. The new suite is:
```sh
node tests/characterization.test.cjs
```
At baseline, `build.mjs --check` did not compare output contents. It now checks
both generated manifests and both scripts byte-for-byte against composition.
Characterization tests retain the exact manifest expectations.
Stage-1 verification on Node.js v24.19.0:
| Command | Result |
| --- | --- |
| `node --check src/content.js` and `node --check src/background.js` | Passed |
| `node --check tests/support/characterization.cjs` | Passed |
| `node tests/consumptionTrend.test.cjs` | Passed |
| `node tests/consumerTokenSessionRetry.test.cjs` | Passed |
| `node tests/aiSummary.test.cjs` | Passed |
| `node tests/characterization.test.cjs` | 22 passed, 0 failed |
| `node scripts/build.mjs --check` | Passed; distribution equivalence also checked by the suite |
| `git diff --check` | Passed |
The individual commands from the npm scripts were executed directly. No build
regeneration was needed because runtime sources and the build were unchanged;
`git diff -- src dist scripts/build.mjs` was empty.
### Verification Limits
- API mocks are not proof of Chrome/Edge/Firefox compatibility or real authentication.
- Recreating a VM tests cache rehydration, not actual service-worker suspension or job recovery.
- DOM mocks do not prove layout, focus, accessibility or SPA lifecycle correctness.
- The original visual fixture used eval. The completed extraction removed it and
serves the composed factory through a normal local script instead.
- No installed-browser smoke test or live Fusion request was performed in stage 1.
- No JavaScript export/generator feature was located. Do not create a generators
directory until its actual source and standalone-output contracts are identified.
## Separate Security and Reliability Backlog
These are preexisting observations, not authorized functional changes in this refactor.
| Issue | Evidence/impact | Separate change to evaluate |
| --- | --- | --- |
| Debug redaction after truncation | Invalid/truncated JSON is returned unmasked by formatDebugBody; reproduced with a synthetic token | Redact before truncation, fail closed on malformed bodies, test all response shapes |
| Debug secrets and shared DOM | Raw headers/bodies are retained for the reveal control; revealed text is in page DOM | Review retention, safe diagnostic schema and whether revealing credentials remains acceptable |
| Cookie store selection | Lookup scans all stores instead of binding to sender account/container | Scope selection to the requesting context and test multi-account behavior |
| Product cache isolation | Cache and in-flight keys use OptyNumber only | Design identity partitioning and explicit cache migration/retention policy |
| Privileged message validation | No explicit sender identity/origin check; supplied header names are accepted | Validate senders/messages and allowlist headers without introducing an arbitrary proxy |
| Session tab retry | Opening failures retry indefinitely; interruption can strand work | Decide cancellation/limits and worker lifecycle recovery explicitly |
| DOM lifecycle | Observer can remain active; guards do not cover every reinjection/resize path | Define ownership/disposal and characterize navigation before changing behavior |
| Token expiry | General/SPA tokens have policies different from Sales Common | Review independently; do not unify policies as part of extraction |
Never include real secrets in repro cases, snapshots, documentation or test output.
Do not add persistent token storage to compensate for worker termination.
## Completed Extraction Stages
All modules listed below are integrated through `scripts/sources.cjs` and the
two composition roots. Former `src/content.js` and `src/background.js` were removed;
their names remain only for the generated distribution scripts. No version change,
commit, publication, runtime dependency, permission expansion or CSP change occurred.
| Stage/responsibility | Files/boundaries | Preserved contracts and evidence | Remaining risk/next safe step |
| --- | --- | --- | --- |
| Build and pure logic | `scripts/compose.cjs`, `sources.cjs`; fiscal-periods, formatters, consumption model | Classic IIFEs, exact manifests, dates, payload/normalization tests; syntax checked per source and composed bundle | Native browser package loading still needs a smoke test |
| Platform and dispatch | `platform/`, `background/main.js`, `messages.js`, `shared/messages.js` | Sync listener registration, callback/promise mocks, async responses, sender-frame progress and unknown-message behavior | Sender validation is a separate security change |
| Auth and HTTP operations | `auth/`, `services/` | Credential selection, scoped-token reuse/expiry, 401/403 retry, missing-cookie and HTML-session recovery, request/debug envelopes | Real SSO and worker suspension not simulated completely |
| Products | `features/products/` | Four-worker queue, pagination, TTL, cache restore, in-flight sharing, partial errors and expansion behavior | Existing identity/cache-retention policy unchanged |
| Content features | opportunities, preferences, AI-summary, consumption and debug factories/state | Filter/search storage round-trip, sorting, stale requests, AI safe text, plan/period controls, preserved labels and DOM | Existing DOM lifecycle and narrow-screen limitations remain |
| Bootstrap, CSS and documentation | `content/main.js`, `background/main.js`, application roots, styles; README/docs | CSS byte-identical to original (hash test); factories connected, no orphan sources or peer-factory dependencies; old marker/eval fixture removed | Native browser matrix below remains pending |
The automated suites were run after the initial extraction and again after the
platform/auth refinements, cleanup and final composition. Intermediate verification
found and corrected test-harness expectations for source inventory and fixture
callback messaging; no production auth, cookie or session policy was changed.
### Final Automated Verification
Executed with Node.js 24.19.0, directly through the package-script equivalents:
| Command | Result |
| --- | --- |
| `node scripts/build.mjs` | Both packages regenerated; all source/composed JavaScript syntax valid |
| `node scripts/test.cjs` | All five suite files passed: AI, architecture (6 cases), characterization (22 cases), session recovery, consumption |
| `node scripts/build.mjs --check` | Generated scripts/manifests match source composition |
| `git diff --check` | No whitespace errors |
There is no configured linter. Syntax validation uses Node's JavaScript parser
through vm.Script, not eval or new Function in the extension. No generators feature
was located; standalone JavaScript generation therefore has no feature-specific
snapshot to assert. The architectural suite verifies deterministic composition,
source coverage, dependency injection, independent per-frame state and lazy
localStorage failure handling. Feature suites use synthetic credentials only.
### UI Smoke Verification
The loopback fixture was exercised in the Codex in-app browser, using the same
composed content factories with injected synthetic runtime/fetch/storage adapters.
It is not an installed extension and does not validate privileged browser APIs.
- Paginated synthetic opportunities, product progress/counts and dashboard rendered.
- Search text/filter remained after closing and reopening the modal.
- Light/dark toggle and product expansion retained the existing presentation.
- AI text, including literal HTML delimiters, appeared as text above the product table.
- Preferences retained checkbox/cache-duration selections after Save and reopen.
- Cloud Consumption opened with plan tabs; weekly default, daily switch, alternate
plan and series visibility toggles worked. Debug listed scoped relay and AI calls.
- Screenshots inspected at 1920x1080, 1280x720 and 390x844. Desktop columns rendered
with complete Last Update Date and WON_PENDING. Existing compact-table scrolling
and clipping at 1280 remain unchanged; mobile controls stack. This is not a full
mobile workflow/accessibility audit and no layout redesign was included.
- No console errors were reported during the exercised flows.
### Outstanding Native Browser Matrix
| Target | Package/runtime | Status |
| --- | --- | --- |
| Chrome | `dist/chromium`, MV3 service worker | Not run as an installed extension |
| Edge | `dist/chromium`, MV3 service worker | Not run as an installed extension |
| Firefox | `dist/firefox`, MV3 background scripts | Not run as an installed extension |
The available automation connection exposed the in-app browser and a Brave
profile, not Chrome, Edge or Firefox. No live Fusion requests, user-session access
or extension installation were used to substitute for the missing native tests.
Actual suspension/reactivation, container routing and SSO remain unverified.
The next safe validation is to load the generated packages in an authorized test
profile in each target browser, then exercise tile insertion, modal reopen,
filters/preferences, products, AI, consumption, debug and background restart.
Resolve the separately listed security issues only in explicitly approved changes.
See [the native browser checklist and execution record](browser-smoke.md) for the
2026-09-17 follow-up. Local tests and output checks passed again; Chrome/Edge were
installed but not connected to automation. Native verification remains pending
while the user connects those browsers.

View File

@@ -1,10 +1,12 @@
{
"name": "opportunities-extension",
"version": "1.0.0",
"version": "1.0.2",
"private": true,
"type": "module",
"scripts": {
"build": "node scripts/build.mjs",
"check": "node --check src/content.js && node scripts/build.mjs --check"
"check": "node scripts/build.mjs --check && node scripts/test.cjs",
"test": "node scripts/test.cjs",
"preview": "node scripts/preview.mjs"
}
}

View File

@@ -1,24 +1,28 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import composition from "./compose.cjs";
import output from "./write-output.cjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const srcDir = path.join(rootDir, "src");
const distDir = path.join(rootDir, "dist");
const contentScript = await readFile(path.join(srcDir, "content.js"), "utf8");
const backgroundScript = await readFile(path.join(srcDir, "background.js"), "utf8");
const contentScript = composition.composeScript("content");
const backgroundScript = composition.composeScript("background");
const checkOnly = process.argv.includes("--check");
const baseManifest = {
manifest_version: 3,
name: "Opportunities Extension",
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
version: "1.0.0",
version: "1.0.2",
permissions: [
"cookies"
"cookies",
"storage"
],
host_permissions: [
"https://eeho.fa.us2.oraclecloud.com/*"
"https://eeho.fa.us2.oraclecloud.com/*",
"https://spa.oracle.com/*",
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
],
content_scripts: [
{
@@ -59,23 +63,24 @@ const targets = [
}
];
if (!checkOnly) {
await rm(distDir, { recursive: true, force: true });
}
for (const target of targets) {
const manifestJson = `${JSON.stringify(target.manifest, null, 2)}\n`;
JSON.parse(manifestJson);
if (checkOnly) {
const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript };
for (const [file, contents] of Object.entries(expected)) {
const actual = await readFile(path.join(distDir, target.name, file), "utf8");
if (actual !== contents) throw new Error(`Stale output: dist/${target.name}/${file}. Run npm run build.`);
}
continue;
}
const outputDir = path.join(distDir, target.name);
await mkdir(outputDir, { recursive: true });
await writeFile(path.join(outputDir, "manifest.json"), manifestJson);
await writeFile(path.join(outputDir, "content.js"), contentScript);
await writeFile(path.join(outputDir, "background.js"), backgroundScript);
await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript);
await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson);
}
if (!checkOnly) {

30
scripts/compose.cjs Normal file
View File

@@ -0,0 +1,30 @@
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const sources = require("./sources.cjs");
const root = path.resolve(__dirname, "..");
/** Compose classic scripts at build time; never load or evaluate code at runtime. */
function composeScript(target, { bootstrap = true } = {}) {
const files = sources[target];
if (!files) throw new Error(`Unknown script target: ${target}`);
if (new Set(files).size !== files.length) throw new Error(`Duplicate source in ${target}`);
const parts = files.map(file => {
const source = fs.readFileSync(path.join(root, file), "utf8");
new vm.Script(source, { filename: file });
return `// Source: ${file}\n${source}`;
});
if (target === "content") {
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css"]
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
}
const factory = target === "content" ? "createContentApplication" : "createBackgroundApplication";
const ending = bootstrap ? `${factory}().start();` : `return ${factory};`;
const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`;
new vm.Script(output, { filename: `${target}.js` });
return output;
}
module.exports = { composeScript, sources };

38
scripts/preview.mjs Normal file
View File

@@ -0,0 +1,38 @@
import http from "node:http";
import { readFile } from "node:fs/promises";
import composition from "./compose.cjs";
// Development-only routes. Never expose the workspace or forward API requests.
const routes = new Map([
["/", ["../tests/fixtures/extension.html", "text/html"]],
["/extension.js", ["../tests/fixtures/extension.js", "text/javascript"]],
["/consumption-trend.html", ["../tests/fixtures/consumption-trend.html", "text/html"]]
]);
const server = http.createServer(async (request, response) => {
const pathname = new URL(request.url, "http://127.0.0.1").pathname;
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
if (request.method !== "GET") {
response.writeHead(405).end();
return;
}
try {
if (pathname === "/fixture-runtime.js") {
response.setHeader("Content-Type", "text/javascript");
response.end(`window.createFixtureApplication = ${composition.composeScript("content", { bootstrap: false })}`);
return;
}
const route = routes.get(pathname);
if (!route) {
response.writeHead(404).end();
return;
}
response.setHeader("Content-Type", `${route[1]}; charset=utf-8`);
response.end(await readFile(new URL(route[0], import.meta.url)));
} catch {
response.writeHead(500).end("Fixture could not be loaded.");
}
});
server.listen(Number(process.env.PORT) || 0, "127.0.0.1", () => {
console.log(`Synthetic extension preview: http://127.0.0.1:${server.address().port}/`);
});

104
scripts/sources.cjs Normal file
View File

@@ -0,0 +1,104 @@
module.exports = {
"content": [
"src/shared/messages.js",
"src/shared/debug-body.js",
"src/content/contracts.js",
"src/auth/session-state.js",
"src/auth/sales-common-state.js",
"src/features/opportunities/query-state.js",
"src/features/opportunities/table-state.js",
"src/features/opportunities/filter-state.js",
"src/features/preferences/state.js",
"src/features/products/content-state.js",
"src/features/ai-summary/state.js",
"src/features/consumption/plans-state.js",
"src/features/consumption/trend-state.js",
"src/features/opportunities/dashboard-state.js",
"src/features/debug/state.js",
"src/features/opportunities/columns.js",
"src/features/opportunities/badge-content.js",
"src/content/fusion-page.js",
"src/content/modal.js",
"src/content/theme.js",
"src/content/icons.js",
"src/content/svg.js",
"src/content/styles.js",
"src/auth/content-session.js",
"src/auth/current-user.js",
"src/auth/sales-common.js",
"src/auth/token-relay.js",
"src/auth/xsrf-content.js",
"src/platform/content-messages.js",
"src/features/opportunities/controller.js",
"src/services/fusion/opportunities.js",
"src/services/fusion/crm-service-requests.js",
"src/features/opportunities/crm-service-requests.js",
"src/features/opportunities/product-activities.js",
"src/features/opportunities/refresh-all.js",
"src/features/opportunities/activity-cache.js",
"src/features/opportunities/activity-progress.js",
"src/features/opportunities/activity-filters.js",
"src/features/opportunities/crm-sr-popover.js",
"src/features/opportunities/workload-actions.js",
"src/features/opportunities/workload-action-form.js",
"src/features/opportunities/workload-action-options.js",
"src/features/opportunities/fiscal-periods.js",
"src/features/opportunities/table.js",
"src/features/opportunities/dashboard.js",
"src/features/opportunities/filters.js",
"src/features/opportunities/formatters.js",
"src/features/opportunities/links.js",
"src/features/preferences/storage.js",
"src/features/preferences/view.js",
"src/features/products/content-jobs.js",
"src/features/products/details.js",
"src/features/ai-summary/content.js",
"src/features/consumption/plans-action.js",
"src/features/install-base/action.js",
"src/features/install-base/view.js",
"src/features/opportunities/customer-table-search.js",
"src/features/service-requests/action.js",
"src/features/service-requests/view.js",
"src/features/consumption/plans-view.js",
"src/features/consumption/header-view.js",
"src/features/ratecard/model.js",
"src/features/ratecard/controller.js",
"src/features/ratecard/view.js",
"src/features/consumption/plan-detail-controller.js",
"src/features/consumption/plan-detail-view.js",
"src/features/consumption/trend-controller.js",
"src/features/consumption/chart.js",
"src/features/consumption/model.js",
"src/features/debug/content-log.js",
"src/features/debug/content-view.js",
"src/content/main.js",
"src/content/application.js"
],
"background": [
"src/shared/messages.js",
"src/shared/debug-body.js",
"src/background/contracts.js",
"src/features/products/cache-state.js",
"src/auth/consumer-session.js",
"src/auth/spa-credentials.js",
"src/services/spa/accounts.js",
"src/services/spa/install-base.js",
"src/services/spa/customer-service-requests.js",
"src/services/spa/workload-actions.js",
"src/services/spa/action-options.js",
"src/services/fusion/sr-details.js",
"src/services/spa/current-user.js",
"src/services/spa/consumption.js",
"src/services/spa/response-shapes.js",
"src/services/http/background-json.js",
"src/features/products/background-jobs.js",
"src/features/products/cache.js",
"src/services/fusion/products.js",
"src/auth/xsrf-recovery.js",
"src/auth/xsrf-cookies.js",
"src/platform/background-api.js",
"src/background/main.js",
"src/background/messages.js",
"src/background/application.js"
]
};

13
scripts/test.cjs Normal file
View File

@@ -0,0 +1,13 @@
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const root = path.resolve(__dirname, "..");
const files = fs.readdirSync(path.join(root, "tests")).filter(name => name.endsWith(".test.cjs")).sort();
let failed = false;
for (const file of files) {
const result = spawnSync(process.execPath, [path.join(root, "tests", file)], { cwd: root, stdio: "inherit" });
if (result.error) console.error(result.error.message);
if (result.status !== 0) failed = true;
}
process.exitCode = failed ? 1 : 0;

20
scripts/write-output.cjs Normal file
View File

@@ -0,0 +1,20 @@
const fs = require("node:fs/promises");
const { randomUUID } = require("node:crypto");
async function writeOutput(file, contents, io = fs) {
try {
if (await io.readFile(file, "utf8") === contents) return;
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
// Replace a complete file; never remove or truncate a script the browser may load.
const temporary = `${file}.${randomUUID()}.tmp`;
try {
await io.writeFile(temporary, contents, { encoding: "utf8", flag: "wx" });
await io.rename(temporary, file);
} finally {
await io.rm(temporary, { force: true });
}
}
module.exports = { writeOutput };

View File

@@ -0,0 +1,233 @@
function createAuthConsumerSession({
CUSTOMER_TOKEN_MAX_ATTEMPTS,
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS,
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS,
CUSTOMER_TOKEN_SERVICE_URL,
CUSTOMER_TOKEN_SESSION_URL,
extractTokenServiceAuthorization,
fetchJson,
supportsFirefoxCookieStoreTabs,
tabsCreate,
tabsRemove,
wait
}) {
async function requestConsumerTokenService(sender) {
const debugRequests = [];
try {
const tokenResponse = await fetchConsumerTokenServiceUntilJson(sender, debugRequests);
const accountSummaryAuthorization = extractTokenServiceAuthorization(
tokenResponse,
"ACCP-TS",
"account-ti-authorization"
);
const accountPlansAuthorization = extractTokenServiceAuthorization(
tokenResponse,
"OCICONS-TS",
"g2m-authorization"
);
const tokenRequestDebugEntry = debugRequests[debugRequests.length - 1];
if (tokenRequestDebugEntry) {
tokenRequestDebugEntry.metadata = {
...tokenRequestDebugEntry.metadata,
accountSummaryTokenService: "ACCP-TS",
accountSummaryAuthorizationHeader: accountSummaryAuthorization.name || "(missing)",
accountSummaryAuthorizationValue: accountSummaryAuthorization.value
? `(present, ${accountSummaryAuthorization.value.length} characters)`
: "(missing)",
accountSummaryAuthorizationExpires: accountSummaryAuthorization.expires || "(not provided)",
accountPlansTokenService: "OCICONS-TS",
accountPlansAuthorizationHeader: accountPlansAuthorization.name || "(missing)",
accountPlansAuthorizationValue: accountPlansAuthorization.value
? `(present, ${accountPlansAuthorization.value.length} characters)`
: "(missing)",
accountPlansAuthorizationExpires: accountPlansAuthorization.expires || "(not provided)"
};
}
if (!accountSummaryAuthorization.value) {
throw new Error("ACCP-TS uiaasHeader.value was not found in requestConsumerTokenService.");
}
if (!accountPlansAuthorization.value) {
throw new Error("OCICONS-TS uiaasHeader.value was not found in requestConsumerTokenService.");
}
return {
ok: true,
authorizations: {
accountSummary: accountSummaryAuthorization,
accountPlans: accountPlansAuthorization,
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""),
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", ""),
workloadActions: extractTokenServiceAuthorization(tokenResponse, "WWB-TS", "Wwb-Provider-Authorization")
},
debugRequests
};
} catch (error) {
error.debugRequests = debugRequests;
throw error;
}
}
async function fetchConsumerTokenServiceUntilJson(sender, debugRequests) {
let attempt = 0;
let sessionTab = null;
let receivedValidJson = false;
try {
while (attempt < CUSTOMER_TOKEN_MAX_ATTEMPTS) {
attempt += 1;
try {
const tokenResponse = await fetchJson(CUSTOMER_TOKEN_SERVICE_URL, {
method: "GET",
credentials: "include",
cache: "no-store",
headers: {
Accept: "application/json",
"Cache-Control": "no-cache, no-store, max-age=0",
Pragma: "no-cache"
}
}, "requestConsumerTokenService", debugRequests);
const debugEntry = debugRequests[debugRequests.length - 1];
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
}
receivedValidJson = true;
return tokenResponse;
} catch (error) {
const isHtmlFallback = error
&& error.code === "INVALID_JSON_RESPONSE"
&& error.httpStatus === 200;
const isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
const retryReason = isSessionUnauthorized
? `HTTP ${error.httpStatus} response requires SPA session recovery`
: "HTTP 200 response did not contain valid JSON";
const debugEntry = debugRequests[debugRequests.length - 1];
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
if (needsSessionRecovery) {
debugEntry.metadata.retryLimitReached = "true";
debugEntry.metadata.retryReason = retryReason;
debugEntry.error = isHtmlFallback
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
: `requestConsumerTokenService failed with status ${error.httpStatus} after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
error.message = debugEntry.error;
}
}
throw error;
}
if (!sessionTab) {
try {
sessionTab = await openConsumerTokenSessionTabWithRetry(sender, debugEntry);
if (debugEntry) {
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab";
}
} catch (sessionError) {
if (debugEntry) {
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
debugEntry.metadata.sessionBootstrapState = `tab-open-failed: ${sessionError.message || "unknown error"}`;
}
}
} else if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = "waiting-for-valid-session";
}
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
debugEntry.metadata.retryReason = retryReason;
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
}
await wait(retryDelayMs);
}
}
throw new Error("requestConsumerTokenService retry loop ended unexpectedly.");
} finally {
if (sessionTab && Number.isInteger(sessionTab.id)) {
const debugEntry = debugRequests[debugRequests.length - 1];
try {
await tabsRemove(sessionTab.id);
if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = receivedValidJson
? "closed-after-valid-json"
: "closed-after-retry-finished";
}
} catch (error) {
if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = `tab-close-failed: ${error.message || "unknown error"}`;
}
}
}
}
}
function getConsumerTokenRetryDelay(attempt) {
return Math.min(
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS * (2 ** Math.max(attempt - 1, 0)),
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS
);
}
async function openConsumerTokenSessionTabWithRetry(sender, debugEntry) {
let attempt = 0;
// Opening failures must not consume the token service's five attempts.
while (true) {
attempt += 1;
if (debugEntry) {
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
debugEntry.metadata.sessionTabOpenAttempt = String(attempt);
}
try {
const tab = await openConsumerTokenSessionTab(sender);
if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab";
delete debugEntry.metadata.sessionTabNextRetryDelayMs;
}
return tab;
} catch (error) {
const retryDelayMs = getConsumerTokenRetryDelay(Math.min(attempt, 5));
if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = "retrying-tab-open";
debugEntry.metadata.sessionTabLastOpenError = error.message || "unknown error";
debugEntry.metadata.sessionTabNextRetryDelayMs = String(retryDelayMs);
}
await wait(retryDelayMs);
}
}
}
function openConsumerTokenSessionTab(sender) {
const details = {
url: CUSTOMER_TOKEN_SESSION_URL,
active: false
};
if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) {
details.windowId = sender.tab.windowId;
}
if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) {
details.cookieStoreId = sender.tab.cookieStoreId;
}
return tabsCreate(details);
}
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab };
}

View File

@@ -0,0 +1,58 @@
function createAuthContentSession({
messageTypes,
AUTH_STATUS,
appendBackgroundRequestLog,
authSessionState,
requestTokenRelay,
sendRuntimeMessage,
updateAuthBadge
}) {
function requestTokenRelayOnce() {
if (!authSessionState.tokenRelayRequest) {
authSessionState.authStatus = AUTH_STATUS.pending;
updateAuthBadge(authSessionState.authStatus);
authSessionState.tokenRelayRequest = requestTokenRelay()
.then((token) => {
authSessionState.accessToken = token;
authSessionState.authStatus = AUTH_STATUS.authenticated;
requestConsumerTokenServiceOnce().catch(() => {});
return token;
})
.catch((error) => {
authSessionState.accessToken = "";
authSessionState.principal = "";
authSessionState.authStatus = AUTH_STATUS.unauthenticated;
throw error;
})
.finally(() => {
updateAuthBadge(authSessionState.authStatus);
});
} else {
updateAuthBadge(authSessionState.authStatus);
}
return authSessionState.tokenRelayRequest;
}
function requestConsumerTokenServiceOnce() {
if (!authSessionState.consumerTokenServiceRequest) {
authSessionState.consumerTokenServiceRequest = sendRuntimeMessage({
type: messageTypes.requestConsumerTokenService
}).then((response) => {
appendBackgroundRequestLog(response && response.debugRequests);
if (!response || !response.ok) {
throw new Error(response && response.error
? response.error
: "Unable to load consumer token service.");
}
return response.authorizations;
});
}
return authSessionState.consumerTokenServiceRequest;
}
return { requestTokenRelayOnce, requestConsumerTokenServiceOnce };
}

19
src/auth/current-user.js Normal file
View File

@@ -0,0 +1,19 @@
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
let pending = null;
let allowed = false;
let userId = "";
function ensure() {
if (!pending) {
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => {
appendBackgroundRequestLog(response?.debugRequests);
const roles = response?.user?.roles;
userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : "";
allowed = response?.ok === true && Array.isArray(roles)
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP");
return allowed;
}).catch(() => { allowed = false; return false; });
}
return pending;
}
return { ensure, allowsCrmActivities: () => allowed, getUserId: () => userId };
}

View File

@@ -0,0 +1,7 @@
/** @returns {{salesCommonToken: null | {access_token: string, expiresAt: number}, salesCommonTokenRequest: null | Promise<string>}} */
function createAuthSalesCommonState() {
return {
salesCommonToken: null,
salesCommonTokenRequest: null
};
}

33
src/auth/sales-common.js Normal file
View File

@@ -0,0 +1,33 @@
function createAuthSalesCommon({
TOKEN_RELAY_URL,
authSalesCommonState,
requestTokenRelay
}) {
function requestTokenRelaySalesCommon() {
if (authSalesCommonState.salesCommonToken && Date.now() < authSalesCommonState.salesCommonToken.expiresAt) {
return Promise.resolve(authSalesCommonState.salesCommonToken.access_token);
}
if (authSalesCommonState.salesCommonTokenRequest) {
return authSalesCommonState.salesCommonTokenRequest;
}
authSalesCommonState.salesCommonTokenRequest = requestTokenRelay({
name: "requestTokenRelaySalesCommon",
url: `${TOKEN_RELAY_URL}?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F`,
includeExpiry: true
}).then((token) => {
authSalesCommonState.salesCommonToken = token;
return token.access_token;
}).finally(() => {
authSalesCommonState.salesCommonTokenRequest = null;
});
return authSalesCommonState.salesCommonTokenRequest;
}
function invalidateSalesCommonToken(rejectedToken) {
if (authSalesCommonState.salesCommonToken && authSalesCommonState.salesCommonToken.access_token === rejectedToken) {
authSalesCommonState.salesCommonToken = null;
}
}
return { requestTokenRelaySalesCommon, invalidateSalesCommonToken };
}

View File

@@ -0,0 +1,9 @@
function createAuthSessionState({ AUTH_STATUS } = {}) {
return {
tokenRelayRequest: null,
consumerTokenServiceRequest: null,
accessToken: "",
principal: "",
authStatus: AUTH_STATUS.idle
};
}

View File

@@ -0,0 +1,40 @@
function createAuthSpaCredentials({
findNestedObject
}) {
function normalizeMessageAuthorization(authorization, fallbackHeaderName) {
const headerName = authorization && typeof authorization.name === "string"
? authorization.name.trim()
: "";
const headerValue = authorization && typeof authorization.value === "string"
? authorization.value.trim()
: "";
return {
name: headerName || fallbackHeaderName,
value: headerValue
};
}
function extractTokenServiceAuthorization(response, tokenServiceName, fallbackHeaderName) {
const tokenService = Array.isArray(response)
? response.find((entry) => entry && entry.tokenService === tokenServiceName)
: findNestedObject(response, (entry) => entry.tokenService === tokenServiceName);
const authorizationHeader = tokenService && tokenService.uiaasHeader;
const headerName = authorizationHeader && typeof authorizationHeader.label === "string"
? authorizationHeader.label.trim()
: "";
const headerValue = authorizationHeader && typeof authorizationHeader.value === "string"
? authorizationHeader.value.trim()
: "";
return {
name: headerName || fallbackHeaderName,
value: headerValue,
expires: authorizationHeader && authorizationHeader.expires
? String(authorizationHeader.expires)
: ""
};
}
return { normalizeMessageAuthorization, extractTokenServiceAuthorization };
}

158
src/auth/token-relay.js Normal file
View File

@@ -0,0 +1,158 @@
function createAuthTokenRelay({
TOKEN_RELAY_URL,
authSessionState,
createDebugBody,
getXsrfToken,
logRequestFailure,
logRequestStart,
logRequestSuccess,
maskToken,
readResponseBodyForDebug,
refreshXsrfCookie,
renderDebugPanel,
environment
}) {
const { fetch } = environment;
async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) {
if (url === TOKEN_RELAY_URL) authSessionState.principal = "";
let refreshResult = null;
let refreshAttempted = false;
while (true) {
const xsrfTokenSource = await getXsrfToken();
if (!xsrfTokenSource.token) {
if (refreshAttempted) {
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh.");
throw new Error("XSRF token cookie not found after automatic refresh.");
}
refreshResult = await refreshXsrfCookie("missing-cookie");
refreshAttempted = true;
continue;
}
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
try {
const response = await fetch(url, {
method: "GET",
credentials: "include",
headers: {
"x-xsrf-token": xsrfTokenSource.token
}
});
const responseBody = await readResponseBodyForDebug(response);
if (!response.ok) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted && isInvalidXsrfStatus(response.status)) {
refreshResult = await refreshXsrfCookie(`tokenrelay-${response.status}`);
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw new Error(`Token relay failed with status ${response.status}.`);
}
let responseData;
try {
responseData = await response.json();
} catch (error) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted) {
refreshResult = await refreshXsrfCookie("tokenrelay-invalid-response");
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw error;
}
if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted) {
refreshResult = await refreshXsrfCookie("tokenrelay-missing-access-token");
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw new Error("Token relay response does not contain access_token.");
}
logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({
...responseData,
access_token: maskToken(responseData.access_token)
}, null, 2)));
if (url === TOKEN_RELAY_URL) {
authSessionState.principal = typeof responseData.principal === "string" ? responseData.principal.trim() : "";
}
if (includeExpiry) {
const lifetime = Number(responseData.expires_in);
return {
access_token: responseData.access_token,
expiresAt: Number.isFinite(lifetime) && lifetime > 0
? requestEntry.startedAt.getTime() + lifetime * 1000 : 0
};
}
return responseData.access_token;
} catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed.");
throw error;
}
}
}
function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name = "requestTokenRelay", url = TOKEN_RELAY_URL) {
return logRequestStart({
name,
method: "GET",
url,
headers: {
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)"
},
metadata: {
xsrfCookieName: xsrfTokenSource.cookieName || "(missing)",
xsrfTokenSource: xsrfTokenSource.source,
xsrfMatchedCookies: xsrfTokenSource.matchedCookieNames && xsrfTokenSource.matchedCookieNames.length
? xsrfTokenSource.matchedCookieNames.join(", ")
: "(none)",
xsrfLookupDetails: xsrfTokenSource.lookupDetails && xsrfTokenSource.lookupDetails.length
? xsrfTokenSource.lookupDetails.join(" | ")
: "(none)",
xsrfTokenError: xsrfTokenSource.error || "",
xsrfRefresh: formatXsrfRefreshResult(refreshResult)
}
});
}
function isInvalidXsrfStatus(status) {
return status === 400 || status === 401 || status === 403;
}
function formatXsrfRefreshResult(result) {
if (!result) {
return "not-requested";
}
const outcome = result.refreshed ? "refreshed" : "not-refreshed";
const cookieName = result.cookieName || "(none)";
const duration = Number.isFinite(result.durationMs) ? `${result.durationMs}ms` : "unknown";
return `${result.reason}; ${outcome}; cookie=${cookieName}; duration=${duration}; ${result.error || ""}`.trim();
}
return { requestTokenRelay, createTokenRelayRequestEntry, isInvalidXsrfStatus, formatXsrfRefreshResult };
}

42
src/auth/xsrf-content.js Normal file
View File

@@ -0,0 +1,42 @@
function createAuthXsrfContent({
getXsrfTokenFromExtensionCookies,
environment
}) {
const { document } = environment;
async function getXsrfToken() {
const extensionCookie = await getXsrfTokenFromExtensionCookies();
if (extensionCookie.token) {
return extensionCookie;
}
const xsrfCookie = document.cookie
.split(";")
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith("XSRF-TOKEN-"));
if (!xsrfCookie) {
return {
token: "",
cookieName: "",
source: extensionCookie.error ? "document.cookie fallback after cookies API error" : "document.cookie fallback",
matchedCookieNames: extensionCookie.matchedCookieNames || [],
lookupDetails: extensionCookie.lookupDetails || [],
error: extensionCookie.error || ""
};
}
const cookieName = xsrfCookie.slice(0, xsrfCookie.indexOf("="));
const tokenValue = xsrfCookie.slice(xsrfCookie.indexOf("=") + 1);
return {
token: decodeURIComponent(tokenValue),
cookieName,
source: "document.cookie",
matchedCookieNames: extensionCookie.matchedCookieNames || [],
lookupDetails: extensionCookie.lookupDetails || [],
error: ""
};
}
return { getXsrfToken };
}

116
src/auth/xsrf-cookies.js Normal file
View File

@@ -0,0 +1,116 @@
function createAuthXsrfCookies({
ORACLE_COOKIE_URLS,
ORACLE_DOMAIN,
XSRF_COOKIE_NAME,
XSRF_COOKIE_PREFIX,
cookiesGet,
cookiesGetAll,
cookiesGetAllCookieStores,
removeUndefinedValues
}) {
async function getXsrfTokenCookie() {
const lookupDetails = [];
const allCookies = [];
const stores = await getCookieStores(lookupDetails);
for (const store of stores) {
const exactCookie = await getExactCookieFromUrls(store.id, lookupDetails);
if (exactCookie) {
allCookies.push(exactCookie);
}
}
for (const store of stores) {
await collectCookies({
name: XSRF_COOKIE_NAME,
storeId: store.id
}, allCookies, lookupDetails);
}
for (const store of stores) {
await collectCookies({
domain: ORACLE_DOMAIN,
storeId: store.id
}, allCookies, lookupDetails);
await collectCookies({
domain: `.${ORACLE_DOMAIN}`,
storeId: store.id
}, allCookies, lookupDetails);
}
for (const store of stores) {
for (const url of ORACLE_COOKIE_URLS) {
await collectCookies({
url,
storeId: store.id
}, allCookies, lookupDetails);
}
}
const uniqueCookies = dedupeCookies(allCookies);
const xsrfCookies = uniqueCookies.filter((cookie) => cookie.name.startsWith(XSRF_COOKIE_PREFIX));
const exactCookie = xsrfCookies.find((cookie) => cookie.name === XSRF_COOKIE_NAME);
const hostCookie = xsrfCookies.find((cookie) => cookie.domain === ORACLE_DOMAIN || cookie.domain === `.${ORACLE_DOMAIN}`);
return {
cookie: exactCookie || hostCookie || xsrfCookies[0] || null,
matchedCookieNames: xsrfCookies.map((cookie) => `${cookie.name} (${cookie.domain}${cookie.path})`),
lookupDetails
};
}
async function getExactCookieFromUrls(storeId, lookupDetails) {
for (const url of ORACLE_COOKIE_URLS) {
const cookie = await cookiesGet({
url,
name: XSRF_COOKIE_NAME,
storeId
});
lookupDetails.push(`${JSON.stringify({ url, name: XSRF_COOKIE_NAME, storeId })} => ${cookie ? "found" : "not found"}`);
if (cookie) {
return cookie;
}
}
return null;
}
async function getCookieStores(lookupDetails) {
try {
const stores = await cookiesGetAllCookieStores();
lookupDetails.push(`getAllCookieStores => ${stores.length} store(s)`);
return stores.length ? stores : [{ id: undefined }];
} catch (error) {
lookupDetails.push(`getAllCookieStores => unavailable (${error.message || "unknown error"})`);
return [{ id: undefined }];
}
}
async function collectCookies(details, target, lookupDetails) {
const cleanDetails = removeUndefinedValues(details);
const cookies = await cookiesGetAll(cleanDetails);
target.push(...cookies);
lookupDetails.push(`${JSON.stringify(cleanDetails)} => ${cookies.length} cookie(s)`);
}
function dedupeCookies(cookies) {
const seen = new Set();
return cookies.filter((cookie) => {
const key = `${cookie.name}|${cookie.domain}|${cookie.path}|${cookie.storeId || ""}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
return { getXsrfTokenCookie, getExactCookieFromUrls, getCookieStores, collectCookies, dedupeCookies };
}

84
src/auth/xsrf-recovery.js Normal file
View File

@@ -0,0 +1,84 @@
function createAuthXsrfRecovery({
COOKIE_REFRESH_POLL_MS,
COOKIE_REFRESH_TIMEOUT_MS,
OPPORTUNITIES_LIST_URL,
getXsrfTokenCookie,
supportsFirefoxCookieStoreTabs,
tabsCreate,
tabsRemove,
wait
}) {
async function refreshXsrfCookie(sender) {
const startedAt = Date.now();
const previous = (await getXsrfTokenCookie()).cookie;
const createProperties = {
url: OPPORTUNITIES_LIST_URL,
active: false
};
if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) {
createProperties.windowId = sender.tab.windowId;
}
if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) {
createProperties.cookieStoreId = sender.tab.cookieStoreId;
}
let refreshTab;
try {
refreshTab = await tabsCreate(createProperties);
const current = await waitForXsrfCookieChange(previous);
const refreshed = hasCookieChanged(previous, current);
return {
ok: Boolean(current),
refreshed,
cookieName: current ? current.name : "",
durationMs: Date.now() - startedAt,
error: refreshed ? "" : "XSRF cookie was not created or changed before timeout."
};
} finally {
if (refreshTab && Number.isInteger(refreshTab.id)) {
try {
await tabsRemove(refreshTab.id);
} catch (error) {
// The temporary tab may already have been closed.
}
}
}
}
async function waitForXsrfCookieChange(previousCookie) {
const deadline = Date.now() + COOKIE_REFRESH_TIMEOUT_MS;
let currentCookie = null;
while (Date.now() < deadline) {
currentCookie = (await getXsrfTokenCookie()).cookie;
if (hasCookieChanged(previousCookie, currentCookie)) {
return currentCookie;
}
await wait(COOKIE_REFRESH_POLL_MS);
}
return currentCookie;
}
function hasCookieChanged(previousCookie, currentCookie) {
if (!currentCookie || !currentCookie.value) {
return false;
}
if (!previousCookie) {
return true;
}
return previousCookie.name !== currentCookie.name
|| previousCookie.value !== currentCookie.value
|| previousCookie.expirationDate !== currentCookie.expirationDate;
}
return { refreshXsrfCookie, waitForXsrfCookieChange, hasCookieChanged };
}

View File

@@ -1,360 +0,0 @@
(function () {
"use strict";
const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com";
const ORACLE_COOKIE_URLS = [
"https://eeho.fa.us2.oraclecloud.com/",
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome",
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome",
"https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay"
];
const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F";
const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-";
const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list";
const COOKIE_REFRESH_TIMEOUT_MS = 15000;
const COOKIE_REFRESH_POLL_MS = 250;
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) {
return false;
}
if (message.type === "opportunitiesExtension.getXsrfToken") {
getXsrfTokenCookie()
.then((result) => {
sendResponse({
ok: true,
cookieName: result.cookie ? result.cookie.name : "",
token: result.cookie ? result.cookie.value : "",
matchedCookieNames: result.matchedCookieNames,
lookupDetails: result.lookupDetails
});
})
.catch((error) => {
sendResponse({
ok: false,
cookieName: "",
token: "",
matchedCookieNames: [],
lookupDetails: [],
error: error.message || "Unable to read cookies."
});
});
return true;
}
if (message.type === "opportunitiesExtension.refreshXsrfCookie") {
refreshXsrfCookie(sender)
.then(sendResponse)
.catch((error) => {
sendResponse({
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
error: error.message || "Unable to refresh XSRF cookie."
});
});
return true;
}
return false;
});
async function refreshXsrfCookie(sender) {
const startedAt = Date.now();
const previous = (await getXsrfTokenCookie()).cookie;
const createProperties = {
url: OPPORTUNITIES_LIST_URL,
active: false
};
if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) {
createProperties.windowId = sender.tab.windowId;
}
if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) {
createProperties.cookieStoreId = sender.tab.cookieStoreId;
}
let refreshTab;
try {
refreshTab = await tabsCreate(createProperties);
const current = await waitForXsrfCookieChange(previous);
const refreshed = hasCookieChanged(previous, current);
return {
ok: Boolean(current),
refreshed,
cookieName: current ? current.name : "",
durationMs: Date.now() - startedAt,
error: refreshed ? "" : "XSRF cookie was not created or changed before timeout."
};
} finally {
if (refreshTab && Number.isInteger(refreshTab.id)) {
try {
await tabsRemove(refreshTab.id);
} catch (error) {
// The temporary tab may already have been closed.
}
}
}
}
async function waitForXsrfCookieChange(previousCookie) {
const deadline = Date.now() + COOKIE_REFRESH_TIMEOUT_MS;
let currentCookie = null;
while (Date.now() < deadline) {
currentCookie = (await getXsrfTokenCookie()).cookie;
if (hasCookieChanged(previousCookie, currentCookie)) {
return currentCookie;
}
await wait(COOKIE_REFRESH_POLL_MS);
}
return currentCookie;
}
function hasCookieChanged(previousCookie, currentCookie) {
if (!currentCookie || !currentCookie.value) {
return false;
}
if (!previousCookie) {
return true;
}
return previousCookie.name !== currentCookie.name
|| previousCookie.value !== currentCookie.value
|| previousCookie.expirationDate !== currentCookie.expirationDate;
}
function supportsFirefoxCookieStoreTabs() {
return Boolean(
runtimeApi.runtime
&& typeof runtimeApi.runtime.getBrowserInfo === "function"
);
}
function tabsCreate(details) {
if (runtimeApi.tabs.create.length <= 1) {
return runtimeApi.tabs.create(details);
}
return new Promise((resolve, reject) => {
runtimeApi.tabs.create(details, (tab) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(tab);
});
});
}
function tabsRemove(tabId) {
if (runtimeApi.tabs.remove.length <= 1) {
return runtimeApi.tabs.remove(tabId);
}
return new Promise((resolve, reject) => {
runtimeApi.tabs.remove(tabId, () => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve();
});
});
}
function wait(durationMs) {
return new Promise((resolve) => setTimeout(resolve, durationMs));
}
async function getXsrfTokenCookie() {
const lookupDetails = [];
const allCookies = [];
const stores = await getCookieStores(lookupDetails);
for (const store of stores) {
const exactCookie = await getExactCookieFromUrls(store.id, lookupDetails);
if (exactCookie) {
allCookies.push(exactCookie);
}
}
for (const store of stores) {
await collectCookies({
name: XSRF_COOKIE_NAME,
storeId: store.id
}, allCookies, lookupDetails);
}
for (const store of stores) {
await collectCookies({
domain: ORACLE_DOMAIN,
storeId: store.id
}, allCookies, lookupDetails);
await collectCookies({
domain: `.${ORACLE_DOMAIN}`,
storeId: store.id
}, allCookies, lookupDetails);
}
for (const store of stores) {
for (const url of ORACLE_COOKIE_URLS) {
await collectCookies({
url,
storeId: store.id
}, allCookies, lookupDetails);
}
}
const uniqueCookies = dedupeCookies(allCookies);
const xsrfCookies = uniqueCookies.filter((cookie) => cookie.name.startsWith(XSRF_COOKIE_PREFIX));
const exactCookie = xsrfCookies.find((cookie) => cookie.name === XSRF_COOKIE_NAME);
const hostCookie = xsrfCookies.find((cookie) => cookie.domain === ORACLE_DOMAIN || cookie.domain === `.${ORACLE_DOMAIN}`);
return {
cookie: exactCookie || hostCookie || xsrfCookies[0] || null,
matchedCookieNames: xsrfCookies.map((cookie) => `${cookie.name} (${cookie.domain}${cookie.path})`),
lookupDetails
};
}
async function getExactCookieFromUrls(storeId, lookupDetails) {
for (const url of ORACLE_COOKIE_URLS) {
const cookie = await cookiesGet({
url,
name: XSRF_COOKIE_NAME,
storeId
});
lookupDetails.push(`${JSON.stringify({ url, name: XSRF_COOKIE_NAME, storeId })} => ${cookie ? "found" : "not found"}`);
if (cookie) {
return cookie;
}
}
return null;
}
async function getCookieStores(lookupDetails) {
try {
const stores = await cookiesGetAllCookieStores();
lookupDetails.push(`getAllCookieStores => ${stores.length} store(s)`);
return stores.length ? stores : [{ id: undefined }];
} catch (error) {
lookupDetails.push(`getAllCookieStores => unavailable (${error.message || "unknown error"})`);
return [{ id: undefined }];
}
}
async function collectCookies(details, target, lookupDetails) {
const cleanDetails = removeUndefinedValues(details);
const cookies = await cookiesGetAll(cleanDetails);
target.push(...cookies);
lookupDetails.push(`${JSON.stringify(cleanDetails)} => ${cookies.length} cookie(s)`);
}
function dedupeCookies(cookies) {
const seen = new Set();
return cookies.filter((cookie) => {
const key = `${cookie.name}|${cookie.domain}|${cookie.path}|${cookie.storeId || ""}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function cookiesGetAll(details) {
if (runtimeApi.cookies.getAll.length <= 1) {
return runtimeApi.cookies.getAll(details);
}
return new Promise((resolve, reject) => {
runtimeApi.cookies.getAll(details, (cookies) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(cookies);
});
});
}
function cookiesGet(details) {
const cleanDetails = removeUndefinedValues(details);
if (runtimeApi.cookies.get.length <= 1) {
return runtimeApi.cookies.get(cleanDetails);
}
return new Promise((resolve, reject) => {
runtimeApi.cookies.get(cleanDetails, (cookie) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(cookie);
});
});
}
function cookiesGetAllCookieStores() {
if (!runtimeApi.cookies.getAllCookieStores) {
return Promise.resolve([{ id: undefined }]);
}
if (runtimeApi.cookies.getAllCookieStores.length === 0) {
return runtimeApi.cookies.getAllCookieStores();
}
return new Promise((resolve, reject) => {
runtimeApi.cookies.getAllCookieStores((stores) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(stores);
});
});
}
function removeUndefinedValues(details) {
return Object.fromEntries(
Object.entries(details).filter((entry) => entry[1] !== undefined)
);
}
})();

View File

@@ -0,0 +1,150 @@
function createBackgroundApplication(overrides = {}) {
const environment = {
fetch: globalThis.fetch,
browser: globalThis.browser,
chrome: globalThis.chrome,
setTimeout: globalThis.setTimeout,
...overrides
};
const messageTypes = createMessageTypes();
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
const contracts = createBackgroundContracts();
const productsCacheState = createProductsCacheState();
const authConsumerSession = createAuthConsumerSession({
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS: contracts.CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS,
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS: contracts.CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS,
CUSTOMER_TOKEN_SERVICE_URL: contracts.CUSTOMER_TOKEN_SERVICE_URL,
CUSTOMER_TOKEN_SESSION_URL: contracts.CUSTOMER_TOKEN_SESSION_URL,
extractTokenServiceAuthorization: (...args) => authSpaCredentials.extractTokenServiceAuthorization(...args),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
wait: (...args) => platformBackgroundApi.wait(...args)
});
const authSpaCredentials = createAuthSpaCredentials({
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args)
});
const spaAccounts = createSpaAccounts({
ACCOUNT_PLANS_PAYLOAD: contracts.ACCOUNT_PLANS_PAYLOAD,
ACCOUNT_PLANS_URL: contracts.ACCOUNT_PLANS_URL,
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
extractAccountPlans: (...args) => spaResponseShapes.extractAccountPlans(...args),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaInstallBase = createSpaInstallBase({
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaCustomerServiceRequests = createSpaCustomerServiceRequests({
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaConsumption = createSpaConsumption({
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaResponseShapes = createSpaResponseShapes();
const httpBackgroundJson = createHttpBackgroundJson({
previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
environment
});
const productsBackgroundJobs = createProductsBackgroundJobs({
messageTypes,
OPPTY_PRODUCTS_CONCURRENCY: contracts.OPPTY_PRODUCTS_CONCURRENCY,
getOpptyProducts: (...args) => productsCache.getOpptyProducts(...args),
normalizeCacheMaxAge: (...args) => productsCache.normalizeCacheMaxAge(...args),
tabsSendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args)
});
const productsCache = createProductsCache({
OPPTY_PRODUCTS_CACHE_KEY: contracts.OPPTY_PRODUCTS_CACHE_KEY,
fetchAllOpptyProducts: (...args) => fusionProducts.fetchAllOpptyProducts(...args),
productsCacheState,
storageLocalGet: (...args) => platformBackgroundApi.storageLocalGet(...args),
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
});
const fusionProducts = createFusionProducts({
createBackgroundDebugEntry: (...args) => httpBackgroundJson.createBackgroundDebugEntry(...args),
completeBackgroundDebugEntry: (...args) => httpBackgroundJson.completeBackgroundDebugEntry(...args),
createDebugBody: (...args) => httpBackgroundJson.createDebugBody(...args),
headersToObject: (...args) => httpBackgroundJson.headersToObject(...args),
OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS,
OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT,
ORACLE_DOMAIN: contracts.ORACLE_DOMAIN,
environment
});
const authXsrfRecovery = createAuthXsrfRecovery({
COOKIE_REFRESH_POLL_MS: contracts.COOKIE_REFRESH_POLL_MS,
COOKIE_REFRESH_TIMEOUT_MS: contracts.COOKIE_REFRESH_TIMEOUT_MS,
OPPORTUNITIES_LIST_URL: contracts.OPPORTUNITIES_LIST_URL,
getXsrfTokenCookie: (...args) => authXsrfCookies.getXsrfTokenCookie(...args),
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
wait: (...args) => platformBackgroundApi.wait(...args)
});
const authXsrfCookies = createAuthXsrfCookies({
ORACLE_COOKIE_URLS: contracts.ORACLE_COOKIE_URLS,
ORACLE_DOMAIN: contracts.ORACLE_DOMAIN,
XSRF_COOKIE_NAME: contracts.XSRF_COOKIE_NAME,
XSRF_COOKIE_PREFIX: contracts.XSRF_COOKIE_PREFIX,
cookiesGet: (...args) => platformBackgroundApi.cookiesGet(...args),
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
cookiesGetAllCookieStores: (...args) => platformBackgroundApi.cookiesGetAllCookieStores(...args),
removeUndefinedValues: (...args) => platformBackgroundApi.removeUndefinedValues(...args)
});
const platformBackgroundApi = createPlatformBackgroundApi({
runtimeApi: environment.browser || environment.chrome,
environment
});
const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args);
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
const requestActionFindLookup = (...args) => spaActionOptions.requestActionFindLookup(...args);
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
const requestConsumptionPlanDetail = (...args) => spaConsumption.requestConsumptionPlanDetail(...args);
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
const fusionSrDetails = createFusionSrDetails({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
function start() {
startBackgroundApplication({ messageTypes, requestInternalServiceRequest: message => fusionSrDetails.requestInternalServiceRequest(message), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,
productsCacheState,
authConsumerSession,
authSpaCredentials,
spaAccounts,
spaInstallBase,
spaCustomerServiceRequests,
spaConsumption,
spaResponseShapes,
httpBackgroundJson,
productsBackgroundJobs,
productsCache,
fusionProducts,
authXsrfRecovery,
authXsrfCookies,
platformBackgroundApi
};
}

View File

@@ -0,0 +1,69 @@
function createBackgroundContracts() {
const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com";
const ORACLE_COOKIE_URLS = [
"https://eeho.fa.us2.oraclecloud.com/",
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome",
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome",
"https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay"
];
const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F";
const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-";
const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list";
const COOKIE_REFRESH_TIMEOUT_MS = 15000;
const COOKIE_REFRESH_POLL_MS = 250;
const OPPTY_PRODUCTS_CACHE_KEY = "opportunitiesExtension.opptyProductsCache.v1";
const OPPTY_PRODUCTS_PAGE_LIMIT = 15;
const OPPTY_PRODUCTS_MAX_PAGES = 250;
const OPPTY_PRODUCTS_CONCURRENCY = 4;
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService";
const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html";
const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5;
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;
const ACCOUNT_SUMMARY_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-account/service/accountsummary";
const ACCOUNT_PLANS_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/account/accountPlans";
const CONSUMPTION_TREND_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/trend";
const CONSUMPTION_TREND_REQUESTS = {
quarterly: "requestConsumptionServiceQuarterly",
monthly: "requestConsumptionServiceMonthly",
weekly: "requestConsumptionServiceWeekly",
daily: "requestConsumptionServiceDaily"
};
const ACCOUNT_PLANS_PAYLOAD = {
hierarchy: "single",
products: "all",
language: "english",
planStatus: [
"active",
"awaiting_provisioning",
"awaiting_provisioning_error",
"draft",
"expired",
"final_billing",
"onhold",
"provisioned",
"signed",
"superseded",
"superseded_pending_final_billing",
"suspended",
"terminated",
"terminated_pending_final_billing"
]
};
const DEBUG_BODY_LIMIT = 100000;
const OPPTY_PRODUCTS_FIELDS = [
"ProductType", "Description", "InventoryItemId", "ProdGroupName", "ProdGroupId", "OwnerLockAsgnFlag",
"Quantity", "RecurTypeCode", "RevnAmountCurcyCode", "UnitPrice", "RevnAmount", "PriceTypeCode",
"EffectiveDate", "Name1", "NonRecurringRevenue", "OpportunityOwnerPartyName", "OpportunityOwnerResourcePartyId",
"OptyId", "OptyNumber", "PartyName2", "PrTerritoryVersionId", "PrTerritoryVersionIdForManual",
"RecurEndDate", "RecurFrequencyCode", "RecurNumberPeriods", "RecurRevenue", "ResourcePartyId", "RevnId",
"RevnNumber", "SplitPercent", "SalesCreditTypeCode", "SplitTypeCode", "TerrOwnerPartyName", "UpsideAmount",
"UsageRevenue", "StatusCode", "BUOrgId", "TypeCode", "WinProb", "ForecastType_c", "ARRLocalCurrency_c",
"CPQLastSyncDate_c", "CPQOperationType_c", "ProdGLID_c", "ProposalNumber_c", "QuoteNumber_c",
"ServicesPeriod_c", "CPQIntLastUpdatedBy_c", "CPQUpdatedAmount_c", "CPQUpdatedQuantity_c",
"CPQUpdatedRevnType_c", "CPQUpdatedServicePeriod_c", "CPQUpdatedStatus_c", "OrderNumber_c", "PrevAmount_c",
"PrevQuantity_c", "PrevRevenueType_c", "PrevServicePeriod_c", "PrevStatus_c", "SubscriptionID_c",
"WorkloadName_c", "ConsumptionStartDate_c", "RampMonths_c"
];
return { ORACLE_DOMAIN, ORACLE_COOKIE_URLS, XSRF_COOKIE_NAME, XSRF_COOKIE_PREFIX, OPPORTUNITIES_LIST_URL, COOKIE_REFRESH_TIMEOUT_MS, COOKIE_REFRESH_POLL_MS, OPPTY_PRODUCTS_CACHE_KEY, OPPTY_PRODUCTS_PAGE_LIMIT, OPPTY_PRODUCTS_MAX_PAGES, OPPTY_PRODUCTS_CONCURRENCY, CUSTOMER_TOKEN_SERVICE_URL, CUSTOMER_TOKEN_SESSION_URL, CUSTOMER_TOKEN_MAX_ATTEMPTS, CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS, CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS, ACCOUNT_SUMMARY_URL, ACCOUNT_PLANS_URL, CONSUMPTION_TREND_URL, CONSUMPTION_TREND_REQUESTS, ACCOUNT_PLANS_PAYLOAD, DEBUG_BODY_LIMIT, OPPTY_PRODUCTS_FIELDS };
}

3
src/background/main.js Normal file
View File

@@ -0,0 +1,3 @@
function startBackgroundApplication(dependencies) {
registerBackgroundMessages(dependencies);
}

188
src/background/messages.js Normal file
View File

@@ -0,0 +1,188 @@
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
messageTypes,
invalidateProductsCache,
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestCustomerServiceRequest,
requestWlActions,
requestActionTeamMembers,
requestActionFindLookup,
requestMgmtAction,
requestInternalServiceRequest,
requestCurrentUser,
requestConsumerTokenService,
requestConsumptionTrend,
requestConsumptionPlanDetail,
requestRatecardByPlan,
requestOpptyProducts,
runtimeApi
}) {
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) {
return false;
}
if (message.type === messageTypes.invalidateProductsCache) {
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
return true;
}
if (message.type === messageTypes.getXsrfToken) {
getXsrfTokenCookie()
.then((result) => {
sendResponse({
ok: true,
cookieName: result.cookie ? result.cookie.name : "",
token: result.cookie ? result.cookie.value : "",
matchedCookieNames: result.matchedCookieNames,
lookupDetails: result.lookupDetails
});
})
.catch((error) => {
sendResponse({
ok: false,
cookieName: "",
token: "",
matchedCookieNames: [],
lookupDetails: [],
error: error.message || "Unable to read cookies."
});
});
return true;
}
if (message.type === messageTypes.refreshXsrfCookie) {
refreshXsrfCookie(sender)
.then(sendResponse)
.catch((error) => {
sendResponse({
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
error: error.message || "Unable to refresh XSRF cookie."
});
});
return true;
}
if (message.type === messageTypes.requestOpptyProducts) {
requestOpptyProducts(message, sender)
.then(sendResponse)
.catch((error) => {
sendResponse({
ok: false,
requestId: message.requestId || "",
error: error.message || "Unable to load opportunity products."
});
});
return true;
}
if (message.type === messageTypes.requestConsumerTokenService) {
requestConsumerTokenService(sender)
.then(sendResponse)
.catch((error) => {
sendResponse({
ok: false,
error: error.message || "Unable to load consumer token service.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
});
});
return true;
}
if (message.type === messageTypes.requestCurrentUser) {
requestCurrentUser().then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load current user.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) {
const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
request(message).then(sendResponse).catch(error => sendResponse({
ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestWlActions) {
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workload actions.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestCustomerServiceRequest) {
requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load service requests.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestInstallBase) {
requestInstallBase(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load installed base.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestAccountPlans) {
requestAccountPlans(message)
.then(sendResponse)
.catch((error) => {
sendResponse({
ok: false,
error: error.message || "Unable to load account plans.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
});
});
return true;
}
if (message.type === messageTypes.requestRatecardByPlan) {
requestRatecardByPlan(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load rate card.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestConsumptionPlanDetail) {
requestConsumptionPlanDetail(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load consumption plan details.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestConsumptionTrend) {
requestConsumptionTrend(message)
.then(sendResponse)
.catch((error) => {
sendResponse({
ok: false,
error: error.message || "Unable to load consumption trend.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
});
});
return true;
}
return false;
});
}

File diff suppressed because it is too large Load Diff

662
src/content/application.js Normal file
View File

@@ -0,0 +1,662 @@
function createContentApplication(overrides = {}) {
const environment = {
document: globalThis.document,
window: globalThis.window,
navigator: globalThis.navigator,
get localStorage() { return globalThis.localStorage; },
fetch: globalThis.fetch,
browser: globalThis.browser,
chrome: globalThis.chrome,
MutationObserver: globalThis.MutationObserver,
Node: globalThis.Node,
requestAnimationFrame: globalThis.requestAnimationFrame,
performance: globalThis.performance,
cancelAnimationFrame: globalThis.cancelAnimationFrame,
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
matchMedia: globalThis.matchMedia,
...overrides
};
const messageTypes = createMessageTypes();
const currentUser = createCurrentUser({
messageTypes,
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args)
});
const contracts = createContentContracts();
const authSessionState = createAuthSessionState(contracts);
const authSalesCommonState = createAuthSalesCommonState();
const opportunitiesQueryState = createOpportunitiesQueryState();
const opportunitiesTableState = createOpportunitiesTableState();
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
const preferencesState = createPreferencesState(contracts);
const activityCache = createActivityCache({ authSessionState, preferencesState, environment });
const productsContentState = createProductsContentState();
const aiSummaryState = createAiSummaryState();
const consumptionPlansState = createConsumptionPlansState();
const consumptionTrendState = createConsumptionTrendState();
const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts);
const debugState = createDebugState();
const columns = createOpportunitiesColumns({
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args),
formatWinProbability: (...args) => opportunitiesFormatters.formatWinProbability(...args),
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
getOwnerName: (...args) => opportunitiesFormatters.getOwnerName(...args)
});
const contentFusionPage = createContentFusionPage({
ADD_ICON_SELECTOR: contracts.ADD_ICON_SELECTOR,
ALLOWED_PATHS: contracts.ALLOWED_PATHS,
SALES_GROUP_SELECTOR: contracts.SALES_GROUP_SELECTOR,
TILE_ID: contracts.TILE_ID,
TILE_LABEL_ID: contracts.TILE_LABEL_ID,
appendPath: (...args) => svgPaths.appendPath(...args),
openOpportunitiesModal: (...args) => contentModal.openOpportunitiesModal(...args),
environment
});
const contentModal = createContentModal({
closeActionForm: (...args) => workloadActionForm.close(...args),
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
closeCrmSrPopover: () => crmSrPopover.close(),
ensureCurrentUser: () => currentUser.ensure(),
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
AUTH_STATUS: contracts.AUTH_STATUS,
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
CUSTOMER_FILTER_SEARCH_ID: contracts.CUSTOMER_FILTER_SEARCH_ID,
CUSTOMER_FILTER_SELECT_ALL_ID: contracts.CUSTOMER_FILTER_SELECT_ALL_ID,
DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID,
ESCAPE_LISTENER_KEY: contracts.ESCAPE_LISTENER_KEY,
MODAL_ID: contracts.MODAL_ID,
OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID,
OWNER_FILTER_BUTTON_ID: contracts.OWNER_FILTER_BUTTON_ID,
OWNER_FILTER_LIST_ID: contracts.OWNER_FILTER_LIST_ID,
OWNER_FILTER_PANEL_ID: contracts.OWNER_FILTER_PANEL_ID,
OWNER_FILTER_SEARCH_ID: contracts.OWNER_FILTER_SEARCH_ID,
OWNER_FILTER_SELECT_ALL_ID: contracts.OWNER_FILTER_SELECT_ALL_ID,
PERIOD_OPTIONS: contracts.PERIOD_OPTIONS,
PREFERENCES_MODAL_ID: contracts.PREFERENCES_MODAL_ID,
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
PRODUCTS_PROGRESS_ID: contracts.PRODUCTS_PROGRESS_ID,
STAGE_DASHBOARD_ID: contracts.STAGE_DASHBOARD_ID,
STAGE_FILTERS_ID: contracts.STAGE_FILTERS_ID,
STATUS_FILTERS_ID: contracts.STATUS_FILTERS_ID,
TABLE_SEARCH_ID: contracts.TABLE_SEARCH_ID,
applyTheme: (...args) => contentTheme.applyTheme(...args),
authSessionState,
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
closeInstallBaseModal: (...args) => installBaseView.closeInstallBaseModal(...args),
closeServiceRequestsModal: (...args) => serviceRequestsView.closeServiceRequestsModal(...args),
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
createRefreshAllButton: (...args) => refreshAll.createButton(...args),
debugState,
ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args),
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
getOwnerOptions: (...args) => opportunitiesFormatters.getOwnerOptions(...args),
getSavedOpportunityTypeValues: (...args) => preferencesStorage.getSavedOpportunityTypeValues(...args),
getSavedOpptyProductsCacheHours: (...args) => preferencesStorage.getSavedOpptyProductsCacheHours(...args),
getSavedTheme: (...args) => contentTheme.getSavedTheme(...args),
openPreferencesModal: (...args) => preferencesView.openPreferencesModal(...args),
opportunitiesFilterState,
opportunitiesQueryState,
preferencesState,
renderCustomerFilter: (...args) => opportunitiesFilters.renderCustomerFilter(...args),
renderCustomerFilterList: (...args) => opportunitiesFilters.renderCustomerFilterList(...args),
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
renderOwnerFilter: (...args) => opportunitiesFilters.renderOwnerFilter(...args),
renderOwnerFilterList: (...args) => opportunitiesFilters.renderOwnerFilterList(...args),
renderStageFilterButtons: (...args) => opportunitiesFilters.renderStageFilterButtons(...args),
renderStatusFilterButtons: (...args) => opportunitiesFilters.renderStatusFilterButtons(...args),
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args),
resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args),
restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args),
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args),
toggleDebugPanel: (...args) => debugContentView.toggleDebugPanel(...args),
toggleOwnerFilterPanel: (...args) => opportunitiesFilters.toggleOwnerFilterPanel(...args),
environment
});
const contentTheme = createContentTheme({
THEME_STORAGE_KEY: contracts.THEME_STORAGE_KEY,
createThemeIcon: (...args) => contentIcons.createThemeIcon(...args),
environment
});
const contentIcons = createContentIcons({
appendPath: (...args) => svgPaths.appendPath(...args),
environment
});
const svgPaths = createSvgPaths({ environment });
const contentStyles = createContentStyles({
STYLE_ID: contracts.STYLE_ID,
redwoodStyles,
environment
});
const authContentSession = createAuthContentSession({
messageTypes,
AUTH_STATUS: contracts.AUTH_STATUS,
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
authSessionState,
requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
updateAuthBadge: (...args) => contentModal.updateAuthBadge(...args)
});
const authSalesCommon = createAuthSalesCommon({
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
authSalesCommonState,
requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args)
});
const authTokenRelay = createAuthTokenRelay({
authSessionState,
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
createDebugBody: (...args) => debugContentLog.createDebugBody(...args),
getXsrfToken: (...args) => authXsrfContent.getXsrfToken(...args),
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
maskToken: (...args) => debugContentLog.maskToken(...args),
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
refreshXsrfCookie: (...args) => platformContentMessages.refreshXsrfCookie(...args),
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args),
environment
});
const authXsrfContent = createAuthXsrfContent({
getXsrfTokenFromExtensionCookies: (...args) => platformContentMessages.getXsrfTokenFromExtensionCookies(...args),
environment
});
const platformContentMessages = createPlatformContentMessages({
messageTypes,
environment
});
const opportunitiesController = createOpportunitiesController({
ensureCurrentUser: () => currentUser.ensure(),
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
authSessionState,
consumptionPlansState,
consumptionTrendState,
getFiscalPeriodRange: (...args) => opportunitiesFiscalPeriods.getFiscalPeriodRange(...args),
opportunitiesDashboardState,
opportunitiesFilterState,
opportunitiesQueryState,
opportunitiesTableState,
preferencesState,
productsContentState,
renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args),
requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args),
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args),
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
});
const fusionOpportunities = createFusionOpportunities({
OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT,
OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL,
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
maskToken: (...args) => debugContentLog.maskToken(...args),
getOpportunityTypeValues: () => preferencesState.selectedOpportunityTypeValues,
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
environment
});
const opportunitiesFiscalPeriods = createOpportunitiesFiscalPeriods();
const fusionCrmServiceRequests = createFusionCrmServiceRequests({
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
maskToken: (...args) => debugContentLog.maskToken(...args), environment
});
const crmSrPopover = createCrmSrPopover({
requestDetails: async srId => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load SR details.");
return result.items;
},
MODAL_ID: contracts.MODAL_ID,
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
});
const opportunityBadgeContent = createOpportunityBadgeContent({ environment });
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
activityCache,
reportProgress: (...args) => activityProgress.update("sr", ...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
bindPopover: (...args) => crmSrPopover.bind(...args),
closePopover: (...args) => crmSrPopover.close(...args),
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
});
const opportunitiesTable = createOpportunitiesTable({
renderActivityFilters: () => activityFilters.render(),
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
OPPORTUNITIES_COLUMNS: columns,
OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID,
OPPORTUNITY_DETAIL_URL: contracts.OPPORTUNITY_DETAIL_URL,
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
createInstallBaseButton: (...args) => installBaseAction.createInstallBaseButton(...args),
createServiceRequestsButton: (...args) => serviceRequestsAction.createServiceRequestsButton(...args),
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args),
createCrmServiceRequestBadge: (...args) => opportunityCrmServiceRequests.createBadge(...args),
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
createStatusBadge: (...args) => opportunitiesLinks.createStatusBadge(...args),
displayOpportunityValue: (...args) => opportunitiesFormatters.displayOpportunityValue(...args),
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
getSortedOpportunities: (...args) => opportunitiesFilters.getSortedOpportunities(...args),
opportunitiesTableState,
productsContentState,
renderCustomerFilter: (...args) => opportunitiesFilters.renderCustomerFilter(...args),
renderOwnerFilter: (...args) => opportunitiesFilters.renderOwnerFilter(...args),
renderStageDashboard: (...args) => opportunitiesDashboard.renderStageDashboard(...args),
resetCustomerFilter: (...args) => opportunitiesFilters.resetCustomerFilter(...args),
resetOwnerFilter: (...args) => opportunitiesFilters.resetOwnerFilter(...args),
environment
});
const opportunitiesDashboard = createOpportunitiesDashboard({
STAGE_DASHBOARD_ID: contracts.STAGE_DASHBOARD_ID,
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
opportunitiesDashboardState,
environment
});
const opportunitiesFilters = createOpportunitiesFilters({
matchesActivities: item => activityFilters.matches(item),
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
CUSTOMER_FILTER_SEARCH_ID: contracts.CUSTOMER_FILTER_SEARCH_ID,
CUSTOMER_FILTER_SELECT_ALL_ID: contracts.CUSTOMER_FILTER_SELECT_ALL_ID,
OPPORTUNITIES_COLUMNS: columns,
OWNER_FILTER_BUTTON_ID: contracts.OWNER_FILTER_BUTTON_ID,
OWNER_FILTER_LIST_ID: contracts.OWNER_FILTER_LIST_ID,
OWNER_FILTER_PANEL_ID: contracts.OWNER_FILTER_PANEL_ID,
OWNER_FILTER_SEARCH_ID: contracts.OWNER_FILTER_SEARCH_ID,
OWNER_FILTER_SELECT_ALL_ID: contracts.OWNER_FILTER_SELECT_ALL_ID,
STAGE_FILTERS_ID: contracts.STAGE_FILTERS_ID,
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
STATUS_FILTERS_ID: contracts.STATUS_FILTERS_ID,
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
getCustomerFilterSummary: (...args) => opportunitiesFormatters.getCustomerFilterSummary(...args),
getCustomerKey: (...args) => opportunitiesFormatters.getCustomerKey(...args),
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
getOwnerKey: (...args) => opportunitiesFormatters.getOwnerKey(...args),
getOwnerOptions: (...args) => opportunitiesFormatters.getOwnerOptions(...args),
opportunitiesFilterState,
opportunitiesTableState,
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
stringifySearchValue: (...args) => opportunitiesFormatters.stringifySearchValue(...args),
environment
});
const opportunitiesFormatters = createOpportunitiesFormatters({
opportunitiesTableState
});
const opportunitiesLinks = createOpportunitiesLinks({
MODAL_ID: contracts.MODAL_ID,
environment
});
const preferencesStorage = createPreferencesStorage({
DEFAULT_OPPORTUNITY_TYPE_VALUES: contracts.DEFAULT_OPPORTUNITY_TYPE_VALUES,
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS: contracts.DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
FILTER_PREFERENCES_STORAGE_KEY: contracts.FILTER_PREFERENCES_STORAGE_KEY,
MODAL_ID: contracts.MODAL_ID,
OPPORTUNITY_TYPE_OPTIONS: contracts.OPPORTUNITY_TYPE_OPTIONS,
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY: contracts.OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
OPPTY_PRODUCTS_CACHE_OPTIONS: contracts.OPPTY_PRODUCTS_CACHE_OPTIONS,
PERIOD_OPTIONS: contracts.PERIOD_OPTIONS,
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
opportunitiesFilterState,
environment
});
const preferencesView = createPreferencesView({
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS: contracts.DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
MODAL_ID: contracts.MODAL_ID,
OPPORTUNITY_TYPE_OPTIONS: contracts.OPPORTUNITY_TYPE_OPTIONS,
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY: contracts.OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
OPPTY_PRODUCTS_CACHE_OPTIONS: contracts.OPPTY_PRODUCTS_CACHE_OPTIONS,
PERIOD_OPTIONS: contracts.PERIOD_OPTIONS,
PREFERENCES_MODAL_ID: contracts.PREFERENCES_MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
getSavedOpportunityTypeValues: (...args) => preferencesStorage.getSavedOpportunityTypeValues(...args),
getSavedOpptyProductsCacheHours: (...args) => preferencesStorage.getSavedOpptyProductsCacheHours(...args),
preferencesState,
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
environment
});
const refreshAll = createRefreshAll({
createRefreshIcon: () => contentIcons.createRefreshIcon(),
messageTypes, activityCache, opportunitiesQueryState, productsContentState,
consumptionPlansState, consumptionTrendState, aiSummaryState, environment,
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
reload: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
onFailure: error => opportunitiesTable.setOpportunitiesTableState({ status: "error", message: error.message || "Unable to refresh cached data." }),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
resetRelatedData: () => {
opportunityCrmServiceRequests.reset();
opportunityWorkloadActions.reset();
consumptionPlansView.closeAccountPlansModal();
installBaseView.closeInstallBaseModal();
serviceRequestsView.closeServiceRequestsModal();
installBaseAction.resetRequests();
serviceRequestsAction.resetRequests();
ratecardController.resetRequests();
consumptionPlanDetailController.resetRequests();
productsContentJobs.renderProductsProgress();
opportunitiesTable.setOpportunitiesTableState({ status: "loading", message: "Refreshing data..." });
}
});
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment });
const activityFilters = createActivityFilters({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
getSrSummary: number => opportunityCrmServiceRequests.getSummary(number),
getActionSummary: number => opportunityWorkloadActions.getSummary(number),
renderTable: () => opportunitiesTable.renderOpportunitiesTable(),
environment
});
const productActivities = createProductActivities({
productsContentState, opportunitiesQueryState,
loadCrm: (...args) => opportunityCrmServiceRequests.load(...args),
loadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
blockActions: products => opportunityWorkloadActions.blockProducts(products),
render: () => {
productsContentJobs.renderProductsProgress();
productsContentJobs.queueProductsTableRender();
}
});
const productsContentJobs = createProductsContentJobs({
beginProductActivities: (...args) => productActivities.begin(...args),
completeProductActivities: (...args) => productActivities.complete(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
messageTypes,
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
PRODUCTS_MESSAGE_LISTENER_KEY: contracts.PRODUCTS_MESSAGE_LISTENER_KEY,
PRODUCTS_PROGRESS_ID: contracts.PRODUCTS_PROGRESS_ID,
preferencesState,
productsContentState,
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
environment
});
const workloadActionOptions = createWorkloadActionOptions({
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
messageTypes
});
const workloadActionForm = createWorkloadActionForm({
onActionSaved: (...args) => opportunityWorkloadActions.applySavedAction(...args),
saveAction: (...args) => workloadActionOptions.save(...args),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
getCurrentUserId: () => currentUser.getUserId(),
requestTeamMembers: (...args) => workloadActionOptions.requestTeamMembers(...args),
requestLookup: (...args) => workloadActionOptions.requestLookup(...args),
getProducts: number => productsContentState.opptyProductsByNumber.get(String(number))?.items || [],
getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
MODAL_ID: contracts.MODAL_ID,
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
isMissingAction: number => opportunityWorkloadActions.getSummary(number)?.state === "missing",
createCloseIcon: () => contentIcons.createCloseIcon(),
environment
});
const opportunityWorkloadActions = createOpportunityWorkloadActions({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
createActionButton: number => workloadActionForm.createButton(number),
closeActionForm: (...args) => workloadActionForm.close(...args),
activityCache,
reportProgress: (...args) => activityProgress.update("actions", ...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
authSessionState,
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender(),
productsContentState, messageTypes, environment
});
const productsDetails = createProductsDetails({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
OPPORTUNITIES_COLUMNS: columns,
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
productsContentState,
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args),
environment
});
const aiSummaryContent = createAiSummaryContent({
aiSummaryState,
invalidateSalesCommonToken: (...args) => authSalesCommon.invalidateSalesCommonToken(...args),
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...args),
environment
});
const customerTableSearch = createCustomerTableSearch({ environment });
const installBaseView = createInstallBaseView({
createSearch: (...args) => customerTableSearch.createSearch(...args),
MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
environment
});
const installBaseAction = createInstallBaseAction({
messageTypes,
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
openInstallBaseModal: (...args) => installBaseView.openInstallBaseModal(...args),
environment
});
const serviceRequestsView = createServiceRequestsView({
createSearch: (...args) => customerTableSearch.createSearch(...args),
MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
environment
});
const serviceRequestsAction = createServiceRequestsAction({
messageTypes,
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
openServiceRequestsModal: (...args) => serviceRequestsView.openServiceRequestsModal(...args),
environment
});
const consumptionPlansAction = createConsumptionPlansAction({
messageTypes,
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
consumptionPlansState,
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
openAccountPlansModal: (...args) => consumptionPlansView.openAccountPlansModal(...args),
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
showCopyConfirmation: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
environment
});
const consumptionPlanDetailView = createConsumptionPlanDetailView({
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
environment
});
const consumptionPlanDetailController = createConsumptionPlanDetailController({
messageTypes,
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
renderPlanDetailBadges: (...args) => consumptionPlanDetailView.renderPlanDetailBadges(...args),
environment
});
const ratecardModel = createRatecardModel();
const consumptionHeaderView = createConsumptionHeaderView({
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
environment
});
const ratecardView = createRatecardView({
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
columns: ratecardModel.columns,
formatValue: (...args) => ratecardModel.formatValue(...args),
selectItems: (...args) => ratecardModel.selectItems(...args),
createPlanDetails: (...args) => consumptionHeaderView.createDetails(...args),
createConsumptionHeader: (...args) => consumptionHeaderView.createHeader(...args),
environment
});
const ratecardController = createRatecardController({
messageTypes,
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
openRatecardDrawer: (...args) => ratecardView.openRatecardDrawer(...args),
closePlanDetailPopover: (...args) => consumptionPlanDetailView.closePlanDetailPopover(...args),
environment
});
const consumptionPlansView = createConsumptionPlansView({
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
MODAL_ID: contracts.MODAL_ID,
createConsumptionHeader: (...args) => consumptionHeaderView.createHeader(...args),
createConsumptionTrendSection: (...args) => consumptionTrendController.createConsumptionTrendSection(...args),
loadPlanDetailForPanel: (...args) => consumptionPlanDetailController.loadPlanDetailForPanel(...args),
closePlanDetailPopover: (...args) => consumptionPlanDetailView.closePlanDetailPopover(...args),
createRatecardButton: (...args) => ratecardController.createRatecardButton(...args),
closeRatecardDrawer: (...args) => ratecardView.closeRatecardDrawer(...args),
createSvgElement: (...args) => consumptionChart.createSvgElement(...args),
createPlanDetails: (...args) => consumptionHeaderView.createDetails(...args),
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
loadConsumptionTrendForPanel: (...args) => consumptionTrendController.loadConsumptionTrendForPanel(...args),
environment
});
const consumptionTrendController = createConsumptionTrendController({
createPlanDetailSection: (...args) => consumptionPlanDetailController.createPlanDetailSection(...args),
messageTypes,
CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS,
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
consumptionTrendState,
renderConsumptionTrendChart: (...args) => consumptionChart.renderConsumptionTrendChart(...args),
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
environment
});
const consumptionChart = createConsumptionChart({
CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS,
CONSUMPTION_TREND_SERIES: contracts.CONSUMPTION_TREND_SERIES,
SVG_NAMESPACE: contracts.SVG_NAMESPACE,
calculateAverageConsumption: (...args) => consumptionModel.calculateAverageConsumption(...args),
consumptionTrendState,
createConsumptionTrendKey: (...args) => consumptionTrendController.createConsumptionTrendKey(...args),
createNiceConsumptionMaximum: (...args) => consumptionModel.createNiceConsumptionMaximum(...args),
formatCompactConsumptionNumber: (...args) => consumptionModel.formatCompactConsumptionNumber(...args),
formatConsumptionCurrency: (...args) => consumptionModel.formatConsumptionCurrency(...args),
formatConsumptionPointLabel: (...args) => consumptionModel.formatConsumptionPointLabel(...args),
formatConsumptionRange: (...args) => consumptionModel.formatConsumptionRange(...args),
normalizeConsumptionTrendItems: (...args) => consumptionModel.normalizeConsumptionTrendItems(...args),
environment
});
const consumptionModel = createConsumptionModel({
});
const debugContentLog = createDebugContentLog({
previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
debugState,
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args)
});
const debugContentView = createDebugContentView({
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID,
MODAL_ID: contracts.MODAL_ID,
debugState,
formatDebugBody: (...args) => debugContentLog.formatDebugBody(...args),
formatHeaderBlock: (...args) => debugContentLog.formatHeaderBlock(...args),
formatMetadataBlock: (...args) => debugContentLog.formatMetadataBlock(...args),
environment
});
function start() {
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
}
return {
workloadActionForm,
activityFilters,
start, contracts,
opportunityWorkloadActions,
fusionCrmServiceRequests, opportunityCrmServiceRequests,
authSessionState,
authSalesCommonState,
opportunitiesQueryState,
opportunitiesTableState,
opportunitiesFilterState,
preferencesState,
productsContentState,
aiSummaryState,
consumptionPlansState,
consumptionTrendState,
opportunitiesDashboardState,
debugState,
contentFusionPage,
contentModal,
contentTheme,
contentIcons,
contentStyles,
authContentSession,
currentUser,
authSalesCommon,
authTokenRelay,
authXsrfContent,
platformContentMessages,
opportunitiesController,
fusionOpportunities,
opportunitiesFiscalPeriods,
opportunitiesTable,
opportunitiesDashboard,
opportunitiesFilters,
opportunitiesFormatters,
opportunitiesLinks,
preferencesStorage,
preferencesView,
productsContentJobs,
productActivities,
activityProgress,
refreshAll,
activityCache,
productsDetails,
aiSummaryContent,
consumptionPlansAction,
installBaseAction,
installBaseView,
serviceRequestsAction,
serviceRequestsView,
consumptionPlansView,
consumptionPlanDetailController,
consumptionPlanDetailView,
ratecardModel,
ratecardView,
ratecardController,
consumptionTrendController,
consumptionChart,
consumptionModel,
debugContentLog,
debugContentView
};
}

100
src/content/contracts.js Normal file
View File

@@ -0,0 +1,100 @@
function createContentContracts() {
const TILE_ID = "c_5c719a88b5624b1eaf81defe2c2ex4x5";
const TILE_LABEL_ID = `${TILE_ID}_0`;
const SALES_GROUP_SELECTOR = "#yourapps_groupNode_sales";
const ADD_ICON_SELECTOR = ".flat-grid-cell.flat-grid-cell-addicon";
const MODAL_ID = "opportunities-extension-modal";
const STYLE_ID = "opportunities-extension-redwood-styles";
const ESCAPE_LISTENER_KEY = "opportunitiesExtensionEscapeListener";
const DEBUG_PANEL_ID = "opportunities-extension-debug-panel";
const OPPORTUNITIES_TABLE_ID = "opportunities-extension-table";
const STAGE_FILTERS_ID = "opportunities-extension-stage-filters";
const STATUS_FILTERS_ID = "opportunities-extension-status-filters";
const CUSTOMER_FILTER_BUTTON_ID = "opportunities-extension-customer-filter-button";
const CUSTOMER_FILTER_PANEL_ID = "opportunities-extension-customer-filter-panel";
const CUSTOMER_FILTER_SEARCH_ID = "opportunities-extension-customer-filter-search";
const CUSTOMER_FILTER_SELECT_ALL_ID = "opportunities-extension-customer-filter-select-all";
const CUSTOMER_FILTER_LIST_ID = "opportunities-extension-customer-filter-list";
const OWNER_FILTER_BUTTON_ID = "opportunities-extension-owner-filter-button";
const OWNER_FILTER_PANEL_ID = "opportunities-extension-owner-filter-panel";
const OWNER_FILTER_SEARCH_ID = "opportunities-extension-owner-filter-search";
const OWNER_FILTER_SELECT_ALL_ID = "opportunities-extension-owner-filter-select-all";
const OWNER_FILTER_LIST_ID = "opportunities-extension-owner-filter-list";
const TABLE_SEARCH_ID = "opportunities-extension-table-search";
const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard";
const PRODUCTS_PROGRESS_ID = "opportunities-extension-products-progress";
const PRODUCTS_CACHE_TIMESTAMP_ID = "opportunities-extension-products-cache-timestamp";
const FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences";
const THEME_STORAGE_KEY = "opportunities-extension-theme";
const OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY = "opportunities-extension-opportunity-type-preferences";
const PREFERENCES_MODAL_ID = "opportunities-extension-preferences-modal";
const ACCOUNT_PLANS_MODAL_ID = "opportunities-extension-account-plans-modal";
const PRODUCTS_MESSAGE_LISTENER_KEY = "opportunitiesExtensionProductsMessageListener";
const OPPORTUNITY_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid=";
const ACCOUNT_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/accounts/accounts-detail?id=";
const DEBUG_BODY_LIMIT = 12000;
const OPPORTUNITIES_PAGE_LIMIT = 15;
const DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS = 24;
const TOKEN_RELAY_URL = "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay";
const OPPORTUNITIES_QUERY_URL = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/11.13.18.05/custom-actions/queries";
const AUTH_STATUS = {
idle: "idle",
pending: "pending",
authenticated: "authenticated",
unauthenticated: "unauthenticated"
};
const CONSUMPTION_TREND_PERIODS = [
{ value: "quarterly", label: "Quarterly", windowSize: 8 },
{ value: "monthly", label: "Monthly", windowSize: 13 },
{ value: "weekly", label: "Weekly", windowSize: 13 },
{ value: "daily", label: "Daily", windowSize: 30 }
];
const CONSUMPTION_TREND_SERIES = [
{ value: "funded", label: "Funded Allocation" },
{ value: "overage", label: "Overage" },
{ value: "average", label: "Average Consumption" }
];
const DEFAULT_CONSUMPTION_TREND_PERIOD = "weekly";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"];
const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"];
const OPPORTUNITY_TYPE_OPTIONS = [
{ label: "All records I can see", value: "ORA_ALLOPTIES" },
{ label: "I am credit receiver", value: "ORA_CREDITRECEIVER_ISME" },
{ label: "I am on the team", value: "ORA_MYSALESTEAMOPTIES" },
{ label: "I or my subordinate is a credit receiver", value: "ORA_CREDITRECEIVER_MYORG" },
{ label: "I own", value: "ORA_MYOPTIES" },
{ label: "My subordinates are on the team", value: "ORA_MYSUBORDSSALESTEAMOPTIES" },
{ label: "My subordinates own", value: "ORA_MYSUBORDINATESOPTIES" },
{ label: "My territory", value: "ORA_MYASSGTERROPTIES" },
{ label: "My territory hierarchy", value: "ORA_MYASSGDESCTERROPTIES" },
{ label: "My territory membership", value: "ORA_MYASSGMEMBTERROPTIES" },
{ label: "My territory membership hierarchy", value: "ORA_MYASSGMEMBDESCTERROPTIES" }
];
const DEFAULT_OPPORTUNITY_TYPE_VALUES = [
"ORA_MYSALESTEAMOPTIES",
"ORA_MYASSGTERROPTIES",
"ORA_CREDITRECEIVER_ISME"
];
const OPPTY_PRODUCTS_CACHE_OPTIONS = [
{ label: "1 hour", value: 1 },
{ label: "6 hours", value: 6 },
{ label: "12 hours", value: 12 },
{ label: "24 hours (default)", value: 24 },
{ label: "48 hours", value: 48 },
{ label: "7 days", value: 168 }
];
const PERIOD_OPTIONS = [
"Current Quarter",
"Next Quarter",
"Previous Quarter",
"Current Fiscal Year",
"4 Rolling Quarters (CQ + 3)",
"RENEWALS (Current + Past Due)"
];
const ALLOWED_PATHS = [
"/hcmUI/faces/FuseWelcome",
"/fscmUI/faces/FuseWelcome"
];
return { TILE_ID, TILE_LABEL_ID, SALES_GROUP_SELECTOR, ADD_ICON_SELECTOR, MODAL_ID, STYLE_ID, ESCAPE_LISTENER_KEY, DEBUG_PANEL_ID, OPPORTUNITIES_TABLE_ID, STAGE_FILTERS_ID, STATUS_FILTERS_ID, CUSTOMER_FILTER_BUTTON_ID, CUSTOMER_FILTER_PANEL_ID, CUSTOMER_FILTER_SEARCH_ID, CUSTOMER_FILTER_SELECT_ALL_ID, CUSTOMER_FILTER_LIST_ID, OWNER_FILTER_BUTTON_ID, OWNER_FILTER_PANEL_ID, OWNER_FILTER_SEARCH_ID, OWNER_FILTER_SELECT_ALL_ID, OWNER_FILTER_LIST_ID, TABLE_SEARCH_ID, STAGE_DASHBOARD_ID, PRODUCTS_PROGRESS_ID, PRODUCTS_CACHE_TIMESTAMP_ID, FILTER_PREFERENCES_STORAGE_KEY, THEME_STORAGE_KEY, OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, PREFERENCES_MODAL_ID, ACCOUNT_PLANS_MODAL_ID, PRODUCTS_MESSAGE_LISTENER_KEY, OPPORTUNITY_DETAIL_URL, ACCOUNT_DETAIL_URL, DEBUG_BODY_LIMIT, OPPORTUNITIES_PAGE_LIMIT, DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, TOKEN_RELAY_URL, OPPORTUNITIES_QUERY_URL, AUTH_STATUS, CONSUMPTION_TREND_PERIODS, CONSUMPTION_TREND_SERIES, DEFAULT_CONSUMPTION_TREND_PERIOD, SVG_NAMESPACE, STAGE_OPTIONS, STATUS_OPTIONS, OPPORTUNITY_TYPE_OPTIONS, DEFAULT_OPPORTUNITY_TYPE_VALUES, OPPTY_PRODUCTS_CACHE_OPTIONS, PERIOD_OPTIONS, ALLOWED_PATHS };
}

107
src/content/fusion-page.js Normal file
View File

@@ -0,0 +1,107 @@
function createContentFusionPage({
ADD_ICON_SELECTOR,
ALLOWED_PATHS,
SALES_GROUP_SELECTOR,
TILE_ID,
TILE_LABEL_ID,
appendPath,
openOpportunitiesModal,
environment
}) {
const { Node, document, window } = environment;
function createOpportunitiesTile() {
const wrapper = document.createElement("div");
wrapper.className = "flat-grid-cell";
const item = document.createElement("div");
item.id = TILE_ID;
item.className = "app-nav-item opportunities-extension-tile";
item.setAttribute("filmstrip", "Opportunities Extension");
item.setAttribute("page", "undefined");
item.setAttribute("index", "0");
item.setAttribute("type", "subcluster");
item.setAttribute("title", "Opportunities Extension");
item.setAttribute("group", "groupNode_tools");
item.setAttribute("destinationurl", "https://gxpap.oracle.com/ords/pgxpap/f?p=138");
item.setAttribute("targetframe", "_blank");
item.setAttribute("isdesturlexist", "true");
item.setAttribute("role", "presentation");
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 48 48");
svg.setAttribute("style", "fill:currentColor");
svg.setAttribute("class", "svg-nav suiicon svg-bkgd09");
svg.setAttribute("data-icon", "navi_reportsearch");
svg.setAttribute("role", "presentation");
svg.setAttribute("focusable", "false");
appendPath(svg, "svg-shortcut", "M28 42.5l-3 2.7v-1.7c-.4 0-1.4 0-2.5.6-1.3 1-1.5 1.6-1.5 1.6s-.4-1.2.8-2.7c1.2-1.6 2.6-1.7 3.2-1.6v-1.6l3 2.7z");
appendPath(svg, "svg-cluster", "M28.5 41.3c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2zm-4 0c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2c-.7 0-1.2-.5-1.2-1.2s.5-1.2 1.2-1.2zm-4 0c.7 0 1.2.5 1.2 1.2s-.5 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2z");
appendPath(svg, "svg-icon15", "M16 31l-1.6-1-3.4 6.5s0 1 .5 1.4c.5.2 1.4-.4 1.4-.4l3-6.7z");
appendPath(svg, "svg-icon03", "M36 10H12c-.8 0-2 1.2-2 2v20c0 .4.2.8.5 1l2-3.6c-1-1.4-1.6-3-1.6-5 0-4.2 3.3-7.6 7.4-7.6H20V16h2v1.7c.7.4 1.3 1 1.8 1.5.6.5 1 1 1.3 1.8h4v7h-4c-.3.7-.8 1.4-1.4 2H35v2H20.3l-2 .2H18L17 34h19c.8 0 2-1.2 2-2V12c0-.8-1.2-2-2-2zm-23 4v-2h22v2H13zm22 14h-5V17h5v11z");
appendPath(svg, "svg-icon12", "M18.5 19c-3 0-5.5 2.5-5.5 5.5s2.5 5.5 5.5 5.5 5.5-2.5 5.5-5.5-2.5-5.5-5.5-5.5zm0 9c-2 0-3.5-1.6-3.5-3.5 0-2 1.6-3.5 3.5-3.5s3.5 1.6 3.5 3.5c0 2-1.6 3.5-3.5 3.5z");
appendPath(svg, "svg-outline", "M35 34.56H13a2.7 2.7 0 0 1-3-3V14a2.76 2.76 0 0 1 3-3h22a2.74 2.74 0 0 1 3 3v17.56a2.68 2.68 0 0 1-3 3zM16.98 22.32a4.72 4.72 0 1 0 4.73 4.73 4.72 4.72 0 0 0-4.73-4.73zM24 25h3.47v4.72H24V25zm5.78-4.66h4.69v9.38h-4.69v-9.38zM13.5 14.5h20.9v2.4H13.5v-2.4zm6.9 17.59l-.01-1.94zm-2.04-12.75l-5.35-.02zm2.13 12.67H35.7zm-.09-8.05L20.39 19z");
appendPath(svg, "svg-outline", "M16.98 31.72a4.68 4.68 0 1 0-4.75-4.67 4.74 4.74 0 0 0 4.75 4.67zm-1.44-.5l-3.6 6.83zM8 40");
const link = document.createElement("a");
link.id = TILE_LABEL_ID;
link.className = "app-nav-label flat-grid-nav-label";
link.href = "#";
link.textContent = "Opportunities Extension";
item.append(svg, link);
wrapper.append(item);
wrapper.addEventListener("click", handleTileClick);
return wrapper;
}
function handleTileClick(event) {
event.preventDefault();
event.stopPropagation();
openOpportunitiesModal();
}
function isAllowedPage() {
if (window.location.hostname !== "eeho.fa.us2.oraclecloud.com") {
return false;
}
return ALLOWED_PATHS.some((path) => {
return window.location.pathname === path || window.location.pathname.startsWith(`${path}/`);
});
}
function findInsertionPoint(salesGroup) {
const addIconInsideGroup = salesGroup.querySelector(ADD_ICON_SELECTOR);
if (addIconInsideGroup) {
return addIconInsideGroup;
}
const container = salesGroup.closest(".flat-grid, .flat-grid-container, [id*='yourapps'], [id*='groupNode']") || salesGroup.parentElement || document.body;
const addIconCells = Array.from(container.querySelectorAll(ADD_ICON_SELECTOR));
return addIconCells.find((cell) => {
return Boolean(salesGroup.compareDocumentPosition(cell) & Node.DOCUMENT_POSITION_FOLLOWING);
}) || null;
}
function insertTile() {
if (document.getElementById(TILE_ID)) {
return true;
}
const salesGroup = document.querySelector(SALES_GROUP_SELECTOR);
const addIconCell = salesGroup ? findInsertionPoint(salesGroup) : null;
if (!salesGroup || !addIconCell) {
return false;
}
addIconCell.before(createOpportunitiesTile());
return true;
}
return { createOpportunitiesTile, handleTileClick, isAllowedPage, findInsertionPoint, insertTile };
}

89
src/content/icons.js Normal file
View File

@@ -0,0 +1,89 @@
function createContentIcons({
appendPath,
environment
}) {
const { document } = environment;
function createThemeIcon(iconName) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
if (iconName === "sun") {
path.setAttribute("d", "M12 3V1m0 22v-2m9-9h2M1 12h2m15.36 6.36 1.42 1.42M4.22 4.22l1.42 1.42m12.72 0 1.42-1.42M4.22 19.78l1.42-1.42M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z");
} else {
path.setAttribute("d", "M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5a8.5 8.5 0 1 0 10.7 10.7Z");
}
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
path.setAttribute("stroke-width", "1.8");
svg.append(path);
return svg;
}
function createSettingsIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const upperControl = document.createElementNS("http://www.w3.org/2000/svg", "circle");
const lowerControl = document.createElementNS("http://www.w3.org/2000/svg", "circle");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("class", "opportunities-extension-settings-icon");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
appendPath(svg, "", "M4 7h16M4 17h16");
[upperControl, lowerControl].forEach((control, index) => {
control.setAttribute("cx", index === 0 ? "9" : "15");
control.setAttribute("cy", index === 0 ? "7" : "17");
control.setAttribute("r", "2.5");
control.setAttribute("fill", "currentColor");
control.setAttribute("stroke", "currentColor");
control.setAttribute("stroke-width", "1.8");
});
svg.querySelector("path").setAttribute("fill", "none");
svg.querySelector("path").setAttribute("stroke", "currentColor");
svg.querySelector("path").setAttribute("stroke-linecap", "round");
svg.querySelector("path").setAttribute("stroke-width", "1.8");
svg.append(upperControl, lowerControl);
return svg;
}
function createCloseIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
path.setAttribute("d", "m6 6 12 12M18 6 6 18");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-width", "1.8");
svg.append(path);
return svg;
}
function createRefreshIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
path.setAttribute("d", "M20 7v5h-5M4 17v-5h5M6.1 6.1A8 8 0 0 1 19.7 10M4.3 14a8 8 0 0 0 13.6 3.9");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.8");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
svg.append(path);
return svg;
}
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon };
}

9
src/content/main.js Normal file
View File

@@ -0,0 +1,9 @@
/** Preserve the existing document_idle initialization and observer behavior. */
function startContentApplication({ environment, page, styles, products }) {
if (!page.isAllowedPage()) return;
styles.ensureExtensionStyles();
products.ensureProductsBackgroundMessageHandler();
if (page.insertTile()) return;
const observer = new environment.MutationObserver(() => page.insertTile());
observer.observe(environment.document.documentElement, { childList: true, subtree: true });
}

573
src/content/modal.js Normal file
View File

@@ -0,0 +1,573 @@
function createContentModal({
ACCOUNT_PLANS_MODAL_ID,
AUTH_STATUS,
CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID,
CUSTOMER_FILTER_PANEL_ID,
CUSTOMER_FILTER_SEARCH_ID,
CUSTOMER_FILTER_SELECT_ALL_ID,
DEBUG_PANEL_ID,
ESCAPE_LISTENER_KEY,
MODAL_ID,
OPPORTUNITIES_TABLE_ID,
OWNER_FILTER_BUTTON_ID,
OWNER_FILTER_LIST_ID,
OWNER_FILTER_PANEL_ID,
OWNER_FILTER_SEARCH_ID,
OWNER_FILTER_SELECT_ALL_ID,
PERIOD_OPTIONS,
PREFERENCES_MODAL_ID,
PRODUCTS_CACHE_TIMESTAMP_ID,
PRODUCTS_PROGRESS_ID,
STAGE_DASHBOARD_ID,
STAGE_FILTERS_ID,
STATUS_FILTERS_ID,
TABLE_SEARCH_ID,
applyTheme,
authSessionState,
closeAccountPlansModal,
closeInstallBaseModal,
closeServiceRequestsModal,
closeActionForm,
closeCustomerFilterPanel,
closeOwnerFilterPanel,
closePreferencesModal,
createSettingsIcon,
createRefreshAllButton,
createActivityFilterControls,
debugState,
ensureExtensionStyles,
getCustomerOptions,
getOwnerOptions,
getSavedOpportunityTypeValues,
getSavedOpptyProductsCacheHours,
getSavedTheme,
openPreferencesModal,
opportunitiesFilterState,
opportunitiesQueryState,
preferencesState,
renderCustomerFilter,
renderCustomerFilterList,
renderOpportunitiesTable,
renderOwnerFilter,
renderOwnerFilterList,
renderStageFilterButtons,
renderStatusFilterButtons,
requestOpportunitiesForPeriod,
closeCrmSrPopover,
requestTokenRelayOnce,
ensureCurrentUser,
resetCustomerAndOwnerFiltersForPeriod,
resetOpportunitiesTable,
restoreFilterPreferences,
saveFilterPreferences,
toggleCustomerFilterPanel,
toggleDebugPanel,
toggleOwnerFilterPanel,
environment
}) {
const { document, window, setTimeout, clearTimeout, matchMedia } = environment;
let authCompletion = null;
function resetAuthCompletion() {
if (!authCompletion) return;
clearTimeout(authCompletion.waitTimer);
clearTimeout(authCompletion.hideTimer);
authCompletion.badge.setAttribute("data-fading", "false");
authCompletion = null;
}
function openOpportunitiesModal() {
ensureExtensionStyles();
debugState.debugSecretsVisible = false;
const existingModal = document.getElementById(MODAL_ID);
if (existingModal) {
existingModal.hidden = false;
existingModal.querySelector("select").focus();
document.documentElement.classList.add("opportunities-extension-scroll-lock");
ensureEscapeKeyHandler();
ensureCurrentUser().then(() => {
if (existingModal.hidden) return;
return requestTokenRelayOnce();
})
.then(() => renderOpportunitiesTable()).catch(() => {});
return;
}
const overlay = document.createElement("div");
debugState.requestLog = [];
authSessionState.tokenRelayRequest = null;
authSessionState.consumerTokenServiceRequest = null;
authSessionState.accessToken = "";
authSessionState.principal = "";
opportunitiesQueryState.periodRequestVersion = 0;
authSessionState.authStatus = AUTH_STATUS.idle;
resetOpportunitiesTable();
preferencesState.selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues());
preferencesState.opptyProductsCacheHours = getSavedOpptyProductsCacheHours();
overlay.id = MODAL_ID;
overlay.className = "opportunities-extension-modal";
overlay.setAttribute("data-theme", getSavedTheme());
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-labelledby", "opportunities-extension-title");
const shell = document.createElement("section");
shell.className = "opportunities-extension-shell";
const header = document.createElement("header");
header.className = "opportunities-extension-header";
const title = document.createElement("h1");
title.id = "opportunities-extension-title";
title.textContent = "Opportunities Extension";
const authBadge = document.createElement("span");
authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending";
authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending);
authBadge.textContent = "Authenticating";
const subtitleRow = document.createElement("div");
subtitleRow.className = "opportunities-extension-subtitle-row";
const productsProgressBadge = document.createElement("span");
productsProgressBadge.id = PRODUCTS_PROGRESS_ID;
productsProgressBadge.className = "opportunities-extension-products-progress";
productsProgressBadge.setAttribute("role", "status");
productsProgressBadge.hidden = true;
const productsCacheTimestamp = document.createElement("span");
productsCacheTimestamp.id = PRODUCTS_CACHE_TIMESTAMP_ID;
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
productsCacheTimestamp.setAttribute("role", "status");
productsCacheTimestamp.hidden = true;
subtitleRow.append(authBadge, productsProgressBadge, productsCacheTimestamp);
const titleBlock = document.createElement("div");
titleBlock.className = "opportunities-extension-title-row";
titleBlock.append(title, subtitleRow);
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Fechar");
closeButton.textContent = "X";
closeButton.addEventListener("click", closeOpportunitiesModal);
const themeButton = document.createElement("button");
themeButton.type = "button";
themeButton.className = "opportunities-extension-icon-button opportunities-extension-theme-toggle";
themeButton.addEventListener("click", () => {
const nextTheme = overlay.getAttribute("data-theme") === "dark" ? "light" : "dark";
applyTheme(overlay, themeButton, nextTheme);
});
applyTheme(overlay, themeButton, overlay.getAttribute("data-theme"));
const preferencesButton = document.createElement("button");
preferencesButton.type = "button";
preferencesButton.className = "opportunities-extension-icon-button";
preferencesButton.setAttribute("aria-label", "Abrir preferências");
preferencesButton.title = "Preferences";
preferencesButton.append(createSettingsIcon());
preferencesButton.addEventListener("click", openPreferencesModal);
const headerActions = document.createElement("div");
headerActions.className = "opportunities-extension-header-actions";
headerActions.append(themeButton, createRefreshAllButton(() => select.value), preferencesButton, closeButton);
header.append(titleBlock, headerActions);
const body = document.createElement("main");
body.className = "opportunities-extension-body";
const form = document.createElement("form");
form.className = "opportunities-extension-form";
const field = document.createElement("label");
field.className = "opportunities-extension-field";
const labelText = document.createElement("span");
labelText.textContent = "Reference";
const selectWrap = document.createElement("span");
selectWrap.className = "opportunities-extension-select-wrap";
const select = document.createElement("select");
select.name = "opportunityPeriod";
PERIOD_OPTIONS.forEach((optionLabel) => {
const option = document.createElement("option");
option.value = optionLabel;
option.textContent = optionLabel;
select.append(option);
});
select.addEventListener("change", () => {
resetCustomerAndOwnerFiltersForPeriod();
saveFilterPreferences(select.value);
requestOpportunitiesForPeriod(select.value);
});
selectWrap.append(select);
field.append(labelText, selectWrap);
restoreFilterPreferences(select);
const stageFilter = document.createElement("section");
stageFilter.className = "opportunities-extension-stage-filter";
const stageFilterLabel = document.createElement("span");
stageFilterLabel.className = "opportunities-extension-stage-filter-label";
stageFilterLabel.textContent = "Stage";
const stageFilters = document.createElement("div");
stageFilters.id = STAGE_FILTERS_ID;
stageFilters.className = "opportunities-extension-stage-filter-controls";
stageFilters.setAttribute("role", "group");
stageFilters.setAttribute("aria-label", "Filter by stage");
stageFilter.append(stageFilterLabel, stageFilters);
const customerFilter = document.createElement("section");
customerFilter.className = "opportunities-extension-customer-filter";
const customerFilterLabel = document.createElement("span");
customerFilterLabel.className = "opportunities-extension-customer-filter-label";
customerFilterLabel.textContent = "Customer";
const customerFilterButton = document.createElement("button");
customerFilterButton.id = CUSTOMER_FILTER_BUTTON_ID;
customerFilterButton.type = "button";
customerFilterButton.className = "opportunities-extension-customer-filter-trigger";
customerFilterButton.setAttribute("aria-expanded", "false");
customerFilterButton.setAttribute("aria-controls", CUSTOMER_FILTER_PANEL_ID);
customerFilterButton.addEventListener("click", toggleCustomerFilterPanel);
const customerFilterPanel = document.createElement("section");
customerFilterPanel.id = CUSTOMER_FILTER_PANEL_ID;
customerFilterPanel.className = "opportunities-extension-customer-filter-panel";
customerFilterPanel.hidden = true;
const customerSearchInput = document.createElement("input");
customerSearchInput.id = CUSTOMER_FILTER_SEARCH_ID;
customerSearchInput.className = "opportunities-extension-customer-filter-search";
customerSearchInput.type = "search";
customerSearchInput.placeholder = "Search customers";
customerSearchInput.setAttribute("aria-label", "Search customers");
customerSearchInput.addEventListener("input", () => {
opportunitiesFilterState.customerSearch = customerSearchInput.value;
saveFilterPreferences();
renderCustomerFilterList();
});
const selectAllLabel = document.createElement("label");
selectAllLabel.className = "opportunities-extension-customer-filter-select-all";
const selectAllCheckbox = document.createElement("input");
selectAllCheckbox.id = CUSTOMER_FILTER_SELECT_ALL_ID;
selectAllCheckbox.type = "checkbox";
selectAllCheckbox.addEventListener("change", () => {
const customerOptions = getCustomerOptions();
opportunitiesFilterState.selectedCustomerKeys = selectAllCheckbox.checked
? new Set(customerOptions.map((customer) => customer.key))
: new Set();
opportunitiesFilterState.hasSavedCustomerFilter = true;
saveFilterPreferences();
renderCustomerFilter();
renderOpportunitiesTable();
});
const selectAllText = document.createElement("span");
selectAllText.textContent = "Select all";
selectAllLabel.append(selectAllCheckbox, selectAllText);
const customerFilterList = document.createElement("div");
customerFilterList.id = CUSTOMER_FILTER_LIST_ID;
customerFilterList.className = "opportunities-extension-customer-filter-list";
customerFilterPanel.append(customerSearchInput, selectAllLabel, customerFilterList);
customerFilter.append(customerFilterLabel, customerFilterButton, customerFilterPanel);
const ownerFilter = document.createElement("section");
ownerFilter.className = "opportunities-extension-customer-filter";
const ownerFilterLabel = document.createElement("span");
ownerFilterLabel.className = "opportunities-extension-customer-filter-label";
ownerFilterLabel.textContent = "Owner";
const ownerFilterButton = document.createElement("button");
ownerFilterButton.id = OWNER_FILTER_BUTTON_ID;
ownerFilterButton.type = "button";
ownerFilterButton.className = "opportunities-extension-customer-filter-trigger";
ownerFilterButton.setAttribute("aria-expanded", "false");
ownerFilterButton.setAttribute("aria-controls", OWNER_FILTER_PANEL_ID);
ownerFilterButton.addEventListener("click", toggleOwnerFilterPanel);
const ownerFilterPanel = document.createElement("section");
ownerFilterPanel.id = OWNER_FILTER_PANEL_ID;
ownerFilterPanel.className = "opportunities-extension-customer-filter-panel";
ownerFilterPanel.hidden = true;
const ownerSearchInput = document.createElement("input");
ownerSearchInput.id = OWNER_FILTER_SEARCH_ID;
ownerSearchInput.className = "opportunities-extension-customer-filter-search";
ownerSearchInput.type = "search";
ownerSearchInput.placeholder = "Search owners";
ownerSearchInput.setAttribute("aria-label", "Search owners");
ownerSearchInput.addEventListener("input", () => {
opportunitiesFilterState.ownerSearch = ownerSearchInput.value;
saveFilterPreferences();
renderOwnerFilterList();
});
const ownerSelectAllLabel = document.createElement("label");
ownerSelectAllLabel.className = "opportunities-extension-customer-filter-select-all";
const ownerSelectAllCheckbox = document.createElement("input");
ownerSelectAllCheckbox.id = OWNER_FILTER_SELECT_ALL_ID;
ownerSelectAllCheckbox.type = "checkbox";
ownerSelectAllCheckbox.addEventListener("change", () => {
const ownerOptions = getOwnerOptions();
opportunitiesFilterState.selectedOwnerKeys = ownerSelectAllCheckbox.checked
? new Set(ownerOptions.map((owner) => owner.key))
: new Set();
opportunitiesFilterState.hasSavedOwnerFilter = true;
saveFilterPreferences();
renderOwnerFilter();
renderOpportunitiesTable();
});
const ownerSelectAllText = document.createElement("span");
ownerSelectAllText.textContent = "Select all";
ownerSelectAllLabel.append(ownerSelectAllCheckbox, ownerSelectAllText);
const ownerFilterList = document.createElement("div");
ownerFilterList.id = OWNER_FILTER_LIST_ID;
ownerFilterList.className = "opportunities-extension-customer-filter-list";
ownerFilterPanel.append(ownerSearchInput, ownerSelectAllLabel, ownerFilterList);
ownerFilter.append(ownerFilterLabel, ownerFilterButton, ownerFilterPanel);
const tableSearchField = document.createElement("label");
tableSearchField.className = "opportunities-extension-table-search-field";
const tableSearchLabel = document.createElement("span");
tableSearchLabel.textContent = "Search";
const tableSearchInput = document.createElement("input");
tableSearchInput.id = TABLE_SEARCH_ID;
tableSearchInput.type = "search";
tableSearchInput.placeholder = "Search opportunities";
tableSearchInput.setAttribute("aria-label", "Search opportunities");
tableSearchInput.value = opportunitiesFilterState.tableSearch;
tableSearchInput.addEventListener("input", () => {
opportunitiesFilterState.tableSearch = tableSearchInput.value;
saveFilterPreferences();
renderOpportunitiesTable();
});
tableSearchField.append(tableSearchLabel, tableSearchInput);
const statusFilter = document.createElement("section");
statusFilter.className = "opportunities-extension-status-filter";
const statusFilterLabel = document.createElement("span");
statusFilterLabel.className = "opportunities-extension-status-filter-label";
statusFilterLabel.textContent = "Status";
const statusFilters = document.createElement("div");
statusFilters.id = STATUS_FILTERS_ID;
statusFilters.className = "opportunities-extension-status-filter-controls";
statusFilters.setAttribute("role", "group");
statusFilters.setAttribute("aria-label", "Filter by status");
statusFilter.append(statusFilterLabel, statusFilters);
form.addEventListener("submit", (event) => event.preventDefault());
const badgeFilters = createActivityFilterControls(form, statusFilter, stageFilter);
form.append(field, customerFilter, ownerFilter, tableSearchField, badgeFilters);
body.append(form);
const stageDashboard = document.createElement("section");
stageDashboard.id = STAGE_DASHBOARD_ID;
stageDashboard.className = "opportunities-extension-stage-dashboard";
stageDashboard.setAttribute("aria-label", "Opportunity amount by stage");
body.append(stageDashboard);
const tableSurface = document.createElement("section");
tableSurface.className = "opportunities-extension-table-surface";
const opportunitiesTable = document.createElement("table");
opportunitiesTable.id = OPPORTUNITIES_TABLE_ID;
opportunitiesTable.className = "opportunities-extension-table";
opportunitiesTable.setAttribute("aria-label", "Opportunities");
tableSurface.append(opportunitiesTable);
body.append(tableSurface);
const debugPanel = document.createElement("aside");
debugPanel.id = DEBUG_PANEL_ID;
debugPanel.className = "opportunities-extension-debug-panel";
debugPanel.hidden = true;
body.append(debugPanel);
shell.append(header, body);
overlay.append(shell);
document.body.append(overlay);
document.documentElement.classList.add("opportunities-extension-scroll-lock");
ensureEscapeKeyHandler();
renderStageFilterButtons();
renderStatusFilterButtons();
renderCustomerFilter();
renderOwnerFilter();
renderOpportunitiesTable();
requestOpportunitiesForPeriod(select.value);
select.focus();
}
function updateAuthBadge(status) {
const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`);
if (!badge) {
return;
}
const badgeStatus = status === AUTH_STATUS.authenticated || status === AUTH_STATUS.unauthenticated
? status
: AUTH_STATUS.pending;
if (authCompletion && (authCompletion.badge !== badge || badgeStatus !== AUTH_STATUS.authenticated)) resetAuthCompletion();
badge.hidden = Boolean(authCompletion?.hidden);
if (badgeStatus === AUTH_STATUS.authenticated && !authCompletion) {
const current = { badge, hidden: false, waitTimer: null, hideTimer: null };
authCompletion = current;
badge.setAttribute("data-fading", "false");
current.waitTimer = setTimeout(() => {
if (authCompletion !== current) return;
badge.setAttribute("data-fading", "true");
const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
current.hideTimer = setTimeout(() => {
if (authCompletion !== current) return;
current.hidden = true;
badge.hidden = true;
}, reducedMotion ? 0 : 300);
}, 5000);
}
badge.setAttribute("data-auth-status", badgeStatus);
badge.className = `opportunities-extension-auth-badge opportunities-extension-auth-badge-${badgeStatus}`;
badge.textContent = badgeStatus === AUTH_STATUS.authenticated
? "Authenticated"
: badgeStatus === AUTH_STATUS.unauthenticated
? "Unauthenticated"
: "Authenticating";
}
function closeOpportunitiesModal() {
resetAuthCompletion();
closeCrmSrPopover();
const modal = document.getElementById(MODAL_ID);
if (modal) {
closePreferencesModal();
closeAccountPlansModal();
closeInstallBaseModal();
closeServiceRequestsModal();
closeActionForm(false);
modal.hidden = true;
}
debugState.debugSecretsVisible = false;
document.documentElement.classList.remove("opportunities-extension-scroll-lock");
}
function ensureEscapeKeyHandler() {
if (window[ESCAPE_LISTENER_KEY]) {
return;
}
window[ESCAPE_LISTENER_KEY] = true;
document.addEventListener("keydown", (event) => {
const modal = document.getElementById(MODAL_ID);
if (!modal || modal.hidden) {
return;
}
if (event.ctrlKey && event.key.toLowerCase() === "d") {
event.preventDefault();
event.stopPropagation();
toggleDebugPanel();
return;
}
if (event.key === "Escape") {
if (closeActionForm()) {
event.preventDefault();
return;
}
if (closeServiceRequestsModal()) {
event.preventDefault();
return;
}
if (closeInstallBaseModal()) {
event.preventDefault();
return;
}
const accountPlansModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
if (accountPlansModal && !accountPlansModal.hidden) {
closeAccountPlansModal();
event.preventDefault();
return;
}
const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID);
if (preferencesModal && !preferencesModal.hidden) {
closePreferencesModal();
event.preventDefault();
return;
}
if (closeCustomerFilterPanel()) {
event.preventDefault();
return;
}
if (closeOwnerFilterPanel()) {
event.preventDefault();
return;
}
closeOpportunitiesModal();
}
});
document.addEventListener("pointerdown", (event) => {
const modal = document.getElementById(MODAL_ID);
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
const ownerPanel = document.getElementById(OWNER_FILTER_PANEL_ID);
if (!modal || modal.hidden) {
return;
}
if (panel && !panel.hidden) {
const customerFilter = panel.closest(".opportunities-extension-customer-filter");
if (customerFilter && !customerFilter.contains(event.target)) {
closeCustomerFilterPanel(false);
}
}
if (ownerPanel && !ownerPanel.hidden) {
const ownerFilter = ownerPanel.closest(".opportunities-extension-customer-filter");
if (ownerFilter && !ownerFilter.contains(event.target)) {
closeOwnerFilterPanel(false);
}
}
});
}
return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler };
}

20
src/content/styles.js Normal file
View File

@@ -0,0 +1,20 @@
function createContentStyles({
STYLE_ID,
redwoodStyles,
environment
}) {
const { document } = environment;
function ensureExtensionStyles() {
if (document.getElementById(STYLE_ID)) {
return;
}
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = redwoodStyles;
document.head.append(style);
}
return { ensureExtensionStyles };
}

File diff suppressed because it is too large Load Diff

10
src/content/svg.js Normal file
View File

@@ -0,0 +1,10 @@
function createSvgPaths({ environment }) {
const { document } = environment;
function appendPath(svg, className, d) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("class", className);
path.setAttribute("d", d);
svg.append(path);
}
return { appendPath };
}

32
src/content/theme.js Normal file
View File

@@ -0,0 +1,32 @@
function createContentTheme({
THEME_STORAGE_KEY,
createThemeIcon,
environment
}) {
const getLocalStorage = () => environment.localStorage;
function getSavedTheme() {
try {
return getLocalStorage().getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light";
} catch (error) {
return "light";
}
}
function applyTheme(modal, button, theme) {
const isDarkTheme = theme === "dark";
modal.setAttribute("data-theme", isDarkTheme ? "dark" : "light");
button.textContent = "";
button.setAttribute("aria-label", isDarkTheme ? "Ativar modo claro" : "Ativar modo escuro");
button.title = isDarkTheme ? "Light mode" : "Dark mode";
button.append(createThemeIcon(isDarkTheme ? "sun" : "moon"));
try {
getLocalStorage().setItem(THEME_STORAGE_KEY, isDarkTheme ? "dark" : "light");
} catch (error) {
// Continue with the selected theme when browser storage is unavailable.
}
}
return { getSavedTheme, applyTheme };
}

View File

@@ -0,0 +1,103 @@
function createAiSummaryContent({
aiSummaryState,
invalidateSalesCommonToken,
logRequestFailure,
logRequestStart,
logRequestSuccess,
readResponseBodyForDebug,
requestTokenRelaySalesCommon,
environment
}) {
const { document, fetch } = environment;
function requestGenerateAiSummary(optyNumber) {
const key = String(optyNumber);
const existing = aiSummaryState.aiSummariesByOptyNumber.get(key);
if (existing && existing.status === "loading") {
return existing.promise;
}
const state = { status: "loading", summaryText: "", error: "", promise: null };
aiSummaryState.aiSummariesByOptyNumber.set(key, state);
state.promise = (async () => {
let entry;
try {
let token = await requestTokenRelaySalesCommon();
if (!token) {
throw new Error("Authentication is required to generate the summary.");
}
const path = "/api/sales-common/gen-ai/v1/summarizations/generate";
const url = `https://eeho.fa.us2.oraclecloud.com${path}`;
const payload = { path, promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: key };
let response;
for (let attempt = 0; attempt < 2; attempt += 1) {
const headers = { Accept: "application/json", Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
entry = logRequestStart({
name: "requestGenerateAiSummary", method: "POST", url, headers,
body: JSON.stringify(payload, null, 2), metadata: { optyNumber: key, attempt: attempt + 1 }
});
response = await fetch(url, {
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
});
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
if (response.status !== 401 && response.status !== 403) {
break;
}
// A late failure for an old token must not discard a newly refreshed token.
invalidateSalesCommonToken(token);
if (attempt === 0) {
token = await requestTokenRelaySalesCommon();
}
}
if (!response.ok) {
throw new Error(`requestGenerateAiSummary failed with status ${response.status}.`);
}
let data;
try {
data = await response.json();
} catch (error) {
throw new Error("requestGenerateAiSummary returned an invalid JSON response.");
}
if (!data || typeof data.summaryText !== "string" || !data.summaryText.trim()) {
throw new Error("requestGenerateAiSummary returned an empty summary.");
}
state.summaryText = data.summaryText;
state.status = "ready";
} catch (error) {
state.status = "error";
state.error = error.message || "Unable to generate the AI summary.";
if (entry) {
logRequestFailure(entry, state.error);
}
} finally {
// Update only visible summaries, without reopening rows or rebuilding the table.
document.querySelectorAll(".opportunities-extension-ai-summary").forEach((alert) => {
if (alert.dataset.optyNumber === key) {
alert.replaceWith(createAiSummaryAlert(key));
}
});
}
})();
return state.promise;
}
function createAiSummaryAlert(optyNumber) {
const state = aiSummaryState.aiSummariesByOptyNumber.get(optyNumber);
const alert = document.createElement("div");
alert.className = "opportunities-extension-ai-summary";
alert.dataset.optyNumber = optyNumber;
alert.setAttribute("role", "status");
alert.setAttribute("aria-live", "polite");
alert.setAttribute("aria-busy", String(!state || state.status === "loading"));
const title = document.createElement("strong");
title.textContent = "Summary generated by AI";
const description = document.createElement("p");
description.textContent = !state || state.status === "loading"
? "Generating summary..."
: state.status === "error" ? state.error : state.summaryText;
alert.classList.toggle("opportunities-extension-ai-summary-error", Boolean(state && state.status === "error"));
alert.append(title, description);
return alert;
}
return { requestGenerateAiSummary, createAiSummaryAlert };
}

View File

@@ -0,0 +1,5 @@
function createAiSummaryState() {
return {
aiSummariesByOptyNumber: new Map()
};
}

View File

@@ -0,0 +1,383 @@
function createConsumptionChart({
CONSUMPTION_TREND_PERIODS,
CONSUMPTION_TREND_SERIES,
SVG_NAMESPACE,
calculateAverageConsumption,
consumptionTrendState,
createConsumptionTrendKey,
createNiceConsumptionMaximum,
formatCompactConsumptionNumber,
formatConsumptionCurrency,
formatConsumptionPointLabel,
formatConsumptionRange,
normalizeConsumptionTrendItems,
environment
}) {
const { document, window } = environment;
function renderConsumptionTrendChart(section, chart, items, period, subPlanNum) {
chart.textContent = "";
chart.setAttribute("aria-busy", "false");
const points = normalizeConsumptionTrendItems(items);
if (points.length === 0) {
const empty = document.createElement("p");
empty.className = "opportunities-extension-consumption-chart-empty";
empty.textContent = "No consumption data found for this period.";
chart.append(empty);
return;
}
const option = CONSUMPTION_TREND_PERIODS.find((item) => item.value === period);
const windowSize = option ? option.windowSize : points.length;
const offsetKey = createConsumptionTrendKey(subPlanNum, period);
const maxOffset = Math.max(0, Math.ceil(points.length / windowSize) - 1);
const offset = Math.min(consumptionTrendState.consumptionTrendWindowOffsets.get(offsetKey) || 0, maxOffset);
const end = Math.max(0, points.length - (offset * windowSize));
const start = Math.max(0, end - windowSize);
const visiblePoints = points.slice(start, end);
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, offset);
const navigation = document.createElement("div");
navigation.className = "opportunities-extension-consumption-navigation";
const olderButton = createConsumptionNavigationButton("older", start === 0);
const newerButton = createConsumptionNavigationButton("newer", end === points.length);
const range = document.createElement("strong");
range.textContent = formatConsumptionRange(visiblePoints, period);
olderButton.addEventListener("click", () => {
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.min(maxOffset, offset + 1));
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
});
newerButton.addEventListener("click", () => {
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.max(0, offset - 1));
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
});
navigation.append(olderButton, range, newerButton);
const figure = document.createElement("figure");
figure.className = "opportunities-extension-consumption-figure";
const visual = document.createElement("div");
visual.className = "opportunities-extension-consumption-visual";
const tooltip = document.createElement("div");
tooltip.className = "opportunities-extension-consumption-tooltip";
tooltip.hidden = true;
tooltip.setAttribute("role", "tooltip");
const activeSeries = getConsumptionTrendSeriesFilter(subPlanNum);
const svg = createConsumptionSvg(
visiblePoints,
period,
end === points.length,
activeSeries,
tooltip
);
const legend = createConsumptionLegend(
section,
chart,
items,
period,
subPlanNum,
activeSeries
);
visual.append(svg, legend);
figure.append(visual, tooltip);
chart.append(navigation, figure);
}
function getConsumptionTrendSeriesFilter(subPlanNum) {
if (!consumptionTrendState.consumptionTrendSeriesFilters.has(subPlanNum)) {
consumptionTrendState.consumptionTrendSeriesFilters.set(
subPlanNum,
new Set(CONSUMPTION_TREND_SERIES.map((series) => series.value))
);
}
return consumptionTrendState.consumptionTrendSeriesFilters.get(subPlanNum);
}
function createConsumptionLegend(section, chart, items, period, subPlanNum, activeSeries) {
const legend = document.createElement("div");
legend.className = "opportunities-extension-consumption-legend";
legend.setAttribute("role", "group");
legend.setAttribute("aria-label", "Chart series filters");
CONSUMPTION_TREND_SERIES.forEach((series) => {
const button = document.createElement("button");
const swatch = document.createElement("span");
const label = document.createElement("span");
button.type = "button";
button.dataset.series = series.value;
button.setAttribute("aria-pressed", String(activeSeries.has(series.value)));
button.setAttribute("aria-label", `${activeSeries.has(series.value) ? "Hide" : "Show"} ${series.label}`);
swatch.className = `opportunities-extension-consumption-legend-swatch opportunities-extension-consumption-legend-${series.value}`;
swatch.setAttribute("aria-hidden", "true");
label.textContent = series.label;
button.append(swatch, label);
button.addEventListener("click", () => {
if (activeSeries.has(series.value)) {
activeSeries.delete(series.value);
} else {
activeSeries.add(series.value);
}
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
chart.querySelector(`[data-series="${series.value}"]`)?.focus();
});
legend.append(button);
});
return legend;
}
function createConsumptionNavigationButton(direction, disabled) {
const button = document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-consumption-navigation-button";
button.disabled = disabled;
button.setAttribute("aria-label", direction === "older" ? "Show older consumption data" : "Show newer consumption data");
button.title = direction === "older" ? "Older" : "Newer";
const icon = createSvgElement("svg", {
viewBox: "0 0 24 24",
"aria-hidden": "true",
focusable: "false"
});
icon.append(createSvgElement("path", {
d: direction === "older" ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7",
fill: "none",
stroke: "currentColor",
"stroke-linecap": "round",
"stroke-linejoin": "round",
"stroke-width": "2"
}));
button.append(icon);
return button;
}
function createConsumptionSvg(points, period, hasPartialLatestPoint, activeSeries, tooltip) {
const width = 1000;
const height = 350;
const plot = { left: 62, top: 24, right: 980, bottom: 258 };
const plotWidth = plot.right - plot.left;
const plotHeight = plot.bottom - plot.top;
const average = calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries);
const maximum = createNiceConsumptionMaximum(Math.max(average, ...points.map((point) => point.total)));
const svg = createSvgElement("svg", {
viewBox: `0 0 ${width} ${height}`,
role: "img",
"aria-label": `${period} cloud consumption chart`
});
svg.classList.add("opportunities-extension-consumption-svg");
for (let tick = 0; tick <= 5; tick += 1) {
const value = (maximum / 5) * tick;
const y = plot.bottom - ((value / maximum) * plotHeight);
svg.append(
createSvgElement("line", { x1: plot.left, y1: y, x2: plot.right, y2: y, class: "consumption-grid-line" }),
createSvgText(plot.left - 10, y + 4, formatCompactConsumptionNumber(value), "consumption-axis-label", "end")
);
}
const step = plotWidth / Math.max(points.length, 1);
const barWidth = Math.min(44, Math.max(10, step * 0.58));
points.forEach((point, index) => {
const x = plot.left + (step * index) + ((step - barWidth) / 2);
const fundedAmount = activeSeries.has("funded") ? point.funded : 0;
const overageAmount = activeSeries.has("overage") ? point.overage : 0;
const visibleTotal = fundedAmount + overageAmount;
const fundedHeight = (fundedAmount / maximum) * plotHeight;
const overageHeight = (overageAmount / maximum) * plotHeight;
const group = createSvgElement("g", {
class: "consumption-bar-group",
tabindex: "0",
role: "img",
"aria-label": createConsumptionPointAriaLabel(point, period)
});
["pointerenter", "pointermove", "focus"].forEach((eventName) => {
group.addEventListener(eventName, (event) => {
showConsumptionTooltip(tooltip, point, period, event);
});
});
["pointerleave", "blur"].forEach((eventName) => {
group.addEventListener(eventName, () => {
tooltip.hidden = true;
});
});
if (fundedHeight > 0) {
const fundedBar = createSvgElement("rect", {
x,
y: plot.bottom - fundedHeight,
width: barWidth,
height: fundedHeight,
class: "consumption-bar-funded"
});
appendConsumptionBarAnimation(
fundedBar,
plot.bottom,
plot.bottom - fundedHeight,
fundedHeight,
index * 34
);
group.append(fundedBar);
}
if (overageHeight > 0) {
const overageTop = plot.bottom - fundedHeight - overageHeight;
const overageBar = createSvgElement("rect", {
x,
y: overageTop,
width: barWidth,
height: overageHeight,
class: "consumption-bar-overage"
});
appendConsumptionBarAnimation(
overageBar,
plot.bottom - fundedHeight,
overageTop,
overageHeight,
(index * 34) + 90
);
group.append(overageBar);
}
const top = plot.bottom - fundedHeight - overageHeight;
if (visibleTotal > 0) {
const valueLabel = createSvgText(
x + (barWidth / 2),
Math.max(plot.top + 11, top - 7),
formatCompactConsumptionNumber(visibleTotal),
"consumption-value-label consumption-animated-label",
"middle"
);
valueLabel.style.animationDelay = `${(index * 34) + 360}ms`;
group.append(valueLabel);
}
const label = createSvgText(
x + (barWidth / 2),
plot.bottom + 18,
formatConsumptionPointLabel(point, period),
"consumption-x-label",
period === "monthly" || period === "quarterly" ? "middle" : "end"
);
if (period === "weekly" || period === "daily") {
label.setAttribute("transform", `rotate(-65 ${x + (barWidth / 2)} ${plot.bottom + 18})`);
}
group.append(label);
svg.append(group);
});
if (activeSeries.has("average")) {
const averageY = plot.bottom - ((average / maximum) * plotHeight);
const averageLine = createSvgElement("line", {
x1: plot.left,
y1: averageY,
x2: plot.right,
y2: averageY,
class: "consumption-average-line consumption-animated-average"
});
svg.append(averageLine);
}
return svg;
}
function appendConsumptionBarAnimation(element, fromY, toY, height, delayMs) {
if (prefersReducedMotion()) {
return;
}
element.append(
createSvgElement("animate", {
attributeName: "y",
from: fromY,
to: toY,
dur: "0.52s",
begin: `${delayMs}ms`,
fill: "freeze",
calcMode: "spline",
keyTimes: "0;1",
keySplines: "0.2 0.8 0.2 1"
}),
createSvgElement("animate", {
attributeName: "height",
from: "0",
to: height,
dur: "0.52s",
begin: `${delayMs}ms`,
fill: "freeze",
calcMode: "spline",
keyTimes: "0;1",
keySplines: "0.2 0.8 0.2 1"
})
);
}
function prefersReducedMotion() {
return typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function createConsumptionPointAriaLabel(point, period) {
return `${formatConsumptionPointLabel(point, period)}. `
+ `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}. `
+ `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}. `
+ `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}.`;
}
function showConsumptionTooltip(tooltip, point, period, event) {
const figure = tooltip.parentElement;
if (!figure) {
return;
}
tooltip.textContent = "";
const title = document.createElement("strong");
title.textContent = formatConsumptionPointLabel(point, period);
const details = document.createElement("span");
details.textContent = `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}`;
const overage = document.createElement("span");
overage.textContent = `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}`;
const total = document.createElement("span");
total.textContent = `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}`;
tooltip.append(title, details, overage, total);
tooltip.hidden = false;
const figureBounds = figure.getBoundingClientRect();
const targetBounds = event.currentTarget.getBoundingClientRect();
const pointerX = Number.isFinite(event.clientX) && event.clientX > 0
? event.clientX
: targetBounds.left + (targetBounds.width / 2);
const pointerY = Number.isFinite(event.clientY) && event.clientY > 0
? event.clientY
: targetBounds.top;
const left = Math.min(
Math.max(8, pointerX - figureBounds.left + 12),
Math.max(8, figureBounds.width - tooltip.offsetWidth - 8)
);
const top = Math.max(8, pointerY - figureBounds.top - tooltip.offsetHeight - 10);
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
}
function createSvgElement(name, attributes = {}) {
const element = document.createElementNS(SVG_NAMESPACE, name);
Object.entries(attributes).forEach(([key, value]) => {
element.setAttribute(key, String(value));
});
return element;
}
function createSvgText(x, y, value, className, anchor) {
const text = createSvgElement("text", { x, y, class: className, "text-anchor": anchor });
text.textContent = value;
return text;
}
return { renderConsumptionTrendChart, getConsumptionTrendSeriesFilter, createConsumptionLegend, createConsumptionNavigationButton, createConsumptionSvg, appendConsumptionBarAnimation, prefersReducedMotion, createConsumptionPointAriaLabel, showConsumptionTooltip, createSvgElement, createSvgText };
}

View File

@@ -0,0 +1,54 @@
function createConsumptionHeaderView({ createCloseIcon, formatOracleResponseDate, environment }) {
const { document } = environment;
function createHeader({ titleId, customerLabel, closeLabel, onClose, titleText = "Cloud Consumption" }) {
const header = document.createElement("header");
header.className = "opportunities-extension-account-plans-header";
const heading = document.createElement("div");
const title = document.createElement("h2");
title.id = titleId;
title.textContent = titleText;
const subtitle = document.createElement("p");
subtitle.textContent = customerLabel;
heading.append(title, subtitle);
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", closeLabel);
closeButton.title = "Close";
closeButton.append(createCloseIcon());
closeButton.addEventListener("click", onClose);
header.append(heading, closeButton);
return header;
}
function createDetails(plan, { contractAction = null } = {}) {
const details = document.createElement("dl");
details.className = "opportunities-extension-account-plan-details";
const fields = [
{ label: "Booking Customer", value: plan.bookingCustomer },
{ label: "Sold To Party Name", value: plan.soldToPartyName },
{ label: "Contract ID", value: plan.contractId },
{ label: "Header Status", value: plan.hdrStatusCode },
{ label: "Start Date", value: formatOracleResponseDate(plan.startDate) },
{ label: "End Date", value: formatOracleResponseDate(plan.endDate) }
];
fields.forEach(field => {
const group = document.createElement("div");
const term = document.createElement("dt");
const description = document.createElement("dd");
term.textContent = field.label;
description.textContent = field.value === null || field.value === undefined || field.value === "" ? "-" : String(field.value);
if (field.label === "Contract ID" && contractAction) {
group.className = "opportunities-extension-contract-row";
const value = document.createElement("span");
value.textContent = description.textContent;
description.textContent = "";
description.className = "opportunities-extension-contract-ratecard";
description.append(value, contractAction);
}
group.append(term, description);
details.append(group);
});
return details;
}
return { createHeader, createDetails };
}

View File

@@ -0,0 +1,155 @@
function createConsumptionModel() {
function normalizeConsumptionTrendItems(items) {
const points = new Map();
(Array.isArray(items) ? items : []).forEach((item) => {
const dateKey = String(item.fyDate || (Array.isArray(item.fiscalTime) ? item.fiscalTime.join("|") : ""));
if (!dateKey) {
return;
}
if (!points.has(dateKey)) {
points.set(dateKey, {
dateKey,
date: parseConsumptionDate(item.fyDate),
fiscalTime: Array.isArray(item.fiscalTime) ? item.fiscalTime.slice() : [],
fiscalQtr: item.fiscalQtr || "",
funded: 0,
overage: 0,
arr: 0,
currencyCode: item.currencyCode || "USD"
});
}
const point = points.get(dateKey);
const amount = toFiniteNumber(item.usedAmount);
const category = String(item.usageCategory || "Funded Allocation").toUpperCase();
if (category === "OVERAGE") {
point.overage += amount;
} else {
point.funded += amount;
}
point.arr = Math.max(point.arr, toFiniteNumber(item.arrCd));
});
return Array.from(points.values())
.map((point) => ({ ...point, total: point.funded + point.overage }))
.sort((left, right) => left.dateKey.localeCompare(right.dateKey));
}
function getConsumptionPointTotal(point, activeSeries) {
const selectedSeries = activeSeries && typeof activeSeries.has === "function"
? activeSeries
: new Set(["funded", "overage"]);
return (selectedSeries.has("funded") ? point.funded : 0)
+ (selectedSeries.has("overage") ? point.overage : 0);
}
function calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries) {
const completePoints = hasPartialLatestPoint && points.length > 1 ? points.slice(0, -1) : points;
return completePoints.reduce(
(sum, point) => sum + getConsumptionPointTotal(point, activeSeries),
0
) / Math.max(completePoints.length, 1);
}
function createNiceConsumptionMaximum(value) {
if (!Number.isFinite(value) || value <= 0) {
return 1;
}
const magnitude = 10 ** Math.floor(Math.log10(value));
const normalized = value / magnitude;
const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return step * magnitude;
}
function formatConsumptionPointLabel(point, period) {
if (period === "quarterly") {
return point.fiscalTime[0] || point.fiscalQtr || point.dateKey;
}
if (period === "monthly") {
return point.fiscalTime[1] || formatConsumptionDate(point.date, { month: "short" });
}
return point.fiscalTime[0] || formatConsumptionDate(point.date, {
day: "2-digit",
month: "short",
year: "2-digit"
});
}
function formatConsumptionRange(points, period) {
if (points.length === 0) {
return "No data";
}
if (period === "quarterly") {
return `${formatConsumptionPointLabel(points[0], period)} - ${formatConsumptionPointLabel(points[points.length - 1], period)}`;
}
if (period === "monthly") {
const options = { month: "short", year: "numeric" };
return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`;
}
const options = { day: "2-digit", month: "short" };
return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`;
}
function parseConsumptionDate(value) {
if (!value) {
return null;
}
const parts = String(value).split("-").map(Number);
return parts.length === 3 && parts.every(Number.isFinite)
? new Date(parts[0], parts[1] - 1, parts[2])
: null;
}
function formatConsumptionDate(date, options) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return "-";
}
return new Intl.DateTimeFormat("en-US", options).format(date).replace(",", "");
}
function formatCompactConsumptionNumber(value) {
const amount = toFiniteNumber(value);
if (Math.abs(amount) >= 1000000) {
return `${(amount / 1000000).toFixed(amount >= 10000000 ? 0 : 1).replace(".0", "")}M`;
}
if (Math.abs(amount) >= 1000) {
return `${Math.round(amount / 1000)}K`;
}
return String(Math.round(amount));
}
function formatConsumptionCurrency(value, currencyCode) {
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currencyCode || "USD",
maximumFractionDigits: 0
}).format(toFiniteNumber(value));
} catch (error) {
return `${currencyCode || "USD"} ${Math.round(toFiniteNumber(value)).toLocaleString("en-US")}`;
}
}
function toFiniteNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
return { normalizeConsumptionTrendItems, getConsumptionPointTotal, calculateAverageConsumption, createNiceConsumptionMaximum, formatConsumptionPointLabel, formatConsumptionRange, parseConsumptionDate, formatConsumptionDate, formatCompactConsumptionNumber, formatConsumptionCurrency, toFiniteNumber };
}

View File

@@ -0,0 +1,66 @@
function createConsumptionPlanDetailController({ messageTypes, requestConsumerTokenServiceOnce, sendRuntimeMessage,
appendBackgroundRequestLog, renderPlanDetailBadges, environment }) {
const { document } = environment;
const states = new WeakMap();
const pending = new Map();
function createPlanDetailSection(plan) {
const section = document.createElement("div");
section.className = "opportunities-extension-plan-detail-badges";
section.dataset.subPlanNum = String(plan.subPlanNum || "").trim();
section.setAttribute("aria-live", "polite");
states.set(section, { status: "idle" });
return section;
}
async function request(subPlanNum) {
const authorizations = await requestConsumerTokenServiceOnce();
const response = await sendRuntimeMessage({ type: messageTypes.requestConsumptionPlanDetail, subPlanNum,
authorization: authorizations && authorizations.accountPlans });
appendBackgroundRequestLog(response?.debugRequests);
if (!response?.ok) throw new Error(response?.error || "Unable to load consumption plan details.");
return response.items;
}
function loadPlanDetailForPanel(panel, plan) {
const section = panel?.querySelector(".opportunities-extension-plan-detail-badges");
if (!section) return Promise.resolve();
const state = states.get(section);
if (state?.status === "ready") return Promise.resolve();
if (state?.status === "loading") return state.promise;
const subPlanNum = String(plan.subPlanNum || "").trim();
section.textContent = "";
section.setAttribute("aria-busy", "true");
const skeleton = document.createElement("span");
skeleton.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-plan-detail-skeleton";
skeleton.setAttribute("role", "status");
skeleton.setAttribute("aria-label", "Loading plan projection");
section.append(skeleton);
const current = { status: "loading", promise: null };
states.set(section, current);
current.promise = (async () => {
try {
if (!subPlanNum) throw new Error("Sub plan number is unavailable.");
if (!pending.has(subPlanNum)) {
const promise = request(subPlanNum).finally(() => { if (pending.get(subPlanNum) === promise) pending.delete(subPlanNum); });
pending.set(subPlanNum, promise);
}
const items = await pending.get(subPlanNum);
if (!section.isConnected || states.get(section) !== current) return;
current.status = "ready";
renderPlanDetailBadges(section, items);
} catch (error) {
if (!section.isConnected || states.get(section) !== current) return;
current.status = "error";
section.textContent = "";
const notice = document.createElement("span");
notice.className = "opportunities-extension-plan-detail-error";
notice.setAttribute("role", "alert");
notice.textContent = error.message || "Unable to load plan details.";
section.append(notice);
} finally {
if (states.get(section) === current) section.setAttribute("aria-busy", "false");
}
})();
return current.promise;
}
return { createPlanDetailSection, loadPlanDetailForPanel, resetRequests: () => pending.clear() };
}

View File

@@ -0,0 +1,160 @@
function createConsumptionPlanDetailView({ createCloseIcon, environment }) {
const { document, window, setTimeout, clearTimeout } = environment;
let active = null;
let sequence = 0;
const fields = [
["pricingModel", "Pricing model"], ["lineStatus", "Line status"],
["startDate", "Start date"], ["endDate", "End date"],
["committedCredits", "Committed credits", true], ["consumed", "Consumed", true],
["consumptionL30Days", "Consumption last 30 days", true], ["monthsRemaining", "Months remaining"],
["projectedConsumption", "Projected consumption", true], ["projectedDelta", "Projected delta", true],
["orderId", "Order number"]
];
function node(tag, className, text) {
const result = document.createElement(tag);
if (className) result.className = className;
if (text !== undefined) result.textContent = text;
return result;
}
function statusBadge(record, tag = "span") {
const text = String(record.projectedStatus || "Unknown");
const badge = node(tag, "opportunities-extension-projection-badge", text);
badge.dataset.status = text.trim().toLowerCase();
return badge;
}
function orderLink(record, text) {
try {
const url = new URL(record.orderTrackerURL);
if (url.origin !== "https://accountdashboard.oracle.com" || url.username || url.password) return null;
const link = node("a", "", text);
link.href = url.href;
link.target = "_blank";
link.rel = "noopener noreferrer";
return link;
} catch { return null; }
}
function createLineDetails(record) {
const section = node("section", "opportunities-extension-projection-line");
const heading = node("h3");
heading.append(statusBadge(record));
section.append(heading);
const details = node("dl", "opportunities-extension-projection-fields");
fields.forEach(([key, label, numeric]) => {
const raw = record[key];
let text = raw === null || raw === undefined || raw === "" ? "-" : String(raw);
if (numeric && text !== "-" && Number.isFinite(Number(raw))) {
text = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(raw));
}
const group = node("div");
const value = node("dd");
const link = key === "orderId" && text !== "-" ? orderLink(record, text) : null;
if (link) value.append(link);
else value.textContent = text;
group.append(node("dt", "", label), value);
details.append(group);
});
section.append(details);
if (Array.isArray(record.clines) && record.clines.length) {
const children = node("div", "opportunities-extension-projection-children");
record.clines.forEach(child => children.append(createLineDetails(child)));
section.append(children);
}
return section;
}
function closePlanDetailPopover(restoreFocus = false) {
if (!active) return;
const previous = active;
active = null;
clearTimeout(previous.timer);
previous.popover.hidden = true;
previous.button.setAttribute("aria-expanded", "false");
document.removeEventListener("pointerdown", previous.outside, true);
window.removeEventListener("resize", previous.resize);
if (restoreFocus && previous.button.isConnected) {
previous.suppressFocus = true;
previous.button.focus();
previous.suppressFocus = false;
}
}
function positionPopover(entry) {
const rect = entry.button.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const width = Math.min(560, viewportWidth - 24);
const height = Math.min(520, viewportHeight - 24);
entry.popover.style.width = `${width}px`;
entry.popover.style.maxHeight = `${height}px`;
const below = viewportHeight - rect.bottom - 12;
const actualHeight = Math.min(entry.popover.getBoundingClientRect().height, height);
entry.popover.style.left = `${Math.max(12, Math.min(rect.left, viewportWidth - width - 12))}px`;
entry.popover.style.top = `${Math.max(12, below >= actualHeight ? rect.bottom + 4 : Math.min(rect.top - actualHeight - 4, viewportHeight - actualHeight - 12))}px`;
}
function createInteractiveBadge(record) {
const wrapper = node("div", "opportunities-extension-projection-wrapper");
const button = statusBadge(record, "button");
button.type = "button";
button.setAttribute("aria-label", `Projected status: ${button.textContent}. View plan details`);
button.setAttribute("aria-haspopup", "dialog");
button.setAttribute("aria-expanded", "false");
const popover = node("div", "opportunities-extension-projection-popover");
popover.id = `opportunities-extension-projection-${++sequence}`;
popover.hidden = true;
popover.setAttribute("role", "dialog");
popover.setAttribute("aria-label", "Consumption plan details");
popover.tabIndex = -1;
button.setAttribute("aria-controls", popover.id);
const dismiss = node("button", "opportunities-extension-projection-dismiss");
dismiss.type = "button";
dismiss.setAttribute("aria-label", "Close plan details");
dismiss.title = "Close plan details";
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => closePlanDetailPopover(true));
popover.append(dismiss, createLineDetails(record));
const entry = { button, popover, wrapper, pinned: false, timer: null, suppressFocus: false,
outside: event => { if (!wrapper.contains(event.target)) closePlanDetailPopover(); },
resize: () => positionPopover(entry) };
function open() {
if (active !== entry) {
closePlanDetailPopover();
entry.pinned = false;
active = entry;
document.addEventListener("pointerdown", entry.outside, true);
window.addEventListener("resize", entry.resize);
}
clearTimeout(entry.timer);
popover.hidden = false;
button.setAttribute("aria-expanded", "true");
positionPopover(entry);
}
wrapper.addEventListener("pointerenter", open);
wrapper.addEventListener("pointerleave", () => {
if (entry.pinned || wrapper.contains(document.activeElement)) return;
entry.timer = setTimeout(() => { if (active === entry) closePlanDetailPopover(); }, 180);
});
wrapper.addEventListener("focusin", () => { if (!entry.suppressFocus) open(); });
wrapper.addEventListener("focusout", event => { if (!wrapper.contains(event.relatedTarget) && active === entry) closePlanDetailPopover(); });
button.addEventListener("click", () => {
if (active === entry && entry.pinned) closePlanDetailPopover();
else { open(); entry.pinned = true; }
});
wrapper.addEventListener("keydown", event => {
if (event.key === "Escape" && active === entry) {
event.preventDefault();
event.stopPropagation();
closePlanDetailPopover(true);
}
});
wrapper.append(button, popover);
return wrapper;
}
function renderPlanDetailBadges(section, items) {
if (active && section.contains(active.button)) closePlanDetailPopover();
section.textContent = "";
if (!items.length) {
section.append(node("span", "opportunities-extension-projection-empty", "No projection data"));
return;
}
items.forEach(record => section.append(createInteractiveBadge(record)));
}
return { renderPlanDetailBadges, createLineDetails, closePlanDetailPopover };
}

View File

@@ -0,0 +1,79 @@
/* Scope sizing to Cloud Consumption, not other dialogs sharing its components. */
[data-consumption-layout="compact"] .opportunities-extension-account-plans-header { padding: 10px 12px; gap: 12px; }
[data-consumption-layout="compact"] .opportunities-extension-account-plans-header h2 { font-size: 16px; line-height: 22px; }
[data-consumption-layout="compact"] .opportunities-extension-account-plans-header p { font-size: 12px; line-height: 16px; margin-top: 3px; overflow-wrap: anywhere; }
[data-consumption-layout="compact"] .opportunities-extension-icon-button { width: 28px; height: 28px; min-width: 28px; min-height: 28px; padding: 4px; }
[data-consumption-layout="compact"] .opportunities-extension-account-plans-tabs { padding: 4px 12px 0; }
[data-consumption-layout="compact"] .opportunities-extension-account-plan-tab { min-height: 30px; padding: 4px 8px; font-size: 12px; }
[data-consumption-layout="compact"] .opportunities-extension-account-plan-panel { padding: 8px 12px; }
[data-consumption-layout="compact"] .opportunities-extension-account-plan-details { gap: 0 12px; }
[data-consumption-layout="compact"] .opportunities-extension-account-plan-details > div { grid-template-columns: minmax(85px, 35%) minmax(0, 1fr); min-height: 32px; padding: 4px 0; gap: 6px; }
[data-consumption-layout="compact"] .opportunities-extension-account-plan-details dt { font-size: 11px; line-height: 16px; }
[data-consumption-layout="compact"] .opportunities-extension-account-plan-details dd { font-size: 12px; line-height: 16px; }
[data-consumption-layout="compact"] .opportunities-extension-consumption-trend { margin-top: 8px; padding-top: 6px; }
[data-consumption-layout="compact"] .opportunities-extension-consumption-periods button { min-height: 28px; padding: 4px 8px; font-size: 12px; }
[data-consumption-layout="compact"] .opportunities-extension-consumption-navigation { grid-template-columns: 28px minmax(120px, auto) 28px; min-height: 30px; }
[data-consumption-layout="compact"] .opportunities-extension-consumption-navigation strong { font-size: 12px; }
[data-consumption-layout="compact"] .opportunities-extension-consumption-navigation-button { width: 28px; height: 28px; }
[data-consumption-layout="compact"] .opportunities-extension-consumption-visual { grid-template-columns: minmax(0, 1fr) 150px; gap: 8px; }
[data-consumption-layout="compact"] .opportunities-extension-consumption-svg { height: 100%; max-height: 100%; min-width: 0; }
[data-consumption-layout="compact"] .opportunities-extension-consumption-legend button { font-size: 11px; padding: 4px; gap: 5px; }
/* Small viewports must scroll rather than clip details or collapse the chart. */
@media (max-height: 560px), (max-width: 700px) {
[data-consumption-layout="compact"] .opportunities-extension-account-plans-panels { overflow: auto; }
[data-consumption-layout="compact"] .opportunities-extension-account-plan-panel { height: auto; min-height: 100%; grid-template-rows: auto minmax(280px, 1fr); }
}
@media (max-width: 700px) {
[data-consumption-layout="compact"] .opportunities-extension-account-plan-details { grid-template-columns: minmax(0, 1fr); }
[data-consumption-layout="compact"] .opportunities-extension-consumption-visual { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; }
[data-consumption-layout="compact"] .opportunities-extension-consumption-legend { display: flex; flex-wrap: wrap; }
}
.opportunities-extension-plan-detail-badges { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.opportunities-extension-consumption-trend-header { align-items: center; justify-content: space-between; gap: 12px; }
.opportunities-extension-consumption-trend-header > .opportunities-extension-plan-detail-badges { min-width: 0; }
.opportunities-extension-plan-detail-skeleton { width: 64px; height: 20px; }
.opportunities-extension-projection-badge {
display: inline-flex; align-items: center; justify-content: center; border: 1px solid transparent;
padding: 3px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; line-height: 1.3;
color: #5f5a55; background: #eceae8; white-space: nowrap;
}
button.opportunities-extension-projection-badge { cursor: pointer; }
.opportunities-extension-projection-badge[data-status="forfeit"] { color: #a12424; background: #fbe5e5; }
.opportunities-extension-projection-badge[data-status="overage"] { color: #185a9d; background: #e0edfc; }
.opportunities-extension-projection-badge:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-projection-popover {
position: fixed; z-index: 8; box-sizing: border-box; padding: 14px; overflow: auto; overscroll-behavior: contain;
border: 1px solid #b9b4ae; border-radius: 4px; background: #ffffff; color: #312d2a;
box-shadow: 0 4px 16px #0003; font-size: 12px; line-height: 1.4; overflow-wrap: anywhere;
}
.opportunities-extension-projection-popover[hidden] { display: none; }
.opportunities-extension-projection-popover .opportunities-extension-projection-dismiss {
display: grid; place-items: center; width: 24px; height: 24px; margin-left: auto; margin-bottom: 8px; padding: 4px; border: 1px solid #b9b4ae;
border-radius: 4px; background: transparent; color: inherit; cursor: pointer; font-size: 12px;
}
.opportunities-extension-projection-dismiss svg { width: 14px; height: 14px; }
.opportunities-extension-projection-line h3 { margin: 0 0 8px; font-size: 12px; }
.opportunities-extension-projection-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 16px; margin: 0; }
.opportunities-extension-projection-fields > div { min-width: 0; }
.opportunities-extension-projection-fields dt { font-weight: 600; color: #5f5a55; font-size: 11px; }
.opportunities-extension-projection-fields dd { margin: 2px 0 0; font-variant-numeric: tabular-nums; }
.opportunities-extension-projection-fields a { color: #006d7a; text-decoration: underline; }
.opportunities-extension-projection-children { margin-top: 14px; padding-left: 12px; border-left: 2px solid #dedbd7; }
.opportunities-extension-projection-children > section + section { margin-top: 16px; }
.opportunities-extension-plan-detail-error { color: #a12424; font-size: 12px; }
.opportunities-extension-projection-empty { color: #5f5a55; font-size: 12px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-popover { color: #f1efec; background: #302e2b; border-color: #716b64; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-badge { color: #dedbd7; background: #484541; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-badge[data-status="forfeit"] { color: #ffb9b9; background: #682e32; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-badge[data-status="overage"] { color: #c5dfff; background: #224b76; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-fields dt,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-empty { color: #cec9c2; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-fields a { color: #76dbea; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-plan-detail-error { color: #ffb9b9; }
@media (max-width: 480px) { .opportunities-extension-projection-fields { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 480px) {
.opportunities-extension-consumption-trend-header > .opportunities-extension-consumption-periods { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (prefers-reduced-motion: reduce) { .opportunities-extension-plan-detail-skeleton { animation: none; } }

View File

@@ -0,0 +1,96 @@
function createConsumptionPlansAction({
messageTypes,
appendBackgroundRequestLog,
consumptionPlansState,
getNestedValue,
openAccountPlansModal,
renderOpportunitiesTable,
requestConsumerTokenServiceOnce,
sendRuntimeMessage,
showCopyConfirmation,
environment
}) {
const { document } = environment;
function createCloudConsumptionButton(opportunity) {
const button = document.createElement("button");
const optyNumber = String(opportunity.OptyNumber || "");
const partyNumber = getNestedValue(opportunity, ["CustomerAccount", "PartyNumber"]);
const requestState = consumptionPlansState.accountPlansRequestState.get(optyNumber);
const isLoading = requestState && requestState.status === "loading";
button.type = "button";
button.className = "opportunities-extension-action-button";
button.setAttribute("aria-label", `View cloud consumption for ${optyNumber || "opportunity"}`);
button.title = isLoading ? "Loading customer consumption" : "View customer consumption";
button.disabled = Boolean(isLoading);
button.classList.toggle("opportunities-extension-action-button-loading", Boolean(isLoading));
button.append(createCloudConsumptionIcon());
button.addEventListener("click", () => {
if (!partyNumber) {
showCopyConfirmation("Customer Party Number is unavailable.", true);
return;
}
const currentRequests = consumptionPlansState.accountPlansRequestState;
currentRequests.set(optyNumber, { status: "loading" });
openAccountPlansModal(opportunity, [], "loading");
renderOpportunitiesTable();
requestConsumerTokenServiceOnce()
.then((authorizations) => sendRuntimeMessage({
type: messageTypes.requestAccountPlans,
partyNumber: String(partyNumber),
authorizations
}))
.then((response) => {
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
appendBackgroundRequestLog(response && response.debugRequests);
if (!response || !response.ok) {
throw new Error(response && response.error ? response.error : "Unable to load cloud consumption.");
}
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
status: "ready",
plans: Array.isArray(response.plans) ? response.plans : []
});
renderOpportunitiesTable();
openAccountPlansModal(opportunity, response.plans || [], "ready");
}).catch((error) => {
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
const errorMessage = error.message || "Unable to load cloud consumption.";
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
status: "error",
error: errorMessage
});
renderOpportunitiesTable();
openAccountPlansModal(opportunity, [], "error", errorMessage);
showCopyConfirmation(errorMessage, true);
});
});
return button;
}
function createCloudConsumptionIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const cloud = document.createElementNS("http://www.w3.org/2000/svg", "path");
const usage = document.createElementNS("http://www.w3.org/2000/svg", "path");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
cloud.setAttribute("d", "M7.2 18.5h10.1a4.2 4.2 0 0 0 .5-8.37A6.25 6.25 0 0 0 5.9 8.75a4.9 4.9 0 0 0 1.3 9.75Z");
usage.setAttribute("d", "M9 15v-2m3 2v-4m3 4V9");
[cloud, usage].forEach((path) => {
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
path.setAttribute("stroke-width", "1.7");
});
svg.append(cloud, usage);
return svg;
}
return { createCloudConsumptionButton, createCloudConsumptionIcon };
}

View File

@@ -0,0 +1,5 @@
function createConsumptionPlansState() {
return {
accountPlansRequestState: new Map()
};
}

View File

@@ -0,0 +1,338 @@
function createConsumptionPlansView({
ACCOUNT_PLANS_MODAL_ID,
DEFAULT_CONSUMPTION_TREND_PERIOD,
MODAL_ID,
createConsumptionHeader,
createConsumptionTrendSection,
loadPlanDetailForPanel,
closePlanDetailPopover,
createRatecardButton,
closeRatecardDrawer,
createSvgElement,
createPlanDetails,
getNestedValue,
loadConsumptionTrendForPanel,
environment
}) {
const { document, requestAnimationFrame, setTimeout, window } = environment;
function openAccountPlansModal(opportunity, plans, state = "ready", errorMessage = "") {
const overlay = document.getElementById(MODAL_ID);
const optyNumber = String(opportunity.OptyNumber || "");
const existingModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
if (!overlay) {
return;
}
if (state !== "loading" && (
!existingModal
|| existingModal.hidden
|| existingModal.dataset.optyNumber !== optyNumber
)) {
return;
}
closePlanDetailPopover();
closeRatecardDrawer({ immediate: true, restoreFocus: false });
existingModal?.remove();
const modal = document.createElement("section");
modal.id = ACCOUNT_PLANS_MODAL_ID;
modal.className = "opportunities-extension-account-plans-modal";
modal.dataset.optyNumber = optyNumber;
modal.dataset.customerLabel = getNestedValue(opportunity, ["CustomerAccount", "PartyUniqueName"]) || opportunity.Name || opportunity.OptyNumber || "Opportunity";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-account-plans-title");
modal.setAttribute("aria-busy", String(state === "loading"));
const dialog = document.createElement("div");
dialog.className = "opportunities-extension-account-plans-dialog";
dialog.setAttribute("data-consumption-layout", "compact");
const header = createConsumptionHeader({
titleId: "opportunities-extension-account-plans-title",
customerLabel: modal.dataset.customerLabel,
closeLabel: "Close cloud consumption",
onClose: closeAccountPlansModal
});
const content = document.createElement("div");
content.className = "opportunities-extension-account-plans-content";
if (state === "loading") {
content.append(createAccountPlansSkeleton());
} else if (state === "error") {
const error = document.createElement("p");
error.className = "opportunities-extension-account-plans-error";
error.setAttribute("role", "alert");
error.textContent = errorMessage || "Unable to load cloud consumption.";
content.append(error);
} else if (!Array.isArray(plans) || plans.length === 0) {
const empty = document.createElement("p");
empty.className = "opportunities-extension-account-plans-empty";
empty.textContent = "No account plans found for this customer.";
content.append(empty);
} else {
const tabList = document.createElement("div");
tabList.className = "opportunities-extension-account-plans-tabs";
tabList.setAttribute("role", "tablist");
tabList.setAttribute("aria-label", "Account plans");
const tabsNavigation = createAccountPlansTabsNavigation(tabList);
const panels = document.createElement("div");
panels.className = "opportunities-extension-account-plans-panels";
plans.forEach((plan, index) => {
const tabId = `opportunities-extension-account-plan-tab-${index}`;
const panelId = `opportunities-extension-account-plan-panel-${index}`;
const tab = document.createElement("button");
tab.id = tabId;
tab.type = "button";
tab.className = "opportunities-extension-account-plan-tab";
tab.setAttribute("role", "tab");
tab.setAttribute("aria-controls", panelId);
tab.setAttribute("aria-selected", String(index === 0));
tab.tabIndex = index === 0 ? 0 : -1;
const planLabel = [plan.planType, plan.subPlanNum]
.filter((value) => value !== null && value !== undefined && value !== "")
.join(" ") || `Plan ${index + 1}`;
const headerStatus = plan.hdrStatusCode === null || plan.hdrStatusCode === undefined || plan.hdrStatusCode === ""
? "Unknown"
: String(plan.hdrStatusCode);
tab.setAttribute("aria-label", `${planLabel}. Header Status: ${headerStatus}`);
tab.append(createAccountPlanStatusIndicator(headerStatus), document.createTextNode(planLabel));
const panel = document.createElement("section");
panel.id = panelId;
panel.className = "opportunities-extension-account-plan-panel";
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", tabId);
panel.hidden = index !== 0;
panel.append(createAccountPlanDetails(plan), createConsumptionTrendSection(plan));
tab.addEventListener("click", () => {
closePlanDetailPopover();
closeRatecardDrawer({ immediate: true, restoreFocus: false });
tabList.querySelectorAll("[role='tab']").forEach((candidate) => {
const isSelected = candidate === tab;
candidate.setAttribute("aria-selected", String(isSelected));
candidate.tabIndex = isSelected ? 0 : -1;
});
panels.querySelectorAll("[role='tabpanel']").forEach((candidate) => {
candidate.hidden = candidate !== panel;
});
const trendSection = panel.querySelector(".opportunities-extension-consumption-trend");
loadConsumptionTrendForPanel(
panel,
plan,
trendSection ? trendSection.dataset.period : DEFAULT_CONSUMPTION_TREND_PERIOD
);
loadPlanDetailForPanel(panel, plan);
});
tabList.append(tab);
panels.append(panel);
});
content.append(tabsNavigation, panels);
}
dialog.append(header, content);
modal.append(dialog);
overlay.append(modal);
modal.querySelector("[role='tab'], .opportunities-extension-icon-button")?.focus();
if (state === "ready" && Array.isArray(plans) && plans.length > 0) {
const firstPanel = modal.querySelector("[role='tabpanel']");
if (firstPanel) {
loadConsumptionTrendForPanel(firstPanel, plans[0], DEFAULT_CONSUMPTION_TREND_PERIOD);
loadPlanDetailForPanel(firstPanel, plans[0]);
}
}
}
function createAccountPlansTabsNavigation(tabList) {
const navigation = document.createElement("div");
navigation.className = "opportunities-extension-account-plans-tabs-navigation";
const previousButton = createAccountPlansTabNavigationButton("previous");
const nextButton = createAccountPlansTabNavigationButton("next");
const updateNavigation = () => {
const maximumScroll = Math.max(0, tabList.scrollWidth - tabList.clientWidth);
const tabs = Array.from(tabList.querySelectorAll("[role='tab']"));
const viewportStart = tabList.scrollLeft;
const viewportEnd = viewportStart + tabList.clientWidth;
const hasContentBefore = tabList.scrollLeft > 1
|| tabs.some((tab) => tab.offsetLeft < viewportStart - 1);
const hasContentAfter = tabList.scrollLeft < maximumScroll - 1
|| tabs.some((tab) => (tab.offsetLeft + tab.offsetWidth) > viewportEnd + 1);
previousButton.hidden = !hasContentBefore;
nextButton.hidden = !hasContentAfter;
};
const scrollTabs = (direction) => {
tabList.scrollBy({
left: direction * Math.max(160, Math.floor(tabList.clientWidth * .72)),
behavior: "smooth"
});
};
previousButton.addEventListener("click", () => scrollTabs(-1));
nextButton.addEventListener("click", () => scrollTabs(1));
tabList.addEventListener("scroll", updateNavigation, { passive: true });
const handleResize = () => {
if (!navigation.isConnected) {
window.removeEventListener("resize", handleResize);
return;
}
updateNavigation();
};
window.addEventListener("resize", handleResize, { passive: true });
requestAnimationFrame(() => requestAnimationFrame(updateNavigation));
setTimeout(updateNavigation, 0);
navigation.append(previousButton, tabList, nextButton);
return navigation;
}
function createAccountPlansTabNavigationButton(direction) {
const isPrevious = direction === "previous";
const button = document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-account-plans-tab-navigation-button";
button.setAttribute("aria-label", isPrevious ? "Show previous account plans" : "Show next account plans");
button.title = isPrevious ? "Previous plans" : "Next plans";
const icon = createSvgElement("svg", {
viewBox: "0 0 24 24",
"aria-hidden": "true",
focusable: "false"
});
icon.append(createSvgElement("path", {
d: isPrevious ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7",
fill: "none",
stroke: "currentColor",
"stroke-linecap": "round",
"stroke-linejoin": "round",
"stroke-width": "2"
}));
button.append(icon);
return button;
}
function createAccountPlansSkeleton() {
const skeleton = document.createElement("div");
skeleton.className = "opportunities-extension-account-plans-skeleton";
skeleton.setAttribute("role", "status");
const announcement = document.createElement("span");
announcement.className = "opportunities-extension-visually-hidden";
announcement.textContent = "Loading cloud consumption";
const tabs = document.createElement("div");
tabs.className = "opportunities-extension-account-plans-skeleton-tabs";
const tab = document.createElement("span");
tab.className = "opportunities-extension-account-plans-skeleton-tab";
const status = document.createElement("span");
status.className = "opportunities-extension-account-plans-skeleton-status";
const tabLabel = document.createElement("span");
tabLabel.className = "opportunities-extension-account-plans-skeleton-bar";
tabLabel.style.width = "104px";
tab.append(status, tabLabel);
tabs.append(tab);
const details = document.createElement("div");
details.className = "opportunities-extension-account-plans-skeleton-details";
[72, 88, 64, 78, 58, 68].forEach((width) => {
const row = document.createElement("div");
const label = document.createElement("span");
const value = document.createElement("span");
label.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-account-plans-skeleton-label";
value.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-account-plans-skeleton-value";
value.style.width = `${width}%`;
row.append(label, value);
details.append(row);
});
const trend = document.createElement("div");
trend.className = "opportunities-extension-account-plans-skeleton-trend";
const trendHeader = document.createElement("div");
trendHeader.className = "opportunities-extension-account-plans-skeleton-trend-header";
const periods = document.createElement("div");
periods.className = "opportunities-extension-account-plans-skeleton-periods";
[58, 52, 56, 38].forEach((width, index) => {
const period = document.createElement("span");
period.className = "opportunities-extension-account-plans-skeleton-bar";
period.style.width = `${width}px`;
if (index === 2) period.classList.add("opportunities-extension-account-plans-skeleton-period-active");
periods.append(period);
});
trendHeader.append(periods);
const navigation = document.createElement("div");
navigation.className = "opportunities-extension-account-plans-skeleton-navigation";
[32, 124, 32].forEach((width) => {
const item = document.createElement("span");
item.className = "opportunities-extension-account-plans-skeleton-bar";
item.style.width = `${width}px`;
navigation.append(item);
});
const chart = document.createElement("div");
chart.className = "opportunities-extension-account-plans-skeleton-chart";
const plot = document.createElement("div");
plot.className = "opportunities-extension-account-plans-skeleton-plot";
[34, 42, 38, 48, 44, 58, 64, 55, 76, 68, 86, 62].forEach((height) => {
const bar = document.createElement("span");
bar.className = "opportunities-extension-account-plans-skeleton-bar";
bar.style.height = `${height}%`;
plot.append(bar);
});
const legend = document.createElement("div");
legend.className = "opportunities-extension-account-plans-skeleton-legend";
[112, 68, 132].forEach((width) => {
const item = document.createElement("span");
item.className = "opportunities-extension-account-plans-skeleton-bar";
item.style.width = `${width}px`;
legend.append(item);
});
chart.append(plot, legend);
trend.append(trendHeader, navigation, chart);
const panel = document.createElement("div");
panel.className = "opportunities-extension-account-plan-panel opportunities-extension-account-plans-skeleton-panel";
panel.append(details, trend);
skeleton.append(announcement, tabs, panel);
return skeleton;
}
function createAccountPlanStatusIndicator(statusValue) {
const indicator = document.createElement("span");
const isActive = String(statusValue).toUpperCase() === "ACTIVE";
indicator.className = "opportunities-extension-account-plan-status-indicator";
indicator.dataset.active = String(isActive);
indicator.setAttribute("aria-hidden", "true");
indicator.title = `Header Status: ${statusValue}`;
return indicator;
}
function createAccountPlanDetails(plan) {
return createPlanDetails(plan, { contractAction: createRatecardButton(plan) });
}
function closeAccountPlansModal() {
closePlanDetailPopover();
closeRatecardDrawer({ immediate: true, restoreFocus: false });
const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
if (modal) {
modal.hidden = true;
}
}
return { openAccountPlansModal, createAccountPlansTabsNavigation, createAccountPlansTabNavigationButton, createAccountPlansSkeleton, createAccountPlanStatusIndicator, createAccountPlanDetails, closeAccountPlansModal };
}

View File

@@ -0,0 +1,172 @@
function createConsumptionTrendController({
messageTypes,
CONSUMPTION_TREND_PERIODS,
DEFAULT_CONSUMPTION_TREND_PERIOD,
appendBackgroundRequestLog,
consumptionTrendState,
createPlanDetailSection,
renderConsumptionTrendChart,
requestConsumerTokenServiceOnce,
sendRuntimeMessage,
environment
}) {
const { document } = environment;
function createConsumptionTrendSection(plan) {
const section = document.createElement("section");
section.className = "opportunities-extension-consumption-trend";
section.dataset.subPlanNum = String(plan.subPlanNum || "");
section.dataset.period = DEFAULT_CONSUMPTION_TREND_PERIOD;
const header = document.createElement("header");
header.className = "opportunities-extension-consumption-trend-header";
const periodControls = document.createElement("div");
periodControls.className = "opportunities-extension-consumption-periods";
periodControls.setAttribute("role", "group");
periodControls.setAttribute("aria-label", "Consumption period");
CONSUMPTION_TREND_PERIODS.forEach((periodOption) => {
const button = document.createElement("button");
button.type = "button";
button.dataset.period = periodOption.value;
button.setAttribute("aria-pressed", String(periodOption.value === DEFAULT_CONSUMPTION_TREND_PERIOD));
button.textContent = periodOption.label;
button.addEventListener("click", () => {
section.dataset.period = periodOption.value;
consumptionTrendState.consumptionTrendWindowOffsets.set(
createConsumptionTrendKey(section.dataset.subPlanNum, periodOption.value),
0
);
loadConsumptionTrendForPanel(section.closest("[role='tabpanel']"), plan, periodOption.value);
});
periodControls.append(button);
});
const chart = document.createElement("div");
chart.className = "opportunities-extension-consumption-chart";
chart.setAttribute("aria-live", "polite");
header.append(createPlanDetailSection(plan), periodControls);
section.append(header, chart);
return section;
}
function loadConsumptionTrendForPanel(panel, plan, period) {
if (!panel) {
return;
}
const section = panel.querySelector(".opportunities-extension-consumption-trend");
if (!section) {
return;
}
const subPlanNum = String(plan.subPlanNum || "").trim();
const selectedPeriod = CONSUMPTION_TREND_PERIODS.some((item) => item.value === period)
? period
: DEFAULT_CONSUMPTION_TREND_PERIOD;
const key = createConsumptionTrendKey(subPlanNum, selectedPeriod);
const chart = section.querySelector(".opportunities-extension-consumption-chart");
section.dataset.period = selectedPeriod;
chart.dataset.requestKey = key;
updateConsumptionPeriodButtons(section, selectedPeriod);
if (!subPlanNum) {
renderConsumptionTrendError(chart, "Sub plan number is unavailable.");
return;
}
const cached = consumptionTrendState.consumptionTrendCache.get(key);
renderConsumptionTrendSkeleton(chart);
if (cached && cached.status === "loading") {
cached.promise
.then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key))
.catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key));
return;
}
const request = requestConsumerTokenServiceOnce()
.then((authorizations) => sendRuntimeMessage({
type: messageTypes.requestConsumptionTrend,
subPlanNum,
period: selectedPeriod,
authorization: authorizations && authorizations.accountPlans
}))
.then((response) => {
appendBackgroundRequestLog(response && response.debugRequests);
if (!response || !response.ok) {
throw new Error(response && response.error ? response.error : "Unable to load consumption trend.");
}
return Array.isArray(response.items) ? response.items : [];
})
.finally(() => {
if (consumptionTrendState.consumptionTrendCache.get(key)?.promise === request) {
consumptionTrendState.consumptionTrendCache.delete(key);
}
});
consumptionTrendState.consumptionTrendCache.set(key, { status: "loading", promise: request });
request
.then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key))
.catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key));
}
function createConsumptionTrendKey(subPlanNum, period) {
return `${subPlanNum}:${period}`;
}
function updateConsumptionPeriodButtons(section, period) {
section.querySelectorAll(".opportunities-extension-consumption-periods button").forEach((button) => {
button.setAttribute("aria-pressed", String(button.dataset.period === period));
});
}
function renderConsumptionTrendIfCurrent(section, chart, items, period, subPlanNum, key) {
if (chart.dataset.requestKey === key) {
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
}
}
function renderConsumptionTrendErrorIfCurrent(chart, errorMessage, key) {
if (chart.dataset.requestKey === key) {
renderConsumptionTrendError(chart, errorMessage || "Unable to load consumption trend.");
}
}
function renderConsumptionTrendSkeleton(chart) {
chart.textContent = "";
chart.setAttribute("aria-busy", "true");
const skeleton = document.createElement("div");
skeleton.className = "opportunities-extension-consumption-chart-skeleton";
const announcement = document.createElement("span");
announcement.className = "opportunities-extension-visually-hidden";
announcement.textContent = "Loading consumption trend";
const plot = document.createElement("div");
plot.className = "opportunities-extension-consumption-chart-skeleton-plot";
for (let index = 0; index < 13; index += 1) {
const bar = document.createElement("span");
bar.style.height = `${28 + ((index * 17) % 58)}%`;
plot.append(bar);
}
skeleton.append(announcement, plot);
chart.append(skeleton);
}
function renderConsumptionTrendError(chart, errorMessage) {
chart.textContent = "";
chart.setAttribute("aria-busy", "false");
const error = document.createElement("p");
error.className = "opportunities-extension-consumption-chart-error";
error.setAttribute("role", "alert");
error.textContent = errorMessage;
chart.append(error);
}
return { createConsumptionTrendSection, loadConsumptionTrendForPanel, createConsumptionTrendKey, updateConsumptionPeriodButtons, renderConsumptionTrendIfCurrent, renderConsumptionTrendErrorIfCurrent, renderConsumptionTrendSkeleton, renderConsumptionTrendError };
}

View File

@@ -0,0 +1,7 @@
function createConsumptionTrendState() {
return {
consumptionTrendCache: new Map(),
consumptionTrendWindowOffsets: new Map(),
consumptionTrendSeriesFilters: new Map()
};
}

View File

@@ -0,0 +1,197 @@
function createDebugContentLog({
previewDebugBody,
DEBUG_BODY_LIMIT,
debugState,
renderDebugPanel
}) {
function logRequestStart(details) {
const entry = {
id: debugState.requestLog.length + 1,
name: details.name,
method: details.method,
url: details.url,
headers: details.headers,
status: "pending",
startedAt: new Date(),
completedAt: null,
durationMs: null,
httpStatus: null,
statusText: "",
requestHeaders: details.headers,
requestBody: details.body || "",
metadata: details.metadata || {},
responseHeaders: {},
responseBody: "",
responseBodyTruncated: false,
error: ""
};
debugState.requestLog.push(entry);
renderDebugPanel();
return entry;
}
function logRequestSuccess(entry, response, responseBody) {
entry.status = response.ok ? "success" : "failed";
entry.completedAt = new Date();
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
entry.httpStatus = response.status;
entry.statusText = response.statusText || "";
entry.responseHeaders = headersToObject(response.headers);
entry.responseBody = responseBody.value;
entry.responseBodyTruncated = responseBody.truncated;
renderDebugPanel();
}
function logRequestFailure(entry, errorMessage) {
entry.status = "failed";
entry.completedAt = new Date();
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
entry.error = errorMessage;
renderDebugPanel();
}
function appendBackgroundRequestLog(entries) {
if (!Array.isArray(entries) || entries.length === 0) {
return;
}
entries.forEach((entry) => {
const existing = entry.debugKey && debugState.requestLog.find(item => item.debugKey === entry.debugKey);
if (existing && existing.status !== "pending" && entry.status === "pending") return;
const normalized = {
id: existing ? existing.id : debugState.requestLog.length + 1,
debugKey: entry.debugKey,
name: entry.name || "backgroundRequest",
method: entry.method || "GET",
url: entry.url || "",
headers: entry.requestHeaders || {},
status: entry.status || "failed",
startedAt: new Date(entry.startedAt),
completedAt: entry.completedAt ? new Date(entry.completedAt) : null,
durationMs: entry.durationMs,
httpStatus: entry.httpStatus,
statusText: entry.statusText || "",
requestHeaders: entry.requestHeaders || {},
requestBody: entry.requestBody || "",
metadata: entry.metadata || { executionContext: "background" },
responseHeaders: entry.responseHeaders || {},
responseBody: entry.responseBody || "",
responseBodyTruncated: Boolean(entry.responseBodyTruncated),
error: entry.error || ""
};
if (existing) Object.assign(existing, normalized);
else debugState.requestLog.push(normalized);
});
renderDebugPanel();
}
async function readResponseBodyForDebug(response) {
try {
const text = await response.clone().text();
return createDebugBody(text);
} catch (error) {
return {
value: `Unable to read response body: ${error.message || "unknown error"}`,
truncated: false
};
}
}
function createDebugBody(text) {
return previewDebugBody(text, DEBUG_BODY_LIMIT);
}
function headersToObject(headers) {
const headerMap = {};
headers.forEach((value, key) => {
headerMap[key] = value;
});
return headerMap;
}
function formatHeaderBlock(headers) {
const entries = Object.entries(headers);
if (entries.length === 0) {
return "(none)";
}
return entries
.map(([key, value]) => `${key}: ${debugState.debugSecretsVisible || !isSensitiveDebugKey(key)
? value
: formatHiddenDebugValue(value)}`)
.join("\n");
}
function formatDebugBody(body) {
const text = body || "(none)";
try {
const normalized = String(text).replace(/^\uFEFF/, "").trimStart()
.replace(/^\)\]\}',?\s*/, "")
.replace(/^while\s*\(1\);\s*/, "");
let value = JSON.parse(normalized);
// Some services serialize their JSON document inside a JSON string.
for (let depth = 0; depth < 2 && typeof value === "string" && /^[\s]*[\[{]/.test(value); depth += 1) {
try { value = JSON.parse(value); } catch { break; }
}
return JSON.stringify(debugState.debugSecretsVisible ? value : maskSensitiveDebugValue(value), null, 2);
} catch (error) {
return text;
}
}
function maskSensitiveDebugValue(value, parentKey = "") {
if (Array.isArray(value)) {
return value.map((item) => maskSensitiveDebugValue(item, parentKey));
}
if (!value || typeof value !== "object") {
return value;
}
return Object.fromEntries(Object.entries(value).map(([key, child]) => {
const isSensitive = isSensitiveDebugKey(key)
|| (key === "value" && /uiaasHeader|uiassHeader/i.test(parentKey));
return [key, isSensitive
? formatHiddenDebugValue(child)
: maskSensitiveDebugValue(child, key)];
}));
}
function isSensitiveDebugKey(key) {
return /authorization|cookie|password|secret|(^|[_-])token($|[_-])|token$/i.test(key);
}
function formatHiddenDebugValue(value) {
if (value === null || value === undefined || value === "") {
return "(missing)";
}
return `(present, ${String(value).length} characters)`;
}
function formatMetadataBlock(metadata) {
const entries = Object.entries(metadata);
if (entries.length === 0) {
return "(none)";
}
return entries
.map(([key, value]) => `${key}: ${value || "(none)"}`)
.join("\n");
}
function maskToken(token) {
if (token.length <= 10) {
return "(present)";
}
return `${token.slice(0, 4)}...${token.slice(-4)}`;
}
return { logRequestStart, logRequestSuccess, logRequestFailure, appendBackgroundRequestLog, readResponseBodyForDebug, createDebugBody, headersToObject, formatHeaderBlock, formatDebugBody, maskSensitiveDebugValue, isSensitiveDebugKey, formatHiddenDebugValue, formatMetadataBlock, maskToken };
}

View File

@@ -0,0 +1,359 @@
function createDebugContentView({
DEBUG_PANEL_ID,
MODAL_ID,
debugState,
formatDebugBody,
formatHeaderBlock,
formatMetadataBlock,
createCloseIcon,
environment
}) {
const { document } = environment;
const categories = [
["all", "All"], ["authentication", "Authentication"],
["opportunities", "Opportunities"], ["products", "Products"],
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"],
["serviceRequests", "Service Requests"], ["actions", "Actions"], ["other", "Other"]
];
let selectedCategory = "all";
const expandedIds = new Set();
let returnFocus = null;
function getRequestCategory(entry) {
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
if (entry.name === "requestOpportunities") return "opportunities";
if (entry.name === "requestOpptyProducts") return "products";
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
if (entry.name === "requestGenerateAiSummary") return "ai";
if (entry.name === "requestInstallBase") return "installBase";
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
return "other";
}
function getChronologicalRequests() {
return [...debugState.requestLog].sort((a, b) =>
(new Date(a.startedAt).getTime() || 0) - (new Date(b.startedAt).getTime() || 0) || a.id - b.id);
}
function toggleDebugPanel() {
const modal = document.getElementById(MODAL_ID);
if (!modal || modal.hidden) {
return;
}
const panel = document.getElementById(DEBUG_PANEL_ID);
if (!panel) {
return;
}
if (!panel.hidden) {
closeDebugPanel();
return;
}
returnFocus = document.activeElement;
panel.hidden = false;
renderDebugPanel();
panel.querySelectorAll(".opportunities-extension-debug-close-button")[0]?.focus({ preventScroll: true });
}
function closeDebugPanel() {
const panel = document.getElementById(DEBUG_PANEL_ID);
if (!panel) return;
panel.hidden = true;
if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
returnFocus = null;
}
function renderDebugPanel() {
const panel = document.getElementById(DEBUG_PANEL_ID);
if (!panel || panel.hidden) {
return;
}
for (const item of panel.querySelectorAll("details[data-request-id]")) {
const id = item.getAttribute("data-request-id");
if (item.open) expandedIds.add(id);
else expandedIds.delete(id);
}
const focusedCategory = document.activeElement && document.activeElement.getAttribute("data-debug-category");
const focusedControl = document.activeElement && document.activeElement.getAttribute("data-debug-focus");
const listScrollTop = panel.querySelectorAll(".opportunities-extension-debug-list")[0]?.scrollTop || 0;
const scrollTop = panel.scrollTop;
panel.textContent = "";
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-modal", "true");
panel.setAttribute("aria-labelledby", `${DEBUG_PANEL_ID}-title`);
const focusTargets = new Map();
const panelHeader = document.createElement("header");
panelHeader.className = "opportunities-extension-debug-panel-header";
const heading = document.createElement("div");
const title = document.createElement("h2");
title.id = `${DEBUG_PANEL_ID}-title`;
title.textContent = "Debug Requests";
const summary = document.createElement("p");
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`;
const running = debugState.requestLog.filter(entry => entry.status === "pending").length;
if (running) summary.textContent += ` ${running} running.`;
heading.append(title, summary);
const visibilityButton = document.createElement("button");
visibilityButton.type = "button";
visibilityButton.className = "opportunities-extension-icon-button opportunities-extension-debug-visibility-button";
visibilityButton.setAttribute("data-debug-focus", "visibility");
focusTargets.set("visibility", visibilityButton);
visibilityButton.setAttribute("aria-pressed", String(debugState.debugSecretsVisible));
visibilityButton.setAttribute("aria-label", debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values");
visibilityButton.title = debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values";
visibilityButton.append(createDebugVisibilityIcon(debugState.debugSecretsVisible));
visibilityButton.addEventListener("click", () => {
debugState.debugSecretsVisible = !debugState.debugSecretsVisible;
renderDebugPanel();
});
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button opportunities-extension-debug-close-button";
closeButton.title = "Close debug";
closeButton.setAttribute("aria-label", "Close debug");
closeButton.setAttribute("data-debug-focus", "close");
closeButton.append(createCloseIcon());
closeButton.addEventListener("click", closeDebugPanel);
focusTargets.set("close", closeButton);
const actions = document.createElement("div");
actions.className = "opportunities-extension-debug-actions";
actions.append(visibilityButton, closeButton);
panelHeader.append(heading, actions);
const entries = getChronologicalRequests();
const availableCategories = categories.filter(([key]) => key === "all" || entries.some(entry => getRequestCategory(entry) === key));
if (!availableCategories.some(([key]) => key === selectedCategory)) selectedCategory = "all";
const tabs = document.createElement("div");
tabs.className = "opportunities-extension-debug-tabs";
tabs.setAttribute("role", "tablist");
tabs.setAttribute("aria-label", "Request categories");
const tabButtons = [];
availableCategories.forEach(([key, label], index) => {
const categoryEntries = key === "all" ? entries : entries.filter(entry => getRequestCategory(entry) === key);
const pending = categoryEntries.filter(entry => entry.status === "pending").length;
const button = document.createElement("button");
button.type = "button";
button.id = `${DEBUG_PANEL_ID}-tab-${key}`;
button.setAttribute("role", "tab");
button.setAttribute("data-debug-category", key);
button.setAttribute("aria-selected", String(key === selectedCategory));
button.setAttribute("aria-controls", `${DEBUG_PANEL_ID}-requests`);
button.tabIndex = key === selectedCategory ? 0 : -1;
button.textContent = `${label} (${categoryEntries.length})${pending ? ` / ${pending} running` : ""}`;
button.addEventListener("click", () => {
selectedCategory = key;
renderDebugPanel();
});
button.addEventListener("keydown", event => {
let nextIndex;
if (event.key === "ArrowRight") nextIndex = (index + 1) % availableCategories.length;
else if (event.key === "ArrowLeft") nextIndex = (index + availableCategories.length - 1) % availableCategories.length;
else if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = availableCategories.length - 1;
else return;
event.preventDefault();
selectedCategory = availableCategories[nextIndex][0];
renderDebugPanel();
});
tabs.append(button);
tabButtons.push(button);
});
const list = document.createElement("div");
list.className = "opportunities-extension-debug-list";
list.id = `${DEBUG_PANEL_ID}-requests`;
list.setAttribute("role", "tabpanel");
list.setAttribute("aria-labelledby", `${DEBUG_PANEL_ID}-tab-${selectedCategory}`);
list.tabIndex = 0;
list.setAttribute("data-debug-focus", "list");
focusTargets.set("list", list);
const summaries = [];
if (debugState.requestLog.length === 0) {
const empty = document.createElement("div");
empty.className = "opportunities-extension-debug-empty";
empty.textContent = "No requests recorded yet.";
list.append(empty);
}
entries.filter(entry => selectedCategory === "all" || getRequestCategory(entry) === selectedCategory).forEach((entry) => {
const item = document.createElement("details");
item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`;
item.setAttribute("data-request-id", String(entry.id));
item.open = expandedIds.has(String(entry.id));
const heading = document.createElement("summary");
heading.className = "opportunities-extension-debug-heading";
heading.setAttribute("data-debug-focus", `request-${entry.id}`);
focusTargets.set(`request-${entry.id}`, heading);
summaries.push(heading);
const name = document.createElement("strong");
name.textContent = `${entry.id}. ${entry.name}`;
if (entry.name === "requestOpptyProducts" && entry.metadata.optyNumber) {
name.textContent += ` (${entry.metadata.optyNumber}, offset ${entry.metadata.offset})`;
}
const httpStatus = document.createElement("span");
httpStatus.className = "opportunities-extension-debug-http-status";
httpStatus.textContent = entry.httpStatus ? String(entry.httpStatus) : "pending";
httpStatus.setAttribute("data-http-class", entry.httpStatus ? String(Math.floor(entry.httpStatus / 100)) : "pending");
const status = document.createElement("span");
status.className = "opportunities-extension-debug-status";
status.textContent = entry.status === "pending" ? "running" : entry.status;
const summaryMeta = document.createElement("span");
summaryMeta.className = "opportunities-extension-debug-summary-meta";
const startedTime = document.createElement("time");
startedTime.className = "opportunities-extension-debug-time";
startedTime.textContent = formatDebugTime(entry.startedAt);
startedTime.title = `Started: ${entry.startedAt.toLocaleString()}`;
const method = document.createElement("span");
method.className = "opportunities-extension-debug-method";
method.textContent = entry.method || "GET";
method.setAttribute("data-method", method.textContent.toUpperCase());
const duration = document.createElement("span");
duration.className = "opportunities-extension-debug-duration";
const hasDuration = typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && entry.durationMs >= 0;
duration.textContent = hasDuration
? entry.durationMs < 1000 ? `${Math.round(entry.durationMs)} ms` : `${(entry.durationMs / 1000).toFixed(2)} s`
: entry.status === "pending" ? "In progress" : "N/D";
duration.title = hasDuration ? `Request duration: ${entry.durationMs} ms` : "Request duration not yet available";
summaryMeta.append(startedTime, method, httpStatus, status, duration);
heading.append(name, summaryMeta);
const lines = [
`${entry.method} ${entry.url}`,
`Started: ${formatDebugTime(entry.startedAt)}`,
entry.completedAt ? `Completed: ${formatDebugTime(entry.completedAt)} (${entry.durationMs}ms)` : "Completed: pending",
entry.httpStatus ? `HTTP: ${entry.httpStatus} ${entry.statusText}`.trim() : "",
entry.error ? `Error: ${entry.error}` : "",
`Response body truncated: ${entry.responseBodyTruncated ? "yes" : "no"}`
].filter(Boolean);
const details = document.createElement("div");
details.className = "opportunities-extension-debug-details";
appendDebugBlock(details, "Request", lines.join("\n"));
appendDebugBlock(details, "Request Metadata", formatMetadataBlock(entry.metadata));
appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders));
appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody));
appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders));
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody));
item.append(heading, details);
list.append(item);
});
panel.append(panelHeader, tabs, list);
panel.onkeydown = event => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
closeDebugPanel();
} else if (event.key === "Tab") {
const controls = [visibilityButton, closeButton, ...tabButtons.filter(button => button.tabIndex === 0), list, ...summaries];
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey && index === 0) || (!event.shiftKey && index === controls.length - 1)) {
event.preventDefault();
controls[event.shiftKey ? controls.length - 1 : 0].focus({ preventScroll: true });
}
}
};
if (focusedCategory) tabButtons.find(button => button.getAttribute("data-debug-category") === selectedCategory).focus({ preventScroll: true });
else if (focusedControl) focusTargets.get(focusedControl)?.focus({ preventScroll: true });
list.scrollTop = listScrollTop;
panel.scrollTop = scrollTop;
}
function createDebugVisibilityIcon(secretsVisible) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const eye = document.createElementNS("http://www.w3.org/2000/svg", "path");
const pupil = document.createElementNS("http://www.w3.org/2000/svg", "circle");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
eye.setAttribute("d", "M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12Z");
pupil.setAttribute("cx", "12");
pupil.setAttribute("cy", "12");
pupil.setAttribute("r", "2.75");
[eye, pupil].forEach((shape) => {
shape.setAttribute("fill", "none");
shape.setAttribute("stroke", "currentColor");
shape.setAttribute("stroke-linecap", "round");
shape.setAttribute("stroke-linejoin", "round");
shape.setAttribute("stroke-width", "1.7");
});
svg.append(eye, pupil);
if (secretsVisible) {
const slash = document.createElementNS("http://www.w3.org/2000/svg", "path");
slash.setAttribute("d", "M4 4l16 16");
slash.setAttribute("fill", "none");
slash.setAttribute("stroke", "currentColor");
slash.setAttribute("stroke-linecap", "round");
slash.setAttribute("stroke-width", "1.9");
svg.append(slash);
}
return svg;
}
function appendDebugBlock(container, label, value) {
const block = document.createElement("section");
block.className = "opportunities-extension-debug-block";
block.setAttribute("data-direction", label.startsWith("Response") ? "response" : "request");
const heading = document.createElement("h3");
const content = document.createElement("pre");
heading.textContent = label;
appendJsonTokens(content, value);
block.append(heading, content);
container.append(block);
}
function appendJsonTokens(content, value) {
const text = String(value ?? "");
try { JSON.parse(text); } catch { content.textContent = text; return; }
// Highlight only already-masked display text; never interpret response content as HTML.
const tokens = [...text.matchAll(/"(?:[^"\\]|\\.)*"\s*:|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|\b(?:true|false|null)\b/g)];
let offset = 0;
const append = (textValue, kind) => {
if (!textValue) return;
const span = document.createElement("span");
span.textContent = textValue;
if (kind) span.className = `opportunities-extension-debug-json-${kind}`;
content.append(span);
};
tokens.forEach(token => {
append(text.slice(offset, token.index));
const value = token[0];
const kind = value.startsWith('"') ? value.endsWith(":") ? "key" : "string"
: /^(true|false|null)$/.test(value) ? "literal" : "number";
append(value, kind);
offset = token.index + value.length;
});
append(text.slice(offset));
}
function formatDebugTime(date) {
return date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}
return { toggleDebugPanel, closeDebugPanel, renderDebugPanel, createDebugVisibilityIcon, appendDebugBlock, formatDebugTime, getRequestCategory, getChronologicalRequests };
}

View File

@@ -0,0 +1,6 @@
function createDebugState() {
return {
requestLog: [],
debugSecretsVisible: false
};
}

View File

@@ -0,0 +1,223 @@
/* Full-page diagnostics, scoped to the extension and its current theme. */
.opportunities-extension-modal .opportunities-extension-debug-panel {
--debug-surface: #fff;
--debug-code: #f5f6f7;
--debug-text: #312d2a;
--debug-muted: #5f5a55;
--debug-border: #dedbd7;
--debug-success: #22613b;
--debug-success-bg: #e1f1e5;
--debug-failed: #a12721;
--debug-failed-bg: #fce8e6;
--debug-pending: #805000;
--debug-pending-bg: #fff1ce;
--debug-info: #075e87;
--debug-info-bg: #e5f1fa;
--debug-key: #075e87;
--debug-string: #22613b;
--debug-number: #865000;
--debug-literal: #73429b;
position: fixed;
inset: 0;
z-index: 50;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
width: auto;
height: auto;
max-height: none;
min-width: 0;
box-sizing: border-box;
overflow: hidden;
margin: 0;
padding: 20px;
border: 0;
border-radius: 0;
background: var(--debug-surface);
color: var(--debug-text);
color-scheme: light;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel {
--debug-surface: #292827;
--debug-code: #222120;
--debug-text: #f6f4f2;
--debug-muted: #c9c5c1;
--debug-border: #514c47;
--debug-success: #a9e3bd;
--debug-success-bg: #214d34;
--debug-failed: #ffb4ab;
--debug-failed-bg: #582c2a;
--debug-pending: #ffe099;
--debug-pending-bg: #59441e;
--debug-info: #a8d9ff;
--debug-info-bg: #233f52;
--debug-key: #8ed6f0;
--debug-string: #a9e3bd;
--debug-number: #ffe099;
--debug-literal: #d6b7f1;
color: #f6f4f2;
color-scheme: dark;
}
.opportunities-extension-debug-actions { display: flex; gap: 8px; flex: 0 0 auto; }
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-list {
overflow: auto;
min-height: 0;
min-width: 0;
align-content: start;
scrollbar-gutter: stable;
padding: 2px 8px 12px 2px;
}
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-item {
--debug-state: var(--debug-info);
--debug-state-bg: var(--debug-info-bg);
border-color: var(--debug-border);
border-left-color: var(--debug-state);
background: var(--debug-surface);
min-width: 0;
}
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-item-success { --debug-state: var(--debug-success); --debug-state-bg: var(--debug-success-bg); }
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-item-failed { --debug-state: var(--debug-failed); --debug-state-bg: var(--debug-failed-bg); }
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-item-pending { --debug-state: var(--debug-pending); --debug-state-bg: var(--debug-pending-bg); }
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-status {
color: var(--debug-state);
background: var(--debug-state-bg);
}
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-http-status,
.opportunities-extension-debug-method {
color: var(--debug-info);
background: var(--debug-info-bg);
}
.opportunities-extension-debug-method {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 4px;
font: 700 11px/1.2 Consolas, monospace;
}
.opportunities-extension-debug-method[data-method="POST"] { color: var(--debug-literal); background: var(--debug-code); }
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-http-status[data-http-class="2"] { color: var(--debug-success); background: var(--debug-success-bg); }
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-http-status[data-http-class="4"],
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-http-status[data-http-class="5"] { color: var(--debug-failed); background: var(--debug-failed-bg); }
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-http-status[data-http-class="pending"] { color: var(--debug-pending); background: var(--debug-pending-bg); }
.opportunities-extension-debug-details { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; padding: 0 14px 16px; }
.opportunities-extension-debug-block { min-width: 0; border-left: 2px solid var(--debug-info); padding-left: 10px; }
.opportunities-extension-debug-block[data-direction="response"] { border-left-color: var(--debug-success); }
.opportunities-extension-modal .opportunities-extension-debug-block h3 { margin: 0 0 8px; color: var(--debug-info); font-size: 12px; }
.opportunities-extension-modal .opportunities-extension-debug-block[data-direction="response"] h3 { color: var(--debug-success); }
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-item pre {
min-width: 0;
margin: 0;
padding: 12px;
max-height: 380px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--debug-text);
background: var(--debug-code);
line-height: 1.6;
tab-size: 2;
}
.opportunities-extension-debug-json-key { color: var(--debug-key); }
.opportunities-extension-debug-json-string { color: var(--debug-string); }
.opportunities-extension-debug-json-number { color: var(--debug-number); }
.opportunities-extension-debug-json-literal { color: var(--debug-literal); }
@media (max-width: 760px) {
.opportunities-extension-modal .opportunities-extension-debug-panel { padding: 12px; }
.opportunities-extension-debug-details { grid-template-columns: minmax(0, 1fr); }
}
.opportunities-extension-debug-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #dedbd7;
}
.opportunities-extension-debug-tabs button {
max-width: 100%;
min-height: 32px;
padding: 6px 8px;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
background: transparent;
color: inherit;
font: inherit;
font-size: 12px;
line-height: 1.35;
overflow-wrap: anywhere;
cursor: pointer;
}
.opportunities-extension-debug-tabs button:hover {
background: #f5f4f2;
}
.opportunities-extension-debug-tabs button[aria-selected="true"] {
border-bottom-color: #00758f;
background: #e8f4f6;
color: #006b84;
font-weight: 700;
}
.opportunities-extension-debug-tabs button:focus-visible,
.opportunities-extension-debug-list[role="tabpanel"]:focus-visible {
outline: 2px solid #00758f;
outline-offset: -2px;
}
.opportunities-extension-debug-time {
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.opportunities-extension-debug-heading {
flex-wrap: wrap;
}
.opportunities-extension-debug-heading strong {
flex: 1 1 180px;
min-width: 0;
overflow-wrap: anywhere;
}
.opportunities-extension-debug-summary-meta {
flex-wrap: wrap;
justify-content: flex-end;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs button {
color: #f6f4f2;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-heading::before {
color: #c9c5c1;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs {
border-bottom-color: #514c47;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs button:hover {
background: #383633;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs button[aria-selected="true"] {
background: #173f48;
border-bottom-color: #8cc8d3;
color: #bde7ee;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs button:focus-visible,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-list[role="tabpanel"]:focus-visible {
outline-color: #8cc8d3;
}
.opportunities-extension-debug-duration {
color: #55514d;
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-duration { color: #d6d2ce; }

View File

@@ -0,0 +1,53 @@
function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce, sendRuntimeMessage,
appendBackgroundRequestLog, openInstallBaseModal, environment }) {
const { document } = environment;
const pending = new Map();
async function load(partyNumber) {
const authorizations = await requestConsumerTokenServiceOnce();
const response = await sendRuntimeMessage({
type: messageTypes.requestInstallBase, partyNumber,
authorizations: { accountSummary: authorizations.accountSummary, installBase: authorizations.installBase }
});
appendBackgroundRequestLog(response?.debugRequests);
if (!response?.ok) throw new Error(response?.error || "Unable to load installed base.");
return response.items;
}
function createInstallBaseButton(opportunity) {
const button = document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-action-button";
button.title = "View customer installed base";
button.setAttribute("aria-label", `View installed base for ${opportunity.CustomerAccount?.PartyUniqueName || "customer"}`);
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
icon.setAttribute("viewBox", "0 0 24 24");
icon.setAttribute("aria-hidden", "true");
icon.setAttribute("focusable", "false");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", "M4 3h16v7H4z M4 14h16v7H4z M7 6.5h.01 M7 17.5h.01 M11 6.5h6 M11 17.5h6 M8 10v4 M16 10v4");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.7");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
icon.append(path);
button.append(icon);
button.addEventListener("click", async () => {
const view = openInstallBaseModal(opportunity, button);
const partyNumber = String(opportunity.CustomerAccount?.PartyNumber || "");
try {
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
if (!pending.has(partyNumber)) {
const request = load(partyNumber).finally(() => { if (pending.get(partyNumber) === request) pending.delete(partyNumber); });
pending.set(partyNumber, request);
}
view.render(await pending.get(partyNumber));
} catch (error) {
view.render([], error.message || "Unable to load installed base.");
}
});
return button;
}
return { createInstallBaseButton, resetRequests: () => pending.clear() };
}

View File

@@ -0,0 +1,80 @@
.opportunities-extension-account-plans-dialog.opportunities-extension-install-base-dialog {
width: min(1700px, 100%);
}
.opportunities-extension-install-base-dialog .opportunities-extension-account-plans-header {
padding: 10px 12px;
gap: 12px;
}
.opportunities-extension-install-base-dialog .opportunities-extension-account-plans-header h2 {
font-size: 16px;
line-height: 22px;
}
.opportunities-extension-install-base-dialog .opportunities-extension-account-plans-header p {
font-size: 12px;
line-height: 16px;
margin-top: 3px;
overflow-wrap: anywhere;
}
.opportunities-extension-install-base-dialog .opportunities-extension-icon-button {
width: 28px;
height: 28px;
min-width: 28px;
min-height: 28px;
padding: 4px;
}
.opportunities-extension-install-base-dialog .opportunities-extension-customer-table-toolbar {
padding: 8px 12px;
}
.opportunities-extension-install-base-dialog .opportunities-extension-customer-table-search {
width: min(320px, 100%);
height: 32px;
padding: 4px 8px;
font-size: 12px;
line-height: 20px;
}
.opportunities-extension-install-base-content {
min-width: 0;
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
}
.opportunities-extension-install-base-message { margin: 10px 12px; font-size: 12px; line-height: 16px; }
.opportunities-extension-install-base-table {
width: 100%; min-width: 1120px; table-layout: fixed; border-collapse: collapse; font-size: 11px;
}
.opportunities-extension-install-base-table col { width: 7%; }
.opportunities-extension-install-base-table .install-base-col-partyName { width: 12%; }
.opportunities-extension-install-base-table .install-base-col-productGroupName { width: 13%; }
.opportunities-extension-install-base-table .install-base-col-productName { width: 13%; }
.opportunities-extension-install-base-table .install-base-col-quantity { width: 5%; }
.opportunities-extension-install-base-table .install-base-col-quantityType { width: 8%; }
.opportunities-extension-install-base-table th,
.opportunities-extension-install-base-table td {
padding: 8px 6px; text-align: left; vertical-align: middle; overflow-wrap: anywhere;
border-bottom: 1px solid #e8e5e1; line-height: 1.4;
}
.opportunities-extension-install-base-table th { position: sticky; top: 0; z-index: 1; background: #faf9f8; }
.opportunities-extension-install-base-table td[data-format="amount"],
.opportunities-extension-install-base-table td[data-format="quantity"] { text-align: right; font-variant-numeric: tabular-nums; }
.opportunities-extension-install-base-table td[data-format="date"] { white-space: nowrap; }
.opportunities-extension-install-base-status {
display: inline-block; padding: 2px 5px; border-radius: 12px; font-size: 10px; font-weight: 600;
color: #5f5a55; background: #eceae8; white-space: nowrap;
}
.opportunities-extension-install-base-status[data-status="ACTIVE"] { color: #17693a; background: #e1f1e5; }
.opportunities-extension-install-base-status[data-status="UNSUPPORTED"] { color: #855300; background: #fff0d6; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-content,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table td { color: #f6f4f2; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-message { color: #c9c5c1; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-message[role="alert"] { color: #ffb4ab; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th { background: #353331; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table td { border-color: #484541; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-status { color: #dedbd7; background: #484541; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-status[data-status="ACTIVE"] { color: #b8e4c6; background: #214d34; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-status[data-status="UNSUPPORTED"] { color: #ffdfa2; background: #614418; }
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-install-base-table .opportunities-extension-account-plans-skeleton-bar { animation: none; }
}

View File

@@ -0,0 +1,146 @@
function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, environment }) {
const { document } = environment;
let active = null;
const columns = [
["partyName", "Customer"], ["productGroupName", "Product Group"], ["productName", "Product"],
["currentSupport", "Current Support", "amount"], ["orderNumber", "Order Number"],
["supportStatus", "Support Status", "status"], ["quantityType", "Quantity Type"],
["quantity", "Quantity", "quantity"], ["supportStartDate", "Support Start Date", "date"],
["supportEndDate", "Support End Date", "date"], ["licenseValue", "License Value", "amount"],
["shipDate", "Ship Date", "date"]
];
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function closeInstallBaseModal() {
if (!active) return false;
const previous = active;
active = null;
previous.modal.remove();
if (previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function formatValue(value, type) {
if (value === null || value === undefined || value === "") return "-";
if (type === "date") return formatOracleResponseDate(value);
if ((type === "amount" || type === "quantity") && Number.isFinite(Number(value))) {
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: type === "amount" ? 2 : 0, maximumFractionDigits: type === "amount" ? 2 : 4
}).format(Number(value));
}
return String(value).trim();
}
function createTable(items, loading = false) {
const table = element("table", "opportunities-extension-install-base-table");
table.setAttribute("aria-label", "Installed base items");
const head = element("thead");
const header = element("tr");
const group = element("colgroup");
columns.forEach(([key, label]) => {
const col = element("col");
col.className = `install-base-col-${key}`;
group.append(col);
const th = element("th", "", label);
th.scope = "col";
header.append(th);
});
head.append(header);
const body = element("tbody");
(loading ? Array.from({ length: 8 }, () => ({})) : items).forEach(item => {
const row = element("tr");
columns.forEach(([key, , type]) => {
const cell = element("td");
if (loading) {
const bar = element("span", "opportunities-extension-account-plans-skeleton-bar");
bar.setAttribute("aria-hidden", "true");
cell.append(bar);
} else {
const value = item[key];
const text = formatValue(value, type);
if (type === "status") {
const badge = element("span", "opportunities-extension-install-base-status", text);
badge.dataset.status = text.toUpperCase();
cell.append(badge);
} else cell.textContent = text;
if (type) cell.dataset.format = type;
}
row.append(cell);
});
body.append(row);
});
table.append(group, head, body);
return table;
}
function openInstallBaseModal(opportunity, trigger) {
closeInstallBaseModal();
const overlay = document.getElementById(MODAL_ID);
if (!overlay || overlay.hidden) return { render() {} };
const modal = element("section", "opportunities-extension-account-plans-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-busy", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-install-base-title");
const dialog = element("div", "opportunities-extension-account-plans-dialog opportunities-extension-install-base-dialog");
const header = element("header", "opportunities-extension-account-plans-header");
const heading = element("div");
const title = element("h2", "", "Installed Base");
title.id = "opportunities-extension-install-base-title";
heading.append(title, element("p", "", opportunity.CustomerAccount?.PartyUniqueName || "Customer"));
const close = element("button", "opportunities-extension-icon-button");
close.type = "button";
close.title = "Close";
close.setAttribute("aria-label", "Close installed base");
close.append(createCloseIcon());
close.addEventListener("click", closeInstallBaseModal);
header.append(heading, close);
const content = element("div", "opportunities-extension-install-base-content");
content.tabIndex = 0;
content.setAttribute("role", "region");
content.setAttribute("aria-label", "Installed base results");
const status = element("p", "opportunities-extension-install-base-message", "Loading installed base...");
status.setAttribute("role", "status");
let loadedItems = [], loaded = false, failure = "";
const search = createSearch({ label: "Search installed base", onSearch: renderResults,
getValues: item => columns.flatMap(([key, , type]) => [item[key], formatValue(item[key], type)]) });
const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
toolbar.append(search.input);
content.append(status, createTable([], true));
dialog.append(header, toolbar, content);
modal.append(dialog);
overlay.append(modal);
active = { modal, trigger };
modal.addEventListener("keydown", event => {
if (event.key === "Tab") {
event.preventDefault();
const controls = [close, search.input, content];
const index = controls.indexOf(document.activeElement);
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus();
}
});
close.focus();
function renderResults() {
if (!loaded || active?.modal !== modal || overlay.hidden) return;
const items = failure ? [] : search.filter(loadedItems);
content.textContent = "";
status.textContent = failure || (loadedItems.length
? `${items.length} of ${loadedItems.length} installed base items${items.length ? "" : ". No matching records."}`
: "No installed base items found for this customer.");
status.setAttribute("role", failure ? "alert" : "status");
content.append(status);
if (!failure && items.length) content.append(createTable(items));
content.scrollTop = 0;
}
return { render(items, error = "") {
// A closed or replaced dialog must never receive a late response.
if (active?.modal !== modal || overlay.hidden) return;
modal.setAttribute("aria-busy", "false");
loadedItems = items; failure = error; loaded = true;
search.reset();
renderResults();
} };
}
return { openInstallBaseModal, closeInstallBaseModal, createTable };
}

View File

@@ -0,0 +1,67 @@
function createActivityCache({ authSessionState, preferencesState, environment }) {
const { browser, chrome } = environment;
const invalidatedKey = "opportunitiesExtension.activities.invalidatedAt.v1";
async function invalidate() {
await storage("set", { [invalidatedKey]: Date.now() });
const entries = await storage("get", null);
const keys = Object.keys(entries || {}).filter(key => key.startsWith("opportunitiesExtension.activities.v1:"));
if (keys.length) await storage("remove", keys);
}
function storage(method, argument) {
if (browser?.storage?.local) return browser.storage.local[method](argument);
if (!chrome?.storage?.local) return Promise.reject(new Error("Extension storage unavailable."));
return new Promise((resolve, reject) => {
chrome.storage.local[method](argument, result => {
const error = chrome.runtime?.lastError;
if (error) reject(new Error(error.message));
else resolve(result);
});
});
}
function context(kind, id, timestamp) {
const principal = authSessionState.principal;
const fetchedAt = Number(timestamp);
const hours = Number(preferencesState.opptyProductsCacheHours);
const ttl = Number.isFinite(hours) && hours > 0 ? Math.min(hours, 720) * 3600000 : 86400000;
if (!principal || !Number.isFinite(fetchedAt) || fetchedAt <= 0 || fetchedAt > Date.now() || Date.now() - fetchedAt >= ttl) return null;
const identity = [principal, kind, String(id)];
const legacyKey = `opportunitiesExtension.activities.v1:${JSON.stringify(identity)}`;
// SR snapshots from different product updates must not overwrite each other.
if (kind === "sr") identity.push(fetchedAt);
return { key: `opportunitiesExtension.activities.v1:${JSON.stringify(identity)}`, legacyKey, fetchedAt, ttl };
}
async function read(kind, id, timestamp) {
const scope = context(kind, id, timestamp);
if (!scope) return null;
try {
const invalidated = await storage("get", invalidatedKey);
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return null;
const result = await storage("get", scope.key);
let entry = result?.[scope.key];
if (!entry && scope.key !== scope.legacyKey) {
const legacy = await storage("get", scope.legacyKey);
entry = legacy?.[scope.legacyKey];
}
return entry?.fetchedAt === scope.fetchedAt && Array.isArray(entry.items)
&& entry.items.every(item => item && typeof item === "object" && !Array.isArray(item))
&& typeof entry.hasNext === "boolean" ? entry : null;
} catch { return null; }
}
async function write(kind, id, timestamp, items, hasNext = false) {
const scope = context(kind, id, timestamp);
if (!scope || !Array.isArray(items)) return;
try {
const invalidated = await storage("get", invalidatedKey);
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return;
await storage("set", { [scope.key]: { fetchedAt: scope.fetchedAt, items, hasNext: Boolean(hasNext) } });
if (kind === "sr") {
const entries = await storage("get", null);
const prefix = `${scope.legacyKey.slice(0, -1)},`;
const expired = Object.keys(entries || {}).filter(key => key.startsWith(prefix)
&& Number(entries[key]?.fetchedAt) <= Math.max(Date.now() - scope.ttl, Number(invalidated?.[invalidatedKey] || 0)));
if (expired.length) await storage("remove", expired);
}
} catch { /* Storage failure must not discard successfully fetched data. */ }
}
return { invalidate, read, write };
}

View File

@@ -0,0 +1,91 @@
function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSummary, renderTable, setBadgeContent, environment }) {
const selections = { sr: new Set(["matched", "missing"]), actions: new Set(["matched", "missing"]) };
let container = null;
let form = null;
const groups = [];
const buttons = [];
function createControls(parentForm, statusFilter, stageFilter) {
form = parentForm;
groups.length = 0;
buttons.length = 0;
container = environment.document.createElement("div");
container.className = "opportunities-extension-badge-filters";
for (const [kind, title, options] of [
["sr", "Service Request", ["matched", "under4", "missing"]],
["actions", "WL Action", ["matched", "missing"]]
]) {
const section = environment.document.createElement("section");
section.className = "opportunities-extension-activity-filter";
const label = environment.document.createElement("span");
label.textContent = title;
const tooltip = kind === "sr"
? "Whether a Service Request has been created for the opportunities."
: "Whether the Action is registered in Workload Workbench.";
label.title = tooltip;
const controls = environment.document.createElement("div");
controls.className = "opportunities-extension-activity-filter-controls";
controls.setAttribute("role", "group");
controls.setAttribute("aria-label", `Filter by ${title}`);
for (const value of options) {
const button = environment.document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-activity-filter-button";
button.setAttribute("data-value", value);
setBadgeContent(button, kind, value === "under4" ? "< 4h" : "");
const description = value === "under4"
? "At least one SR with reported hours below 4; replaces other SR filters."
: `${kind === "sr" ? "Service Request" : "Action"} ${value === "matched" ? "registered" : "not registered"}`;
button.title = `${tooltip}\n${description}`;
button.setAttribute("aria-label", description);
button.addEventListener("click", () => toggle(kind, value));
buttons.push({ button, kind, value });
controls.append(button);
}
section.append(label, controls);
groups.push(section);
container.append(section);
}
container.append(statusFilter, stageFilter);
render();
return container;
}
function render() {
if (!container) return;
const allowed = allowsCrmActivities();
form.setAttribute("data-activity-filters", String(allowed));
for (const group of groups) group.hidden = !allowed;
for (const { button, kind, value } of buttons) {
button.setAttribute("aria-pressed", String(selections[kind].has(value)));
}
}
function toggle(kind, value) {
const selected = selections[kind];
if (!selected || !allowsCrmActivities()) return;
if (selected.has(value)) selected.delete(value);
else {
if (value === "under4") selected.clear();
else selected.delete("under4");
selected.add(value);
}
render();
renderTable();
}
function matchesSelection(selected, summary) {
// Unrestricted filters retain rows whose activities are not known yet.
if (selected.has("matched") && selected.has("missing")) return true;
if (!summary) return false;
if (selected.has("under4")) return summary.state === "matched" && summary.hasUnderFourHours;
return selected.has(summary.state);
}
function matches(item) {
if (!allowsCrmActivities()) return true;
return matchesSelection(selections.sr, getSrSummary(item.OptyNumber))
&& matchesSelection(selections.actions, getActionSummary(item.OptyNumber));
}
return { createControls, render, toggle, matches };
}

View File

@@ -0,0 +1,38 @@
function createActivityProgress({ MODAL_ID, environment }) {
const { document, setTimeout, clearTimeout, matchMedia } = environment;
const entries = new Map();
function update(key, state, completed = 0, total = null) {
const host = document?.querySelector?.(`#${MODAL_ID} .opportunities-extension-subtitle-row`);
if (!host) return;
let entry = entries.get(key);
if (!entry) {
const badge = document.createElement("span");
badge.className = "opportunities-extension-products-progress";
badge.setAttribute("role", "status");
badge.setAttribute("aria-live", "polite");
host.append(badge);
entry = { badge, timer: null, hideTimer: null, state: "idle" };
entries.set(key, entry);
}
if (state === "complete" && entry.state === "complete") return;
clearTimeout(entry.timer);
clearTimeout(entry.hideTimer);
entry.state = state;
const label = key === "sr" ? "Service Requests" : "Actions";
entry.badge.hidden = state === "idle";
entry.badge.setAttribute("data-state", state);
entry.badge.setAttribute("data-fading", "false");
entry.badge.setAttribute("aria-busy", String(state === "running"));
const count = total === null ? String(completed) : `${completed}/${total}`;
entry.badge.textContent = state === "error" ? `${label} unavailable`
: state === "complete-with-errors" ? `${label} ${count} (errors)`
: state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`;
entry.badge.title = key === "sr" ? "CRM service request records loaded" : "Workload action requests completed";
if (state === "complete") entry.timer = setTimeout(() => {
entry.badge.setAttribute("data-fading", "true");
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
entry.hideTimer = setTimeout(() => { entry.badge.hidden = true; }, reduced ? 0 : 300);
}, 5000);
}
return { update };
}

View File

@@ -0,0 +1,63 @@
/*
* Lucide icons: package, chart-no-axes-column-increasing, clipboard-list, list-checks.
* https://github.com/lucide-icons/lucide (ISC License)
* Copyright (c) 2026 Lucide Icons and Contributors
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
function createOpportunityBadgeContent({ environment }) {
const shapes = {
products: [
["path", { d: "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" }],
["path", { d: "M12 22V12" }],
["polyline", { points: "3.29 7 12 12 20.71 7" }],
["path", { d: "m7.5 4.27 9 5.15" }]
],
ramp: [
["path", { d: "M5 21v-6" }],
["path", { d: "M12 21V9" }],
["path", { d: "M19 21V3" }]
],
sr: [
["rect", { width: "8", height: "4", x: "8", y: "2", rx: "1", ry: "1" }],
["path", { d: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" }],
["path", { d: "M12 11h4" }],
["path", { d: "M12 16h4" }],
["path", { d: "M8 11h.01" }],
["path", { d: "M8 16h.01" }]
],
actions: [
["path", { d: "M13 5h8" }],
["path", { d: "M13 12h8" }],
["path", { d: "M13 19h8" }],
["path", { d: "m3 17 2 2 4-4" }],
["path", { d: "m3 7 2 2 4-4" }]
]
};
function setContent(badge, kind, value) {
badge.textContent = String(value);
const namespace = "http://www.w3.org/2000/svg";
const icon = environment.document.createElementNS(namespace, "svg");
for (const [name, attribute] of Object.entries({
class: "opportunities-extension-badge-icon", viewBox: "0 0 24 24",
width: "16", height: "16", fill: "none", stroke: "currentColor",
"stroke-width": "2.25", "stroke-linecap": "round", "stroke-linejoin": "round",
"aria-hidden": "true", focusable: "false", "data-badge-icon": kind
})) icon.setAttribute(name, attribute);
for (const [tag, attributes] of shapes[kind]) {
const shape = environment.document.createElementNS(namespace, tag);
for (const [name, attribute] of Object.entries(attributes)) shape.setAttribute(name, attribute);
icon.append(shape);
}
badge.append(icon);
}
return { setContent };
}

View File

@@ -0,0 +1,14 @@
function createOpportunitiesColumns({ formatOracleResponseDate, formatRevenue, formatWinProbability, getNestedValue, getOwnerName }) {
return [
{ key: "name", label: "Name", value: (item) => item.Name, display: (item) => item.Name },
{ key: "optyNumber", label: "Opty Number", value: (item) => item.OptyNumber, display: (item) => item.OptyNumber },
{ key: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]) },
{ key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName },
{ key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue },
{ key: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) },
{ key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode },
{ key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
{ key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c },
{ key: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }
];
}

View File

@@ -0,0 +1,153 @@
function createOpportunitiesController({
STAGE_OPTIONS,
STATUS_OPTIONS,
authSessionState,
consumptionPlansState,
consumptionTrendState,
getFiscalPeriodRange,
opportunitiesDashboardState,
opportunitiesFilterState,
opportunitiesQueryState,
opportunitiesTableState,
preferencesState,
productsContentState,
renderProductsProgress,
requestOpportunitiesPage,
resetCrmServiceRequests,
resetWorkloadActions,
requestOpptyProductsInBackground,
requestTokenRelayOnce,
ensureCurrentUser,
setOpportunitiesTableState
}) {
async function requestOpportunitiesForPeriod(period) {
const dateRange = getFiscalPeriodRange(period, new Date());
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
await ensureCurrentUser();
if (!isCurrentPeriodRequest(requestVersion)) return;
if (preferencesState.selectedOpportunityTypeValues.size === 0) {
setOpportunitiesTableState({
items: [],
status: "empty",
message: "Select at least one Opportunity Type View in Preferences."
});
return;
}
if (!dateRange) {
setOpportunitiesTableState({
items: [],
status: "empty",
message: "No opportunities to display for this period."
});
return;
}
try {
const token = authSessionState.accessToken || await requestTokenRelayOnce();
if (requestVersion !== opportunitiesQueryState.periodRequestVersion) {
return;
}
await requestOpportunities(token, period, dateRange, requestVersion);
} catch (error) {
// Authentication and request errors are recorded by their respective request handlers.
}
}
async function requestOpportunities(token, period, dateRange, requestVersion) {
if (isCurrentPeriodRequest(requestVersion)) {
productsContentState.currentProductsRequestId = "";
productsContentState.opptyProductsByNumber = new Map();
productsContentState.expandedProductOptyNumbers = new Set();
productsContentState.productsProgress = {
state: "idle",
completed: 0,
total: 0,
failed: 0,
cacheHits: 0
};
consumptionPlansState.accountPlansRequestState = new Map();
consumptionTrendState.consumptionTrendCache = new Map();
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
renderProductsProgress();
setOpportunitiesTableState({
items: [],
status: "loading",
message: "Loading opportunities..."
});
}
try {
if (!isCurrentPeriodRequest(requestVersion)) return;
const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1);
if (allItems && isCurrentPeriodRequest(requestVersion)) {
setOpportunitiesTableState({
items: allItems,
status: "ready",
message: ""
});
requestOpptyProductsInBackground(allItems, token);
}
} catch (error) {
if (isCurrentPeriodRequest(requestVersion)) {
setOpportunitiesTableState({
items: [],
status: "error",
message: "Unable to load opportunities."
});
}
throw error;
}
}
function resetOpportunitiesTable() {
resetWorkloadActions();
resetCrmServiceRequests();
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);
opportunitiesFilterState.selectedCustomerKeys = new Set();
opportunitiesFilterState.customerSearch = "";
opportunitiesFilterState.selectedOwnerKeys = new Set();
opportunitiesFilterState.ownerSearch = "";
opportunitiesFilterState.tableSearch = "";
opportunitiesFilterState.hasSavedCustomerFilter = false;
opportunitiesFilterState.hasSavedOwnerFilter = false;
opportunitiesFilterState.customerFilterInitialized = false;
opportunitiesFilterState.ownerFilterInitialized = false;
productsContentState.currentProductsRequestId = "";
productsContentState.opptyProductsByNumber = new Map();
productsContentState.expandedProductOptyNumbers = new Set();
productsContentState.productsProgress = {
state: "idle",
completed: 0,
total: 0,
failed: 0,
cacheHits: 0
};
productsContentState.productsLastCachedAt = 0;
consumptionPlansState.accountPlansRequestState = new Map();
consumptionTrendState.consumptionTrendCache = new Map();
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
opportunitiesDashboardState.stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]);
opportunitiesTableState.opportunitiesTableState = {
items: [],
status: "idle",
message: "",
sortKey: "",
sortDirection: "ascending"
};
}
function isCurrentPeriodRequest(requestVersion) {
return requestVersion === opportunitiesQueryState.periodRequestVersion;
}
return { requestOpportunitiesForPeriod, requestOpportunities, resetOpportunitiesTable, isCurrentPeriodRequest };
}

View File

@@ -0,0 +1,96 @@
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, environment }) {
let status = "idle";
let byOpportunity = new Map();
let pending = null;
let generation = 0;
let loadedItems = [];
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); loadedItems = []; reportProgress("idle"); }
function load(token, version, cacheOptions = null) {
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
if (status === "ready") {
return Promise.resolve(cacheOptions ? activityCache.write("sr", "all", cacheOptions.cachedAt, loadedItems) : undefined).then(() => true);
}
if (!pending) {
const request = run(token, version, generation, cacheOptions);
pending = request;
request.finally(() => { if (pending === request) pending = null; });
}
return pending;
}
async function run(token, version, currentGeneration, cacheOptions) {
const isActive = () => currentGeneration === generation;
status = "loading";
try {
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
if (!isActive()) return false;
if (!cached && cacheOptions?.cacheOnly) { status = "idle"; return false; }
let items = cached?.items;
if (!items) {
reportProgress("running", 0);
items = await requestCrmServiceRequest(token, version, isActive, (count, total) => {
if (isActive()) reportProgress("running", count, total);
});
if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items);
}
if (!items || !isActive()) return false;
const grouped = new Map();
for (const item of items) {
const number = item?.OpportunityName_Id_c?.OptyNumber;
if (!number) continue;
const key = String(number);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(item);
}
byOpportunity = grouped;
loadedItems = items;
status = "ready";
if (!cached) reportProgress("complete", items.length, items.length);
return true;
} catch {
if (isActive()) { status = "error"; reportProgress("error"); }
return false;
}
}
function getSummary(optyNumber) {
if (!allowsCrmActivities() || !optyNumber) return null;
const items = byOpportunity.get(String(optyNumber)) || [];
const hours = items.map(item => item.ReportedHours_c).filter(value =>
(typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isFinite(Number(value))
).map(Number);
return {
state: status === "ready" ? items.length ? "matched" : "missing" : status,
reportedHours: hours.length === items.length ? hours.reduce((total, value) => total + value, 0) : null,
hasUnderFourHours: status === "ready" && hours.some(value => value < 4)
};
}
function createBadge(optyNumber) {
if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null;
const items = byOpportunity.get(String(optyNumber)) || [];
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span");
badge.className = "opportunities-extension-crm-sr-badge";
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing");
const summary = getSummary(optyNumber);
const reportedHours = summary.reportedHours !== null
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
: "N/D";
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/D" ? "" : "h"}` : "0h");
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
const descriptions = items.map(item => [
`Title: ${display(item.Title)}`,
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
`Reported Hours: ${display(item.ReportedHours_c)}`,
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/D"}`,
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
].join("\n"));
const description = status === "error" ? "Unable to check CRM service requests. See Debug Requests."
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
: items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
badge.setAttribute("aria-label", description);
badge.title = "Whether a Service Request has been created for the opportunities.";
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
badge.tabIndex = 0;
return badge;
}
return { reset, load, createBadge, getSummary };
}

View File

@@ -0,0 +1,176 @@
function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, requestDetails, environment }) {
const { document, window, setTimeout, clearTimeout } = environment;
let active = null;
let sequence = 0;
function close(restoreFocus = false) {
if (!active) return;
const entry = active;
active = null;
clearTimeout(entry.timer);
entry.panel.remove();
entry.button.setAttribute("aria-expanded", "false");
document.removeEventListener("pointerdown", entry.outside, true);
document.removeEventListener("keydown", entry.keydown, true);
window.removeEventListener("resize", entry.position);
document.removeEventListener("scroll", entry.scroll, true);
if (restoreFocus && entry.button.isConnected) {
entry.suppressFocus = true;
entry.button.focus();
entry.suppressFocus = false;
}
}
function bind(button, items, descriptions) {
button.type = "button";
button.setAttribute("aria-haspopup", "dialog");
button.setAttribute("aria-expanded", "false");
const id = `opportunities-extension-sr-details-${++sequence}`;
button.setAttribute("aria-controls", id);
const entry = { button, panel: null, timer: null, pinned: false, suppressFocus: false };
const contains = target => target && (button.contains(target) || entry.panel?.contains(target));
entry.outside = event => { if (!contains(event.target)) close(); };
entry.scroll = event => { if (!entry.panel.contains(event.target)) close(); };
entry.keydown = event => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
close(true);
}
};
entry.position = () => {
const rect = button.getBoundingClientRect();
const width = Math.min(480, window.innerWidth - 24);
entry.panel.style.width = `${width}px`;
entry.panel.style.maxHeight = `${Math.min(480, window.innerHeight - 24)}px`;
const height = entry.panel.getBoundingClientRect().height;
entry.panel.style.left = `${Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))}px`;
entry.panel.style.top = `${Math.max(12, Math.min(rect.bottom + 6, window.innerHeight - height - 12))}px`;
};
function leave() {
clearTimeout(entry.timer);
if (!entry.pinned && !contains(document.activeElement)) {
entry.timer = setTimeout(() => { if (active === entry) close(); }, 200);
}
}
function focusOut(event) { if (!contains(event.relatedTarget) && active === entry) close(); }
function open() {
if (entry.suppressFocus) return;
clearTimeout(entry.timer);
if (active === entry) return;
close();
const host = document.getElementById(MODAL_ID);
if (!host) return;
const panel = document.createElement("div");
entry.panel = panel;
entry.pinned = false;
panel.id = id;
panel.className = "opportunities-extension-sr-popover";
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-label", "CRM service requests");
const dismiss = document.createElement("button");
dismiss.type = "button";
dismiss.className = "opportunities-extension-sr-dismiss";
dismiss.title = "Close service request details";
dismiss.setAttribute("aria-label", dismiss.title);
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => close(true));
panel.append(dismiss);
const detailLoads = [];
items.forEach((item, index) => {
const section = document.createElement("section");
const heading = document.createElement("div");
heading.className = "opportunities-extension-sr-number";
const number = item.SrNumber;
const link = document.createElement(number ? "a" : "span");
link.textContent = number ? String(number) : "N/D";
if (number) {
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(number))}`;
link.target = "_blank";
link.rel = "noopener noreferrer";
}
const label = document.createElement("span");
label.textContent = "SR Number:";
heading.append(label, link);
if (number) heading.append(createCopyButton(String(number), "SR number", "SR Number"));
const details = document.createElement("div");
details.className = "opportunities-extension-sr-details";
details.textContent = descriptions[index];
section.append(heading, details);
const children = document.createElement("div");
children.className = "opportunities-extension-sr-children";
children.setAttribute("aria-busy", "true");
children.setAttribute("role", "status");
children.textContent = "Loading SR details...";
section.append(children);
detailLoads.push(async () => {
try {
const records = await requestDetails(item.SrId);
if (active !== entry || entry.panel !== panel) return;
children.textContent = "";
if (!records.length) children.textContent = "No further details.";
for (const record of records) {
const child = document.createElement("section");
const row = document.createElement("div");
row.className = "opportunities-extension-sr-number";
const label = document.createElement("span");
label.textContent = "Internal SR Number:";
const link = document.createElement(record.SrNumber ? "a" : "span");
link.textContent = record.SrNumber ? String(record.SrNumber) : "N/D";
if (record.SrNumber) {
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(record.SrNumber))}&srId=${encodeURIComponent(String(record.SrId ?? ""))}`;
link.target = "_blank";
link.rel = "noopener noreferrer";
}
row.append(label, link);
if (record.SrNumber) row.append(createCopyButton(String(record.SrNumber), "SR number", "SR Number"));
const body = document.createElement("div");
body.className = "opportunities-extension-sr-details";
body.textContent = ["Title", "OpportunityName_c", "StatusCd", "ServiceNew_c", "AccountPartyName",
"LastUpdateDate", "SvcHealth_c", "AssigneePersonName", "OpenDate"].map(key =>
`${key === "ServiceNew_c" ? "Service Queue" : key}: ${record[key] == null || record[key] === "" ? "N/D" : String(record[key])}`).join("\n");
child.append(row, body);
children.append(child);
}
} catch (error) {
if (active !== entry || entry.panel !== panel) return;
children.setAttribute("role", "alert");
children.textContent = error.message || "Unable to load SR details.";
} finally {
if (active === entry && entry.panel === panel) {
children.setAttribute("aria-busy", "false");
entry.position();
}
}
});
panel.append(section);
});
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));
panel.addEventListener("pointerleave", leave);
panel.addEventListener("focusout", focusOut);
host.append(panel);
active = entry;
detailLoads.forEach(load => load());
button.setAttribute("aria-expanded", "true");
entry.position();
document.addEventListener("pointerdown", entry.outside, true);
document.addEventListener("keydown", entry.keydown, true);
document.addEventListener("scroll", entry.scroll, true);
window.addEventListener("resize", entry.position);
}
button.addEventListener("pointerenter", open);
button.addEventListener("pointerleave", leave);
button.addEventListener("focus", open);
button.addEventListener("focusout", focusOut);
button.addEventListener("click", event => {
event.stopPropagation();
if (active === entry && entry.pinned) close();
else {
open();
if (active === entry) {
entry.pinned = true;
entry.panel.querySelector("a, button").focus();
}
}
});
}
return { bind, close };
}

View File

@@ -0,0 +1,24 @@
.opportunities-extension-account-plans-dialog.opportunities-extension-install-base-dialog,
.opportunities-extension-account-plans-dialog.opportunities-extension-service-requests-dialog {
grid-template-rows: auto auto minmax(0, 1fr);
}
.opportunities-extension-customer-table-toolbar { padding: 12px 16px; border-bottom: 1px solid #dedbd7; }
.opportunities-extension-customer-table-search {
box-sizing: border-box;
width: min(420px, 100%);
min-width: 0;
height: 38px;
padding: 8px 12px;
border: 1px solid #8f8a85;
border-radius: 4px;
background: #fff;
color: #312d2a;
font: inherit;
font-size: 13px;
}
.opportunities-extension-customer-table-search::placeholder { color: #5f5a55; opacity: 1; }
.opportunities-extension-customer-table-search:focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-table-toolbar { border-color: #484541; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-table-search { background: #353331; color: #f6f4f2; border-color: #8f8a85; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-table-search::placeholder { color: #c9c5c1; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-table-search:focus-visible { outline-color: #59d1e5; }

View File

@@ -0,0 +1,24 @@
function createCustomerTableSearch({ environment }) {
function createSearch({ label, onSearch, getValues }) {
const input = environment.document.createElement("input");
input.type = "search";
input.className = "opportunities-extension-customer-table-search";
input.placeholder = label;
input.setAttribute("aria-label", label);
input.addEventListener("input", onSearch);
const normalize = value => String(value ?? "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
let index = new WeakMap();
return { input,
reset() { index = new WeakMap(); },
filter(items) {
const terms = normalize(input.value).trim().split(/\s+/).filter(Boolean);
if (!terms.length) return items;
return items.filter(item => {
if (!index.has(item)) index.set(item, getValues(item).map(normalize));
return terms.every(term => index.get(item).some(value => value.includes(term)));
});
}
};
}
return { createSearch };
}

View File

@@ -0,0 +1,5 @@
function createOpportunitiesDashboardState({ STAGE_OPTIONS } = {}) {
return {
stageDashboardAmounts: new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])])
};
}

View File

@@ -0,0 +1,119 @@
function createOpportunitiesDashboard({
STAGE_DASHBOARD_ID,
STAGE_OPTIONS,
getNestedValue,
opportunitiesDashboardState,
environment
}) {
const { document, requestAnimationFrame, performance } = environment;
function renderStageDashboard(items, totalCount = items.length) {
const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
if (!dashboard) {
return;
}
dashboard.textContent = "";
const totalCard = document.createElement("section");
const totalLabel = document.createElement("span");
const totalValue = document.createElement("strong");
totalCard.className = "opportunities-extension-stage-dashboard-card opportunities-extension-total-dashboard-card";
totalCard.setAttribute("data-stage", "TOTAL");
totalLabel.textContent = "Total Opportunities";
totalValue.className = "opportunities-extension-stage-dashboard-value";
totalValue.setAttribute("aria-label", `${items.length} filtered opportunities out of ${totalCount} total`);
animateStageDashboardValue(
totalValue,
"TOTAL",
items.length,
(amount) => `${Math.round(amount).toLocaleString()}/${totalCount.toLocaleString()}`
);
totalCard.append(totalLabel, totalValue);
dashboard.append(totalCard);
STAGE_OPTIONS.forEach((stage) => {
const stageItems = items.filter((item) => item.ForecastGroup_c === stage);
const amount = stageItems.reduce((total, item) => {
const value = Number(getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]));
return Number.isFinite(value) ? total + value : total;
}, 0);
const card = document.createElement("section");
const label = document.createElement("span");
const value = document.createElement("strong");
card.className = "opportunities-extension-stage-dashboard-card";
card.setAttribute("data-stage", stage);
label.textContent = stage;
value.className = "opportunities-extension-stage-dashboard-value";
value.setAttribute("aria-label", `${stage} total amount`);
animateStageDashboardValue(
value,
stage,
amount,
createStageAmountFormatter(stageItems)
);
card.append(label, value);
dashboard.append(card);
});
}
function createStageAmountFormatter(items) {
const currencies = new Set(items
.map((item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]))
.filter(Boolean));
if (currencies.size === 1) {
const [currency] = currencies;
try {
const formatter = new Intl.NumberFormat(undefined, {
style: "currency",
currency,
maximumFractionDigits: 0
});
return (amount) => formatter.format(amount);
} catch (error) {
// Fall through to a neutral numeric total when the currency code is invalid.
}
}
return (amount) => amount.toLocaleString(undefined, {
maximumFractionDigits: 0
});
}
function animateStageDashboardValue(element, stage, target, format) {
const current = opportunitiesDashboardState.stageDashboardAmounts.get(stage) || 0;
opportunitiesDashboardState.stageDashboardAmounts.set(stage, target);
if (current === target) {
element.textContent = format(target);
return;
}
const startTime = performance.now();
const duration = 480;
function tick(now) {
const progress = Math.min((now - startTime) / duration, 1);
const easedProgress = 1 - Math.pow(1 - progress, 3);
const value = current + ((target - current) * easedProgress);
element.textContent = format(value);
if (progress < 1) {
requestAnimationFrame(tick);
} else {
element.textContent = format(target);
}
}
requestAnimationFrame(tick);
}
return { renderStageDashboard, createStageAmountFormatter, animateStageDashboardValue };
}

View File

@@ -0,0 +1,15 @@
function createOpportunitiesFilterState({ STAGE_OPTIONS, STATUS_OPTIONS } = {}) {
return {
selectedStages: new Set(STAGE_OPTIONS),
selectedStatuses: new Set(STATUS_OPTIONS),
selectedCustomerKeys: new Set(),
customerSearch: "",
selectedOwnerKeys: new Set(),
ownerSearch: "",
tableSearch: "",
hasSavedCustomerFilter: false,
hasSavedOwnerFilter: false,
customerFilterInitialized: false,
ownerFilterInitialized: false
};
}

View File

@@ -0,0 +1,408 @@
function createOpportunitiesFilters({
CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID,
CUSTOMER_FILTER_PANEL_ID,
CUSTOMER_FILTER_SEARCH_ID,
CUSTOMER_FILTER_SELECT_ALL_ID,
OPPORTUNITIES_COLUMNS,
OWNER_FILTER_BUTTON_ID,
OWNER_FILTER_LIST_ID,
OWNER_FILTER_PANEL_ID,
OWNER_FILTER_SEARCH_ID,
OWNER_FILTER_SELECT_ALL_ID,
STAGE_FILTERS_ID,
STAGE_OPTIONS,
STATUS_FILTERS_ID,
STATUS_OPTIONS,
getCustomerFilterSummary,
getCustomerKey,
getCustomerOptions,
getOwnerKey,
getOwnerOptions,
opportunitiesFilterState,
opportunitiesTableState,
renderOpportunitiesTable,
saveFilterPreferences,
stringifySearchValue,
matchesActivities,
environment
}) {
const { document } = environment;
function resetCustomerAndOwnerFiltersForPeriod() {
opportunitiesFilterState.selectedCustomerKeys = new Set();
opportunitiesFilterState.selectedOwnerKeys = new Set();
opportunitiesFilterState.customerSearch = "";
opportunitiesFilterState.ownerSearch = "";
opportunitiesFilterState.hasSavedCustomerFilter = false;
opportunitiesFilterState.hasSavedOwnerFilter = false;
opportunitiesFilterState.customerFilterInitialized = false;
opportunitiesFilterState.ownerFilterInitialized = false;
}
function renderStageFilterButtons() {
const controls = document.getElementById(STAGE_FILTERS_ID);
if (!controls) {
return;
}
controls.textContent = "";
STAGE_OPTIONS.forEach((stage) => {
const button = document.createElement("button");
const isPressed = opportunitiesFilterState.selectedStages.has(stage);
button.type = "button";
button.className = "opportunities-extension-stage-filter-button";
button.setAttribute("data-stage", stage);
button.setAttribute("aria-pressed", isPressed ? "true" : "false");
button.textContent = stage;
button.addEventListener("click", () => toggleStageFilter(stage));
controls.append(button);
});
}
function toggleStageFilter(stage) {
if (opportunitiesFilterState.selectedStages.has(stage)) {
opportunitiesFilterState.selectedStages.delete(stage);
} else {
opportunitiesFilterState.selectedStages.add(stage);
}
saveFilterPreferences();
renderStageFilterButtons();
renderOpportunitiesTable();
}
function renderStatusFilterButtons() {
const controls = document.getElementById(STATUS_FILTERS_ID);
if (!controls) {
return;
}
controls.textContent = "";
STATUS_OPTIONS.forEach((status) => {
const button = document.createElement("button");
const isPressed = opportunitiesFilterState.selectedStatuses.has(status);
button.type = "button";
button.className = "opportunities-extension-status-filter-button";
button.setAttribute("data-status", status);
button.setAttribute("aria-pressed", isPressed ? "true" : "false");
button.textContent = status;
button.addEventListener("click", () => toggleStatusFilter(status));
controls.append(button);
});
}
function toggleStatusFilter(status) {
if (opportunitiesFilterState.selectedStatuses.has(status)) {
opportunitiesFilterState.selectedStatuses.delete(status);
} else {
opportunitiesFilterState.selectedStatuses.add(status);
}
saveFilterPreferences();
renderStatusFilterButtons();
renderOpportunitiesTable();
}
function toggleCustomerFilterPanel() {
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
if (!panel || !button || button.disabled) {
return;
}
panel.hidden = !panel.hidden;
button.setAttribute("aria-expanded", panel.hidden ? "false" : "true");
if (!panel.hidden) {
const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID);
searchInput.focus();
}
}
function closeCustomerFilterPanel(restoreFocus = true) {
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
if (!panel || panel.hidden) {
return false;
}
panel.hidden = true;
button.setAttribute("aria-expanded", "false");
if (restoreFocus) {
button.focus();
}
return true;
}
function toggleOwnerFilterPanel() {
const panel = document.getElementById(OWNER_FILTER_PANEL_ID);
const button = document.getElementById(OWNER_FILTER_BUTTON_ID);
if (!panel || !button || button.disabled) {
return;
}
panel.hidden = !panel.hidden;
button.setAttribute("aria-expanded", panel.hidden ? "false" : "true");
if (!panel.hidden) {
const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID);
searchInput.focus();
}
}
function closeOwnerFilterPanel(restoreFocus = true) {
const panel = document.getElementById(OWNER_FILTER_PANEL_ID);
const button = document.getElementById(OWNER_FILTER_BUTTON_ID);
if (!panel || panel.hidden) {
return false;
}
panel.hidden = true;
button.setAttribute("aria-expanded", "false");
if (restoreFocus) {
button.focus();
}
return true;
}
function resetCustomerFilter(items) {
const customerOptions = getCustomerOptions(items);
if (!opportunitiesFilterState.customerFilterInitialized) {
opportunitiesFilterState.selectedCustomerKeys = new Set(customerOptions.map((customer) => customer.key));
opportunitiesFilterState.customerFilterInitialized = true;
}
}
function resetOwnerFilter(items) {
const ownerOptions = getOwnerOptions(items);
if (!opportunitiesFilterState.ownerFilterInitialized) {
opportunitiesFilterState.selectedOwnerKeys = new Set(ownerOptions.map((owner) => owner.key));
opportunitiesFilterState.ownerFilterInitialized = true;
}
}
function renderCustomerFilter() {
const trigger = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID);
const selectAllCheckbox = document.getElementById(CUSTOMER_FILTER_SELECT_ALL_ID);
const customerOptions = getCustomerOptions();
if (!trigger || !searchInput || !selectAllCheckbox) {
return;
}
const selectedCount = customerOptions.filter((customer) => opportunitiesFilterState.selectedCustomerKeys.has(customer.key)).length;
trigger.disabled = customerOptions.length === 0;
trigger.textContent = getCustomerFilterSummary(customerOptions.length, selectedCount);
searchInput.value = opportunitiesFilterState.customerSearch;
selectAllCheckbox.disabled = customerOptions.length === 0;
selectAllCheckbox.checked = customerOptions.length > 0 && selectedCount === customerOptions.length;
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < customerOptions.length;
renderCustomerFilterList();
}
function renderCustomerFilterList() {
const list = document.getElementById(CUSTOMER_FILTER_LIST_ID);
if (!list) {
return;
}
const normalizedSearch = opportunitiesFilterState.customerSearch.trim().toLocaleLowerCase();
const customerOptions = getCustomerOptions().filter((customer) => {
return customer.label.toLocaleLowerCase().includes(normalizedSearch);
});
list.textContent = "";
if (customerOptions.length === 0) {
const empty = document.createElement("p");
empty.className = "opportunities-extension-customer-filter-empty";
empty.textContent = opportunitiesFilterState.customerSearch ? "No matching customers." : "No customers available.";
list.append(empty);
return;
}
customerOptions.forEach((customer) => {
const option = document.createElement("label");
option.className = "opportunities-extension-customer-filter-option";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key);
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
opportunitiesFilterState.selectedCustomerKeys.add(customer.key);
} else {
opportunitiesFilterState.selectedCustomerKeys.delete(customer.key);
}
opportunitiesFilterState.hasSavedCustomerFilter = true;
saveFilterPreferences();
renderCustomerFilter();
renderOpportunitiesTable();
});
const label = document.createElement("span");
label.textContent = customer.label;
option.append(checkbox, label);
list.append(option);
});
}
function renderOwnerFilter() {
const trigger = document.getElementById(OWNER_FILTER_BUTTON_ID);
const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID);
const selectAllCheckbox = document.getElementById(OWNER_FILTER_SELECT_ALL_ID);
const ownerOptions = getOwnerOptions();
if (!trigger || !searchInput || !selectAllCheckbox) {
return;
}
const selectedCount = ownerOptions.filter((owner) => opportunitiesFilterState.selectedOwnerKeys.has(owner.key)).length;
trigger.disabled = ownerOptions.length === 0;
trigger.textContent = getCustomerFilterSummary(ownerOptions.length, selectedCount)
.replace("customer", "owner");
searchInput.value = opportunitiesFilterState.ownerSearch;
selectAllCheckbox.disabled = ownerOptions.length === 0;
selectAllCheckbox.checked = ownerOptions.length > 0 && selectedCount === ownerOptions.length;
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < ownerOptions.length;
renderOwnerFilterList();
}
function renderOwnerFilterList() {
const list = document.getElementById(OWNER_FILTER_LIST_ID);
if (!list) {
return;
}
const normalizedSearch = opportunitiesFilterState.ownerSearch.trim().toLocaleLowerCase();
const ownerOptions = getOwnerOptions().filter((owner) => {
return owner.label.toLocaleLowerCase().includes(normalizedSearch);
});
list.textContent = "";
if (ownerOptions.length === 0) {
const empty = document.createElement("p");
empty.className = "opportunities-extension-customer-filter-empty";
empty.textContent = opportunitiesFilterState.ownerSearch ? "No matching owners." : "No owners available.";
list.append(empty);
return;
}
ownerOptions.forEach((owner) => {
const option = document.createElement("label");
option.className = "opportunities-extension-customer-filter-option";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key);
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
opportunitiesFilterState.selectedOwnerKeys.add(owner.key);
} else {
opportunitiesFilterState.selectedOwnerKeys.delete(owner.key);
}
opportunitiesFilterState.hasSavedOwnerFilter = true;
saveFilterPreferences();
renderOwnerFilter();
renderOpportunitiesTable();
});
const label = document.createElement("span");
label.textContent = owner.label;
option.append(checkbox, label);
list.append(option);
});
}
function getSortedOpportunities() {
const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase();
const allStages = STAGE_OPTIONS.every(stage => opportunitiesFilterState.selectedStages.has(stage));
const allCustomers = getCustomerOptions().every(option => opportunitiesFilterState.selectedCustomerKeys.has(option.key));
const allOwners = getOwnerOptions().every(option => opportunitiesFilterState.selectedOwnerKeys.has(option.key));
const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => {
return (allStages || opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c))
&& matchesSelectedStatus(item.StatusCode)
&& (allCustomers || opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)))
&& (allOwners || opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)))
&& matchesTableSearch(item, normalizedSearch)
&& matchesActivities(item);
});
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);
if (!column) {
return items;
}
const direction = opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending" ? 1 : -1;
return items.sort((first, second) => {
const firstValue = column.value(first);
const secondValue = column.value(second);
const firstIsEmpty = firstValue === null || firstValue === undefined || firstValue === "";
const secondIsEmpty = secondValue === null || secondValue === undefined || secondValue === "";
if (firstIsEmpty || secondIsEmpty) {
if (firstIsEmpty && secondIsEmpty) {
return 0;
}
return firstIsEmpty ? 1 : -1;
}
const firstNumber = Number(firstValue);
const secondNumber = Number(secondValue);
if (Number.isFinite(firstNumber) && Number.isFinite(secondNumber)) {
return (firstNumber - secondNumber) * direction;
}
return String(firstValue).localeCompare(String(secondValue), undefined, {
numeric: true,
sensitivity: "base"
}) * direction;
});
}
function matchesTableSearch(item, normalizedSearch) {
if (!normalizedSearch) {
return true;
}
return OPPORTUNITIES_COLUMNS.some((column) => {
const rawValue = stringifySearchValue(column.value(item));
const displayValue = stringifySearchValue(column.display(item));
return `${rawValue} ${displayValue}`.toLocaleLowerCase().includes(normalizedSearch);
});
}
function matchesSelectedStatus(statusCode) {
if (STATUS_OPTIONS.every(status => opportunitiesFilterState.selectedStatuses.has(status))) return true;
if (statusCode === "WON_PENDING") {
return opportunitiesFilterState.selectedStatuses.has("WON");
}
return opportunitiesFilterState.selectedStatuses.has(statusCode);
}
return { resetCustomerAndOwnerFiltersForPeriod, renderStageFilterButtons, toggleStageFilter, renderStatusFilterButtons, toggleStatusFilter, toggleCustomerFilterPanel, closeCustomerFilterPanel, toggleOwnerFilterPanel, closeOwnerFilterPanel, resetCustomerFilter, resetOwnerFilter, renderCustomerFilter, renderCustomerFilterList, renderOwnerFilter, renderOwnerFilterList, getSortedOpportunities, matchesTableSearch, matchesSelectedStatus };
}

View File

@@ -0,0 +1,55 @@
function createOpportunitiesFiscalPeriods() {
function getFiscalPeriodRange(period, currentDate) {
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth();
const fiscalStartYear = currentMonth >= 5 ? currentYear : currentYear - 1;
const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3);
if (period === "Current Fiscal Year") {
const isBeforeFiscalYearStart = currentMonth < 5;
const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear;
const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1;
return createDateRange(startYear, 5, endYear, 4);
}
let quarterOffset;
let numberOfQuarters = 1;
if (period === "Current Quarter") {
quarterOffset = currentQuarterIndex;
} else if (period === "Next Quarter") {
quarterOffset = currentQuarterIndex + 1;
} else if (period === "Previous Quarter") {
quarterOffset = currentQuarterIndex - 1;
} else if (period === "4 Rolling Quarters (CQ + 3)") {
quarterOffset = currentQuarterIndex;
numberOfQuarters = 4;
} else {
return null;
}
const start = new Date(fiscalStartYear, 5 + (quarterOffset * 3), 1);
const end = new Date(start.getFullYear(), start.getMonth() + (numberOfQuarters * 3), 0);
return {
startCloseDate: formatOracleDate(start),
endCloseDate: formatOracleDate(end)
};
}
function createDateRange(startYear, startMonth, endYear, endMonth) {
return {
startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)),
endCloseDate: formatOracleDate(new Date(endYear, endMonth + 1, 0))
};
}
function formatOracleDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}T00:00:00.000Z`;
}
return { getFiscalPeriodRange, createDateRange, formatOracleDate };
}

View File

@@ -0,0 +1,176 @@
function createOpportunitiesFormatters({
opportunitiesTableState
}) {
function getCustomerOptions(items) {
const optionsByKey = new Map();
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
const key = getCustomerKey(item);
const label = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]);
if (key && label && !optionsByKey.has(key)) {
optionsByKey.set(key, { key, label: String(label) });
}
});
return Array.from(optionsByKey.values()).sort((first, second) => {
return first.label.localeCompare(second.label, undefined, {
sensitivity: "base"
});
});
}
function getCustomerKey(item) {
const partyId = getNestedValue(item, ["CustomerAccount", "PartyId"]);
const partyName = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]);
if (partyId !== null && partyId !== undefined && partyId !== "") {
return `party:${partyId}`;
}
return partyName ? `name:${partyName}` : "";
}
function getOwnerName(item) {
const owner = item.Owner;
if (typeof owner === "string" || typeof owner === "number") {
return owner;
}
if (owner && typeof owner === "object") {
const firstName = owner.PersonFirstName;
const lastName = owner.PersonLastName;
if (firstName || lastName) {
return [firstName, lastName].filter(Boolean).join(" ");
}
return owner.PartyName
|| owner.PartyUniqueName
|| owner.Name
|| owner.DisplayName
|| owner.ResourceName
|| owner.OwnerName
|| "";
}
return "";
}
function getCustomerFilterSummary(total, selected) {
if (total === 0) {
return "No customers";
}
if (selected === total) {
return "All customers";
}
if (selected === 0) {
return "No customers";
}
return `${selected} customer${selected === 1 ? "" : "s"}`;
}
function getOwnerOptions(items) {
const optionsByKey = new Map();
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
const key = getOwnerKey(item);
const label = String(getOwnerName(item)).trim() || "-";
if (key && label && !optionsByKey.has(key)) {
optionsByKey.set(key, { key, label: String(label) });
}
});
return Array.from(optionsByKey.values()).sort((first, second) => {
return first.label.localeCompare(second.label, undefined, {
sensitivity: "base"
});
});
}
function getOwnerKey(item) {
const owner = item.Owner;
const partyId = owner && typeof owner === "object" ? owner.PartyId : "";
const name = String(getOwnerName(item)).trim();
if (!name) return "owner:unassigned";
if (partyId !== null && partyId !== undefined && partyId !== "") {
return `party:${partyId}`;
}
return name ? `name:${name}` : "";
}
function stringifySearchValue(value) {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "object") {
return JSON.stringify(value);
}
return String(value);
}
function displayOpportunityValue(column, item) {
const value = column.display(item);
return value === null || value === undefined || value === "" ? "-" : String(value);
}
function getNestedValue(value, path) {
return path.reduce((result, key) => result && result[key], value);
}
function formatWinProbability(item) {
const value = getNestedValue(item, ["PrimaryRevenue", "WinProb"]);
const number = Number(value);
return Number.isFinite(number) ? `${number}%` : value;
}
function formatRevenue(item) {
const amount = getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]);
const currency = getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]);
const number = Number(amount);
if (!Number.isFinite(number)) {
return amount;
}
if (!currency) {
return number.toLocaleString();
}
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency
}).format(number);
} catch (error) {
return `${currency} ${number.toLocaleString()}`;
}
}
function formatOracleResponseDate(value, includeTime) {
if (typeof value !== "string") {
return value;
}
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/);
if (!match) {
return value;
}
const date = `${match[3]}/${match[2]}/${match[1]}`;
return includeTime && match[4] ? `${date} ${match[4]}:${match[5]}` : date;
}
return { getCustomerOptions, getCustomerKey, getOwnerName, getCustomerFilterSummary, getOwnerOptions, getOwnerKey, stringifySearchValue, displayOpportunityValue, getNestedValue, formatWinProbability, formatRevenue, formatOracleResponseDate };
}

View File

@@ -0,0 +1,118 @@
function createOpportunitiesLinks({
MODAL_ID,
environment
}) {
const { document, navigator, window } = environment;
function createDetailLink(label, href) {
if (label === null || label === undefined || label === "") {
const placeholder = document.createElement("span");
placeholder.textContent = "-";
return placeholder;
}
if (!href) {
const text = document.createElement("span");
text.textContent = String(label);
return text;
}
const link = document.createElement("a");
link.href = href;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = String(label);
return link;
}
function createCopyOpportunityButton(optyNumber, label = "opportunity number", confirmationLabel = "Opty Number") {
const button = document.createElement("button");
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
button.type = "button";
button.className = "opportunities-extension-copy-button";
button.setAttribute("aria-label", `Copy ${label} ${optyNumber}`);
button.title = `Copy ${label}`;
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
copyOpportunityNumber(String(optyNumber), label, confirmationLabel);
});
icon.setAttribute("viewBox", "0 0 24 24");
icon.setAttribute("aria-hidden", "true");
path.setAttribute("d", "M8 8V5.5A2.5 2.5 0 0 1 10.5 3h8A2.5 2.5 0 0 1 21 5.5v8a2.5 2.5 0 0 1-2.5 2.5H16v2.5a2.5 2.5 0 0 1-2.5 2.5h-8A2.5 2.5 0 0 1 3 18.5v-8A2.5 2.5 0 0 1 5.5 8H8zm2 0h3.5A2.5 2.5 0 0 1 16 10.5v3.5h2.5a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5V8zm3.5 2h-8a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5z");
icon.append(path);
button.append(icon);
return button;
}
async function copyOpportunityNumber(optyNumber, label = "opportunity number", confirmationLabel = "Opty Number") {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(optyNumber);
} else {
const input = document.createElement("textarea");
input.value = optyNumber;
input.setAttribute("readonly", "true");
input.style.position = "fixed";
input.style.opacity = "0";
document.body.append(input);
input.select();
document.execCommand("copy");
input.remove();
}
showCopyConfirmation(`${confirmationLabel} ${optyNumber} copied.`);
} catch (error) {
showCopyConfirmation(`Unable to copy ${label}.`, true);
}
}
function showCopyConfirmation(message, isError = false) {
const modal = document.getElementById(MODAL_ID);
if (!modal) {
return;
}
let toast = modal.querySelector(".opportunities-extension-copy-toast");
if (!toast) {
toast = document.createElement("div");
toast.className = "opportunities-extension-copy-toast";
toast.setAttribute("role", "status");
modal.append(toast);
}
toast.classList.toggle("opportunities-extension-copy-toast-error", isError);
toast.textContent = message;
toast.hidden = false;
window.clearTimeout(toast.copyTimeout);
toast.copyTimeout = window.setTimeout(() => {
toast.hidden = true;
}, 2400);
}
function createStageBadge(stage) {
const badge = document.createElement("span");
const normalizedStage = typeof stage === "string" ? stage.toUpperCase() : "";
badge.className = "opportunities-extension-stage-badge";
badge.setAttribute("data-stage", normalizedStage);
badge.textContent = normalizedStage || "-";
return badge;
}
function createStatusBadge(status) {
const badge = document.createElement("span");
const normalizedStatus = typeof status === "string" ? status.toUpperCase() : "";
badge.className = "opportunities-extension-status-badge";
badge.setAttribute("data-status", normalizedStatus);
badge.textContent = normalizedStatus || "-";
return badge;
}
return { createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge };
}

View File

@@ -0,0 +1,42 @@
function createProductActivities({ productsContentState, opportunitiesQueryState,
loadCrm, loadActions, blockActions, render }) {
let cycle = null;
function begin(requestId, token) {
cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false };
}
function complete(requestId) {
const current = cycle;
if (!current || current.completed || current.requestId !== requestId
|| productsContentState.currentProductsRequestId !== requestId) return;
current.completed = true;
const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error);
if (!products.length) return;
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId
&& opportunitiesQueryState.periodRequestVersion === current.version;
return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })).then(async success => {
if (!isActive()) return;
if (success === false) {
products.forEach(blockActions);
render();
return;
}
const pending = [];
const seen = new Set();
for (const result of products) {
const items = result.items.filter(item => {
const id = String(item?.RevnId ?? "");
if (seen.has(id)) return false;
seen.add(id);
return true;
});
pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true }));
}
await Promise.allSettled(pending);
if (!isActive()) return;
if (cachedAt > 0) productsContentState.productsLastCachedAt = cachedAt;
render();
}).catch(() => { if (isActive()) { products.forEach(blockActions); render(); } });
}
return { begin, complete };
}

View File

@@ -0,0 +1,5 @@
function createOpportunitiesQueryState() {
return {
periodRequestVersion: 0
};
}

View File

@@ -0,0 +1,49 @@
function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, resetRelatedData,
opportunitiesQueryState, productsContentState, consumptionPlansState, consumptionTrendState,
aiSummaryState, reload, onFailure, notify, createRefreshIcon, environment }) {
let pending = null;
function refresh(period) {
if (pending) return pending;
opportunitiesQueryState.periodRequestVersion += 1;
productsContentState.currentProductsRequestId = "";
productsContentState.opptyProductsByNumber = new Map();
productsContentState.expandedProductOptyNumbers = new Set();
productsContentState.productsProgress = { state: "idle", completed: 0, total: 0, failed: 0, cacheHits: 0 };
consumptionPlansState.accountPlansRequestState = new Map();
consumptionTrendState.consumptionTrendCache = new Map();
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
aiSummaryState.aiSummariesByOptyNumber = new Map();
resetRelatedData();
pending = (async () => {
await activityCache.invalidate();
const response = await sendRuntimeMessage({ type: messageTypes.invalidateProductsCache });
if (!response?.ok) throw new Error(response?.error || "Unable to clear product cache.");
await reload(period);
})().catch(error => { onFailure(error); throw error; }).finally(() => { pending = null; });
return pending;
}
function createButton(getPeriod) {
const button = environment.document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-icon-button opportunities-extension-refresh-all";
button.append(createRefreshIcon());
button.title = "Refresh all";
button.setAttribute("aria-label", "Refresh all");
button.addEventListener("click", async () => {
if (button.disabled) return;
button.disabled = true;
button.title = "Refreshing...";
button.setAttribute("aria-busy", "true");
try { await refresh(getPeriod()); }
catch (error) { notify(error.message || "Unable to refresh cached data.", true); }
finally {
button.disabled = false;
button.title = "Refresh all";
button.setAttribute("aria-busy", "false");
}
});
return button;
}
return { refresh, createButton };
}

View File

@@ -0,0 +1,587 @@
/* Compact filters use available CSS width, including Windows display scaling. */
.opportunities-extension-products-progress,
.opportunities-extension-auth-badge { opacity: 1; transition: opacity 300ms ease; }
.opportunities-extension-products-progress[data-fading="true"],
.opportunities-extension-auth-badge[data-fading="true"] { opacity: 0; }
.opportunities-extension-auth-badge[hidden] { display: none; }
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-products-progress,
.opportunities-extension-auth-badge { transition: none; }
}
.opportunities-extension-crm-sr-badge {
border: 1px solid #408254;
color: #185b30;
background: #e1f1e5;
}
.opportunities-extension-crm-sr-badge[data-state="missing"] { color: #9f201b; background: #fde8e7; border-color: #c74640; }
.opportunities-extension-crm-sr-badge[data-state="error"] { color: #785000; background: #fff0cd; border-color: #987021; }
.opportunities-extension-wl-actions-badge[data-state="loading"] { color: #5f5a55; background: #eceae8; border-color: #8f8a85; }
.opportunities-extension-crm-sr-badge:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge { color: #b8e4c6; background: #214d34; border-color: #6eac82; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge[data-state="missing"] { color: #ffd9d5; background: #542824; border-color: #ef938b; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge[data-state="error"] { color: #ffe0a3; background: #584219; border-color: #c99b4b; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-wl-actions-badge[data-state="loading"] { color: #dedbd7; background: #484541; border-color: #8f8a85; }
.opportunities-extension-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)) max-content max-content;
align-items: end;
gap: 12px;
min-height: 0;
padding: 12px;
}
.opportunities-extension-form > *,
.opportunities-extension-badge-filters > section {
min-width: 0;
width: 100%;
max-width: none;
grid-template-rows: auto 32px;
gap: 4px;
font-size: 12px;
}
.opportunities-extension-form > * > span:first-child,
.opportunities-extension-badge-filters > section > span:first-child {
font-size: 12px;
line-height: 16px;
}
.opportunities-extension-form .opportunities-extension-select-wrap {
width: 100%;
}
.opportunities-extension-form .opportunities-extension-field select,
.opportunities-extension-form .opportunities-extension-customer-filter-trigger,
.opportunities-extension-form .opportunities-extension-table-search-field input {
box-sizing: border-box;
width: 100%;
min-width: 0;
height: 32px;
min-height: 32px;
padding: 4px 8px;
font-size: 12px;
line-height: 20px;
}
.opportunities-extension-form .opportunities-extension-field select,
.opportunities-extension-form .opportunities-extension-customer-filter-trigger {
padding-right: 26px;
}
.opportunities-extension-form .opportunities-extension-select-wrap::after,
.opportunities-extension-form .opportunities-extension-customer-filter-trigger::after {
right: 10px;
border-left-width: 4px;
border-right-width: 4px;
border-top-width: 5px;
}
.opportunities-extension-form .opportunities-extension-stage-filter-controls,
.opportunities-extension-form .opportunities-extension-status-filter-controls {
flex-wrap: nowrap;
gap: 4px;
}
.opportunities-extension-form .opportunities-extension-stage-filter-button,
.opportunities-extension-form .opportunities-extension-status-filter-button,
.opportunities-extension-form .opportunities-extension-activity-filter-button {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 28px;
min-height: 28px;
padding: 4px 7px;
border: 1px solid currentColor;
border-radius: 18px;
font-family: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
line-height: 18px;
white-space: nowrap;
}
.opportunities-extension-cell-actions > .opportunities-extension-products-count,
.opportunities-extension-cell-actions > .opportunities-extension-products-ramp,
.opportunities-extension-cell-actions > .opportunities-extension-crm-sr-badge {
display: inline-flex;
align-items: center;
justify-content: flex-start;
box-sizing: border-box;
flex: 0 0 auto;
gap: 4px;
height: 20px;
min-height: 20px;
margin: 0;
padding: 0 6px;
border-width: 1px;
border-style: solid;
border-radius: 8px;
font-family: inherit;
font-size: 10px;
font-style: normal;
font-weight: 600;
line-height: 16px;
letter-spacing: 0;
text-align: center;
vertical-align: middle;
white-space: nowrap;
}
.opportunities-extension-cell-actions .opportunities-extension-badge-icon,
.opportunities-extension-activity-filter-button .opportunities-extension-badge-icon {
display: block;
order: -1;
flex: 0 0 16px;
width: 16px;
height: 16px;
margin: 0;
padding: 0;
border: 0;
color: inherit;
fill: none;
stroke: currentColor;
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
@media (max-width: 1150px) {
.opportunities-extension-form {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
grid-column: span 2;
}
}
@media (max-width: 700px) {
.opportunities-extension-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.opportunities-extension-form .opportunities-extension-stage-filter-controls,
.opportunities-extension-form .opportunities-extension-status-filter-controls {
flex-wrap: wrap;
}
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
grid-template-rows: auto auto;
}
}
@media (max-width: 420px) {
.opportunities-extension-form { grid-template-columns: minmax(0, 1fr); }
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter { grid-column: auto; }
}
.opportunities-extension-badge-filters { display: contents; }
.opportunities-extension-activity-filter { display: grid; }
.opportunities-extension-activity-filter[hidden] { display: none; }
.opportunities-extension-activity-filter-controls { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; }
/* Flexible fields share the space left by the four compact badge groups. */
.opportunities-extension-form[data-activity-filters="true"] {
grid-template-columns: repeat(4, minmax(0, 1fr)) repeat(4, max-content);
column-gap: 8px;
}
.opportunities-extension-activity-filter-button {
min-width: 32px;
gap: 4px;
color: #185b30;
background: #fff;
cursor: pointer;
}
.opportunities-extension-activity-filter-button[data-value="missing"] { color: #9f201b; }
.opportunities-extension-activity-filter-button[data-value="under4"] { color: #005f7d; }
.opportunities-extension-activity-filter-button[aria-pressed="true"] { color: #fff; background: #25653a; border-color: #25653a; }
.opportunities-extension-activity-filter-button[aria-pressed="true"][data-value="missing"] { background: #a12d27; border-color: #a12d27; }
.opportunities-extension-activity-filter-button[aria-pressed="true"][data-value="under4"] { background: #006b86; border-color: #006b86; }
.opportunities-extension-activity-filter-button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter { color: #eeeae6; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button { color: #b8e4c6; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button:not([aria-pressed="true"]) { background: #333130; border-color: #6a6560; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="missing"] { color: #ffd9d5; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="under4"] { color: #b5e9fc; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[aria-pressed="true"] { border-color: currentColor; }
@media (max-width: 1350px) {
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.opportunities-extension-form[data-activity-filters="true"] .opportunities-extension-badge-filters {
display: flex;
grid-column: 1 / -1;
flex-wrap: wrap;
align-items: start;
gap: 12px 20px;
}
.opportunities-extension-form[data-activity-filters="true"] .opportunities-extension-badge-filters > section {
width: auto;
max-width: 100%;
grid-template-rows: auto auto;
}
}
@media (max-width: 700px) {
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.opportunities-extension-activity-filter-controls { flex-wrap: wrap; }
}
@media (max-width: 420px) {
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: minmax(0, 1fr); }
}
.opportunities-extension-add-action {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
flex: 0 0 16px;
width: 16px;
height: 16px;
padding: 0;
margin: 0 0 0 2px;
border: 0;
border-radius: 3px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.opportunities-extension-add-action:hover { background: rgba(127, 127, 127, .2); }
.opportunities-extension-add-action:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-action-form-modal {
position: absolute;
inset: 0;
z-index: 5;
display: grid;
place-items: center;
padding: 12px;
background: rgba(0, 0, 0, .42);
}
.opportunities-extension-action-form-dialog {
box-sizing: border-box;
width: min(560px, 100%);
max-height: calc(100vh - 24px);
overflow-y: auto;
background: #fff;
color: #312d2a;
border: 1px solid #b9b4ae;
border-radius: 6px;
box-shadow: 0 12px 36px rgba(0, 0, 0, .25);
}
.opportunities-extension-action-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
padding: 18px;
}
.opportunities-extension-action-form-heading { min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-action-form-context {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 6px 12px;
margin: 12px 0 0;
font-size: 12px;
line-height: 18px;
}
.opportunities-extension-action-form-context dt { font-weight: 600; }
.opportunities-extension-action-form-context dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-action-form-field { display: grid; gap: 6px; min-width: 0; font-size: 13px; }
.opportunities-extension-action-form-field[hidden] { display: none; }
.opportunities-extension-action-form-status { margin: 0; font-size: 13px; overflow-wrap: anywhere; }
.opportunities-extension-action-form-status[role="alert"] { color: #a12d27; }
.opportunities-extension-action-form-wide,
.opportunities-extension-action-form-footer { grid-column: 1 / -1; }
.opportunities-extension-action-form-field input,
.opportunities-extension-action-form-field select,
.opportunities-extension-action-form-field textarea {
box-sizing: border-box;
min-width: 0;
width: 100%;
border: 1px solid #8f8983;
border-radius: 4px;
padding: 8px;
font: inherit;
line-height: 20px;
color: inherit;
background: #fff;
}
.opportunities-extension-action-form-field textarea { resize: vertical; min-height: 96px; }
.opportunities-extension-action-form-field select { height: 38px; }
.opportunities-extension-action-form-field select[hidden] { display: none; }
.opportunities-extension-action-form [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form [aria-invalid="true"] { border-color: #ffaaa4; outline-color: #ffaaa4; }
.opportunities-extension-modal .opportunities-extension-copy-toast { z-index: 20; }
.opportunities-extension-action-combobox { position: relative; min-width: 0; }
.opportunities-extension-action-combobox input { height: 38px; }
.opportunities-extension-action-options {
position: absolute;
z-index: 10;
top: calc(100% + 4px);
left: 0;
right: 0;
box-sizing: border-box;
max-height: 180px;
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid #8f8983;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, .2);
background: #fff;
color: #292725;
}
.opportunities-extension-action-options[data-placement="above"] { top: auto; bottom: calc(100% + 4px); }
.opportunities-extension-action-options[hidden] { display: none; }
.opportunities-extension-action-option { padding: 8px; line-height: 20px; overflow-wrap: anywhere; cursor: pointer; }
.opportunities-extension-action-option:hover,
.opportunities-extension-action-option[aria-selected="true"] { background: #e3f2f5; color: #005b70; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-options { background: #211f1d; color: #eeeae6; border-color: #918a82; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-option:hover,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-option[aria-selected="true"] { background: #174955; color: #c9f4ff; }
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-combobox input:disabled { animation: opportunities-action-options-loading 1s ease-in-out infinite alternate; }
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-form-field > :disabled {
animation: opportunities-action-options-loading 1s ease-in-out infinite alternate;
}
@keyframes opportunities-action-options-loading { from { opacity: .45; } to { opacity: .85; } }
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-form-field > :disabled,
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-combobox input:disabled { animation: none; }
}
.opportunities-extension-action-form-footer { display: flex; justify-content: flex-end; gap: 8px; }
.opportunities-extension-action-form-footer button {
border: 1px solid #8f8983;
border-radius: 4px;
padding: 7px 14px;
font: inherit;
font-size: 13px;
background: transparent;
color: inherit;
cursor: pointer;
}
.opportunities-extension-action-form-footer button:disabled { opacity: .55; cursor: not-allowed; }
.opportunities-extension-action-form :focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-dialog {
color-scheme: dark;
background: #292725;
color: #eeeae6;
border-color: #756f68;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field input,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field select,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field textarea { background: #211f1d; border-color: #918a82; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-status[role="alert"] { color: #ffd9d5; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form :focus-visible { outline-color: #80d6e8; }
@media (max-width: 420px) {
.opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); }
}
/* Keep layout rules on the outer table, never on expanded product tables. */
.opportunities-extension-table[data-opportunities-layout="inline-actions"] {
width: 100%;
min-width: 1120px;
table-layout: fixed;
}
/* Fixed table layout shares leftover width only between the two auto columns. */
.opportunities-extension-table > thead > tr > th[data-column="name"] { width: auto; }
.opportunities-extension-table > thead > tr > th[data-column="customer"] { width: auto; }
.opportunities-extension-table > thead > tr > th[data-column="owner"] { width: 132px; }
.opportunities-extension-table > thead > tr > th[data-column="optyNumber"] { width: 106px; }
.opportunities-extension-table > thead > tr > th[data-column="revenue"] { width: 124px; }
.opportunities-extension-table > thead > tr > th[data-column="closeDate"] { width: 100px; }
.opportunities-extension-table > thead > tr > th[data-column="status"] { width: 114px; }
.opportunities-extension-table > thead > tr > th[data-column="winProbability"] { width: 110px; }
.opportunities-extension-table > thead > tr > th[data-column="stage"] { width: 94px; }
.opportunities-extension-table > thead > tr > th[data-column="lastUpdateDate"] { width: 136px; }
.opportunities-extension-table > tbody > tr > td[data-column] {
padding: 10px 8px;
overflow: visible;
text-overflow: clip;
white-space: normal;
font-variant-numeric: tabular-nums;
}
.opportunities-extension-table > tbody > tr > td[data-column="name"],
.opportunities-extension-table > tbody > tr > td[data-column="customer"] {
position: relative;
padding-right: 16px;
padding-left: 12px;
padding-bottom: 38px;
vertical-align: top;
}
.opportunities-extension-table > tbody > tr > td[data-column="owner"] {
padding-right: 16px;
}
.opportunities-extension-table > tbody > tr > td[data-column="closeDate"],
.opportunities-extension-table > tbody > tr > td[data-column="lastUpdateDate"],
.opportunities-extension-table > tbody > tr > td[data-column="status"],
.opportunities-extension-table > tbody > tr > td[data-column="stage"],
.opportunities-extension-table > tbody > tr > td[data-column="winProbability"] {
white-space: nowrap;
}
.opportunities-extension-cell-content {
display: block;
min-width: 0;
min-height: 2.7em;
line-height: 1.35;
}
.opportunities-extension-cell-content > :first-child {
display: block;
min-width: 0;
overflow-wrap: anywhere;
}
.opportunities-extension-cell-actions {
position: absolute;
left: 12px;
right: 16px;
bottom: 10px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
height: 24px;
}
.opportunities-extension-cell-actions .opportunities-extension-action-button {
flex: 0 0 24px;
width: 24px;
height: 24px;
padding: 2px;
}
.opportunities-extension-table > tbody > tr > td[data-column="customer"] .opportunities-extension-cell-actions {
min-width: 84px;
}
.opportunities-extension-cell-actions .opportunities-extension-action-button svg {
width: 18px;
height: 18px;
}
.opportunities-extension-cell-actions button:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.opportunities-extension-products-ramp {
min-width: 0;
max-width: 100%;
border: 1px solid #bba5cf;
background: #f0e8f7;
color: #63358a;
overflow: hidden;
text-overflow: ellipsis;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-ramp {
background: #43334f;
color: #ead5fc;
border-color: #9372ac;
}
.opportunities-extension-products-ramp[data-missing="true"] {
background: #fde8e7;
color: #9f201b;
border-color: #c74640;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-ramp[data-missing="true"] {
background: #542824;
color: #ffd9d5;
border-color: #ef938b;
}
.opportunities-extension-table > thead > tr > th[data-column] .opportunities-extension-sort-button {
display: grid;
grid-template-columns: minmax(0, 1fr) 12px;
gap: 6px;
padding: 10px 8px;
align-items: center;
}
.opportunities-extension-table > thead > tr > th[data-column="name"] .opportunities-extension-sort-button,
.opportunities-extension-table > thead > tr > th[data-column="customer"] .opportunities-extension-sort-button {
padding-left: 12px;
padding-right: 16px;
}
.opportunities-extension-sort-label {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
.opportunities-extension-table > thead > tr > th[data-column] .opportunities-extension-sort-button::after {
position: static;
justify-self: end;
}
.opportunities-extension-crm-sr-badge[type="button"] { cursor: pointer; font-family: inherit; }
.opportunities-extension-sr-popover {
position: fixed; z-index: 8; box-sizing: border-box; overflow: auto;
padding: 12px; border: 1px solid #b9b5b0; border-radius: 6px;
background: #fff; color: #302e2b; box-shadow: 0 6px 24px #0003;
font: 12px/1.5 Arial, sans-serif; text-align: left; overflow-wrap: anywhere;
}
.opportunities-extension-sr-popover section { padding: 8px 0; }
.opportunities-extension-sr-popover section + section { border-top: 1px solid #dedbd7; }
.opportunities-extension-sr-number { display: flex; align-items: center; gap: 8px; padding-right: 30px; font-weight: 600; }
.opportunities-extension-sr-popover a { color: #00677d; text-decoration: underline; }
.opportunities-extension-sr-details { white-space: pre-wrap; margin-top: 6px; }
.opportunities-extension-sr-children { margin: 10px 0 0 8px; padding-left: 10px; border-left: 2px solid #8c969a; overflow-wrap: anywhere; }
.opportunities-extension-sr-children[aria-busy="true"] { min-height: 28px; animation: opportunities-action-options-loading 1s ease-in-out infinite alternate; }
.opportunities-extension-sr-children[role="alert"] { color: #a12d27; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-children[role="alert"] { color: #ffd9d5; }
@media (prefers-reduced-motion: reduce) { .opportunities-extension-sr-children[aria-busy="true"] { animation: none; } }
.opportunities-extension-sr-dismiss {
position: absolute; top: 6px; right: 6px; width: 28px; height: 28px;
display: grid; place-items: center; padding: 4px; border: 0;
background: transparent; color: inherit; cursor: pointer;
}
.opportunities-extension-sr-dismiss svg { width: 16px; height: 16px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover { background: #292827; color: #f1efec; border-color: #77716b; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover section + section { border-color: #5f5953; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover a { color: #7ddced; }
.opportunities-extension-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
min-width: 0;
flex: 1;
}
.opportunities-extension-title-row h1 {
display: block;
margin: 0;
padding: 0;
line-height: 1;
}
.opportunities-extension-title-row .opportunities-extension-subtitle-row {
display: flex;
align-items: center;
align-self: center;
flex-wrap: wrap;
gap: 8px;
margin: 0;
padding: 0;
min-width: 0;
}
.opportunities-extension-title-row .opportunities-extension-subtitle-row > span {
box-sizing: border-box;
margin: 0;
min-height: 22px;
line-height: 16px;
}
.opportunities-extension-title-row + .opportunities-extension-header-actions { flex-shrink: 0; }
.opportunities-extension-refresh-all:disabled { opacity: .65; cursor: wait; }
.opportunities-extension-refresh-all:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: opportunities-refresh-spin 1s linear infinite; }
@keyframes opportunities-refresh-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: none; }
}

View File

@@ -0,0 +1,11 @@
function createOpportunitiesTableState() {
return {
opportunitiesTableState: {
items: [],
status: "idle",
message: "",
sortKey: "",
sortDirection: "ascending"
}
};
}

View File

@@ -0,0 +1,199 @@
function createOpportunitiesTable({
ACCOUNT_DETAIL_URL,
OPPORTUNITIES_COLUMNS,
OPPORTUNITIES_TABLE_ID,
OPPORTUNITY_DETAIL_URL,
createCloudConsumptionButton,
createInstallBaseButton,
createServiceRequestsButton,
createCopyOpportunityButton,
createDetailLink,
createProductsCountButton,
createProductsRampBadge,
createCrmServiceRequestBadge,
closeCrmSrPopover,
createWorkloadActionsBadge,
createProductsDetailRow,
createStageBadge,
createStatusBadge,
displayOpportunityValue,
getNestedValue,
getSortedOpportunities,
opportunitiesTableState,
productsContentState,
renderCustomerFilter,
renderOwnerFilter,
renderActivityFilters,
renderStageDashboard,
resetCustomerFilter,
resetOwnerFilter,
environment
}) {
const { document } = environment;
function setOpportunitiesTableState(nextState) {
if (nextState.status === "ready" && Array.isArray(nextState.items)) {
resetCustomerFilter(nextState.items);
resetOwnerFilter(nextState.items);
}
opportunitiesTableState.opportunitiesTableState = {
...opportunitiesTableState.opportunitiesTableState,
...nextState,
sortKey: nextState.items ? "" : opportunitiesTableState.opportunitiesTableState.sortKey,
sortDirection: nextState.items ? "ascending" : opportunitiesTableState.opportunitiesTableState.sortDirection
};
renderOpportunitiesTable();
renderCustomerFilter();
renderOwnerFilter();
}
function renderOpportunitiesTable() {
renderActivityFilters();
const table = document.getElementById(OPPORTUNITIES_TABLE_ID);
if (!table) {
return;
}
closeCrmSrPopover();
table.textContent = "";
table.setAttribute("data-opportunities-layout", "inline-actions");
table.setAttribute("aria-busy", opportunitiesTableState.opportunitiesTableState.status === "loading" ? "true" : "false");
const caption = document.createElement("caption");
caption.className = "opportunities-extension-visually-hidden";
caption.textContent = "Opportunities";
const tableHead = document.createElement("thead");
const headerRow = document.createElement("tr");
OPPORTUNITIES_COLUMNS.forEach((column) => {
const header = document.createElement("th");
const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key;
header.scope = "col";
header.setAttribute("data-column", column.key);
if (column.sortable === false) {
const label = document.createElement("span");
label.className = "opportunities-extension-table-header-label";
label.textContent = column.label;
header.append(label);
} else {
header.setAttribute("aria-sort", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
const sortButton = document.createElement("button");
sortButton.type = "button";
sortButton.className = "opportunities-extension-sort-button";
sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`);
const sortLabel = document.createElement("span");
sortLabel.className = "opportunities-extension-sort-label";
sortLabel.textContent = column.label;
sortButton.append(sortLabel);
sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key));
header.append(sortButton);
}
headerRow.append(header);
});
tableHead.append(headerRow);
const tableBody = document.createElement("tbody");
const items = getSortedOpportunities();
renderStageDashboard(items, opportunitiesTableState.opportunitiesTableState.items.length);
if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.className = "opportunities-extension-table-status";
cell.colSpan = OPPORTUNITIES_COLUMNS.length;
cell.textContent = opportunitiesTableState.opportunitiesTableState.message || "No opportunities found.";
row.append(cell);
tableBody.append(row);
} else {
items.forEach((item) => {
const row = document.createElement("tr");
OPPORTUNITIES_COLUMNS.forEach((column) => {
const cell = document.createElement("td");
cell.setAttribute("data-column", column.key);
if (column.key === "stage") {
cell.append(createStageBadge(column.display(item)));
} else if (column.key === "status") {
cell.append(createStatusBadge(column.display(item)));
} else if (column.key === "name" || column.key === "optyNumber") {
const opportunityLink = createDetailLink(
column.display(item),
item.OptyNumber ? `${OPPORTUNITY_DETAIL_URL}${encodeURIComponent(item.OptyNumber)}` : ""
);
if (column.key === "name") {
const nameCellContent = document.createElement("span");
nameCellContent.className = "opportunities-extension-cell-content";
const productActions = document.createElement("span");
productActions.className = "opportunities-extension-cell-actions";
const productsResult = item.OptyNumber && productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
if (productsResult) {
productActions.append(createProductsCountButton(item.OptyNumber, productsResult));
const rampBadge = createProductsRampBadge(productsResult);
if (rampBadge) productActions.append(rampBadge);
}
const srBadge = createCrmServiceRequestBadge(item.OptyNumber);
if (srBadge) productActions.append(srBadge);
const actionsBadge = createWorkloadActionsBadge(item.OptyNumber);
if (actionsBadge) productActions.append(actionsBadge);
nameCellContent.append(opportunityLink, productActions);
cell.append(nameCellContent);
} else if (column.key === "optyNumber" && item.OptyNumber) {
const opportunityCellContent = document.createElement("span");
opportunityCellContent.className = "opportunities-extension-opty-number-content";
opportunityCellContent.append(opportunityLink, createCopyOpportunityButton(item.OptyNumber));
cell.append(opportunityCellContent);
} else {
cell.append(opportunityLink);
}
} else if (column.key === "customer") {
const customerCellContent = document.createElement("span");
customerCellContent.className = "opportunities-extension-cell-content";
const customerLink = createDetailLink(
column.display(item),
getNestedValue(item, ["CustomerAccount", "PartyId"])
? `${ACCOUNT_DETAIL_URL}${encodeURIComponent(getNestedValue(item, ["CustomerAccount", "PartyId"]))}`
: ""
);
const customerActions = document.createElement("span");
customerActions.className = "opportunities-extension-cell-actions";
customerActions.append(createCloudConsumptionButton(item), createInstallBaseButton(item), createServiceRequestsButton(item));
customerCellContent.append(customerLink, customerActions);
cell.append(customerCellContent);
} else {
cell.textContent = displayOpportunityValue(column, item);
}
row.append(cell);
});
tableBody.append(row);
if (item.OptyNumber && productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber))) {
tableBody.append(createProductsDetailRow(item));
}
});
}
table.append(caption, tableHead, tableBody);
}
function sortOpportunitiesBy(key) {
const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key;
opportunitiesTableState.opportunitiesTableState.sortKey = key;
opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending"
? "descending"
: "ascending";
renderOpportunitiesTable();
}
return { setOpportunitiesTableState, renderOpportunitiesTable, sortOpportunitiesBy };
}

View File

@@ -0,0 +1,407 @@
function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, getProducts,
requestTeamMembers, requestLookup, getCurrentUserId, saveAction, onActionSaved, notify, formatDate, createCloseIcon, environment }) {
const { document } = environment;
let active = null;
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function searchableSelect(select, field, onChange) {
const box = element("div", "opportunities-extension-action-combobox");
const input = element("input");
input.type = "text";
input.autocomplete = "off";
input.required = !!select.required;
input.setAttribute("aria-required", String(!!select.required));
select.addEventListener("invalid", event => { event.preventDefault(); input.focus(); });
input.id = `${select.id}-search`;
field.htmlFor = input.id;
const list = element("div", "opportunities-extension-action-options");
list.id = `${select.id}-options`;
list.setAttribute("role", "listbox");
input.setAttribute("role", "combobox");
input.setAttribute("aria-autocomplete", "list");
input.setAttribute("aria-controls", list.id);
input.setAttribute("aria-expanded", "false");
select.hidden = true;
select.tabIndex = -1;
box.append(input, list);
field.append(box);
let matches = [], index = -1;
const options = () => Array.from(select.children).filter(item => item.value);
const normalize = value => String(value).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
function closeList() {
list.hidden = true;
input.setAttribute("aria-expanded", "false");
input.setAttribute("aria-activedescendant", "");
}
function sync() {
closeList();
input.disabled = !!select.disabled;
input.placeholder = select.children[0]?.textContent || "Select";
input.value = options().find(item => item.value === select.value)?.textContent || "";
input.title = input.value;
}
function highlight() {
Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index)));
input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : "");
list.children[index]?.scrollIntoView?.({ block: "nearest" });
}
function choose(i) {
if (!matches[i]) return;
const changed = select.value !== matches[i].value;
select.value = matches[i].value;
sync();
if (changed) onChange?.();
}
function positionList() {
const bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
const anchor = input.getBoundingClientRect?.();
if (!bounds || !anchor) return;
const below = Math.max(0, bounds.bottom - anchor.bottom - 8);
const above = Math.max(0, anchor.top - bounds.top - 8);
const opensAbove = below < 180 && above > below;
list.setAttribute("data-placement", opensAbove ? "above" : "below");
list.style.maxHeight = `${Math.min(180, opensAbove ? above : below)}px`;
}
function render(query = "") {
if (input.disabled) return;
matches = options().filter(item => normalize(`${item.textContent} ${item.value}`).includes(normalize(query)));
index = -1;
list.textContent = "";
matches.forEach((item, i) => {
const row = element("div", "opportunities-extension-action-option", item.textContent);
row.id = `${list.id}-${i}`;
row.setAttribute("role", "option");
row.setAttribute("aria-selected", "false");
row.addEventListener("mousedown", event => event.preventDefault());
row.addEventListener("click", event => { event.preventDefault(); choose(i); });
list.append(row);
});
if (!matches.length) {
const empty = element("div", "opportunities-extension-action-option", "No results");
empty.setAttribute("role", "status");
list.append(empty);
}
positionList();
list.hidden = false;
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-activedescendant", "");
}
input.addEventListener("click", () => { render(); input.select?.(); });
input.addEventListener("input", () => render(input.value));
input.addEventListener("blur", sync);
input.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (list.hidden) render();
if (matches.length) index = (index + (event.key === "ArrowDown" ? 1 : index < 0 ? 0 : -1) + matches.length) % matches.length;
highlight();
} else if (event.key === "Enter" && !list.hidden) {
event.preventDefault();
choose(index < 0 && matches.length === 1 ? 0 : index);
} else if (event.key === "Escape" && !list.hidden) {
event.preventDefault();
event.stopPropagation();
sync();
} else if (event.key === "Tab") sync();
});
sync();
return { input, sync };
}
function close(restoreFocus = true) {
if (!active) return false;
if (active.saving) return true;
const previous = active;
active = null;
previous.modal.remove();
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function createButton(optyNumber) {
const button = element("button", "opportunities-extension-add-action", "+");
button.type = "button";
button.title = "Add Action";
button.setAttribute("aria-label", `Add Action for ${optyNumber}`);
button.setAttribute("aria-haspopup", "dialog");
button.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
open(optyNumber, button);
});
return button;
}
function open(optyNumber, trigger) {
if (!allowsCrmActivities() || !isMissingAction(optyNumber)) return;
const host = document.getElementById(MODAL_ID);
if (!host || host.hidden) return;
close(false);
const modal = element("section", "opportunities-extension-action-form-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-action-form-title");
const dialog = element("div", "opportunities-extension-action-form-dialog");
const header = element("header", "opportunities-extension-account-plans-header");
const heading = element("div", "opportunities-extension-action-form-heading");
const title = element("h2", "", "Add Action");
title.id = "opportunities-extension-action-form-title";
heading.append(title, element("p", "", String(optyNumber)));
const opportunity = getOpportunity(optyNumber);
const details = element("dl", "opportunities-extension-action-form-context");
for (const [label, value] of [
["Customer", opportunity?.CustomerAccount?.PartyUniqueName],
["Name", opportunity?.Name],
["Close Date", formatDate(opportunity?.EffectiveDate)]
]) {
details.append(element("dt", "", label), element("dd", "", value == null || value === "" ? "-" : String(value)));
}
heading.append(details);
const dismiss = element("button", "opportunities-extension-icon-button");
dismiss.type = "button";
dismiss.title = "Close";
dismiss.setAttribute("aria-label", "Close Action form");
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => close());
header.append(heading, dismiss);
const form = element("form", "opportunities-extension-action-form");
form.noValidate = true;
const workloads = [...new Map(getProducts(optyNumber).filter(product => /^\d+$/.test(String(product?.RevnId ?? "")))
.map(product => [String(product.RevnId), product])).entries()];
const workloadField = element("label", "opportunities-extension-action-form-field opportunities-extension-action-form-wide");
const workload = element("select");
workload.id = "opportunities-extension-action-form-workload";
workloadField.htmlFor = workload.id;
function option(select, value, label) {
const item = element("option", "", label);
item.value = value;
select.append(item);
}
option(workload, "", "Select workload");
for (const [id, product] of workloads) option(workload, id, `${product.WorkloadName_c || product.ProdGroupName || "Workload"} (${id})`);
workload.value = workloads.length === 1 ? workloads[0][0] : "";
workloadField.hidden = workloads.length <= 1;
workloadField.append(element("span", "", "Workload"), workload);
const status = element("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
status.setAttribute("role", "status");
form.append(workloadField, status);
const searchable = new Map();
searchable.set(workload, searchableSelect(workload, workloadField, () => loadOptions()));
const fields = [];
const selects = [];
const lookupKeys = { role: "WWB_ACTION_ROLE", action: "WWB_ACTION_NAME", team: "WWB_ACTION_TEAM",
status: "WWB_ACTION_STATUS", complexity: "WWB_ACTION_COMPLEXITY" };
for (const [name, label, type] of [
["owner", "Owner", "select"], ["role", "Role", "select"], ["action", "Action", "select"],
["team", "Team", "select"], ["startDate", "Start date", "date"],
["endDate", "End Date", "date"], ["status", "Status", "select"],
["complexity", "Complexity", "select"], ["notes", "Notes", "textarea"]
]) {
const field = element("label", "opportunities-extension-action-form-field");
const input = element(type === "select" || type === "textarea" ? type : "input");
if (type === "select") selects.push({ input, label, name });
else if (type === "textarea") input.rows = 5;
else input.type = type;
input.disabled = true;
input.required = name !== "notes";
input.setAttribute("aria-required", String(input.required));
input.name = name;
input.id = `opportunities-extension-action-form-${name}`;
field.htmlFor = input.id;
if (["notes", "team", "action"].includes(name)) field.className += " opportunities-extension-action-form-wide";
field.append(element("span", "", `${label} (${input.required ? "required" : "optional"})`), input);
if (type === "select") searchable.set(input, searchableSelect(input, field));
form.append(field);
fields.push(input);
}
const footer = element("footer", "opportunities-extension-action-form-footer");
const save = element("button", "opportunities-extension-action-form-save", "Save");
save.type = "submit";
save.disabled = true;
save.title = "Save Action";
const retry = element("button", "", "Retry");
retry.type = "button";
retry.hidden = true;
const cancel = element("button", "", "Cancel");
cancel.type = "button";
cancel.addEventListener("click", () => close());
footer.append(retry, save, cancel);
form.append(footer);
dialog.append(header, form);
modal.append(dialog);
host.append(modal);
const view = { modal, trigger, generation: 0, lookup: null, loadingId: null };
active = view;
const isOpen = () => active === view && allowsCrmActivities();
let ready = false;
function validate() {
const invalid = [];
const value = name => String(fields.find(field => field.name === name)?.value || "").trim();
for (const field of fields) {
let valid = !field.required || !!value(field.name);
if (field.required && searchable.has(field)) valid = Array.from(field.children).some(option => option.value && option.value === field.value);
if (field.type === "date") {
const date = value(field.name);
valid = /^\d{4}-\d{2}-\d{2}$/.test(date) && Number.isFinite(Date.parse(`${date}T00:00:00.000Z`));
if (valid) valid = new Date(`${date}T00:00:00.000Z`).toISOString().slice(0, 10) === date;
if (field.name === "endDate" && value("startDate") > date) valid = false;
}
const control = searchable.get(field)?.input || field;
control.setAttribute("aria-invalid", String(!valid));
if (!valid) invalid.push(control);
}
return { invalid, value };
}
form.addEventListener("submit", async event => {
event.preventDefault();
if (!ready || !isOpen() || view.saving) return;
const { invalid, value } = validate();
if (invalid.length) {
status.hidden = false;
status.setAttribute("role", "alert");
status.textContent = "Complete the highlighted required fields. End date must be on or after start date.";
invalid[0].focus();
return;
}
const userId = getCurrentUserId();
if (!userId) { notify("Unable to save: current user is unavailable.", true); return; }
const payload = { parentType: "WORKLOAD", parentId: workload.value, action: {
ownerEmail: value("owner"), role: value("role"), name: value("action"), team: value("team"), tags: "",
startDate: `${value("startDate")}T00:00:00.000Z`, endDate: `${value("endDate")}T00:00:00.000Z`,
status: value("status"), complexity: value("complexity"), notes: value("notes") ? [value("notes")] : [],
objective: "", createdBy: userId, updatedBy: userId
} };
view.saving = true;
save.disabled = cancel.disabled = dismiss.disabled = workload.disabled = true;
fields.forEach(field => { field.disabled = true; });
searchable.forEach(control => control.sync());
save.textContent = "Saving...";
form.setAttribute("aria-busy", "true");
status.hidden = true;
try {
const result = await saveAction(payload, isOpen);
if (!result) return;
view.saving = false;
close();
try {
await onActionSaved(optyNumber, payload.parentId, result.result, payload.action);
} catch {
notify("Action saved, but the local badge could not be updated.", true);
return;
}
notify("Action saved successfully.");
} catch (error) {
status.hidden = false;
status.setAttribute("role", "alert");
status.textContent = error.message || "Unable to save Action.";
notify(status.textContent, true);
} finally {
view.saving = false;
if (active === view) {
save.disabled = cancel.disabled = dismiss.disabled = workload.disabled = false;
fields.forEach(field => { field.disabled = false; });
searchable.forEach(control => control.sync());
save.textContent = "Save";
form.setAttribute("aria-busy", "false");
}
}
});
const settled = promise => promise.then(result => ({ result }), error => ({ error }));
async function loadOptions(refresh = false) {
const id = workload.value;
if (!isOpen() || (view.loadingId === id && !refresh)) return;
const generation = ++view.generation;
const isCurrent = () => isOpen() && view.generation === generation;
ready = false;
save.disabled = true;
retry.hidden = true;
status.hidden = false;
status.setAttribute("role", "status");
fields.forEach(input => { input.disabled = true; });
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Loading..."); });
const syncSearch = () => selects.forEach(({ input }) => searchable.get(input).sync());
syncSearch();
if (!workloads.length) {
form.setAttribute("data-state", "error");
form.setAttribute("aria-busy", "false");
status.setAttribute("role", "alert");
status.textContent = "No product with a valid RevnId is available for this opportunity.";
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Unavailable"); });
syncSearch();
return;
}
if (!view.lookup || refresh) view.lookup = settled(requestLookup(isOpen));
if (!workloads.some(([value]) => value === id)) {
view.loadingId = null;
form.setAttribute("data-state", "waiting");
form.setAttribute("aria-busy", "false");
status.textContent = "Select a workload to load Action options.";
return;
}
view.loadingId = id;
form.setAttribute("data-state", "loading");
form.setAttribute("aria-busy", "true");
status.textContent = "Loading Action options...";
try {
const [lookup, team] = await Promise.all([view.lookup, settled(requestTeamMembers(id, isCurrent))]);
if (!isCurrent()) return;
if (lookup.error || team.error) throw lookup.error || team.error;
const values = selects.map(({ name }) => name === "owner"
? team.result.members.map(({ email }) => ({ code: email, name: email }))
: lookup.result.lookups[lookupKeys[name]]);
const defaults = { owner: getCurrentUserId(), role: "Cloud Architect",
action: "Success Plan & Consumption review", status: "Complete" };
const normalize = value => String(value ?? "").trim().toLowerCase();
for (const [index, { input, label, name }] of selects.entries()) {
input.textContent = "";
option(input, "", values[index].length ? `Select ${label.toLowerCase()}` : "No options available");
for (const item of values[index]) option(input, item.code, item.name);
const match = defaults[name] && values[index].find(item =>
normalize(name === "owner" ? item.code : item.name) === normalize(defaults[name]));
input.value = match ? match.code : "";
}
fields.forEach(input => { input.disabled = false; });
selects.forEach(({ input }, index) => { input.disabled = !values[index].length; });
form.setAttribute("data-state", "ready");
ready = true;
save.disabled = false;
status.hidden = true;
syncSearch();
if (document.activeElement === dismiss) {
const first = fields.find(field => !field.disabled);
(searchable.get(first)?.input || first)?.focus();
}
} catch (error) {
if (!isCurrent()) return;
form.setAttribute("data-state", "error");
status.setAttribute("role", "alert");
status.textContent = error.message || "Unable to load Action options.";
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Unavailable"); });
syncSearch();
retry.hidden = false;
} finally {
if (isCurrent()) { view.loadingId = null; form.setAttribute("aria-busy", "false"); }
}
}
retry.addEventListener("click", () => { if (!view.loadingId) loadOptions(true); });
workload.addEventListener("change", () => loadOptions());
modal.addEventListener("click", event => { if (event.target === modal) close(); });
modal.addEventListener("keydown", event => {
if (event.key !== "Tab") return;
const controls = [dismiss, ...(!workloadField.hidden ? [workload] : []), ...fields, retry, save, cancel]
.map(control => searchable.get(control)?.input || control)
.filter(control => !control.disabled && !control.hidden);
const index = controls.indexOf(document.activeElement);
if (!controls.length) { event.preventDefault(); return; }
if (index === -1 || (!event.shiftKey && index === controls.length - 1) || (event.shiftKey && index === 0)) {
event.preventDefault();
controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
});
if (!workloadField.hidden) searchable.get(workload).input.focus();
else dismiss.focus();
loadOptions();
}
return { createButton, open, close };
}

View File

@@ -0,0 +1,17 @@
function createWorkloadActionOptions({ requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
async function request(type, workloadId, isCurrent, payload) {
const authorizations = await requestConsumerTokenServiceOnce();
if (!isCurrent()) return null;
const authorization = authorizations?.workloadActions;
if (!authorization?.value) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
const result = await sendRuntimeMessage({ type, ...(workloadId === undefined ? {} : { workloadId }), ...(payload ? { payload } : {}), authorization });
appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load Action options.");
return result;
}
return {
save: (payload, isCurrent) => request(messageTypes.requestMgmtAction, undefined, isCurrent, payload),
requestTeamMembers: (id, isCurrent) => request(messageTypes.requestActionTeamMembers, id, isCurrent),
requestLookup: isCurrent => request(messageTypes.requestActionFindLookup, undefined, isCurrent)
};
}

View File

@@ -0,0 +1,172 @@
function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage,
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, reportProgress, activityCache, createActionButton, closeActionForm, setBadgeContent, environment }) {
let session = newSession();
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0, progress: new Set() }; }
function updateProgress(current) {
if (current !== session || !current.enabled || !current.progress.size) return;
const records = [...current.progress];
const completed = records.filter(record => record.status !== "loading").length;
const failed = records.filter(record => record.status === "error").length;
reportProgress(completed < records.length ? "running" : failed ? "complete-with-errors" : "complete", completed, records.length);
}
function reset(enabled = true) {
closeActionForm(false);
session.enabled = false;
for (const job of session.queue) job.record.resolveCompletion();
session.queue.length = 0;
session = newSession();
session.enabled = enabled;
reportProgress("idle");
}
function idsFor(products) {
return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))];
}
function loadProducts(products, refresh = false, cacheOptions = null) {
if (!session.enabled || !allowsCrmActivities()) return;
const pending = [];
if ([...session.progress].every(record => record.status !== "loading")) session.progress.clear();
for (const revnId of idsFor(products)) {
if (!refresh && session.records.has(revnId)) {
pending.push(session.records.get(revnId).completion);
continue;
}
session.progress.delete(session.records.get(revnId));
const record = { status: "loading", actions: [], hasNext: false };
record.completion = new Promise(resolve => { record.resolveCompletion = resolve; });
pending.push(record.completion);
session.records.set(revnId, record);
if (!cacheOptions) session.progress.add(record);
session.queue.push({ revnId, record, cacheOptions });
}
updateProgress(session);
pump(session);
return Promise.all(pending);
}
function reopen() {
reset();
for (const products of productsContentState.opptyProductsByNumber.values()) loadProducts(products);
queueProductsTableRender();
}
function blockProducts(products) {
if (!allowsCrmActivities()) return;
for (const id of idsFor(products)) {
session.records.set(id, { status: "error", actions: [], hasNext: false, error: "CRM service requests unavailable." });
}
}
function pump(current) {
while (current.enabled && current === session && current.running < 4 && current.queue.length) {
const job = current.queue.shift();
current.running += 1;
run(current, job).finally(() => { current.running -= 1; pump(current); });
}
}
async function run(current, { revnId, record, cacheOptions }) {
try {
const cached = cacheOptions ? await activityCache.read("actions", revnId, cacheOptions.cachedAt) : null;
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
if (cached) {
record.actions = cached.items;
record.hasNext = cached.hasNext;
record.status = "ready";
return;
}
if (cacheOptions?.cacheOnly) { record.status = "error"; record.error = "No matching activity cache for this product update."; return; }
current.progress.add(record);
updateProgress(current);
let result;
for (let attempt = 1; attempt <= 5; attempt += 1) {
try {
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
const authorizations = await requestConsumerTokenServiceOnce();
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
appendBackgroundRequestLog(result?.debugRequests);
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
break;
} catch (error) {
if (attempt === 5) throw error;
}
}
record.actions = result.actions;
record.hasNext = result.hasNext;
record.status = "ready";
if (cacheOptions) await activityCache.write("actions", revnId, cacheOptions.cachedAt, result.actions, result.hasNext);
} catch (error) {
if (!current.enabled || current !== session) return;
record.status = "error";
record.error = error.message || "Unable to load workload actions.";
} finally {
record.resolveCompletion();
if (current.enabled && current === session) { updateProgress(current); queueProductsTableRender(); }
}
}
function getSummary(optyNumber) {
if (!session.enabled || !allowsCrmActivities() || !optyNumber) return null;
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
if (!products) return null;
const ids = idsFor(products);
const records = ids.map(id => session.records.get(id));
const actions = records.flatMap(record => record?.actions || []);
const pending = records.some(record => !record || record.status === "loading");
const uncertain = products.error || (products.items || []).some(item => !String(item?.RevnId ?? "").trim())
|| records.some(record => record?.status === "error" || record?.hasNext);
const principal = authSessionState.principal;
const owned = Boolean(principal?.trim()) && actions.some(action => typeof action.owner === "string"
&& action.owner.trim().toLowerCase() === principal.trim().toLowerCase());
const state = owned ? "matched" : pending ? "loading" : uncertain || !principal ? "error" : "missing";
return { state, actions, records, principal, pending, uncertain };
}
async function applySavedAction(optyNumber, revnId, response, submittedAction = {}) {
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
const id = String(revnId);
if (!session.enabled || !allowsCrmActivities() || !idsFor(products).includes(id)) return false;
const returned = Array.isArray(response) ? response : Array.isArray(response?.actions) ? response.actions : [response?.action || response];
const saved = returned.filter(action => action && typeof action === "object" && action.id != null
&& (action.revenueId == null || String(action.revenueId) === id));
if (!saved.length) return false;
const previous = session.records.get(id);
const actions = [...(previous?.actions || [])];
for (const action of saved) {
// The POST can return a display name in owner, unlike the workload GET.
const ownerEmail = typeof action.ownerEmail === "string" && action.ownerEmail.trim()
? action.ownerEmail.trim() : submittedAction.ownerEmail || action.owner || "";
const normalized = { ...action, ownerEmail, owner: ownerEmail };
const index = actions.findIndex(item => String(item.id) === String(action.id));
if (index === -1) actions.push(normalized);
else actions[index] = { ...actions[index], ...normalized };
}
const record = { status: "ready", actions, hasNext: previous?.hasNext || false,
completion: Promise.resolve(), resolveCompletion() {} };
session.records.set(id, record);
previous?.resolveCompletion?.();
session.progress.delete(previous);
queueProductsTableRender();
if (products.cachedAt) await activityCache.write("actions", id, products.cachedAt, actions, record.hasNext);
return true;
}
function createBadge(optyNumber) {
const summary = getSummary(optyNumber);
if (!summary) return null;
const { state, actions, records, principal, pending, uncertain } = summary;
const badge = environment.document.createElement("span");
badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge";
badge.setAttribute("data-state", state);
setBadgeContent(badge, "actions", state === "matched" || state === "missing" ? actions.length : state === "loading" ? "..." : "?");
badge.title = actions.length ? actions.map(action => [
`Action: ${action.name ?? "N/D"}`, `ID: ${action.id ?? "N/D"}`, `Status: ${action.status ?? "N/D"}`,
`Owner: ${action.owner || "N/D"}`, `Email: ${action.ownerEmail || "N/D"}`, `Workload: ${action.workloadName || "N/D"}`,
`Description: ${action.workloadDescription || "N/D"}`, `Start: ${action.startDate || "N/D"}`, `End: ${action.endDate || "N/D"}`
].join("\n")).join("\n\n") : state === "missing" ? "No actions found for this opportunity's products."
: state === "loading" ? "Loading workload actions..." : "Unable to confirm workload actions. Check product RevnId and Debug Requests.";
if (records.some(record => record?.hasNext)) badge.title += "\nAdditional actions exist; pagination is not enabled.";
if (!principal) badge.title += "\nTokenrelay principal is unavailable; ownership cannot be verified.";
else if (actions.length && state === "missing") badge.title += "\nNone of the returned actions belong to the signed-in principal.";
if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked.";
badge.tabIndex = 0;
badge.setAttribute("aria-label", badge.title);
if (state === "missing") badge.append(createActionButton(String(optyNumber)));
return badge;
}
return { reset, reopen, loadProducts, blockProducts, createBadge, getSummary, applySavedAction };
}

View File

@@ -0,0 +1,6 @@
function createPreferencesState({ DEFAULT_OPPORTUNITY_TYPE_VALUES, DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS } = {}) {
return {
selectedOpportunityTypeValues: new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES),
opptyProductsCacheHours: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS
};
}

View File

@@ -0,0 +1,124 @@
function createPreferencesStorage({
DEFAULT_OPPORTUNITY_TYPE_VALUES,
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
FILTER_PREFERENCES_STORAGE_KEY,
MODAL_ID,
OPPORTUNITY_TYPE_OPTIONS,
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
OPPTY_PRODUCTS_CACHE_OPTIONS,
PERIOD_OPTIONS,
STAGE_OPTIONS,
STATUS_OPTIONS,
opportunitiesFilterState,
environment
}) {
const { document } = environment;
const getLocalStorage = () => environment.localStorage;
function restoreFilterPreferences(select) {
try {
const rawPreferences = getLocalStorage().getItem(FILTER_PREFERENCES_STORAGE_KEY);
if (!rawPreferences) {
return;
}
const preferences = JSON.parse(rawPreferences);
const savedStages = Array.isArray(preferences.stages)
? preferences.stages.filter((stage) => STAGE_OPTIONS.includes(stage))
: null;
const savedStatuses = Array.isArray(preferences.statuses)
? preferences.statuses.filter((status) => STATUS_OPTIONS.includes(status))
: null;
if (savedStages) {
opportunitiesFilterState.selectedStages = new Set(savedStages);
}
if (savedStatuses) {
opportunitiesFilterState.selectedStatuses = new Set(savedStatuses);
}
if (Array.isArray(preferences.customerKeys)) {
opportunitiesFilterState.selectedCustomerKeys = new Set(preferences.customerKeys.map(String));
opportunitiesFilterState.hasSavedCustomerFilter = true;
opportunitiesFilterState.customerFilterInitialized = true;
}
if (Array.isArray(preferences.ownerKeys)) {
opportunitiesFilterState.selectedOwnerKeys = new Set(preferences.ownerKeys.map(String));
opportunitiesFilterState.hasSavedOwnerFilter = true;
opportunitiesFilterState.ownerFilterInitialized = true;
}
opportunitiesFilterState.customerSearch = typeof preferences.customerSearch === "string" ? preferences.customerSearch : "";
opportunitiesFilterState.ownerSearch = typeof preferences.ownerSearch === "string" ? preferences.ownerSearch : "";
opportunitiesFilterState.tableSearch = typeof preferences.tableSearch === "string" ? preferences.tableSearch : "";
if (PERIOD_OPTIONS.includes(preferences.period)) {
select.value = preferences.period;
}
} catch (error) {
// Ignore malformed or unavailable browser storage and use default filters.
}
}
function saveFilterPreferences(period) {
try {
const currentPeriod = period || document.querySelector(`#${MODAL_ID} select`)?.value || PERIOD_OPTIONS[0];
getLocalStorage().setItem(FILTER_PREFERENCES_STORAGE_KEY, JSON.stringify({
period: currentPeriod,
stages: Array.from(opportunitiesFilterState.selectedStages),
statuses: Array.from(opportunitiesFilterState.selectedStatuses),
customerKeys: opportunitiesFilterState.hasSavedCustomerFilter ? Array.from(opportunitiesFilterState.selectedCustomerKeys) : null,
ownerKeys: opportunitiesFilterState.hasSavedOwnerFilter ? Array.from(opportunitiesFilterState.selectedOwnerKeys) : null,
customerSearch: opportunitiesFilterState.customerSearch,
ownerSearch: opportunitiesFilterState.ownerSearch,
tableSearch: opportunitiesFilterState.tableSearch
}));
} catch (error) {
// Ignore unavailable browser storage; filters still work for the current modal.
}
}
function getSavedOpportunityTypeValues() {
try {
const rawPreferences = getLocalStorage().getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY);
if (!rawPreferences) {
return DEFAULT_OPPORTUNITY_TYPE_VALUES;
}
const preferences = JSON.parse(rawPreferences);
const values = Array.isArray(preferences.opportunityTypeValues) ? preferences.opportunityTypeValues : null;
if (!values) {
return DEFAULT_OPPORTUNITY_TYPE_VALUES;
}
const allowedValues = new Set(OPPORTUNITY_TYPE_OPTIONS.map((option) => option.value));
return values.filter((value) => allowedValues.has(value));
} catch (error) {
return DEFAULT_OPPORTUNITY_TYPE_VALUES;
}
}
function getSavedOpptyProductsCacheHours() {
try {
const rawPreferences = getLocalStorage().getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY);
if (!rawPreferences) {
return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
}
const preferences = JSON.parse(rawPreferences);
const cacheHours = Number(preferences.opptyProductsCacheHours);
return OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === cacheHours)
? cacheHours
: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
} catch (error) {
return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
}
}
return { restoreFilterPreferences, saveFilterPreferences, getSavedOpportunityTypeValues, getSavedOpptyProductsCacheHours };
}

View File

@@ -0,0 +1,165 @@
function createPreferencesView({
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
MODAL_ID,
OPPORTUNITY_TYPE_OPTIONS,
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
OPPTY_PRODUCTS_CACHE_OPTIONS,
PERIOD_OPTIONS,
PREFERENCES_MODAL_ID,
createCloseIcon,
getSavedOpportunityTypeValues,
getSavedOpptyProductsCacheHours,
preferencesState,
requestOpportunitiesForPeriod,
environment
}) {
const { document } = environment;
const getLocalStorage = () => environment.localStorage;
function openPreferencesModal() {
const overlay = document.getElementById(MODAL_ID);
if (!overlay) {
return;
}
const existingModal = document.getElementById(PREFERENCES_MODAL_ID);
if (existingModal) {
const savedValues = new Set(getSavedOpportunityTypeValues());
existingModal.querySelectorAll("input[name='opportunityTypeView']").forEach((checkbox) => {
checkbox.checked = savedValues.has(checkbox.value);
});
const cacheSelect = existingModal.querySelector("select[name='opptyProductsCacheHours']");
if (cacheSelect) {
cacheSelect.value = String(getSavedOpptyProductsCacheHours());
}
existingModal.hidden = false;
existingModal.querySelector("input")?.focus();
return;
}
const preferencesModal = document.createElement("section");
preferencesModal.id = PREFERENCES_MODAL_ID;
preferencesModal.className = "opportunities-extension-preferences-modal";
preferencesModal.setAttribute("role", "dialog");
preferencesModal.setAttribute("aria-modal", "true");
preferencesModal.setAttribute("aria-labelledby", "opportunities-extension-preferences-title");
const dialog = document.createElement("div");
dialog.className = "opportunities-extension-preferences-dialog";
const header = document.createElement("header");
header.className = "opportunities-extension-preferences-header";
const title = document.createElement("h2");
title.id = "opportunities-extension-preferences-title";
title.textContent = "Preferences";
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Fechar preferências");
closeButton.title = "Close";
closeButton.append(createCloseIcon());
closeButton.addEventListener("click", closePreferencesModal);
header.append(title, closeButton);
const content = document.createElement("div");
content.className = "opportunities-extension-preferences-content";
const fieldset = document.createElement("fieldset");
fieldset.className = "opportunities-extension-preferences-fieldset";
const legend = document.createElement("legend");
legend.textContent = "Opportunity Type View";
fieldset.append(legend);
const selectedValues = new Set(getSavedOpportunityTypeValues());
const options = document.createElement("div");
options.className = "opportunities-extension-preferences-options";
OPPORTUNITY_TYPE_OPTIONS.forEach((option) => {
const label = document.createElement("label");
label.className = "opportunities-extension-preferences-option";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.name = "opportunityTypeView";
checkbox.value = option.value;
checkbox.checked = selectedValues.has(option.value);
const text = document.createElement("span");
text.textContent = option.label;
label.append(checkbox, text);
options.append(label);
});
fieldset.append(options);
const cacheField = document.createElement("label");
cacheField.className = "opportunities-extension-preferences-cache-field";
const cacheLabel = document.createElement("span");
cacheLabel.textContent = "Product cache duration";
const cacheSelect = document.createElement("select");
cacheSelect.name = "opptyProductsCacheHours";
cacheSelect.setAttribute("aria-label", "Product cache duration");
OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => {
const optionElement = document.createElement("option");
optionElement.value = String(option.value);
optionElement.textContent = option.label;
optionElement.selected = option.value === getSavedOpptyProductsCacheHours();
cacheSelect.append(optionElement);
});
cacheField.append(cacheLabel, cacheSelect);
content.append(fieldset, cacheField);
const footer = document.createElement("footer");
footer.className = "opportunities-extension-preferences-footer";
const saveButton = document.createElement("button");
saveButton.type = "button";
saveButton.className = "opportunities-extension-button opportunities-extension-button-primary";
saveButton.textContent = "Save";
saveButton.addEventListener("click", () => {
const selectedValues = Array.from(preferencesModal.querySelectorAll("input[name='opportunityTypeView']:checked"), (checkbox) => checkbox.value);
const selectedCacheHours = Number(preferencesModal.querySelector("select[name='opptyProductsCacheHours']")?.value);
preferencesState.selectedOpportunityTypeValues = new Set(selectedValues);
preferencesState.opptyProductsCacheHours = OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === selectedCacheHours)
? selectedCacheHours
: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
try {
getLocalStorage().setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({
opportunityTypeValues: selectedValues,
opptyProductsCacheHours: preferencesState.opptyProductsCacheHours
}));
} catch (error) {
// Continue with the saved selection for the active modal when storage is unavailable.
}
closePreferencesModal();
const periodSelect = overlay.querySelector("select[name='opportunityPeriod']");
requestOpportunitiesForPeriod(periodSelect?.value || PERIOD_OPTIONS[0]);
});
footer.append(saveButton);
dialog.append(header, content, footer);
preferencesModal.append(dialog);
overlay.append(preferencesModal);
preferencesModal.querySelector("input")?.focus();
}
function closePreferencesModal() {
const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID);
if (preferencesModal) {
preferencesModal.hidden = true;
}
}
return { openPreferencesModal, closePreferencesModal };
}

View File

@@ -0,0 +1,117 @@
function createProductsBackgroundJobs({
messageTypes,
OPPTY_PRODUCTS_CONCURRENCY,
getOpptyProducts,
normalizeCacheMaxAge,
tabsSendMessage
}) {
async function requestOpptyProducts(message, sender) {
const requestId = typeof message.requestId === "string" ? message.requestId : "";
const accessToken = typeof message.accessToken === "string" ? message.accessToken : "";
const cacheMaxAgeMs = normalizeCacheMaxAge(message.cacheMaxAgeMs);
const optyNumbers = Array.from(new Set(
(Array.isArray(message.optyNumbers) ? message.optyNumbers : [])
.filter(Boolean)
.map(String)
));
if (!requestId || !accessToken) {
throw new Error("A request id and access token are required for requestOpptyProducts.");
}
let nextIndex = 0;
let completed = 0;
let failed = 0;
let cacheHits = 0;
let debugDelivery = Promise.resolve();
const reportRequest = entry => {
const message = {
type: messageTypes.opptyProductsRequestDebug,
requestId,
entry: { ...entry, debugKey: JSON.stringify([requestId, entry.metadata.optyNumber, entry.metadata.offset]) }
};
debugDelivery = debugDelivery.catch(() => {}).then(() => sendProductsProgress(sender, message));
};
const runWorker = async () => {
while (nextIndex < optyNumbers.length) {
const index = nextIndex;
nextIndex += 1;
const optyNumber = optyNumbers[index];
try {
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, reportRequest);
completed += 1;
if (result.fromCache) {
cacheHits += 1;
}
await sendProductsProgress(sender, {
type: messageTypes.opptyProductsProgress,
requestId,
state: "running",
optyNumber,
items: result.items,
fromCache: result.fromCache,
cachedAt: result.cachedAt,
completed,
total: optyNumbers.length,
failed,
cacheHits
});
} catch (error) {
completed += 1;
failed += 1;
await sendProductsProgress(sender, {
type: messageTypes.opptyProductsProgress,
requestId,
state: "running",
optyNumber,
items: [],
error: error.message || "Unable to load products.",
completed,
total: optyNumbers.length,
failed,
cacheHits
});
}
}
};
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
await Promise.all(Array.from({ length: workerCount }, runWorker));
await debugDelivery.catch(() => {});
const state = failed > 0 ? "complete-with-errors" : "complete";
await sendProductsProgress(sender, {
type: messageTypes.opptyProductsProgress,
requestId,
state,
completed,
total: optyNumbers.length,
failed,
cacheHits
});
return {
ok: failed === 0,
requestId,
completed,
total: optyNumbers.length,
failed,
cacheHits
};
}
function sendProductsProgress(sender, message) {
if (!sender || !sender.tab || !Number.isInteger(sender.tab.id)) {
return Promise.resolve();
}
const options = Number.isInteger(sender.frameId) ? { frameId: sender.frameId } : undefined;
return tabsSendMessage(sender.tab.id, message, options).catch(() => {});
}
return { requestOpptyProducts, sendProductsProgress };
}

View File

@@ -0,0 +1,7 @@
function createProductsCacheState() {
return {
productsCachePromise: null,
productsCacheWritePromise: Promise.resolve(),
inFlightProducts: new Map()
};
}

View File

@@ -0,0 +1,90 @@
function createProductsCache({
OPPTY_PRODUCTS_CACHE_KEY,
fetchAllOpptyProducts,
productsCacheState,
storageLocalGet,
storageLocalSet
}) {
let generation = 0;
async function invalidate() {
generation += 1;
productsCacheState.inFlightProducts.clear();
productsCacheState.productsCachePromise = Promise.resolve({});
await saveProductsCache({});
}
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
const currentGeneration = generation;
const cache = await getProductsCache();
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
const cachedEntry = cache[optyNumber];
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
return { items: cachedEntry.items, fromCache: true, cachedAt: Number(cachedEntry.fetchedAt) };
}
if (!productsCacheState.inFlightProducts.has(optyNumber)) {
const flight = { listeners: new Set(), entries: new Map(), promise: null };
productsCacheState.inFlightProducts.set(optyNumber, flight);
flight.promise = fetchAllOpptyProducts(optyNumber, accessToken, entry => {
flight.entries.set(entry.metadata.offset, entry);
for (const listener of flight.listeners) {
try { listener(entry); } catch (_) {}
}
})
.then(async (items) => {
const fetchedAt = Date.now();
if (generation === currentGeneration) {
cache[optyNumber] = { fetchedAt, items };
await saveProductsCache(cache).catch(() => {});
}
return { items, fetchedAt };
})
.finally(() => {
if (productsCacheState.inFlightProducts.get(optyNumber) === flight) productsCacheState.inFlightProducts.delete(optyNumber);
});
}
const flight = productsCacheState.inFlightProducts.get(optyNumber);
// A second tab/job shares the fetch but still receives its current diagnostics.
flight.listeners.add(onDebug);
for (const entry of flight.entries.values()) {
try { onDebug(entry); } catch (_) {}
}
try {
const fetchedResult = await flight.promise;
return { items: fetchedResult.items, cachedAt: fetchedResult.fetchedAt, fromCache: false };
} finally {
flight.listeners.delete(onDebug);
}
}
function normalizeCacheMaxAge(value) {
const maxAge = Number(value);
const defaultMaxAge = 24 * 60 * 60 * 1000;
const maximumMaxAge = 30 * 24 * 60 * 60 * 1000;
return Number.isFinite(maxAge) && maxAge > 0
? Math.min(maxAge, maximumMaxAge)
: defaultMaxAge;
}
async function getProductsCache() {
if (!productsCacheState.productsCachePromise) {
productsCacheState.productsCachePromise = storageLocalGet(OPPTY_PRODUCTS_CACHE_KEY)
.then((result) => result && typeof result[OPPTY_PRODUCTS_CACHE_KEY] === "object"
? result[OPPTY_PRODUCTS_CACHE_KEY]
: {})
.catch(() => ({}));
}
return productsCacheState.productsCachePromise;
}
function saveProductsCache(cache) {
productsCacheState.productsCacheWritePromise = productsCacheState.productsCacheWritePromise
.catch(() => {})
.then(() => storageLocalSet({ [OPPTY_PRODUCTS_CACHE_KEY]: cache }));
return productsCacheState.productsCacheWritePromise;
}
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
}

View File

@@ -0,0 +1,204 @@
function createProductsContentJobs({
messageTypes,
PRODUCTS_CACHE_TIMESTAMP_ID,
PRODUCTS_MESSAGE_LISTENER_KEY,
PRODUCTS_PROGRESS_ID,
appendBackgroundRequestLog,
preferencesState,
productsContentState,
renderOpportunitiesTable,
beginProductActivities,
completeProductActivities,
sendRuntimeMessage,
environment
}) {
const { browser, chrome, document, window, setTimeout, clearTimeout, matchMedia } = environment;
let completion = null;
function updateCompletionVisibility(badge) {
const requestId = productsContentState.currentProductsRequestId;
const complete = productsContentState.productsProgress.state === "complete";
if (completion && (!complete || completion.requestId !== requestId || completion.badge !== badge)) {
clearTimeout(completion.waitTimer);
clearTimeout(completion.hideTimer);
completion.badge.setAttribute("data-fading", "false");
completion = null;
}
if (!complete) {
badge.setAttribute("data-fading", "false");
return;
}
if (completion) {
badge.hidden = completion.hidden;
return;
}
const current = { requestId, badge, hidden: false, waitTimer: null, hideTimer: null };
completion = current;
badge.setAttribute("data-fading", "false");
current.waitTimer = setTimeout(() => {
if (completion !== current) return;
badge.setAttribute("data-fading", "true");
const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
current.hideTimer = setTimeout(() => {
if (completion !== current) return;
current.hidden = true;
badge.hidden = true;
}, reducedMotion ? 0 : 300);
}, 5000);
}
function requestOpptyProductsInBackground(opportunities, token) {
const optyNumbers = Array.from(new Set(opportunities
.map((item) => item && item.OptyNumber)
.filter(Boolean)
.map(String)));
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
productsContentState.currentProductsRequestId = requestId;
beginProductActivities(requestId, token);
productsContentState.opptyProductsByNumber = new Map();
productsContentState.expandedProductOptyNumbers = new Set();
productsContentState.productsProgress = {
state: optyNumbers.length ? "running" : "complete",
completed: 0,
total: optyNumbers.length,
failed: 0,
cacheHits: 0
};
renderProductsProgress();
if (optyNumbers.length === 0) {
return;
}
sendRuntimeMessage({
type: messageTypes.requestOpptyProducts,
requestId,
accessToken: token,
optyNumbers,
cacheMaxAgeMs: preferencesState.opptyProductsCacheHours * 60 * 60 * 1000
}).then((response) => {
if (response && response.ok === false && response.requestId === productsContentState.currentProductsRequestId && productsContentState.productsProgress.state === "running") {
productsContentState.productsProgress.state = "error";
productsContentState.productsProgress.failed = productsContentState.productsProgress.total;
renderProductsProgress();
}
}).catch(() => {
if (requestId === productsContentState.currentProductsRequestId && productsContentState.productsProgress.state === "running") {
productsContentState.productsProgress.state = "error";
productsContentState.productsProgress.failed = productsContentState.productsProgress.total;
renderProductsProgress();
}
});
}
function ensureProductsBackgroundMessageHandler() {
if (window[PRODUCTS_MESSAGE_LISTENER_KEY]) {
return;
}
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.onMessage) {
return;
}
window[PRODUCTS_MESSAGE_LISTENER_KEY] = true;
runtimeApi.runtime.onMessage.addListener((message) => {
if (!message || message.requestId !== productsContentState.currentProductsRequestId) {
return false;
}
if (message.type === messageTypes.opptyProductsRequestDebug) {
if (message.entry && typeof message.entry.debugKey === "string") appendBackgroundRequestLog([message.entry]);
return false;
}
if (message.type !== messageTypes.opptyProductsProgress) {
return false;
}
if (message.optyNumber) {
productsContentState.opptyProductsByNumber.set(String(message.optyNumber), {
items: Array.isArray(message.items) ? message.items : [],
fromCache: Boolean(message.fromCache),
cachedAt: Number(message.cachedAt) || 0,
error: message.error || ""
});
}
productsContentState.productsProgress = {
state: message.state || productsContentState.productsProgress.state,
completed: Number(message.completed) || 0,
total: Number(message.total) || productsContentState.productsProgress.total,
failed: Number(message.failed) || 0,
cacheHits: Number(message.cacheHits) || 0
};
renderProductsProgress();
if (message.state === "complete" || message.state === "complete-with-errors") completeProductActivities(message.requestId);
queueProductsTableRender();
return false;
});
}
function renderProductsProgress() {
const badge = document.getElementById(PRODUCTS_PROGRESS_ID);
if (!badge) {
return;
}
badge.hidden = productsContentState.productsProgress.state === "idle";
badge.setAttribute("data-state", productsContentState.productsProgress.state);
if (productsContentState.productsProgress.state === "running") {
badge.textContent = `Products ${productsContentState.productsProgress.completed}/${productsContentState.productsProgress.total}`;
badge.title = `${productsContentState.productsProgress.cacheHits} loaded from cache`;
} else if (productsContentState.productsProgress.state === "complete") {
badge.textContent = `Products ready ${productsContentState.productsProgress.total}`;
badge.title = `${productsContentState.productsProgress.cacheHits} loaded from cache`;
} else if (productsContentState.productsProgress.state === "complete-with-errors") {
badge.textContent = `Products ${productsContentState.productsProgress.total - productsContentState.productsProgress.failed}/${productsContentState.productsProgress.total}`;
badge.title = `${productsContentState.productsProgress.failed} product requests failed`;
} else if (productsContentState.productsProgress.state === "error") {
badge.textContent = "Products unavailable";
badge.title = "Unable to load opportunity products";
} else {
badge.textContent = "Products ready 0";
}
updateCompletionVisibility(badge);
const timestamp = document.getElementById(PRODUCTS_CACHE_TIMESTAMP_ID);
if (timestamp) {
timestamp.hidden = !productsContentState.productsLastCachedAt;
timestamp.textContent = productsContentState.productsLastCachedAt
? `Last update: ${formatProductsCacheTimestamp(productsContentState.productsLastCachedAt)}`
: "";
timestamp.title = productsContentState.productsLastCachedAt
? `Latest product update: ${formatProductsCacheTimestamp(productsContentState.productsLastCachedAt)}`
: "";
}
}
function formatProductsCacheTimestamp(timestamp) {
try {
return new Intl.DateTimeFormat("en-GB", {
dateStyle: "short",
timeStyle: "short"
}).format(new Date(timestamp));
} catch (error) {
return new Date(timestamp).toLocaleString();
}
}
function queueProductsTableRender() {
if (productsContentState.productsRenderQueued) {
return;
}
productsContentState.productsRenderQueued = true;
window.requestAnimationFrame(() => {
productsContentState.productsRenderQueued = false;
renderOpportunitiesTable();
});
}
return { requestOpptyProductsInBackground, ensureProductsBackgroundMessageHandler, renderProductsProgress, formatProductsCacheTimestamp, queueProductsTableRender };
}

View File

@@ -0,0 +1,16 @@
function createProductsContentState() {
return {
currentProductsRequestId: "",
productsRenderQueued: false,
opptyProductsByNumber: new Map(),
expandedProductOptyNumbers: new Set(),
productsProgress: {
state: "idle",
completed: 0,
total: 0,
failed: 0,
cacheHits: 0
},
productsLastCachedAt: 0
};
}

View File

@@ -0,0 +1,169 @@
function createProductsDetails({
OPPORTUNITIES_COLUMNS,
setBadgeContent,
createAiSummaryAlert,
formatOracleResponseDate,
productsContentState,
renderOpportunitiesTable,
requestGenerateAiSummary,
environment
}) {
const { document } = environment;
function createProductsRampBadge(productsResult) {
const values = (Array.isArray(productsResult.items) ? productsResult.items : [])
.map(item => item && item.RampMonths_c)
.map(value => value === null || value === undefined || String(value).trim() === "" ? "N/D" : String(value).trim())
.filter((value, index, items) => value !== "N/D" || items.indexOf(value) === index);
if (!values.length) return null;
const badge = document.createElement("span");
badge.className = "opportunities-extension-products-ramp";
badge.setAttribute("data-missing", String(values.includes("N/D")));
setBadgeContent(badge, "ramp", values.join(","));
badge.title = `Ramp Months: ${values.join(", ")}`;
badge.setAttribute("aria-label", badge.title);
return badge;
}
function createProductsCountButton(optyNumber, productsResult) {
const normalizedOptyNumber = String(optyNumber);
const button = document.createElement("button");
const count = Array.isArray(productsResult.items) ? productsResult.items.length : 0;
const isExpanded = productsContentState.expandedProductOptyNumbers.has(normalizedOptyNumber);
button.type = "button";
button.className = "opportunities-extension-products-count";
setBadgeContent(button, "products", count);
button.setAttribute("aria-expanded", String(isExpanded));
button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`);
button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`;
button.classList.toggle("opportunities-extension-products-count-error", Boolean(productsResult.error));
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (isExpanded) {
productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber);
} else {
productsContentState.expandedProductOptyNumbers.add(normalizedOptyNumber);
requestGenerateAiSummary(normalizedOptyNumber);
}
renderOpportunitiesTable();
});
return button;
}
function createProductsDetailRow(opportunity) {
const row = document.createElement("tr");
const cell = document.createElement("td");
const optyNumber = String(opportunity.OptyNumber);
const productsResult = productsContentState.opptyProductsByNumber.get(optyNumber);
const products = productsResult && Array.isArray(productsResult.items) ? productsResult.items : [];
row.className = "opportunities-extension-products-detail-row";
cell.colSpan = OPPORTUNITIES_COLUMNS.length;
cell.className = "opportunities-extension-products-detail-cell";
const panel = document.createElement("section");
panel.className = "opportunities-extension-products-detail";
const heading = document.createElement("div");
heading.className = "opportunities-extension-products-detail-heading";
const title = document.createElement("strong");
title.textContent = `Products for ${optyNumber}`;
const summary = document.createElement("span");
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
heading.append(title, summary);
panel.append(heading);
panel.append(createAiSummaryAlert(optyNumber));
if (productsResult && productsResult.error) {
const error = document.createElement("p");
error.className = "opportunities-extension-products-empty";
error.textContent = productsResult.error;
panel.append(error);
} else if (products.length === 0) {
const empty = document.createElement("p");
empty.className = "opportunities-extension-products-empty";
empty.textContent = "No products found for this opportunity.";
panel.append(empty);
} else {
const table = document.createElement("table");
table.className = "opportunities-extension-products-table";
table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`);
const columns = [
{ label: "Product Group", value: (item) => item.ProdGroupName || "-" },
{ label: "Workload", value: (item) => item.WorkloadName_c || "-" },
{ label: "Currency", value: (item) => item.RevnAmountCurcyCode || "-" },
{ label: "Amount", value: (item) => formatProductAmount(item.RevnAmount, item.RevnAmountCurcyCode) },
{ label: "Type", value: (item) => item.TypeCode || "-" },
{ label: "Status", value: (item) => item.StatusCode || "-" },
{ label: "Win Probability", value: (item) => formatProductWinProbability(item.WinProb) },
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
{ label: "Ramp Months", value: (item) => item.RampMonths_c === null || item.RampMonths_c === undefined || String(item.RampMonths_c).trim() === ""
? "N/D" : formatProductNumber(item.RampMonths_c) }
];
const head = document.createElement("thead");
const headerRow = document.createElement("tr");
columns.forEach((column) => {
const header = document.createElement("th");
header.scope = "col";
header.textContent = column.label;
headerRow.append(header);
});
head.append(headerRow);
const body = document.createElement("tbody");
products.forEach((product) => {
const productRow = document.createElement("tr");
columns.forEach((column) => {
const productCell = document.createElement("td");
productCell.textContent = String(column.value(product));
productRow.append(productCell);
});
body.append(productRow);
});
table.append(head, body);
panel.append(table);
}
cell.append(panel);
row.append(cell);
return row;
}
function formatProductNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(number) : "-";
}
function formatProductAmount(value, currency) {
const amount = Number(value);
if (!Number.isFinite(amount)) {
return "-";
}
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency || "USD"
}).format(amount);
} catch (error) {
return `${currency || ""} ${formatProductNumber(amount)}`.trim();
}
}
function formatProductWinProbability(value) {
const number = Number(value);
return Number.isFinite(number) ? `${number}%` : "-";
}
return { createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
}

View File

@@ -0,0 +1,41 @@
function createRatecardController({ messageTypes, requestConsumerTokenServiceOnce, sendRuntimeMessage,
appendBackgroundRequestLog, openRatecardDrawer, closePlanDetailPopover, environment }) {
const pending = new Map();
async function request(planNumber) {
const authorizations = await requestConsumerTokenServiceOnce();
const response = await sendRuntimeMessage({ type: messageTypes.requestRatecardByPlan, planNumber,
authorization: authorizations && authorizations.accountPlans });
appendBackgroundRequestLog(response?.debugRequests);
if (!response?.ok) throw new Error(response?.error || "Unable to load rate card.");
return response.items;
}
async function showRatecard(plan, trigger) {
closePlanDetailPopover();
const handle = openRatecardDrawer(plan, trigger);
if (!handle) return;
try {
const planNumber = String(plan.subPlanNum ?? "").trim();
if (!planNumber) throw new Error("Plan number is unavailable for this account plan.");
if (!pending.has(planNumber)) {
const promise = request(planNumber).finally(() => { if (pending.get(planNumber) === promise) pending.delete(planNumber); });
pending.set(planNumber, promise);
}
handle.showItems(await pending.get(planNumber));
} catch (error) {
handle.showError(error.message || "Unable to load rate card.");
}
}
function createRatecardButton(plan) {
const button = environment.document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-ratecard-button";
button.textContent = "Rate Card";
button.title = "View rate card";
button.setAttribute("aria-haspopup", "dialog");
button.addEventListener("click", () => showRatecard(plan, button));
return button;
}
return { createRatecardButton, showRatecard, resetRequests: () => pending.clear() };
}

Some files were not shown because too many files have changed in this diff Show More