45 Commits

Author SHA1 Message Date
cc025e488d Release 1.0.4 2026-10-19 10:48:44 -03:00
9eef03e5f4 Fully implemented the Manage Time functionality 2026-10-19 10:30:09 -03:00
d392e5ddd7 Partially implemented the Manage Time functionality 2026-10-16 19:50:39 -03:00
2ae057cc90 Release 1.0.3 2026-10-13 11:14:12 -03:00
8b4d88b226 Implemented the Create Internal Service Request functionality 2026-10-09 18:41:39 -03:00
c9de750261 Implemented the calendar view for opportunities 2026-10-05 18:35:28 -03:00
7a7143b6e7 Implemented the functionality to create Service Requests 2026-10-05 16:30:29 -03:00
e9c295be91 Some visual adjustments to the badges 2026-10-02 18:44:47 -03:00
f512ef9928 Loading comcipapic to establish a session for SR requests 2026-10-02 02:37:35 -03:00
3fd89a307c The sessions required for Internal SR API calls are now loaded via an iframe 2026-09-29 17:48:03 -03:00
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
9c6c5ff690 Release version 1.0.0 2026-09-02 17:11:13 -03:00
9cb58809c8 Adjustment to API authentication with fallback in case the cookie is invalid or missing 2026-09-02 14:04:33 -03:00
dfc84c64ee Update the request to include the status code WON_PENDING. 2026-09-01 15:56:22 -03:00
0b854e23d2 Some visual adjustments to the layout 2026-08-31 11:58:17 -03:00
184 changed files with 50059 additions and 8554 deletions

379
README.md
View File

@@ -1,42 +1,381 @@
# Opportunities Extension
# Opportunities Extension 1.0.4
Extensao de navegador para adicionar o atalho "Opportunities Extension" nas paginas Oracle Fusion permitidas.
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table or calendar.
## Estrutura de desenvolvimento
## Opportunity Views
- `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.
**Visao geral** is the default table view. **Calendario** displays opportunities
by Close Date (`EffectiveDate`) with month and week views only. Both views share
Reference, search and all existing filters; switching views does not send requests.
Events use date-only values without time-zone shifts, stage colors, and a read-only
detail with an opportunity link and copy button. Invalid or missing dates appear
in a separate list. Calendar navigation does not change the selected Reference.
Each day shows a filtered opportunity count, at most two events and an overflow
link to the remaining events. The grid fits the available height and reduces
visible events to one or zero when needed to keep every week visible. Missing
close dates are available in a collapsible list below the calendar.
Fiscal labels use `FY27-Q2-W19`: June-May fiscal
years, Sunday-Saturday weeks, and W1 for the week containing June 1. A quarter is
assigned by the week's Sunday, except the first fiscal week which belongs to Q1.
FullCalendar Standard 6.1.19 is bundled locally under `src/vendor/fullcalendar/`
with its MIT license. It is initialized only on first use, without a runtime CDN.
## Development Structure
- `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 identity token is retained in debug responses and hidden by default; the
explicit sensitive-values toggle reveals the original value.
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.
## Como carregar em modo desenvolvimento
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.
### Chrome ou Microsoft Edge
## Tests and Maintenance
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`.
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 in full, preserving indentation, highlighting and sensitive-field
masking. Original sensitive values are retained for explicit reveal, including
identity, tokenrelay and catalog tokens. 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
- 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.
## Release 1.0.1
- 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.
## 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.
## Release 1.0.3 - 2026-10-13
- Added monthly and weekly calendar views with stage colors, fiscal week labels, daily counts and overflow details.
- Added searchable Action forms, required-field validation, saving and local badge updates.
- Added Service Request creation with dynamic lookups, payload/header confirmation and session recovery.
- Added Internal Service Request details and creation. Save reads parent data from `requestSRInfo`, resolves the current resource user, and links the new record using the parent `SrId`.
- Internal SR creation uses Session authentication, JSON, Origin and the observed App Builder client ID. Customer deadlines remain date-only; internal deadlines use midnight UTC ISO timestamps.
- Improved activity filters, cache reuse, Refresh all, progress indicators and compact layouts.
- Debug retains complete response bodies and reveals original sensitive values through the visibility toggle; values are hidden by default.
- Regenerated Chromium/Edge and Firefox distributions. Automated tests use synthetic data; real Oracle creation and installed-browser smoke tests are not part of release validation.
## Release 1.0.4 - 2026-10-19
- Added Manage Time with weekly navigation, a persisted reference date, daily hour totals and loading skeletons.
- Added searchable Service Request, Activity and Task Type fields, expanded SR tooltips and separate Service/Non Service task types.
- Existing time entries populate weekly hours and Notes. Local drafts are preserved while navigating between weeks.
- Save creates entries with POST, updates changed entries with PUT and applies queued deletions with DELETE. Writes use Session, Origin and the observed App Builder client ID.
- Added required-field validation, the 0-24 hour range, duplicate SR selection prevention, temporary row highlighting and success/error toasts.
- Reused Cache duration across form queries and invalidated cached responses after successful writes. Resource user data is reused after SR session initialization.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
## 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, exibe o alerta `Deu certo`.
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

11261
dist/chromium/content.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -2,12 +2,17 @@
"manifest_version": 3,
"name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "0.1.0",
"version": "1.0.4",
"permissions": [
"cookies"
"cookies",
"storage",
"webRequest",
"declarativeNetRequestWithHostAccess"
],
"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

11261
dist/firefox/content.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -2,12 +2,17 @@
"manifest_version": 3,
"name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "0.1.0",
"version": "1.0.4",
"permissions": [
"cookies"
"cookies",
"storage",
"webRequest",
"declarativeNetRequestWithHostAccess"
],
"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.4; 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": "0.1.0",
"version": "1.0.4",
"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,30 @@
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: "0.1.0",
version: "1.0.4",
permissions: [
"cookies"
"cookies",
"storage",
"webRequest",
"declarativeNetRequestWithHostAccess"
],
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 +65,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) {

35
scripts/compose.cjs Normal file
View File

@@ -0,0 +1,35 @@
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const sources = require("./sources.cjs");
const vendorSources = { content: ["src/vendor/fullcalendar/index.global.min.js"], background: [] };
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 calendarLibrary = fs.readFileSync(path.join(root, vendorSources.content[0]), "utf8");
const calendarLicense = fs.readFileSync(path.join(root, "src/vendor/fullcalendar/LICENSE.md"), "utf8");
parts.unshift(`function loadOpportunityCalendarLibrary() {\n/* ${calendarLicense} */\n${calendarLibrary}\nreturn FullCalendar;\n}`);
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", "src/features/opportunities/calendar.css"]
.concat("src/features/manage-time/styles.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, vendorSources };

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}/`);
});

117
scripts/sources.cjs Normal file
View File

@@ -0,0 +1,117 @@
module.exports = {
"content": [
"src/shared/time-dates.js",
"src/features/manage-time/view.js",
"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/preferences/request-cache.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/crm-sr-form.js",
"src/features/opportunities/internal-sr-form.js",
"src/features/opportunities/searchable-select.js",
"src/features/opportunities/internal-sr-session.js",
"src/features/opportunities/sr-session-preparation.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/calendar.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/time-dates.js",
"src/services/fusion/time-entries.js",
"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/auth/sr-request-headers.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/fusion/sr-create.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 };
}

View File

@@ -0,0 +1,131 @@
function createSrRequestHeaders({ runtimeApi }) {
const origin = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com";
const appPath = "/ic/builder/rt/SeaaS_initiation/";
const internalPath = "/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPICloudAccount/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
const storageKey = "opportunitiesExtension.srAppBuilderClientId.v1";
const maxAge = 60 * 60 * 1000;
let observed = null;
let started = false;
function valid(record) {
return record?.origin === origin && typeof record.value === "string"
&& /^[\x21-\x7e]{1,2048}$/.test(record.value)
&& Number.isFinite(record.observedAt) && record.observedAt <= Date.now()
&& Date.now() - record.observedAt < maxAge;
}
function observeHeaders(rawUrl, headers) {
let url;
try { url = new URL(rawUrl); } catch { return; }
if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management|oalset_timeentrymobile)\/live(?:\/|;)/.test(url.pathname)) return;
// Read only the client ID, not cookies, bodies or authorization.
const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id")
: headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value;
const record = { origin, value: value?.trim(), observedAt: Date.now() };
if (!valid(record)) return;
observed = record;
// Session storage survives service-worker suspension without persisting across browser sessions.
runtimeApi.storage?.session?.set({ [storageKey]: record }).catch(() => {});
}
function start() {
if (started || !runtimeApi?.webRequest) return;
started = true;
const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`, `${origin}/ic/builder/rt/oalset_timeentrymobile/*`], types: ["xmlhttprequest"] };
runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => {
if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders);
}, filter, ["requestHeaders"]);
runtimeApi.webRequest.onHeadersReceived?.addListener(details => {
if (!details.incognito) observeHeaders(details.url, details.responseHeaders);
}, filter, ["responseHeaders"]);
}
async function previewTokenHeaders(url) {
const target = new URL(url);
if (target.origin !== origin || !target.pathname.startsWith(appPath) || !/\/Catalogue_c(?:\/\d+)?$/.test(target.pathname)) {
throw new Error("Unexpected Service Request token URL.");
}
return sessionHeaders(target.origin, "application/vnd.oracle.adf.action+json");
}
async function sessionHeaders(targetOrigin, contentType) {
if (!valid(observed)) {
try {
const stored = await runtimeApi.storage?.session?.get(storageKey);
if (valid(stored?.[storageKey]) && !valid(observed)) observed = stored[storageKey];
} catch { /* A missing capture must not prevent trying the other required headers. */ }
}
return {
Accept: "application/json", "Content-Type": contentType,
Authorization: "Session", Origin: targetOrigin,
...(valid(observed) ? { "x-appbuilder-client-id": observed.value } : {})
};
}
async function tokenHeaders(url, serviceRequestUrl) {
const headers = await previewTokenHeaders(url);
if (!/\/Catalogue_c\/\d+$/.test(new URL(url).pathname)) throw new Error("Missing Catalogue Id.");
const serviceTarget = new URL(serviceRequestUrl);
const isServiceRequest = serviceTarget.pathname.startsWith(appPath) && serviceTarget.pathname.endsWith("/set/seaas/mgmt/serviceRequests");
if (serviceTarget.origin !== headers.Origin || !isServiceRequest || serviceTarget.search || serviceTarget.hash) {
throw new Error("Unexpected Service Request URL.");
}
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
throw new Error("Reload the extension to enable the Service Request token header permissions.");
}
const extensionDomain = new URL(runtimeApi.runtime.getURL("/")).hostname;
const catalogUrl = url.slice(0, url.lastIndexOf("/") + 1);
const escapedCatalogUrl = catalogUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapedServiceUrl = serviceTarget.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const ruleIds = [7201, 7202];
await runtimeApi.declarativeNetRequest.updateSessionRules({
removeRuleIds: ruleIds,
addRules: [`^${escapedCatalogUrl}[0-9]+$`, `^${escapedServiceUrl}$`].map((regexFilter, index) => ({
id: ruleIds[index], priority: 1,
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: headers.Origin }] },
condition: { regexFilter, initiatorDomains: [extensionDomain],
requestMethods: ["post"], resourceTypes: ["xmlhttprequest"] }
}))
});
return headers;
}
async function internalServiceRequestHeaders(url) {
const target = new URL(url);
if (target.origin !== origin || target.pathname !== internalPath || target.search || target.hash) {
throw new Error("Unexpected Internal Service Request URL.");
}
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
throw new Error("Reload the extension to enable the Internal Service Request header permissions.");
}
const headers = await sessionHeaders(target.origin, "application/json");
const extensionDomain = new URL(runtimeApi.runtime.getURL("/")).hostname;
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await runtimeApi.declarativeNetRequest.updateSessionRules({
removeRuleIds: [7203],
addRules: [{
id: 7203, priority: 1,
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: headers.Origin }] },
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [extensionDomain],
requestMethods: ["post"], resourceTypes: ["xmlhttprequest"] }
}]
});
return headers;
}
async function timeEntriesHeaders(url) {
const target = new URL(url);
const path = "/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries";
const isDelete = target.pathname.startsWith(`${path}/`) && /^[1-9]\d*$/.test(target.pathname.slice(path.length + 1));
if (target.origin !== origin || (target.pathname !== path && !isDelete) || target.search || target.hash) {
throw new Error("Unexpected time entries URL.");
}
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
throw new Error("Reload the extension to enable the time entry header permissions.");
}
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await runtimeApi.declarativeNetRequest.updateSessionRules({
removeRuleIds: [isDelete ? 7205 : 7204],
addRules: [{
id: isDelete ? 7205 : 7204, priority: 1,
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: target.origin }] },
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [new URL(runtimeApi.runtime.getURL("/")).hostname],
requestMethods: isDelete ? ["delete"] : ["post", "put"], resourceTypes: ["xmlhttprequest"] }
}]
});
return sessionHeaders(target.origin, "application/json");
}
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, timeEntriesHeaders, observeResponseHeaders: observeHeaders };
}

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

@@ -0,0 +1,155 @@
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, responseBody);
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 || "(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 };
}

View File

@@ -1,46 +1,13 @@
(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 runtimeApi = typeof browser !== "undefined" ? browser : chrome;
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || message.type !== "opportunitiesExtension.getXsrfToken") {
return false;
}
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;
});
function createAuthXsrfCookies({
ORACLE_COOKIE_URLS,
ORACLE_DOMAIN,
XSRF_COOKIE_NAME,
XSRF_COOKIE_PREFIX,
cookiesGet,
cookiesGetAll,
cookiesGetAllCookieStores,
removeUndefinedValues
}) {
async function getXsrfTokenCookie() {
const lookupDetails = [];
const allCookies = [];
@@ -145,72 +112,5 @@
});
}
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)
);
}
})();
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

@@ -0,0 +1,181 @@
function createBackgroundApplication(overrides = {}) {
const environment = {
fetch: globalThis.fetch,
browser: globalThis.browser,
chrome: globalThis.chrome,
setTimeout: globalThis.setTimeout,
...overrides
};
const messageTypes = createMessageTypes();
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
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({
observeResponseHeaders: (...args) => srRequestHeaders.observeResponseHeaders(...args),
previewDebugBody: createDebugBodyPreview,
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 srRequestHeaders = createSrRequestHeaders({ runtimeApi });
const fusionSrCreate = createFusionSrCreate({
runtimeApi,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
tokenHeaders: (...args) => srRequestHeaders.tokenHeaders(...args),
internalServiceRequestHeaders: url => srRequestHeaders.internalServiceRequestHeaders(url),
previewTokenHeaders: url => srRequestHeaders.previewTokenHeaders(url)
});
const fusionSrDetails = createFusionSrDetails({
cookieChanges: runtimeApi?.cookies?.onChanged,
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
supportsFirefoxCookieStoreTabs: () => platformBackgroundApi.supportsFirefoxCookieStoreTabs(),
wait: (...args) => platformBackgroundApi.wait(...args),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args)
});
function start() {
srRequestHeaders.start();
startBackgroundApplication({
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
requestTimeEntries: message => timeEntries.requestTimeEntries(message),
requestTimeEntriesSave: message => timeEntries.requestTimeEntriesSave(message),
requestTimeEntriesDelete: message => timeEntries.requestTimeEntriesDelete(message),
requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
requestResourceUser: message => fusionSrCreate.getResourceUser(message),
requestActiveServiceRequests: message => timeEntries.requestActiveServiceRequests(message),
requestLookupTypePlatform: () => fusionSrDetails.requestLookupTypePlatform(),
requestPostInternalServiceRequest: message => fusionSrCreate.requestPostInternalServiceRequest(message),
requestInternalSrServices: message => fusionSrDetails.requestInternalSrServices(message),
requestPostServiceRequest: message => fusionSrCreate.requestPostServiceRequest(message), prepareSrSession: sender => fusionSrDetails.prepareSession(sender), requestSrLookupWithSession: (...args) => fusionSrDetails.requestLookupWithSession(...args), messageTypes, requestSrLookupLanguage: () => fusionSrDetails.requestSrLookupLanguage(), requestSrLookupTechPlatform: () => fusionSrDetails.requestSrLookupTechPlatform(), requestSrLookupOpportunityBusiness: message => fusionSrDetails.requestSrLookupOpportunityBusiness(message), requestSrLookupPrimaryPillar: () => fusionSrDetails.requestSrLookupPrimaryPillar(), requestInternalServiceRequest: (message, sender) => fusionSrDetails.requestInternalServiceRequest(message, sender), 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);
}

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

@@ -0,0 +1,256 @@
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
messageTypes,
requestWeekDetails,
requestTimeEntries,
requestTimeEntriesSave,
requestTimeEntriesDelete,
requestLookupTaskType,
requestServiceRequestExpand,
requestResourceUser,
requestActiveServiceRequests,
invalidateProductsCache,
invalidateResourceUser = async () => {},
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestCustomerServiceRequest,
requestWlActions,
requestActionTeamMembers,
requestActionFindLookup,
requestMgmtAction,
requestPostServiceRequest,
requestPostInternalServiceRequest,
requestInternalServiceRequest,
requestSrLookupPrimaryPillar,
requestSrLookupWithSession,
prepareSrSession,
requestSrLookupLanguage,
requestSrLookupTechPlatform,
requestLookupTypePlatform,
requestInternalSrServices,
requestSrLookupOpportunityBusiness,
requestCurrentUser,
requestConsumerTokenService,
requestConsumptionTrend,
requestConsumptionPlanDetail,
requestRatecardByPlan,
requestOpptyProducts,
runtimeApi
}) {
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) {
return false;
}
if (message.type === messageTypes.requestLookupTaskType) {
requestLookupTaskType().then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.requestTimeEntriesSave) {
requestTimeEntriesSave(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.requestTimeEntriesDelete) {
requestTimeEntriesDelete(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true;
}
if ([messageTypes.requestTimeEntries, messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
const request = message.type === messageTypes.requestTimeEntries ? requestTimeEntries : message.type === messageTypes.requestServiceRequestExpand ? requestServiceRequestExpand : message.type === messageTypes.requestWeekDetails ? requestWeekDetails : message.type === messageTypes.requestResourceUser ? requestResourceUser : requestActiveServiceRequests;
request(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.requestPostInternalServiceRequest) {
requestPostInternalServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.requestPostServiceRequest) {
requestPostServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.prepareSrSession) {
prepareSrSession(sender).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
const srLookups = {
[messageTypes.requestLookupTypePlatform]: requestLookupTypePlatform,
[messageTypes.requestInternalSrServices]: requestInternalSrServices,
[messageTypes.requestSrLookupPrimaryPillar]: requestSrLookupPrimaryPillar,
[messageTypes.requestSrLookupLanguage]: requestSrLookupLanguage,
[messageTypes.requestSrLookupTechPlatform]: requestSrLookupTechPlatform,
[messageTypes.requestSrLookupOpportunityBusiness]: requestSrLookupOpportunityBusiness
};
if (Object.hasOwn(srLookups, message.type)) {
requestSrLookupWithSession(srLookups[message.type], message, sender).then(sendResponse).catch(error => sendResponse({ ok: false,
httpStatus: error.httpStatus, error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.invalidateProductsCache) {
invalidateProductsCache().then(() => invalidateResourceUser()).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, sender).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

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

@@ -0,0 +1,799 @@
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 searchableSelect = createSearchableSelect({ environment });
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({
createManageTimeButton: () => manageTime.createButton(),
prepareSrSession: () => srSessionPreparation.ensure(),
closeActionForm: (...args) => manageTime.close(...args) || internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
mountOpportunityViews: (...args) => opportunityCalendar.mount(...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 manageTime = createManageTime({ MODAL_ID: contracts.MODAL_ID, environment, searchableSelect, dates: createTimeDates(),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
async requestDelete(id) {
await srSessionPreparation.ensure();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntriesDelete, id });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to delete time entry.");
return result.result;
},
async requestSave(method, items) {
await srSessionPreparation.ensure();
await currentUser.ensure();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntriesSave, method, items, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to save time entries.");
return result.result;
},
async requestEntries(weekId) {
await srSessionPreparation.ensure();
await currentUser.ensure();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntries, weekId, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load time entries.");
return result.items;
},
async requestTaskTypes() {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestLookupTaskType });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load Task Types.");
return result.items;
},
async requestExpand(srNumbers) {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestServiceRequestExpand, srNumbers });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load activities.");
return result.items;
},
async requestServices(weekId) {
await srSessionPreparation.ensure();
await currentUser.ensure();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestActiveServiceRequests, weekId, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load Service Requests.");
return result.items;
},
async requestWeek(dateReference) {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestWeekDetails, dateReference });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load week details.");
return result.items;
}
});
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({
requestCache: createFormRequestCache({ getHours: () => preferencesState.opptyProductsCacheHours, getUserId: () => currentUser.getUserId() }),
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 internalSrSession = createInternalSrSession({
environment,
query: async srId => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
return result;
}
});
const crmSrPopover = createCrmSrPopover({
openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestDetails: srId => internalSrSession.request(srId),
MODAL_ID: contracts.MODAL_ID,
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
});
const opportunityBadgeContent = createOpportunityBadgeContent({ environment });
async function requestSrFormLookup(type, fields = {}) {
const result = await platformContentMessages.sendRuntimeMessage({ type, ...fields });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load Service Request options.");
return result.items;
}
const internalSrForm = createInternalSrForm({
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
requestSave: async payload => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestPostInternalServiceRequest, payload, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to create Internal Service Request.");
return result.result;
},
getOpportunity: number => number == null ? null : opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
MODAL_ID: contracts.MODAL_ID, environment, searchableSelect,
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestPlatform: () => requestSrFormLookup(messageTypes.requestLookupTypePlatform),
requestServices: parentServiceId => requestSrFormLookup(messageTypes.requestInternalSrServices, { parentServiceId, emailAddress: currentUser.getUserId() })
});
const crmSrForm = createCrmSrForm({
requestSave: async fields => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestPostServiceRequest, ...fields, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to save Service Request.");
return result;
},
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestLanguage: () => requestSrFormLookup(messageTypes.requestSrLookupLanguage),
requestPlatform: () => requestSrFormLookup(messageTypes.requestSrLookupTechPlatform),
requestServices: () => requestSrFormLookup(messageTypes.requestSrLookupOpportunityBusiness, { emailAddress: currentUser.getUserId() }),
searchableSelect,
MODAL_ID: contracts.MODAL_ID, environment,
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
isMissing: number => opportunityCrmServiceRequests.getSummary(number)?.state === "missing",
createCloseIcon: () => contentIcons.createCloseIcon(),
requestPillars: () => requestSrFormLookup(messageTypes.requestSrLookupPrimaryPillar)
});
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
createSrButton: number => crmSrForm.createButton(number),
closeSrForm: () => { internalSrForm.close(false); crmSrForm.close(false); },
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 opportunityCalendar = createOpportunityCalendar({
createViewIcon: view => contentIcons.createViewIcon(view),
environment, loadLibrary: () => loadOpportunityCalendarLibrary(),
getState: () => opportunitiesTableState.opportunitiesTableState,
getItems: () => opportunitiesFilters.getSortedOpportunities(),
getVersion: () => opportunitiesQueryState.periodRequestVersion,
formatRevenue: item => opportunitiesFormatters.formatRevenue(item),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
getOwnerName: item => opportunitiesFormatters.getOwnerName(item),
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: () => contentIcons.createCloseIcon(),
createStageBadge: value => opportunitiesLinks.createStageBadge(value),
createStatusBadge: value => opportunitiesLinks.createStatusBadge(value),
opportunityUrl: contracts.OPPORTUNITY_DETAIL_URL, modalId: contracts.MODAL_ID,
closeSrPopover: () => crmSrPopover.close()
});
const opportunitiesTable = createOpportunitiesTable({
renderCalendar: (...args) => opportunityCalendar.render(...args),
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 srSessionPreparation = createSrSessionPreparation({
async onReady() {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestResourceUser, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load resource user.");
},
ensureCurrentUser: () => currentUser.ensure(),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
sendRequest: () => platformContentMessages.sendRuntimeMessage({ type: messageTypes.prepareSrSession }),
reportProgress: state => { activityProgress.update("session", state); manageTime.setReady(state === "complete"); },
appendLog: entries => debugContentLog.appendBackgroundRequestLog(entries)
});
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({
searchableSelect,
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,
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 {
opportunityCalendar,
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,
srSessionPreparation,
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 };
}

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

@@ -0,0 +1,109 @@
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;
}
function createViewIcon(view) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const calendarFrame = "M8 2v4m8-4v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z";
path.setAttribute("d", view === "today" ? `${calendarFrame}M9 16l2 2 4-4`
: view === "week" ? `${calendarFrame}M6 14h12v4H6Z M10 14v4m4-4v4`
: view === "calendar"
? "M8 2v4m8-4v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2ZM7 14h2m6 0h2m-10 4h2m6 0h2"
: "M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1ZM3 9h18M9 9v12M3 15h18");
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, createViewIcon };
}

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 });
}

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

@@ -0,0 +1,578 @@
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,
createManageTimeButton,
createActivityFilterControls,
mountOpportunityViews,
debugState,
ensureExtensionStyles,
getCustomerOptions,
getOwnerOptions,
getSavedOpportunityTypeValues,
getSavedOpptyProductsCacheHours,
getSavedTheme,
openPreferencesModal,
opportunitiesFilterState,
opportunitiesQueryState,
preferencesState,
renderCustomerFilter,
renderCustomerFilterList,
renderOpportunitiesTable,
renderOwnerFilter,
renderOwnerFilterList,
renderStageFilterButtons,
renderStatusFilterButtons,
requestOpportunitiesForPeriod,
closeCrmSrPopover,
requestTokenRelayOnce,
ensureCurrentUser,
prepareSrSession,
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;
prepareSrSession?.();
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), createManageTimeButton(), 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);
mountOpportunityViews(body, 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);
prepareSrSession?.();
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,202 @@
function createDebugContentLog({
previewDebugBody,
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);
}
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, requestName = "") {
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; }
}
if (!debugState.debugSecretsVisible && requestName === "requestSrTokenCatalog") {
value = value && typeof value === "object" && !Array.isArray(value)
? { ...value, ...(Object.hasOwn(value, "result") ? { result: formatHiddenDebugValue(value.result) } : {}) }
: formatHiddenDebugValue(value);
}
return JSON.stringify(debugState.debugSecretsVisible ? value : maskSensitiveDebugValue(value), null, 2);
} catch (error) {
if (!debugState.debugSecretsVisible && /^(requestSrTokenCatalog|requestCurrentUser|requestTokenRelay.*)$/.test(requestName)) return formatHiddenDebugValue(text);
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$|x-appbuilder-client-id/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,360 @@
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 (["requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestResourceUser", "requestSrCatalog", "requestSrTokenCatalog", "requestPostServiceRequest"].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, entry.name));
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,64 @@
.oe-time-dialog { --time-bg: #fff; --time-input-bg: #fff; --time-muted: #faf9f8; --time-border: #dedbd7; --time-input-border: #8f8983; --time-accent: #00758f; width: min(1600px, 100%); max-height: calc(100dvh - 24px); display: flex; flex-direction: column; overflow: hidden; font-size: 13px; line-height: 20px; }
.opportunities-extension-modal[data-theme="dark"] .oe-time-dialog { --time-bg: #292725; --time-input-bg: #211f1d; --time-muted: #35322f; --time-border: #514c47; --time-input-border: #918a82; --time-accent: #80d6e8; }
.oe-time-modal { grid-template-columns: minmax(0, 1fr); }
.oe-time-dialog, .oe-time-body { min-width: 0; }
.oe-time-dialog * { box-sizing: border-box; }
.oe-time-dialog [hidden] { display: none !important; }
.oe-time-header { flex-shrink: 0; }
.oe-time-body { padding: 18px; overflow: auto; min-height: 0; }
.oe-time-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.oe-time-navigation { display: flex; align-items: center; gap: 8px; }
.oe-time-ending { display: grid; gap: 4px; text-align: right; }
.oe-time-summary { display: grid; grid-template-columns: repeat(4, 1fr); padding: 12px 0; gap: 12px; text-align: center; border-block: 1px solid var(--time-border); margin-bottom: 18px; }
.oe-time-body button { min-width: 38px; min-height: 38px; display: inline-flex; gap: 8px; align-items: center; justify-content: center; color: inherit; background: transparent; border: 1px solid var(--time-input-border); border-radius: 4px; padding: 7px; cursor: pointer; font: inherit; }
.oe-time-dialog button:hover:not(:disabled) { background: var(--time-muted); border-color: var(--time-accent); }
.oe-time-dialog svg { width: 17px; height: 17px; flex-shrink: 0; }
.oe-time-dialog input, .oe-time-dialog select, .oe-time-dialog textarea { font: inherit; line-height: 20px; color: inherit; background: var(--time-input-bg); border: 1px solid var(--time-input-border); border-radius: 4px; padding: 8px; min-height: 38px; }
.oe-time-dialog select { height: 38px; }
.oe-time-dialog :focus-visible { outline: 2px solid var(--time-accent); outline-offset: 2px; }
.oe-time-dialog :disabled { opacity: .55; cursor: not-allowed; }
.oe-time-grid { overflow-x: auto; }
.oe-time-grid table { width: 100%; min-width: 1060px; border-collapse: collapse; table-layout: fixed; }
.oe-time-grid th { background: var(--time-muted); padding: 10px 5px; font-weight: 600; text-align: center; border-bottom: 1px solid var(--time-border); }
.oe-time-grid th:first-child { width: 23%; }
.oe-time-grid th:nth-child(2), .oe-time-grid th:nth-child(3) { width: 12%; }
.oe-time-grid th:nth-last-child(-n+3) { width: 46px; }
.oe-time-grid td { padding: 10px 5px; border-bottom: 1px solid var(--time-border); vertical-align: middle; text-align: center; }
.oe-time-duplicate-row { outline: 2px solid #b3261e; outline-offset: -2px; animation: oe-time-duplicate-fade 5.5s linear forwards; }
.opportunities-extension-modal[data-theme="dark"] .oe-time-duplicate-row { --time-duplicate-color: #ffaaa4; }
@keyframes oe-time-duplicate-fade { 0%, 90.909% { outline-color: var(--time-duplicate-color, #b3261e); } 100% { outline-color: transparent; } }
@media (prefers-reduced-motion: reduce) { .oe-time-duplicate-row { animation-timing-function: step-end; } }
.oe-time-grid select { width: 100%; }
.oe-time-grid input[type="number"] { width: min(100%, 60px); padding: 6px 3px; text-align: center; }
.oe-time-grid .opportunities-extension-action-combobox input { width: 100%; text-align: left; text-overflow: ellipsis; }
.oe-time-grid .opportunities-extension-action-options { text-align: left; }
.oe-time-dialog .opportunities-extension-select-tooltip { position: fixed; z-index: 100; width: min(560px, calc(100vw - 40px)); max-height: min(440px, calc(100dvh - 48px)); overflow: auto; padding: 14px; border: 1px solid var(--time-input-border); border-radius: 4px; background: var(--time-input-bg); color: inherit; box-shadow: 0 4px 16px #0003; text-align: left; }
.oe-time-sr-details { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 18px; margin: 0; }
.oe-time-sr-details > div { min-width: 0; overflow-wrap: anywhere; }
.oe-time-sr-details dt { font-size: 11px; opacity: .75; margin-bottom: 2px; }
.oe-time-sr-details dd { margin: 0; white-space: pre-wrap; }
.oe-time-sr-details .oe-time-sr-description { grid-column: 1 / -1; }
.oe-time-day > span { display: block; }
.oe-time-day .oe-time-day-label { display: inline-flex; align-items: center; gap: 4px; margin-bottom: 9px; }
.oe-time-day-total { font-size: 10px; background: var(--time-accent); color: var(--time-bg); min-width: 16px; border-radius: 8px; padding: 2px 4px; }
.oe-time-notes textarea { width: 100%; resize: vertical; }
.oe-time-notes-box { position: relative; text-align: left; background: var(--time-input-bg); border: 1px solid var(--time-input-border); border-radius: 4px; padding: 14px 12px 42px; }
.oe-time-notes-count { display: block; font-weight: 600; opacity: .8; margin-bottom: 8px; }
.oe-time-notes-box textarea { border: 0; min-height: 128px; padding: 0; }
.oe-time-notes-box .oe-time-notes-collapse { position: absolute; right: 0; bottom: 0; background: var(--time-muted); }
.oe-time-footer { flex-wrap: wrap; justify-content: flex-start; margin-top: 18px; }
.oe-time-footer button { padding: 7px 14px; }
.oe-time-status { padding: 12px 0; }
.oe-time-loading-status { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.oe-time-skeleton { display: inline-block; vertical-align: middle; border-radius: 4px; background: var(--time-border); color: transparent; animation: oe-time-pulse 1.3s ease-in-out infinite alternate; }
.oe-time-skeleton-select { display: block; width: 100%; height: 38px; }
.oe-time-skeleton-control { width: min(100%, 60px); height: 38px; }
.oe-time-skeleton-heading { width: 65%; height: 20px; margin: 17px 0; }
.oe-time-skeleton-summary { min-width: 44px; height: 20px; }
@keyframes oe-time-pulse { from { opacity: .4; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .oe-time-skeleton { animation: none; } }
.oe-time-status[role="alert"] { color: #a12d27; }
.oe-time-dialog [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
.opportunities-extension-modal[data-theme="dark"] .oe-time-status[role="alert"] { color: #ffd9d5; }
.opportunities-extension-modal[data-theme="dark"] .oe-time-dialog [aria-invalid="true"] { border-color: #ffaaa4; outline-color: #ffaaa4; }
@media (max-width: 700px) { .oe-time-modal { padding: 8px; } .oe-time-dialog { max-height: calc(100dvh - 16px); } .oe-time-body { padding: 12px; } .oe-time-summary { grid-template-columns: repeat(2, 1fr); text-align: left; } .oe-time-toolbar { flex-wrap: wrap; } .oe-time-ending { text-align: left; } }

View File

@@ -0,0 +1,525 @@
function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requestWeek, requestSave, requestDelete, notify = () => {}, requestEntries = async () => [], requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) {
const { document } = environment;
let active = null;
let ready = false;
let launcher = null;
const referenceDateKey = "opportunities-extension-manage-time-reference-date";
let lastReferenceDate = "";
function savedReferenceDate() {
try {
const saved = environment.localStorage?.getItem(referenceDateKey);
if (dates.parse(saved)) lastReferenceDate = saved;
} catch { /* Keep the in-memory selection when storage is unavailable. */ }
return dates.parse(lastReferenceDate) ? lastReferenceDate : dates.today();
}
function saveReferenceDate(value) {
if (!dates.parse(value)) return;
lastReferenceDate = value;
try { environment.localStorage?.setItem(referenceDateKey, value); }
catch { /* Keep the selection for this page even if persistence is blocked. */ }
}
function setReady(value) {
ready = !!value;
if (launcher) { launcher.disabled = !ready; launcher.title = ready ? "Manage Time" : "Waiting for SR session ready"; }
}
function node(tag, className = "", text) {
const el = document.createElement(tag);
el.className = className;
if (text !== undefined) el.textContent = text;
return el;
}
function icon(name) {
const paths = {
clock: "M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0ZM12 6v6l4 2",
left: "m15 18-6-6 6-6", right: "m9 18 6-6-6-6", close: "m6 6 12 12M18 6 6 18",
up: "m6 15 6-6 6 6",
add: "M12 5v14M5 12h14", copy: "M9 9h12v12H9ZM5 15H3V3h12v2",
notes: "M4 3h16v18H4ZM8 7h8M8 11h8M8 15h5",
trash: "M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7",
save: "M5 3h12l4 4v14H3V3ZM7 3v6h10V3M7 21v-8h10v8"
};
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", paths[name]);
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;
}
function button(label, symbol, onClick, text = false) {
const el = node("button");
el.type = "button"; el.title = label;
el.setAttribute("aria-label", label);
el.append(icon(symbol));
if (text) el.append(node("span", "", label));
if (onClick) el.addEventListener("click", onClick);
return el;
}
function createButton() {
const trigger = button("Manage Time", "clock", () => open(trigger));
trigger.className = "opportunities-extension-icon-button";
launcher = trigger; setReady(ready);
return trigger;
}
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 open(trigger) {
if (active?.saving) return;
if (!ready) return;
const host = document.getElementById(MODAL_ID);
if (!host || host.hidden) return;
close(false);
const modal = node("section", "opportunities-extension-action-form-modal oe-time-modal");
modal.setAttribute("role", "dialog"); modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "oe-time-title");
const dialog = node("div", "opportunities-extension-action-form-dialog oe-time-dialog");
const header = node("header", "opportunities-extension-account-plans-header oe-time-header");
const title = node("h2", "", "Manage Time"); title.id = "oe-time-title";
const closeButton = button("Close Manage Time", "close", () => close());
closeButton.className = "opportunities-extension-icon-button";
header.append(title, closeButton);
const body = node("div", "oe-time-body");
const toolbar = node("div", "oe-time-toolbar");
const navigation = node("div", "oe-time-navigation");
const date = node("input"); date.type = "date"; date.value = savedReferenceDate(); date.required = true;
date.setAttribute("aria-label", "Reference date");
const ending = node("div", "oe-time-ending");
const endingValue = node("strong", "", "--");
ending.append(node("span", "", "Week Ending"), endingValue);
const changeDate = () => { saveReferenceDate(date.value); load(); };
const changeWeek = days => { if (dates.parse(date.value)) { date.value = dates.shift(date.value, days); changeDate(); } };
navigation.append(button("Previous week", "left", () => changeWeek(-7)), date, button("Next week", "right", () => changeWeek(7)));
date.addEventListener("change", changeDate); toolbar.append(navigation, ending);
const summary = node("div", "oe-time-summary"), sums = {};
for (const label of ["Service", "Non Service", "Time Off", "Total Hours"]) {
const item = node("div"); sums[label] = node("strong", "", "0h");
item.append(node("span", "", `${label}: `), sums[label]); summary.append(item);
}
const status = node("div", "oe-time-status"); status.setAttribute("role", "status");
const retry = button("Retry", "clock", load, true); retry.hidden = true;
const serviceStatus = node("div", "oe-time-status"); serviceStatus.setAttribute("role", "status"); serviceStatus.hidden = true;
const serviceRetry = button("Retry Service Requests", "clock", () => loadServices(entry.initialWeekId, true), true); serviceRetry.hidden = true;
const taskStatus = node("div", "oe-time-status"); taskStatus.setAttribute("role", "status");
const taskRetry = button("Retry Task Types", "clock", loadTaskTypes, true); taskRetry.hidden = true;
const scroll = node("div", "oe-time-grid"), table = node("table");
table.setAttribute("aria-label", "Weekly time entries"); scroll.append(table);
const footer = node("footer", "opportunities-extension-action-form-footer oe-time-footer");
const add = button("Add", "add", () => addRow("Service"), true);
const addNonService = button("Add (Non service)", "add", () => addRow("Non Service"), true);
const save = button("Save", "save", saveEntries, true); save.disabled = true;
const saveStatus = node("div", "oe-time-status"); saveStatus.hidden = true; saveStatus.setAttribute("role", "status");
footer.append(add, addNonService, save);
body.append(toolbar, summary, status, retry, serviceStatus, serviceRetry, taskStatus, taskRetry, scroll, saveStatus, footer);
dialog.append(header, body); modal.append(dialog); host.append(modal);
const entry = { modal, trigger, version: 0, days: [], rows: [], drafts: new Map(), tbody: null, dayTotals: [], services: [], servicesStarted: false, servicesReady: false, expanded: new Map() };
active = entry;
entry.deletions = new Map();
entry.taskTypes = [];
entry.taskTypesReady = false;
let combos = [];
modal.addEventListener("scroll", event => {
if (event.target === body || event.target === scroll) combos.forEach(combo => combo.sync());
}, true);
modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key !== "Tab") return;
const elements = [...dialog.querySelectorAll("button:not(:disabled), input:not(:disabled), textarea:not(:disabled)")].filter(el => !el.hidden && el.getClientRects().length);
const first = elements[0], last = elements[elements.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
});
function blankRow(type) { return { type, hours: {}, invalidHours: {}, notes: "", srNumber: "", activity: "", taskType: "", notesOpen: false }; }
const hourFields = ["sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours", "saturdayTaskHours"];
function rowPayload(row) {
const payload = { srNumber: row.type === "Non Service" ? "non-service-sr" : row.srNumber,
activityCode: row.activity, taskType: row.taskType, deliveryType: row.deliveryType || "",
notes: row.notes, weekId: entry.days[0]?.WeekId, tag: row.type === "Non Service" ? "NON_SERVICE" : "SERVICE" };
for (const day of entry.days) payload[hourFields[dates.fromApi(day.Date).getUTCDay()]] = Number(row.hours[day.Date]) || 0;
return payload;
}
function signature(row) { return JSON.stringify(rowPayload(row)); }
function touched(row) { return row.id || row.persisted || row.srNumber || row.activity || row.taskType || row.notes.trim() || Object.values(row.hours).some(value => value !== "") || Object.values(row.invalidHours).some(Boolean); }
function validRow(row) {
return !!row.taskType && (row.type === "Non Service" || (!!row.srNumber && !!row.activity))
&& !Object.values(row.invalidHours).some(Boolean)
&& Object.values(row.hours).every(value => Number.isFinite(Number(value)) && Number(value) >= 0 && Number(value) <= 24)
&& Object.values(row.hours).some(value => Number(value) > 0);
}
function changedRows() { return entry.rows.filter(row => touched(row) && signature(row) !== row.baseline); }
function pendingDeletes() {
const key = entry.days.map(day => day.Date).join("|");
if (!entry.deletions.has(key)) entry.deletions.set(key, new Set());
return entry.deletions.get(key);
}
function updateSave() {
const rows = changedRows();
const deletions = pendingDeletes();
save.disabled = (rows.length > 0 && !requestSave) || (deletions.size > 0 && !requestDelete) || !entry.tbody || entry.saving || (!rows.length && !deletions.size)
|| entry.rows.some(row => touched(row) && !validRow(row)) || rows.some(row => row.persisted && !row.id);
save.title = save.disabled ? "Complete the required fields and enter hours to save changes." : "Save time entries";
}
async function saveEntries() {
updateSave();
if (save.disabled) return;
const rows = changedRows(), knownIds = new Set(entry.rows.filter(row => row.id).map(row => String(row.id)));
const controls = [];
function lock(node) {
if (["BUTTON", "INPUT", "TEXTAREA", "SELECT"].includes(String(node.tagName || node.tag).toUpperCase())) { controls.push([node, node.disabled]); node.disabled = true; }
Array.from(node.children).forEach(lock);
}
entry.saving = true; lock(modal);
saveStatus.hidden = false; saveStatus.setAttribute("role", "status"); saveStatus.textContent = "Saving time entries...";
let savedCount = 0, deletedCount = 0;
try {
const deletions = pendingDeletes();
for (const id of [...deletions]) {
await requestDelete(id);
deletions.delete(id);
deletedCount++;
}
for (const method of ["PUT", "POST"]) {
const group = rows.filter(row => method === "PUT" ? !!row.id : !row.id);
if (!group.length) continue;
const result = await requestSave(method, group.map(row => ({ ...rowPayload(row), ...(row.id ? { id: row.id } : {}) })));
group.forEach(row => { row.persisted = true; row.baseline = signature(row); });
savedCount += group.length;
if (method === "POST") {
let records = Array.isArray(result) ? result : Array.isArray(result?.items) ? result.items : [];
function assignIds() {
const candidates = rowsFromEntries(records, entry.days);
for (const row of group.filter(row => !row.id)) {
const match = candidates.find(candidate => candidate.id && !knownIds.has(String(candidate.id)) && signature(candidate) === row.baseline);
if (match) { row.id = match.id; knownIds.add(String(match.id)); }
}
}
assignIds();
if (group.some(row => !row.id)) { records = await requestEntries(entry.days[0].WeekId); assignIds(); }
if (group.some(row => !row.id)) throw new Error("Entries saved, but their IDs could not be confirmed. Reopen Manage Time before editing these entries.");
}
}
saveStatus.textContent = [savedCount ? `${savedCount} time ${savedCount === 1 ? "entry" : "entries"} saved.` : "", deletedCount ? `${deletedCount} time ${deletedCount === 1 ? "entry" : "entries"} deleted.` : ""].filter(Boolean).join(" ");
notify(saveStatus.textContent);
saveStatus.hidden = true;
} catch (error) {
saveStatus.setAttribute("role", "alert");
saveStatus.textContent = `${savedCount ? `${savedCount} saved. ` : ""}${deletedCount ? `${deletedCount} deleted. ` : ""}${error.message || "Unable to save time entries."}`;
notify(saveStatus.textContent, true);
} finally {
entry.saving = false;
controls.forEach(([control, disabled]) => { control.disabled = disabled; });
if (active === entry) { renderRows(); updateSave(); }
}
}
function total(value) { return Number(Number(value).toFixed(2)).toString(); }
function updateTotals() {
const groups = { Service: 0, "Non Service": 0, "Time Off": 0, "Total Hours": 0 };
entry.days.forEach((day, i) => {
let sum = 0;
entry.rows.forEach(row => {
const hours = Number(row.hours[day.Date]) || 0;
sum += hours; groups[row.type] += hours; groups["Total Hours"] += hours;
});
entry.dayTotals[i].textContent = total(sum);
});
Object.entries(groups).forEach(([label, value]) => { sums[label].textContent = `${total(value)}h`; });
updateSave();
}
function addRow(type, source) {
if (!entry.tbody || add.disabled) return;
entry.rows.push(source ? { ...source, id: undefined, persisted: false, baseline: undefined, duplicateHighlightAt: undefined,
...(source.type === "Service" ? { srNumber: "", activity: "" } : {}),
invalidHours: { ...source.invalidHours }, hours: { ...source.hours } } : blankRow(type)); renderRows();
}
function renderRows() {
if (entry.saving) return;
const focusedId = document.activeElement?.id;
combos = [];
entry.tbody.replaceChildren();
entry.rows.forEach((row, index) => {
const tr = node("tr");
if (row.duplicateHighlightAt && Date.now() - row.duplicateHighlightAt < 5500) {
tr.className = "oe-time-duplicate-row";
tr.setAttribute("style", `animation-delay: -${Math.max(0, Date.now() - row.duplicateHighlightAt)}ms`);
tr.addEventListener("animationend", () => { tr.className = ""; row.duplicateHighlightAt = undefined; });
}
for (const label of [row.type === "Service" ? "Service Request" : "Non Service", "Activity", "Task Type"]) {
const td = node("td"), select = node("select");
select.id = `oe-time-${index}-${label.toLowerCase().replaceAll(" ", "-")}`;
let change = () => {};
select.setAttribute("aria-label", `${label}, row ${index + 1}`);
const placeholder = node("option", "", label); placeholder.value = ""; select.append(placeholder);
if (label === "Service Request") {
for (const sr of entry.services) {
const option = node("option", "", sr.srTitle ? `${sr.srTitle} (${sr.srNumber})` : sr.srNumber);
option.value = sr.srNumber; option.title = option.textContent; select.append(option);
}
if (row.srNumber && !entry.services.some(sr => sr.srNumber === row.srNumber)) {
const option = node("option", "", row.srNumber); option.value = row.srNumber; select.append(option);
}
select.value = row.srNumber;
select.disabled = !entry.servicesReady;
select.title = entry.servicesReady ? "Service Request" : "Loading Service Requests";
change = () => {
const duplicate = select.value && entry.rows.find(other => other !== row && other.type === "Service" && other.srNumber === select.value);
if (duplicate) {
duplicate.duplicateHighlightAt = Date.now();
select.value = row.srNumber;
notify("This Service Request is already selected in another row.", true);
renderRows(); return;
}
row.srNumber = select.value; row.activity = ""; renderRows();
};
} else if (label === "Activity") {
const source = entry.expanded.get(row.type === "Service" ? row.srNumber : "non-service-sr");
const raw = String(source?.SRActivities_c || "");
const activities = [...new Set(raw.split(raw.includes(";") ? ";" : ",").map(value => value.trim()).filter(Boolean))];
if (row.activity && !activities.includes(row.activity)) activities.push(row.activity);
for (const activity of activities) { const option = node("option", "", activity); option.value = activity; select.append(option); }
select.value = row.activity; select.disabled = !activities.length;
select.title = activities.length ? "Activity" : "No activities available for this Service Request";
change = () => { row.activity = select.value; updateSave(); };
} else if (label === "Task Type") {
const tag = row.type === "Service" ? "SERVICE" : "NON_SERVICE";
const taskTypes = entry.taskTypes.filter(item => item.tags_c === tag);
for (const item of taskTypes) {
const option = node("option", "", item.lookUpMeaning_c); option.value = item.lookUpCode_c; select.append(option);
}
if (row.taskType && !taskTypes.some(item => item.lookUpCode_c === row.taskType)) {
const option = node("option", "", row.taskType); option.value = row.taskType; select.append(option);
}
select.value = row.taskType;
select.disabled = !entry.taskTypesReady || (!taskTypes.length && !row.taskType);
select.title = !entry.taskTypesReady ? "Loading Task Types" : taskTypes.length ? "Task Type" : "No Task Types available for this row type";
change = () => { row.taskType = select.value; updateSave(); };
} else { select.disabled = true; select.title = "Options are not available yet."; }
td.append(select); tr.append(td);
select.addEventListener("change", change);
const combo = searchableSelect(select, td, change, {
floating: true,
listWidth: label === "Service Request" ? 640 : 0,
listMaxHeight: label === "Service Request" ? 360 : 180,
tooltipContent: label === "Service Request" ? serviceRequestTooltip : undefined
});
combo.input.setAttribute("aria-label", `${label}, row ${index + 1}`);
combos.push(combo);
}
for (const day of entry.days) {
const td = node("td"), input = node("input");
input.type = "number"; input.min = "0"; input.max = "24"; input.step = "1";
input.value = row.hours[day.Date] ?? "";
input.setAttribute("aria-invalid", String(!!row.invalidHours[day.Date]));
input.setAttribute("aria-label", `Hours ${day.Date}, row ${index + 1}`);
input.addEventListener("input", () => {
const valid = !input.validity?.badInput && (input.value === "" || (Number.isFinite(input.valueAsNumber) && input.valueAsNumber >= 0 && input.valueAsNumber <= 24));
if (!valid) {
input.value = row.hours[day.Date] ?? "";
notify("Enter hours between 0 and 24.", true);
return;
}
row.invalidHours[day.Date] = !valid;
input.setAttribute("aria-invalid", String(!valid)); row.hours[day.Date] = input.value; updateTotals();
}); td.append(input); tr.append(td);
}
const notesCell = node("td"), notesRow = node("tr", "oe-time-notes"); notesRow.hidden = !row.notesOpen;
const noteCell = node("td"); noteCell.colSpan = entry.days.length + 6;
const notesBox = node("div", "oe-time-notes-box");
const remaining = node("span", "oe-time-notes-count", `${2000 - row.notes.length} characters left`);
const notes = node("textarea"); notes.rows = 6; notes.maxLength = 2000; notes.value = row.notes;
notes.setAttribute("aria-label", `Notes, row ${index + 1}`);
notes.addEventListener("input", () => { row.notes = notes.value.slice(0, 2000); remaining.textContent = `${2000 - row.notes.length} characters left`; updateSave(); });
const collapse = button(`Collapse notes, row ${index + 1}`, "up", () => {
row.notesOpen = false; notesRow.hidden = true; notesButton.setAttribute("aria-expanded", "false"); notesButton.focus();
});
collapse.className = "oe-time-notes-collapse";
notesBox.append(remaining, notes, collapse); noteCell.append(notesBox); notesRow.append(noteCell);
const notesButton = button(`Notes, row ${index + 1}`, "notes", () => {
row.notesOpen = !row.notesOpen; notesRow.hidden = !row.notesOpen; notesButton.setAttribute("aria-expanded", String(row.notesOpen));
if (!notesRow.hidden) notes.focus();
}); notesButton.setAttribute("aria-expanded", String(row.notesOpen)); notesCell.append(notesButton); tr.append(notesCell);
const copy = node("td"); copy.append(button(`Copy row ${index + 1}`, "copy", () => addRow(row.type, row))); tr.append(copy);
const remove = node("td"); remove.append(button(`Delete row ${index + 1}`, "trash", () => {
if (entry.saving) return;
if (row.persisted && !row.id) {
saveStatus.hidden = false; saveStatus.setAttribute("role", "alert");
saveStatus.textContent = "Reopen Manage Time to obtain the saved entry ID before deleting it."; return;
}
if (row.id) pendingDeletes().add(String(row.id));
entry.rows.splice(index, 1); renderRows();
saveStatus.hidden = false; saveStatus.setAttribute("role", "status");
saveStatus.textContent = pendingDeletes().size ? `${pendingDeletes().size} pending ${pendingDeletes().size === 1 ? "deletion" : "deletions"}. Click Save to apply.` : "Unsaved row removed.";
})); tr.append(remove);
entry.tbody.append(tr, notesRow);
}); updateTotals();
combos.find(combo => combo.input.id === focusedId)?.input.focus();
}
function serviceRequestTooltip(srNumber) {
const sr = entry.expanded.get(srNumber);
if (!sr) return null;
const details = node("dl", "oe-time-sr-details");
for (const [key, label] of [
["SrNumber", "SR number"], ["AccountPartyUniqueName", "Account"],
["OptyName_c", "Opportunity"], ["OptyNumber_c", "Opty number"],
["OptyOwner_c", "Opty owner"], ["OptyStatus_c", "Opty status"],
["ServiceNew_c", "Service"], ["ReportedByPartyUniqueName", "Requested by"],
["CountryText_c", "Country"], ["StatusCdMeaning", "Status"],
["AssigneePersonName", "Service lead"], ["ProblemDescription", "Description"]
]) {
const field = node("div", key === "ProblemDescription" ? "oe-time-sr-description" : "");
field.append(node("dt", "", label), node("dd", "", String(sr[key] ?? "-")));
details.append(field);
}
return details;
}
async function loadTaskTypes() {
taskStatus.className = "oe-time-status oe-time-loading-status";
taskStatus.hidden = false; taskStatus.textContent = "Loading Task Types...";
taskStatus.setAttribute("role", "status"); taskRetry.hidden = true;
try {
const items = await requestTaskTypes();
if (active !== entry) return;
entry.taskTypes = items.filter(item => typeof item?.lookUpCode_c === "string" && item.lookUpCode_c.trim()
&& typeof item.lookUpMeaning_c === "string" && item.lookUpMeaning_c.trim());
entry.taskTypesReady = true;
taskStatus.className = "oe-time-status";
taskStatus.textContent = "No Task Types available."; taskStatus.hidden = entry.taskTypes.length > 0;
if (entry.tbody) renderRows();
} catch (error) {
if (active !== entry) return;
taskStatus.className = "oe-time-status";
taskStatus.textContent = error.message || "Unable to load Task Types.";
taskStatus.setAttribute("role", "alert"); taskRetry.hidden = false;
}
}
async function loadServices(weekId, retrying = false) {
if (entry.servicesStarted && !retrying) return;
entry.servicesStarted = true; entry.initialWeekId = weekId;
serviceRetry.hidden = true; serviceStatus.hidden = false;
serviceStatus.className = "oe-time-status oe-time-loading-status";
serviceStatus.setAttribute("role", "status"); serviceStatus.textContent = "Loading Service Requests...";
try {
const services = entry.servicesReady ? entry.services : await requestServices(weekId);
if (active !== entry) return;
entry.services = services; entry.servicesReady = true;
serviceStatus.textContent = services.length ? "" : "No active Service Requests.";
serviceStatus.hidden = services.length > 0;
if (entry.tbody) renderRows();
serviceStatus.hidden = false; serviceStatus.textContent = "Loading activities...";
const expanded = await requestExpand(services.map(sr => sr.srNumber));
if (active !== entry) return;
entry.expanded = new Map(expanded.map(sr => [sr.SrNumber, sr]));
serviceStatus.hidden = true;
if (entry.tbody) renderRows();
} catch (error) {
if (active !== entry) return;
serviceStatus.className = "oe-time-status";
serviceStatus.textContent = error.message || "Unable to load Service Requests.";
serviceStatus.setAttribute("role", "alert"); serviceRetry.hidden = false;
}
}
function rowsFromEntries(records, days) {
return records.map(record => {
const row = blankRow(record.tag === "NON_SERVICE" || record.srNumber === "non-service-sr" ? "Non Service" : "Service");
row.id = record.id;
row.deliveryType = record.deliveryType || "";
row.srNumber = record.srNumber || "";
row.activity = record.activityCode || "";
row.taskType = record.taskType || "";
row.notes = typeof record.notes === "string" ? record.notes : "";
for (const day of days) {
const value = record[hourFields[dates.fromApi(day.Date).getUTCDay()]];
row.hours[day.Date] = value == null ? "" : String(value);
}
row.baseline = signature(row);
return row;
});
}
function renderWeek(items, records) {
entry.days = items;
const key = items.map(day => day.Date).join("|");
if (!entry.drafts.has(key)) entry.drafts.set(key, records.length ? rowsFromEntries(records, items) : Array.from({ length: 3 }, () => blankRow("Service")));
entry.rows = entry.drafts.get(key);
const head = node("thead"), row = node("tr");
for (const label of ["Service Request", "Activity", "Task Type"]) row.append(node("th", "", label));
entry.dayTotals = [];
for (const day of items) {
const parsed = dates.fromApi(day.Date), th = node("th", "oe-time-day");
const dayLabel = node("span", "oe-time-day-label", ["Su", "M", "T", "W", "Th", "F", "Sa"][parsed.getUTCDay()]);
const count = node("span", "oe-time-day-total", "0"); entry.dayTotals.push(count); dayLabel.append(count);
th.append(dayLabel, node("span", "", parsed.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }))); row.append(th);
}
for (const label of ["Notes", "Copy", ""]) row.append(node("th", "", label));
[...row.children].forEach(th => th.setAttribute("scope", "col"));
head.append(row); entry.tbody = node("tbody"); table.replaceChildren(head, entry.tbody);
endingValue.textContent = dates.fromApi(items[0].EndDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); renderRows();
}
function renderSkeleton() {
const head = node("thead"), heading = node("tr"), skeletonBody = node("tbody");
for (let column = 0; column < 13; column++) {
const cell = node("th");
cell.append(node("span", "oe-time-skeleton oe-time-skeleton-heading")); heading.append(cell);
}
head.append(heading);
for (let row = 0; row < 3; row++) {
const tr = node("tr", "oe-time-skeleton-row");
for (let column = 0; column < 13; column++) {
const cell = node("td");
cell.append(node("span", `oe-time-skeleton ${column < 3 ? "oe-time-skeleton-select" : "oe-time-skeleton-control"}`)); tr.append(cell);
}
skeletonBody.append(tr);
}
table.setAttribute("aria-hidden", "true"); table.replaceChildren(head, skeletonBody);
}
function finishSkeleton() {
table.setAttribute("aria-hidden", "false");
endingValue.className = "";
Object.values(sums).forEach(value => { value.className = ""; });
status.className = "oe-time-status";
}
async function load() {
if (entry.saving) return;
finishSkeleton();
const version = ++entry.version; entry.tbody = null; table.replaceChildren(); endingValue.textContent = "--";
save.disabled = true; saveStatus.hidden = true;
Object.values(sums).forEach(value => { value.textContent = "--"; });
add.disabled = addNonService.disabled = true; retry.hidden = true; status.hidden = false;
status.setAttribute("role", "status"); scroll.setAttribute("aria-busy", "false");
if (!dates.parse(date.value)) { status.textContent = "Select a valid reference date."; return; }
status.className = "oe-time-status oe-time-loading-status";
endingValue.className = "oe-time-skeleton oe-time-skeleton-summary";
Object.values(sums).forEach(value => { value.className = "oe-time-skeleton oe-time-skeleton-summary"; });
renderSkeleton();
status.textContent = "Loading week details..."; scroll.setAttribute("aria-busy", "true");
try {
const items = await requestWeek(date.value);
if (active !== entry || entry.version !== version) return;
status.textContent = "Loading time entries...";
const key = items.map(day => day.Date).join("|");
const records = entry.drafts.has(key) ? [] : await requestEntries(items[0].WeekId);
if (active !== entry || entry.version !== version) return;
finishSkeleton(); renderWeek(items, records); add.disabled = addNonService.disabled = false; status.hidden = true;
loadServices(items[0].WeekId);
} catch (error) {
if (active !== entry || entry.version !== version) return;
finishSkeleton(); table.replaceChildren();
status.textContent = error.message || "Unable to load week details.";
status.setAttribute("role", "alert"); retry.hidden = false;
} finally {
if (active === entry && entry.version === version) scroll.setAttribute("aria-busy", "false");
}
}
date.focus(); load(); loadTaskTypes();
}
return { createButton, open, close, setReady };
}

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,39 @@
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 === "session" ? "SR session" : 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}`;
if (key === "session") entry.badge.textContent = state === "complete" ? "SR session ready" : state === "error" ? "SR session unavailable" : "SR session loading";
entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Product workloads checked for Actions in the selected Reference, regardless of display filters";
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,105 @@
.opportunities-extension-views { min-height: 0; min-width: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 8px; }
.opportunities-extension-views > [hidden] { display: none; }
.opportunities-extension-view-tabs { display: flex; gap: 20px; border-bottom: 1px solid #dedbd7; }
.opportunities-extension-view-tabs button { border: 0; border-bottom: 3px solid transparent; background: transparent; color: #625d58; padding: 6px 2px 9px; font: 600 13px/20px Arial, sans-serif; cursor: pointer; }
.opportunities-extension-view-tabs button[aria-selected="true"] { border-bottom-color: #00758f; color: #00657b; }
.opportunities-extension-view-tabs button:focus-visible { outline: 2px solid #00758f; outline-offset: -2px; }
.opportunities-extension-view-tabs button { display: inline-flex; align-items: center; gap: 6px; }
.opportunities-extension-view-tabs button svg { width: 16px; height: 16px; flex: 0 0 16px; }
.opportunities-extension-calendar {
--fc-border-color: #dedbd7; --fc-page-bg-color: #fff; --fc-neutral-bg-color: #f5f4f2;
--fc-neutral-text-color: #625d58; --fc-today-bg-color: #eef7fa;
--fc-button-bg-color: #fff; --fc-button-text-color: #312d2a; --fc-button-border-color: #b9b4ae;
--fc-button-hover-bg-color: #e6f3f5; --fc-button-hover-border-color: #00758f;
--fc-button-active-bg-color: #dceff3; --fc-button-active-border-color: #00758f;
min-height: 0; min-width: 0; overflow: hidden; display: flex; flex-direction: column; padding: 12px; background: #fff; color: #312d2a;
border: 1px solid var(--fc-border-color); border-radius: 4px; font: 13px/1.4 Arial, sans-serif;
}
.opportunities-calendar-grid { flex: 1 1 0; min-height: 0; min-width: 0; }
.opportunities-extension-calendar .fc { min-width: 620px; }
.opportunities-extension-calendar .fc .fc-toolbar { gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.opportunities-extension-calendar .fc .fc-toolbar-title { font-size: 18px; line-height: 26px; letter-spacing: 0; }
.opportunities-extension-calendar .fc .fc-button { font-size: 13px; line-height: 20px; padding: 5px 10px; border-radius: 4px; box-shadow: none; }
.opportunities-extension-calendar .fc .fc-button:focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
.opportunities-extension-calendar .fc .fc-button svg { width: 16px; height: 16px; margin-right: 6px; vertical-align: -3px; }
.opportunities-extension-calendar .fc a { color: inherit; text-decoration: none; }
.opportunities-extension-calendar .fc th { background: var(--fc-neutral-bg-color); font-size: 12px; font-weight: 600; padding: 4px 0; }
.opportunities-extension-calendar .fc .fc-daygrid-day-number { font-size: 11px; line-height: 18px; padding: 1px 6px; }
.opportunities-extension-calendar .fc .fc-daygrid-day-events { min-height: 0; margin-bottom: 0; }
.opportunities-extension-calendar .fc .fc-daygrid-day-bottom { margin: 0; }
.opportunities-extension-calendar .fc .fc-daygrid-day-frame { min-height: 0; padding-top: 0; }
/* Event content must not contribute to the table's intrinsic row heights. */
.opportunities-extension-calendar .fc .fc-daygrid-body tbody { height: 100%; }
.opportunities-extension-calendar .fc .fc-daygrid-body tr { height: calc(100% / var(--opportunities-calendar-weeks, 5)); }
.opportunities-extension-calendar .fc .fc-daygrid-day { position: relative; }
.opportunities-extension-calendar .fc .fc-daygrid-day-frame { position: absolute; inset: 0; }
.opportunities-extension-calendar .fc .fc-daygrid-week-number { top: 0; left: 0; padding: 3px 6px; background: var(--fc-neutral-bg-color); color: inherit; font: 600 10px/16px Arial, sans-serif; white-space: nowrap; }
.opportunities-calendar-day-count { position: absolute; top: 2px; left: 6px; min-width: 18px; height: 16px; padding: 0 4px; border-radius: 8px; background: #dceff3; color: #005467; font: 600 10px/16px Arial, sans-serif; text-align: center; }
.fc-daygrid-week-number ~ .opportunities-calendar-day-count { left: 82px; }
.opportunities-calendar-day-count[hidden] { display: none; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-calendar-day-count:not([data-empty="true"]) { background: #16454f; color: #b9eaf4; }
.opportunities-extension-calendar .fc .fc-day-other { background: var(--fc-neutral-bg-color); }
.opportunities-extension-calendar .fc .fc-daygrid-event { white-space: normal; margin: 3px 4px; border-radius: 4px; cursor: pointer; border-width: 0 0 0 3px; border-style: solid; }
.opportunities-extension-calendar .fc .fc-event-main { color: inherit; }
.opportunities-calendar-event-content { min-width: 0; padding: 5px 6px; overflow-wrap: anywhere; }
.opportunities-calendar-event-content strong { display: block; font-size: 12px; line-height: 16px; font-weight: 600; }
.opportunities-calendar-event-content span { display: block; margin-top: 3px; font-size: 11px; line-height: 15px; }
.opportunities-calendar-event-content strong,
.opportunities-calendar-event-content span,
.opportunities-calendar-day-item strong,
.opportunities-calendar-day-item span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.opportunities-extension-calendar .fc .fc-daygrid-more-link { color: #00657b; font-size: 11px; line-height: 16px; padding: 0 5px; }
.opportunities-calendar-day-list { min-height: 0; overflow-y: auto; padding: 8px 20px 20px; }
.opportunities-calendar-day-item { display: block; width: 100%; padding: 12px 0; border: 0; border-bottom: 1px solid #dedbd7; background: transparent; color: #00657b; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
.opportunities-calendar-day-item strong, .opportunities-calendar-day-item span { display: block; font: 600 13px/20px Arial, sans-serif; }
.opportunities-calendar-day-item span { margin-top: 4px; font-weight: 400; }
.opportunities-calendar-day-item:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-calendar-day-item { border-bottom-color: #514d48; color: #80d6e8; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-SQL { background: #dff1f4; border-color: #00829d; color: #005467; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-PIPELINE,
.opportunities-extension-calendar .fc .opportunities-calendar-stage-OTHER { background: #efeeec; border-color: #706a64; color: #49443f; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-UPSIDE { background: #fff0da; border-color: #ba7000; color: #774700; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-FORECAST { background: #f0e8f7; border-color: #8262a3; color: #624379; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-WON { background: #e2f1e5; border-color: #2f8050; color: #215939; }
.opportunities-calendar-message { margin: 0 0 12px; font-size: 13px; }
.opportunities-calendar-message[hidden], .opportunities-calendar-grid[hidden], .opportunities-calendar-undated[hidden] { display: none; }
.opportunities-calendar-undated { flex: 0 0 auto; max-height: 90px; overflow-y: auto; margin-top: 6px; border-top: 1px solid var(--fc-border-color); padding-top: 4px; }
.opportunities-calendar-undated summary { cursor: pointer; font-size: 12px; line-height: 18px; }
.opportunities-calendar-undated h3 { font-size: 13px; margin: 0 0 8px; }
.opportunities-calendar-undated button { display: block; background: none; border: 0; text-align: left; font: inherit; padding: 6px 0; color: inherit; text-decoration: underline; cursor: pointer; overflow-wrap: anywhere; }
.opportunities-calendar-detail .opportunities-extension-account-plans-header { align-items: start; gap: 16px; }
.opportunities-calendar-detail .opportunities-extension-action-form-dialog { display: grid; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; }
.opportunities-calendar-detail h2 { font-size: 18px; overflow-wrap: anywhere; }
.opportunities-calendar-detail-content { padding: 20px; overflow-y: auto; }
.opportunities-calendar-detail-content a { color: #006b82; }
.opportunities-calendar-detail-badges { display: flex; gap: 8px; margin-top: 18px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-view-tabs { border-color: #514d48; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-view-tabs button { color: #c7c2bc; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-view-tabs button[aria-selected="true"] { color: #80d6e8; border-bottom-color: #80d6e8; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar {
--fc-border-color: #514d48; --fc-page-bg-color: #292725; --fc-neutral-bg-color: #33302d;
--fc-neutral-text-color: #c7c2bc; --fc-today-bg-color: #223b3f;
--fc-button-bg-color: #292725; --fc-button-text-color: #eeeae6; --fc-button-border-color: #756f68;
--fc-button-hover-bg-color: #234850; --fc-button-hover-border-color: #80d6e8;
--fc-button-active-bg-color: #234850; --fc-button-active-border-color: #80d6e8;
background: #292725; color: #eeeae6;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-SQL { background: #16454f; color: #b9eaf4; border-color: #67cde0; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-PIPELINE,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-OTHER { background: #403c38; color: #e4dfd9; border-color: #b8b1a8; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-UPSIDE { background: #533e1e; color: #ffe0a9; border-color: #dba44f; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-FORECAST { background: #493657; color: #ead5fc; border-color: #b998d8; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-WON { background: #244a32; color: #c7edce; border-color: #84c396; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .fc-daygrid-more-link,
.opportunities-extension-modal[data-theme="dark"] .opportunities-calendar-detail-content a { color: #80d6e8; }
@media (max-width: 700px) {
.opportunities-extension-body { overflow-y: auto; grid-template-rows: max-content max-content minmax(480px, 1fr); }
.opportunities-extension-views { min-height: 480px; }
.opportunities-extension-calendar { padding: 10px; overflow-x: auto; }
.opportunities-extension-calendar .fc { min-width: 0; }
.opportunities-extension-calendar .fc .fc-view-harness { min-width: 560px; }
.opportunities-extension-calendar .fc .fc-toolbar-title { font-size: 16px; }
.opportunities-extension-calendar .fc .fc-daygrid-day-frame { padding-top: 22px; }
.opportunities-calendar-day-count { top: 25px; }
.fc-daygrid-week-number ~ .opportunities-calendar-day-count { left: 6px; }
}

View File

@@ -0,0 +1,292 @@
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion,
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon }) {
const { document } = environment;
let elements = null;
let calendar = null;
let library = null;
let selectedView = "overview";
let anchoredVersion = null;
let details = null;
let lastEvents = "";
let dayCounts = new Map();
const dayBadges = new Map();
let resizeObserver = null;
let layoutFrame = null;
let eventLimit = 2;
function visibleEventLimit(height, weeks, headerHeight) {
return Math.max(0, Math.min(2, Math.floor((height / Math.max(1, weeks) - headerHeight - 26) / 48)));
}
function scheduleLayout() {
for (const [name, icon] of [["today", "today"], ["dayGridMonth", "calendar"], ["dayGridWeek", "week"]]) {
const button = elements?.calendarHost.querySelector?.(`.fc-${name}-button`);
if (button && !button.querySelector("svg")) button.prepend(createViewIcon(icon));
}
const win = environment.window;
if (!win?.requestAnimationFrame || layoutFrame !== null) return;
layoutFrame = win.requestAnimationFrame(() => {
layoutFrame = null;
if (!calendar || selectedView !== "calendar" || !elements.calendarHost.clientHeight) return;
calendar.updateSize();
const frame = elements.calendarHost.querySelector(".fc-daygrid-day-frame");
const events = frame?.querySelector(".fc-daygrid-day-events");
if (!frame || !events) return;
const scroller = frame.closest(".fc-scroller");
const weeks = elements.calendarHost.querySelectorAll(".fc-daygrid-body tr").length;
if (!scroller || !weeks) return;
elements.calendarHost.style.setProperty("--opportunities-calendar-weeks", String(weeks));
const headerHeight = events.getBoundingClientRect().top - frame.getBoundingClientRect().top;
const limit = visibleEventLimit(scroller.clientHeight, weeks, headerHeight);
if (limit !== eventLimit) { eventLimit = limit; calendar.setOption("dayMaxEvents", limit); }
});
}
function dateKey(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function fiscalWeekLabel(date) {
// FullCalendar's week hook supplies a UTC date marker, not a local instant.
const start = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
start.setUTCDate(start.getUTCDate() - start.getUTCDay());
const end = new Date(start.getTime() + 6 * 86400000);
const year = end.getUTCMonth() >= 5 ? end.getUTCFullYear() : end.getUTCFullYear() - 1;
const fiscalStart = new Date(Date.UTC(year, 5, 1));
const firstSunday = fiscalStart.getTime() - fiscalStart.getUTCDay() * 86400000;
const representative = start < fiscalStart ? fiscalStart : start;
const quarter = Math.floor(((representative.getUTCMonth() + 7) % 12) / 3) + 1;
const week = Math.round((start.getTime() - firstSunday) / (7 * 86400000)) + 1;
return `FY${String(year + 1).slice(-2)}-Q${quarter}-W${week}`;
}
function updateDayBadge(badge, key) {
const count = dayCounts.get(key) || 0;
badge.hidden = count === 0;
badge.textContent = String(count);
badge.title = `${count} oportunidade${count === 1 ? "" : "s"}`;
badge.setAttribute("aria-label", badge.title);
badge.setAttribute("data-empty", String(count === 0));
}
function node(tag, className, text) {
const el = document.createElement(tag);
el.className = className;
if (text !== undefined) el.textContent = text;
return el;
}
function closeDate(value) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}(?:T|$)/.test(value)) return null;
const day = value.slice(0, 10);
const date = new Date(`${day}T00:00:00Z`);
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === day ? day : null;
}
function eventsFor(items) {
return items.flatMap((item, index) => {
const start = closeDate(item.EffectiveDate);
if (!start) return [];
return [{ id: String(item.OptyNumber || item.OptyId || index), title: String(item.Name || item.OptyNumber || "-"),
start, allDay: true, extendedProps: { item }, classNames: [`opportunities-calendar-stage-${stage(item)}`] }];
});
}
function stage(item) { return ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"].includes(item.ForecastGroup_c) ? item.ForecastGroup_c : "OTHER"; }
function initialDate(items, now = new Date()) {
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const dates = items.map(item => closeDate(item.EffectiveDate)).filter(Boolean).sort();
return dates.find(date => date >= today) || dates.at(-1) || today;
}
function closeDetails(restoreFocus = true) {
if (!details) return false;
const previous = details;
details = null;
previous.overlay.remove();
if (restoreFocus) (previous.trigger?.isConnected ? previous.trigger : elements?.tabs.calendar)?.focus();
return true;
}
function showDialog(heading, content, contentControls, trigger) {
closeDetails(false);
const host = document.getElementById(modalId);
if (!host || host.hidden) return;
const overlay = node("section", "opportunities-extension-action-form-modal opportunities-calendar-detail");
overlay.setAttribute("role", "dialog"); overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-labelledby", "opportunities-calendar-detail-title");
const panel = node("div", "opportunities-extension-action-form-dialog");
const header = node("header", "opportunities-extension-account-plans-header");
const title = node("h2", "", heading);
title.id = "opportunities-calendar-detail-title";
const dismiss = node("button", "opportunities-extension-icon-button");
dismiss.type = "button"; dismiss.title = "Close"; dismiss.setAttribute("aria-label", "Close opportunity details");
dismiss.append(createCloseIcon()); dismiss.addEventListener("click", () => closeDetails());
header.append(title, dismiss);
panel.append(header, content); overlay.append(panel); host.append(overlay);
details = { overlay, trigger };
overlay.addEventListener("click", event => { if (event.target === overlay) closeDetails(); });
overlay.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeDetails(); }
if (event.key === "Tab") {
const controls = [dismiss, ...contentControls];
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
}
});
dismiss.focus();
}
function showDetails(item, trigger) {
const content = node("div", "opportunities-calendar-detail-content");
const link = createDetailLink(item.OptyNumber || "-", item.OptyNumber ? opportunityUrl + encodeURIComponent(item.OptyNumber) : "");
const identity = node("div", "opportunities-extension-opty-number-content");
identity.append(link);
const copy = item.OptyNumber ? createCopyButton(item.OptyNumber) : null;
if (copy) identity.append(copy);
const summary = node("dl", "opportunities-extension-action-form-context");
for (const [label, value] of [["Customer", item.CustomerAccount?.PartyUniqueName], ["Owner", getOwnerName(item)],
["Close Date", closeDate(item.EffectiveDate) ? formatDate(item.EffectiveDate) : "N/D"], ["Amount", formatRevenue(item)]]) {
summary.append(node("dt", "", label), node("dd", "", String(value || "-")));
}
const badges = node("div", "opportunities-calendar-detail-badges");
badges.append(createStatusBadge(item.StatusCode), createStageBadge(item.ForecastGroup_c));
content.append(identity, summary, badges);
showDialog(item.Name || item.OptyNumber || "Opportunity", content, item.OptyNumber ? [link, copy] : [], trigger);
}
function showDayOpportunities({ date, allSegs, jsEvent }) {
jsEvent.preventDefault();
const trigger = jsEvent.currentTarget || elements?.tabs.calendar;
const content = node("div", "opportunities-calendar-day-list");
const controls = [];
const events = new Map(allSegs.map(({ event }) => [event.id, event]));
for (const event of events.values()) {
const item = event.extendedProps.item;
const button = node("button", "opportunities-calendar-day-item"); button.type = "button";
const summary = `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/D"} | ${formatRevenue(item)}`;
button.append(node("strong", "", event.title), node("span", "", summary));
button.title = `${event.title}\n${summary}`;
button.addEventListener("click", () => showDetails(item, trigger));
controls.push(button); content.append(button);
}
showDialog(`${formatDate(date.toISOString().slice(0, 10))} | ${events.size} oportunidades`, content, controls, trigger);
// A truthy non-string result prevents FullCalendar from opening its measured popover.
return true;
}
function mount(body, tableSurface) {
resizeObserver?.disconnect();
if (layoutFrame !== null) environment.window?.cancelAnimationFrame(layoutFrame);
layoutFrame = null; eventLimit = 2;
calendar?.destroy(); calendar = null; lastEvents = ""; anchoredVersion = null; selectedView = "overview";
dayBadges.clear(); dayCounts.clear();
closeDetails(false);
const workspace = node("section", "opportunities-extension-views");
const tablist = node("div", "opportunities-extension-view-tabs");
tablist.setAttribute("role", "tablist"); tablist.setAttribute("aria-label", "Visualiza\u00e7\u00e3o das oportunidades");
const tabs = {};
const surface = node("section", "opportunities-extension-calendar");
const message = node("p", "opportunities-calendar-message"); message.setAttribute("role", "status");
const calendarHost = node("div", "opportunities-calendar-grid");
const undated = node("details", "opportunities-calendar-undated");
surface.append(message, calendarHost, undated);
for (const [key, label, panel] of [["overview", "Vis\u00e3o geral", tableSurface], ["calendar", "Calend\u00e1rio", surface]]) {
const tab = node("button", ""); tab.type = "button"; tab.id = `opportunities-view-${key}-tab`;
tab.append(createViewIcon(key), node("span", "", label));
panel.id = `opportunities-view-${key}`; panel.setAttribute("role", "tabpanel"); panel.setAttribute("aria-labelledby", tab.id);
tab.setAttribute("role", "tab"); tab.setAttribute("aria-controls", panel.id);
tab.addEventListener("click", () => selectView(key));
tab.addEventListener("keydown", event => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const next = event.key === "Home" ? "overview" : event.key === "End" ? "calendar" : key === "overview" ? "calendar" : "overview";
selectView(next); tabs[next].focus();
});
tabs[key] = tab; tablist.append(tab);
}
workspace.append(tablist, tableSurface, surface); body.append(workspace);
elements = { workspace, tableSurface, surface, message, calendarHost, undated, tabs };
if (environment.window?.ResizeObserver) {
resizeObserver = new environment.window.ResizeObserver(scheduleLayout);
resizeObserver.observe(calendarHost);
}
selectView("overview");
}
function selectView(view) {
if (!elements || !["overview", "calendar"].includes(view)) return;
closeDetails(false); closeSrPopover(); selectedView = view;
elements.tableSurface.hidden = view !== "overview"; elements.surface.hidden = view !== "calendar";
for (const [key, tab] of Object.entries(elements.tabs)) {
tab.setAttribute("aria-selected", String(view === key)); tab.tabIndex = view === key ? 0 : -1;
}
render(getItems(), getState());
if (view === "calendar") calendar?.updateSize();
scheduleLayout();
}
function render(items, state) {
if (!elements || selectedView !== "calendar") return;
const { surface, message, calendarHost, undated } = elements;
surface.setAttribute("aria-busy", String(state.status === "loading"));
if (["loading", "error", "empty", "idle"].includes(state.status)) {
message.textContent = state.message || (state.status === "loading" ? "Loading opportunities..." : "No opportunities found.");
message.hidden = false; calendarHost.hidden = true; undated.hidden = true; closeDetails(false); return;
}
message.textContent = items.length ? "" : "No opportunities found."; message.hidden = !!items.length;
calendarHost.hidden = false;
const events = eventsFor(items);
dayCounts = new Map();
for (const event of events) dayCounts.set(event.start, (dayCounts.get(event.start) || 0) + 1);
for (const { badge, key } of dayBadges.values()) updateDayBadge(badge, key);
const anchor = initialDate(state.items);
if (!calendar) {
try {
library ||= loadLibrary();
calendar = new library.Calendar(calendarHost, {
initialView: "dayGridMonth", initialDate: anchor, height: "100%", expandRows: true, fixedWeekCount: false,
datesSet: scheduleLayout,
locale: "pt-br", firstDay: 0, editable: false, selectable: false, eventInteractive: true,
headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,dayGridWeek" },
buttonText: { today: "Hoje", month: "M\u00eas", week: "Semana" },
buttonHints: { prev: "Anterior", next: "Pr\u00f3ximo", today: "Hoje", dayGridMonth: "M\u00eas", dayGridWeek: "Semana" },
dayMaxEvents: 2, moreLinkText: count => `... +${count}`, displayEventTime: false,
moreLinkHint: count => `Ver mais ${count} oportunidades`,
moreLinkClick: showDayOpportunities,
weekNumbers: true,
weekNumberContent: ({ date }) => ({ domNodes: [node("span", "opportunities-calendar-fiscal-week", fiscalWeekLabel(date))] }),
dayCellDidMount: ({ el, date }) => {
const badge = node("span", "opportunities-calendar-day-count");
const key = dateKey(date);
updateDayBadge(badge, key);
el.querySelector(".fc-daygrid-day-frame").append(badge);
dayBadges.set(el, { badge, key });
},
dayCellWillUnmount: ({ el }) => { dayBadges.get(el)?.badge.remove(); dayBadges.delete(el); },
eventOrder: "title", eventDisplay: "block", events,
eventContent: ({ event }) => {
const item = event.extendedProps.item;
const card = node("div", "opportunities-calendar-event-content");
const summary = node("span", "", `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/D"} | ${formatRevenue(item)}`);
summary.title = summary.textContent;
card.append(node("strong", "", event.title), summary);
return { domNodes: [card] };
},
eventDidMount: ({ el, event }) => {
el.title = `${event.title}\n${event.extendedProps.item.CustomerAccount?.PartyUniqueName || ""}\n${formatDate(event.startStr)} | ${event.extendedProps.item.ForecastGroup_c || "-"}`;
el.setAttribute("role", "button"); el.setAttribute("aria-label", el.title);
},
eventClick: ({ event, el, jsEvent }) => { jsEvent.preventDefault(); showDetails(event.extendedProps.item, el); }
});
calendar.render(); lastEvents = JSON.stringify(events); anchoredVersion = getVersion();
} catch {
calendar?.destroy(); calendar = null; message.textContent = "Unable to display the calendar. Use the overview or try again.";
message.hidden = false; calendarHost.hidden = true; undated.hidden = true; return;
}
}
const signature = JSON.stringify(events);
calendar.batchRendering(() => {
if (anchoredVersion !== getVersion()) { calendar.gotoDate(anchor); anchoredVersion = getVersion(); closeDetails(false); }
if (signature !== lastEvents) { calendar.removeAllEvents(); calendar.addEventSource(events); lastEvents = signature; closeDetails(false); }
});
const missing = items.filter(item => !closeDate(item.EffectiveDate));
undated.hidden = !missing.length; undated.textContent = "";
if (missing.length) {
undated.append(node("summary", "", `Sem Close Date v\u00e1lido (${missing.length})`));
for (const item of missing) {
const button = node("button", "", `${item.OptyNumber || "-"} | ${item.Name || "-"}`); button.type = "button";
button.addEventListener("click", () => showDetails(item, button)); undated.append(button);
}
}
scheduleLayout();
}
return { mount, render, selectView, closeDetails, closeDate, eventsFor, initialDate, fiscalWeekLabel, visibleEventLimit };
}

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,154 @@
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;
resetWorkloadActions();
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,98 @@
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, createSrButton, closeSrForm, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, formatDate, environment }) {
let status = "idle";
let byOpportunity = new Map();
let pending = null;
let generation = 0;
let loadedItems = [];
function reset() { closeSrForm(); 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)}`,
`Customer Deadline: ${display(formatDate(item.CustomerDeadline_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);
if (!items.length && status === "ready") badge.append(createSrButton(String(optyNumber)));
badge.tabIndex = 0;
return badge;
}
return { reset, load, createBadge, getSummary };
}

View File

@@ -0,0 +1,416 @@
function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportunity, requestSave, notify, formatDate, requestPillars, requestLanguage, requestPlatform, requestServices, createCloseIcon, searchableSelect, environment }) {
const { document } = environment;
let active = null;
function node(tag, className, text) {
const element = document.createElement(tag);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
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(number) {
const button = node("button", "opportunities-extension-add-action", "+");
button.type = "button";
button.title = "Add Service Request";
button.setAttribute("aria-label", `Add Service Request for ${number}`);
button.setAttribute("aria-haspopup", "dialog");
button.addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); open(number, button); });
return button;
}
function open(number, trigger) {
if (active?.saving) return;
const host = document.getElementById(MODAL_ID);
if (!host || host.hidden || !allowsCrmActivities() || !isMissing(number)) return;
close(false);
const modal = node("section", "opportunities-extension-action-form-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-sr-form-title");
const dialog = node("div", "opportunities-extension-action-form-dialog opportunities-extension-sr-form-dialog");
const header = node("header", "opportunities-extension-account-plans-header");
const heading = node("div", "opportunities-extension-action-form-heading");
const title = node("h2", "", "Add Service Request");
title.id = "opportunities-extension-sr-form-title";
heading.append(title, node("p", "", String(number)));
const opportunity = getOpportunity(number);
const details = node("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(node("dt", "", label), node("dd", "", value == null || value === "" ? "-" : String(value)));
}
heading.append(details);
const dismiss = node("button", "opportunities-extension-icon-button");
dismiss.type = "button";
dismiss.setAttribute("aria-label", "Close Service Request form");
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => close());
header.append(heading, dismiss);
const form = node("form", "opportunities-extension-action-form");
form.noValidate = true;
const fields = new Map();
const combos = new Map();
const loadingFields = new Set(["primaryPillar", "language", "platform", "services"]);
const availableFields = new Set();
let serviceItems = [];
const serviceRadios = [];
let serviceGroup;
const field = node("label", "opportunities-extension-action-form-field");
const select = node("select");
select.id = "opportunities-extension-sr-primary-pillar";
select.name = "primaryPillar";
select.required = true;
field.htmlFor = select.id;
field.append(node("span", "", "Primary pillar (required)"), select);
const autocomplete = searchableSelect(select, field);
fields.set("primaryPillar", select);
combos.set(select, autocomplete);
const status = node("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
status.setAttribute("role", "status");
const footer = node("footer", "opportunities-extension-action-form-footer");
const retry = node("button", "", "Retry");
retry.type = "button";
const cancel = node("button", "", "Cancel");
const save = node("button", "opportunities-extension-action-form-save", "Save");
save.type = "submit";
save.disabled = true;
cancel.type = "button";
cancel.addEventListener("click", () => close());
footer.append(retry, save, cancel);
form.append(field);
const extraControls = [];
const extraLoads = [];
function textField(name, label, type) {
const wrapper = node("label", "opportunities-extension-action-form-field" + (type === "textarea" ? " opportunities-extension-action-form-wide" : ""));
const input = node(type === "textarea" ? "textarea" : "input");
if (type === "textarea") input.rows = 4;
else input.type = type;
input.id = `opportunities-extension-sr-${name}`;
input.name = name;
input.required = true;
fields.set(name, input);
wrapper.htmlFor = input.id;
wrapper.append(node("span", "", `${label} (required)`), input);
form.append(wrapper);
extraControls.push(input);
}
textField("customerDeadline", "Customer deadline", "date");
function lookupField(name, label, request, radios = false) {
const wrapper = node(radios ? "fieldset" : "div", "opportunities-extension-sr-lookup" + (radios ? " opportunities-extension-action-form-wide" : ""));
const content = node("div", radios ? "opportunities-extension-sr-services" : "");
const message = node("p", "opportunities-extension-action-form-status");
const retryOption = node("button", "opportunities-extension-sr-lookup-retry", "Retry");
retryOption.type = "button";
retryOption.setAttribute("aria-label", `Retry ${label}`);
retryOption.hidden = true;
extraControls.push(retryOption);
let input, combo;
if (radios) { wrapper.append(node("legend", "", `${label} (required)`)); serviceGroup = wrapper; }
else {
const field = node("label", "opportunities-extension-action-form-field");
input = node("select");
input.id = `opportunities-extension-sr-${name}`;
input.name = name;
input.required = true;
fields.set(name, input);
field.htmlFor = input.id;
field.append(node("span", "", `${label} (required)`), input);
combo = searchableSelect(input, field);
combos.set(input, combo);
content.append(field);
extraControls.push(combo.input);
}
wrapper.append(content, message, retryOption);
form.append(wrapper);
let loading = false;
async function loadOptions() {
if (loading || !current()) return;
loading = true;
loadingFields.add(name);
availableFields.delete(name);
updateSave();
retryOption.hidden = true;
message.hidden = false;
message.setAttribute("role", "status");
message.textContent = `Loading ${label.toLowerCase()}...`;
wrapper.setAttribute("aria-busy", "true");
if (input) { input.disabled = true; input.textContent = ""; combo.sync(); }
try {
const items = await request();
if (!current()) return;
if (items.length) availableFields.add(name);
if (input) {
const blank = node("option", "", items.length ? `Select ${label.toLowerCase()}` : "No options available");
blank.value = "";
input.append(blank);
items.forEach(item => { const option = node("option", "", item.name); option.value = item.code; input.append(option); });
input.value = "";
input.disabled = !items.length;
} else {
content.textContent = "";
serviceItems = items;
serviceRadios.length = 0;
const seen = new Set();
items.forEach((item, index) => {
if (seen.has(String(item.Id))) return;
seen.add(String(item.Id));
const row = node("label", "opportunities-extension-sr-service-option");
const radio = node("input");
radio.type = "radio";
radio.name = "service";
radio.required = true;
serviceRadios.push(radio);
radio.value = String(item.Id);
radio.id = `opportunities-extension-sr-service-${index}`;
const copy = node("span");
const abstract = node("span", "opportunities-extension-sr-service-abstract", item.Abstract_c || "");
abstract.id = `${radio.id}-description`;
radio.setAttribute("aria-describedby", abstract.id);
row.htmlFor = radio.id;
copy.append(node("span", "opportunities-extension-sr-service-name", item.RecordName), abstract);
row.append(radio, copy);
content.append(row);
extraControls.push(radio);
});
}
message.textContent = items.length ? "" : "No options available.";
message.hidden = !!items.length;
} catch (error) {
if (!current()) return;
message.setAttribute("role", "alert");
message.textContent = error.message || `Unable to load ${label.toLowerCase()}.`;
retryOption.hidden = false;
} finally {
loading = false;
loadingFields.delete(name);
if (current()) updateSave();
if (current()) { combo?.sync(); wrapper.setAttribute("aria-busy", "false"); }
}
}
retryOption.addEventListener("click", loadOptions);
extraLoads.push(loadOptions);
}
lookupField("language", "Language of delivery", requestLanguage);
lookupField("platform", "Platform", requestPlatform);
textField("description", "Description", "textarea");
lookupField("services", "Services", requestServices, true);
form.append(status, footer);
dialog.append(header, form);
modal.append(dialog);
host.append(modal);
const view = { modal, trigger, loading: false };
active = view;
const current = () => active === view && allowsCrmActivities();
function updateSave() { save.disabled = loadingFields.size > 0 || availableFields.size < 4 || Boolean(view.saving); }
function confirmPayload(payload, requestHeaders = {}) {
return new Promise(resolve => {
const overlay = node("section", "opportunities-extension-action-form-modal opportunities-extension-sr-confirmation");
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-labelledby", "opportunities-extension-sr-confirm-title");
const panel = node("div", "opportunities-extension-action-form-dialog opportunities-extension-sr-confirm-dialog");
const heading = node("header", "opportunities-extension-account-plans-header");
const title = node("h2", "", "Confirm Service Request");
title.id = "opportunities-extension-sr-confirm-title";
heading.append(title);
const body = node("div", "opportunities-extension-sr-confirm-body");
const label = node("p", "", "requestPostServiceRequest");
label.id = "opportunities-extension-sr-confirm-payload-label";
const preview = node("pre", "opportunities-extension-sr-confirm-payload", JSON.stringify(payload, null, 2));
preview.tabIndex = 0;
preview.setAttribute("aria-labelledby", label.id);
body.append(label, preview);
const headerPreviews = [];
for (const name of ["requestSrTokenCatalog", "requestPostServiceRequest"]) {
if (!requestHeaders[name]) continue;
const headerLabel = node("p", "", `${name} - Headers`);
headerLabel.id = `opportunities-extension-sr-confirm-${name}-headers`;
const headerPreview = node("pre", "opportunities-extension-sr-confirm-payload", JSON.stringify(requestHeaders[name], null, 2));
headerPreview.tabIndex = 0;
headerPreview.setAttribute("aria-labelledby", headerLabel.id);
headerPreviews.push(headerPreview);
body.append(headerLabel, headerPreview);
if (name === "requestSrTokenCatalog" && !requestHeaders[name]["x-appbuilder-client-id"]) {
body.append(node("p", "opportunities-extension-action-form-status", "x-appbuilder-client-id: not yet observed in SeaaS requests."));
}
}
const footer = node("footer", "opportunities-extension-action-form-footer");
const cancel = node("button", "", "Cancel");
const confirm = node("button", "", "Confirm Save");
cancel.type = confirm.type = "button";
footer.append(cancel, confirm);
panel.append(heading, body, footer);
overlay.append(panel);
dialog.inert = true;
dialog.setAttribute("aria-hidden", "true");
modal.setAttribute("aria-modal", "false");
modal.append(overlay);
let settled = false;
function finish(confirmed) {
if (settled) return;
settled = true;
overlay.remove();
dialog.inert = false;
dialog.setAttribute("aria-hidden", "false");
modal.setAttribute("aria-modal", "true");
resolve(confirmed);
}
cancel.addEventListener("click", () => finish(false));
confirm.addEventListener("click", () => finish(true));
overlay.addEventListener("keydown", event => {
event.stopPropagation();
if (event.key === "Escape") { event.preventDefault(); finish(false); }
if (event.key === "Tab") {
const controls = [preview, ...headerPreviews, cancel, confirm];
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
}
});
cancel.focus();
});
}
form.addEventListener("submit", async event => {
event.preventDefault();
if (!current() || view.saving || save.disabled) return;
const invalid = [];
const value = name => String(fields.get(name)?.value || "").trim();
for (const [name, input] of fields) {
let valid = !!value(name);
if (combos.has(input)) valid = valid && Array.from(input.children).some(option => option.value === value(name));
if (name === "customerDeadline") {
const date = value(name);
valid = /^\d{4}-\d{2}-\d{2}$/.test(date) && Number.isFinite(Date.parse(`${date}T00:00:00Z`))
&& new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) === date;
}
const control = combos.get(input)?.input || input;
control.setAttribute("aria-invalid", String(!valid));
if (!valid) invalid.push(control);
}
const selected = serviceRadios.find(radio => radio.checked);
const service = serviceItems.find(item => String(item.Id) === selected?.value);
serviceGroup.setAttribute("aria-invalid", String(!service));
if (!service) invalid.push(serviceRadios[0]);
if (invalid.length) {
status.hidden = false;
status.setAttribute("role", "alert");
status.textContent = "Complete the highlighted required fields with valid values.";
invalid[0]?.focus();
return;
}
const payload = {
AccountPartyId: opportunity?.CustomerAccount?.PartyId,
OpportunityName_Id_c: opportunity?.OptyId,
Title: `${service.RecordName}-${number}-${opportunity?.CustomerAccount?.PartyUniqueName || ""}`,
RegionText_c: opportunity?.CustomerAccount?.Region_c,
CountryText_c: opportunity?.CustomerAccount?.PrimaryAddress?.CountryCode,
ServiceNew_Id_c: service.Id,
ProblemDescription: value("description"), Language_c: value("language"),
CustomerDeadline_c: value("customerDeadline"), TechPlatformLookUp_c: value("platform"),
PrimaryPillarNew_c: value("primaryPillar")
};
view.saving = true;
const controls = [dismiss, retry, cancel, ...fields.values(), ...serviceRadios, ...extraControls];
const disabled = controls.map(control => [control, control.disabled]);
controls.forEach(control => { control.disabled = true; });
combos.forEach(combo => combo.sync());
updateSave();
save.textContent = "Preparing...";
form.setAttribute("aria-busy", "true");
status.hidden = true;
try {
const prepared = await requestSave({ phase: "prepare", payload });
if (!current() || host.hidden || !await confirmPayload(prepared.payload, prepared.headers)) return;
save.textContent = "Saving...";
await requestSave({ phase: "confirm", payload: prepared.payload });
view.saving = false;
close();
notify("Service Request saved successfully.");
} catch (error) {
status.hidden = false;
status.setAttribute("role", "alert");
status.textContent = error.message || "Unable to save Service Request.";
notify(status.textContent, true);
} finally {
view.saving = false;
if (current()) {
disabled.forEach(([control, wasDisabled]) => { control.disabled = wasDisabled; });
combos.forEach(combo => combo.sync());
updateSave();
save.textContent = "Save";
form.setAttribute("aria-busy", "false");
save.focus();
}
}
});
function option(value, text) { const item = node("option", "", text); item.value = value; select.append(item); }
async function load() {
if (view.loading || !current()) return;
view.loading = true;
loadingFields.add("primaryPillar");
availableFields.delete("primaryPillar");
updateSave();
retry.hidden = true;
select.disabled = true;
select.textContent = "";
option("", "Loading...");
autocomplete.sync();
form.setAttribute("data-state", "loading");
form.setAttribute("aria-busy", "true");
status.hidden = false;
status.setAttribute("role", "status");
status.textContent = "Loading primary pillars...";
try {
const items = await requestPillars();
if (!current()) return;
select.textContent = "";
option("", items.length ? "Select primary pillar" : "No options available");
items.forEach(item => option(item.code, item.name));
select.value = "";
select.disabled = !items.length;
if (items.length) availableFields.add("primaryPillar");
form.setAttribute("data-state", "ready");
status.hidden = true;
} catch (error) {
if (!current()) return;
form.setAttribute("data-state", "error");
select.textContent = "";
option("", "Unavailable");
status.setAttribute("role", "alert");
status.textContent = error.message || "Unable to load primary pillars.";
retry.hidden = false;
} finally {
view.loading = false;
loadingFields.delete("primaryPillar");
if (current()) updateSave();
if (current()) { autocomplete.sync(); form.setAttribute("aria-busy", "false"); }
}
}
retry.addEventListener("click", load);
modal.addEventListener("click", event => { if (event.target === modal) close(); });
modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key !== "Tab") return;
const controls = [dismiss, autocomplete.input, ...extraControls, retry, save, cancel].filter(control => !control.disabled && !control.hidden);
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
});
dismiss.focus();
load();
extraLoads.forEach(loadOptions => loadOptions());
}
return { createButton, close, open };
}

View File

@@ -0,0 +1,199 @@
function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, requestDetails, formatDate, openInternalSrForm, 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 add = document.createElement("button");
add.type = "button";
add.className = "opportunities-extension-copy-button opportunities-extension-internal-sr-add";
add.title = "Create Internal Service Request";
add.setAttribute("aria-label", add.title);
add.setAttribute("aria-haspopup", "dialog");
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", "M12 5v14M5 12h14");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "2");
path.setAttribute("stroke-linecap", "round");
icon.append(path);
add.append(icon);
add.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
close();
openInternalSrForm(item, button);
});
heading.append(add);
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(key === "LastUpdateDate" || key === "OpenDate" ? formatDate(record[key]) : 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,197 @@
function createInternalSrForm({ MODAL_ID, searchableSelect, formatDate, getOpportunity, requestPlatform, requestServices, requestSave, notify, environment }) {
const { document } = environment;
let active = null;
function node(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text !== undefined) el.textContent = text;
return el;
}
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 open(parent, trigger) {
if (active?.saving) return;
const host = document.getElementById(MODAL_ID);
if (!host || host.hidden) return;
close(false);
const modal = node("section", "opportunities-extension-action-form-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "internal-sr-form-title");
const dialog = node("div", "opportunities-extension-action-form-dialog opportunities-extension-internal-sr-dialog");
const header = node("header", "opportunities-extension-account-plans-header");
const heading = node("div", "opportunities-extension-action-form-heading");
const title = node("h2", "", "Create Internal Service Request");
title.id = "internal-sr-form-title";
const context = node("dl", "opportunities-extension-action-form-context");
const opportunity = getOpportunity(parent.OpportunityName_Id_c?.OptyNumber);
for (const [label, value] of [["SR Number", parent.SrNumber], ["Opportunity", opportunity?.Name], ["Title", parent.Title]]) {
context.append(node("dt", "", label), node("dd", "", value == null || value === "" ? "N/D" : String(value)));
}
heading.append(title, context);
header.append(heading);
const form = node("form", "opportunities-extension-action-form");
form.noValidate = true;
const fields = new Map();
const combos = new Map();
const ready = new Set();
function field(name, label, type, wide = false) {
const wrapper = node("label", "opportunities-extension-action-form-field" + (wide ? " opportunities-extension-action-form-wide" : ""));
const input = node(type === "textarea" || type === "select" ? type : "input");
if (!["textarea", "select"].includes(type)) input.type = type;
if (type === "textarea") input.rows = 4;
input.id = `internal-sr-${name}`;
input.name = name;
fields.set(name, input);
input.required = name !== "customerDeadline";
input.setAttribute("aria-required", String(input.required));
wrapper.htmlFor = input.id;
wrapper.append(node("span", "", input.required ? `${label} (required)` : label), input);
form.append(wrapper);
return { input, wrapper };
}
const titleInput = field("title", "Title", "text", true).input;
const deadline = field("customerDeadline", "Customer Deadline", "text").input;
deadline.readOnly = true;
deadline.value = formatDate(parent.CustomerDeadline_c) || "N/D";
field("internalDeadline", "Internal Deadline", "date");
const platform = field("platform", "Platform", "select", true);
field("description", "Description", "textarea", true);
const services = field("services", "Services", "select", true);
const footer = node("footer", "opportunities-extension-action-form-footer");
const cancel = node("button", "", "Cancel");
const save = node("button", "opportunities-extension-action-form-save", "Save");
save.type = "submit";
save.disabled = true;
const feedback = node("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
feedback.setAttribute("role", "status");
feedback.hidden = true;
cancel.type = "button";
cancel.addEventListener("click", () => close());
footer.append(save, cancel);
form.append(feedback, footer);
dialog.append(header, form);
modal.append(dialog);
host.append(modal);
const entry = { modal, trigger, saving: false };
active = entry;
const validDate = value => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(`${value}T00:00:00Z`))
&& new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value;
form.addEventListener("submit", async event => {
event.preventDefault();
if (active !== entry || entry.saving || ready.size !== 2) return;
let firstInvalid = null;
for (const [name, input] of fields) {
if (!input.required) continue;
const value = String(input.value || "").trim();
const valid = !!value && (name !== "internalDeadline" || validDate(value))
&& (!combos.has(name) || Array.from(input.children).some(option => option.value === value));
input.setAttribute("aria-invalid", String(!valid));
const visible = combos.get(name)?.input || input;
visible.setAttribute("aria-invalid", String(!valid));
if (!valid && !firstInvalid) firstInvalid = visible;
}
feedback.hidden = false;
if (firstInvalid) {
feedback.setAttribute("role", "alert");
feedback.textContent = "Complete all required fields with valid values.";
firstInvalid.focus();
return;
}
const value = name => String(fields.get(name).value || "").trim();
const dateParts = value("customerDeadline").match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
const payload = {
Title: value("title"),
ServiceNew_Id_c: value("services"),
ProblemDescription: value("description"),
CustomerDeadline_c: dateParts ? `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}` : "",
TechPlatformLookUp_c: value("platform"),
ServiceRequest_Id_SRToInternalSR: String(parent.SrId ?? ""),
SvcQuestionsCollection_c: [], InternalDeadline_c: value("internalDeadline")
};
entry.saving = true;
form.setAttribute("aria-busy", "true");
save.disabled = cancel.disabled = true;
save.textContent = "Saving...";
for (const [name, input] of fields) { input.disabled = true; combos.get(name)?.sync(); }
feedback.setAttribute("role", "status");
feedback.textContent = "Creating Internal Service Request...";
try {
await requestSave(payload);
entry.saving = false;
close();
notify("Internal Service Request created successfully.");
} catch (error) {
if (active !== entry) return;
feedback.setAttribute("role", "alert");
feedback.textContent = error.message || "Unable to create Internal Service Request.";
notify(feedback.textContent, true);
} finally {
entry.saving = false;
if (active === entry) {
form.setAttribute("aria-busy", "false");
save.disabled = cancel.disabled = false;
save.textContent = "Save";
for (const [name, input] of fields) { input.disabled = false; combos.get(name)?.sync(); }
}
}
});
function load(target, request, service) {
const { input, wrapper } = target;
input.disabled = true;
const combo = searchableSelect(input, wrapper);
combos.set(input.name, combo);
const status = node("p", "opportunities-extension-action-form-status", "Loading...");
status.setAttribute("role", "status");
wrapper.append(status);
wrapper.setAttribute("aria-busy", "true");
Promise.resolve().then(request).then(items => {
if (active !== entry) return;
const placeholder = node("option", "", items.length ? "Select an option" : "No options available");
placeholder.value = "";
input.append(placeholder);
const seen = new Set();
for (const item of items) {
const value = String(service ? item.Id : item.code);
if (seen.has(value)) continue;
seen.add(value);
const option = node("option", "", service ? item.RecordName : item.name);
option.value = value;
if (service && item.Description_c) option.setAttribute("data-description", item.Description_c);
input.append(option);
}
input.value = "";
input.disabled = !items.length;
if (items.length) ready.add(input.name);
save.disabled = ready.size !== 2;
status.textContent = items.length ? "" : "No options available.";
status.hidden = !!items.length;
combo.sync();
}).catch(error => {
if (active !== entry) return;
status.setAttribute("role", "alert");
status.textContent = error.message || "Unable to load options. Close and reopen the form to retry.";
}).finally(() => { if (active === entry) wrapper.setAttribute("aria-busy", "false"); });
}
load(platform, requestPlatform, false);
load(services, () => requestServices(parent.SrId), true);
modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key === "Tab") {
const focusable = Array.from(form.querySelectorAll("input, textarea, button")).filter(el => !el.disabled && !el.hidden);
const first = focusable[0], last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
});
titleInput.focus();
}
return { open, close };
}

View File

@@ -0,0 +1,60 @@
function createInternalSrSession({ query, environment, sessionUrl, sessionLabel = "Internal SR" }) {
const { document, setTimeout } = environment;
let recovery = null;
const wait = duration => new Promise(resolve => setTimeout(resolve, duration));
const now = () => environment.performance?.now() ?? Date.now();
function isUnauthorized(result) {
const status = result?.httpStatus ?? result?.debugRequests?.at(-1)?.httpStatus;
if (status != null) return Number(status) === 401;
// Older background workers may return only the request error text.
return /failed with status 401\.$/.test(result?.error || "");
}
async function warmSession(id) {
const frame = document.createElement("iframe");
frame.src = sessionUrl || "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR0001421040&srId=302963804324543&view=showisr";
frame.title = `${sessionLabel} session initialization`;
frame.tabIndex = -1;
frame.setAttribute("aria-hidden", "true");
frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
frame.style.cssText = "position:fixed;width:1px;height:1px;left:-10000px;top:0;border:0;opacity:0;pointer-events:none;";
let blocked = false;
let loaded = false;
frame.addEventListener("error", () => { blocked = true; });
frame.addEventListener("load", () => { loaded = true; });
try {
if (!document.body) throw new Error(`The current page cannot initialize the ${sessionLabel} session.`);
document.body.append(frame);
const deadline = now() + 60000;
for (let attempt = 0; now() < deadline; attempt += 1) {
// Check immediately, then briefly poll faster before backing off.
if (attempt > 0) await wait(Math.min(attempt <= 4 ? 250 : 1000, deadline - now()));
if (now() >= deadline) break;
if (blocked) throw new Error(`The ${sessionLabel} session iframe could not be loaded. Sign in manually and try again.`);
const result = await query(id);
if (result?.ok) return { id, result };
if (!isUnauthorized(result)) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
}
throw new Error(`${sessionLabel} session timed out after 60 seconds: the API still returned HTTP 401. ${loaded ? "The iframe emitted a load event, but authentication was not confirmed." : "The iframe did not emit a load event."} Sign in manually and try again.`);
} finally {
// Stop remaining page resources without stopping the caller's page.
try { frame.contentWindow?.stop(); } catch { /* Redirects can make the frame cross-origin. */ }
frame.src = "about:blank";
frame.remove();
}
}
async function request(id) {
let result = await query(id);
for (let attempt = 0; !result?.ok && isUnauthorized(result) && attempt < 2; attempt += 1) {
if (!recovery) {
recovery = warmSession(id);
const pending = recovery;
pending.finally(() => { if (recovery === pending) recovery = null; }).catch(() => {});
}
const recovered = await recovery;
result = recovered.id === id ? recovered.result : await query(id);
}
if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
return result.items;
}
return { request };
}

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,44 @@
function createProductActivities({ productsContentState, opportunitiesQueryState,
loadCrm, loadActions, blockActions, render }) {
let cycle = null;
function begin(requestId, token, opportunityNumbers = null) {
cycle = { requestId, token, numbers: opportunityNumbers && new Set(opportunityNumbers.map(String)), 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 entries = [...productsContentState.opptyProductsByNumber].filter(([number]) => !current.numbers || current.numbers.has(String(number)));
const products = entries.map(([, result]) => result).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;
// Product cache hits do not guarantee that the corresponding activity cache exists.
return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: false })).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: false }));
}
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,175 @@
function createSearchableSelect({ environment }) {
const { document } = environment;
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, { floating = false, tooltipContent, listWidth = 0, listMaxHeight = 180 } = {}) {
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);
const tooltip = tooltipContent ? element("div", "opportunities-extension-select-tooltip") : null;
if (tooltip) {
tooltip.id = `${select.id}-tooltip`;
tooltip.setAttribute("role", "tooltip");
tooltip.hidden = true;
box.append(tooltip);
}
let tooltipAnchor = null;
function hideTooltip() {
if (!tooltip) return;
tooltip.hidden = true;
tooltipAnchor?.removeAttribute?.("aria-describedby");
tooltipAnchor = null;
}
function showTooltip(value, anchor) {
hideTooltip();
if (!tooltip || !value) return;
const content = tooltipContent(value);
if (!content) return;
tooltip.replaceChildren(content);
tooltip.hidden = false;
tooltipAnchor = anchor;
anchor.setAttribute("aria-describedby", tooltip.id);
const rect = anchor.getBoundingClientRect?.();
const bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
if (!rect || !bounds) return;
const size = tooltip.getBoundingClientRect();
const left = rect.right + 8 + size.width <= bounds.right ? rect.right + 8 : Math.max(bounds.left + 8, rect.left - size.width - 8);
tooltip.style.left = `${left}px`;
tooltip.style.top = `${Math.max(bounds.top + 8, Math.min(rect.top, bounds.bottom - size.height - 8))}px`;
}
function leaveTooltip(event) {
if (!tooltip?.contains?.(event.relatedTarget)) hideTooltip();
}
tooltip?.addEventListener("mouseleave", hideTooltip);
list.addEventListener("scroll", hideTooltip);
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() {
hideTooltip();
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 = tooltipContent ? "" : input.value;
}
function highlight(withTooltip = true) {
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" });
if (withTooltip && matches[index]) showTooltip(matches[index].value, list.children[index]);
}
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() {
let bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
const anchor = input.getBoundingClientRect?.();
if (!bounds || !anchor) return;
if (floating && listWidth && document.documentElement?.clientWidth) {
bounds = { top: 0, left: 0, right: document.documentElement.clientWidth, bottom: document.documentElement.clientHeight };
}
const below = Math.max(0, bounds.bottom - anchor.bottom - 8);
const above = Math.max(0, anchor.top - bounds.top - 8);
const opensAbove = below < listMaxHeight && above > below;
list.setAttribute("data-placement", opensAbove ? "above" : "below");
list.style.maxHeight = `${Math.min(listMaxHeight, opensAbove ? above : below)}px`;
if (floating) {
const width = listWidth ? Math.min(Math.max(anchor.width, listWidth), bounds.right - bounds.left - 16) : anchor.width;
list.style.position = "fixed";
list.style.left = `${listWidth ? Math.max(bounds.left + 8, Math.min(anchor.left, bounds.right - width - 8)) : anchor.left}px`;
list.style.width = `${width}px`;
list.style.right = "auto";
list.style.bottom = "auto";
list.style.top = `${opensAbove ? anchor.top - 4 : anchor.bottom + 4}px`;
list.style.transform = opensAbove ? "translateY(-100%)" : "none";
}
}
function render(query = "") {
if (input.disabled) return;
hideTooltip();
matches = options().filter(item => normalize(`${item.textContent} ${item.getAttribute?.("data-description") || ""} ${item.value}`).includes(normalize(query)));
index = matches.findIndex(item => item.value === select.value);
list.textContent = "";
matches.forEach((item, i) => {
const row = element("div", "opportunities-extension-action-option", item.textContent);
const description = item.getAttribute?.("data-description");
if (description) row.append(element("small", "opportunities-extension-option-description", description));
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); });
if (tooltip) {
row.addEventListener("mouseenter", () => showTooltip(item.value, row));
row.addEventListener("mouseleave", leaveTooltip);
}
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");
highlight(false);
}
input.addEventListener("click", () => { render(); input.select?.(); });
input.addEventListener("input", () => render(input.value));
input.addEventListener("blur", sync);
if (tooltip) {
input.addEventListener("mouseenter", () => { if (list.hidden) showTooltip(select.value, input); });
input.addEventListener("mouseleave", leaveTooltip);
}
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 || (tooltip && !tooltip.hidden))) {
event.preventDefault();
event.stopPropagation();
sync();
} else if (event.key === "Tab") sync();
});
sync();
return { input, sync };
}
return searchableSelect;
}

View File

@@ -0,0 +1,22 @@
function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, sendRequest, reportProgress, appendLog, onReady = async () => {} }) {
let pending = null;
function ensure() {
if (pending) return pending;
pending = (async () => {
try {
await ensureCurrentUser();
if (!allowsCrmActivities()) return;
reportProgress("running");
const result = await sendRequest();
appendLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Session initialization failed.");
reportProgress("complete");
await onReady();
} catch {
reportProgress("error");
}
})();
return pending;
}
return { ensure };
}

View File

@@ -0,0 +1,630 @@
/* 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-sr-form-dialog { width: min(900px, 100%); }
.opportunities-extension-sr-confirmation { z-index: 6; }
.opportunities-extension-sr-confirm-dialog { width: min(800px, 100%); display: flex; flex-direction: column; overflow: hidden; }
.opportunities-extension-sr-confirm-dialog > header,
.opportunities-extension-sr-confirm-dialog > footer { flex-shrink: 0; }
.opportunities-extension-sr-confirm-dialog > footer { padding: 16px 20px; }
.opportunities-extension-sr-confirm-body { min-height: 0; overflow: auto; padding: 0 20px; }
.opportunities-extension-sr-confirm-body > p { font-size: 13px; font-weight: 600; }
.opportunities-extension-sr-confirm-payload { margin: 0; padding: 16px; border: 1px solid #b9b4ae; border-radius: 4px; background: #f6f5f4; color: #312d2a; font: 13px/1.6 Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.opportunities-extension-sr-confirmation :focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-confirm-payload { background: #211f1d; color: #eeeae6; border-color: #756f68; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-confirmation :focus-visible { outline-color: #80d6e8; }
.opportunities-extension-sr-form-dialog > .opportunities-extension-account-plans-header { padding: 20px 24px; }
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { gap: 20px 24px; padding: 24px; align-items: start; }
.opportunities-extension-sr-form-dialog .opportunities-extension-action-form-field { gap: 8px; }
.opportunities-extension-sr-form-dialog textarea { min-height: 112px; }
.opportunities-extension-sr-form-dialog .opportunities-extension-sr-service-option { padding: 12px 0; gap: 10px; }
.opportunities-extension-sr-form-dialog .opportunities-extension-sr-service-abstract { font-size: 13px; line-height: 20px; }
@media (min-width: 1000px) {
.opportunities-extension-sr-form-dialog { width: min(1120px, 100%); }
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px 24px; }
.opportunities-extension-sr-services { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 28px; align-items: start; }
.opportunities-extension-sr-services .opportunities-extension-sr-service-option { margin: 0; border-top: 1px solid #aaa6a040; }
.opportunities-extension-sr-services .opportunities-extension-sr-service-option:nth-child(-n+2) { border-top: 0; }
}
@media (max-width: 600px) {
.opportunities-extension-sr-form-dialog > .opportunities-extension-account-plans-header { padding: 16px; }
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); gap: 18px; padding: 16px; }
}
.opportunities-extension-sr-lookup { min-width: 0; padding: 0; margin: 0; border: 0; font-size: 13px; }
.opportunities-extension-sr-lookup legend { padding: 0 0 8px; font-weight: 600; }
.opportunities-extension-sr-lookup[aria-busy="true"] > p { opacity: .65; }
.opportunities-extension-sr-service-option { display: flex; align-items: flex-start; gap: 8px; padding: 9px 0; cursor: pointer; }
.opportunities-extension-sr-service-option + .opportunities-extension-sr-service-option { border-top: 1px solid #aaa6a040; }
.opportunities-extension-sr-service-option input { flex: 0 0 16px; width: 16px; height: 16px; margin: 2px 0 0; accent-color: #00758f; }
.opportunities-extension-sr-service-option > span { min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-sr-service-name { display: block; font-weight: 600; line-height: 20px; }
.opportunities-extension-sr-service-abstract { display: block; margin-top: 3px; color: #625d58; font-size: 12px; line-height: 18px; white-space: pre-wrap; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-service-abstract { color: #c7c2bc; }
.opportunities-extension-sr-lookup-retry { color: inherit; background: transparent; border: 1px solid #8f8983; border-radius: 4px; padding: 5px 10px; cursor: pointer; }
.opportunities-extension-sr-lookup-retry[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; }
}
.opportunities-extension-option-description { display: block; font-size: 12px; line-height: 1.4; margin-top: 4px; white-space: normal; overflow-wrap: anywhere; opacity: .85; }
.opportunities-extension-action-form-dialog.opportunities-extension-internal-sr-dialog { width: min(760px, 100%); }

View File

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

View File

@@ -0,0 +1,201 @@
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,
renderCalendar,
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);
renderCalendar?.(items, opportunitiesTableState.opportunitiesTableState);
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,304 @@
function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, getProducts,
requestTeamMembers, requestLookup, getCurrentUserId, saveAction, onActionSaved, notify, formatDate, createCloseIcon, searchableSelect, 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 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,175 @@
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." });
}
updateProgress(session);
}
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.";
const errors = [...new Set(records.filter(record => record?.error).map(record => record.error))];
if (errors.length) badge.title += `\n${errors.join("\n")}`;
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,61 @@
function createFormRequestCache({ getHours, getUserId }) {
const entries = new Map();
const pending = new Map();
let generation = 0;
let owner = "";
const queries = new Set([
"requestWeekDetails", "requestTimeEntries", "requestActiveServiceRequests", "requestServiceRequestExpand", "requestLookupTaskType",
"requestActionTeamMembers", "requestActionFindLookup", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage",
"requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestLookupTypePlatform", "requestInternalSrServices"
]);
const writes = new Set(["requestTimeEntriesDelete", "requestTimeEntriesSave", "requestMgmtAction", "requestPostInternalServiceRequest", "requestPostServiceRequest"]);
function invalidate() { generation++; entries.clear(); pending.clear(); }
function maxAge() {
const hours = Number(getHours());
return (Number.isFinite(hours) && hours > 0 ? Math.min(hours, 168) : 24) * 3600000;
}
function copy(value) { return JSON.parse(JSON.stringify(value)); }
function stable(value) {
if (Array.isArray(value)) return value.map(stable);
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
return value;
}
async function send(message, transport) {
const userId = String(getUserId() || "").trim().toLowerCase();
if (userId !== owner) { invalidate(); owner = userId; }
const operation = String(message.type || "").split(".").pop();
const duration = maxAge();
const request = queries.has(operation) || writes.has(operation) || operation === "requestResourceUser"
? { ...message, cacheMaxAgeMs: duration } : message;
if (operation === "invalidateProductsCache") invalidate();
// Only explicitly known read operations are reusable. Writes and token generation always execute.
if (!queries.has(operation) || !userId) {
const result = await transport(request);
if (result?.ok && writes.has(operation) && !(operation === "requestPostServiceRequest" && message.phase === "prepare")) invalidate();
return result;
}
const key = JSON.stringify(stable(message));
const hit = entries.get(key);
if (hit && Date.now() >= hit.fetchedAt && Date.now() - hit.fetchedAt < duration) {
return { ...copy(hit.result), debugRequests: [], fromCache: true, cachedAt: hit.fetchedAt };
}
entries.delete(key);
if (pending.has(key)) {
const result = await pending.get(key);
return { ...copy(result), debugRequests: [] };
}
const version = generation;
const promise = transport(request).then(result => {
if (result?.ok && version === generation) {
// Keep this per-page cache bounded and avoid persisting credentials or customer data to disk.
if (entries.size >= 500) entries.delete(entries.keys().next().value);
entries.set(key, { result: copy({ ...result, debugRequests: [] }), fetchedAt: Date.now() });
}
return result;
});
pending.set(key, promise);
try { return await promise; }
finally { if (pending.get(key) === promise) pending.delete(key); }
}
return { send, invalidate };
}

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