28 Commits

Author SHA1 Message Date
2363e37aab Release 1.1.0 2026-10-30 14:53:03 -03:00
9cdb46b6c2 Added the ability to bookmark the extension, along with some visual adjustments 2026-10-30 14:48:00 -03:00
00bcfdc42e Improved the background request loading order 2026-10-29 17:35:20 -03:00
9b5e751179 Updated the structure of the RELEASE_NOTES.md file 2026-10-28 19:19:06 -03:00
88de9b81b7 Release 1.0.9 2026-10-28 11:47:48 -03:00
3fa212e561 Updated the process for retrieving records from CRMServiceRequests 2026-10-28 11:43:26 -03:00
318459cef8 Release 1.0.8 2026-10-27 11:52:59 -03:00
aff137b319 Adjusted the simplified display of the customer name 2026-10-27 11:49:56 -03:00
8d2110e8e8 Optimized the process for obtaining user sessions for SPA and comcipapic 2026-10-27 03:34:23 -03:00
1d5b28e95b Updated the calendar to improve opportunity display and navigation 2026-10-27 03:22:10 -03:00
7606643ff4 Adjusted typography, styles, and other visual elements to ensure consistent layouts 2026-10-26 19:14:18 -03:00
3229ce9c41 Implemented the Organization Chart feature to create customer organizational 2026-10-26 18:16:55 -03:00
bf48ed1dcb Release 1.0.7 2026-10-26 11:41:53 -03:00
45428c9626 Updated the logic to retrieve the default savedSearchNumber dynamically 2026-10-26 11:39:21 -03:00
0f1f399d1a Table sorting preferences will be saved and reused based on the user’s selections 2026-10-25 00:39:55 -03:00
19edee4e11 Release 1.0.6 2026-10-21 15:59:08 -03:00
ba61f91c53 Fixed the SPA authentication flow 2026-10-21 15:55:29 -03:00
5ef4c22c59 Release 1.0.5 2026-10-20 16:01:21 -03:00
60daabfe91 Implemented the feature to identify and update estimates based on the ACR value, consumption start date, and ramp-up 2026-10-20 15:55:58 -03:00
71d550dfb1 Fixed the date ranges for the Reference options 2026-10-19 18:10:16 -03:00
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
172 changed files with 22193 additions and 2252 deletions

134
README.md
View File

@@ -1,6 +1,74 @@
# Opportunities Extension 1.0.2 # Opportunities Extension
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table. 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.
## Opportunity Views
**Overview** is the default table view. **Calendar** 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.
Table sorting remembers the last column and direction independently for Overview,
Installed Base, Service Requests, Rate Card, Consumption projection and Manage Time.
Preferences are restored when reopening each view and survive page reloads in the
same browser profile. Opportunity product tables retain their original order.
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.
## Customer Organization Charts
The organization-chart icon beside **View customer service requests** opens a
builder shared by opportunities for the same customer. Add people and direct
reports, change their manager using **Reports to**, or click a person to edit
name and notes, and view contact details. **Name** uses the shared searchable select, populated by
`requestQueryAccountContact` after a 350 ms pause in typing, using the Token Relay
Bearer token and filtering by the customer's `PartyId`. Each POST returns up to
15 relevance-ranked results; empty queries and chart opening do not send searches.
Superseded responses are ignored. Contact names are keyed by `PartyId`;
options display only the person's name and job title. **Job Title** and **Email address**
are read-only and fetched afresh using the person's name as `keywords` whenever
Person details opens. The stored contact `PartyId` identifies the correct result.
Job Title is saved with the chart and shown immediately on the node. A fresh
response updates the matching person's name and Job Title automatically, preserving
notes and reporting relationships; **Save chart** persists those changes.
Email remains in memory only; existing persisted emails are removed by migration. Existing
saved people remain available even if absent from the contact list. Notes appear on hover or
keyboard focus. Branches can be expanded/collapsed; zoom, fit and canvas panning
help navigate larger trees. Deleting a person promotes their direct reports to
that person's manager without deleting those reports.
**Apply changes** updates the working tree; **Save chart** persists it. Unsaved
changes require confirmation before closing. The extension background stores
JSON-compatible records in IndexedDB, scoped by current user and customer ID in
the current browser profile. Records include schema version, revision, update
timestamp and a flat node array (`id`, `parentId`, `name`, `jobTitle`, `notes`,
`collapsed`, and optional `contactPartyId`). Concurrent stale saves are rejected. Charts are local and do not
sync between devices or write to Oracle services. They support up to 500 people
and 60 reporting levels, with required name and optional notes.
The interaction is inspired by [OrgChart](https://github.com/dabeng/OrgChart).
Layout uses bundled [D3 Hierarchy 3.1.2](https://d3js.org/d3-hierarchy/tree), with
the extension's existing modal, theme and autocomplete patterns. D3 and icon
licenses are in `src/vendor/d3-hierarchy/` and `src/vendor/lucide/`. There is no
runtime CDN dependency or new browser permission.
For synthetic browser checks, install Playwright separately (or provide it through
`NODE_PATH`) and run `node scripts/verify-org-chart.cjs`. `TEST_BROWSER_CHANNEL`
can select an installed `msedge` or `chrome`. This exercises real IndexedDB in a
local fixture and writes light/dark/mobile screenshots to a temporary folder;
native extension storage and Oracle integration still require the browser smoke
check below.
## Development Structure ## Development Structure
@@ -33,9 +101,24 @@ On the first Opportunities modal opening in a page session, `requestCurrentUser`
fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls, fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls,
Reference changes and modal reopenings reuse that result. Users with `SALES_REP` 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. 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; Identity must contain a valid user (id, name, roles) and token before dependent
other opportunity features continue. Reload the page to refresh identity/roles. requests start. Failed identity validation keeps those requests waiting and retries
The unused identity token is discarded and redacted from debug responses. with backoff. 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.
After an initial identity failure, recovery opens `https://spa.oracle.com/` in one
inactive auxiliary tab. The top-frame-only `spa-page.js` content script inspects
the page without sending HTTP requests. It rejects error pages, SAML/login forms,
OAuth callbacks and incomplete documents; cookies, HTTP 200 and elapsed time alone
do not establish readiness. After a stable loaded document is verified, identity
is fetched in that tab's same-origin context, at most once per document. A failed
read requires a fresh verified navigation before retrying. Rejected callbacks
restart at the SPA home URL, never at the one-use callback URL. A two-minute
readiness timeout ends the cycle without an additional API probe. The owned tab
and observers are cleaned up on success or failure. Debug metadata records the
page state, wait reason and `spa-tab` execution context; sensitive values remain
hidden by default. Reload the extension after changes to the manifest or script.
Consumer token recovery also handles HTTP 401/403, in addition to HTTP 200 with 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, invalid JSON. It opens the existing SPA session URL in an inactive temporary tab,
@@ -233,15 +316,15 @@ scrolling is reserved for table containers below
1120px. The scoped table stylesheet does not change product-table column widths 1120px. The scoped table stylesheet does not change product-table column widths
or the existing light/dark colors. or the existing light/dark colors.
Product HTTP requests appear in Debug Requests (Ctrl+D) as soon as they start. Product HTTP requests appear in Debug Requests (Ctrl+Shift+D) as soon as they start.
Each entry identifies the opportunity and page offset, stays `running` while Each entry identifies the opportunity and page offset, stays `running` while
waiting for headers or the response body, and updates with HTTP status, response, 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 duration or error. Cache hits do not create HTTP entries. Product diagnostics
mask authorization and sensitive response headers before messaging the source mask authorization and sensitive response headers before messaging the source
tab/frame; this change adds no timeout, retry or authentication behavior. 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 Debug Requests opens as a full-page view inside the extension using Ctrl+Shift+D, without
opening a browser tab or entering native browser fullscreen. Close, Escape or Ctrl+D opening a browser tab or entering native browser fullscreen. Close, Escape or Ctrl+Shift+D
returns to the underlying view. Header/category navigation remains above the returns to the underlying view. Header/category navigation remains above the
scrollable timeline; expanded details use two columns on wide screens and one on scrollable timeline; expanded details use two columns on wide screens and one on
narrow screens. Theme-specific colors distinguish success, failure, running, narrow screens. Theme-specific colors distinguish success, failure, running,
@@ -249,8 +332,9 @@ HTTP methods/statuses, request/response sections and JSON tokens. Highlighting
uses safe text nodes only after the existing sensitive-value masking. Compact JSON, uses safe text nodes only after the existing sensitive-value masking. Compact JSON,
supported protection prefixes and JSON documents wrapped in strings are normalized supported protection prefixes and JSON documents wrapped in strings are normalized
and indented, including while sensitive values are visible. Large JSON responses and indented, including while sensitive values are visible. Large JSON responses
are captured as bounded, valid JSON previews with an explicit omission notice, are captured in full, preserving indentation, highlighting and sensitive-field
preserving indentation, highlighting and sensitive-field masking. Already incomplete 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 or invalid JSON remains plain text. The visibility toggle retains its existing
behavior. Focus, expansion and timeline scroll position survive log updates. behavior. Focus, expansion and timeline scroll position survive log updates.
@@ -288,34 +372,6 @@ AI text and consumption; unexpected requests fail closed. It does not authentica
against Fusion or read real extension storage. `/consumption-trend.html` provides against Fusion or read real extension storage. `/consumption-trend.html` provides
a focused chart fixture. Stop the server with Ctrl+C when finished. 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.
## Loading in Development Mode ## Loading in Development Mode
### Chrome or Microsoft Edge ### Chrome or Microsoft Edge
@@ -337,3 +393,5 @@ a focused chart fixture. Stop the server with Ctrl+C when finished.
- `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome` - `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome`
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. 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.
With the Opportunities Extension modal open, press Ctrl+Shift+F to choose a browser bookmarks folder and save the extension shortcut.

107
RELEASE_NOTES.md Normal file
View File

@@ -0,0 +1,107 @@
# Release Notes
## Release 1.1.0 - 2026-10-30
- Display opportunities first, then load Service Requests and update their badges in the background.
- Clicking an item when all filter choices are selected now selects that item alone, including Booking and Workload.
- Added a bookmark shortcut that opens the extension automatically from Fusion.
- Ctrl+Shift+F opens a searchable bookmarks folder picker; Ctrl+Shift+D toggles Debug Requests.
- Show a prominent favorites shortcut hint for 20 seconds when the shortcut is not bookmarked; keep Last update first in the header.
- Smaller dialogs can be dragged by their title bars, with up to 70% overflow beyond viewport edges.
- Updated Chromium/Edge and Firefox builds. Automated validation uses synthetic data; live Oracle and installed-browser testing are not included.
## Release 1.0.9 - 2026-10-28
- Added a landscape print/PDF view with filters, totals, and opportunities.
- Resolve the CRM Service Request saved search dynamically before querying records.
- Restore hidden-iframe Internal SR session recovery with bounded retries and shared pending requests.
- Align opportunity badges and customer actions consistently.
- Move release history from README to this dedicated file.
## Release 1.0.8 - 2026-10-27
- Added customer Organization Charts with contact search, contact creation, person details, notes, hierarchy editing and locally persisted chart data.
- Added paginated, sortable opportunities by contact using the contact PartyNumber.
- Unified modal and table layouts, loading skeletons and typography; added Rate Card order-number filtering and simplified Overview customer names using PartyName.
- Added Reference-bounded scrolling calendars with all daily opportunity cards and positioning around the current week.
- Added Booking/Workload filters and improved ramp/projection access, alerts and unavailable-workload indicators.
- Removed AI-generated opportunity summaries and their dedicated requests.
- Persisted debug requests in localStorage with lazy rendering; history resets only when a new opportunity query starts.
- Reuse successful current-user identity during the browser session; fixed debug timer invocation and added Token Relay timeout/retry support.
- Prepare SPA and SR sessions concurrently, inspect SPA before visual assets finish loading, and provide SR session retry with a 70-second timeout.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests are not included.
## Release 1.0.7 - 2026-10-26
- Remember table sorting preferences and restore the selected column and direction when reopening supported tables.
- Retrieve the user's Opportunity saved searches before loading opportunities and use the default savedSearchNumber for copiedFrom across all result pages.
- Show requestOpportunitiesSavedSearch in the Opportunities debug category and stop dependent queries when the default search is missing or invalid.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests were not performed for this release.
## Release 1.0.6 - 2026-10-21
- Validate the current-user JSON structure before starting dependent requests, sharing concurrent authentication work and retrying failed identity acquisition.
- Recover SPA sessions through one inactive auxiliary tab at `https://spa.oracle.com/`, preserving the originating window and Firefox cookie store.
- Inspect the SPA document before retrying identity: reject error pages, SAML/login forms, OAuth callbacks and incomplete documents. HTTP 200, cookies and elapsed time alone no longer release identity probes.
- Read identity in the verified SPA tab, at most once per document; restart rejected authentication at the home URL rather than replaying OAuth callbacks. Readiness timeouts do not trigger fallback API probes.
- Correct native timer invocation and retain protected debug diagnostics and auxiliary-tab cleanup.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle authentication and installed-browser smoke tests were not performed for this release.
## Release 1.0.5 - 2026-10-20
- Corrected Reference date ranges for Oracle fiscal quarters and years, updated the opportunity query structure and removed the Renewals reference.
- Added paginated workload retrieval by unique customer account and joined workloads to products by RevnId.
- Added consumption projection comparisons using adjustedACR, RampMonths_c and the reference workbook formula, including first-month proration.
- Product and opportunity alerts identify monthly forecast differences. Single-product alerts open the comparison directly; multiple-product alerts expand the product list.
- Added Update estimates with explicit confirmation before PATCH, dynamic calculated amounts and current-user attribution, duplicate-submit protection and success/error feedback.
- Successful forecast updates refresh local comparisons and invalidate workload caches. Workloads reuse Cache duration across modal openings and token renewals.
- 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.
## 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.
## 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.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.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.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.

File diff suppressed because it is too large Load Diff

4696
dist/chromium/content.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -2,10 +2,13 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Opportunities Extension", "name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", "description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.2", "version": "1.1.0",
"permissions": [ "permissions": [
"bookmarks",
"cookies", "cookies",
"storage" "storage",
"webRequest",
"declarativeNetRequestWithHostAccess"
], ],
"host_permissions": [ "host_permissions": [
"https://eeho.fa.us2.oraclecloud.com/*", "https://eeho.fa.us2.oraclecloud.com/*",
@@ -13,6 +16,17 @@
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*" "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
], ],
"content_scripts": [ "content_scripts": [
{
"matches": [
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"
],
"js": [
"shortcut-intent.js"
],
"all_frames": false,
"run_at": "document_start"
},
{ {
"matches": [ "matches": [
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
@@ -23,6 +37,16 @@
], ],
"all_frames": true, "all_frames": true,
"run_at": "document_idle" "run_at": "document_idle"
},
{
"matches": [
"https://spa.oracle.com/*"
],
"js": [
"spa-page.js"
],
"all_frames": false,
"run_at": "document_end"
} }
], ],
"background": { "background": {

11
dist/chromium/shortcut-intent.js vendored Normal file
View File

@@ -0,0 +1,11 @@
// Capture the bookmark before Fusion rewrites its navigation URL.
(() => {
if (window.top !== window) return;
const capture = () => {
const url = new URL(window.location.href);
if (url.hash !== "#opportunities-extension" && url.searchParams.get("opportunitiesExtension") !== "open") return;
try { window.sessionStorage.setItem("opportunitiesExtension.shortcutIntent", String(Date.now())); } catch { /* The URL remains a fallback. */ }
};
capture();
window.addEventListener("hashchange", capture);
})();

58
dist/chromium/spa-page.js vendored Normal file
View File

@@ -0,0 +1,58 @@
(function () {
"use strict";
// Source: src/auth/spa-page.js
function createSpaPage() {
function start() {
if (location.origin !== "https://spa.oracle.com" || window.top !== window) return;
const runtime = (globalThis.browser || globalThis.chrome)?.runtime;
if (!runtime) return;
const documentId = `${performance.timeOrigin}:${location.pathname}`;
let identityRequest = null;
function inspect() {
const text = `${document.title}\n${document.body?.innerText || ""}`.trim();
let status = "loading";
if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error";
else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname)
|| /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title)
|| document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login";
else if ((document.readyState === "interactive" || document.readyState === "complete") && text
&& document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready";
return { status, documentId };
}
runtime.onMessage.addListener((message, sender, reply) => {
if (sender.id !== runtime.id) return false;
if (message?.type === "opportunitiesExtension.inspectSpaPage") {
reply(inspect());
return false;
}
if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false;
const page = inspect();
if (page.status !== "ready" || page.documentId !== message.documentId) {
reply({ error: "SPA page is not ready or has navigated." });
return false;
}
// A document is probed at most once; failed identity reads require a fresh navigation.
if (!identityRequest) identityRequest = (async () => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal
});
return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers),
contentType: response.headers.get("content-type") || "", body: await response.text() };
} catch (error) {
return { error: error.message || "Unable to read SPA identity." };
} finally { clearTimeout(timer); }
})();
identityRequest.then(reply);
return true;
});
}
return { start };
}
createSpaPage().start();
})();

File diff suppressed because it is too large Load Diff

4696
dist/firefox/content.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -2,10 +2,13 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Opportunities Extension", "name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", "description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.2", "version": "1.1.0",
"permissions": [ "permissions": [
"bookmarks",
"cookies", "cookies",
"storage" "storage",
"webRequest",
"declarativeNetRequestWithHostAccess"
], ],
"host_permissions": [ "host_permissions": [
"https://eeho.fa.us2.oraclecloud.com/*", "https://eeho.fa.us2.oraclecloud.com/*",
@@ -13,6 +16,17 @@
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*" "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
], ],
"content_scripts": [ "content_scripts": [
{
"matches": [
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"
],
"js": [
"shortcut-intent.js"
],
"all_frames": false,
"run_at": "document_start"
},
{ {
"matches": [ "matches": [
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
@@ -23,6 +37,16 @@
], ],
"all_frames": true, "all_frames": true,
"run_at": "document_idle" "run_at": "document_idle"
},
{
"matches": [
"https://spa.oracle.com/*"
],
"js": [
"spa-page.js"
],
"all_frames": false,
"run_at": "document_end"
} }
], ],
"background": { "background": {

11
dist/firefox/shortcut-intent.js vendored Normal file
View File

@@ -0,0 +1,11 @@
// Capture the bookmark before Fusion rewrites its navigation URL.
(() => {
if (window.top !== window) return;
const capture = () => {
const url = new URL(window.location.href);
if (url.hash !== "#opportunities-extension" && url.searchParams.get("opportunitiesExtension") !== "open") return;
try { window.sessionStorage.setItem("opportunitiesExtension.shortcutIntent", String(Date.now())); } catch { /* The URL remains a fallback. */ }
};
capture();
window.addEventListener("hashchange", capture);
})();

58
dist/firefox/spa-page.js vendored Normal file
View File

@@ -0,0 +1,58 @@
(function () {
"use strict";
// Source: src/auth/spa-page.js
function createSpaPage() {
function start() {
if (location.origin !== "https://spa.oracle.com" || window.top !== window) return;
const runtime = (globalThis.browser || globalThis.chrome)?.runtime;
if (!runtime) return;
const documentId = `${performance.timeOrigin}:${location.pathname}`;
let identityRequest = null;
function inspect() {
const text = `${document.title}\n${document.body?.innerText || ""}`.trim();
let status = "loading";
if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error";
else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname)
|| /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title)
|| document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login";
else if ((document.readyState === "interactive" || document.readyState === "complete") && text
&& document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready";
return { status, documentId };
}
runtime.onMessage.addListener((message, sender, reply) => {
if (sender.id !== runtime.id) return false;
if (message?.type === "opportunitiesExtension.inspectSpaPage") {
reply(inspect());
return false;
}
if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false;
const page = inspect();
if (page.status !== "ready" || page.documentId !== message.documentId) {
reply({ error: "SPA page is not ready or has navigated." });
return false;
}
// A document is probed at most once; failed identity reads require a fresh navigation.
if (!identityRequest) identityRequest = (async () => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal
});
return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers),
contentType: response.headers.get("content-type") || "", body: await response.text() };
} catch (error) {
return { error: error.message || "Unable to read SPA identity." };
} finally { clearTimeout(timer); }
})();
identityRequest.then(reply);
return true;
});
}
return { start };
}
createSpaPage().start();
})();

View File

@@ -14,6 +14,11 @@ preexisting risks are tracked in [refactoring.md](refactoring.md).
uses `background.scripts`. Neither entry point is declared as a module. uses `background.scripts`. Neither entry point is declared as a module.
- Content scripts run at `document_idle`, in all matching frames on the two - Content scripts run at `document_idle`, in all matching frames on the two
configured Fusion welcome pages. Their code also checks the hostname/path. configured Fusion welcome pages. Their code also checks the hostname/path.
- `src/auth/spa-page.js` is a separate top-frame-only SPA content script, copied
into both distributions. It handles only extension messages for DOM readiness
inspection and a fixed same-origin identity GET; it sends no requests on its own.
`services/spa/page-client.js` connects this receiver to the background recovery
flow and the existing debug envelope. No page-world script is injected.
- There are no extension UI pages, injected page-context scripts, custom CSP, - There are no extension UI pages, injected page-context scripts, custom CSP,
declared execution world, or web-accessible resources. declared execution world, or web-accessible resources.
- Permissions remain `cookies` and `storage`, with host access limited to the - Permissions remain `cookies` and `storage`, with host access limited to the

View File

@@ -12,7 +12,7 @@ pass until executed and recorded separately for each browser.
- Run `node scripts/build.mjs --check` and `node scripts/test.cjs` first. - Run `node scripts/build.mjs --check` and `node scripts/test.cjs` first.
- Load `dist/chromium` using Load unpacked in Chrome/Edge, or load - Load `dist/chromium` using Load unpacked in Chrome/Edge, or load
`dist/firefox/manifest.json` as a temporary Firefox add-on. Verify the source `dist/firefox/manifest.json` as a temporary Firefox add-on. Verify the source
directory and version 1.0.2; avoid testing a previously installed stale copy. directory and version 1.0.7; avoid testing a previously installed stale copy.
- Keep debug secrets hidden. Record only status codes, request names, durations - Keep debug secrets hidden. Record only status codes, request names, durations
and sanitized errors. Do not export real tokens, response bodies or account data. 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 - AI generation and tests requiring a deliberately expired session must use an
@@ -34,6 +34,8 @@ pass until executed and recorded separately for each browser.
| Consumption | Dialog opens promptly, weekly chart by default; plan/period changes and series filters work | | 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 | | 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 | | Debug | Expected operation names/statuses; sensitive values remain hidden |
| Organization charts | Icon after customer SR action; add/report/reparent/edit/notes/collapse; Save chart then reopen from another opportunity for the same customer; reload page and restart browser retains chart; theme and small viewport remain usable |
| Organization chart conflicts | Two tabs open the same chart; save one, then save stale second tab; second save reports a conflict and retains local edits rather than overwriting the first |
| Background restart | Close its inspector, allow suspension, then trigger a message; listener responds and stored product cache is reloaded | | 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 | | 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 | | Firefox containers | Test only in authorized containers; record current cookie/cache isolation limitations without silently changing behavior |

View File

@@ -1,6 +1,6 @@
{ {
"name": "opportunities-extension", "name": "opportunities-extension",
"version": "1.0.2", "version": "1.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -8,16 +8,21 @@ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const distDir = path.join(rootDir, "dist"); const distDir = path.join(rootDir, "dist");
const contentScript = composition.composeScript("content"); const contentScript = composition.composeScript("content");
const backgroundScript = composition.composeScript("background"); const backgroundScript = composition.composeScript("background");
const spaPageScript = composition.composeScript("spa-page");
const shortcutScript = await readFile(path.join(rootDir, "src/content/shortcut-intent.js"), "utf8");
const checkOnly = process.argv.includes("--check"); const checkOnly = process.argv.includes("--check");
const baseManifest = { const baseManifest = {
manifest_version: 3, manifest_version: 3,
name: "Opportunities Extension", name: "Opportunities Extension",
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
version: "1.0.2", version: "1.1.0",
permissions: [ permissions: [
"bookmarks",
"cookies", "cookies",
"storage" "storage",
"webRequest",
"declarativeNetRequestWithHostAccess"
], ],
host_permissions: [ host_permissions: [
"https://eeho.fa.us2.oraclecloud.com/*", "https://eeho.fa.us2.oraclecloud.com/*",
@@ -25,6 +30,12 @@ const baseManifest = {
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*" "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
], ],
content_scripts: [ content_scripts: [
{
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
js: ["shortcut-intent.js"],
all_frames: false,
run_at: "document_start"
},
{ {
matches: [ matches: [
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
@@ -33,6 +44,12 @@ const baseManifest = {
js: ["content.js"], js: ["content.js"],
all_frames: true, all_frames: true,
run_at: "document_idle" run_at: "document_idle"
},
{
matches: ["https://spa.oracle.com/*"],
js: ["spa-page.js"],
all_frames: false,
run_at: "document_end"
} }
] ]
}; };
@@ -68,7 +85,7 @@ for (const target of targets) {
JSON.parse(manifestJson); JSON.parse(manifestJson);
if (checkOnly) { if (checkOnly) {
const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript }; const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript, "spa-page.js": spaPageScript, "shortcut-intent.js": shortcutScript };
for (const [file, contents] of Object.entries(expected)) { for (const [file, contents] of Object.entries(expected)) {
const actual = await readFile(path.join(distDir, target.name, file), "utf8"); 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.`); if (actual !== contents) throw new Error(`Stale output: dist/${target.name}/${file}. Run npm run build.`);
@@ -80,6 +97,8 @@ for (const target of targets) {
await mkdir(outputDir, { recursive: true }); await mkdir(outputDir, { recursive: true });
await output.writeOutput(path.join(outputDir, "content.js"), contentScript); await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript); await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript);
await output.writeOutput(path.join(outputDir, "spa-page.js"), spaPageScript);
await output.writeOutput(path.join(outputDir, "shortcut-intent.js"), shortcutScript);
await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson); await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson);
} }

View File

@@ -2,6 +2,7 @@ const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const vm = require("node:vm"); const vm = require("node:vm");
const sources = require("./sources.cjs"); const sources = require("./sources.cjs");
const vendorSources = { content: ["src/vendor/fullcalendar/index.global.min.js", "src/vendor/d3-hierarchy/d3-hierarchy.min.js"], background: [] };
const root = path.resolve(__dirname, ".."); const root = path.resolve(__dirname, "..");
@@ -16,15 +17,23 @@ function composeScript(target, { bootstrap = true } = {}) {
return `// Source: ${file}\n${source}`; return `// Source: ${file}\n${source}`;
}); });
if (target === "content") { if (target === "content") {
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css"] const hierarchySource = fs.readFileSync(path.join(root, vendorSources.content[1]), "utf8");
const hierarchyLicense = fs.readFileSync(path.join(root, "src/vendor/d3-hierarchy/LICENSE"), "utf8");
const iconLicense = fs.readFileSync(path.join(root, "src/vendor/lucide/LICENSE"), "utf8");
parts.unshift(`/* ${iconLicense} */\nlet organizationLayout;\nfunction loadOrganizationLayout() {\nif (!organizationLayout) { const exports = {}; const module = { exports };\n/* ${hierarchyLicense} */\n${hierarchySource}\norganizationLayout = exports; }\nreturn organizationLayout;\n}`);
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", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css", "src/content/styles/tables.css", "src/content/styles/dialogs.css")
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n"); .map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`); parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
} }
const factory = target === "content" ? "createContentApplication" : "createBackgroundApplication"; const factory = { content: "createContentApplication", background: "createBackgroundApplication", "spa-page": "createSpaPage" }[target];
const ending = bootstrap ? `${factory}().start();` : `return ${factory};`; const ending = bootstrap ? `${factory}().start();` : `return ${factory};`;
const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`; const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`;
new vm.Script(output, { filename: `${target}.js` }); new vm.Script(output, { filename: `${target}.js` });
return output; return output;
} }
module.exports = { composeScript, sources }; module.exports = { composeScript, sources, vendorSources };

View File

@@ -17,6 +17,14 @@ const server = http.createServer(async (request, response) => {
return; return;
} }
try { try {
if (pathname === "/org-storage-fixture.js") {
const files = ["../src/features/org-chart/model.js", "../src/features/org-chart/storage.js"];
const parts = [];
for (const file of files) parts.push(await readFile(new URL(file, import.meta.url), "utf8"));
response.setHeader("Content-Type", "text/javascript");
response.end(parts.join("\n") + "\nwindow.fixtureOrgStorage = createOrgChartStorage({ environment: { indexedDB }, model: createOrgChartModel() });");
return;
}
if (pathname === "/fixture-runtime.js") { if (pathname === "/fixture-runtime.js") {
response.setHeader("Content-Type", "text/javascript"); response.setHeader("Content-Type", "text/javascript");
response.end(`window.createFixtureApplication = ${composition.composeScript("content", { bootstrap: false })}`); response.end(`window.createFixtureApplication = ${composition.composeScript("content", { bootstrap: false })}`);

View File

@@ -1,21 +1,30 @@
module.exports = { module.exports = {
"spa-page": ["src/auth/spa-page.js"],
"content": [ "content": [
"src/features/org-chart/model.js",
"src/features/org-chart/icons.js",
"src/features/org-chart/calling-codes.js",
"src/features/org-chart/view.js",
"src/services/fusion/account-contact.js",
"src/features/preferences/table-sorting.js",
"src/shared/time-dates.js",
"src/features/manage-time/view.js",
"src/shared/messages.js", "src/shared/messages.js",
"src/shared/debug-body.js", "src/shared/debug-body.js",
"src/content/contracts.js", "src/content/contracts.js",
"src/auth/session-state.js", "src/auth/session-state.js",
"src/auth/sales-common-state.js",
"src/features/opportunities/query-state.js", "src/features/opportunities/query-state.js",
"src/features/opportunities/table-state.js", "src/features/opportunities/table-state.js",
"src/features/opportunities/filter-state.js", "src/features/opportunities/filter-state.js",
"src/features/preferences/state.js", "src/features/preferences/state.js",
"src/features/preferences/request-cache.js",
"src/features/products/content-state.js", "src/features/products/content-state.js",
"src/features/ai-summary/state.js",
"src/features/consumption/plans-state.js", "src/features/consumption/plans-state.js",
"src/features/consumption/trend-state.js", "src/features/consumption/trend-state.js",
"src/features/opportunities/dashboard-state.js", "src/features/opportunities/dashboard-state.js",
"src/features/debug/state.js", "src/features/debug/state.js",
"src/features/opportunities/columns.js", "src/features/opportunities/columns.js",
"src/features/opportunities/print.js",
"src/features/opportunities/badge-content.js", "src/features/opportunities/badge-content.js",
"src/content/fusion-page.js", "src/content/fusion-page.js",
"src/content/modal.js", "src/content/modal.js",
@@ -23,9 +32,9 @@ module.exports = {
"src/content/icons.js", "src/content/icons.js",
"src/content/svg.js", "src/content/svg.js",
"src/content/styles.js", "src/content/styles.js",
"src/content/dialog-drag.js",
"src/auth/content-session.js", "src/auth/content-session.js",
"src/auth/current-user.js", "src/auth/current-user.js",
"src/auth/sales-common.js",
"src/auth/token-relay.js", "src/auth/token-relay.js",
"src/auth/xsrf-content.js", "src/auth/xsrf-content.js",
"src/platform/content-messages.js", "src/platform/content-messages.js",
@@ -40,6 +49,7 @@ module.exports = {
"src/features/opportunities/activity-filters.js", "src/features/opportunities/activity-filters.js",
"src/features/opportunities/crm-sr-popover.js", "src/features/opportunities/crm-sr-popover.js",
"src/features/opportunities/crm-sr-form.js", "src/features/opportunities/crm-sr-form.js",
"src/features/opportunities/internal-sr-form.js",
"src/features/opportunities/searchable-select.js", "src/features/opportunities/searchable-select.js",
"src/features/opportunities/internal-sr-session.js", "src/features/opportunities/internal-sr-session.js",
"src/features/opportunities/sr-session-preparation.js", "src/features/opportunities/sr-session-preparation.js",
@@ -48,6 +58,7 @@ module.exports = {
"src/features/opportunities/workload-action-options.js", "src/features/opportunities/workload-action-options.js",
"src/features/opportunities/fiscal-periods.js", "src/features/opportunities/fiscal-periods.js",
"src/features/opportunities/table.js", "src/features/opportunities/table.js",
"src/features/opportunities/calendar.js",
"src/features/opportunities/dashboard.js", "src/features/opportunities/dashboard.js",
"src/features/opportunities/filters.js", "src/features/opportunities/filters.js",
"src/features/opportunities/formatters.js", "src/features/opportunities/formatters.js",
@@ -55,8 +66,11 @@ module.exports = {
"src/features/preferences/storage.js", "src/features/preferences/storage.js",
"src/features/preferences/view.js", "src/features/preferences/view.js",
"src/features/products/content-jobs.js", "src/features/products/content-jobs.js",
"src/features/products/workloads.js",
"src/features/products/details.js", "src/features/products/details.js",
"src/features/ai-summary/content.js", "src/features/products/ramp-projection.js",
"src/features/products/forecast-update.js",
"src/features/products/ramp-projection-view.js",
"src/features/consumption/plans-action.js", "src/features/consumption/plans-action.js",
"src/features/install-base/action.js", "src/features/install-base/action.js",
"src/features/install-base/view.js", "src/features/install-base/view.js",
@@ -79,19 +93,28 @@ module.exports = {
"src/content/application.js" "src/content/application.js"
], ],
"background": [ "background": [
"src/features/org-chart/model.js",
"src/features/org-chart/storage.js",
"src/shared/time-dates.js",
"src/services/fusion/time-entries.js",
"src/shared/messages.js", "src/shared/messages.js",
"src/shared/debug-body.js", "src/shared/debug-body.js",
"src/background/contracts.js", "src/background/contracts.js",
"src/features/products/cache-state.js", "src/features/products/cache-state.js",
"src/auth/consumer-session.js", "src/auth/consumer-session.js",
"src/auth/spa-credentials.js", "src/auth/spa-credentials.js",
"src/auth/sr-request-headers.js",
"src/services/spa/accounts.js", "src/services/spa/accounts.js",
"src/services/spa/install-base.js", "src/services/spa/install-base.js",
"src/services/spa/customer-service-requests.js", "src/services/spa/customer-service-requests.js",
"src/services/spa/workload-actions.js", "src/services/spa/workload-actions.js",
"src/services/spa/workloads.js",
"src/features/products/workloads-cache.js",
"src/services/spa/action-options.js", "src/services/spa/action-options.js",
"src/services/fusion/sr-details.js", "src/services/fusion/sr-details.js",
"src/services/fusion/sr-create.js",
"src/services/spa/current-user.js", "src/services/spa/current-user.js",
"src/services/spa/page-client.js",
"src/services/spa/consumption.js", "src/services/spa/consumption.js",
"src/services/spa/response-shapes.js", "src/services/spa/response-shapes.js",
"src/services/http/background-json.js", "src/services/http/background-json.js",

View File

@@ -0,0 +1,273 @@
// Synthetic browser verification; install Playwright separately or provide NODE_PATH.
// No Oracle requests, credentials or real customer data are used.
const { chromium } = require("playwright");
const { spawn } = require("node:child_process");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
(async () => {
const server = spawn(process.execPath, ["scripts/preview.mjs"], { cwd: path.resolve(__dirname, ".."), stdio: ["ignore", "pipe", "inherit"] });
let browser;
const output = fs.mkdtempSync(path.join(os.tmpdir(), "org-chart-verification-"));
try {
const url = await new Promise((resolve, reject) => {
server.once("error", reject); server.once("exit", code => reject(new Error("Preview exited: " + code)));
server.stdout.on("data", chunk => { const found = String(chunk).match(/http:\/\/127\.0\.0\.1:\d+\//); if (found) resolve(found[0]); });
});
browser = await chromium.launch({ headless: true, ...(process.env.TEST_BROWSER_CHANNEL ? { channel: process.env.TEST_BROWSER_CHANNEL } : {}) });
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
const errors = []; page.on("pageerror", error => errors.push(error.message));
// Any external request is a fixture regression, not a permitted live test.
await page.route("**/*", route => new URL(route.request().url()).origin === new URL(url).origin ? route.continue() : route.abort());
page.setDefaultTimeout(10000);
await page.goto(url);
await page.evaluate(() => new Promise((resolve,reject) => {
const request=indexedDB.open("opportunities-extension-organizations",1);
request.onupgradeneeded=()=>request.result.createObjectStore("charts",{keyPath:"key"});
request.onerror=()=>reject(request.error);
request.onsuccess=()=>{
const db=request.result, tx=db.transaction("charts","readwrite");
tx.objectStore("charts").put({key:"legacy-migration",schemaVersion:1,revision:3,nodes:[{id:"legacy",parentId:null,name:"Legacy",role:"Old title",emailAddress:"old@example.invalid",notes:"Retain notes",collapsed:false}]});
tx.oncomplete=()=>{db.close();resolve();}; tx.onerror=()=>reject(tx.error);
};
}));
const open = () => page.getByRole("button", { name: "View customer organization chart", exact: true }).first().click();
const dialog = page.locator(".oe-org-dialog");
const button = label => dialog.getByRole("button", { name: label, exact: true });
const editor = page.locator(".oe-org-editor");
async function addPerson(name, role, notes = "", parent) {
await button(parent ? "Add report to " + parent : "Add person").click();
const search = editor.getByRole("combobox", { name: "Name", exact: true });
await search.fill(name);
await page.locator("#oe-org-name-options").getByRole("option").filter({ hasText: name }).click();
assert.equal(await editor.getByRole("textbox", { name: "Job Title", exact: true }).inputValue(), role);
assert.equal(await editor.getByRole("textbox", { name: "Job Title", exact: true }).evaluate(input => input.readOnly), true);
await editor.getByRole("textbox", { name: "Notes", exact: true }).fill(notes);
await editor.getByRole("button", { name: "Add person", exact: true }).click();
}
async function save() {
await button("Save chart").click();
await page.waitForFunction(() => document.querySelector(".oe-org-footer .oe-org-status")?.textContent.startsWith("Saved locally:"));
}
await open();
await dialog.getByRole("heading", { name: "No people yet" }).waitFor();
assert.deepEqual(await page.evaluate(()=>window.fixtureContactQueries || []),[]);
await button("Add person").click();
const nameSearch=editor.getByRole("combobox",{name:"Name",exact:true});
await editor.getByRole("button", { name: "Create Contact", exact: true }).click();
const contactDialog = page.locator(".oe-contact-dialog");
await contactDialog.getByRole("button", { name: "Save", exact: true }).click();
assert.equal(await contactDialog.locator('[aria-invalid="true"]').count(), 3);
assert.equal(await contactDialog.locator('#oe-contact-validation').textContent(), "Complete the highlighted required fields with valid values.");
await contactDialog.getByRole("textbox", { name: "First Name", exact: true }).fill("Test");
assert.equal(await contactDialog.locator('[aria-invalid="true"]').count(), 2);
assert.equal(await contactDialog.locator("#oe-contact-workCountry option").count(), 246);
assert.equal(await contactDialog.locator("#oe-contact-mobileCountry option").count(), 246);
const country = contactDialog.getByRole("combobox", { name: "Work Phone country code", exact: true });
await country.fill("Brazil");
await contactDialog.getByRole("option", { name: "BR · Brazil (+55)", exact: true }).click();
assert.equal(await contactDialog.locator("#oe-contact-workCountry").inputValue(), "BR");
assert.equal(await contactDialog.locator("#oe-contact-mobileCountry").inputValue(), "");
assert.equal(await contactDialog.locator('input[name="primaryPhone"]').count(), 0);
await page.screenshot({path:path.join(output,"create-contact.png")});
await contactDialog.getByRole("button", { name: "Cancel", exact: true }).click();
assert.equal(await editor.getByRole("button", { name: "Create Contact", exact: true }).evaluate(item => item === document.activeElement), true);
await nameSearch.fill("A"); await nameSearch.fill("Al"); await nameSearch.fill("Alex");
await page.locator("#oe-org-name-options [role=option]").waitFor();
assert.deepEqual(await page.evaluate(()=>window.fixtureContactQueries),["Alex"]);
await page.evaluate(()=>{window.fixtureContactCompleted=[]; window.fixtureContactDelay={Alex:1400};});
await nameSearch.fill("Alex");
await page.waitForFunction(()=>window.fixtureContactQueries.length===2);
await nameSearch.fill("Taylor");
await page.locator("#oe-org-name-options [role=option]").filter({hasText:"Taylor Chen"}).waitFor();
await page.waitForFunction(()=>window.fixtureContactCompleted.includes("Alex"));
assert.equal(await page.locator("#oe-org-name-options [role=option]").filter({hasText:"Alex Morgan"}).count(),0);
await page.evaluate(()=>{window.fixtureContactsFailure=true; window.fixtureContactDelay={};});
await nameSearch.fill("Alex");
await page.locator("#oe-org-name-options [role=status]").filter({hasText:"failed with status 503"}).waitFor();
await page.evaluate(()=>window.fixtureContactsFailure=false);
await nameSearch.fill("Jordan");
await page.locator("#oe-org-name-options [role=option]").filter({hasText:"Jordan Silva"}).waitFor();
await editor.getByRole("button", {name:"Cancel",exact:true}).click();
await button("Add person").click();
await editor.getByRole("combobox", { name: "Name", exact: true }).fill("Not a contact");
await editor.getByRole("button", { name: "Add person", exact: true }).click();
assert.equal(await page.locator(".oe-org-node").count(), 0);
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
await addPerson("Alex Morgan", "Chief Technology Officer", "Decision maker for the cloud program.");
await addPerson("Taylor Chen", "Infrastructure Director", "Owns the migration roadmap.", "Alex Morgan");
await addPerson("Jordan Silva", "Data and Analytics Director", "Coordinates the analytics team.", "Alex Morgan");
await addPerson("Sam Rivera", "Cloud Architect", "Technical workshop: November 12.", "Taylor Chen");
const queriesBeforeOpportunities = await page.evaluate(() => window.fixtureContactQueries.length);
await page.evaluate(() => { window.fixtureOpportunitiesDelay = 400; });
await button("View opportunities for Alex Morgan").click();
const contactOpportunities = page.locator(".oe-contact-opportunities");
await contactOpportunities.locator('table[aria-busy="true"]').waitFor();
assert.equal(await contactOpportunities.locator("tbody .oe-contact-skeleton").count(),35);
await contactOpportunities.getByText("6 opportunities", {exact:true}).waitFor();
assert.equal(await contactOpportunities.locator(".oe-contact-skeleton").count(),0);
await page.evaluate(() => { window.fixtureOpportunitiesDelay = 0; });
assert.equal(await page.evaluate(() => window.fixtureContactQueries.length), queriesBeforeOpportunities, "Opportunity lookup must reuse the person's PartyNumber without searching contacts");
assert.equal(await contactOpportunities.locator("tbody tr").count(), 6);
const presentation = await contactOpportunities.locator("table").evaluate(table => ({
marker: table.getAttribute("data-table-layout"),
textHeader: getComputedStyle(table.querySelector("th")).textAlign,
moneyHeader: getComputedStyle(table.querySelector('th[data-align="money"]')).textAlign,
moneyCell: getComputedStyle(table.querySelector('td[data-align="money"]')).textAlign,
nowrap: getComputedStyle(table.querySelector(".opportunities-extension-sort-label")).whiteSpace
}));
assert.deepEqual(presentation,{marker:"standard",textHeader:"left",moneyHeader:"right",moneyCell:"right",nowrap:"nowrap"});
await page.screenshot({path:path.join(output,"shared-table.png")});
assert.equal(await contactOpportunities.getByRole("link", {name:"Contact opportunity 0",exact:true}).getAttribute("target"), "_blank");
assert.ok((await contactOpportunities.getByRole("link", {name:"Contact opportunity 0",exact:true}).getAttribute("href")).endsWith("?puid=CONTACT0"));
assert.deepEqual((await page.evaluate(()=>window.fixtureOpportunitiesByContact)).map(url=>new URL(url).searchParams.get("offset")), ["0","5"]);
await contactOpportunities.getByRole("button",{name:"Sort by OptyNumber",exact:true}).click();
await contactOpportunities.getByRole("button",{name:"Sort by OptyNumber, ascending",exact:true}).click();
assert.equal(await contactOpportunities.locator("tbody tr").first().locator("td").first().textContent(),"CONTACT5");
assert.equal(new URL((await page.evaluate(()=>window.fixtureOpportunitiesByContact))[0]).searchParams.get("q"), "(OpportunityContact.ContactPartyNumber='18000')");
await contactOpportunities.getByRole("button", {name:"Close opportunities",exact:true}).click();
await button("View opportunities for Alex Morgan").click();
await contactOpportunities.getByText("6 opportunities",{exact:true}).waitFor();
assert.equal(await contactOpportunities.locator("tbody tr").first().locator("td").first().textContent(),"CONTACT5");
await contactOpportunities.getByRole("button", {name:"Close opportunities",exact:true}).click();
await button("Fit chart to view").click();
assert.equal(await page.locator(".oe-org-node").count(), 4);
await save();
await button("Edit Alex Morgan").hover();
assert.equal(await page.locator(".oe-org-tooltip").innerText(), "Decision maker for the cloud program.");
assert.equal(await dialog.getByRole("button", { name: /inline/i }).count(), 0);
await button("Edit Alex Morgan").click();
await page.waitForFunction(() => document.querySelector("#oe-org-emailAddress").value === "contact0@example.invalid");
assert.equal(await editor.getByRole("combobox", { name: "Name", exact: true }).inputValue(), "Alex Morgan");
await editor.getByRole("textbox", { name: "Notes", exact: true }).fill("Updated contact notes");
await button("Apply changes").click();
await page.evaluate(() => {
window.fixtureOriginalOrgSave = window.fixtureOrgStorage.save;
window.fixtureOrgStorage.save = async () => { throw new Error("Synthetic storage failure"); };
});
await button("Save chart").click();
await page.locator(".oe-org-status.oe-org-error").filter({ hasText: "Synthetic storage failure" }).waitFor();
assert.equal(await button("Save chart").isEnabled(), true);
await page.evaluate(() => { window.fixtureOrgStorage.save = window.fixtureOriginalOrgSave; });
await button("Collapse reports for Taylor Chen").click();
assert.equal(await page.locator(".oe-org-node").count(), 3);
await save();
await page.reload(); await open();
await button("Expand reports for Taylor Chen").waitFor();
assert.equal(await page.locator(".oe-org-node").count(), 3);
await button("Edit Alex Morgan").click();
await page.waitForFunction(() => document.querySelector("#oe-org-role").value === "Chief Technology Officer");
await editor.getByRole("button", {name:"Cancel",exact:true}).click();
await button("Expand reports for Taylor Chen").click();
await button("Edit Taylor Chen").click();
await page.waitForFunction(() => document.querySelector("#oe-org-emailAddress").value === "contact1@example.invalid");
assert.deepEqual(await editor.locator("#oe-org-parent option").allTextContents(), ["No manager (top level)", "Alex Morgan - Chief Technology Officer", "Jordan Silva - Data and Analytics Director"]);
await editor.getByRole("textbox", { name: "Notes", exact: true }).fill("Unapplied note");
await button("Close organization chart").click();
await page.getByRole("button", { name: "Keep editing", exact: true }).click();
assert.equal(await editor.getByRole("textbox", { name: "Notes", exact: true }).inputValue(), "Unapplied note");
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
// Reparent through the searchable select, then restore the original manager.
await button("Edit Sam Rivera").click();
await editor.getByRole("combobox", { name: "Reports to", exact: true }).click();
await page.getByRole("option", { name: "Jordan Silva - Data and Analytics Director", exact: true }).click();
await editor.getByRole("button", { name: "Apply changes", exact: true }).click();
await button("Edit Sam Rivera").click();
await editor.getByRole("combobox", { name: "Reports to", exact: true }).click();
await page.getByRole("option", { name: "Taylor Chen - Infrastructure Director", exact: true }).click();
await editor.getByRole("button", { name: "Apply changes", exact: true }).click();
await button("Fit chart to view").click();
await save();
const boxes = await page.locator(".oe-org-node").evaluateAll(nodes => nodes.map(node => { const r = node.getBoundingClientRect(); return { x:r.x,y:r.y,w:r.width,h:r.height }; }));
for (let a=0;a<boxes.length;a++) for(let b=a+1;b<boxes.length;b++) {
const p=boxes[a],q=boxes[b]; assert.ok(p.x+p.w<=q.x || q.x+q.w<=p.x || p.y+p.h<=q.y || q.y+q.h<=p.y, "Cards must not overlap");
}
await page.mouse.move(5,5);
await page.screenshot({ path: path.join(output, "light.png") });
await page.locator(".opportunities-extension-modal").first().evaluate(node => node.dataset.theme="dark");
await button("Edit Jordan Silva").click();
await page.keyboard.press("Control+d");
const debug = page.locator(".opportunities-extension-debug-panel");
await debug.getByRole("button", { name: "Close debug", exact: true }).waitFor();
assert.equal(await debug.evaluate(node => !node.closest("[inert]") && node.parentElement.classList.contains("opportunities-extension-modal")), true);
assert.equal(await page.evaluate(() => !!document.elementFromPoint(innerWidth / 2, innerHeight / 2).closest(".opportunities-extension-debug-panel")), true);
await debug.getByRole("button", { name: "Close debug", exact: true }).click();
assert.equal(await editor.isVisible(), true);
await editor.getByRole("button", { name: "Delete person", exact: true }).click();
await page.keyboard.press("Control+d");
assert.equal(await page.evaluate(() => !!document.elementFromPoint(innerWidth / 2, innerHeight / 2).closest(".opportunities-extension-debug-panel")), true);
await page.keyboard.press("Escape");
assert.equal(await page.getByRole("alertdialog").isVisible(), true);
await page.getByRole("alertdialog").getByRole("button", { name: "Keep editing", exact: true }).click();
await page.screenshot({ path: path.join(output, "dark-editor.png") });
await page.setViewportSize({width:390,height:844});
await page.screenshot({ path: path.join(output, "mobile-editor.png") });
const bounds = await dialog.boundingBox(); assert.ok(bounds.x>=0 && bounds.x+bounds.width<=390 && bounds.y+bounds.height<=844);
await page.setViewportSize({width:1440,height:960});
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
await button("Delete Taylor Chen").click();
await page.getByRole("alertdialog").getByRole("button", { name: "Keep editing", exact: true }).click();
assert.equal(await page.locator(".oe-org-node").count(), 4);
await button("Delete Taylor Chen").click();
await page.getByRole("alertdialog").getByRole("button", { name: "Delete person", exact: true }).click();
await save();
const storageResult = await page.evaluate(async () => {
const identity={principal:"example@example.invalid",customerId:"100000810210835"};
const store=window.fixtureOrgStorage, chart=(await store.load(identity)).chart;
const sam=chart.nodes.find(n=>n.name==="Sam Rivera"), alex=chart.nodes.find(n=>n.name==="Alex Morgan");
let conflict=false; try { await store.save({...identity,nodes:[],revision:0}); } catch { conflict=true; }
return {conflict,promoted:sam.parentId===alex.id,notes:sam.notes,contactPartyId:sam.contactPartyId,
independentUser:(await store.load({...identity,principal:"different@example.invalid"})).chart.nodes.length,
independentCustomer:(await store.load({...identity,customerId:"another-customer"})).chart.nodes.length,
afterConflict:(await store.load(identity)).chart.nodes.length};
});
assert.deepEqual(storageResult,{conflict:true,promoted:true,notes:"Technical workshop: November 12.",contactPartyId:"2003",independentUser:0,independentCustomer:0,afterConflict:3});
const rawRecords = await page.evaluate(() => new Promise((resolve,reject) => {
const request=indexedDB.open("opportunities-extension-organizations",3);
request.onerror=()=>reject(request.error);
request.onsuccess=()=>{
const db=request.result, tx=db.transaction("charts","readonly"), all=tx.objectStore("charts").getAll();
all.onsuccess=()=>{resolve(all.result);db.close();}; all.onerror=()=>reject(all.error);
};
}));
assert.ok(rawRecords.every(record=>record.nodes.every(node=>!("role" in node)&&!("emailAddress" in node)&&typeof node.jobTitle === "string")));
assert.equal(rawRecords.find(record=>record.key==="legacy-migration").nodes[0].notes,"Retain notes");
assert.equal(rawRecords.find(record=>record.key==="legacy-migration").revision,3);
assert.equal(rawRecords.find(record=>record.key==="legacy-migration").nodes[0].jobTitle,"Old title");
await button("Close organization chart").click();
await page.getByRole("button", { name: "View customer organization chart", exact: true }).nth(1).click();
await button("Edit Sam Rivera").waitFor(); // same customer, different opportunity
const queriesBefore=await page.evaluate(()=>window.fixtureContactQueries.filter(q=>q==="Sam Rivera").length);
await button("Edit Sam Rivera").click();
await page.waitForFunction(()=>document.querySelector("#oe-org-emailAddress").value==="contact3@example.invalid");
await editor.getByRole("button",{name:"Cancel",exact:true}).click();
await button("Edit Sam Rivera").click();
await page.waitForFunction(()=>document.querySelector("#oe-org-emailAddress").value==="contact3@example.invalid");
assert.equal(await page.evaluate(()=>window.fixtureContactQueries.filter(q=>q==="Sam Rivera").length),queriesBefore+2);
assert.equal(await button("Save chart").isDisabled(),true);
await editor.getByRole("button",{name:"Cancel",exact:true}).click();
await page.evaluate(()=>{
window.fixtureContactOverrides={"2003":{PartyName:"Samuel Rivera",JobTitle:"Principal Architect"}};
window.fixtureContactDelay={"Sam Rivera":250};
});
await button("Edit Sam Rivera").click();
await editor.getByRole("textbox",{name:"Notes",exact:true}).fill("Pending note preserved");
await page.waitForFunction(()=>document.querySelector("#oe-org-role").value==="Principal Architect");
assert.equal(await editor.getByRole("combobox",{name:"Name",exact:true}).inputValue(),"Samuel Rivera");
assert.equal(await editor.getByRole("textbox",{name:"Notes",exact:true}).inputValue(),"Pending note preserved");
assert.equal(await button("Edit Samuel Rivera").count(),1);
await save();
const updated=await page.evaluate(async()=>{
const chart=(await window.fixtureOrgStorage.load({principal:"example@example.invalid",customerId:"100000810210835"})).chart;
return chart.nodes.find(node=>node.contactPartyId==="2003");
});
assert.equal(updated.name,"Samuel Rivera");assert.equal(updated.jobTitle,"Principal Architect");
assert.equal(updated.notes,"Pending note preserved");assert.equal("emailAddress" in updated,false);
await button("Close organization chart").click();
assert.deepEqual(errors, []);
console.log("PASS: contact autocomplete, chart creation, reparenting, editing, notes, collapse, reload, failed-save recovery, unsaved protection, deletion, account/user isolation and revision conflict.");
console.log("Screenshots: " + output);
} finally { await browser?.close(); server.kill(); }
})().catch(error => { console.error(error); process.exitCode=1; });

View File

@@ -8,9 +8,16 @@ function createAuthConsumerSession({
fetchJson, fetchJson,
supportsFirefoxCookieStoreTabs, supportsFirefoxCookieStoreTabs,
tabsCreate, tabsCreate,
tabsUpdate,
tabsRemove, tabsRemove,
responseHeaders,
cookieChanges,
inspectPage,
setTimeout,
clearTimeout,
wait wait
}) { }) {
const sessionTabs = new Map();
async function requestConsumerTokenService(sender) { async function requestConsumerTokenService(sender) {
const debugRequests = []; const debugRequests = [];
@@ -150,7 +157,8 @@ function createAuthConsumerSession({
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs); debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
} }
await wait(retryDelayMs); if (sessionTab) await waitForConsumerTokenSession(sessionTab, debugEntry, retryDelayMs);
else await wait(retryDelayMs);
} }
} }
@@ -160,7 +168,7 @@ function createAuthConsumerSession({
const debugEntry = debugRequests[debugRequests.length - 1]; const debugEntry = debugRequests[debugRequests.length - 1];
try { try {
await tabsRemove(sessionTab.id); await closeConsumerTokenSessionTab(sessionTab.id);
if (debugEntry) { if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = receivedValidJson debugEntry.metadata.sessionBootstrapState = receivedValidJson
? "closed-after-valid-json" ? "closed-after-valid-json"
@@ -212,7 +220,7 @@ function createAuthConsumerSession({
} }
} }
function openConsumerTokenSessionTab(sender) { async function openConsumerTokenSessionTab(sender) {
const details = { const details = {
url: CUSTOMER_TOKEN_SESSION_URL, url: CUSTOMER_TOKEN_SESSION_URL,
active: false active: false
@@ -226,8 +234,184 @@ function createAuthConsumerSession({
details.cookieStoreId = sender.tab.cookieStoreId; details.cookieStoreId = sender.tab.cookieStoreId;
} }
return tabsCreate(details); const tab = await tabsCreate(details);
if (Number.isInteger(tab?.id)) {
const state = { failedStatus: 0, reloads: 0, lastReloadAt: -Infinity, readyVersion: 0, consumedVersion: 0, closed: false };
const signal = reason => {
state.readyVersion++;
state.readyReason = reason;
state.wake?.();
};
state.listener = details => {
if (details.tabId !== tab.id
|| !/^https:\/\/spa\.oracle\.com\//.test(details.url)) return;
if (details.type === "main_frame") {
if (details.statusCode === 401 || details.statusCode === 403) {
state.failedStatus = details.statusCode;
state.consumedVersion = state.readyVersion;
state.wake?.();
} else if (details.statusCode >= 200 && details.statusCode < 300) {
state.failedStatus = 0;
if (!details.url.startsWith("https://spa.oracle.com/cloudgate/")) signal("spa-document-response");
}
} else if (details.type === "xmlhttprequest" && details.statusCode === 200
&& details.url.split("?")[0] === "https://spa.oracle.com/oalcrm/web/api/identity-management/users/current"
&& details.responseHeaders?.some(header => header.name.toLowerCase() === "content-type" && /application\/json/i.test(header.value))) {
state.failedStatus = 0;
signal("spa-identity-json-response");
}
};
state.cookieListener = change => {
const cookie = change.cookie;
if (change.removed || !cookie?.value || !cookie.secure || !cookie.httpOnly
|| cookie.domain?.replace(/^\./, "") !== "spa.oracle.com" || !/session/i.test(cookie.name)
|| (cookie.expirationDate != null && cookie.expirationDate <= Date.now() / 1000)
|| (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId)) return;
signal("spa-session-cookie-changed");
};
try {
responseHeaders?.addListener(state.listener, { urls: ["https://spa.oracle.com/*"], types: ["main_frame", "xmlhttprequest"] }, ["responseHeaders"]);
cookieChanges?.addListener(state.cookieListener);
sessionTabs.set(tab.id, state);
} catch (error) {
responseHeaders?.removeListener?.(state.listener);
cookieChanges?.removeListener(state.cookieListener);
await tabsRemove(tab.id).catch(() => {});
throw error;
}
}
return tab;
} }
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab }; async function recoverConsumerTokenSessionTab(tab, debugEntry, reason = "") {
const state = sessionTabs.get(tab.id);
if (!state || (!state.failedStatus && !reason) || Date.now() - state.lastReloadAt < 2000) return false;
const status = state.failedStatus;
state.failedStatus = 0;
state.consumedVersion = state.readyVersion;
state.lastReloadAt = Date.now();
state.reloads++;
if (debugEntry) {
debugEntry.metadata.sessionBootstrapReloadCount = String(state.reloads);
debugEntry.metadata.sessionBootstrapReloadReason = reason || `SPA navigation returned HTTP ${status}`;
}
try {
// Restart at the entry point; OAuth callback URLs may contain single-use state.
await tabsUpdate(tab.id, { url: CUSTOMER_TOKEN_SESSION_URL, active: false });
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = "restarted-at-spa-home";
return true;
} catch (error) {
state.failedStatus = status;
if (debugEntry) debugEntry.metadata.sessionBootstrapReloadState = `restart-failed: ${error.message || "unknown error"}`;
return false;
}
}
async function waitForSpaPage(tab, debugEntry) {
const startedAt = Date.now();
const deadline = startedAt + 120000;
let restartedDocument = null;
let restarts = 0;
if (debugEntry) {
delete debugEntry.metadata.nextRetryDelayMs;
debugEntry.metadata.sessionWaitTimeoutMs = "120000";
debugEntry.metadata.sessionWaitReason = "inspecting-spa-document";
}
while (Date.now() < deadline) {
const state = sessionTabs.get(tab.id);
if (state?.closed) break;
let page;
try { page = await inspectPage(tab); } catch { /* Cross-origin SSO pages have no SPA receiver. */ }
if (debugEntry) debugEntry.metadata.spaPageState = page?.status || "loading-or-login";
const documentId = typeof page?.documentId === "string" ? page.documentId : null;
const alreadyRead = documentId && documentId === tab.spaDocumentId;
if (state?.failedStatus || page?.status === "error" || alreadyRead) {
if (restarts < 3 && (state?.failedStatus || documentId !== restartedDocument)) {
const previousReloads = state?.reloads || 0;
const restarted = await recoverConsumerTokenSessionTab(tab, debugEntry,
state?.failedStatus ? "" : alreadyRead ? "Identity rejected in this SPA document" : "SPA document displays an authorization error");
restarts += (state?.reloads || 0) - previousReloads;
if (restarted) restartedDocument = documentId;
}
} else if (page?.status === "ready" && documentId) {
// Confirm the parsed application document without waiting for visual assets.
await wait(100);
let confirmed;
try { confirmed = await inspectPage(tab); } catch { /* A redirect started during validation. */ }
if (confirmed?.status === "ready" && confirmed.documentId === documentId && !state?.failedStatus) {
tab.spaDocumentId = documentId;
if (debugEntry) {
debugEntry.metadata.sessionWaitReason = "spa-document-verified";
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
}
return;
}
}
await wait(1000);
}
const error = new Error("SPA page did not finish authentication. No further identity request was sent.");
error.code = "SPA_PAGE_NOT_READY";
throw error;
}
async function waitForConsumerTokenSession(tab, debugEntry, retryDelay) {
const state = sessionTabs.get(tab.id);
if (!state || !responseHeaders?.addListener || !setTimeout) return wait(retryDelay);
const startedAt = Date.now();
const deadline = startedAt + 20000;
if (debugEntry) {
delete debugEntry.metadata.nextRetryDelayMs;
debugEntry.metadata.sessionWaitTimeoutMs = "20000";
}
let restarts = 0;
let reason = "timeout-fallback";
// Do not issue API probes while SSO redirects are still establishing the session.
while (!state.closed && Date.now() < deadline) {
if (state.failedStatus) {
if (restarts >= 3) {
await wait(Math.max(0, deadline - Date.now()));
break;
}
await recoverConsumerTokenSessionTab(tab, debugEntry);
restarts++;
if (state.failedStatus) await wait(2000);
continue;
}
if (state.readyVersion > state.consumedVersion) {
await wait(250);
if (state.failedStatus) continue;
state.consumedVersion = state.readyVersion;
reason = state.readyReason;
break;
}
let timer;
try {
await new Promise(resolve => {
state.wake = resolve;
timer = setTimeout(resolve, Math.max(0, deadline - Date.now()));
});
} finally {
clearTimeout?.(timer);
state.wake = null;
}
}
if (debugEntry) {
debugEntry.metadata.sessionWaitReason = reason;
debugEntry.metadata.sessionWaitDurationMs = String(Date.now() - startedAt);
}
}
async function closeConsumerTokenSessionTab(id) {
const state = sessionTabs.get(id);
sessionTabs.delete(id);
if (state) {
state.closed = true;
state.wake?.();
responseHeaders?.removeListener?.(state.listener);
cookieChanges?.removeListener(state.cookieListener);
}
await tabsRemove(id);
}
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab, recoverConsumerTokenSessionTab, waitForConsumerTokenSession, waitForSpaPage, closeConsumerTokenSessionTab };
} }

View File

@@ -19,6 +19,7 @@ function createAuthContentSession({
return token; return token;
}) })
.catch((error) => { .catch((error) => {
authSessionState.tokenRelayRequest = null;
authSessionState.accessToken = ""; authSessionState.accessToken = "";
authSessionState.principal = ""; authSessionState.principal = "";
authSessionState.authStatus = AUTH_STATUS.unauthenticated; authSessionState.authStatus = AUTH_STATUS.unauthenticated;

View File

@@ -1,17 +1,32 @@
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) { function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes, environment }) {
const { setTimeout } = environment;
let pending = null; let pending = null;
let allowed = false; let allowed = false;
let userId = ""; let userId = "";
function ensure() { function ensure() {
if (!pending) { if (!pending) {
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => { pending = (async () => {
let retryDelay = 1000;
// Keep a single pending identity request until the response is usable.
for (;;) {
try {
const response = await sendRuntimeMessage({ type: messageTypes.requestCurrentUser });
appendBackgroundRequestLog(response?.debugRequests); appendBackgroundRequestLog(response?.debugRequests);
const roles = response?.user?.roles; const user = response?.user;
userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : ""; if (response?.ok === true && typeof user?.id === "string" && user.id.trim()
allowed = response?.ok === true && Array.isArray(roles) && typeof user.name === "string" && user.name.trim()
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP"); && Array.isArray(user.roles) && user.roles.every(role => typeof role === "string" && role.trim())) {
userId = user.id;
allowed = !user.roles.includes("SALES_REP");
return allowed; return allowed;
}).catch(() => { allowed = false; return false; }); }
} catch (error) {
appendBackgroundRequestLog(error?.debugRequests);
}
await new Promise(resolve => setTimeout(resolve, retryDelay));
retryDelay = Math.min(retryDelay * 2, 10000);
}
})();
} }
return pending; return pending;
} }

View File

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

View File

@@ -1,33 +0,0 @@
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 };
}

52
src/auth/spa-page.js Normal file
View File

@@ -0,0 +1,52 @@
function createSpaPage() {
function start() {
if (location.origin !== "https://spa.oracle.com" || window.top !== window) return;
const runtime = (globalThis.browser || globalThis.chrome)?.runtime;
if (!runtime) return;
const documentId = `${performance.timeOrigin}:${location.pathname}`;
let identityRequest = null;
function inspect() {
const text = `${document.title}\n${document.body?.innerText || ""}`.trim();
let status = "loading";
if (/\b(?:401\s+(?:authorization required|unauthorized)|403\s+forbidden|access denied|authorization required|authentication failed|internal server error|service unavailable|bad gateway)\b/i.test(text)) status = "error";
else if (/^\/(?:cloudgate|login|signin|sso|oauth)(?:\/|$)/i.test(location.pathname)
|| /\b(?:sign(?:ing)? in|log in|single sign.on|oracle access manager)\b/i.test(document.title)
|| document.querySelector('input[name="SAMLResponse"], input[name="SAMLRequest"], input[type="password"], form[action*="/sso"], form[action*="/login"], meta[http-equiv="refresh" i]')) status = "login";
else if ((document.readyState === "interactive" || document.readyState === "complete") && text
&& document.querySelector('a[href], button, input, [role="main"], main, nav')) status = "ready";
return { status, documentId };
}
runtime.onMessage.addListener((message, sender, reply) => {
if (sender.id !== runtime.id) return false;
if (message?.type === "opportunitiesExtension.inspectSpaPage") {
reply(inspect());
return false;
}
if (message?.type !== "opportunitiesExtension.readSpaIdentity") return false;
const page = inspect();
if (page.status !== "ready" || page.documentId !== message.documentId) {
reply({ error: "SPA page is not ready or has navigated." });
return false;
}
// A document is probed at most once; failed identity reads require a fresh navigation.
if (!identityRequest) identityRequest = (async () => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch("https://spa.oracle.com/oalcrm/web/api/identity-management/users/current", {
method: "GET", credentials: "include", cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal
});
return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers),
contentType: response.headers.get("content-type") || "", body: await response.text() };
} catch (error) {
return { error: error.message || "Unable to read SPA identity." };
} finally { clearTimeout(timer); }
})();
identityRequest.then(reply);
return true;
});
}
return { start };
}

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

View File

@@ -34,17 +34,27 @@ function createAuthTokenRelay({
} }
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url); const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
const controller = environment.AbortController ? new environment.AbortController() : null;
let timeoutId;
const timeout = new Promise((_, reject) => {
if (!environment.setTimeout) return;
timeoutId = environment.setTimeout(() => {
reject(new Error("Token relay timed out after 30 seconds. Please try again."));
controller?.abort();
}, 30000);
});
try { try {
const response = await fetch(url, { const response = await Promise.race([fetch(url, {
method: "GET", method: "GET",
credentials: "include", credentials: "include",
...(controller ? { signal: controller.signal } : {}),
headers: { headers: {
"x-xsrf-token": xsrfTokenSource.token "x-xsrf-token": xsrfTokenSource.token
} }
}); }), timeout]);
const responseBody = await readResponseBodyForDebug(response); const responseBody = await Promise.race([readResponseBodyForDebug(response), timeout]);
if (!response.ok) { if (!response.ok) {
logRequestSuccess(requestEntry, response, responseBody); logRequestSuccess(requestEntry, response, responseBody);
@@ -63,8 +73,9 @@ function createAuthTokenRelay({
let responseData; let responseData;
try { try {
responseData = await response.json(); responseData = await Promise.race([response.json(), timeout]);
} catch (error) { } catch (error) {
if (controller?.signal.aborted) throw error;
logRequestSuccess(requestEntry, response, responseBody); logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted) { if (!refreshAttempted) {
@@ -92,10 +103,7 @@ function createAuthTokenRelay({
throw new Error("Token relay response does not contain access_token."); throw new Error("Token relay response does not contain access_token.");
} }
logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({ logRequestSuccess(requestEntry, response, responseBody);
...responseData,
access_token: maskToken(responseData.access_token)
}, null, 2)));
if (url === TOKEN_RELAY_URL) { if (url === TOKEN_RELAY_URL) {
authSessionState.principal = typeof responseData.principal === "string" ? responseData.principal.trim() : ""; authSessionState.principal = typeof responseData.principal === "string" ? responseData.principal.trim() : "";
@@ -112,6 +120,8 @@ function createAuthTokenRelay({
} catch (error) { } catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed."); logRequestFailure(requestEntry, error.message || "Request failed.");
throw error; throw error;
} finally {
if (timeoutId !== undefined) environment.clearTimeout?.(timeoutId);
} }
} }
} }
@@ -122,7 +132,7 @@ function createAuthTokenRelay({
method: "GET", method: "GET",
url, url,
headers: { headers: {
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)" "x-xsrf-token": xsrfTokenSource.token || "(missing)"
}, },
metadata: { metadata: {
xsrfCookieName: xsrfTokenSource.cookieName || "(missing)", xsrfCookieName: xsrfTokenSource.cookieName || "(missing)",

View File

@@ -1,15 +1,31 @@
function createBackgroundApplication(overrides = {}) { function createBackgroundApplication(overrides = {}) {
const environment = { const environment = {
indexedDB: globalThis.indexedDB,
fetch: globalThis.fetch, fetch: globalThis.fetch,
browser: globalThis.browser, browser: globalThis.browser,
chrome: globalThis.chrome, chrome: globalThis.chrome,
setTimeout: globalThis.setTimeout, setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
...overrides ...overrides
}; };
const messageTypes = createMessageTypes(); const messageTypes = createMessageTypes();
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) }); const orgChartStorage = createOrgChartStorage({ environment, model: createOrgChartModel() });
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser(); const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
const contracts = createBackgroundContracts(); const contracts = createBackgroundContracts();
const spaPageClient = createSpaPageClient({
sendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args),
getHttp: () => httpBackgroundJson
});
const spaCurrentUser = createSpaCurrentUser({
sessionStorage: (environment.browser || environment.chrome)?.storage?.session,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
fetchInTab: (tab, logs) => spaPageClient.requestIdentity(tab, logs),
openSessionTab: sender => authConsumerSession.openConsumerTokenSessionTab(sender),
waitForSession: (tab, entry) => authConsumerSession.waitForSpaPage(tab, entry),
closeSessionTab: id => authConsumerSession.closeConsumerTokenSessionTab(id),
sessionUrl: contracts.CUSTOMER_TOKEN_SESSION_URL
});
const requestCurrentUser = sender => spaCurrentUser.requestCurrentUser(sender);
const productsCacheState = createProductsCacheState(); const productsCacheState = createProductsCacheState();
const authConsumerSession = createAuthConsumerSession({ const authConsumerSession = createAuthConsumerSession({
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS, CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
@@ -21,7 +37,13 @@ function createBackgroundApplication(overrides = {}) {
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args), supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args), tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsUpdate: (...args) => platformBackgroundApi.tabsUpdate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args), tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
responseHeaders: (environment.browser || environment.chrome)?.webRequest?.onHeadersReceived,
cookieChanges: (environment.browser || environment.chrome)?.cookies?.onChanged,
inspectPage: tab => spaPageClient.inspect(tab),
setTimeout: environment.setTimeout,
clearTimeout: environment.clearTimeout,
wait: (...args) => platformBackgroundApi.wait(...args) wait: (...args) => platformBackgroundApi.wait(...args)
}); });
const authSpaCredentials = createAuthSpaCredentials({ const authSpaCredentials = createAuthSpaCredentials({
@@ -56,8 +78,8 @@ function createBackgroundApplication(overrides = {}) {
}); });
const spaResponseShapes = createSpaResponseShapes(); const spaResponseShapes = createSpaResponseShapes();
const httpBackgroundJson = createHttpBackgroundJson({ const httpBackgroundJson = createHttpBackgroundJson({
observeResponseHeaders: (...args) => srRequestHeaders.observeResponseHeaders(...args),
previewDebugBody: createDebugBodyPreview, previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
environment environment
}); });
const productsBackgroundJobs = createProductsBackgroundJobs({ const productsBackgroundJobs = createProductsBackgroundJobs({
@@ -114,6 +136,17 @@ function createBackgroundApplication(overrides = {}) {
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args); const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args); const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) }); const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const workloadsCache = createWorkloadsCache({
fetchAllWorkloads: (...args) => spaWorkloads.fetchAllWorkloads(...args),
storageLocalGet: (...args) => platformBackgroundApi.storageLocalGet(...args),
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
});
const spaWorkloads = createSpaWorkloads({
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
getCachedWorkloads: (...args) => workloadsCache.get(...args),
normalizeCacheMaxAge: value => productsCache.normalizeCacheMaxAge(value)
});
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args); const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) }); const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args); const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
@@ -125,6 +158,14 @@ function createBackgroundApplication(overrides = {}) {
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args); const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args); const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome; 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({ const fusionSrDetails = createFusionSrDetails({
cookieChanges: runtimeApi?.cookies?.onChanged, cookieChanges: runtimeApi?.cookies?.onChanged,
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args), cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
@@ -135,7 +176,26 @@ function createBackgroundApplication(overrides = {}) {
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) fetchJson: (...args) => httpBackgroundJson.fetchJson(...args)
}); });
function start() { function start() {
startBackgroundApplication({ 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 }); srRequestHeaders.start();
startBackgroundApplication({
loadOrganizationChart: message => orgChartStorage.load(message),
saveOrganizationChart: message => orgChartStorage.save(message),
requestWorkloads: message => spaWorkloads.requestWorkloads(message),
requestWorkloadForecastUpdate: message => spaWorkloads.requestWorkloadForecastUpdate(message),
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
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 { return {
start, contracts, start, contracts,
@@ -150,6 +210,8 @@ function createBackgroundApplication(overrides = {}) {
httpBackgroundJson, httpBackgroundJson,
productsBackgroundJobs, productsBackgroundJobs,
productsCache, productsCache,
workloadsCache,
spaWorkloads,
fusionProducts, fusionProducts,
authXsrfRecovery, authXsrfRecovery,
authXsrfCookies, authXsrfCookies,

View File

@@ -16,7 +16,7 @@ function createBackgroundContracts() {
const OPPTY_PRODUCTS_MAX_PAGES = 250; const OPPTY_PRODUCTS_MAX_PAGES = 250;
const OPPTY_PRODUCTS_CONCURRENCY = 4; const OPPTY_PRODUCTS_CONCURRENCY = 4;
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService"; 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_SESSION_URL = "https://spa.oracle.com/";
const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5; const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5;
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750; const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000; const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;

View File

@@ -1,22 +1,40 @@
/** Register synchronously; replies retain the WebExtension sendResponse contract. */ /** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({ function registerBackgroundMessages({
loadOrganizationChart,
saveOrganizationChart,
messageTypes, messageTypes,
requestWeekDetails,
requestTimeEntries,
requestTimeEntriesSave,
requestTimeEntriesDelete,
requestLookupTaskType,
requestServiceRequestExpand,
requestResourceUser,
requestActiveServiceRequests,
invalidateProductsCache, invalidateProductsCache,
invalidateResourceUser = async () => {},
getXsrfTokenCookie, getXsrfTokenCookie,
refreshXsrfCookie, refreshXsrfCookie,
requestAccountPlans, requestAccountPlans,
requestInstallBase, requestInstallBase,
requestCustomerServiceRequest, requestCustomerServiceRequest,
requestWlActions, requestWlActions,
requestWorkloads,
requestWorkloadForecastUpdate,
invalidateWorkloadsCache,
requestActionTeamMembers, requestActionTeamMembers,
requestActionFindLookup, requestActionFindLookup,
requestMgmtAction, requestMgmtAction,
requestPostServiceRequest,
requestPostInternalServiceRequest,
requestInternalServiceRequest, requestInternalServiceRequest,
requestSrLookupPrimaryPillar, requestSrLookupPrimaryPillar,
requestSrLookupWithSession, requestSrLookupWithSession,
prepareSrSession, prepareSrSession,
requestSrLookupLanguage, requestSrLookupLanguage,
requestSrLookupTechPlatform, requestSrLookupTechPlatform,
requestLookupTypePlatform,
requestInternalSrServices,
requestSrLookupOpportunityBusiness, requestSrLookupOpportunityBusiness,
requestCurrentUser, requestCurrentUser,
requestConsumerTokenService, requestConsumerTokenService,
@@ -26,16 +44,96 @@ function registerBackgroundMessages({
requestOpptyProducts, requestOpptyProducts,
runtimeApi runtimeApi
}) { }) {
const bookmarkPending = new Map();
async function getBookmarkFolders() {
const folders = [];
function visit(nodes, ancestors = []) {
for (const node of nodes) {
if (node.url || node.unmodifiable) continue;
const path = node.parentId ? [...ancestors, node.title] : ancestors;
if (node.parentId) folders.push({ id: node.id, label: path.join(" / ") });
visit(node.children || [], path);
}
}
visit(await runtimeApi.bookmarks.getTree());
return { ok: true, folders };
}
function addShortcutBookmark(parentId) {
if (bookmarkPending.has(parentId)) return bookmarkPending.get(parentId);
const pending = (async () => {
const { folders } = await getBookmarkFolders();
if (!folders.some(folder => folder.id === parentId)) throw new Error("Select an available bookmarks folder.");
const url = "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension";
const existing = await runtimeApi.bookmarks.search({ url });
if (existing.some(bookmark => bookmark.parentId === parentId)) return { ok: true, existing: true };
await runtimeApi.bookmarks.create({ title: "Opportunities Extension", url, parentId });
return { ok: true, existing: false };
})().finally(() => { bookmarkPending.delete(parentId); });
bookmarkPending.set(parentId, pending);
return pending;
}
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) { if (!message || !message.type) {
return false; return false;
} }
if (message.type === messageTypes.addShortcutBookmark) {
addShortcutBookmark(message.parentId).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.getBookmarkFolders) {
getBookmarkFolders().then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if (message.type === messageTypes.getShortcutBookmarkStatus) {
runtimeApi.bookmarks.search({ url: "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome#opportunities-extension" })
.then(items => sendResponse({ ok: true, bookmarked: items.length > 0 }))
.catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
if ([messageTypes.loadOrganizationChart, messageTypes.saveOrganizationChart].includes(message.type)) {
const operation = message.type === messageTypes.loadOrganizationChart ? loadOrganizationChart : saveOrganizationChart;
operation(message).then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
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) { if (message.type === messageTypes.prepareSrSession) {
prepareSrSession(sender).then(sendResponse).catch(error => sendResponse({ ok: false, prepareSrSession(sender).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] })); error: error.message, debugRequests: error.debugRequests || [] }));
return true; return true;
} }
const srLookups = { const srLookups = {
[messageTypes.requestLookupTypePlatform]: requestLookupTypePlatform,
[messageTypes.requestInternalSrServices]: requestInternalSrServices,
[messageTypes.requestSrLookupPrimaryPillar]: requestSrLookupPrimaryPillar, [messageTypes.requestSrLookupPrimaryPillar]: requestSrLookupPrimaryPillar,
[messageTypes.requestSrLookupLanguage]: requestSrLookupLanguage, [messageTypes.requestSrLookupLanguage]: requestSrLookupLanguage,
[messageTypes.requestSrLookupTechPlatform]: requestSrLookupTechPlatform, [messageTypes.requestSrLookupTechPlatform]: requestSrLookupTechPlatform,
@@ -48,7 +146,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
} }
if (message.type === messageTypes.invalidateProductsCache) { if (message.type === messageTypes.invalidateProductsCache) {
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." })); Promise.all([invalidateProductsCache(), invalidateWorkloadsCache()]).then(() => invalidateResourceUser()).then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
return true; return true;
} }
if (message.type === messageTypes.getXsrfToken) { if (message.type === messageTypes.getXsrfToken) {
@@ -121,7 +219,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
} }
if (message.type === messageTypes.requestCurrentUser) { if (message.type === messageTypes.requestCurrentUser) {
requestCurrentUser().then(sendResponse).catch(error => sendResponse({ requestCurrentUser(sender).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load current user.", ok: false, error: error.message || "Unable to load current user.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
})); }));
@@ -137,6 +235,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true; return true;
} }
if ([messageTypes.requestWorkloads, messageTypes.requestWorkloadForecastUpdate].includes(message.type)) {
const request = message.type === messageTypes.requestWorkloads ? requestWorkloads : requestWorkloadForecastUpdate;
request(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workloads.", httpStatus: error.httpStatus,
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestWlActions) { if (message.type === messageTypes.requestWlActions) {
requestWlActions(message).then(sendResponse).catch(error => sendResponse({ requestWlActions(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workload actions.", ok: false, error: error.message || "Unable to load workload actions.",

View File

@@ -3,8 +3,9 @@ function createContentApplication(overrides = {}) {
document: globalThis.document, document: globalThis.document,
window: globalThis.window, window: globalThis.window,
navigator: globalThis.navigator, navigator: globalThis.navigator,
crypto: globalThis.crypto,
get localStorage() { return globalThis.localStorage; }, get localStorage() { return globalThis.localStorage; },
fetch: globalThis.fetch, fetch: globalThis.fetch && ((...args) => globalThis.fetch.call(undefined, ...args)),
browser: globalThis.browser, browser: globalThis.browser,
chrome: globalThis.chrome, chrome: globalThis.chrome,
MutationObserver: globalThis.MutationObserver, MutationObserver: globalThis.MutationObserver,
@@ -12,13 +13,15 @@ function createContentApplication(overrides = {}) {
requestAnimationFrame: globalThis.requestAnimationFrame, requestAnimationFrame: globalThis.requestAnimationFrame,
performance: globalThis.performance, performance: globalThis.performance,
cancelAnimationFrame: globalThis.cancelAnimationFrame, cancelAnimationFrame: globalThis.cancelAnimationFrame,
setTimeout: globalThis.setTimeout, setTimeout: globalThis.setTimeout && ((...args) => globalThis.setTimeout.call(undefined, ...args)),
clearTimeout: globalThis.clearTimeout, clearTimeout: globalThis.clearTimeout && ((...args) => globalThis.clearTimeout.call(undefined, ...args)),
AbortController: globalThis.AbortController,
matchMedia: globalThis.matchMedia, matchMedia: globalThis.matchMedia,
...overrides ...overrides
}; };
const messageTypes = createMessageTypes(); const messageTypes = createMessageTypes();
const currentUser = createCurrentUser({ const currentUser = createCurrentUser({
environment,
messageTypes, messageTypes,
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args) appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args)
@@ -26,18 +29,38 @@ function createContentApplication(overrides = {}) {
const contracts = createContentContracts(); const contracts = createContentContracts();
const searchableSelect = createSearchableSelect({ environment }); const searchableSelect = createSearchableSelect({ environment });
const authSessionState = createAuthSessionState(contracts); const authSessionState = createAuthSessionState(contracts);
const authSalesCommonState = createAuthSalesCommonState();
const opportunitiesQueryState = createOpportunitiesQueryState(); const opportunitiesQueryState = createOpportunitiesQueryState();
const tableSorting = createTableSorting({ environment });
const orgChartIcons = createOrgChartIcons({ environment });
const accountContact = createFusionAccountContact({ environment,
getToken: () => authContentSession.requestTokenRelayOnce(),
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args)
});
const orgChart = createOrgChartView({ environment, tableSorting, model: createOrgChartModel(),
requestQueryAccountContact: (...args) => accountContact.requestQueryAccountContact(...args),
requestPersistContact: (...args) => accountContact.requestPersistContact(...args),
requestOpportunitiesByContact: (...args) => accountContact.requestOpportunitiesByContact(...args),
loadLayout: loadOrganizationLayout, icon: orgChartIcons.icon, searchableSelect, modalId: contracts.MODAL_ID,
getPrincipal: () => currentUser.getUserId(), notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
async request(operation, data) {
const result = await platformContentMessages.sendRuntimeMessage({ ...data,
type: operation === "load" ? messageTypes.loadOrganizationChart : messageTypes.saveOrganizationChart });
if (!result?.ok) throw new Error(result?.error || "Unable to access local organization charts.");
return result.chart;
}
});
const opportunitiesTableState = createOpportunitiesTableState(); const opportunitiesTableState = createOpportunitiesTableState();
const opportunitiesFilterState = createOpportunitiesFilterState(contracts); const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
const preferencesState = createPreferencesState(contracts); const preferencesState = createPreferencesState(contracts);
const activityCache = createActivityCache({ authSessionState, preferencesState, environment }); const activityCache = createActivityCache({ authSessionState, preferencesState, environment });
const productsContentState = createProductsContentState(); const productsContentState = createProductsContentState();
const aiSummaryState = createAiSummaryState();
const consumptionPlansState = createConsumptionPlansState(); const consumptionPlansState = createConsumptionPlansState();
const consumptionTrendState = createConsumptionTrendState(); const consumptionTrendState = createConsumptionTrendState();
const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts); const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts);
const debugState = createDebugState(); const debugState = createDebugState(environment);
const columns = createOpportunitiesColumns({ const columns = createOpportunitiesColumns({
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args), formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args),
@@ -56,8 +79,19 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const contentModal = createContentModal({ const contentModal = createContentModal({
getShortcutBookmarkStatus: () => platformContentMessages.sendRuntimeMessage({ type: messageTypes.getShortcutBookmarkStatus }),
openBookmarkPicker: () => opportunitiesLinks.chooseBookmarkFolder(
() => platformContentMessages.sendRuntimeMessage({ type: messageTypes.getBookmarkFolders }),
async parentId => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.addShortcutBookmark, parentId });
if (result?.ok) contentModal.hideBookmarkHint();
return result;
}
),
createManageTimeButton: () => manageTime.createButton(),
prepareSrSession: () => srSessionPreparation.ensure(), prepareSrSession: () => srSessionPreparation.ensure(),
closeActionForm: (...args) => crmSrForm.close(...args) || workloadActionForm.close(...args), closeActionForm: (...args) => rampProjectionView.close(...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), createActivityFilterControls: (...args) => activityFilters.createControls(...args),
closeCrmSrPopover: () => crmSrPopover.close(), closeCrmSrPopover: () => crmSrPopover.close(),
ensureCurrentUser: () => currentUser.ensure(), ensureCurrentUser: () => currentUser.ensure(),
@@ -90,6 +124,7 @@ function createContentApplication(overrides = {}) {
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args), closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
closeInstallBaseModal: (...args) => installBaseView.closeInstallBaseModal(...args), closeInstallBaseModal: (...args) => installBaseView.closeInstallBaseModal(...args),
closeServiceRequestsModal: (...args) => serviceRequestsView.closeServiceRequestsModal(...args), closeServiceRequestsModal: (...args) => serviceRequestsView.closeServiceRequestsModal(...args),
closeOrganizationChart: () => orgChart.close(),
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args), closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args), closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args), closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
@@ -134,7 +169,61 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const svgPaths = createSvgPaths({ environment }); const svgPaths = createSvgPaths({ environment });
const manageTime = createManageTime({ MODAL_ID: contracts.MODAL_ID, environment, tableSorting, 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 dialogDrag = createDialogDrag({ environment });
const contentStyles = createContentStyles({ const contentStyles = createContentStyles({
enableDialogDrag: () => dialogDrag.install(),
STYLE_ID: contracts.STYLE_ID, STYLE_ID: contracts.STYLE_ID,
redwoodStyles, redwoodStyles,
environment environment
@@ -148,11 +237,6 @@ function createContentApplication(overrides = {}) {
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
updateAuthBadge: (...args) => contentModal.updateAuthBadge(...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({ const authTokenRelay = createAuthTokenRelay({
authSessionState, authSessionState,
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL, TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
@@ -172,12 +256,15 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const platformContentMessages = createPlatformContentMessages({ const platformContentMessages = createPlatformContentMessages({
requestCache: createFormRequestCache({ getHours: () => preferencesState.opptyProductsCacheHours, getUserId: () => currentUser.getUserId() }),
messageTypes, messageTypes,
environment environment
}); });
const opportunitiesController = createOpportunitiesController({ const opportunitiesController = createOpportunitiesController({
loadServiceRequests: (token, version) => opportunityCrmServiceRequests.load(token, version),
ensureCurrentUser: () => currentUser.ensure(), ensureCurrentUser: () => currentUser.ensure(),
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args), resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
resetWorkloads: () => { rampProjectionView.close(false); productWorkloads.reset(); },
STAGE_OPTIONS: contracts.STAGE_OPTIONS, STAGE_OPTIONS: contracts.STAGE_OPTIONS,
STATUS_OPTIONS: contracts.STATUS_OPTIONS, STATUS_OPTIONS: contracts.STATUS_OPTIONS,
authSessionState, authSessionState,
@@ -198,6 +285,7 @@ function createContentApplication(overrides = {}) {
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args) setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
}); });
const fusionOpportunities = createFusionOpportunities({ const fusionOpportunities = createFusionOpportunities({
resetDebugRequests: () => debugState.reset(),
OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT, OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT,
OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL, OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL,
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
@@ -220,14 +308,16 @@ function createContentApplication(overrides = {}) {
}); });
const internalSrSession = createInternalSrSession({ const internalSrSession = createInternalSrSession({
environment, environment,
query: async srId => { query: async (srId, srNumber) => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId }); const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId, srNumber });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []); debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
return result; return result;
} }
}); });
const crmSrPopover = createCrmSrPopover({ const crmSrPopover = createCrmSrPopover({
requestDetails: srId => internalSrSession.request(srId), openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestDetails: (srId, srNumber) => internalSrSession.request(srId, srNumber),
MODAL_ID: contracts.MODAL_ID, MODAL_ID: contracts.MODAL_ID,
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args), createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
@@ -239,7 +329,28 @@ function createContentApplication(overrides = {}) {
if (!result?.ok) throw new Error(result?.error || "Unable to load Service Request options."); if (!result?.ok) throw new Error(result?.error || "Unable to load Service Request options.");
return result.items; 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({ 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)), getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value), formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestLanguage: () => requestSrFormLookup(messageTypes.requestSrLookupLanguage), requestLanguage: () => requestSrFormLookup(messageTypes.requestSrLookupLanguage),
@@ -253,8 +364,12 @@ function createContentApplication(overrides = {}) {
requestPillars: () => requestSrFormLookup(messageTypes.requestSrLookupPrimaryPillar) requestPillars: () => requestSrFormLookup(messageTypes.requestSrLookupPrimaryPillar)
}); });
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({ const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
onStateChange: () => {
if (environment.document?.getElementById?.(contracts.OPPORTUNITIES_TABLE_ID)) opportunitiesTable.renderOpportunitiesTable();
},
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
createSrButton: number => crmSrForm.createButton(number), createSrButton: number => crmSrForm.createButton(number),
closeSrForm: () => crmSrForm.close(false), closeSrForm: () => { internalSrForm.close(false); crmSrForm.close(false); },
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args), setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
activityCache, activityCache,
reportProgress: (...args) => activityProgress.update("sr", ...args), reportProgress: (...args) => activityProgress.update("sr", ...args),
@@ -264,7 +379,65 @@ function createContentApplication(overrides = {}) {
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args), requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
}); });
const opportunitiesPrint = createOpportunitiesPrint({ environment, getSnapshot: () => {
const state = opportunitiesTableState.opportunitiesTableState;
const items = opportunitiesFilters.getSortedOpportunities();
const filter = opportunitiesFilterState;
const selectedLabels = (options, selected, allLabel) => {
const matching = options.filter(option => selected.has(option.key));
if (options.length && matching.length === options.length) return allLabel;
return matching.map(option => option.label).join(", ") || "None";
};
const root = environment.document.getElementById(contracts.MODAL_ID);
const filters = [
["Reference", root?.querySelector(".opportunities-extension-form select")?.value || "-"],
["Customer", selectedLabels(opportunitiesFormatters.getCustomerOptions(), filter.selectedCustomerKeys, "All customers")],
["Owner", selectedLabels(opportunitiesFormatters.getOwnerOptions(), filter.selectedOwnerKeys, "All owners")],
["Search", filter.tableSearch || "All"],
["Status", [...filter.selectedStatuses].join(", ")],
["Stage", [...filter.selectedStages].join(", ")],
["Opportunity type", [...filter.selectedProductTypes].join(", ")]
];
for (const group of root?.querySelectorAll(".opportunities-extension-activity-filter-controls") || []) {
filters.push([group.getAttribute("aria-label").replace(/^Filter by /, ""),
[...group.querySelectorAll('[aria-pressed="true"]')].map(button => button.getAttribute("aria-label")).join("; ")]);
}
const totals = [{ label: "Total Opportunities", value: `${items.length}/${state.items.length}`, detail: "Filtered / total" }];
for (const stage of contracts.STAGE_OPTIONS) {
const stageItems = items.filter(item => item.ForecastGroup_c === stage);
const amount = stageItems.reduce((total, item) => {
const value = Number(item.PrimaryRevenue?.RevnAmount);
return total + (Number.isFinite(value) ? value : 0);
}, 0);
totals.push({ label: stage, value: opportunitiesDashboard.createStageAmountFormatter(stageItems)(amount), detail: `${stageItems.length} opportunities` });
}
return { status: state.status, filters, totals, columns,
rows: items.map(item => columns.map(column => opportunitiesFormatters.displayOpportunityValue(column, item))) };
} });
const opportunityCalendar = createOpportunityCalendar({
createPrintButton: () => opportunitiesPrint.createButton(),
productTypeSelection: opportunitiesFilterState.selectedProductTypes,
onProductTypeChange: () => opportunitiesTable.renderOpportunitiesTable(),
createViewIcon: view => contentIcons.createViewIcon(view),
environment, loadLibrary: () => loadOpportunityCalendarLibrary(),
getState: () => opportunitiesTableState.opportunitiesTableState,
getItems: () => opportunitiesFilters.getSortedOpportunities(),
getVersion: () => opportunitiesQueryState.periodRequestVersion,
getRange: () => opportunitiesQueryState.dateRange,
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({ const opportunitiesTable = createOpportunitiesTable({
tableSorting,
renderCalendar: (...args) => opportunityCalendar.render(...args),
renderActivityFilters: () => activityFilters.render(), renderActivityFilters: () => activityFilters.render(),
closeCrmSrPopover: (...args) => crmSrPopover.close(...args), closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args), createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
@@ -275,10 +448,12 @@ function createContentApplication(overrides = {}) {
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args), createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
createInstallBaseButton: (...args) => installBaseAction.createInstallBaseButton(...args), createInstallBaseButton: (...args) => installBaseAction.createInstallBaseButton(...args),
createServiceRequestsButton: (...args) => serviceRequestsAction.createServiceRequestsButton(...args), createServiceRequestsButton: (...args) => serviceRequestsAction.createServiceRequestsButton(...args),
createOrganizationChartButton: (...args) => orgChart.createButton(...args),
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args), createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args), createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args), createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args), createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args),
createProjectionIndicator: (...args) => rampProjectionView.createOpportunityIndicator(...args),
createCrmServiceRequestBadge: (...args) => opportunityCrmServiceRequests.createBadge(...args), createCrmServiceRequestBadge: (...args) => opportunityCrmServiceRequests.createBadge(...args),
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args), createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args), createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
@@ -303,6 +478,13 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const opportunitiesFilters = createOpportunitiesFilters({ const opportunitiesFilters = createOpportunitiesFilters({
matchesProductType: item => {
const selected = opportunitiesFilterState.selectedProductTypes;
if (selected.size === 2) return true;
const result = productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
if (!result || result.error) return false;
return selected.has(productsDetails.isBooking(result.items) ? "Booking" : "Workload");
},
matchesActivities: item => activityFilters.matches(item), matchesActivities: item => activityFilters.matches(item),
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID, CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID, CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
@@ -336,6 +518,7 @@ function createContentApplication(overrides = {}) {
}); });
const opportunitiesLinks = createOpportunitiesLinks({ const opportunitiesLinks = createOpportunitiesLinks({
MODAL_ID: contracts.MODAL_ID, MODAL_ID: contracts.MODAL_ID,
searchableSelect,
environment environment
}); });
const preferencesStorage = createPreferencesStorage({ const preferencesStorage = createPreferencesStorage({
@@ -370,7 +553,7 @@ function createContentApplication(overrides = {}) {
const refreshAll = createRefreshAll({ const refreshAll = createRefreshAll({
createRefreshIcon: () => contentIcons.createRefreshIcon(), createRefreshIcon: () => contentIcons.createRefreshIcon(),
messageTypes, activityCache, opportunitiesQueryState, productsContentState, messageTypes, activityCache, opportunitiesQueryState, productsContentState,
consumptionPlansState, consumptionTrendState, aiSummaryState, environment, consumptionPlansState, consumptionTrendState, environment,
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
reload: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args), reload: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
onFailure: error => opportunitiesTable.setOpportunitiesTableState({ status: "error", message: error.message || "Unable to refresh cached data." }), onFailure: error => opportunitiesTable.setOpportunitiesTableState({ status: "error", message: error.message || "Unable to refresh cached data." }),
@@ -378,6 +561,8 @@ function createContentApplication(overrides = {}) {
resetRelatedData: () => { resetRelatedData: () => {
opportunityCrmServiceRequests.reset(); opportunityCrmServiceRequests.reset();
opportunityWorkloadActions.reset(); opportunityWorkloadActions.reset();
productWorkloads.reset();
rampProjectionView.close(false);
consumptionPlansView.closeAccountPlansModal(); consumptionPlansView.closeAccountPlansModal();
installBaseView.closeInstallBaseModal(); installBaseView.closeInstallBaseModal();
serviceRequestsView.closeServiceRequestsModal(); serviceRequestsView.closeServiceRequestsModal();
@@ -389,12 +574,18 @@ function createContentApplication(overrides = {}) {
opportunitiesTable.setOpportunitiesTableState({ status: "loading", message: "Refreshing data..." }); opportunitiesTable.setOpportunitiesTableState({ status: "loading", message: "Refreshing data..." });
} }
}); });
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment }); const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment,
retrySession: () => srSessionPreparation.retry() });
const srSessionPreparation = createSrSessionPreparation({ 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(), ensureCurrentUser: () => currentUser.ensure(),
allowsCrmActivities: () => currentUser.allowsCrmActivities(), allowsCrmActivities: () => currentUser.allowsCrmActivities(),
sendRequest: () => platformContentMessages.sendRuntimeMessage({ type: messageTypes.prepareSrSession }), sendRequest: () => platformContentMessages.sendRuntimeMessage({ type: messageTypes.prepareSrSession }),
reportProgress: state => activityProgress.update("session", state), reportProgress: state => { activityProgress.update("session", state); manageTime.setReady(state === "complete"); },
appendLog: entries => debugContentLog.appendBackgroundRequestLog(entries) appendLog: entries => debugContentLog.appendBackgroundRequestLog(entries)
}); });
const activityFilters = createActivityFilters({ const activityFilters = createActivityFilters({
@@ -416,6 +607,8 @@ function createContentApplication(overrides = {}) {
} }
}); });
const productsContentJobs = createProductsContentJobs({ const productsContentJobs = createProductsContentJobs({
loadWorkloads: (...args) => productWorkloads.load(...args),
joinWorkloads: number => productWorkloads.joinProducts(number),
beginProductActivities: (...args) => productActivities.begin(...args), beginProductActivities: (...args) => productActivities.begin(...args),
completeProductActivities: (...args) => productActivities.complete(...args), completeProductActivities: (...args) => productActivities.complete(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args), appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
@@ -429,6 +622,14 @@ function createContentApplication(overrides = {}) {
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
environment environment
}); });
const productWorkloads = createProductWorkloads({
productsContentState, opportunitiesQueryState, preferencesState, messageTypes,
requestConsumerTokenServiceOnce: () => authContentSession.requestConsumerTokenServiceOnce(),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
getPrincipal: () => currentUser.getUserId() || authSessionState.principal,
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
});
const workloadActionOptions = createWorkloadActionOptions({ const workloadActionOptions = createWorkloadActionOptions({
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args), requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
@@ -467,27 +668,43 @@ function createContentApplication(overrides = {}) {
productsContentState, messageTypes, environment productsContentState, messageTypes, environment
}); });
const productsDetails = createProductsDetails({ const productsDetails = createProductsDetails({
createProjectionIndicator: (...args) => rampProjectionView.createIndicator(...args),
openProjection: (products, opportunity, trigger) => rampProjectionView.open(products[0], opportunity, trigger, products),
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args), setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
OPPORTUNITIES_COLUMNS: columns, OPPORTUNITIES_COLUMNS: columns,
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
productsContentState, productsContentState,
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args),
environment environment
}); });
const aiSummaryContent = createAiSummaryContent({ const rampProjection = createRampProjection();
aiSummaryState, const workloadForecastUpdate = createWorkloadForecastUpdate({
invalidateSalesCommonToken: (...args) => authSalesCommon.invalidateSalesCommonToken(...args), compare: product => rampProjection.compare(product),
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args), ensureCurrentUser: () => currentUser.ensure(),
logRequestStart: (...args) => debugContentLog.logRequestStart(...args), getCurrentUserId: () => currentUser.getUserId(),
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args), requestAuthorization: () => authContentSession.requestConsumerTokenServiceOnce(),
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args), sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...args), messageTypes,
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
productsContentState,
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
});
const rampProjectionView = createRampProjectionView({
tableSorting,
saveEstimates: product => workloadForecastUpdate.save(product),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
MODAL_ID: contracts.MODAL_ID,
compare: product => rampProjection.compare(product),
expandProducts: number => productsDetails.expandProducts(number),
createAlertIcon: () => contentIcons.createAlertIcon(),
createCloseIcon: () => contentIcons.createCloseIcon(),
formatAmount: (...args) => productsDetails.formatProductAmount(...args),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
environment environment
}); });
const customerTableSearch = createCustomerTableSearch({ environment }); const customerTableSearch = createCustomerTableSearch({ environment });
const installBaseView = createInstallBaseView({ const installBaseView = createInstallBaseView({
tableSorting,
createSearch: (...args) => customerTableSearch.createSearch(...args), createSearch: (...args) => customerTableSearch.createSearch(...args),
MODAL_ID: contracts.MODAL_ID, MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
@@ -503,6 +720,7 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const serviceRequestsView = createServiceRequestsView({ const serviceRequestsView = createServiceRequestsView({
tableSorting,
createSearch: (...args) => customerTableSearch.createSearch(...args), createSearch: (...args) => customerTableSearch.createSearch(...args),
MODAL_ID: contracts.MODAL_ID, MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
@@ -548,6 +766,7 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const ratecardView = createRatecardView({ const ratecardView = createRatecardView({
tableSorting,
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID, ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
columns: ratecardModel.columns, columns: ratecardModel.columns,
formatValue: (...args) => ratecardModel.formatValue(...args), formatValue: (...args) => ratecardModel.formatValue(...args),
@@ -613,7 +832,6 @@ function createContentApplication(overrides = {}) {
}); });
const debugContentLog = createDebugContentLog({ const debugContentLog = createDebugContentLog({
previewDebugBody: createDebugBodyPreview, previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
debugState, debugState,
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args) renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args)
}); });
@@ -631,19 +849,19 @@ function createContentApplication(overrides = {}) {
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs }); startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
} }
return { return {
opportunityCalendar,
orgChart,
workloadActionForm, workloadActionForm,
activityFilters, activityFilters,
start, contracts, start, contracts,
opportunityWorkloadActions, opportunityWorkloadActions,
fusionCrmServiceRequests, opportunityCrmServiceRequests, fusionCrmServiceRequests, opportunityCrmServiceRequests,
authSessionState, authSessionState,
authSalesCommonState,
opportunitiesQueryState, opportunitiesQueryState,
opportunitiesTableState, opportunitiesTableState,
opportunitiesFilterState, opportunitiesFilterState,
preferencesState, preferencesState,
productsContentState, productsContentState,
aiSummaryState,
consumptionPlansState, consumptionPlansState,
consumptionTrendState, consumptionTrendState,
opportunitiesDashboardState, opportunitiesDashboardState,
@@ -655,7 +873,6 @@ function createContentApplication(overrides = {}) {
contentStyles, contentStyles,
authContentSession, authContentSession,
currentUser, currentUser,
authSalesCommon,
authTokenRelay, authTokenRelay,
authXsrfContent, authXsrfContent,
platformContentMessages, platformContentMessages,
@@ -670,13 +887,16 @@ function createContentApplication(overrides = {}) {
preferencesStorage, preferencesStorage,
preferencesView, preferencesView,
productsContentJobs, productsContentJobs,
productWorkloads,
productActivities, productActivities,
activityProgress, activityProgress,
srSessionPreparation, srSessionPreparation,
refreshAll, refreshAll,
activityCache, activityCache,
productsDetails, productsDetails,
aiSummaryContent, rampProjection,
workloadForecastUpdate,
rampProjectionView,
consumptionPlansAction, consumptionPlansAction,
installBaseAction, installBaseAction,
installBaseView, installBaseView,

View File

@@ -89,8 +89,7 @@ function createContentContracts() {
"Next Quarter", "Next Quarter",
"Previous Quarter", "Previous Quarter",
"Current Fiscal Year", "Current Fiscal Year",
"4 Rolling Quarters (CQ + 3)", "4 Rolling Quarters (CQ + 3)"
"RENEWALS (Current + Past Due)"
]; ];
const ALLOWED_PATHS = [ const ALLOWED_PATHS = [
"/hcmUI/faces/FuseWelcome", "/hcmUI/faces/FuseWelcome",

View File

@@ -0,0 +1,71 @@
function createDialogDrag({ environment }) {
const { document, window } = environment;
const panels = ".opportunities-extension-action-form-dialog, .opportunities-extension-preferences-dialog, .opportunities-extension-account-plans-dialog, .oe-org-confirm-panel";
const positions = new WeakMap();
let installed = false, drag = null;
function move(panel, dx, dy) {
const rect = panel.getBoundingClientRect();
const viewport = window.visualViewport;
const left = viewport?.offsetLeft || 0, top = viewport?.offsetTop || 0;
const width = viewport?.width || document.documentElement.clientWidth;
const height = viewport?.height || document.documentElement.clientHeight;
const x = Math.max(left - rect.width * 0.7, Math.min(rect.left + dx, left + width - rect.width * 0.3));
const y = Math.max(top - rect.height * 0.7, Math.min(rect.top + dy, top + height - rect.height * 0.3));
const previous = positions.get(panel) || { x: 0, y: 0 };
const next = { x: previous.x + x - rect.left, y: previous.y + y - rect.top };
positions.set(panel, next);
panel.style.position = "relative";
panel.style.left = `${next.x}px`;
panel.style.top = `${next.y}px`;
panel.setAttribute("data-dialog-moved", "true");
}
function stop() {
if (!drag) return;
const previous = drag;
drag = null;
previous.panel.removeAttribute("data-dialog-dragging");
if (previous.title.hasPointerCapture?.(previous.id)) previous.title.releasePointerCapture(previous.id);
}
function install() {
if (installed || window.__opportunitiesDialogDragInstalled || !document.addEventListener || !window.addEventListener) return;
installed = true;
window.__opportunitiesDialogDragInstalled = true;
document.addEventListener("pointerdown", event => {
if (event.button !== 0 || event.isPrimary === false) return;
const title = event.target.closest?.("header, h2, h3");
const panel = title?.closest(panels);
if (!panel || !panel.closest(".opportunities-extension-modal") || panel.closest("[inert], [hidden]") ||
event.target.closest("button, a, input, select, textarea, [role='button'], [contenteditable='true']")) return;
// Accept the whole title bar, but not headers inside the dialog's content.
const heading = panel.querySelector("h2, h3");
if (!heading || (heading !== title && !title.contains(heading))) return;
const rect = panel.getBoundingClientRect();
if (rect.width >= document.documentElement.clientWidth - 2 && rect.height >= document.documentElement.clientHeight - 2) return;
stop();
event.preventDefault();
drag = { title, panel, id: event.pointerId, x: event.clientX, y: event.clientY };
panel.setAttribute("data-dialog-dragging", "true");
title.setPointerCapture?.(event.pointerId);
});
document.addEventListener("pointermove", event => {
if (!drag || event.pointerId !== drag.id) return;
if (!drag.panel.isConnected || drag.panel.closest("[hidden], [inert]")) { stop(); return; }
event.preventDefault();
move(drag.panel, event.clientX - drag.x, event.clientY - drag.y);
drag.x = event.clientX; drag.y = event.clientY;
});
for (const name of ["pointerup", "pointercancel", "lostpointercapture"]) {
document.addEventListener(name, event => { if (drag?.id === event.pointerId) stop(); });
}
window.addEventListener("blur", stop);
function resize() {
stop();
for (const panel of document.querySelectorAll('[data-dialog-moved="true"]')) {
if (panel.getClientRects().length) move(panel, 0, 0);
}
}
window.addEventListener("resize", resize);
window.visualViewport?.addEventListener("resize", resize);
}
return { install };
}

View File

@@ -9,6 +9,20 @@ function createContentFusionPage({
environment environment
}) { }) {
const { Node, document, window } = environment; const { Node, document, window } = environment;
let shortcutOpened = false;
function openShortcutIfRequested() {
if (shortcutOpened || !isAllowedPage()) return;
if (window.top && window.top !== window) return;
let pending = false;
try {
const timestamp = Number(window.sessionStorage.getItem("opportunitiesExtension.shortcutIntent"));
pending = timestamp > 0 && Date.now() >= timestamp && Date.now() - timestamp < 10 * 60 * 1000;
window.sessionStorage.removeItem("opportunitiesExtension.shortcutIntent");
} catch { /* Storage may be disabled. */ }
if (!pending && window.location.hash !== "#opportunities-extension") return;
shortcutOpened = true;
openOpportunitiesModal();
}
function createOpportunitiesTile() { function createOpportunitiesTile() {
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
wrapper.className = "flat-grid-cell"; wrapper.className = "flat-grid-cell";
@@ -88,6 +102,9 @@ function createContentFusionPage({
} }
function insertTile() { function insertTile() {
// Opening a bookmarked shortcut must not depend on Sales being expanded
// or its tile insertion point being present in Fusion's lazy navigation.
openShortcutIfRequested();
if (document.getElementById(TILE_ID)) { if (document.getElementById(TILE_ID)) {
return true; return true;
} }

View File

@@ -85,5 +85,67 @@ function createContentIcons({
svg.append(path); svg.append(path);
return svg; return svg;
} }
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon }; 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;
}
function createAlertIcon() {
/* Lucide triangle-alert (https://lucide.dev/icons/triangle-alert), 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.
*
* Derived from Feather alert-triangle, MIT License.
* Copyright (c) 2013-present Cole Bemis
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
for (const [name, value] of Object.entries({ viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false",
fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" })) svg.setAttribute(name, value);
for (const d of ["m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3", "M12 9v4", "M12 17h.01"]) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", d);
svg.append(path);
}
return svg;
}
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon, createViewIcon, createAlertIcon };
} }

View File

@@ -3,6 +3,7 @@ function startContentApplication({ environment, page, styles, products }) {
if (!page.isAllowedPage()) return; if (!page.isAllowedPage()) return;
styles.ensureExtensionStyles(); styles.ensureExtensionStyles();
products.ensureProductsBackgroundMessageHandler(); products.ensureProductsBackgroundMessageHandler();
environment.window?.addEventListener?.("hashchange", () => page.insertTile());
if (page.insertTile()) return; if (page.insertTile()) return;
const observer = new environment.MutationObserver(() => page.insertTile()); const observer = new environment.MutationObserver(() => page.insertTile());
observer.observe(environment.document.documentElement, { childList: true, subtree: true }); observer.observe(environment.document.documentElement, { childList: true, subtree: true });

View File

@@ -1,4 +1,6 @@
function createContentModal({ function createContentModal({
openBookmarkPicker,
getShortcutBookmarkStatus,
ACCOUNT_PLANS_MODAL_ID, ACCOUNT_PLANS_MODAL_ID,
AUTH_STATUS, AUTH_STATUS,
CUSTOMER_FILTER_BUTTON_ID, CUSTOMER_FILTER_BUTTON_ID,
@@ -28,13 +30,16 @@ function createContentModal({
closeAccountPlansModal, closeAccountPlansModal,
closeInstallBaseModal, closeInstallBaseModal,
closeServiceRequestsModal, closeServiceRequestsModal,
closeOrganizationChart,
closeActionForm, closeActionForm,
closeCustomerFilterPanel, closeCustomerFilterPanel,
closeOwnerFilterPanel, closeOwnerFilterPanel,
closePreferencesModal, closePreferencesModal,
createSettingsIcon, createSettingsIcon,
createRefreshAllButton, createRefreshAllButton,
createManageTimeButton,
createActivityFilterControls, createActivityFilterControls,
mountOpportunityViews,
debugState, debugState,
ensureExtensionStyles, ensureExtensionStyles,
getCustomerOptions, getCustomerOptions,
@@ -69,6 +74,27 @@ function createContentModal({
}) { }) {
const { document, window, setTimeout, clearTimeout, matchMedia } = environment; const { document, window, setTimeout, clearTimeout, matchMedia } = environment;
let authCompletion = null; let authCompletion = null;
let bookmarkHintTimer = null;
let bookmarkHintVersion = 0;
function hideBookmarkHint() {
bookmarkHintVersion++;
if (bookmarkHintTimer !== null) clearTimeout(bookmarkHintTimer);
bookmarkHintTimer = null;
const hint = document.getElementById("opportunities-extension-bookmark-hint");
if (hint) hint.hidden = true;
}
async function refreshBookmarkHint() {
hideBookmarkHint();
const version = bookmarkHintVersion;
try {
const result = await getShortcutBookmarkStatus?.();
const modal = document.getElementById(MODAL_ID);
const hint = document.getElementById("opportunities-extension-bookmark-hint");
if (version !== bookmarkHintVersion || !modal || modal.hidden || !hint || !result?.ok || result.bookmarked) return;
hint.hidden = false;
bookmarkHintTimer = setTimeout(hideBookmarkHint, 20000);
} catch { /* A failed bookmarks check must not interrupt opportunities. */ }
}
function resetAuthCompletion() { function resetAuthCompletion() {
if (!authCompletion) return; if (!authCompletion) return;
clearTimeout(authCompletion.waitTimer); clearTimeout(authCompletion.waitTimer);
@@ -78,12 +104,15 @@ function createContentModal({
} }
function openOpportunitiesModal() { function openOpportunitiesModal() {
ensureExtensionStyles(); ensureExtensionStyles();
const oldDebug = document.getElementById(DEBUG_PANEL_ID);
if (oldDebug) { oldDebug.hidden = true; oldDebug.textContent = ""; }
debugState.debugSecretsVisible = false; debugState.debugSecretsVisible = false;
const existingModal = document.getElementById(MODAL_ID); const existingModal = document.getElementById(MODAL_ID);
if (existingModal) { if (existingModal) {
existingModal.hidden = false; existingModal.hidden = false;
refreshBookmarkHint();
prepareSrSession?.(); prepareSrSession?.();
existingModal.querySelector("select").focus(); existingModal.querySelector("select").focus();
document.documentElement.classList.add("opportunities-extension-scroll-lock"); document.documentElement.classList.add("opportunities-extension-scroll-lock");
@@ -97,7 +126,7 @@ function createContentModal({
} }
const overlay = document.createElement("div"); const overlay = document.createElement("div");
debugState.requestLog = []; overlay.setAttribute("lang", "en");
authSessionState.tokenRelayRequest = null; authSessionState.tokenRelayRequest = null;
authSessionState.consumerTokenServiceRequest = null; authSessionState.consumerTokenServiceRequest = null;
authSessionState.accessToken = ""; authSessionState.accessToken = "";
@@ -143,7 +172,13 @@ function createContentModal({
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp"; productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
productsCacheTimestamp.setAttribute("role", "status"); productsCacheTimestamp.setAttribute("role", "status");
productsCacheTimestamp.hidden = true; productsCacheTimestamp.hidden = true;
subtitleRow.append(authBadge, productsProgressBadge, productsCacheTimestamp); const bookmarkHint = document.createElement("span");
bookmarkHint.id = "opportunities-extension-bookmark-hint";
bookmarkHint.className = "opportunities-extension-products-cache-timestamp";
bookmarkHint.setAttribute("role", "status");
bookmarkHint.textContent = "Save to favorites: Ctrl+Shift+F";
bookmarkHint.hidden = true;
subtitleRow.append(productsCacheTimestamp, bookmarkHint, authBadge, productsProgressBadge);
const titleBlock = document.createElement("div"); const titleBlock = document.createElement("div");
titleBlock.className = "opportunities-extension-title-row"; titleBlock.className = "opportunities-extension-title-row";
@@ -152,7 +187,7 @@ function createContentModal({
const closeButton = document.createElement("button"); const closeButton = document.createElement("button");
closeButton.type = "button"; closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button"; closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Fechar"); closeButton.setAttribute("aria-label", "Close");
closeButton.textContent = "X"; closeButton.textContent = "X";
closeButton.addEventListener("click", closeOpportunitiesModal); closeButton.addEventListener("click", closeOpportunitiesModal);
@@ -168,14 +203,14 @@ function createContentModal({
const preferencesButton = document.createElement("button"); const preferencesButton = document.createElement("button");
preferencesButton.type = "button"; preferencesButton.type = "button";
preferencesButton.className = "opportunities-extension-icon-button"; preferencesButton.className = "opportunities-extension-icon-button";
preferencesButton.setAttribute("aria-label", "Abrir preferências"); preferencesButton.setAttribute("aria-label", "Open preferences");
preferencesButton.title = "Preferences"; preferencesButton.title = "Preferences";
preferencesButton.append(createSettingsIcon()); preferencesButton.append(createSettingsIcon());
preferencesButton.addEventListener("click", openPreferencesModal); preferencesButton.addEventListener("click", openPreferencesModal);
const headerActions = document.createElement("div"); const headerActions = document.createElement("div");
headerActions.className = "opportunities-extension-header-actions"; headerActions.className = "opportunities-extension-header-actions";
headerActions.append(themeButton, createRefreshAllButton(() => select.value), preferencesButton, closeButton); headerActions.append(themeButton, createRefreshAllButton(() => select.value), createManageTimeButton(), preferencesButton, closeButton);
header.append(titleBlock, headerActions); header.append(titleBlock, headerActions);
@@ -400,10 +435,11 @@ function createContentModal({
const opportunitiesTable = document.createElement("table"); const opportunitiesTable = document.createElement("table");
opportunitiesTable.id = OPPORTUNITIES_TABLE_ID; opportunitiesTable.id = OPPORTUNITIES_TABLE_ID;
opportunitiesTable.className = "opportunities-extension-table"; opportunitiesTable.className = "opportunities-extension-table";
opportunitiesTable.setAttribute("data-table-layout", "standard");
opportunitiesTable.setAttribute("aria-label", "Opportunities"); opportunitiesTable.setAttribute("aria-label", "Opportunities");
tableSurface.append(opportunitiesTable); tableSurface.append(opportunitiesTable);
body.append(tableSurface); mountOpportunityViews(body, tableSurface);
const debugPanel = document.createElement("aside"); const debugPanel = document.createElement("aside");
debugPanel.id = DEBUG_PANEL_ID; debugPanel.id = DEBUG_PANEL_ID;
@@ -414,6 +450,7 @@ function createContentModal({
shell.append(header, body); shell.append(header, body);
overlay.append(shell); overlay.append(shell);
document.body.append(overlay); document.body.append(overlay);
refreshBookmarkHint();
document.documentElement.classList.add("opportunities-extension-scroll-lock"); document.documentElement.classList.add("opportunities-extension-scroll-lock");
ensureEscapeKeyHandler(); ensureEscapeKeyHandler();
renderStageFilterButtons(); renderStageFilterButtons();
@@ -465,6 +502,8 @@ function createContentModal({
} }
function closeOpportunitiesModal() { function closeOpportunitiesModal() {
if (closeOrganizationChart()) return;
hideBookmarkHint();
resetAuthCompletion(); resetAuthCompletion();
closeCrmSrPopover(); closeCrmSrPopover();
const modal = document.getElementById(MODAL_ID); const modal = document.getElementById(MODAL_ID);
@@ -495,14 +534,19 @@ function createContentModal({
return; return;
} }
if (event.ctrlKey && event.key.toLowerCase() === "d") { const shortcutKey = event.key.toLowerCase();
if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey &&
(shortcutKey === "d" || shortcutKey === "f")) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
toggleDebugPanel(); if (event.repeat) return;
if (shortcutKey === "d") toggleDebugPanel();
else openBookmarkPicker();
return; return;
} }
if (event.key === "Escape") { if (event.key === "Escape") {
if (closeOrganizationChart()) { event.preventDefault(); return; }
if (closeActionForm()) { if (closeActionForm()) {
event.preventDefault(); event.preventDefault();
return; return;
@@ -572,5 +616,5 @@ function createContentModal({
}); });
} }
return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler }; return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler, hideBookmarkHint, refreshBookmarkHint };
} }

View File

@@ -0,0 +1,11 @@
// Capture the bookmark before Fusion rewrites its navigation URL.
(() => {
if (window.top !== window) return;
const capture = () => {
const url = new URL(window.location.href);
if (url.hash !== "#opportunities-extension" && url.searchParams.get("opportunitiesExtension") !== "open") return;
try { window.sessionStorage.setItem("opportunitiesExtension.shortcutIntent", String(Date.now())); } catch { /* The URL remains a fallback. */ }
};
capture();
window.addEventListener("hashchange", capture);
})();

View File

@@ -1,10 +1,12 @@
function createContentStyles({ function createContentStyles({
STYLE_ID, STYLE_ID,
enableDialogDrag,
redwoodStyles, redwoodStyles,
environment environment
}) { }) {
const { document } = environment; const { document } = environment;
function ensureExtensionStyles() { function ensureExtensionStyles() {
enableDialogDrag?.();
if (document.getElementById(STYLE_ID)) { if (document.getElementById(STYLE_ID)) {
return; return;
} }

View File

@@ -0,0 +1,108 @@
/* Modal titles follow the customer Service Requests heading. Keep section and
calendar headings independent from dialog typography. */
.opportunities-extension-modal :is(.opportunities-extension-action-form-dialog,
.opportunities-extension-preferences-dialog, .opportunities-extension-account-plans-dialog,
.oe-org-confirm-panel) :is(header, h2, h3) {
cursor: grab;
touch-action: none;
user-select: none;
}
.opportunities-extension-modal [data-dialog-dragging="true"] :is(header, h2, h3) { cursor: grabbing; }
.opportunities-extension-modal #opportunities-extension-bookmark-hint {
background: #9a4600;
color: #ffffff;
border: 1px solid #b95b00;
}
.opportunities-extension-modal[data-theme="dark"] #opportunities-extension-bookmark-hint {
background: #ffca70;
color: #3b2400;
border-color: #ffd99a;
}
.opportunities-extension-modal :is(
.opportunities-extension-header,
.opportunities-extension-account-plans-header,
.opportunities-extension-preferences-header
) {
padding: 16px;
gap: 16px;
align-items: flex-start;
}
/* The main heading, status badges and toolbar share one vertical center. */
.opportunities-extension-modal .opportunities-extension-header {
align-items: center;
}
.opportunities-extension-modal .opportunities-extension-header .opportunities-extension-title-row,
.opportunities-extension-modal .opportunities-extension-header .opportunities-extension-subtitle-row,
.opportunities-extension-modal .opportunities-extension-header .opportunities-extension-header-actions {
align-items: center;
}
.opportunities-extension-modal .opportunities-extension-header .opportunities-extension-subtitle-row {
margin-block: 0;
}
/* These headers live in panels that already supply the outer inset. */
.opportunities-extension-modal :is(.oe-org-dialog.oe-contact-dialog,
.oe-org-confirm-panel, .opportunities-extension-projection-confirm,
.opportunities-extension-debug-panel) { padding: 16px; }
.opportunities-extension-modal :is(.oe-org-editor-header,
.opportunities-extension-debug-panel-header) {
padding: 0;
gap: 16px;
align-items: flex-start;
}
.opportunities-extension-modal :is(
.opportunities-extension-account-plans-header,
.opportunities-extension-preferences-header,
.opportunities-extension-debug-panel-header
) p {
margin: 5px 0 0;
font-family: "Oracle Sans", Arial, Helvetica, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 1.35;
letter-spacing: normal;
overflow-wrap: anywhere;
}
/* Search controls share the 16px inset of their table frames. */
.opportunities-extension-modal :is(.opportunities-extension-customer-table-toolbar,
.opportunities-extension-ratecard-toolbar,
.opportunities-extension-ratecard-plan-details) { padding-inline: 16px; }
.opportunities-extension-modal .opportunities-extension-customer-table-toolbar {
padding-block: 12px;
}
.opportunities-extension-modal .opportunities-extension-customer-table-search {
width: min(420px, 100%);
height: 38px;
padding: 8px 12px;
font-size: 13px;
line-height: 20px;
}
.opportunities-extension-modal :is(
.opportunities-extension-header,
.opportunities-extension-account-plans-header,
.opportunities-extension-preferences-header,
.opportunities-extension-debug-panel-header,
.oe-org-editor-header
) :is(h1, h2, h3),
.opportunities-extension-modal .opportunities-extension-projection-confirm h2,
.opportunities-extension-modal .oe-org-confirm-panel h3 {
margin: 0;
font-family: "Oracle Sans", Arial, Helvetica, sans-serif;
font-size: 20px;
font-weight: 700;
line-height: 24px;
letter-spacing: normal;
color: #312d2a;
min-width: 0;
overflow-wrap: anywhere;
}
.opportunities-extension-modal[data-theme="dark"] :is(
.opportunities-extension-header,
.opportunities-extension-account-plans-header,
.opportunities-extension-preferences-header,
.opportunities-extension-debug-panel-header,
.oe-org-editor-header
) :is(h1, h2, h3),
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-confirm h2,
.opportunities-extension-modal[data-theme="dark"] .oe-org-confirm-panel h3 {
color: #f6f4f2;
}

View File

@@ -918,51 +918,6 @@
color: #312d2a; color: #312d2a;
} }
.opportunities-extension-ai-summary {
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: 0 0 12px;
padding: 12px 16px;
border: 1px solid #dedbd7;
border-radius: 6px;
background: #ffffff;
color: #312d2a;
font-size: 13px;
line-height: 1.5;
text-align: left;
white-space: normal;
overflow-wrap: anywhere;
}
.opportunities-extension-ai-summary strong {
display: block;
font-size: 13px;
font-weight: 600;
}
.opportunities-extension-ai-summary p {
margin: 4px 0 0;
font: inherit;
white-space: pre-wrap;
}
.opportunities-extension-ai-summary-error {
color: #b42318;
border-color: #d9928b;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary {
background: #292827;
color: #f6f4f2;
border-color: #5f5a55;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary-error {
color: #ffb4ab;
border-color: #a45c55;
}
.opportunities-extension-products-detail-heading strong { .opportunities-extension-products-detail-heading strong {
font-size: 13px; font-size: 13px;
} }

View File

@@ -0,0 +1,138 @@
/* Shared data-table presentation. Calendar grids are intentionally not data tables. */
.opportunities-extension-modal :is(
.opportunities-extension-table-surface,
.opportunities-extension-install-base-content,
.opportunities-extension-service-requests-content,
.opportunities-extension-ratecard-viewport,
.opportunities-extension-projection-viewport,
.oe-time-grid,
.oe-contact-opportunities-table
) {
box-sizing: border-box;
min-width: 0;
border: 1px solid var(--oe-table-heading-border);
border-radius: 4px;
overflow: auto;
}
/* Unpadded dialogs need an inset on the scrolling frame itself. */
.opportunities-extension-modal :is(
.opportunities-extension-install-base-content,
.opportunities-extension-service-requests-content,
.opportunities-extension-ratecard-viewport
) { margin: 16px; }
/* Other frames already sit inside a padded body: keep a single gutter. */
.opportunities-extension-modal :is(
.opportunities-extension-body,
.opportunities-extension-projection-body,
.opportunities-extension-products-detail,
.oe-time-body,
.oe-org-dialog.oe-contact-opportunities
) { padding: 16px; }
.opportunities-extension-modal table.opportunities-extension-products-table {
border: 1px solid var(--oe-table-heading-border);
}
.opportunities-extension-modal {
--oe-table-heading: #faf9f8;
--oe-table-text: #312d2a;
--oe-table-border: #ebe8e5;
--oe-table-heading-border: #b8b2ad;
--oe-table-hover: #f5f4f2;
}
.opportunities-extension-modal[data-theme="dark"] {
--oe-table-heading: #333130;
--oe-table-text: #f6f4f2;
--oe-table-border: #3f3c39;
--oe-table-heading-border: #4e4a46;
--oe-table-hover: #35322f;
}
.opportunities-extension-modal table[data-table-layout="standard"]:not(.fc-scrollgrid) {
width: 100%; border-collapse: collapse; border-spacing: 0; table-layout: auto;
color: var(--oe-table-text); font-size: 12px; line-height: 1.35;
}
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th {
padding: 10px 12px; height: 44px; vertical-align: middle; text-align: left;
white-space: nowrap; overflow-wrap: normal; word-break: normal;
font-size: 12px; font-weight: 700; color: var(--oe-table-text);
background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-heading-border);
}
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > td {
padding: 10px 12px; vertical-align: middle; text-align: left;
border-bottom: 1px solid var(--oe-table-border);
}
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr:hover { background: var(--oe-table-hover); }
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-sort-button,
.opportunities-extension-modal table[data-table-layout="standard"] .opportunities-extension-ratecard-sort {
display: flex; align-items: center; justify-content: flex-start; gap: 8px;
width: 100%; min-width: max-content; min-height: 28px; padding: 4px 18px 4px 0;
border: 0; border-radius: 0; background: transparent; color: inherit;
text-align: left; white-space: nowrap; overflow-wrap: normal; font-weight: 700;
}
.opportunities-extension-modal table[data-table-layout="standard"] .opportunities-extension-sort-label { white-space: nowrap; overflow-wrap: normal; word-break: normal; }
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-sort-button::after { position: absolute; right: 0; left: auto; font-size: 12px; }
.opportunities-extension-modal table[data-table-layout="standard"] .opportunities-extension-ratecard-sort-icon { margin-left: auto; }
/* Currency columns keep their heading and value on the same right edge. */
.opportunities-extension-modal table[data-table-layout="standard"] :is(th, td)[data-align="money"] { text-align: right; font-variant-numeric: tabular-nums; }
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th[data-align="money"] .opportunities-extension-sort-button,
.opportunities-extension-modal table[data-table-layout="standard"] th[data-align="money"] .opportunities-extension-ratecard-sort { justify-content: flex-end; text-align: right; padding: 4px 0 4px 18px; }
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th[data-align="money"] .opportunities-extension-sort-button::after { left: 0; right: auto; }
.opportunities-extension-modal table[data-table-layout="standard"] th[data-align="money"] .opportunities-extension-ratecard-sort-icon { order: -1; margin-left: 0; margin-right: auto; }
.opportunities-extension-modal table[data-table-layout="standard"] td[data-align="money"] > span[class*="skeleton"] { margin-left: auto; }
/* Preserve room for Overview badges and the compact daily entry controls. */
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td:is([data-column="name"], [data-column="customer"]) { padding-bottom: 10px; vertical-align: top; }
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td[data-column] > .opportunities-extension-cell-content {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td[data-column] > .opportunities-extension-cell-content > .opportunities-extension-cell-actions {
position: static;
height: 24px;
flex-shrink: 0;
}
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-left: 5px; padding-right: 5px; }
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] { table-layout: fixed; }
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > th[scope="row"] { padding: 10px 12px; text-align: left; background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-border); }
/* Rate Card must fit its drawer; headings may wrap at spaces, never mid-word. */
/* Overview reserves nearly half its width for the two primary descriptions. */
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td)[data-column],
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td)[data-column] :is(a, span, button) {
font-family: "Oracle Sans", Arial, Helvetica, sans-serif;
font-size: 12px;
}
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] { table-layout: fixed; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="name"] { width: 24%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="customer"] { width: 24%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="optyNumber"] { width: 7%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="owner"] { width: 7%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="revenue"] { width: 8%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="closeDate"] { width: 7%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="status"] { width: 5%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="winProbability"] { width: 4%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="stage"] { width: 6%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="lastUpdateDate"] { width: 8%; }
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-inline: 6px; }
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-sort-button { min-width: 0; gap: 4px; }
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] .opportunities-extension-sort-label { overflow: hidden; text-overflow: ellipsis; }
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] {
min-width: 0;
table-layout: fixed;
}
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) {
padding-inline: 6px;
}
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > thead > tr > th,
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-ratecard-sort {
white-space: normal;
overflow-wrap: normal;
word-break: normal;
hyphens: none;
min-width: 0;
}
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-ratecard-sort {
padding: 4px 0;
gap: 4px;
}
.opportunities-extension-modal .opportunities-extension-ratecard-sort > span:first-child { min-width: 0; }
.opportunities-extension-modal .opportunities-extension-ratecard-sort-icon { flex: 0 0 12px; }

View File

@@ -1,103 +0,0 @@
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

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

View File

@@ -1,6 +1,5 @@
function createDebugContentLog({ function createDebugContentLog({
previewDebugBody, previewDebugBody,
DEBUG_BODY_LIMIT,
debugState, debugState,
renderDebugPanel renderDebugPanel
}) { }) {
@@ -10,14 +9,14 @@ function createDebugContentLog({
name: details.name, name: details.name,
method: details.method, method: details.method,
url: details.url, url: details.url,
headers: details.headers, headers: details.headers || {},
status: "pending", status: "pending",
startedAt: new Date(), startedAt: new Date(),
completedAt: null, completedAt: null,
durationMs: null, durationMs: null,
httpStatus: null, httpStatus: null,
statusText: "", statusText: "",
requestHeaders: details.headers, requestHeaders: details.headers || {},
requestBody: details.body || "", requestBody: details.body || "",
metadata: details.metadata || {}, metadata: details.metadata || {},
responseHeaders: {}, responseHeaders: {},
@@ -27,11 +26,13 @@ function createDebugContentLog({
}; };
debugState.requestLog.push(entry); debugState.requestLog.push(entry);
debugState.persist(entry);
renderDebugPanel(); renderDebugPanel();
return entry; return entry;
} }
function logRequestSuccess(entry, response, responseBody) { function logRequestSuccess(entry, response, responseBody) {
if (!debugState.requestLog.includes(entry)) return;
entry.status = response.ok ? "success" : "failed"; entry.status = response.ok ? "success" : "failed";
entry.completedAt = new Date(); entry.completedAt = new Date();
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime(); entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
@@ -40,14 +41,17 @@ function createDebugContentLog({
entry.responseHeaders = headersToObject(response.headers); entry.responseHeaders = headersToObject(response.headers);
entry.responseBody = responseBody.value; entry.responseBody = responseBody.value;
entry.responseBodyTruncated = responseBody.truncated; entry.responseBodyTruncated = responseBody.truncated;
debugState.persist(entry);
renderDebugPanel(); renderDebugPanel();
} }
function logRequestFailure(entry, errorMessage) { function logRequestFailure(entry, errorMessage) {
if (!debugState.requestLog.includes(entry)) return;
entry.status = "failed"; entry.status = "failed";
entry.completedAt = new Date(); entry.completedAt = new Date();
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime(); entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
entry.error = errorMessage; entry.error = errorMessage;
debugState.persist(entry);
renderDebugPanel(); renderDebugPanel();
} }
@@ -82,6 +86,7 @@ function createDebugContentLog({
}; };
if (existing) Object.assign(existing, normalized); if (existing) Object.assign(existing, normalized);
else debugState.requestLog.push(normalized); else debugState.requestLog.push(normalized);
debugState.persist(existing || normalized);
}); });
renderDebugPanel(); renderDebugPanel();
} }
@@ -99,7 +104,7 @@ function createDebugContentLog({
} }
function createDebugBody(text) { function createDebugBody(text) {
return previewDebugBody(text, DEBUG_BODY_LIMIT); return previewDebugBody(text);
} }
function headersToObject(headers) { function headersToObject(headers) {
@@ -113,7 +118,7 @@ function createDebugContentLog({
} }
function formatHeaderBlock(headers) { function formatHeaderBlock(headers) {
const entries = Object.entries(headers); const entries = Object.entries(headers || {});
if (entries.length === 0) { if (entries.length === 0) {
return "(none)"; return "(none)";
@@ -126,7 +131,7 @@ function createDebugContentLog({
.join("\n"); .join("\n");
} }
function formatDebugBody(body) { function formatDebugBody(body, requestName = "") {
const text = body || "(none)"; const text = body || "(none)";
try { try {
const normalized = String(text).replace(/^\uFEFF/, "").trimStart() const normalized = String(text).replace(/^\uFEFF/, "").trimStart()
@@ -137,8 +142,14 @@ function createDebugContentLog({
for (let depth = 0; depth < 2 && typeof value === "string" && /^[\s]*[\[{]/.test(value); depth += 1) { for (let depth = 0; depth < 2 && typeof value === "string" && /^[\s]*[\[{]/.test(value); depth += 1) {
try { value = JSON.parse(value); } catch { break; } 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); return JSON.stringify(debugState.debugSecretsVisible ? value : maskSensitiveDebugValue(value), null, 2);
} catch (error) { } catch (error) {
if (!debugState.debugSecretsVisible && /^(requestSrTokenCatalog|requestCurrentUser|requestTokenRelay.*)$/.test(requestName)) return formatHiddenDebugValue(text);
return text; return text;
} }
} }
@@ -162,7 +173,7 @@ function createDebugContentLog({
} }
function isSensitiveDebugKey(key) { function isSensitiveDebugKey(key) {
return /authorization|cookie|password|secret|(^|[_-])token($|[_-])|token$/i.test(key); return /authorization|cookie|password|secret|(^|[_-])token($|[_-])|token$|x-appbuilder-client-id/i.test(key);
} }
function formatHiddenDebugValue(value) { function formatHiddenDebugValue(value) {
@@ -174,14 +185,14 @@ function createDebugContentLog({
} }
function formatMetadataBlock(metadata) { function formatMetadataBlock(metadata) {
const entries = Object.entries(metadata); const entries = Object.entries(metadata || {});
if (entries.length === 0) { if (entries.length === 0) {
return "(none)"; return "(none)";
} }
return entries return entries
.map(([key, value]) => `${key}: ${value || "(none)"}`) .map(([key, value]) => `${key}: ${value ?? "(none)"}`)
.join("\n"); .join("\n");
} }

View File

@@ -11,22 +11,24 @@ function createDebugContentView({
const { document } = environment; const { document } = environment;
const categories = [ const categories = [
["all", "All"], ["authentication", "Authentication"], ["all", "All"], ["authentication", "Authentication"],
["opportunities", "Opportunities"], ["products", "Products"], ["opportunities", "Opportunities"], ["contacts", "Contacts"], ["products", "Products"],
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"], ["consumption", "Consumption"], ["installBase", "Installed Base"],
["serviceRequests", "Service Requests"], ["actions", "Actions"], ["other", "Other"] ["serviceRequests", "Service Requests"], ["actions", "Actions"], ["other", "Other"]
]; ];
let selectedCategory = "all"; let selectedCategory = "all";
const expandedIds = new Set(); const expandedIds = new Set();
let returnFocus = null; let returnFocus = null;
let generation = -1;
function getRequestCategory(entry) { function getRequestCategory(entry) {
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication"; if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
if (entry.name === "requestOpportunities") return "opportunities"; if (["requestOpportunities", "requestOpportunitiesSavedSearch", "requestOpportunitiesByContact", "resolveContactForOpportunities"].includes(entry.name)) return "opportunities";
if (["requestQueryAccountContact", "requestPersistContact"].includes(entry.name)) return "contacts";
if (entry.name === "requestOpptyProducts") return "products"; if (entry.name === "requestOpptyProducts") return "products";
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions"; if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
if (entry.name === "requestGenerateAiSummary") return "ai";
if (entry.name === "requestInstallBase") return "installBase"; if (entry.name === "requestInstallBase") return "installBase";
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness"].includes(entry.name)) return "serviceRequests"; if (["requestSRSearchResources", "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"; if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
return "other"; return "other";
} }
@@ -52,7 +54,11 @@ function createDebugContentView({
closeDebugPanel(); closeDebugPanel();
return; return;
} }
debugState.reload();
returnFocus = document.activeElement; returnFocus = document.activeElement;
// Escape nested stacking contexts and inert containers created by child modals.
modal.append(panel);
panel.inert = false;
panel.hidden = false; panel.hidden = false;
renderDebugPanel(); renderDebugPanel();
panel.querySelectorAll(".opportunities-extension-debug-close-button")[0]?.focus({ preventScroll: true }); panel.querySelectorAll(".opportunities-extension-debug-close-button")[0]?.focus({ preventScroll: true });
@@ -62,6 +68,8 @@ function createDebugContentView({
const panel = document.getElementById(DEBUG_PANEL_ID); const panel = document.getElementById(DEBUG_PANEL_ID);
if (!panel) return; if (!panel) return;
panel.hidden = true; panel.hidden = true;
panel.textContent = "";
panel.onkeydown = null;
if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true }); if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
returnFocus = null; returnFocus = null;
} }
@@ -73,6 +81,7 @@ function createDebugContentView({
return; return;
} }
if (generation !== debugState.generation) { expandedIds.clear(); selectedCategory = "all"; generation = debugState.generation; }
for (const item of panel.querySelectorAll("details[data-request-id]")) { for (const item of panel.querySelectorAll("details[data-request-id]")) {
const id = item.getAttribute("data-request-id"); const id = item.getAttribute("data-request-id");
if (item.open) expandedIds.add(id); if (item.open) expandedIds.add(id);
@@ -97,7 +106,8 @@ function createDebugContentView({
title.textContent = "Debug Requests"; title.textContent = "Debug Requests";
const summary = document.createElement("p"); const summary = document.createElement("p");
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`; summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} in the current query history.`;
if (debugState.storageError) summary.textContent += ` ${debugState.storageError}`;
const running = debugState.requestLog.filter(entry => entry.status === "pending").length; const running = debugState.requestLog.filter(entry => entry.status === "pending").length;
if (running) summary.textContent += ` ${running} running.`; if (running) summary.textContent += ` ${running} running.`;
heading.append(title, summary); heading.append(title, summary);
@@ -217,7 +227,7 @@ function createDebugContentView({
const startedTime = document.createElement("time"); const startedTime = document.createElement("time");
startedTime.className = "opportunities-extension-debug-time"; startedTime.className = "opportunities-extension-debug-time";
startedTime.textContent = formatDebugTime(entry.startedAt); startedTime.textContent = formatDebugTime(entry.startedAt);
startedTime.title = `Started: ${entry.startedAt.toLocaleString()}`; startedTime.title = `Started: ${entry.startedAt.toLocaleString("en-GB")}`;
const method = document.createElement("span"); const method = document.createElement("span");
method.className = "opportunities-extension-debug-method"; method.className = "opportunities-extension-debug-method";
method.textContent = entry.method || "GET"; method.textContent = entry.method || "GET";
@@ -227,12 +237,15 @@ function createDebugContentView({
const hasDuration = typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && entry.durationMs >= 0; const hasDuration = typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && entry.durationMs >= 0;
duration.textContent = hasDuration duration.textContent = hasDuration
? entry.durationMs < 1000 ? `${Math.round(entry.durationMs)} ms` : `${(entry.durationMs / 1000).toFixed(2)} s` ? entry.durationMs < 1000 ? `${Math.round(entry.durationMs)} ms` : `${(entry.durationMs / 1000).toFixed(2)} s`
: entry.status === "pending" ? "In progress" : "N/D"; : entry.status === "pending" ? "In progress" : "N/A";
duration.title = hasDuration ? `Request duration: ${entry.durationMs} ms` : "Request duration not yet available"; duration.title = hasDuration ? `Request duration: ${entry.durationMs} ms` : "Request duration not yet available";
summaryMeta.append(startedTime, method, httpStatus, status, duration); summaryMeta.append(startedTime, method, httpStatus, status, duration);
heading.append(name, summaryMeta); heading.append(name, summaryMeta);
item.append(heading);
const renderDetails = () => {
if (!item.open || item.children.length > 1) return;
const lines = [ const lines = [
`${entry.method} ${entry.url}`, `${entry.method} ${entry.url}`,
`Started: ${formatDebugTime(entry.startedAt)}`, `Started: ${formatDebugTime(entry.startedAt)}`,
@@ -249,9 +262,19 @@ function createDebugContentView({
appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders)); appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders));
appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody)); appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody));
appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders)); appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders));
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody)); appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody, entry.name));
item.append(heading, details); item.append(details);
};
item.addEventListener("toggle", () => {
if (item.open) { expandedIds.add(String(entry.id)); renderDetails(); }
else {
expandedIds.delete(String(entry.id));
item.querySelector(".opportunities-extension-debug-details")?.remove();
}
});
renderDetails();
list.append(item); list.append(item);
}); });
@@ -348,7 +371,7 @@ function createDebugContentView({
} }
function formatDebugTime(date) { function formatDebugTime(date) {
return date.toLocaleTimeString(undefined, { return date.toLocaleTimeString("en-US", {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
second: "2-digit" second: "2-digit"

View File

@@ -1,6 +1,87 @@
function createDebugState() { function createDebugState(environment = {}) {
return { let storage;
requestLog: [], let tabId;
debugSecretsVisible: false try {
}; storage = environment.localStorage;
const session = environment.window?.sessionStorage;
tabId = session?.getItem("opportunities-extension-debug-tab");
if (!tabId) {
tabId = environment.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
session?.setItem("opportunities-extension-debug-tab", tabId);
}
} catch { storage = null; }
const prefix = `opportunities-extension-debug:${tabId || "memory"}:`;
let timer = null;
const pending = new Map();
const state = {
requestLog: [],
debugSecretsVisible: false,
storageError: "",
generation: 0,
persist,
flush,
reload,
reset
};
function persist(entry) {
if (!state.requestLog.includes(entry)) return;
pending.set(entry.id, entry);
try {
if (timer === null && environment.setTimeout) timer = environment.setTimeout(flush, 100);
else if (!environment.setTimeout) flush();
} catch {
// Debug persistence must never interrupt the request being recorded.
timer = null;
flush();
}
}
function flush() {
if (timer !== null) environment.clearTimeout?.(timer);
timer = null;
if (!storage) { pending.clear(); return; }
try {
// Write only changed records, never stringify all response bodies per request.
for (const [id, entry] of pending) storage.setItem(`${prefix}${id}`, JSON.stringify(entry));
storage.setItem(`${prefix}index`, JSON.stringify({ version: 1, count: state.requestLog.length }));
state.storageError = "";
} catch {
state.storageError = "Local storage is unavailable or full. Requests remain available in memory for this session.";
}
pending.clear();
}
function reload() {
flush();
if (!storage || state.storageError) return;
try {
const index = JSON.parse(storage.getItem(`${prefix}index`) || "null");
if (!index || index.version !== 1 || index.count !== state.requestLog.length) return;
const records = state.requestLog.map(entry => {
const saved = JSON.parse(storage.getItem(`${prefix}${entry.id}`) || "null");
if (!saved || saved.id !== entry.id) throw new Error("Incomplete debug record");
return { ...saved, startedAt: new Date(saved.startedAt), completedAt: saved.completedAt ? new Date(saved.completedAt) : null };
});
// Preserve the handles held by requests which are still in flight.
records.forEach((record, i) => Object.assign(state.requestLog[i], record));
} catch { state.storageError = "Saved debug data could not be read. Showing this session's requests from memory."; }
}
function reset() {
if (timer !== null) environment.clearTimeout?.(timer);
timer = null;
pending.clear();
state.requestLog = [];
state.debugSecretsVisible = false;
state.storageError = "";
state.generation++;
try {
if (!storage) return;
const keys = [];
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i);
if (key?.startsWith(prefix)) keys.push(key);
}
keys.forEach(key => storage.removeItem(key));
storage.setItem(`${prefix}index`, JSON.stringify({ version: 1, count: 0 }));
} catch { state.storageError = "Local storage is unavailable. Requests will be kept in memory for this session."; }
}
return state;
} }

View File

@@ -19,7 +19,7 @@
--debug-literal: #73429b; --debug-literal: #73429b;
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 50; z-index: 2147483647;
display: grid; display: grid;
grid-template-rows: auto auto minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr);
width: auto; width: auto;

View File

@@ -43,7 +43,6 @@
width: 100%; min-width: 1120px; table-layout: fixed; border-collapse: collapse; font-size: 11px; 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 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-productGroupName { width: 13%; }
.opportunities-extension-install-base-table .install-base-col-productName { 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-quantity { width: 5%; }
@@ -78,3 +77,5 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.opportunities-extension-install-base-table .opportunities-extension-account-plans-skeleton-bar { animation: none; } .opportunities-extension-install-base-table .opportunities-extension-account-plans-skeleton-bar { animation: none; }
} }
.opportunities-extension-install-base-table .opportunities-extension-sort-button { min-height: 28px; padding: 4px 16px 4px 0; overflow-wrap: anywhere; }
.opportunities-extension-install-base-table .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }

View File

@@ -1,8 +1,8 @@
function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, environment }) { function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, tableSorting, environment }) {
const { document } = environment; const { document } = environment;
let active = null; let active = null;
const columns = [ const columns = [
["partyName", "Customer"], ["productGroupName", "Product Group"], ["productName", "Product"], ["productGroupName", "Product Group"], ["productName", "Product"],
["currentSupport", "Current Support", "amount"], ["orderNumber", "Order Number"], ["currentSupport", "Current Support", "amount"], ["orderNumber", "Order Number"],
["supportStatus", "Support Status", "status"], ["quantityType", "Quantity Type"], ["supportStatus", "Support Status", "status"], ["quantityType", "Quantity Type"],
["quantity", "Quantity", "quantity"], ["supportStartDate", "Support Start Date", "date"], ["quantity", "Quantity", "quantity"], ["supportStartDate", "Support Start Date", "date"],
@@ -33,8 +33,10 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
} }
return String(value).trim(); return String(value).trim();
} }
function createTable(items, loading = false) { const sortColumns = columns.map(([key, , type]) => ({ key, type: ["amount", "quantity"].includes(type) ? "number" : type }));
function createTable(items, loading = false, sorting = tableSorting.create("installed-base", sortColumns), onSort) {
const table = element("table", "opportunities-extension-install-base-table"); const table = element("table", "opportunities-extension-install-base-table");
table.setAttribute("data-table-layout", "standard");
table.setAttribute("aria-label", "Installed base items"); table.setAttribute("aria-label", "Installed base items");
const head = element("thead"); const head = element("thead");
const header = element("tr"); const header = element("tr");
@@ -43,13 +45,14 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
const col = element("col"); const col = element("col");
col.className = `install-base-col-${key}`; col.className = `install-base-col-${key}`;
group.append(col); group.append(col);
const th = element("th", "", label); const th = element("th");
th.scope = "col"; th.scope = "col";
sorting.header(th, key, label, onSort, loading);
header.append(th); header.append(th);
}); });
head.append(header); head.append(header);
const body = element("tbody"); const body = element("tbody");
(loading ? Array.from({ length: 8 }, () => ({})) : items).forEach(item => { (loading ? Array.from({ length: 8 }, () => ({})) : sorting.select(items)).forEach(item => {
const row = element("tr"); const row = element("tr");
columns.forEach(([key, , type]) => { columns.forEach(([key, , type]) => {
const cell = element("td"); const cell = element("td");
@@ -103,6 +106,7 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
const status = element("p", "opportunities-extension-install-base-message", "Loading installed base..."); const status = element("p", "opportunities-extension-install-base-message", "Loading installed base...");
status.setAttribute("role", "status"); status.setAttribute("role", "status");
let loadedItems = [], loaded = false, failure = ""; let loadedItems = [], loaded = false, failure = "";
const sorting = tableSorting.create("installed-base", sortColumns);
const search = createSearch({ label: "Search installed base", onSearch: renderResults, const search = createSearch({ label: "Search installed base", onSearch: renderResults,
getValues: item => columns.flatMap(([key, , type]) => [item[key], formatValue(item[key], type)]) }); getValues: item => columns.flatMap(([key, , type]) => [item[key], formatValue(item[key], type)]) });
const toolbar = element("div", "opportunities-extension-customer-table-toolbar"); const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
@@ -115,7 +119,7 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
modal.addEventListener("keydown", event => { modal.addEventListener("keydown", event => {
if (event.key === "Tab") { if (event.key === "Tab") {
event.preventDefault(); event.preventDefault();
const controls = [close, search.input, content]; const controls = [close, search.input, content, ...content.querySelectorAll("button")].filter(control => !control.disabled);
const index = controls.indexOf(document.activeElement); const index = controls.indexOf(document.activeElement);
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus(); controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus();
} }
@@ -130,7 +134,10 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
: "No installed base items found for this customer."); : "No installed base items found for this customer.");
status.setAttribute("role", failure ? "alert" : "status"); status.setAttribute("role", failure ? "alert" : "status");
content.append(status); content.append(status);
if (!failure && items.length) content.append(createTable(items)); if (!failure && items.length) content.append(createTable(items, false, sorting, key => {
renderResults();
content.querySelector(`[data-sort-key="${key}"]`)?.focus();
}));
content.scrollTop = 0; content.scrollTop = 0;
} }
return { render(items, error = "") { return { render(items, error = "") {

View File

@@ -0,0 +1,67 @@
.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 .opportunities-extension-sort-button { min-width: 0; min-height: 28px; padding: 4px 12px 4px 0; border: 0; border-radius: 0; gap: 0; }
.oe-time-grid .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }
.oe-time-grid .oe-time-day-label { margin-bottom: 0; }
.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,541 @@
function createManageTime({ MODAL_ID, environment, tableSorting, 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("data-table-layout", "standard");
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"];
const sorting = tableSorting.create("manage-time", [
{ key: "service", value: row => row.type === "Non Service" ? "Non Service" : `${entry.services.find(sr => sr.srNumber === row.srNumber)?.srTitle || ""} ${row.srNumber}` },
{ key: "activity" },
{ key: "taskType", value: row => entry.taskTypes.find(item => item.lookUpCode_c === row.taskType)?.lookUpMeaning_c || row.taskType },
{ key: "notes" },
...hourFields.map((key, weekday) => ({ key, type: "number", value: row => {
const day = entry.days.find(item => dates.fromApi(item.Date).getUTCDay() === weekday);
return day ? row.hours[day.Date] : null;
} }))
]);
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();
sorting.select(entry.rows).forEach(row => {
const index = entry.rows.indexOf(row);
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 [key, label] of [["service", "Service Request"], ["activity", "Activity"], ["taskType", "Task Type"]]) {
const th = node("th"); sorting.header(th, key, label, renderRows); row.append(th);
}
entry.dayTotals = [];
for (const day of items) {
const parsed = dates.fromApi(day.Date), th = node("th", "oe-time-day");
const dayButton = sorting.header(th, hourFields[parsed.getUTCDay()], ["Su", "M", "T", "W", "Th", "F", "Sa"][parsed.getUTCDay()], renderRows);
const dayLabel = dayButton.children[0]; dayLabel.className += " oe-time-day-label";
const count = node("span", "oe-time-day-total", "0"); entry.dayTotals.push(count); dayLabel.append(count);
th.append(node("span", "", parsed.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }))); row.append(th);
}
const notesHeading = node("th"); sorting.header(notesHeading, "notes", "Notes", renderRows); row.append(notesHeading);
for (const label of ["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

@@ -64,7 +64,10 @@ function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSum
function toggle(kind, value) { function toggle(kind, value) {
const selected = selections[kind]; const selected = selections[kind];
if (!selected || !allowsCrmActivities()) return; if (!selected || !allowsCrmActivities()) return;
if (selected.has(value)) selected.delete(value); if (selected.has("matched") && selected.has("missing")) {
selected.clear();
selected.add(value);
} else if (selected.has(value)) selected.delete(value);
else { else {
if (value === "under4") selected.clear(); if (value === "under4") selected.clear();
else selected.delete("under4"); else selected.delete("under4");

View File

@@ -1,4 +1,4 @@
function createActivityProgress({ MODAL_ID, environment }) { function createActivityProgress({ MODAL_ID, environment, retrySession = () => {} }) {
const { document, setTimeout, clearTimeout, matchMedia } = environment; const { document, setTimeout, clearTimeout, matchMedia } = environment;
const entries = new Map(); const entries = new Map();
function update(key, state, completed = 0, total = null) { function update(key, state, completed = 0, total = null) {
@@ -28,7 +28,16 @@ function createActivityProgress({ MODAL_ID, environment }) {
: state === "complete-with-errors" ? `${label} ${count} (errors)` : state === "complete-with-errors" ? `${label} ${count} (errors)`
: state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`; : 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"; 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" : "Workload action requests completed"; 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 (key === "session" && state === "error") {
const retry = document.createElement("button");
retry.type = "button";
retry.className = "opportunities-extension-session-retry";
retry.textContent = "Retry";
retry.setAttribute("aria-label", "Retry Service Request session initialization");
retry.addEventListener("click", () => { retry.disabled = true; retrySession(); });
entry.badge.append(retry);
}
if (state === "complete") entry.timer = setTimeout(() => { if (state === "complete") entry.timer = setTimeout(() => {
entry.badge.setAttribute("data-fading", "true"); entry.badge.setAttribute("data-fading", "true");
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches; const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;

View File

@@ -0,0 +1,130 @@
.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-calendar-day-cards { position: absolute; inset: 27px 0 0; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; padding-bottom: 4px; }
.opportunities-calendar-day-card { display: block; width: calc(100% - 8px); padding: 0; text-align: left; font: inherit; }
.opportunities-calendar-day-card:focus-visible { outline: 2px solid #00758f; outline-offset: -2px; }
/* A single vertical sequence of months, with a separate scroll area per day. */
.opportunities-extension-calendar .fc .fc-multimonth { overflow-y: auto; overflow-x: hidden; }
.opportunities-extension-calendar .fc .fc-multimonth-month { width: 100%; }
.opportunities-extension-calendar .fc .fc-multimonth-daygrid-table { height: auto !important; }
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-body tbody { height: auto; }
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-body tr { height: var(--calendar-week-height, 190px); }
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-frame { position: relative; height: var(--calendar-week-height, 190px); min-height: var(--calendar-week-height, 190px); }
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-events {
position: absolute; inset: 27px 0 0; overflow-y: auto; overflow-x: hidden;
margin: 0; padding-bottom: 4px; scrollbar-width: thin;
}
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-event-harness { position: relative !important; top: auto !important; left: auto !important; right: auto !important; }
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-bg { pointer-events: none; }
.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; }
}
.opportunities-view-bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--oe-table-heading-border); flex-wrap: wrap; }
.opportunities-view-bar .opportunities-extension-view-tabs { border-bottom: 0; }
.opportunities-product-type-filters { display: flex; gap: 8px; margin-left: auto; padding: 4px 0; }
.opportunities-product-type-filter { border: 1px solid #8f8a85; border-radius: 12px; padding: 3px 9px; background: transparent; color: inherit; font: 600 12px/16px Arial, sans-serif; cursor: pointer; }
.opportunities-product-type-filter[aria-pressed="true"][data-type="Booking"] { color: #714016; background: #fff0df; border-color: #c58b50; }
.opportunities-product-type-filter[aria-pressed="true"][data-type="Workload"] { color: #63358a; background: #f0e8f7; border-color: #bba5cf; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-product-type-filter[aria-pressed="true"][data-type="Booking"] { color: #ffdbb5; background: #4b321e; border-color: #c99760; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-product-type-filter[aria-pressed="true"][data-type="Workload"] { color: #ead5fc; background: #43334f; border-color: #9372ac; }
.opportunities-product-type-filter:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }

View File

@@ -0,0 +1,356 @@
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, getRange,
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon,
productTypeSelection, onProductTypeChange, createPrintButton }) {
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();
let dayEvents = new Map();
const dayBadges = new Map();
let resizeObserver = null;
let layoutFrame = null;
let positionToday = false;
function scheduleLayout() {
const win = environment.window;
if (!win?.requestAnimationFrame || layoutFrame !== null) return;
layoutFrame = win.requestAnimationFrame(() => {
layoutFrame = null;
if (calendar && selectedView === "calendar") {
calendar.updateSize();
if (positionToday && !elements.calendarHost.hidden) {
const scroller = elements.calendarHost.querySelector(".fc-multimonth");
if (!scroller?.clientHeight) return;
const today = dateKey(new Date());
const range = getRange();
const cell = range && today >= range.startCloseDate.slice(0, 10) && today < range.endCloseDate.slice(0, 10)
? elements.calendarHost.querySelector(`[data-date="${today}"]:not(.fc-day-other)`)
: null;
if (cell) {
const first = new Date(`${today}T12:00:00`);
first.setDate(first.getDate() - first.getDay() - 7);
const last = new Date(first);
last.setDate(last.getDate() + 20);
const start = dateKey(first), end = dateKey(last);
// A week crossing a month boundary has two rendered rows. Include both.
const rows = [...scroller.querySelectorAll(".fc-daygrid-body tr")].filter(row =>
[...row.querySelectorAll("[data-date]:not(.fc-day-other)")].some(day => {
const date = day.getAttribute("data-date");
return date >= start && date <= end;
}));
const headings = [...new Set(rows.map(row => row.closest(".fc-multimonth-month")?.querySelector(".fc-multimonth-header")))].filter(Boolean);
const headerSpace = headings.reduce((sum, header) => sum + header.getBoundingClientRect().height, 0);
const height = Math.max(48, Math.floor((scroller.clientHeight - headerSpace - 20) / Math.max(1, rows.length)));
elements.calendarHost.style.setProperty("--calendar-week-height", `${height}px`);
const target = rows[0] || cell;
const inset = (headings[0]?.getBoundingClientRect().height || 0) + 8;
scroller.scrollTop += target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - inset;
} else scroller.scrollTop = 0;
positionToday = false;
}
}
});
}
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} ${count === 1 ? "opportunity" : "opportunities"}`;
badge.setAttribute("aria-label", badge.title);
badge.setAttribute("data-empty", String(count === 0));
}
function renderDayCards(container, key) {
container.textContent = "";
for (const event of dayEvents.get(key) || []) {
const item = event.extendedProps.item;
const button = node("button", `opportunities-calendar-day-card fc-daygrid-event ${event.classNames.join(" ")}`);
button.type = "button";
const card = node("div", "opportunities-calendar-event-content");
const summary = `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/A"} | ${formatRevenue(item)}`;
card.append(node("strong", "", event.title), node("span", "", summary));
button.title = `${event.title}\n${summary}`;
button.append(card);
button.addEventListener("click", () => showDetails(item, button));
container.append(button);
}
}
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/A"], ["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 mount(body, tableSurface) {
resizeObserver?.disconnect();
if (layoutFrame !== null) environment.window?.cancelAnimationFrame(layoutFrame);
layoutFrame = null;
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", "Opportunity views");
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", "Overview", tableSurface], ["calendar", "Calendar", 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);
}
const viewbar = node("div", "opportunities-view-bar");
viewbar.append(tablist);
if (productTypeSelection) {
const filters = node("div", "opportunities-product-type-filters");
const filterButtons = new Map();
filters.setAttribute("role", "group"); filters.setAttribute("aria-label", "Opportunity type");
for (const type of ["Booking", "Workload"]) {
const button = node("button", "opportunities-product-type-filter", type);
button.type = "button";
button.setAttribute("data-type", type);
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
button.addEventListener("click", () => {
if (["Booking", "Workload"].every(value => productTypeSelection.has(value))) {
productTypeSelection.clear();
productTypeSelection.add(type);
} else if (productTypeSelection.has(type)) productTypeSelection.delete(type); else productTypeSelection.add(type);
for (const [value, filterButton] of filterButtons) {
filterButton.setAttribute("aria-pressed", String(productTypeSelection.has(value)));
}
onProductTypeChange();
});
filterButtons.set(type, button);
filters.append(button);
}
if (createPrintButton) filters.append(createPrintButton());
viewbar.append(filters);
}
workspace.append(viewbar, 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;
if (view === "calendar" && selectedView !== "calendar") positionToday = true;
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", "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 range = getRange();
if (!range) { calendarHost.hidden = true; return; }
const start = range.startCloseDate.slice(0, 10);
const end = range.endCloseDate.slice(0, 10);
const visibleRange = { start, end }; // Fiscal ranges end at the next period's start (exclusive).
const events = eventsFor(items).filter(event => event.start >= start && event.start < end);
dayCounts = new Map();
dayEvents = new Map();
for (const event of events) {
dayCounts.set(event.start, (dayCounts.get(event.start) || 0) + 1);
if (!dayEvents.has(event.start)) dayEvents.set(event.start, []);
dayEvents.get(event.start).push(event);
}
for (const { badge, key, cards } of dayBadges.values()) { updateDayBadge(badge, key); renderDayCards(cards, key); }
const anchor = start;
if (!calendar) {
try {
library ||= loadLibrary();
calendar = new library.Calendar(calendarHost, {
initialView: "referenceMonths", initialDate: anchor, height: "100%", fixedWeekCount: false,
views: { referenceMonths: { type: "multiMonth", visibleRange, dayMaxEvents: false, dayMaxEventRows: false } },
visibleRange, validRange: visibleRange, showNonCurrentDates: false,
multiMonthMaxColumns: 1, multiMonthMinWidth: 0,
multiMonthTitleFormat: { month: "long", year: "numeric" },
datesSet: scheduleLayout,
locale: "en", firstDay: 0, editable: false, selectable: false, eventInteractive: true,
headerToolbar: false,
dayMaxEvents: false, dayMaxEventRows: false, displayEventTime: false,
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);
const cards = node("div", "opportunities-calendar-day-cards");
cards.tabIndex = 0;
cards.setAttribute("role", "region");
cards.setAttribute("aria-label", `Opportunities for ${formatDate(key)}`);
renderDayCards(cards, key);
el.querySelector(".fc-daygrid-day-frame").append(badge, cards);
dayBadges.set(el, { badge, key, cards });
},
dayCellWillUnmount: ({ el }) => { dayBadges.get(el)?.badge.remove(); dayBadges.get(el)?.cards.remove(); dayBadges.delete(el); },
// MultiMonth imposes its own event limit. Render all cards in our scrollable day region.
eventOrder: "title", eventDisplay: "none", 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/A"} | ${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()) {
positionToday = true;
calendar.setOption("views", { referenceMonths: { type: "multiMonth", visibleRange, dayMaxEvents: false, dayMaxEventRows: false } });
calendar.setOption("visibleRange", visibleRange);
calendar.setOption("validRange", visibleRange);
calendar.gotoDate(anchor); anchoredVersion = getVersion(); closeDetails(false);
const scroller = calendarHost.querySelector?.(".fc-multimonth");
if (scroller) scroller.scrollTop = 0;
}
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", "", `No valid Close Date (${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 };
}

View File

@@ -2,12 +2,12 @@ function createOpportunitiesColumns({ formatOracleResponseDate, formatRevenue, f
return [ return [
{ key: "name", label: "Name", value: (item) => item.Name, display: (item) => item.Name }, { 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: "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: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyName"]) },
{ key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName }, { key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName },
{ key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue }, { 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: "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: "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: "winProbability", label: "Prob.", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
{ key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c }, { 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) } { key: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }
]; ];

View File

@@ -13,8 +13,10 @@ function createOpportunitiesController({
productsContentState, productsContentState,
renderProductsProgress, renderProductsProgress,
requestOpportunitiesPage, requestOpportunitiesPage,
loadServiceRequests = async () => {},
resetCrmServiceRequests, resetCrmServiceRequests,
resetWorkloadActions, resetWorkloadActions,
resetWorkloads,
requestOpptyProductsInBackground, requestOpptyProductsInBackground,
requestTokenRelayOnce, requestTokenRelayOnce,
ensureCurrentUser, ensureCurrentUser,
@@ -22,7 +24,10 @@ function createOpportunitiesController({
}) { }) {
async function requestOpportunitiesForPeriod(period) { async function requestOpportunitiesForPeriod(period) {
const dateRange = getFiscalPeriodRange(period, new Date()); const dateRange = getFiscalPeriodRange(period, new Date());
opportunitiesQueryState.dateRange = dateRange;
const requestVersion = ++opportunitiesQueryState.periodRequestVersion; const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
resetWorkloadActions();
resetWorkloads();
await ensureCurrentUser(); await ensureCurrentUser();
if (!isCurrentPeriodRequest(requestVersion)) return; if (!isCurrentPeriodRequest(requestVersion)) return;
@@ -91,6 +96,9 @@ function createOpportunitiesController({
status: "ready", status: "ready",
message: "" message: ""
}); });
// Start SR only after all opportunity pages have been displayed.
// Its incremental updates run independently of the opportunity request.
try { Promise.resolve(loadServiceRequests(token, requestVersion)).catch(() => {}); } catch { /* Keep the displayed opportunities available. */ }
requestOpptyProductsInBackground(allItems, token); requestOpptyProductsInBackground(allItems, token);
} }
} catch (error) { } catch (error) {
@@ -108,6 +116,7 @@ function createOpportunitiesController({
function resetOpportunitiesTable() { function resetOpportunitiesTable() {
resetWorkloadActions(); resetWorkloadActions();
resetWorkloads();
resetCrmServiceRequests(); resetCrmServiceRequests();
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS); opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS); opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);

View File

@@ -1,4 +1,4 @@
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, createSrButton, closeSrForm, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, environment }) { function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, createSrButton, closeSrForm, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, formatDate, environment, onStateChange = () => {} }) {
let status = "idle"; let status = "idle";
let byOpportunity = new Map(); let byOpportunity = new Map();
let pending = null; let pending = null;
@@ -20,6 +20,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
async function run(token, version, currentGeneration, cacheOptions) { async function run(token, version, currentGeneration, cacheOptions) {
const isActive = () => currentGeneration === generation; const isActive = () => currentGeneration === generation;
status = "loading"; status = "loading";
onStateChange();
try { try {
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null; const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
if (!isActive()) return false; if (!isActive()) return false;
@@ -27,12 +28,28 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
let items = cached?.items; let items = cached?.items;
if (!items) { if (!items) {
reportProgress("running", 0); reportProgress("running", 0);
items = await requestCrmServiceRequest(token, version, isActive, (count, total) => { items = await requestCrmServiceRequest(token, version, isActive, (count, total, partialItems) => {
if (isActive()) reportProgress("running", count, total); if (!isActive()) return;
reportProgress("running", count, total);
if (Array.isArray(partialItems)) {
updateItems(partialItems);
onStateChange();
}
}); });
if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items); if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items);
} }
if (!items || !isActive()) return false; if (!items || !isActive()) return false;
updateItems(items);
status = "ready";
if (!cached) reportProgress("complete", items.length, items.length);
onStateChange();
return true;
} catch {
if (isActive()) { status = "error"; reportProgress("error"); onStateChange(); }
return false;
}
}
function updateItems(items) {
const grouped = new Map(); const grouped = new Map();
for (const item of items) { for (const item of items) {
const number = item?.OpportunityName_Id_c?.OptyNumber; const number = item?.OpportunityName_Id_c?.OptyNumber;
@@ -43,13 +60,6 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
} }
byOpportunity = grouped; byOpportunity = grouped;
loadedItems = items; 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) { function getSummary(optyNumber) {
if (!allowsCrmActivities() || !optyNumber) return null; if (!allowsCrmActivities() || !optyNumber) return null;
@@ -58,38 +68,40 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
(typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isFinite(Number(value)) (typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isFinite(Number(value))
).map(Number); ).map(Number);
return { return {
state: status === "ready" ? items.length ? "matched" : "missing" : status, state: status === "loading" && items.length ? "matched" : status === "ready" ? items.length ? "matched" : "missing" : status,
reportedHours: hours.length === items.length ? hours.reduce((total, value) => total + value, 0) : null, reportedHours: hours.length === items.length ? hours.reduce((total, value) => total + value, 0) : null,
hasUnderFourHours: status === "ready" && hours.some(value => value < 4) hasUnderFourHours: (status === "ready" || status === "loading") && hours.some(value => value < 4)
}; };
} }
function createBadge(optyNumber) { function createBadge(optyNumber) {
if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null; const badgeStatus = status === "loading" && byOpportunity.get(String(optyNumber))?.length ? "ready" : status;
if (!allowsCrmActivities() || !optyNumber || badgeStatus === "idle" || badgeStatus === "loading") return null;
const items = byOpportunity.get(String(optyNumber)) || []; const items = byOpportunity.get(String(optyNumber)) || [];
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span"); const badge = environment.document.createElement(items.length && badgeStatus === "ready" ? "button" : "span");
badge.className = "opportunities-extension-crm-sr-badge"; badge.className = "opportunities-extension-crm-sr-badge";
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing"); badge.setAttribute("data-state", badgeStatus === "error" ? "error" : items.length ? "matched" : "missing");
const summary = getSummary(optyNumber); const summary = getSummary(optyNumber);
const reportedHours = summary.reportedHours !== null const reportedHours = summary.reportedHours !== null
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours) ? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
: "N/D"; : "N/A";
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/D" ? "" : "h"}` : "0h"); setBadgeContent(badge, "sr", badgeStatus === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/A" ? "" : "h"}` : "0h");
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value); const display = value => value === null || value === undefined || value === "" ? "N/A" : String(value);
const descriptions = items.map(item => [ const descriptions = items.map(item => [
`Title: ${display(item.Title)}`, `Title: ${display(item.Title)}`,
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`, `Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
`Reported Hours: ${display(item.ReportedHours_c)}`, `Reported Hours: ${display(item.ReportedHours_c)}`,
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/D"}`, `Customer Deadline: ${display(formatDate(item.CustomerDeadline_c))}`,
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/A"}`,
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`, `Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}` `Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
].join("\n")); ].join("\n"));
const description = status === "error" ? "Unable to check CRM service requests. See Debug Requests." const description = badgeStatus === "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.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"); : items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
badge.setAttribute("aria-label", description); badge.setAttribute("aria-label", description);
badge.title = "Whether a Service Request has been created for the opportunities."; badge.title = "Whether a Service Request has been created for the opportunities.";
if (items.length && status === "ready") bindPopover(badge, items, descriptions); if (items.length && badgeStatus === "ready") bindPopover(badge, items, descriptions);
if (!items.length && status === "ready") badge.append(createSrButton(String(optyNumber))); if (!items.length && badgeStatus === "ready") badge.append(createSrButton(String(optyNumber)));
badge.tabIndex = 0; badge.tabIndex = 0;
return badge; return badge;
} }

View File

@@ -1,4 +1,4 @@
function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportunity, formatDate, requestPillars, requestLanguage, requestPlatform, requestServices, createCloseIcon, searchableSelect, environment }) { function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportunity, requestSave, notify, formatDate, requestPillars, requestLanguage, requestPlatform, requestServices, createCloseIcon, searchableSelect, environment }) {
const { document } = environment; const { document } = environment;
let active = null; let active = null;
function node(tag, className, text) { function node(tag, className, text) {
@@ -9,6 +9,7 @@ function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportun
} }
function close(restoreFocus = true) { function close(restoreFocus = true) {
if (!active) return false; if (!active) return false;
if (active.saving) return true;
const previous = active; const previous = active;
active = null; active = null;
previous.modal.remove(); previous.modal.remove();
@@ -25,6 +26,7 @@ function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportun
return button; return button;
} }
function open(number, trigger) { function open(number, trigger) {
if (active?.saving) return;
const host = document.getElementById(MODAL_ID); const host = document.getElementById(MODAL_ID);
if (!host || host.hidden || !allowsCrmActivities() || !isMissing(number)) return; if (!host || host.hidden || !allowsCrmActivities() || !isMissing(number)) return;
close(false); close(false);
@@ -55,23 +57,36 @@ function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportun
dismiss.addEventListener("click", () => close()); dismiss.addEventListener("click", () => close());
header.append(heading, dismiss); header.append(heading, dismiss);
const form = node("form", "opportunities-extension-action-form"); const form = node("form", "opportunities-extension-action-form");
form.addEventListener("submit", event => event.preventDefault()); 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 field = node("label", "opportunities-extension-action-form-field");
const select = node("select"); const select = node("select");
select.id = "opportunities-extension-sr-primary-pillar"; select.id = "opportunities-extension-sr-primary-pillar";
select.name = "primaryPillar"; select.name = "primaryPillar";
select.required = true;
field.htmlFor = select.id; field.htmlFor = select.id;
field.append(node("span", "", "Primary pillar"), select); field.append(node("span", "", "Primary pillar (required)"), select);
const autocomplete = searchableSelect(select, field); 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"); const status = node("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
status.setAttribute("role", "status"); status.setAttribute("role", "status");
const footer = node("footer", "opportunities-extension-action-form-footer"); const footer = node("footer", "opportunities-extension-action-form-footer");
const retry = node("button", "", "Retry"); const retry = node("button", "", "Retry");
retry.type = "button"; retry.type = "button";
const cancel = node("button", "", "Cancel"); 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.type = "button";
cancel.addEventListener("click", () => close()); cancel.addEventListener("click", () => close());
footer.append(retry, cancel); footer.append(retry, save, cancel);
form.append(field); form.append(field);
const extraControls = []; const extraControls = [];
const extraLoads = []; const extraLoads = [];
@@ -82,8 +97,10 @@ function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportun
else input.type = type; else input.type = type;
input.id = `opportunities-extension-sr-${name}`; input.id = `opportunities-extension-sr-${name}`;
input.name = name; input.name = name;
input.required = true;
fields.set(name, input);
wrapper.htmlFor = input.id; wrapper.htmlFor = input.id;
wrapper.append(node("span", "", label), input); wrapper.append(node("span", "", `${label} (required)`), input);
form.append(wrapper); form.append(wrapper);
extraControls.push(input); extraControls.push(input);
} }
@@ -98,15 +115,18 @@ function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportun
retryOption.hidden = true; retryOption.hidden = true;
extraControls.push(retryOption); extraControls.push(retryOption);
let input, combo; let input, combo;
if (radios) wrapper.append(node("legend", "", label)); if (radios) { wrapper.append(node("legend", "", `${label} (required)`)); serviceGroup = wrapper; }
else { else {
const field = node("label", "opportunities-extension-action-form-field"); const field = node("label", "opportunities-extension-action-form-field");
input = node("select"); input = node("select");
input.id = `opportunities-extension-sr-${name}`; input.id = `opportunities-extension-sr-${name}`;
input.name = name; input.name = name;
input.required = true;
fields.set(name, input);
field.htmlFor = input.id; field.htmlFor = input.id;
field.append(node("span", "", label), input); field.append(node("span", "", `${label} (required)`), input);
combo = searchableSelect(input, field); combo = searchableSelect(input, field);
combos.set(input, combo);
content.append(field); content.append(field);
extraControls.push(combo.input); extraControls.push(combo.input);
} }
@@ -116,6 +136,9 @@ function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportun
async function loadOptions() { async function loadOptions() {
if (loading || !current()) return; if (loading || !current()) return;
loading = true; loading = true;
loadingFields.add(name);
availableFields.delete(name);
updateSave();
retryOption.hidden = true; retryOption.hidden = true;
message.hidden = false; message.hidden = false;
message.setAttribute("role", "status"); message.setAttribute("role", "status");
@@ -125,6 +148,7 @@ function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportun
try { try {
const items = await request(); const items = await request();
if (!current()) return; if (!current()) return;
if (items.length) availableFields.add(name);
if (input) { if (input) {
const blank = node("option", "", items.length ? `Select ${label.toLowerCase()}` : "No options available"); const blank = node("option", "", items.length ? `Select ${label.toLowerCase()}` : "No options available");
blank.value = ""; blank.value = "";
@@ -134,6 +158,8 @@ function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportun
input.disabled = !items.length; input.disabled = !items.length;
} else { } else {
content.textContent = ""; content.textContent = "";
serviceItems = items;
serviceRadios.length = 0;
const seen = new Set(); const seen = new Set();
items.forEach((item, index) => { items.forEach((item, index) => {
if (seen.has(String(item.Id))) return; if (seen.has(String(item.Id))) return;
@@ -142,6 +168,8 @@ function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportun
const radio = node("input"); const radio = node("input");
radio.type = "radio"; radio.type = "radio";
radio.name = "service"; radio.name = "service";
radio.required = true;
serviceRadios.push(radio);
radio.value = String(item.Id); radio.value = String(item.Id);
radio.id = `opportunities-extension-sr-service-${index}`; radio.id = `opportunities-extension-sr-service-${index}`;
const copy = node("span"); const copy = node("span");
@@ -164,6 +192,8 @@ function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportun
retryOption.hidden = false; retryOption.hidden = false;
} finally { } finally {
loading = false; loading = false;
loadingFields.delete(name);
if (current()) updateSave();
if (current()) { combo?.sync(); wrapper.setAttribute("aria-busy", "false"); } if (current()) { combo?.sync(); wrapper.setAttribute("aria-busy", "false"); }
} }
} }
@@ -181,10 +211,87 @@ function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportun
const view = { modal, trigger, loading: false }; const view = { modal, trigger, loading: false };
active = view; active = view;
const current = () => active === view && allowsCrmActivities(); const current = () => active === view && allowsCrmActivities();
function updateSave() { save.disabled = loadingFields.size > 0 || availableFields.size < 4 || Boolean(view.saving); }
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) 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); } function option(value, text) { const item = node("option", "", text); item.value = value; select.append(item); }
async function load() { async function load() {
if (view.loading || !current()) return; if (view.loading || !current()) return;
view.loading = true; view.loading = true;
loadingFields.add("primaryPillar");
availableFields.delete("primaryPillar");
updateSave();
retry.hidden = true; retry.hidden = true;
select.disabled = true; select.disabled = true;
select.textContent = ""; select.textContent = "";
@@ -203,6 +310,7 @@ function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportun
items.forEach(item => option(item.code, item.name)); items.forEach(item => option(item.code, item.name));
select.value = ""; select.value = "";
select.disabled = !items.length; select.disabled = !items.length;
if (items.length) availableFields.add("primaryPillar");
form.setAttribute("data-state", "ready"); form.setAttribute("data-state", "ready");
status.hidden = true; status.hidden = true;
} catch (error) { } catch (error) {
@@ -215,6 +323,8 @@ function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportun
retry.hidden = false; retry.hidden = false;
} finally { } finally {
view.loading = false; view.loading = false;
loadingFields.delete("primaryPillar");
if (current()) updateSave();
if (current()) { autocomplete.sync(); form.setAttribute("aria-busy", "false"); } if (current()) { autocomplete.sync(); form.setAttribute("aria-busy", "false"); }
} }
} }
@@ -223,7 +333,7 @@ function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportun
modal.addEventListener("keydown", event => { modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); } if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key !== "Tab") return; if (event.key !== "Tab") return;
const controls = [dismiss, autocomplete.input, ...extraControls, retry, cancel].filter(control => !control.disabled && !control.hidden); const controls = [dismiss, autocomplete.input, ...extraControls, retry, save, cancel].filter(control => !control.disabled && !control.hidden);
const index = controls.indexOf(document.activeElement); const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) { if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus(); event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();

View File

@@ -1,4 +1,4 @@
function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, requestDetails, environment }) { function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, requestDetails, formatDate, openInternalSrForm, environment }) {
const { document, window, setTimeout, clearTimeout } = environment; const { document, window, setTimeout, clearTimeout } = environment;
let active = null; let active = null;
let sequence = 0; let sequence = 0;
@@ -81,7 +81,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
heading.className = "opportunities-extension-sr-number"; heading.className = "opportunities-extension-sr-number";
const number = item.SrNumber; const number = item.SrNumber;
const link = document.createElement(number ? "a" : "span"); const link = document.createElement(number ? "a" : "span");
link.textContent = number ? String(number) : "N/D"; link.textContent = number ? String(number) : "N/A";
if (number) { if (number) {
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(number))}`; link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(number))}`;
link.target = "_blank"; link.target = "_blank";
@@ -91,6 +91,29 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
label.textContent = "SR Number:"; label.textContent = "SR Number:";
heading.append(label, link); heading.append(label, link);
if (number) heading.append(createCopyButton(String(number), "SR number", "SR Number")); 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"); const details = document.createElement("div");
details.className = "opportunities-extension-sr-details"; details.className = "opportunities-extension-sr-details";
details.textContent = descriptions[index]; details.textContent = descriptions[index];
@@ -101,9 +124,13 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
children.setAttribute("role", "status"); children.setAttribute("role", "status");
children.textContent = "Loading SR details..."; children.textContent = "Loading SR details...";
section.append(children); section.append(children);
detailLoads.push(async () => { const loadDetails = async () => {
if (active !== entry || entry.panel !== panel) return;
children.textContent = "Loading SR details...";
children.setAttribute("aria-busy", "true");
children.setAttribute("role", "status");
try { try {
const records = await requestDetails(item.SrId); const records = await requestDetails(item.SrId, item.SrNumber);
if (active !== entry || entry.panel !== panel) return; if (active !== entry || entry.panel !== panel) return;
children.textContent = ""; children.textContent = "";
if (!records.length) children.textContent = "No further details."; if (!records.length) children.textContent = "No further details.";
@@ -114,7 +141,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
const label = document.createElement("span"); const label = document.createElement("span");
label.textContent = "Internal SR Number:"; label.textContent = "Internal SR Number:";
const link = document.createElement(record.SrNumber ? "a" : "span"); const link = document.createElement(record.SrNumber ? "a" : "span");
link.textContent = record.SrNumber ? String(record.SrNumber) : "N/D"; link.textContent = record.SrNumber ? String(record.SrNumber) : "N/A";
if (record.SrNumber) { 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.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.target = "_blank";
@@ -126,7 +153,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
body.className = "opportunities-extension-sr-details"; body.className = "opportunities-extension-sr-details";
body.textContent = ["Title", "OpportunityName_c", "StatusCd", "ServiceNew_c", "AccountPartyName", body.textContent = ["Title", "OpportunityName_c", "StatusCd", "ServiceNew_c", "AccountPartyName",
"LastUpdateDate", "SvcHealth_c", "AssigneePersonName", "OpenDate"].map(key => "LastUpdateDate", "SvcHealth_c", "AssigneePersonName", "OpenDate"].map(key =>
`${key === "ServiceNew_c" ? "Service Queue" : key}: ${record[key] == null || record[key] === "" ? "N/D" : String(record[key])}`).join("\n"); `${key === "ServiceNew_c" ? "Service Queue" : key}: ${record[key] == null || record[key] === "" ? "N/A" : String(key === "LastUpdateDate" || key === "OpenDate" ? formatDate(record[key]) : record[key])}`).join("\n");
child.append(row, body); child.append(row, body);
children.append(child); children.append(child);
} }
@@ -134,13 +161,20 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
if (active !== entry || entry.panel !== panel) return; if (active !== entry || entry.panel !== panel) return;
children.setAttribute("role", "alert"); children.setAttribute("role", "alert");
children.textContent = error.message || "Unable to load SR details."; children.textContent = error.message || "Unable to load SR details.";
const retry = document.createElement("button");
retry.type = "button";
retry.textContent = "Retry";
retry.setAttribute("aria-label", "Retry loading Internal Service Requests");
retry.addEventListener("click", () => { retry.disabled = true; loadDetails(); });
children.append(retry);
} finally { } finally {
if (active === entry && entry.panel === panel) { if (active === entry && entry.panel === panel) {
children.setAttribute("aria-busy", "false"); children.setAttribute("aria-busy", "false");
entry.position(); entry.position();
} }
} }
}); };
detailLoads.push(loadDetails);
panel.append(section); panel.append(section);
}); });
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer)); panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));

View File

@@ -28,7 +28,7 @@ function createOpportunitiesDashboard({
totalValue, totalValue,
"TOTAL", "TOTAL",
items.length, items.length,
(amount) => `${Math.round(amount).toLocaleString()}/${totalCount.toLocaleString()}` (amount) => `${Math.round(amount).toLocaleString("en-US")}/${totalCount.toLocaleString("en-US")}`
); );
totalCard.append(totalLabel, totalValue); totalCard.append(totalLabel, totalValue);
dashboard.append(totalCard); dashboard.append(totalCard);
@@ -81,7 +81,7 @@ function createOpportunitiesDashboard({
} }
} }
return (amount) => amount.toLocaleString(undefined, { return (amount) => amount.toLocaleString("en-US", {
maximumFractionDigits: 0 maximumFractionDigits: 0
}); });
} }

View File

@@ -1,5 +1,6 @@
function createOpportunitiesFilterState({ STAGE_OPTIONS, STATUS_OPTIONS } = {}) { function createOpportunitiesFilterState({ STAGE_OPTIONS, STATUS_OPTIONS } = {}) {
return { return {
selectedProductTypes: new Set(["Booking", "Workload"]),
selectedStages: new Set(STAGE_OPTIONS), selectedStages: new Set(STAGE_OPTIONS),
selectedStatuses: new Set(STATUS_OPTIONS), selectedStatuses: new Set(STATUS_OPTIONS),
selectedCustomerKeys: new Set(), selectedCustomerKeys: new Set(),

View File

@@ -25,6 +25,7 @@ function createOpportunitiesFilters({
saveFilterPreferences, saveFilterPreferences,
stringifySearchValue, stringifySearchValue,
matchesActivities, matchesActivities,
matchesProductType = () => true,
environment environment
}) { }) {
const { document } = environment; const { document } = environment;
@@ -63,7 +64,10 @@ function createOpportunitiesFilters({
} }
function toggleStageFilter(stage) { function toggleStageFilter(stage) {
if (opportunitiesFilterState.selectedStages.has(stage)) { if (STAGE_OPTIONS.every(value => opportunitiesFilterState.selectedStages.has(value))) {
opportunitiesFilterState.selectedStages.clear();
opportunitiesFilterState.selectedStages.add(stage);
} else if (opportunitiesFilterState.selectedStages.has(stage)) {
opportunitiesFilterState.selectedStages.delete(stage); opportunitiesFilterState.selectedStages.delete(stage);
} else { } else {
opportunitiesFilterState.selectedStages.add(stage); opportunitiesFilterState.selectedStages.add(stage);
@@ -98,7 +102,10 @@ function createOpportunitiesFilters({
} }
function toggleStatusFilter(status) { function toggleStatusFilter(status) {
if (opportunitiesFilterState.selectedStatuses.has(status)) { if (STATUS_OPTIONS.every(value => opportunitiesFilterState.selectedStatuses.has(value))) {
opportunitiesFilterState.selectedStatuses.clear();
opportunitiesFilterState.selectedStatuses.add(status);
} else if (opportunitiesFilterState.selectedStatuses.has(status)) {
opportunitiesFilterState.selectedStatuses.delete(status); opportunitiesFilterState.selectedStatuses.delete(status);
} else { } else {
opportunitiesFilterState.selectedStatuses.add(status); opportunitiesFilterState.selectedStatuses.add(status);
@@ -245,7 +252,10 @@ function createOpportunitiesFilters({
checkbox.type = "checkbox"; checkbox.type = "checkbox";
checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key); checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key);
checkbox.addEventListener("change", () => { checkbox.addEventListener("change", () => {
if (checkbox.checked) { if (getCustomerOptions().every(option => opportunitiesFilterState.selectedCustomerKeys.has(option.key))) {
opportunitiesFilterState.selectedCustomerKeys.clear();
opportunitiesFilterState.selectedCustomerKeys.add(customer.key);
} else if (checkbox.checked) {
opportunitiesFilterState.selectedCustomerKeys.add(customer.key); opportunitiesFilterState.selectedCustomerKeys.add(customer.key);
} else { } else {
opportunitiesFilterState.selectedCustomerKeys.delete(customer.key); opportunitiesFilterState.selectedCustomerKeys.delete(customer.key);
@@ -315,7 +325,10 @@ function createOpportunitiesFilters({
checkbox.type = "checkbox"; checkbox.type = "checkbox";
checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key); checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key);
checkbox.addEventListener("change", () => { checkbox.addEventListener("change", () => {
if (checkbox.checked) { if (getOwnerOptions().every(option => opportunitiesFilterState.selectedOwnerKeys.has(option.key))) {
opportunitiesFilterState.selectedOwnerKeys.clear();
opportunitiesFilterState.selectedOwnerKeys.add(owner.key);
} else if (checkbox.checked) {
opportunitiesFilterState.selectedOwnerKeys.add(owner.key); opportunitiesFilterState.selectedOwnerKeys.add(owner.key);
} else { } else {
opportunitiesFilterState.selectedOwnerKeys.delete(owner.key); opportunitiesFilterState.selectedOwnerKeys.delete(owner.key);
@@ -345,7 +358,8 @@ function createOpportunitiesFilters({
&& (allCustomers || opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item))) && (allCustomers || opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)))
&& (allOwners || opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item))) && (allOwners || opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)))
&& matchesTableSearch(item, normalizedSearch) && matchesTableSearch(item, normalizedSearch)
&& matchesActivities(item); && matchesActivities(item)
&& matchesProductType(item);
}); });
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey); const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);

View File

@@ -1,15 +1,15 @@
function createOpportunitiesFiscalPeriods() { function createOpportunitiesFiscalPeriods() {
// Oracle fiscal years start on June 1; each range ends at the next period's start.
const FISCAL_START_MONTH = 5;
function getFiscalPeriodRange(period, currentDate) { function getFiscalPeriodRange(period, currentDate) {
const currentYear = currentDate.getFullYear(); const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth(); const currentMonth = currentDate.getMonth();
const fiscalStartYear = currentMonth >= 5 ? currentYear : currentYear - 1; const fiscalStartYear = currentMonth >= FISCAL_START_MONTH ? currentYear : currentYear - 1;
const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3); const currentQuarterIndex = Math.floor(((currentMonth - FISCAL_START_MONTH + 12) % 12) / 3);
if (period === "Current Fiscal Year") { if (period === "Current Fiscal Year") {
const isBeforeFiscalYearStart = currentMonth < 5; return createDateRange(fiscalStartYear, FISCAL_START_MONTH, fiscalStartYear + 1, FISCAL_START_MONTH);
const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear;
const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1;
return createDateRange(startYear, 5, endYear, 4);
} }
let quarterOffset; let quarterOffset;
@@ -28,19 +28,14 @@ function createOpportunitiesFiscalPeriods() {
return null; return null;
} }
const start = new Date(fiscalStartYear, 5 + (quarterOffset * 3), 1); const startMonth = FISCAL_START_MONTH + (quarterOffset * 3);
const end = new Date(start.getFullYear(), start.getMonth() + (numberOfQuarters * 3), 0); return createDateRange(fiscalStartYear, startMonth, fiscalStartYear, startMonth + (numberOfQuarters * 3));
return {
startCloseDate: formatOracleDate(start),
endCloseDate: formatOracleDate(end)
};
} }
function createDateRange(startYear, startMonth, endYear, endMonth) { function createDateRange(startYear, startMonth, endYear, endMonth) {
return { return {
startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)), startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)),
endCloseDate: formatOracleDate(new Date(endYear, endMonth + 1, 0)) endCloseDate: formatOracleDate(new Date(endYear, endMonth, 1))
}; };
} }

View File

@@ -144,7 +144,7 @@ function createOpportunitiesFormatters({
} }
if (!currency) { if (!currency) {
return number.toLocaleString(); return number.toLocaleString("en-US");
} }
try { try {
@@ -153,7 +153,7 @@ function createOpportunitiesFormatters({
currency currency
}).format(number); }).format(number);
} catch (error) { } catch (error) {
return `${currency} ${number.toLocaleString()}`; return `${currency} ${number.toLocaleString("en-US")}`;
} }
} }

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/A" : 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/A";
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

@@ -1,16 +1,15 @@
function createInternalSrSession({ query, environment, sessionUrl, sessionLabel = "Internal SR" }) { function createInternalSrSession({ query, environment, sessionUrl, sessionLabel = "Internal SR" }) {
const { document, setTimeout } = environment; const pending = new Map();
let recovery = null; let recovery = null;
const wait = duration => new Promise(resolve => setTimeout(resolve, duration));
const now = () => environment.performance?.now() ?? Date.now();
function isUnauthorized(result) { function isUnauthorized(result) {
const status = result?.httpStatus ?? result?.debugRequests?.at(-1)?.httpStatus; const status = result?.httpStatus ?? result?.debugRequests?.at(-1)?.httpStatus;
if (status != null) return Number(status) === 401; return status != null ? Number(status) === 401 : /failed with status 401\.$/.test(result?.error || "");
// Older background workers may return only the request error text.
return /failed with status 401\.$/.test(result?.error || "");
} }
async function warmSession(id) { async function warmSession(id, srNumber) {
const document = environment?.document;
if (!document?.body) throw new Error(`${sessionLabel} session cannot be initialized on this page.`);
const frame = document.createElement("iframe"); const frame = document.createElement("iframe");
// Restore the original session initialization route without opening a tab.
frame.src = sessionUrl || "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR0001421040&srId=302963804324543&view=showisr"; 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.title = `${sessionLabel} session initialization`;
frame.tabIndex = -1; frame.tabIndex = -1;
@@ -18,43 +17,46 @@ function createInternalSrSession({ query, environment, sessionUrl, sessionLabel
frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); 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;"; frame.style.cssText = "position:fixed;width:1px;height:1px;left:-10000px;top:0;border:0;opacity:0;pointer-events:none;";
let blocked = false; let blocked = false;
let loaded = false; const failed = () => { blocked = true; };
frame.addEventListener("error", () => { blocked = true; }); frame.addEventListener("error", failed);
frame.addEventListener("load", () => { loaded = true; });
try { try {
if (!document.body) throw new Error(`The current page cannot initialize the ${sessionLabel} session.`);
document.body.append(frame); document.body.append(frame);
const deadline = now() + 60000; // Keep the iframe alive while its application completes authentication.
for (let attempt = 0; now() < deadline; attempt += 1) { // A load event is not proof of a ready session. Limit and space API checks.
// Check immediately, then briefly poll faster before backing off. for (const delay of [2000, 4000, 8000, 12000, 16000]) {
if (attempt > 0) await wait(Math.min(attempt <= 4 ? 250 : 1000, deadline - now())); await new Promise(resolve => environment.setTimeout(resolve, delay));
if (now() >= deadline) break; if (blocked) throw new Error(`${sessionLabel} session iframe could not be loaded. Retry after signing in.`);
if (blocked) throw new Error(`The ${sessionLabel} session iframe could not be loaded. Sign in manually and try again.`); const result = await query(id, srNumber);
const result = await query(id); if (result?.ok) return { id: String(id), result };
if (result?.ok) return { id, result };
if (!isUnauthorized(result)) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`); 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.`); throw new Error(`${sessionLabel} session is still unavailable. Retry loading the details after signing in.`);
} finally { } finally {
// Stop remaining page resources without stopping the caller's page. frame.removeEventListener("error", failed);
try { frame.contentWindow?.stop(); } catch { /* Redirects can make the frame cross-origin. */ } try { frame.contentWindow?.stop(); } catch { /* SSO may redirect cross-origin. */ }
frame.src = "about:blank";
frame.remove(); frame.remove();
} }
} }
async function request(id) { function request(id, srNumber) {
let result = await query(id); const key = String(id);
for (let attempt = 0; !result?.ok && isUnauthorized(result) && attempt < 2; attempt += 1) { if (!pending.has(key)) {
const operation = (async () => {
let result = await query(id, srNumber);
if (!result?.ok && isUnauthorized(result)) {
if (!recovery) { if (!recovery) {
recovery = warmSession(id); const current = warmSession(id, srNumber);
const pending = recovery; recovery = current;
pending.finally(() => { if (recovery === pending) recovery = null; }).catch(() => {}); current.finally(() => { if (recovery === current) recovery = null; }).catch(() => {});
} }
const recovered = await recovery; const recovered = await recovery;
result = recovered.id === id ? recovered.result : await query(id); result = recovered.id === key ? recovered.result : await query(id, srNumber);
} }
if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`); if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
return result.items; return result.items;
})().finally(() => pending.delete(key));
pending.set(key, operation);
}
return pending.get(key);
} }
return { request }; return { request };
} }

View File

@@ -1,8 +1,140 @@
function createOpportunitiesLinks({ function createOpportunitiesLinks({
MODAL_ID, MODAL_ID,
searchableSelect,
environment environment
}) { }) {
const { document, navigator, window } = environment; const { document, navigator, window } = environment;
function chooseBookmarkFolder(loadFolders, saveBookmark) {
const modal = document.getElementById(MODAL_ID);
if (!modal || modal.querySelector(".oe-bookmark-picker")) return;
const previous = document.activeElement;
const overlay = document.createElement("div");
overlay.className = "opportunities-extension-action-form-modal oe-bookmark-picker";
const panel = document.createElement("section");
panel.className = "opportunities-extension-action-form-dialog";
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-modal", "true");
panel.setAttribute("aria-labelledby", "oe-bookmark-title");
const header = document.createElement("header");
header.className = "opportunities-extension-account-plans-header";
const title = document.createElement("h2");
title.id = "oe-bookmark-title";
title.textContent = "Add to favorites";
const dismiss = document.createElement("button");
dismiss.type = "button";
dismiss.className = "opportunities-extension-icon-button";
dismiss.setAttribute("aria-label", "Close favorites form");
const closeIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
closeIcon.setAttribute("viewBox", "0 0 24 24");
closeIcon.setAttribute("aria-hidden", "true");
const closePath = document.createElementNS("http://www.w3.org/2000/svg", "path");
closePath.setAttribute("d", "M6 6l12 12M18 6 6 18");
closePath.setAttribute("stroke", "currentColor");
closePath.setAttribute("stroke-width", "1.75");
closeIcon.append(closePath);
dismiss.append(closeIcon);
header.append(title, dismiss);
const body = document.createElement("div");
body.className = "opportunities-extension-action-form";
const label = document.createElement("label");
label.className = "opportunities-extension-action-form-field opportunities-extension-action-form-wide";
label.textContent = "Folder (required)";
const select = document.createElement("select");
select.id = "oe-bookmark-folder";
select.required = true;
select.setAttribute("aria-describedby", "oe-bookmark-status");
select.disabled = true;
label.append(select);
const status = document.createElement("p");
status.id = "oe-bookmark-status";
status.className = "opportunities-extension-action-form-status opportunities-extension-action-form-wide";
status.setAttribute("role", "status");
status.textContent = "Loading bookmarks folders…";
const actions = document.createElement("footer");
actions.className = "opportunities-extension-action-form-footer";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.textContent = "Cancel";
const save = document.createElement("button");
save.type = "button";
save.className = "opportunities-extension-action-form-save";
save.textContent = "Save";
save.disabled = true;
const autocomplete = searchableSelect(select, label, () => { save.disabled = !select.value; }, { floating: true });
autocomplete.input.setAttribute("aria-describedby", "oe-bookmark-status");
let saving = false;
const siblings = Array.from(modal.children).map(node => [node, node.inert]);
siblings.forEach(([node]) => { node.inert = true; });
function close() {
if (saving) return;
autocomplete.destroy();
overlay.remove();
siblings.forEach(([node, inert]) => { node.inert = inert; });
previous?.focus();
}
cancel.addEventListener("click", close);
dismiss.addEventListener("click", close);
save.addEventListener("click", async () => {
if (!select.value || saving) return;
saving = true;
save.disabled = cancel.disabled = select.disabled = dismiss.disabled = true;
autocomplete.sync();
status.setAttribute("role", "status");
status.textContent = "Saving favorite…";
try {
const result = await saveBookmark(select.value);
if (!result?.ok) throw new Error(result?.error || "Unable to add favorite.");
saving = false;
close();
showCopyConfirmation(result.existing ? "This shortcut is already in the selected folder." : "Opportunities Extension added to favorites.");
} catch (error) {
status.setAttribute("role", "alert");
status.textContent = error.message;
saving = false;
save.disabled = cancel.disabled = select.disabled = dismiss.disabled = false;
autocomplete.sync();
}
});
overlay.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key === "Tab") {
const controls = [dismiss, autocomplete.input, cancel, save].filter(control => !control.disabled);
event.preventDefault();
const index = controls.indexOf(document.activeElement);
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length]?.focus();
}
});
actions.append(cancel, save);
body.append(label, status, actions);
panel.append(header, body);
overlay.append(panel);
modal.append(overlay);
cancel.focus();
Promise.resolve().then(loadFolders).then(result => {
if (!overlay.isConnected) return;
if (!result?.ok) throw new Error(result?.error || "Unable to load bookmarks folders.");
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "Select a folder";
select.append(placeholder);
for (const folder of result.folders) {
const option = document.createElement("option");
option.value = folder.id;
option.textContent = folder.label;
select.append(option);
}
select.disabled = !result.folders.length;
status.textContent = result.folders.length ? "Choose where to save Opportunities Extension." : "No writable bookmarks folders available.";
select.addEventListener("change", () => { save.disabled = !select.value; });
autocomplete.sync();
if (!select.disabled) autocomplete.input.focus();
}).catch(error => {
if (overlay.isConnected) {
status.setAttribute("role", "alert");
status.textContent = error.message;
}
});
}
function createDetailLink(label, href) { function createDetailLink(label, href) {
if (label === null || label === undefined || label === "") { if (label === null || label === undefined || label === "") {
const placeholder = document.createElement("span"); const placeholder = document.createElement("span");
@@ -114,5 +246,5 @@ function createOpportunitiesLinks({
return badge; return badge;
} }
return { createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge }; return { chooseBookmarkFolder, createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge };
} }

View File

@@ -0,0 +1,100 @@
function createOpportunitiesPrint({ environment, getSnapshot }) {
function createButton() {
const doc = environment.document;
const button = doc.createElement("button");
button.type = "button";
button.className = "opportunities-extension-icon-button";
button.title = "Print opportunities";
button.setAttribute("aria-label", "Print opportunities");
const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.6");
svg.setAttribute("aria-hidden", "true");
const path = doc.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", "M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6zM17 11h1");
svg.append(path); button.append(svg);
button.addEventListener("click", print);
return button;
}
function print() {
const snapshot = getSnapshot();
if (!["ready", "empty"].includes(snapshot.status)) {
environment.window.alert("Wait for opportunities to finish loading before printing.");
return;
}
const popup = environment.window.open("", "_blank", "width=1200,height=850");
if (!popup) { environment.window.alert("Allow pop-ups to print opportunities."); return; }
popup.opener = null;
const doc = popup.document;
doc.title = "Opportunities Extension — Report";
doc.documentElement.lang = "en";
const node = (tag, text, parent) => {
const el = doc.createElement(tag);
if (text != null) el.textContent = String(text);
if (parent) parent.append(el);
return el;
};
node("style", `
@page { size: A4 landscape; margin: 15mm; }
* { box-sizing: border-box; }
body { margin: 32px; font: 11px/1.4 Arial, sans-serif; color: #222; background: white; }
h1 { margin: 0 0 4px; font-size: 21px; } h2 { font-size: 13px; margin: 16px 0 6px; }
.meta { color: #555; margin: 0 0 12px; }
.filters { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px 16px; border: 1px solid #ccc; padding: 10px; }
.filters div { overflow-wrap: anywhere; } .filters strong { margin-right: 5px; }
.totals { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 12px 0; }
.totals section { border: 1px solid #bbb; border-top: 3px solid #426a75; padding: 8px; break-inside: avoid; }
.totals strong { display: block; font-size: 16px; } .totals small { display: block; }
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 9px; }
th, td { padding: 6px 4px; border-bottom: 1px solid #ccc; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
th { background: #eee; font-weight: bold; } tbody tr:nth-child(even) { background: #f7f7f7; }
thead { display: table-header-group; } tr { break-inside: avoid; }
th:nth-child(1), th:nth-child(3) { width: 22%; }
th:nth-child(2) { width: 7%; } th:nth-child(4) { width: 8%; }
th:nth-child(5) { width: 9%; } th:nth-child(6) { width: 7%; }
th:nth-child(7), th:nth-child(8) { width: 5%; } th:nth-child(9) { width: 7%; } th:nth-child(10) { width: 8%; }
.money { text-align: right; font-variant-numeric: tabular-nums; }
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
button { padding: 8px 14px; cursor: pointer; }
@media print { body { margin: 0; } .toolbar { display: none; } th { print-color-adjust: exact; } }
`, doc.head);
const toolbar = node("div", null, doc.body); toolbar.className = "toolbar";
node("button", "Print / Save PDF", toolbar).addEventListener("click", () => popup.print());
node("button", "Close", toolbar).addEventListener("click", () => popup.close());
node("h1", "Opportunities Extension", doc.body);
node("p", `Generated ${new Date().toLocaleString("en-US")} • ${snapshot.rows.length} opportunities`, doc.body).className = "meta";
const filters = node("div", null, doc.body); filters.className = "filters";
for (const [label, value] of snapshot.filters) {
const field = node("div", null, filters);
node("strong", `${label}:`, field); node("span", value || "None", field);
}
const totals = node("div", null, doc.body); totals.className = "totals";
for (const card of snapshot.totals) {
const section = node("section", null, totals);
node("span", card.label, section); node("strong", card.value, section);
if (card.detail) node("small", card.detail, section);
}
node("h2", "Opportunities", doc.body);
const table = node("table", null, doc.body);
const header = node("tr", null, node("thead", null, table));
for (const column of snapshot.columns) {
const th = node("th", column.label, header); th.scope = "col";
if (column.key === "revenue") th.className = "money";
}
const body = node("tbody", null, table);
for (const values of snapshot.rows) {
const row = node("tr", null, body);
values.forEach((value, index) => {
const td = node("td", value, row);
if (snapshot.columns[index].key === "revenue") td.className = "money";
});
}
if (!snapshot.rows.length) node("p", "No opportunities match the selected filters.", doc.body);
popup.focus();
// Print a frozen snapshot, without changing the extension's scroll or selected view.
environment.setTimeout(() => { if (!popup.closed) popup.print(); }, 150);
}
return { createButton, print };
}

View File

@@ -1,20 +1,22 @@
function createProductActivities({ productsContentState, opportunitiesQueryState, function createProductActivities({ productsContentState, opportunitiesQueryState,
loadCrm, loadActions, blockActions, render }) { loadCrm, loadActions, blockActions, render }) {
let cycle = null; let cycle = null;
function begin(requestId, token) { function begin(requestId, token, opportunityNumbers = null) {
cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false }; cycle = { requestId, token, numbers: opportunityNumbers && new Set(opportunityNumbers.map(String)), version: opportunitiesQueryState.periodRequestVersion, completed: false };
} }
function complete(requestId) { function complete(requestId) {
const current = cycle; const current = cycle;
if (!current || current.completed || current.requestId !== requestId if (!current || current.completed || current.requestId !== requestId
|| productsContentState.currentProductsRequestId !== requestId) return; || productsContentState.currentProductsRequestId !== requestId) return;
current.completed = true; current.completed = true;
const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error); 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; if (!products.length) return;
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0)); const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId
&& opportunitiesQueryState.periodRequestVersion === current.version; && opportunitiesQueryState.periodRequestVersion === current.version;
return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })).then(async success => { // 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 (!isActive()) return;
if (success === false) { if (success === false) {
products.forEach(blockActions); products.forEach(blockActions);
@@ -30,7 +32,7 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
seen.add(id); seen.add(id);
return true; return true;
}); });
pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true })); pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: false }));
} }
await Promise.allSettled(pending); await Promise.allSettled(pending);
if (!isActive()) return; if (!isActive()) return;

View File

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

View File

@@ -1,6 +1,6 @@
function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, resetRelatedData, function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, resetRelatedData,
opportunitiesQueryState, productsContentState, consumptionPlansState, consumptionTrendState, opportunitiesQueryState, productsContentState, consumptionPlansState, consumptionTrendState,
aiSummaryState, reload, onFailure, notify, createRefreshIcon, environment }) { reload, onFailure, notify, createRefreshIcon, environment }) {
let pending = null; let pending = null;
function refresh(period) { function refresh(period) {
if (pending) return pending; if (pending) return pending;
@@ -13,7 +13,6 @@ function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, res
consumptionTrendState.consumptionTrendCache = new Map(); consumptionTrendState.consumptionTrendCache = new Map();
consumptionTrendState.consumptionTrendWindowOffsets = new Map(); consumptionTrendState.consumptionTrendWindowOffsets = new Map();
consumptionTrendState.consumptionTrendSeriesFilters = new Map(); consumptionTrendState.consumptionTrendSeriesFilters = new Map();
aiSummaryState.aiSummariesByOptyNumber = new Map();
resetRelatedData(); resetRelatedData();
pending = (async () => { pending = (async () => {
await activityCache.invalidate(); await activityCache.invalidate();

View File

@@ -6,7 +6,10 @@ function createSearchableSelect({ environment }) {
if (text !== undefined) node.textContent = text; if (text !== undefined) node.textContent = text;
return node; return node;
} }
function searchableSelect(select, field, onChange) { function searchableSelect(select, field, onChange, { floating = false, tooltipContent, listWidth = 0, listMaxHeight = 180, search, searchDelay = 350 } = {}) {
let searchTimer, searchVersion = 0, disposed = false;
const schedule = environment.setTimeout || globalThis.setTimeout, cancel = environment.clearTimeout || globalThis.clearTimeout;
function cancelSearch() { searchVersion++; if (searchTimer) cancel(searchTimer); searchTimer = null; input.removeAttribute("aria-busy"); }
const box = element("div", "opportunities-extension-action-combobox"); const box = element("div", "opportunities-extension-action-combobox");
const input = element("input"); const input = element("input");
input.type = "text"; input.type = "text";
@@ -27,10 +30,48 @@ function createSearchableSelect({ environment }) {
select.tabIndex = -1; select.tabIndex = -1;
box.append(input, list); box.append(input, list);
field.append(box); 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; let matches = [], index = -1;
const options = () => Array.from(select.children).filter(item => item.value); const options = () => Array.from(select.children).filter(item => item.value);
const normalize = value => String(value).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase(); const normalize = value => String(value).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
function closeList() { function closeList() {
if (search) cancelSearch();
hideTooltip();
list.hidden = true; list.hidden = true;
input.setAttribute("aria-expanded", "false"); input.setAttribute("aria-expanded", "false");
input.setAttribute("aria-activedescendant", ""); input.setAttribute("aria-activedescendant", "");
@@ -40,12 +81,13 @@ function createSearchableSelect({ environment }) {
input.disabled = !!select.disabled; input.disabled = !!select.disabled;
input.placeholder = select.children[0]?.textContent || "Select"; input.placeholder = select.children[0]?.textContent || "Select";
input.value = options().find(item => item.value === select.value)?.textContent || ""; input.value = options().find(item => item.value === select.value)?.textContent || "";
input.title = input.value; input.title = tooltipContent ? "" : input.value;
} }
function highlight() { function highlight(withTooltip = true) {
Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index))); Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index)));
input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : ""); input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : "");
list.children[index]?.scrollIntoView?.({ block: "nearest" }); list.children[index]?.scrollIntoView?.({ block: "nearest" });
if (withTooltip && matches[index]) showTooltip(matches[index].value, list.children[index]);
} }
function choose(i) { function choose(i) {
if (!matches[i]) return; if (!matches[i]) return;
@@ -55,42 +97,92 @@ function createSearchableSelect({ environment }) {
if (changed) onChange?.(); if (changed) onChange?.();
} }
function positionList() { function positionList() {
const bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect(); let bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
const anchor = input.getBoundingClientRect?.(); const anchor = input.getBoundingClientRect?.();
if (!bounds || !anchor) return; 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 below = Math.max(0, bounds.bottom - anchor.bottom - 8);
const above = Math.max(0, anchor.top - bounds.top - 8); const above = Math.max(0, anchor.top - bounds.top - 8);
const opensAbove = below < 180 && above > below; const opensAbove = below < listMaxHeight && above > below;
list.setAttribute("data-placement", opensAbove ? "above" : "below"); list.setAttribute("data-placement", opensAbove ? "above" : "below");
list.style.maxHeight = `${Math.min(180, opensAbove ? above : below)}px`; 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 = "") { }
function render(query = "", statusText = "") {
if (input.disabled) return; if (input.disabled) return;
matches = options().filter(item => normalize(`${item.textContent} ${item.value}`).includes(normalize(query))); hideTooltip();
index = -1; matches = statusText ? [] : options().filter(item => search || normalize(`${item.textContent} ${item.getAttribute?.("data-description") || ""} ${item.value}`).includes(normalize(query)));
index = matches.findIndex(item => item.value === select.value);
list.textContent = ""; list.textContent = "";
matches.forEach((item, i) => { matches.forEach((item, i) => {
const row = element("div", "opportunities-extension-action-option", item.textContent); 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.id = `${list.id}-${i}`;
row.setAttribute("role", "option"); row.setAttribute("role", "option");
row.setAttribute("aria-selected", "false"); row.setAttribute("aria-selected", "false");
row.addEventListener("mousedown", event => event.preventDefault()); row.addEventListener("mousedown", event => event.preventDefault());
row.addEventListener("click", event => { event.preventDefault(); choose(i); }); row.addEventListener("click", event => { event.preventDefault(); choose(i); });
if (tooltip) {
row.addEventListener("mouseenter", () => showTooltip(item.value, row));
row.addEventListener("mouseleave", leaveTooltip);
}
list.append(row); list.append(row);
}); });
if (!matches.length) { if (!matches.length) {
const empty = element("div", "opportunities-extension-action-option", "No results"); const empty = element("div", "opportunities-extension-action-option", statusText || "No results");
empty.setAttribute("role", "status"); empty.setAttribute("role", "status");
list.append(empty); list.append(empty);
} }
positionList(); positionList();
list.hidden = false; list.hidden = false;
input.setAttribute("aria-expanded", "true"); input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-activedescendant", ""); highlight(false);
} }
input.addEventListener("click", () => { render(); input.select?.(); }); input.addEventListener("click", () => { render(); input.select?.(); });
input.addEventListener("input", () => render(input.value)); function searchInput(event) {
if (!search) { render(input.value); return; }
cancelSearch(); select.replaceChildren(select.children[0]); select.value = ""; onChange?.();
const query = input.value;
render("", query.trim() ? "Searching..." : "Type to search.");
if (!query.trim() || event?.isComposing) return;
const version = searchVersion;
const isCurrent = () => !disposed && version === searchVersion && input.isConnected && document.activeElement === input;
input.setAttribute("aria-busy", "true");
searchTimer = schedule(async () => {
if (!isCurrent()) return;
try {
const results = await search(query, isCurrent);
if (!isCurrent()) return;
const placeholder = select.children[0]; select.replaceChildren(placeholder);
for (const result of results) {
const option = element("option", "", result.label); option.value = result.value;
if (result.description) option.setAttribute("data-description", result.description);
select.append(option);
}
select.value = ""; render();
} catch (error) { if (isCurrent()) render("", (error.message || "Unable to search contacts.") + " Type to retry."); }
finally { if (isCurrent()) input.removeAttribute("aria-busy"); }
}, searchDelay);
}
input.addEventListener("input", searchInput);
if (search) input.addEventListener("compositionend", searchInput);
input.addEventListener("blur", sync); input.addEventListener("blur", sync);
if (tooltip) {
input.addEventListener("mouseenter", () => { if (list.hidden) showTooltip(select.value, input); });
input.addEventListener("mouseleave", leaveTooltip);
}
input.addEventListener("keydown", event => { input.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") { if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault(); event.preventDefault();
@@ -100,14 +192,14 @@ function createSearchableSelect({ environment }) {
} else if (event.key === "Enter" && !list.hidden) { } else if (event.key === "Enter" && !list.hidden) {
event.preventDefault(); event.preventDefault();
choose(index < 0 && matches.length === 1 ? 0 : index); choose(index < 0 && matches.length === 1 ? 0 : index);
} else if (event.key === "Escape" && !list.hidden) { } else if (event.key === "Escape" && (!list.hidden || (tooltip && !tooltip.hidden))) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
sync(); sync();
} else if (event.key === "Tab") sync(); } else if (event.key === "Tab") sync();
}); });
sync(); sync();
return { input, sync }; return { input, sync, destroy() { disposed = true; closeList(); } };
} }
return searchableSelect; return searchableSelect;
} }

View File

@@ -1,21 +1,30 @@
function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, sendRequest, reportProgress, appendLog }) { function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, sendRequest, reportProgress, appendLog, onReady = async () => {} }) {
let pending = null; let pending = null;
let failed = false;
function ensure() { function ensure() {
if (pending) return pending; if (pending) return pending;
pending = (async () => { pending = (async () => {
failed = false;
try { try {
await ensureCurrentUser();
if (!allowsCrmActivities()) return;
reportProgress("running"); reportProgress("running");
const result = await sendRequest(); // Navigation establishes SR cookies independently of the SPA identity.
// Consume user-dependent data only once both preparations have finished.
const [, result] = await Promise.all([ensureCurrentUser(), sendRequest()]);
appendLog(result?.debugRequests || []); appendLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Session initialization failed."); if (!result?.ok) throw new Error(result?.error || "Session initialization failed.");
if (!allowsCrmActivities()) { reportProgress("idle"); return; }
reportProgress("complete"); reportProgress("complete");
await onReady();
} catch { } catch {
failed = true;
reportProgress("error"); reportProgress("error");
} }
})(); })();
return pending; return pending;
} }
return { ensure }; function retry() {
if (failed) pending = null;
return ensure();
}
return { ensure, retry };
} }

View File

@@ -408,10 +408,18 @@
/* Keep layout rules on the outer table, never on expanded product tables. */ /* Keep layout rules on the outer table, never on expanded product tables. */
.opportunities-extension-table[data-opportunities-layout="inline-actions"] { .opportunities-extension-table[data-opportunities-layout="inline-actions"] {
width: 100%; width: 100%;
min-width: 1120px; min-width: 1168px;
table-layout: fixed; table-layout: fixed;
} }
.opportunities-extension-table-skeleton-row { height: 76px; }
.opportunities-extension-table-skeleton-text { width: 85%; max-width: 100%; }
.opportunities-extension-table-skeleton-row:nth-of-type(even) .opportunities-extension-table-skeleton-text { width: 70%; }
.opportunities-extension-table-skeleton-badge { flex: 0 1 30px; width: 30px; min-width: 0; height: 16px; }
.opportunities-extension-table-skeleton-row > td[data-column="customer"] .opportunities-extension-table-skeleton-badge { flex-basis: 18px; width: 18px; }
.opportunities-extension-table-skeleton-row > td[data-column="status"] .opportunities-extension-table-skeleton-text,
.opportunities-extension-table-skeleton-row > td[data-column="stage"] .opportunities-extension-table-skeleton-text { height: 20px; max-width: 66px; border-radius: 8px; }
/* Fixed table layout shares leftover width only between the two auto columns. */ /* 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="name"] { width: auto; }
.opportunities-extension-table > thead > tr > th[data-column="customer"] { width: auto; } .opportunities-extension-table > thead > tr > th[data-column="customer"] { width: auto; }
@@ -486,7 +494,7 @@
} }
.opportunities-extension-table > tbody > tr > td[data-column="customer"] .opportunities-extension-cell-actions { .opportunities-extension-table > tbody > tr > td[data-column="customer"] .opportunities-extension-cell-actions {
min-width: 84px; min-width: 114px;
} }
.opportunities-extension-cell-actions .opportunities-extension-action-button svg { .opportunities-extension-cell-actions .opportunities-extension-action-button svg {
@@ -615,3 +623,11 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: none; } .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%); }
.opportunities-extension-session-retry {
margin-left: 8px; padding: 2px 6px; border: 1px solid currentColor;
border-radius: 4px; background: transparent; color: inherit; font: inherit; cursor: pointer;
}
.opportunities-extension-session-retry:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-session-retry:disabled { opacity: .6; cursor: wait; }

View File

@@ -1,4 +1,5 @@
function createOpportunitiesTable({ function createOpportunitiesTable({
tableSorting,
ACCOUNT_DETAIL_URL, ACCOUNT_DETAIL_URL,
OPPORTUNITIES_COLUMNS, OPPORTUNITIES_COLUMNS,
OPPORTUNITIES_TABLE_ID, OPPORTUNITIES_TABLE_ID,
@@ -6,10 +7,12 @@ function createOpportunitiesTable({
createCloudConsumptionButton, createCloudConsumptionButton,
createInstallBaseButton, createInstallBaseButton,
createServiceRequestsButton, createServiceRequestsButton,
createOrganizationChartButton,
createCopyOpportunityButton, createCopyOpportunityButton,
createDetailLink, createDetailLink,
createProductsCountButton, createProductsCountButton,
createProductsRampBadge, createProductsRampBadge,
createProjectionIndicator,
createCrmServiceRequestBadge, createCrmServiceRequestBadge,
closeCrmSrPopover, closeCrmSrPopover,
createWorkloadActionsBadge, createWorkloadActionsBadge,
@@ -25,6 +28,7 @@ function createOpportunitiesTable({
renderOwnerFilter, renderOwnerFilter,
renderActivityFilters, renderActivityFilters,
renderStageDashboard, renderStageDashboard,
renderCalendar,
resetCustomerFilter, resetCustomerFilter,
resetOwnerFilter, resetOwnerFilter,
environment environment
@@ -39,8 +43,8 @@ function createOpportunitiesTable({
opportunitiesTableState.opportunitiesTableState = { opportunitiesTableState.opportunitiesTableState = {
...opportunitiesTableState.opportunitiesTableState, ...opportunitiesTableState.opportunitiesTableState,
...nextState, ...nextState,
sortKey: nextState.items ? "" : opportunitiesTableState.opportunitiesTableState.sortKey, sortKey: tableSorting.read("overview", OPPORTUNITIES_COLUMNS).key,
sortDirection: nextState.items ? "ascending" : opportunitiesTableState.opportunitiesTableState.sortDirection sortDirection: tableSorting.read("overview", OPPORTUNITIES_COLUMNS).direction
}; };
renderOpportunitiesTable(); renderOpportunitiesTable();
renderCustomerFilter(); renderCustomerFilter();
@@ -62,7 +66,7 @@ function createOpportunitiesTable({
const caption = document.createElement("caption"); const caption = document.createElement("caption");
caption.className = "opportunities-extension-visually-hidden"; caption.className = "opportunities-extension-visually-hidden";
caption.textContent = "Opportunities"; caption.textContent = opportunitiesTableState.opportunitiesTableState.status === "loading" ? "Loading opportunities..." : "Opportunities";
const tableHead = document.createElement("thead"); const tableHead = document.createElement("thead");
const headerRow = document.createElement("tr"); const headerRow = document.createElement("tr");
@@ -72,6 +76,7 @@ function createOpportunitiesTable({
const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key; const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key;
header.scope = "col"; header.scope = "col";
header.setAttribute("data-column", column.key); header.setAttribute("data-column", column.key);
if (column.key === "revenue") header.setAttribute("data-align", "money");
if (column.sortable === false) { if (column.sortable === false) {
const label = document.createElement("span"); const label = document.createElement("span");
@@ -83,6 +88,7 @@ function createOpportunitiesTable({
const sortButton = document.createElement("button"); const sortButton = document.createElement("button");
sortButton.type = "button"; sortButton.type = "button";
sortButton.disabled = opportunitiesTableState.opportunitiesTableState.status === "loading";
sortButton.className = "opportunities-extension-sort-button"; sortButton.className = "opportunities-extension-sort-button";
sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none"); sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`); sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`);
@@ -102,8 +108,35 @@ function createOpportunitiesTable({
const tableBody = document.createElement("tbody"); const tableBody = document.createElement("tbody");
const items = getSortedOpportunities(); const items = getSortedOpportunities();
renderStageDashboard(items, opportunitiesTableState.opportunitiesTableState.items.length); 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)) { if (opportunitiesTableState.opportunitiesTableState.status === "loading") {
for (let index = 0; index < 8; index++) {
const row = document.createElement("tr");
row.className = "opportunities-extension-table-skeleton-row";
row.setAttribute("aria-hidden", "true");
OPPORTUNITIES_COLUMNS.forEach(column => {
const cell = document.createElement("td");
cell.setAttribute("data-column", column.key);
if (column.key === "revenue") cell.setAttribute("data-align", "money");
const bar = document.createElement("span");
bar.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-table-skeleton-text";
cell.append(bar);
if (column.key === "name" || column.key === "customer") {
const actions = document.createElement("span");
actions.className = "opportunities-extension-cell-actions";
for (let action = 0; action < 4; action++) {
const badge = document.createElement("span");
badge.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-table-skeleton-badge";
actions.append(badge);
}
cell.append(actions);
}
row.append(cell);
});
tableBody.append(row);
}
} else if (opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
const row = document.createElement("tr"); const row = document.createElement("tr");
const cell = document.createElement("td"); const cell = document.createElement("td");
cell.className = "opportunities-extension-table-status"; cell.className = "opportunities-extension-table-status";
@@ -118,6 +151,7 @@ function createOpportunitiesTable({
OPPORTUNITIES_COLUMNS.forEach((column) => { OPPORTUNITIES_COLUMNS.forEach((column) => {
const cell = document.createElement("td"); const cell = document.createElement("td");
cell.setAttribute("data-column", column.key); cell.setAttribute("data-column", column.key);
if (column.key === "revenue") cell.setAttribute("data-align", "money");
if (column.key === "stage") { if (column.key === "stage") {
cell.append(createStageBadge(column.display(item))); cell.append(createStageBadge(column.display(item)));
@@ -137,9 +171,12 @@ function createOpportunitiesTable({
const productsResult = item.OptyNumber && productsContentState.opptyProductsByNumber.get(String(item.OptyNumber)); const productsResult = item.OptyNumber && productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
if (productsResult) { if (productsResult) {
productActions.append(createProductsCountButton(item.OptyNumber, productsResult)); productActions.append(createProductsCountButton(item.OptyNumber, productsResult));
const rampBadge = createProductsRampBadge(productsResult); const rampBadge = createProductsRampBadge(productsResult, item);
if (rampBadge) productActions.append(rampBadge); if (rampBadge) productActions.append(rampBadge);
} }
const projectionIndicator = createProjectionIndicator(productsResult, item,
productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber)));
if (projectionIndicator) productActions.append(projectionIndicator);
const srBadge = createCrmServiceRequestBadge(item.OptyNumber); const srBadge = createCrmServiceRequestBadge(item.OptyNumber);
if (srBadge) productActions.append(srBadge); if (srBadge) productActions.append(srBadge);
const actionsBadge = createWorkloadActionsBadge(item.OptyNumber); const actionsBadge = createWorkloadActionsBadge(item.OptyNumber);
@@ -165,7 +202,7 @@ function createOpportunitiesTable({
); );
const customerActions = document.createElement("span"); const customerActions = document.createElement("span");
customerActions.className = "opportunities-extension-cell-actions"; customerActions.className = "opportunities-extension-cell-actions";
customerActions.append(createCloudConsumptionButton(item), createInstallBaseButton(item), createServiceRequestsButton(item)); customerActions.append(createCloudConsumptionButton(item), createInstallBaseButton(item), createServiceRequestsButton(item), createOrganizationChartButton(item));
customerCellContent.append(customerLink, customerActions); customerCellContent.append(customerLink, customerActions);
cell.append(customerCellContent); cell.append(customerCellContent);
} else { } else {
@@ -187,11 +224,13 @@ function createOpportunitiesTable({
} }
function sortOpportunitiesBy(key) { function sortOpportunitiesBy(key) {
if (!OPPORTUNITIES_COLUMNS.some(column => column.key === key && column.sortable !== false)) return;
const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key; const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key;
opportunitiesTableState.opportunitiesTableState.sortKey = key; opportunitiesTableState.opportunitiesTableState.sortKey = key;
opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending" opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending"
? "descending" ? "descending"
: "ascending"; : "ascending";
tableSorting.save("overview", { key, direction: opportunitiesTableState.opportunitiesTableState.sortDirection });
renderOpportunitiesTable(); renderOpportunitiesTable();
} }

View File

@@ -52,6 +52,7 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
for (const id of idsFor(products)) { for (const id of idsFor(products)) {
session.records.set(id, { status: "error", actions: [], hasNext: false, error: "CRM service requests unavailable." }); session.records.set(id, { status: "error", actions: [], hasNext: false, error: "CRM service requests unavailable." });
} }
updateProgress(session);
} }
function pump(current) { function pump(current) {
while (current.enabled && current === session && current.running < 4 && current.queue.length) { while (current.enabled && current === session && current.running < 4 && current.queue.length) {
@@ -154,12 +155,14 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
badge.setAttribute("data-state", state); badge.setAttribute("data-state", state);
setBadgeContent(badge, "actions", state === "matched" || state === "missing" ? actions.length : state === "loading" ? "..." : "?"); setBadgeContent(badge, "actions", state === "matched" || state === "missing" ? actions.length : state === "loading" ? "..." : "?");
badge.title = actions.length ? actions.map(action => [ badge.title = actions.length ? actions.map(action => [
`Action: ${action.name ?? "N/D"}`, `ID: ${action.id ?? "N/D"}`, `Status: ${action.status ?? "N/D"}`, `Action: ${action.name ?? "N/A"}`, `ID: ${action.id ?? "N/A"}`, `Status: ${action.status ?? "N/A"}`,
`Owner: ${action.owner || "N/D"}`, `Email: ${action.ownerEmail || "N/D"}`, `Workload: ${action.workloadName || "N/D"}`, `Owner: ${action.owner || "N/A"}`, `Email: ${action.ownerEmail || "N/A"}`, `Workload: ${action.workloadName || "N/A"}`,
`Description: ${action.workloadDescription || "N/D"}`, `Start: ${action.startDate || "N/D"}`, `End: ${action.endDate || "N/D"}` `Description: ${action.workloadDescription || "N/A"}`, `Start: ${action.startDate || "N/A"}`, `End: ${action.endDate || "N/A"}`
].join("\n")).join("\n\n") : state === "missing" ? "No actions found for this opportunity's products." ].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."; : state === "loading" ? "Loading workload actions..." : "Unable to confirm workload actions. Check product RevnId and Debug Requests.";
if (records.some(record => record?.hasNext)) badge.title += "\nAdditional actions exist; pagination is not enabled."; if (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."; 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."; 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."; if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked.";

View File

@@ -0,0 +1,3 @@
// Calling codes: Google libphonenumber, Apache-2.0. Retrieved 2026-10-26.
// https://github.com/google/libphonenumber/blob/master/resources/PhoneNumberMetadata.xml
const orgChartCallingCodes = [["AC","247"],["AD","376"],["AE","971"],["AF","93"],["AG","1"],["AI","1"],["AL","355"],["AM","374"],["AO","244"],["AR","54"],["AS","1"],["AT","43"],["AU","61"],["AW","297"],["AX","358"],["AZ","994"],["BA","387"],["BB","1"],["BD","880"],["BE","32"],["BF","226"],["BG","359"],["BH","973"],["BI","257"],["BJ","229"],["BL","590"],["BM","1"],["BN","673"],["BO","591"],["BQ","599"],["BR","55"],["BS","1"],["BT","975"],["BW","267"],["BY","375"],["BZ","501"],["CA","1"],["CC","61"],["CD","243"],["CF","236"],["CG","242"],["CH","41"],["CI","225"],["CK","682"],["CL","56"],["CM","237"],["CN","86"],["CO","57"],["CR","506"],["CU","53"],["CV","238"],["CW","599"],["CX","61"],["CY","357"],["CZ","420"],["DE","49"],["DJ","253"],["DK","45"],["DM","1"],["DO","1"],["DZ","213"],["EC","593"],["EE","372"],["EG","20"],["EH","212"],["ER","291"],["ES","34"],["ET","251"],["FI","358"],["FJ","679"],["FK","500"],["FM","691"],["FO","298"],["FR","33"],["GA","241"],["GB","44"],["GD","1"],["GE","995"],["GF","594"],["GG","44"],["GH","233"],["GI","350"],["GL","299"],["GM","220"],["GN","224"],["GP","590"],["GQ","240"],["GR","30"],["GT","502"],["GU","1"],["GW","245"],["GY","592"],["HK","852"],["HN","504"],["HR","385"],["HT","509"],["HU","36"],["ID","62"],["IE","353"],["IL","972"],["IM","44"],["IN","91"],["IO","246"],["IQ","964"],["IR","98"],["IS","354"],["IT","39"],["JE","44"],["JM","1"],["JO","962"],["JP","81"],["KE","254"],["KG","996"],["KH","855"],["KI","686"],["KM","269"],["KN","1"],["KP","850"],["KR","82"],["KW","965"],["KY","1"],["KZ","7"],["LA","856"],["LB","961"],["LC","1"],["LI","423"],["LK","94"],["LR","231"],["LS","266"],["LT","370"],["LU","352"],["LV","371"],["LY","218"],["MA","212"],["MC","377"],["MD","373"],["ME","382"],["MF","590"],["MG","261"],["MH","692"],["MK","389"],["ML","223"],["MM","95"],["MN","976"],["MO","853"],["MP","1"],["MQ","596"],["MR","222"],["MS","1"],["MT","356"],["MU","230"],["MV","960"],["MW","265"],["MX","52"],["MY","60"],["MZ","258"],["NA","264"],["NC","687"],["NE","227"],["NF","672"],["NG","234"],["NI","505"],["NL","31"],["NO","47"],["NP","977"],["NR","674"],["NU","683"],["NZ","64"],["OM","968"],["PA","507"],["PE","51"],["PF","689"],["PG","675"],["PH","63"],["PK","92"],["PL","48"],["PM","508"],["PR","1"],["PS","970"],["PT","351"],["PW","680"],["PY","595"],["QA","974"],["RE","262"],["RO","40"],["RS","381"],["RU","7"],["RW","250"],["SA","966"],["SB","677"],["SC","248"],["SD","249"],["SE","46"],["SG","65"],["SH","290"],["SI","386"],["SJ","47"],["SK","421"],["SL","232"],["SM","378"],["SN","221"],["SO","252"],["SR","597"],["SS","211"],["ST","239"],["SV","503"],["SX","1"],["SY","963"],["SZ","268"],["TA","290"],["TC","1"],["TD","235"],["TG","228"],["TH","66"],["TJ","992"],["TK","690"],["TL","670"],["TM","993"],["TN","216"],["TO","676"],["TR","90"],["TT","1"],["TV","688"],["TW","886"],["TZ","255"],["UA","380"],["UG","256"],["US","1"],["UY","598"],["UZ","998"],["VA","39"],["VC","1"],["VE","58"],["VG","1"],["VI","1"],["VN","84"],["VU","678"],["WF","681"],["WS","685"],["XK","383"],["YE","967"],["YT","262"],["ZA","27"],["ZM","260"],["ZW","263"]];

View File

@@ -0,0 +1,28 @@
function createOrgChartIcons({ environment }) {
// Lucide geometry; license included in src/vendor/lucide/LICENSE.
const paths = {
opportunities: ["M3 7h18v14H3Z", "M8 7V3h8v4", "M3 12h18", "M10 12v3h4v-3"],
contact: ["M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2", "M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0", "M20 8v6", "M17 11h6"],
chart: ["M12 8v4", "M12 12H5v4", "M12 12h7v4"],
plus: ["M12 5v14", "M5 12h14"],
close: ["m6 6 12 12", "M18 6 6 18"], check: ["m9 12 2 2 4-4"],
down: ["m6 9 6 6 6-6"], up: ["m18 15-6-6-6 6"],
expand: ["m7 10 5-5 5 5", "m7 14 5 5 5-5"], collapse: ["m7 4 5 5 5-5", "m7 20 5-5 5 5"],
minus: ["M5 12h14"], fit: ["M8 3H5a2 2 0 0 0-2 2v3", "M16 3h3a2 2 0 0 1 2 2v3", "M21 16v3a2 2 0 0 1-2 2h-3", "M8 21H5a2 2 0 0 1-2-2v-3"],
save: ["M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12l4 4v12a2 2 0 0 1-2 2", "M7 21v-8h10v8", "M7 3v6h10V3"],
trash: ["M3 6h18", "M19 6v14H5V6", "M10 11v6", "M14 11v6", "M9 6V3h6v3"],
note: ["M12 3H5v18h14V10Z", "M12 3v7h7", "M8 15h8", "M8 18h5"]
};
function icon(name) {
const svg = environment.document.createElementNS("http://www.w3.org/2000/svg", "svg");
for (const [key, value] of Object.entries({ viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "1.8", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", focusable: "false" })) svg.setAttribute(key, value);
for (const d of paths[name] || paths.chart) { const path = environment.document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", d); svg.append(path); }
if (name === "chart") for (const [x, y] of [[8, 2], [1, 16], [15, 16]]) {
const rect = environment.document.createElementNS("http://www.w3.org/2000/svg", "rect");
for (const [key, value] of Object.entries({ x, y, width: 8, height: 6, rx: 1 })) rect.setAttribute(key, value);
svg.append(rect);
}
return svg;
}
return { icon };
}

View File

@@ -0,0 +1,63 @@
function createOrgChartModel() {
function normalize(nodes) {
if (!Array.isArray(nodes) || nodes.length > 500) throw new Error("An organization chart supports up to 500 people.");
const ids = new Set();
const result = nodes.map(node => {
if (!node || typeof node.id !== "string" || !/^[\w-]{1,80}$/.test(node.id) || ids.has(node.id)) throw new Error("Invalid or duplicate person ID.");
ids.add(node.id);
const item = { id: node.id, parentId: node.parentId == null ? null : node.parentId, collapsed: node.collapsed === true };
const jobTitle = node.jobTitle ?? node.role ?? "";
if (typeof jobTitle !== "string" || jobTitle.length > 160) throw new Error("Invalid Job Title.");
item.jobTitle = jobTitle.trim();
if (node.PartyNumber != null && node.PartyNumber !== "") {
if (!/^[A-Za-z0-9]{1,100}$/.test(String(node.PartyNumber))) throw new Error("Invalid contact PartyNumber.");
item.PartyNumber = String(node.PartyNumber);
}
if (node.contactPartyId != null) {
if (typeof node.contactPartyId !== "string" || !/^[\w-]{1,100}$/.test(node.contactPartyId)) throw new Error("Invalid contact ID.");
item.contactPartyId = node.contactPartyId;
}
for (const [key, max] of [["name", 160], ["notes", 4000]]) {
if (typeof node[key] !== "string" || node[key].length > max || (key === "name" && !node[key].trim())) throw new Error(`Enter a valid ${key} (up to ${max} characters).`);
item[key] = key === "notes" ? node[key] : node[key].trim();
}
return item;
});
const byId = new Map(result.map(node => [node.id, node]));
for (const node of result) {
const seen = new Set([node.id]);
let parent = node.parentId;
while (parent !== null) {
if (!byId.has(parent) || seen.has(parent)) throw new Error("The reporting structure contains a missing manager or a cycle.");
seen.add(parent);
if (seen.size > 60) throw new Error("The reporting structure supports up to 60 levels.");
parent = byId.get(parent).parentId;
}
}
return result;
}
function descendants(nodes, id) {
const found = new Set([id]);
let changed = true;
while (changed) {
changed = false;
for (const node of nodes) if (found.has(node.parentId) && !found.has(node.id)) { found.add(node.id); changed = true; }
}
return found;
}
function upsert(nodes, person) {
return normalize(nodes.some(node => node.id === person.id) ? nodes.map(node => node.id === person.id ? person : node) : [...nodes, person]);
}
function remove(nodes, id) {
const person = nodes.find(node => node.id === id);
if (!person) return nodes;
return normalize(nodes.filter(node => node.id !== id).map(node => node.parentId === id ? { ...node, parentId: person.parentId } : node));
}
function hierarchy(nodes) {
const root = { id: "", children: [] }, byId = new Map([[null, root]]);
nodes.forEach(node => byId.set(node.id, { ...node, children: [] }));
nodes.forEach(node => { if (!byId.get(node.parentId).collapsed) byId.get(node.parentId).children.push(byId.get(node.id)); });
return root;
}
return { normalize, descendants, upsert, remove, hierarchy };
}

View File

@@ -0,0 +1,68 @@
function createOrgChartStorage({ environment, model }) {
let opening = null;
function database() {
if (opening) return opening;
opening = new Promise((resolve, reject) => {
if (!environment.indexedDB) { reject(new Error("IndexedDB is unavailable. The organization chart cannot be saved locally.")); return; }
const request = environment.indexedDB.open("opportunities-extension-organizations", 3);
let blocked = false;
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains("charts")) request.result.createObjectStore("charts", { keyPath: "key" });
else {
const cursor = request.transaction.objectStore("charts").openCursor();
cursor.onsuccess = () => {
const current = cursor.result;
if (!current) return;
const record = current.value;
if (Array.isArray(record.nodes)) {
record.nodes = record.nodes.map(node => {
const clean = { ...node, jobTitle: node.jobTitle ?? node.role ?? "" }; delete clean.role; delete clean.emailAddress; return clean;
});
current.update(record);
}
current.continue();
};
}
};
request.onerror = () => reject(new Error("Unable to open local organization charts."));
request.onblocked = () => { blocked = true; reject(new Error("Local storage is blocked by another window. Close it and retry.")); };
request.onsuccess = () => {
const db = request.result;
if (blocked) { db.close(); return; }
db.onversionchange = () => { db.close(); opening = null; };
resolve(db);
};
}).catch(error => { opening = null; throw error; });
return opening;
}
function identity({ principal, customerId }) {
if (typeof principal !== "string" || !principal.trim() || principal.length > 254 || typeof customerId !== "string" || !/^[\w-]{1,100}$/.test(customerId)) throw new Error("A current user and customer ID are required.");
return JSON.stringify([principal.trim().toLowerCase(), customerId]);
}
async function transact(message, write) {
const key = identity(message);
const nodes = write ? model.normalize(message.nodes) : null;
if (write && (!Number.isSafeInteger(message.revision) || message.revision < 0)) throw new Error("Invalid organization chart revision.");
const db = await database();
return new Promise((resolve, reject) => {
const transaction = db.transaction("charts", write ? "readwrite" : "readonly");
const store = transaction.objectStore("charts");
const request = store.get(key);
let result, failure;
request.onsuccess = () => {
try {
const existing = request.result;
if (existing && existing.schemaVersion !== 1) throw new Error("Unsupported organization chart version.");
if (write) {
if ((existing?.revision || 0) !== message.revision) throw new Error("This chart changed in another window. Reopen it before saving your changes.");
result = { key, schemaVersion: 1, revision: message.revision + 1, nodes, updatedAt: new Date().toISOString() };
store.put(result);
} else result = existing ? { ...existing, nodes: model.normalize(existing.nodes) } : { schemaVersion: 1, revision: 0, nodes: [], updatedAt: null };
} catch (error) { failure = error; transaction.abort(); }
};
transaction.oncomplete = () => resolve({ ok: true, chart: result });
transaction.onabort = transaction.onerror = () => reject(failure || new Error("Unable to save or read the organization chart. Check available browser storage and retry."));
});
}
return { load: message => transact(message, false), save: message => transact(message, true) };
}

View File

@@ -0,0 +1,104 @@
.oe-org-modal { grid-template-columns: minmax(0, 1fr); }
.oe-org-dialog { --org-bg: #fff; --org-canvas: #faf9f8; --org-border: #dedbd7; --org-muted: #625d58; --org-accent: #00758f; --org-selected: #e3f1f4; width: min(1500px, 100%); height: min(880px, calc(100dvh - 40px)); max-height: calc(100dvh - 40px); display: flex; flex-direction: column; overflow: hidden; font-size: 13px; color: #312d2a; background: var(--org-bg); }
.opportunities-extension-modal[data-theme="dark"] .oe-org-dialog { --org-bg: #292725; --org-canvas: #211f1d; --org-border: #625d58; --org-muted: #c9c3bd; --org-accent: #80d6e8; --org-selected: #203f46; color: #f5f4f2; }
.oe-org-modal [hidden] { display: none !important; }
.oe-org-dialog * { box-sizing: border-box; letter-spacing: 0; }
.oe-org-dialog > header, .oe-org-toolbar, .oe-org-footer { flex: none; }
.oe-org-heading { min-width: 0; overflow-wrap: anywhere; }
.oe-org-heading p { margin: 5px 0 0; font-size: 12px; color: var(--org-muted); }
.oe-org-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--org-border); }
.oe-org-tools { display: flex; align-items: center; gap: 6px; margin-left: auto; }
.oe-org-count { color: var(--org-muted); font-variant-numeric: tabular-nums; }
.oe-org-zoom { width: 44px; text-align: center; font-variant-numeric: tabular-nums; }
.oe-org-modal .oe-org-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 34px; min-height: 34px; padding: 6px 9px; border: 1px solid var(--org-border, #8f8983); border-radius: 4px; background: transparent; color: inherit; font: inherit; cursor: pointer; }
.oe-org-modal svg { width: 17px; height: 17px; flex: none; }
.oe-org-dialog button:hover:not(:disabled) { background: var(--org-selected); }
.oe-org-dialog :focus-visible { outline: 2px solid var(--org-accent); outline-offset: 2px; }
.oe-org-modal .oe-org-primary { background: #006879; border-color: #006879; color: #fff; }
.oe-org-dialog .oe-org-primary:hover:not(:disabled) { background: #005563; }
.oe-org-dialog :disabled { opacity: .5; cursor: not-allowed; }
.oe-org-workspace { min-height: 0; min-width: 0; flex: 1; display: flex; overflow: hidden; }
.oe-org-viewport { position: relative; min-width: 0; min-height: 0; flex: 1; overflow: auto; background: var(--org-canvas); overscroll-behavior: contain; cursor: grab; }
.oe-org-sizing { position: relative; margin-inline: auto; }
.oe-org-canvas { position: relative; transform-origin: top left; }
.oe-org-canvas .oe-org-lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.oe-org-lines path { stroke: var(--org-border); stroke-width: 2; fill: none; }
.oe-org-node { position: absolute; width: 236px; height: 146px; border: 1px solid var(--org-border); border-top: 3px solid var(--org-accent); border-radius: 4px; background: var(--org-bg); display: flex; flex-direction: column; cursor: default; }
.oe-org-node[data-selected="true"] { outline: 2px solid var(--org-accent); outline-offset: 2px; }
.oe-org-person { flex: 1; min-height: 0; display: flex; flex-direction: column; justify-content: center; gap: 6px; text-align: left; padding: 9px 12px; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; border-radius: 0; width: 100%; }
.oe-org-person strong, .oe-org-person > span { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; line-height: 18px; }
.oe-org-person > span { color: var(--org-muted); font-size: 12px; }
.oe-org-node-actions { flex: none; display: flex; align-items: center; gap: 4px; padding: 4px 8px; height: 38px; border-top: 1px solid var(--org-border); }
.oe-org-node-actions .oe-org-button { min-height: 28px; height: 28px; min-width: 28px; padding: 3px; border: 0; }
.oe-org-node-actions [aria-expanded] { margin-left: auto; font-size: 11px; }
.oe-org-note-marker { display: inline-flex; color: var(--org-muted); margin-left: auto; }
.oe-org-editor { flex: 0 0 310px; width: 310px; padding: 16px; border-left: 1px solid var(--org-border); overflow-y: auto; background: var(--org-bg); }
.oe-org-editor-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 16px; }
.oe-org-editor h3 { margin: 0; font-size: 15px; }
.oe-org-form { display: grid; gap: 16px; }
.oe-org-field { display: grid; gap: 6px; font-size: 12px; font-weight: 600; min-width: 0; }
.oe-org-dialog input, .oe-org-dialog textarea { min-width: 0; width: 100%; font: inherit; font-weight: 400; line-height: 20px; color: inherit; background: var(--org-bg); border: 1px solid var(--org-muted); border-radius: 3px; padding: 7px 8px; }
.oe-org-dialog textarea { resize: vertical; min-height: 100px; max-height: 300px; }
.oe-org-form-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.oe-contact-name { position: relative; }
.oe-org-dialog .oe-contact-name > input { padding-right: 42px; }
.oe-org-modal .oe-contact-name > .oe-org-button { position: absolute; right: 2px; top: 2px; min-height: 30px; height: 30px; border: 0; }
.oe-org-dialog.oe-contact-dialog { width: min(680px, 100%); height: auto; padding: 20px; gap: 20px; overflow-y: auto; }
.oe-contact-dialog > header { margin: 0; }
.oe-org-dialog.oe-contact-opportunities { width: min(1200px, 100%); height: auto; padding: 20px; gap: 16px; }
.oe-contact-opportunities > header { margin: 0; }
.oe-contact-opportunities-table { overflow: auto; min-height: 0; }
.oe-contact-opportunities table { width: 100%; border-collapse: collapse; font-size: 12px; }
.oe-contact-opportunities th, .oe-contact-opportunities td { padding: 12px; text-align: left; border-bottom: 1px solid var(--org-border); }
.oe-contact-opportunities th { background: var(--org-canvas); white-space: nowrap; }
.oe-contact-opportunities .opportunities-extension-sort-button { white-space: nowrap; min-width: max-content; min-height: 28px; padding: 4px 18px 4px 0; justify-content: flex-start; text-align: left; }
.oe-contact-opportunities .opportunities-extension-sort-button::after { right: 0; }
.oe-contact-opportunities th:nth-child(5), .oe-contact-opportunities td:nth-child(5) { text-align: right; font-variant-numeric: tabular-nums; }
.oe-contact-opportunities th:nth-child(5) .opportunities-extension-sort-button { padding: 4px 0 4px 18px; justify-content: flex-end; text-align: right; }
.oe-contact-opportunities th:nth-child(5) .opportunities-extension-sort-button::after { left: 0; right: auto; }
.oe-contact-opportunities td:nth-child(5) .oe-contact-skeleton { margin-left: auto; }
.oe-contact-opportunities .opportunities-extension-sort-label { white-space: nowrap; overflow-wrap: normal; word-break: normal; }
.oe-contact-opportunities table { min-width: 900px; }
.oe-contact-opportunities a { color: var(--org-accent); }
.oe-contact-skeleton { display: block; height: 14px; min-width: 24px; border-radius: 4px; background: var(--org-border); animation: oe-time-pulse 1.3s ease-in-out infinite alternate; }
.oe-contact-skeleton-total { width: 110px; }
.oe-contact-opportunities table[aria-busy="true"] { table-layout: fixed; }
.oe-contact-opportunities table[aria-busy="true"] th:nth-child(3) { width: 28%; }
@media (prefers-reduced-motion: reduce) { .oe-contact-skeleton { animation: none; } }
.oe-contact-dialog [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
.opportunities-extension-modal[data-theme="dark"] .oe-contact-dialog [aria-invalid="true"] { border-color: #ffaaa4; outline-color: #ffaaa4; }
.oe-contact-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.oe-contact-phone { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; border: 1px solid var(--org-border); border-radius: 4px; padding: 12px; min-width: 0; margin: 0; }
.oe-contact-form legend { font-weight: 600; padding: 0 4px; }
.oe-contact-primary { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 20px; border: 0; padding: 0; margin: 0; }
.oe-contact-primary label { display: inline-flex; align-items: center; gap: 6px; margin-top: 8px; }
.oe-contact-primary input { width: auto; accent-color: var(--org-accent); }
@media (max-width: 520px) { .oe-contact-form, .oe-contact-phone { grid-template-columns: minmax(0, 1fr); } }
.oe-org-dialog .oe-org-danger, .oe-org-error { color: #b3261e; }
.opportunities-extension-modal[data-theme="dark"] .oe-org-dialog .oe-org-danger, .opportunities-extension-modal[data-theme="dark"] .oe-org-error { color: #ffaaa4; }
.oe-org-footer { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--org-border); }
.oe-org-status { margin: 0 auto 0 0; font-size: 12px; overflow-wrap: anywhere; }
.oe-org-footer .oe-org-button { flex: none; }
.oe-org-empty { display: grid; place-content: center; justify-items: center; height: 100%; min-height: 180px; gap: 12px; }
.oe-org-empty > svg { width: 40px; height: 40px; color: var(--org-accent); }
.oe-org-empty h3 { margin: 0; font-size: 16px; }
.oe-org-tooltip { position: fixed; z-index: 20; max-width: min(360px, calc(100vw - 24px)); max-height: min(260px, 45dvh); overflow-y: auto; padding: 12px; background: #fff; color: #312d2a; border: 1px solid #8f8983; border-radius: 4px; box-shadow: 0 3px 12px #0002; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; line-height: 18px; }
.opportunities-extension-modal[data-theme="dark"] .oe-org-tooltip { background: #35322f; color: #f5f4f2; }
.oe-org-tooltip { pointer-events: none; }
.oe-org-confirm { position: absolute; inset: 0; display: grid; place-items: center; background: #0006; z-index: 30; padding: 16px; }
.oe-org-confirm-panel { width: min(430px, 100%); background: #fff; color: #312d2a; border: 1px solid #8f8983; border-radius: 6px; padding: 24px; overflow-wrap: anywhere; }
.oe-org-confirm-panel h3 { margin: 0 0 12px; font-size: 18px; }
.oe-org-confirm-panel p { font-size: 13px; line-height: 20px; margin: 0 0 24px; }
.opportunities-extension-modal[data-theme="dark"] .oe-org-confirm-panel { background: #292725; color: #f5f4f2; }
.oe-org-skeleton { width: 236px; height: 146px; margin: 28px; border-radius: 4px; background: var(--org-border); animation: oe-time-pulse 1.3s ease-in-out infinite alternate; }
@media (prefers-reduced-motion: reduce) { .oe-org-skeleton { animation: none; } }
@media (max-width: 760px) {
.oe-org-modal { padding: 8px; }
.oe-org-dialog { height: calc(100dvh - 16px); max-height: calc(100dvh - 16px); }
.oe-org-workspace { flex-direction: column; }
.oe-org-editor { flex: 0 1 55%; width: 100%; border-left: 0; border-top: 1px solid var(--org-border); }
.oe-org-viewport { min-height: 140px; }
.oe-org-toolbar { padding: 8px; gap: 6px; }
.oe-org-tools { gap: 2px; }
.oe-org-footer { padding: 8px; }
}

View File

@@ -0,0 +1,600 @@
function createOrgChartView({ environment, tableSorting, model, loadLayout, icon, searchableSelect, request, requestQueryAccountContact, requestPersistContact, requestOpportunitiesByContact, getPrincipal, modalId, notify }) {
const { document } = environment;
let active = null;
const nodeWidth = 236, nodeHeight = 146;
function el(tag, className = "", text) {
const element = document.createElement(tag);
element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
function button(label, symbol, action, text = false) {
const item = el("button", "oe-org-button");
item.type = "button"; item.title = label; item.setAttribute("aria-label", label);
item.append(icon(symbol));
if (text) item.append(el("span", "", label));
item.addEventListener("click", action);
return item;
}
function createButton(opportunity) {
const item = button("View customer organization chart", "chart", () => open(opportunity, item));
item.className = "opportunities-extension-action-button"; item.setAttribute("aria-haspopup", "dialog");
return item;
}
function close(force = false) {
if (!active) return false;
const entry = active;
if (entry.saving) return true;
if (!force && entry.hasChanges()) {
entry.confirm("Discard unsaved changes?", "Your organization chart has unsaved changes.", "Discard changes", () => close(true));
return true;
}
entry.contactCleanup?.();
active = null; entry.edit?.nameCombo.destroy(); entry.observer?.disconnect(); entry.modal.remove();
entry.siblings.forEach(([element, inert]) => { element.inert = inert; });
if (entry.trigger?.isConnected) entry.trigger.focus();
return true;
}
function open(opportunity, trigger) {
if (active) return;
const host = document.getElementById(modalId);
if (!host || host.hidden) return;
const customer = opportunity.CustomerAccount || {};
const customerId = String(customer.PartyId || customer.PartyNumber || ""), principal = getPrincipal();
const modal = el("section", "opportunities-extension-action-form-modal oe-org-modal");
modal.setAttribute("role", "dialog"); modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "oe-org-title"); modal.setAttribute("lang", "en");
const dialog = el("div", "opportunities-extension-action-form-dialog oe-org-dialog");
const header = el("header", "opportunities-extension-account-plans-header"), heading = el("div", "oe-org-heading");
const title = el("h2", "", "Organization chart"); title.id = "oe-org-title";
heading.append(title, el("p", "", customer.PartyUniqueName || "Customer"));
const dismiss = button("Close organization chart", "close", () => close());
dismiss.className = "opportunities-extension-icon-button"; header.append(heading, dismiss);
const toolbar = el("div", "oe-org-toolbar"), tools = el("div", "oe-org-tools"), count = el("span", "oe-org-count");
const add = button("Add person", "plus", () => guardEditor(() => editPerson(null)), true);
const expand = button("Expand all", "expand", () => foldAll(false)), collapse = button("Collapse all", "collapse", () => foldAll(true));
const zoomOut = button("Zoom out", "minus", () => setZoom(entry.zoom - .1)), zoomIn = button("Zoom in", "plus", () => setZoom(entry.zoom + .1));
const zoomValue = el("output", "oe-org-zoom", "100%"); zoomValue.setAttribute("aria-label", "Zoom level");
const fitButton = button("Fit chart to view", "fit", fit);
tools.append(expand, collapse, zoomOut, zoomValue, zoomIn, fitButton); toolbar.append(add, count, tools);
const workspace = el("div", "oe-org-workspace"), viewport = el("div", "oe-org-viewport"); viewport.tabIndex = 0;
viewport.setAttribute("role", "region"); viewport.setAttribute("aria-label", "Organization chart canvas");
const sizing = el("div", "oe-org-sizing"), canvas = el("div", "oe-org-canvas"); sizing.append(canvas); viewport.append(sizing);
const empty = el("div", "oe-org-empty"); empty.append(icon("chart"), el("h3", "", "No people yet"), button("Add first person", "plus", () => editPerson(null), true)); viewport.append(empty);
const editor = el("aside", "oe-org-editor"); editor.hidden = true; editor.setAttribute("aria-label", "Person details"); workspace.append(viewport, editor);
const status = el("p", "oe-org-status", "Loading organization chart..."); status.setAttribute("role", "status");
const retry = button("Retry loading", "plus", load, true); retry.hidden = true;
const footer = el("footer", "oe-org-footer"), save = button("Save chart", "save", saveChart, true); save.classList.add("oe-org-primary");
footer.append(status, retry, save); dialog.append(header, toolbar, workspace, footer); modal.append(dialog);
const tooltip = el("div", "oe-org-tooltip"); tooltip.hidden = true; tooltip.id = "oe-org-notes-tooltip"; tooltip.setAttribute("role", "tooltip"); modal.append(tooltip);
const siblings = Array.from(host.children).map(element => [element, element.inert]); siblings.forEach(([element]) => { element.inert = true; }); host.append(modal);
const entry = { modal, trigger, siblings, nodes: [], baseline: "[]", revision: 0, ready: false, saving: false,
zoom: 1, autoFit: true, width: 1, height: 1, selected: null, edit: null, confirmation: null, contacts: [], details: new Map() };
entry.hasChanges = () => JSON.stringify(entry.nodes) !== entry.baseline || editorDirty();
entry.confirm = confirm; active = entry;
function message(text, error = false) {
status.textContent = text; status.setAttribute("role", error ? "alert" : "status"); status.classList.toggle("oe-org-error", error);
}
function updateControls() {
const blocked = !entry.ready || entry.saving;
for (const control of [add, expand, collapse, zoomOut, zoomIn, fitButton]) control.disabled = blocked || (control !== add && !entry.nodes.length);
save.disabled = blocked || !entry.hasChanges(); dismiss.disabled = entry.saving;
toolbar.inert = workspace.inert = entry.saving;
}
function changed() { message("Unsaved changes"); updateControls(); }
function editorValue() {
if (!entry.edit) return null;
const { fields, person } = entry.edit;
const contact = entry.contacts.find(item => item.PartyId === fields.name.value);
return { ...person, ...(contact ? { contactPartyId: contact.PartyId, PartyNumber: contact.PartyNumber || (contact.PartyId === person.contactPartyId ? person.PartyNumber : undefined) } : {}),
name: contact ? contact.PartyName : fields.name.value === "__saved" ? person.name : "",
jobTitle: contact ? (typeof contact.JobTitle === "string" ? contact.JobTitle.trim() : "") : fields.name.value === "__saved" ? person.jobTitle || "" : "",
notes: fields.notes.value, parentId: fields.parent.value === "__root" ? null : fields.parent.value };
}
function editorDirty() { return !!entry.edit && JSON.stringify(editorValue()) !== entry.edit.baseline; }
function guardEditor(action) {
if (entry.saving) return;
if (editorDirty()) confirm("Discard person edits?", "This person has unapplied changes.", "Discard edits", () => { entry.edit?.nameCombo.destroy(); entry.edit = null; editor.hidden = true; action(); });
else action();
}
function confirm(titleText, bodyText, label, action) {
if (entry.confirmation) return;
const overlay = el("div", "oe-org-confirm"), panel = el("section", "oe-org-confirm-panel");
panel.setAttribute("role", "alertdialog"); panel.setAttribute("aria-modal", "true");
panel.setAttribute("aria-labelledby", "oe-org-confirm-title"); panel.setAttribute("aria-describedby", "oe-org-confirm-body");
const heading = el("h3", "", titleText); heading.id = "oe-org-confirm-title";
const body = el("p", "", bodyText); body.id = "oe-org-confirm-body";
const actions = el("div", "oe-org-form-actions"), previous = document.activeElement;
function finish() { entry.confirmation = null; overlay.remove(); dialog.inert = false; previous?.focus(); }
const cancel = button("Keep editing", "close", finish, true), proceed = button(label, "check", () => { finish(); action(); }, true);
actions.append(cancel, proceed); panel.append(heading, body, actions); overlay.append(panel); modal.append(overlay);
entry.confirmation = overlay; dialog.inert = true; tooltip.hidden = true;
overlay.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); finish(); }
if (event.key === "Tab") { event.preventDefault(); (document.activeElement === cancel ? proceed : cancel).focus(); }
}); cancel.focus();
}
function discardEditor() {
entry.edit?.nameCombo.destroy(); entry.edit = null; editor.hidden = true; entry.selected = null; render(); updateControls();
}
function deletePerson(person) {
confirm("Delete person?", "Delete " + person.name + "? Direct reports will move to this person's manager.", "Delete person", () => {
entry.nodes = model.remove(entry.nodes, person.id);
entry.edit?.nameCombo.destroy(); entry.edit = null; entry.selected = null; editor.hidden = true;
changed(); render(); add.focus();
});
}
function showContactOpportunities(person, trigger) {
if (entry.confirmation) return;
person = entry.nodes.find(node => node.id === person.id) || person;
const overlay = el("div", "oe-org-confirm"), panel = el("section", "opportunities-extension-action-form-dialog oe-org-dialog oe-contact-opportunities");
panel.setAttribute("role", "dialog"); panel.setAttribute("aria-modal", "true"); panel.setAttribute("aria-labelledby", "oe-contact-opportunities-title");
const header = el("header", "oe-org-editor-header"), title = el("h3", "", "Opportunities | " + person.name); title.id = "oe-contact-opportunities-title";
let closed = false, offset = 0, partyNumber = person.PartyNumber;
const current = () => !closed && active === entry;
function finish() { closed = true; overlay.remove(); entry.confirmation = null; entry.contactCleanup = null; dialog.inert = false; trigger.focus(); }
header.append(title, button("Close opportunities", "close", finish));
const status = el("p", "oe-org-status"), surface = el("div", "oe-contact-opportunities-table"), table = el("table"), head = el("thead"), row = el("tr"), body = el("tbody");
status.setAttribute("role", "status");
table.setAttribute("data-table-layout", "standard");
const columns = [
{ key: "OptyNumber", label: "OptyNumber" }, { key: "WinProb", label: "Win Probability", type: "number" },
{ key: "Name", label: "Name" }, { key: "CurrencyCode", label: "CurrencyCode" },
{ key: "Revenue", label: "Amount", type: "number" }, { key: "EffectiveDate", label: "Close date", type: "date" }, { key: "StatusCode", label: "StatusCode" }
];
const sorting = tableSorting.create("contact-opportunities", columns);
let allItems = [], loading = false;
for (const column of columns) { const th = el("th"); th.scope = "col"; if (column.key === "Revenue") th.setAttribute("data-align", "money"); sorting.header(th, column.key, column.label, renderRows); row.append(th); }
head.append(row); table.append(head, body); surface.append(table);
const footer = el("footer", "oe-org-form-actions");
const retry = button("Retry", "plus", () => load(), true);
footer.append(retry); panel.append(header, status, surface, footer); overlay.append(panel); modal.append(overlay);
entry.confirmation = overlay; entry.contactCleanup = finish; dialog.inert = true; tooltip.hidden = true;
overlay.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); finish(); }
if (event.key === "Tab") {
const controls = [...panel.querySelectorAll("button:not(:disabled), a")].filter(item => item.getClientRects().length);
const first = controls[0], last = controls.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
});
function renderRows() {
body.replaceChildren();
table.setAttribute("aria-busy", String(loading));
for (const button of head.querySelectorAll("button")) button.disabled = loading;
if (loading) {
for (let index = 0; index < 5; index++) {
const row = el("tr"); row.setAttribute("aria-hidden", "true");
for (const column of columns) {
const cell = el("td"), bar = el("span", "oe-contact-skeleton");
if (column.key === "Revenue") cell.setAttribute("data-align", "money");
bar.style.width = column.key === "Name" ? "90%" : column.key === "WinProb" || column.key === "StatusCode" ? "45%" : "70%";
cell.append(bar); row.append(cell);
}
body.append(row);
}
return;
}
for (const item of sorting.select(allItems)) {
const row = el("tr");
for (const key of ["OptyNumber", "WinProb", "Name", "CurrencyCode", "Revenue", "EffectiveDate", "StatusCode"]) {
const cell = el("td"); if (key === "Revenue") cell.setAttribute("data-align", "money"); let value = item[key] ?? "—";
if (key === "Name" && item.OptyNumber) {
const link = el("a", "", String(value)); link.href = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid=" + encodeURIComponent(item.OptyNumber); link.target = "_blank"; link.rel = "noopener noreferrer"; cell.append(link);
} else {
if (key === "WinProb" && item[key] != null) value += "%";
if (key === "Revenue" && item[key] != null && Number.isFinite(Number(value))) value = Number(value).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
if (key === "EffectiveDate" && /^\d{4}-\d{2}-\d{2}/.test(String(value))) value = String(value).slice(0, 10).split("-").reverse().join("/");
cell.textContent = String(value);
}
row.append(cell);
}
body.append(row);
}
}
async function load() {
offset = 0; retry.hidden = true; status.classList.remove("oe-org-error"); status.textContent = "";
status.setAttribute("aria-label", "Loading opportunities");
const totalSkeleton = el("span", "oe-contact-skeleton oe-contact-skeleton-total"); totalSkeleton.setAttribute("aria-hidden", "true"); status.append(totalSkeleton);
let loaded = 0; allItems = []; loading = true; renderRows();
try {
while (current()) {
const data = await requestOpportunitiesByContact(partyNumber, offset, { contactPartyId: person.contactPartyId, contactName: person.name, isActive: current, async resolvePartyNumber() {
// Read the saved person, never replace a persisted identifier with a search result.
const chart = await request("load", { principal, customerId });
if (!current()) return;
const saved = chart.nodes.find(node => node.id === person.id && node.contactPartyId === person.contactPartyId);
partyNumber = saved?.PartyNumber;
if (!partyNumber) throw new Error("This person has no PartyNumber saved in the organization chart. Open Person details to retrieve and save it before viewing opportunities.");
const currentPerson = entry.nodes.find(node => node.id === person.id);
if (currentPerson) entry.nodes = model.upsert(entry.nodes, { ...currentPerson, PartyNumber: partyNumber });
return partyNumber;
} });
if (!current()) return;
allItems.push(...data.items);
loaded += data.items.length;
if (!data.hasMore) { status.textContent = loaded ? `${loaded} opportunities` : "No opportunities found."; break; }
const count = Number(data.count ?? data.items.length), responseOffset = Number(data.offset ?? offset);
const nextOffset = responseOffset + count;
if (!data.items.length || !Number.isSafeInteger(nextOffset) || count <= 0 || nextOffset <= offset) throw new Error("The API returned hasMore without advancing the page. Please retry.");
offset = nextOffset;
status.setAttribute("aria-label", `Loading opportunities: ${loaded} loaded`);
}
} catch (error) { if (current()) { status.textContent = error.message; status.classList.add("oe-org-error"); retry.hidden = false; } }
finally { if (current()) { loading = false; status.removeAttribute("aria-label"); renderRows(); } }
}
header.querySelector("button").focus(); load();
}
function createContact(trigger) {
if (entry.confirmation) return;
const overlay = el("div", "oe-org-confirm"), panel = el("section", "opportunities-extension-action-form-dialog oe-org-dialog oe-contact-dialog");
panel.setAttribute("role", "dialog"); panel.setAttribute("aria-modal", "true");
panel.setAttribute("aria-labelledby", "oe-contact-title");
const header = el("header", "oe-org-editor-header"), title = el("h3", "", "Create Contact"); title.id = "oe-contact-title";
const form = el("form", "oe-contact-form"), combos = [];
form.noValidate = true;
let validationShown = false;
let saving = false;
function finish() {
if (saving) return;
combos.forEach(combo => combo.destroy()); overlay.remove(); entry.confirmation = null;
entry.contactCleanup = null; dialog.inert = false; trigger.focus();
}
header.append(title, button("Close Create Contact", "close", finish));
for (const [label, type, autocomplete] of [["First Name", "text", "given-name"], ["Last Name", "text", "family-name"], ["Job Title", "text", "organization-title"], ["Email Address", "email", "email"]]) {
const field = el("label", "oe-org-field", label), input = el("input");
input.type = type; input.autocomplete = autocomplete; input.name = autocomplete;
if (["given-name", "family-name", "email"].includes(autocomplete)) { input.required = true; field.append(document.createTextNode(" (required)")); }
else field.append(document.createTextNode(" (optional)"));
input.setAttribute("aria-label", label); field.append(input); form.append(field);
}
function consent(parent, name, label, verb) {
const group = el("fieldset", "oe-contact-primary"); group.append(el("legend", "", label));
for (const [value, text] of [["none", "None"], ["deny", "Do Not " + verb], ["allow", "Ok to " + verb]]) {
const field = el("label"), radio = el("input"); radio.type = "radio"; radio.name = name; radio.value = value; radio.checked = value === "none";
field.append(radio, document.createTextNode(text)); group.append(field);
}
parent.append(group);
}
consent(form, "emailConsent", "Email preference", "Email");
const names = new Intl.DisplayNames(["en"], { type: "region" });
const countries = orgChartCallingCodes.map(([code, dial]) => ({ code, dial, name: names.of(code) })).sort((a, b) => a.name.localeCompare(b.name, "en"));
for (const label of ["Work Phone", "Mobile Phone"]) {
const group = el("fieldset", "oe-contact-phone"), legend = el("legend", "", label);
const countryField = el("label", "oe-org-field", "Country code"), select = el("select");
const placeholder = el("option", "", "Select country code"); placeholder.value = ""; select.append(placeholder);
for (const country of countries) {
const option = el("option", "", country.code + " · " + country.name + " (+" + country.dial + ")");
option.value = country.code; option.dataset.callingCode = country.dial; select.append(option);
}
select.name = label === "Work Phone" ? "workCountry" : "mobileCountry";
select.id = "oe-contact-" + select.name;
countryField.append(select);
const numberField = el("label", "oe-org-field", "Phone number"), input = el("input");
input.type = "tel"; input.autocomplete = "tel-national"; input.name = label === "Work Phone" ? "workPhone" : "mobilePhone";
input.setAttribute("aria-label", label); numberField.append(input);
group.append(legend, countryField, numberField); form.append(group);
if (label === "Work Phone") {
const extension = el("label", "oe-org-field", "Extension (optional)"), input = el("input"); input.name = "workExtension"; input.type = "tel"; extension.append(input); group.append(extension);
}
consent(group, label === "Work Phone" ? "workConsent" : "mobileConsent", "Contact preference", "Call");
function validatePhone() {
input.setCustomValidity(select.value && !input.value.trim() ? "Enter the phone number." : "");
combo.input.setCustomValidity(input.value.trim() && !select.value ? "Select a country code." : "");
}
const combo = searchableSelect(select, countryField, validatePhone, { floating: true, listWidth: 390, listMaxHeight: 260 });
input.addEventListener("input", validatePhone);
combo.input.setAttribute("aria-label", label + " country code"); combos.push(combo);
}
const result = el("p", "oe-org-status"); result.setAttribute("role", "status");
result.id = "oe-contact-validation";
function validateContact(focus = false) {
const invalid = [];
for (const input of form.querySelectorAll("input:not([type=radio])")) {
const valid = input.validity.valid && (!input.required || !!input.value.trim());
input.setAttribute("aria-invalid", String(!valid));
if (!valid) { input.setAttribute("aria-describedby", result.id); invalid.push(input); }
else input.removeAttribute("aria-describedby");
}
result.classList.toggle("oe-org-error", invalid.length > 0);
result.setAttribute("role", invalid.length ? "alert" : "status");
result.textContent = invalid.length ? "Complete the highlighted required fields with valid values." : "";
if (focus) invalid[0]?.focus();
return !invalid.length;
}
form.addEventListener("input", () => { if (validationShown && !saving) validateContact(); });
form.addEventListener("change", () => { if (validationShown && !saving) validateContact(); });
const actions = el("footer", "oe-org-form-actions"), saveContact = button("Save", "save", () => form.requestSubmit(), true);
saveContact.classList.add("oe-org-primary"); actions.append(button("Cancel", "close", finish, true), saveContact);
form.addEventListener("submit", async event => {
event.preventDefault(); if (saving) return;
validationShown = true; if (!validateContact(true)) return;
const value = name => form.elements.namedItem(name)?.value || "";
const country = name => form.elements.namedItem(name).selectedOptions[0]?.dataset.callingCode || "";
const values = { firstName: value("given-name"), lastName: value("family-name"), jobTitle: value("organization-title"), email: value("email"),
emailConsent: value("emailConsent"), workConsent: value("workConsent"), mobileConsent: value("mobileConsent"),
workPhone: value("workPhone"), mobilePhone: value("mobilePhone"), workCountry: country("workCountry"), mobileCountry: country("mobileCountry"), workExtension: value("workExtension") };
saving = true; saveContact.disabled = true; form.inert = true; result.classList.remove("oe-org-error"); result.textContent = "Saving contact…";
try {
const data = await requestPersistContact(customer, values);
saving = false;
if (active !== entry) return;
finish(); notify("Contact created successfully.");
if (entry.edit && data?.PartyId != null) {
const contact = { PartyId: String(data.PartyId), PartyNumber: data.PartyNumber, PartyName: data.PartyName || [values.firstName, values.lastName].join(" "), JobTitle: data.JobTitle ?? values.jobTitle,
PrimaryEmail: { EmailAddress: data.EmailAddress ?? values.email } };
entry.contacts.push(contact);
const option = el("option", "", contact.PartyName); option.value = contact.PartyId;
entry.edit.fields.name.append(option); entry.edit.fields.name.value = contact.PartyId; entry.edit.nameCombo.sync(); showContactDetails(entry.edit, contact); updateControls();
}
} catch (error) {
if (active === entry) { result.textContent = error.message || "Unable to create contact."; result.classList.add("oe-org-error"); notify(result.textContent, true); }
} finally { saving = false; saveContact.disabled = false; form.inert = false; }
});
panel.append(header, form, result, actions); overlay.append(panel); modal.append(overlay);
entry.confirmation = overlay; entry.contactCleanup = finish; dialog.inert = true; tooltip.hidden = true;
overlay.addEventListener("keydown", event => {
if (event.key === "Escape" && !event.defaultPrevented) { event.preventDefault(); event.stopPropagation(); finish(); }
if (event.key === "Tab") {
const controls = [...panel.querySelectorAll("button, input")].filter(item => !item.disabled && item.getClientRects().length);
const first = controls[0], last = controls.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
});
form.querySelector("input").focus();
}
function editPerson(person, parentId = null) {
entry.edit?.nameCombo.destroy();
tooltip.hidden = true;
const existing = person || { id: environment.crypto.randomUUID(), parentId, name: "", notes: "", collapsed: false };
entry.selected = person?.id || null; editor.replaceChildren(); editor.hidden = false;
const editorHeader = el("div", "oe-org-editor-header");
editorHeader.append(el("h3", "", person ? "Person details" : "Add person"), button("Close person details", "close", () => guardEditor(discardEditor)));
const form = el("form", "oe-org-form"), fields = {};
const nameField = el("label", "oe-org-field", "Name *"), nameSelect = el("select");
nameSelect.id = "oe-org-name"; nameSelect.required = true; fields.name = nameSelect; nameField.append(nameSelect); form.append(nameField);
const placeholder = el("option", "", "Type to search contacts"); placeholder.value = ""; nameSelect.append(placeholder);
if (existing.name) { const saved = el("option", "", existing.name); saved.value = "__saved"; nameSelect.append(saved); nameSelect.value = "__saved"; }
const nameCombo = searchableSelect(nameSelect, nameField, () => {
nameCombo.input.setCustomValidity(""); entry.edit.detailsVersion++;
const contact = entry.contacts.find(item => item.PartyId === fields.name.value);
showContactDetails(entry.edit, contact); updateControls();
}, {
floating: true, listMaxHeight: 280, searchDelay: 350,
async search(keywords, isCurrent) {
const contacts = await requestQueryAccountContact(customer.PartyId, keywords, () => active === entry && isCurrent());
if (!isCurrent() || active !== entry) return [];
entry.contacts = contacts;
return contacts.map(contact => ({ value: contact.PartyId, label: contact.PartyName,
description: contact.JobTitle || "" }));
}
});
nameCombo.input.setAttribute("aria-label", "Name");
const create = button("Create Contact", "contact", event => { event.preventDefault(); createContact(create); });
create.setAttribute("aria-haspopup", "dialog");
const wrapper = nameCombo.input.parentElement; wrapper.classList.add("oe-contact-name"); wrapper.append(create);
for (const [key, label, max] of [["role", "Job Title", 160], ["emailAddress", "Email address", 320], ["notes", "Notes", 4000]]) {
const field = el("label", "oe-org-field", label + (key === "notes" ? " (optional)" : " (from contact)"));
const input = el(key === "notes" ? "textarea" : "input"); input.id = "oe-org-" + key; input.value = (key === "role" ? existing.jobTitle : existing[key]) || ""; input.maxLength = max;
input.setAttribute("aria-label", label); if (key === "notes") input.rows = 7;
else { input.readOnly = true; input.placeholder = "Not provided"; }
field.append(input); fields[key] = input; form.append(field);
input.addEventListener("input", () => { input.setCustomValidity(""); updateControls(); });
}
const detailStatus = el("p", "oe-org-status"); detailStatus.setAttribute("role", "status");
const detailRetry = button("Retry contact details", "plus", () => loadPersonDetails(entry.edit), true); detailRetry.hidden = true;
form.append(detailStatus, detailRetry);
const reports = el("label", "oe-org-field", "Reports to"), select = el("select"); select.id = "oe-org-parent";
const top = el("option", "", "No manager (top level)"); top.value = "__root"; select.append(top);
const excluded = person ? model.descendants(entry.nodes, person.id) : new Set();
for (const candidate of entry.nodes.filter(item => !excluded.has(item.id))) {
const title = candidate.jobTitle;
const option = el("option", "", candidate.name + (title ? " - " + title : "")); option.value = candidate.id; select.append(option);
}
select.value = existing.parentId || "__root"; fields.parent = select; reports.append(select); form.append(reports);
const combo = searchableSelect(select, reports, updateControls, { floating: true }); combo.input.setAttribute("aria-label", "Reports to");
const actions = el("div", "oe-org-form-actions");
const apply = button(person ? "Apply changes" : "Add person", "check", () => {}, true); apply.type = "submit"; apply.classList.add("oe-org-primary");
actions.append(button("Cancel", "close", discardEditor, true), apply);
if (person) {
const remove = button("Delete person", "trash", () => deletePerson(person), true);
remove.classList.add("oe-org-danger"); form.append(remove);
}
form.append(actions); editor.append(editorHeader, form); entry.edit = { person: existing, fields, form, combo, nameCombo, baseline: "", detailsVersion: 0, detailStatus, detailRetry };
entry.edit.baseline = JSON.stringify(editorValue());
form.addEventListener("submit", event => { event.preventDefault(); if (applyEditor()) { render(); focusPerson(entry.selected); } });
render(); updateControls(); if (!nameCombo.input.disabled) nameCombo.input.focus(); else fields.role.focus();
if (person) loadPersonDetails(entry.edit);
}
function showContactDetails(edit, contact) {
edit.fields.role.value = typeof contact?.JobTitle === "string" ? contact.JobTitle : "";
edit.fields.emailAddress.value = typeof contact?.PrimaryEmail?.EmailAddress === "string" ? contact.PrimaryEmail.EmailAddress : "";
for (const key of ["role", "emailAddress"]) edit.fields[key].placeholder = "Not provided";
edit.detailStatus.textContent = ""; edit.detailRetry.hidden = true;
if (contact) entry.details.set(String(contact.PartyId), contact);
render();
}
async function persistPartyNumber(person, value) {
const number = String(value).trim();
if (!/^[A-Za-z0-9]{1,100}$/.test(number)) return;
const current = entry.nodes.find(node => node.id === person.id && node.contactPartyId === person.contactPartyId);
if (!current) return;
entry.nodes = model.upsert(entry.nodes, { ...current, PartyNumber: number });
if (entry.edit?.person.id === person.id) {
entry.edit.person = { ...entry.edit.person, PartyNumber: number };
entry.edit.baseline = JSON.stringify({ ...JSON.parse(entry.edit.baseline), PartyNumber: number });
}
const savedNodes = JSON.parse(entry.baseline);
const saved = savedNodes.find(node => node.id === person.id && node.contactPartyId === person.contactPartyId);
if (!saved || saved.PartyNumber === number || entry.saving) { updateControls(); return; }
saved.PartyNumber = number;
entry.saving = true; updateControls();
try {
const chart = await request("save", { principal, customerId, nodes: savedNodes, revision: entry.revision });
if (active !== entry) return;
entry.revision = chart.revision; entry.baseline = JSON.stringify(chart.nodes);
} finally { if (active === entry) { entry.saving = false; updateControls(); } }
}
async function loadPersonDetails(edit) {
if (!edit) return;
const version = ++edit.detailsVersion;
const isCurrent = () => active === entry && entry.edit === edit && edit.detailsVersion === version;
entry.details.delete(edit.person.contactPartyId);
showContactDetails(edit, null);
if (!edit.person.contactPartyId) { edit.detailStatus.textContent = "Select a contact to load Job Title and email address."; return; }
edit.detailStatus.textContent = "Loading contact details...";
for (const key of ["role", "emailAddress"]) edit.fields[key].placeholder = "Loading...";
try {
let contacts = await requestQueryAccountContact(customer.PartyId, edit.person.name, isCurrent);
if (isCurrent() && !edit.person.PartyNumber && !contacts.find(item => String(item.PartyId) === edit.person.contactPartyId)?.PartyNumber) {
contacts = await requestQueryAccountContact(customer.PartyId, edit.person.name, isCurrent, edit.person.contactPartyId);
}
if (!isCurrent()) return;
const contact = contacts.find(item => String(item.PartyId) === edit.person.contactPartyId);
if (contact) {
const current = entry.nodes.find(node => node.id === edit.person.id);
const jobTitle = typeof contact.JobTitle === "string" ? contact.JobTitle.trim() : "";
if (current && (current.name !== contact.PartyName.trim() || current.jobTitle !== jobTitle)) {
entry.nodes = model.upsert(entry.nodes, { ...current, name: contact.PartyName, jobTitle });
edit.person = entry.nodes.find(node => node.id === current.id);
const baseline = JSON.parse(edit.baseline);
edit.baseline = JSON.stringify({ ...baseline, name: edit.person.name, jobTitle });
edit.fields.name.querySelector('option[value="__saved"]').textContent = edit.person.name;
edit.nameCombo.sync(); changed();
}
}
showContactDetails(edit, contact);
if (contact?.PartyNumber) await persistPartyNumber(edit.person, contact.PartyNumber);
if (!contact) edit.detailStatus.textContent = "Contact details are unavailable. Select a contact to update this person.";
} catch (error) {
if (!isCurrent()) return;
showContactDetails(edit, null); edit.detailStatus.textContent = error.message || "Unable to load contact details.";
edit.detailRetry.hidden = false;
}
}
function applyEditor() {
if (!entry.edit) return true;
const { form, fields } = entry.edit;
if (!editorValue().name) { message("Select a contact in Name.", true); entry.edit.nameCombo.input.focus(); return false; }
if (!form.reportValidity()) return false;
try {
const person = editorValue();
person.collapsed = entry.nodes.find(node => node.id === person.id)?.collapsed ?? person.collapsed;
entry.nodes = model.upsert(entry.nodes, person);
if (person.parentId) entry.nodes = entry.nodes.map(node => node.id === person.parentId ? { ...node, collapsed: false } : node);
entry.edit.nameCombo.destroy(); entry.edit = null; editor.hidden = true; entry.selected = person.id; changed(); return true;
} catch (error) { message(error.message, true); return false; }
}
function focusPerson(id) { canvas.querySelector('[data-person-id="' + id + '"] .oe-org-person')?.focus(); }
function showNotes(person, anchor) {
const card = anchor.closest(".oe-org-node");
if (!person.notes.trim() || entry.saving || active !== entry || !card?.isConnected) return;
tooltip.textContent = person.notes; tooltip.hidden = false;
const box = card.getBoundingClientRect(), rect = tooltip.getBoundingClientRect();
const width = document.documentElement.clientWidth, height = document.documentElement.clientHeight;
tooltip.style.left = Math.max(12, Math.min(box.left, width - rect.width - 12)) + "px";
tooltip.style.top = Math.max(12, box.bottom + rect.height + 8 > height ? box.top - rect.height - 8 : box.bottom + 8) + "px";
}
function render() {
tooltip.hidden = true; canvas.replaceChildren();
const visible = entry.ready && entry.nodes.length;
empty.hidden = !entry.ready || !!visible; sizing.hidden = !visible;
count.textContent = entry.nodes.length + (entry.nodes.length === 1 ? " person" : " people");
if (!visible) return;
const d3 = loadLayout(), root = d3.hierarchy(model.hierarchy(entry.nodes));
d3.tree().nodeSize([nodeWidth + 28, nodeHeight + 54]).separation(() => 1)(root);
const nodes = root.descendants().filter(node => node.depth > 0), left = Math.min(...nodes.map(node => node.x)) - nodeWidth / 2 - 28;
entry.width = Math.max(...nodes.map(node => node.x)) - left + nodeWidth / 2 + 28;
entry.height = Math.max(...nodes.map(node => (node.depth - 1) * (nodeHeight + 54))) + nodeHeight + 56;
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("oe-org-lines"); svg.setAttribute("width", entry.width); svg.setAttribute("height", entry.height); svg.setAttribute("aria-hidden", "true"); canvas.append(svg);
for (const node of nodes) {
const x = node.x - left - nodeWidth / 2, y = (node.depth - 1) * (nodeHeight + 54) + 28;
if (node.parent.depth > 0) {
const px = node.parent.x - left, py = y - 54, path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", "M" + px + "," + py + " V" + (py + 27) + " H" + (x + nodeWidth / 2) + " V" + y); svg.append(path);
}
const person = node.data, card = el("article", "oe-org-node");
card.setAttribute("data-person-id", person.id); card.setAttribute("data-selected", String(entry.selected === person.id)); card.style.left = x + "px"; card.style.top = y + "px";
const main = button("Edit " + person.name, "chart", () => guardEditor(() => editPerson(entry.nodes.find(item => item.id === person.id))));
main.className = "oe-org-person"; main.removeAttribute("title"); main.replaceChildren(el("strong", "", person.name), el("span", "", person.jobTitle || "Job Title not provided"));
card.addEventListener("mouseenter", () => showNotes(person, main)); card.addEventListener("mouseleave", () => { tooltip.hidden = true; });
main.addEventListener("focus", () => showNotes(person, main)); main.addEventListener("blur", () => { tooltip.hidden = true; });
if (person.notes.trim()) main.setAttribute("aria-describedby", tooltip.id);
const actions = el("div", "oe-org-node-actions");
actions.append(button("Add report to " + person.name, "plus", () => guardEditor(() => editPerson(null, person.id))));
const remove = button("Delete " + person.name, "trash", () => guardEditor(() => deletePerson(person)));
remove.classList.add("oe-org-danger"); actions.append(remove);
const opportunities = button("View opportunities for " + person.name, "opportunities", () => showContactOpportunities(person, opportunities));
opportunities.setAttribute("aria-haspopup", "dialog"); actions.append(opportunities);
if (person.notes.trim()) { const marker = el("span", "oe-org-note-marker"); marker.append(icon("note")); marker.title = "Has notes"; actions.append(marker); }
const reports = entry.nodes.filter(item => item.parentId === person.id).length;
if (reports) {
const toggle = button((person.collapsed ? "Expand" : "Collapse") + " reports for " + person.name, person.collapsed ? "down" : "up", () => guardEditor(() => {
entry.nodes = entry.nodes.map(item => item.id === person.id ? { ...item, collapsed: !item.collapsed } : item); changed(); render(); focusPerson(person.id);
}));
toggle.setAttribute("aria-expanded", String(!person.collapsed)); toggle.append(el("span", "", String(reports))); actions.append(toggle);
}
card.append(main, actions); canvas.append(card);
}
if (entry.autoFit) fit(); else setZoom(entry.zoom, true);
}
function setZoom(value, preserveFit = false) {
if (!preserveFit) entry.autoFit = false;
entry.zoom = Math.max(.25, Math.min(1.5, value)); canvas.style.width = entry.width + "px"; canvas.style.height = entry.height + "px";
canvas.style.transform = "scale(" + entry.zoom + ")"; sizing.style.width = entry.width * entry.zoom + "px"; sizing.style.height = entry.height * entry.zoom + "px";
zoomValue.textContent = Math.round(entry.zoom * 100) + "%";
}
function fit() {
if (!entry.nodes.length) return;
entry.autoFit = true;
setZoom(Math.min(1, (viewport.clientWidth - 24) / entry.width, (viewport.clientHeight - 24) / entry.height), true); viewport.scrollTo({ left: 0, top: 0 });
}
function foldAll(collapsed) { guardEditor(() => { entry.nodes = entry.nodes.map(person => ({ ...person, collapsed })); changed(); render(); fit(); }); }
async function load() {
retry.hidden = true; entry.ready = false; updateControls(); modal.setAttribute("aria-busy", "true"); empty.hidden = true;
canvas.replaceChildren(el("div", "oe-org-skeleton")); sizing.hidden = false; message("Loading organization chart...");
try {
if (!customerId || !principal) throw new Error("The current user and customer must be available before opening an organization chart.");
const chart = await request("load", { principal, customerId }); if (active !== entry) return;
entry.nodes = model.normalize(chart.nodes); entry.baseline = JSON.stringify(entry.nodes); entry.revision = chart.revision; entry.ready = true;
message(chart.updatedAt ? "Saved locally: " + new Date(chart.updatedAt).toLocaleString("en-US") : "Not saved yet"); render(); fit();
} catch (error) { if (active === entry) { canvas.replaceChildren(); message(error.message || "Unable to load organization chart.", true); retry.hidden = false; } }
finally { if (active === entry) { modal.setAttribute("aria-busy", "false"); updateControls(); } }
}
async function saveChart() {
if (!entry.ready || entry.saving) return;
if (!applyEditor()) return;
render(); entry.saving = true; updateControls(); message("Saving locally...");
try {
const chart = await request("save", { principal, customerId, nodes: entry.nodes, revision: entry.revision }); if (active !== entry) return;
entry.revision = chart.revision; entry.baseline = JSON.stringify(entry.nodes); message("Saved locally: " + new Date(chart.updatedAt).toLocaleString("en-US"));
notify("Organization chart saved locally.");
} catch (error) { if (active === entry) { message(error.message || "Unable to save organization chart.", true); notify("Organization chart could not be saved.", true); } }
finally { if (active === entry) { entry.saving = false; updateControls(); } }
}
let drag = null;
viewport.addEventListener("pointerdown", event => {
if (event.button !== 0 || event.target.closest("button, input, textarea, .oe-org-node")) return;
drag = { x: event.clientX, y: event.clientY, left: viewport.scrollLeft, top: viewport.scrollTop }; viewport.setPointerCapture(event.pointerId);
});
viewport.addEventListener("pointermove", event => { if (drag) { viewport.scrollLeft = drag.left - event.clientX + drag.x; viewport.scrollTop = drag.top - event.clientY + drag.y; } });
viewport.addEventListener("pointerup", () => { drag = null; }); viewport.addEventListener("pointercancel", () => { drag = null; });
viewport.addEventListener("scroll", () => { tooltip.hidden = true; });
if (environment.window?.ResizeObserver) { entry.observer = new environment.window.ResizeObserver(() => { tooltip.hidden = true; if (entry.autoFit) fit(); }); entry.observer.observe(viewport); }
modal.addEventListener("keydown", event => {
if (entry.confirmation) return;
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); tooltip.hidden = true; close(); }
if (event.key === "Tab") {
const controls = [...modal.querySelectorAll("button, input, textarea, [tabindex='0']")].filter(item => !item.disabled && !item.closest("[hidden], [inert]") && item.getClientRects().length);
const first = controls[0], last = controls.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
}
});
dismiss.focus(); load();
}
return { createButton, open, close };
}

View File

@@ -0,0 +1,69 @@
function createFormRequestCache({ getHours, getUserId }) {
const entries = new Map();
const pending = new Map();
let generation = 0;
let owner = "";
const queries = new Set([
"requestWorkloads",
"requestWeekDetails", "requestTimeEntries", "requestActiveServiceRequests", "requestServiceRequestExpand", "requestLookupTaskType",
"requestActionTeamMembers", "requestActionFindLookup", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage",
"requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestLookupTypePlatform", "requestInternalSrServices"
]);
const writes = new Set(["requestTimeEntriesDelete", "requestTimeEntriesSave", "requestMgmtAction", "requestPostInternalServiceRequest", "requestPostServiceRequest", "requestWorkloadForecastUpdate"]);
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;
}
// Workloads are scoped to the user/account, not the rotating WWB token or the selected Reference.
const key = JSON.stringify(stable(operation === "requestWorkloads"
? { type: message.type, customerId: String(message.customerId), principal: String(message.principal || userId).trim().toLowerCase() }
: 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 => {
const workload = operation === "requestWorkloads";
const fetchedAt = workload && Number.isFinite(result?.cachedAt) ? result.cachedAt : Date.now();
const cacheable = !workload || (Array.isArray(result?.workloads) && (!result.fromCache || Number.isFinite(result.cachedAt)));
if (result?.ok && cacheable && 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);
// Reusing a persistent workload entry must not restart its original TTL.
entries.set(key, { result: copy({ ...result, debugRequests: [] }), fetchedAt });
}
return result;
});
pending.set(key, promise);
try { return await promise; }
finally { if (pending.get(key) === promise) pending.delete(key); }
}
return { send, invalidate };
}

View File

@@ -0,0 +1,75 @@
function createTableSorting({ environment }) {
const memory = new Map();
const storageKey = id => `opportunities-extension-table-sort-v1-${id}`;
function read(id, columns) {
let saved = memory.get(id);
if (!saved) {
try { saved = JSON.parse(environment.localStorage?.getItem(storageKey(id)) || "null"); } catch { /* Storage is optional. */ }
}
return saved && columns.some(column => column.key === saved.key && column.sortable !== false)
&& ["ascending", "descending"].includes(saved.direction)
? { key: saved.key, direction: saved.direction } : { key: "", direction: "ascending" };
}
function save(id, state) {
memory.set(id, { ...state });
try { environment.localStorage?.setItem(storageKey(id), JSON.stringify(state)); } catch { /* Retain the page-session preference. */ }
}
function value(raw, type) {
if (raw == null || String(raw).trim() === "") return null;
if (type === "number") return Number.isFinite(Number(raw)) ? Number(raw) : null;
if (type === "date") {
const timestamp = Date.parse(raw);
return Number.isFinite(timestamp) ? timestamp : null;
}
return String(raw);
}
function create(id, columns) {
let state;
const headers = new Map();
const get = () => state || (state = read(id, columns));
function toggle(key) {
if (!columns.some(column => column.key === key && column.sortable !== false)) return;
const previous = get();
state = { key, direction: previous.key === key && previous.direction === "ascending" ? "descending" : "ascending" };
save(id, state);
headers.forEach(update);
}
function select(items) {
const selected = get(), column = columns.find(column => column.key === selected.key);
if (!column) return [...items];
const direction = selected.direction === "descending" ? -1 : 1;
return items.map((item, index) => ({ item, index, value: value(column.value ? column.value(item) : item[column.key], column.type) }))
.sort((a, b) => {
if (a.value === null || b.value === null) return a.value === b.value ? a.index - b.index : a.value === null ? 1 : -1;
const result = typeof a.value === "number" ? a.value - b.value : a.value.localeCompare(b.value, undefined, { numeric: true, sensitivity: "base" });
return result * direction || a.index - b.index;
}).map(record => record.item);
}
function update({ th, button, key, label }) {
const selected = get();
const direction = selected.key === key ? selected.direction : "none";
th.setAttribute("aria-sort", direction);
button.setAttribute("data-sort-direction", direction);
button.setAttribute("aria-label", `Sort by ${label}${direction === "none" ? "" : `, ${direction}`}`);
}
function header(th, key, label, onChange, disabled = false) {
const button = environment.document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-sort-button";
button.disabled = disabled;
button.title = `Sort by ${label}`;
button.setAttribute("data-sort-key", key);
const text = environment.document.createElement("span");
text.className = "opportunities-extension-sort-label";
text.textContent = label;
button.append(text);
const record = { th, button, key, label };
headers.set(key, record); update(record);
th.append(button);
button.addEventListener("click", () => { toggle(key); onChange?.(key); });
return button;
}
return { get, toggle, select, header };
}
return { read, save, create };
}

View File

@@ -60,7 +60,7 @@ function createPreferencesView({
const closeButton = document.createElement("button"); const closeButton = document.createElement("button");
closeButton.type = "button"; closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button"; closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Fechar preferências"); closeButton.setAttribute("aria-label", "Close preferences");
closeButton.title = "Close"; closeButton.title = "Close";
closeButton.append(createCloseIcon()); closeButton.append(createCloseIcon());
closeButton.addEventListener("click", closePreferencesModal); closeButton.addEventListener("click", closePreferencesModal);
@@ -102,11 +102,11 @@ function createPreferencesView({
cacheField.className = "opportunities-extension-preferences-cache-field"; cacheField.className = "opportunities-extension-preferences-cache-field";
const cacheLabel = document.createElement("span"); const cacheLabel = document.createElement("span");
cacheLabel.textContent = "Product cache duration"; cacheLabel.textContent = "Cache duration";
const cacheSelect = document.createElement("select"); const cacheSelect = document.createElement("select");
cacheSelect.name = "opptyProductsCacheHours"; cacheSelect.name = "opptyProductsCacheHours";
cacheSelect.setAttribute("aria-label", "Product cache duration"); cacheSelect.setAttribute("aria-label", "Cache duration");
OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => { OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => {
const optionElement = document.createElement("option"); const optionElement = document.createElement("option");
optionElement.value = String(option.value); optionElement.value = String(option.value);

View File

@@ -9,6 +9,8 @@ function createProductsContentJobs({
renderOpportunitiesTable, renderOpportunitiesTable,
beginProductActivities, beginProductActivities,
completeProductActivities, completeProductActivities,
loadWorkloads,
joinWorkloads,
sendRuntimeMessage, sendRuntimeMessage,
environment environment
}) { }) {
@@ -53,7 +55,7 @@ function createProductsContentJobs({
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
productsContentState.currentProductsRequestId = requestId; productsContentState.currentProductsRequestId = requestId;
beginProductActivities(requestId, token); beginProductActivities(requestId, token, optyNumbers);
productsContentState.opptyProductsByNumber = new Map(); productsContentState.opptyProductsByNumber = new Map();
productsContentState.expandedProductOptyNumbers = new Set(); productsContentState.expandedProductOptyNumbers = new Set();
productsContentState.productsProgress = { productsContentState.productsProgress = {
@@ -65,6 +67,8 @@ function createProductsContentJobs({
}; };
renderProductsProgress(); renderProductsProgress();
loadWorkloads(opportunities, requestId);
if (optyNumbers.length === 0) { if (optyNumbers.length === 0) {
return; return;
} }
@@ -121,6 +125,7 @@ function createProductsContentJobs({
cachedAt: Number(message.cachedAt) || 0, cachedAt: Number(message.cachedAt) || 0,
error: message.error || "" error: message.error || ""
}); });
joinWorkloads(message.optyNumber);
} }
productsContentState.productsProgress = { productsContentState.productsProgress = {
@@ -184,7 +189,7 @@ function createProductsContentJobs({
timeStyle: "short" timeStyle: "short"
}).format(new Date(timestamp)); }).format(new Date(timestamp));
} catch (error) { } catch (error) {
return new Date(timestamp).toLocaleString(); return new Date(timestamp).toLocaleString("en-GB");
} }
} }

View File

@@ -3,6 +3,8 @@ function createProductsContentState() {
currentProductsRequestId: "", currentProductsRequestId: "",
productsRenderQueued: false, productsRenderQueued: false,
opptyProductsByNumber: new Map(), opptyProductsByNumber: new Map(),
workloadCustomerIds: [],
workloadsByCustomer: new Map(),
expandedProductOptyNumbers: new Set(), expandedProductOptyNumbers: new Set(),
productsProgress: { productsProgress: {
state: "idle", state: "idle",

View File

@@ -1,28 +1,54 @@
function createProductsDetails({ function createProductsDetails({
OPPORTUNITIES_COLUMNS, OPPORTUNITIES_COLUMNS,
setBadgeContent, setBadgeContent,
createAiSummaryAlert, createProjectionIndicator,
openProjection,
formatOracleResponseDate, formatOracleResponseDate,
productsContentState, productsContentState,
renderOpportunitiesTable, renderOpportunitiesTable,
requestGenerateAiSummary,
environment environment
}) { }) {
const { document } = environment; const { document } = environment;
function createProductsRampBadge(productsResult) { function isBooking(products) {
const values = (Array.isArray(productsResult.items) ? productsResult.items : []) return Boolean(products?.length) && products.every(item => !String(item?.WorkloadName_c ?? "").trim() || String(item.WorkloadName_c).trim() === "-");
.map(item => item && item.RampMonths_c) }
.map(value => value === null || value === undefined || String(value).trim() === "" ? "N/D" : String(value).trim()) function createProductsRampBadge(productsResult, opportunity) {
.filter((value, index, items) => value !== "N/D" || items.indexOf(value) === index); const products = Array.isArray(productsResult.items) ? productsResult.items : [];
if (!values.length) return null; if (isBooking(products)) {
const badge = document.createElement("span"); const badge = document.createElement("span");
badge.className = "opportunities-extension-products-ramp"; badge.className = "opportunities-extension-products-ramp";
badge.setAttribute("data-missing", String(values.includes("N/D"))); badge.setAttribute("data-booking", "true");
setBadgeContent(badge, "ramp", values.join(",")); badge.textContent = "Booking";
badge.title = `Ramp Months: ${values.join(", ")}`; badge.title = "Booking: no product has a Workload assigned.";
badge.setAttribute("aria-label", badge.title); badge.setAttribute("aria-label", badge.title);
return badge; return badge;
} }
const values = products
.map(item => item && item.RampMonths_c)
.map(value => value === null || value === undefined || String(value).trim() === "" ? "N/A" : String(value).trim())
.filter((value, index, items) => value !== "N/A" || items.indexOf(value) === index);
if (!values.length) return null;
const badge = document.createElement("button");
badge.type = "button";
badge.className = "opportunities-extension-products-ramp";
badge.setAttribute("data-missing", String(values.includes("N/A")));
setBadgeContent(badge, "ramp", values.join(" | "));
badge.title = `Ramp Months: ${values.join(", ")}`;
badge.setAttribute("aria-label", badge.title);
badge.setAttribute("aria-haspopup", "dialog");
badge.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
openProjection(productsResult.items, opportunity, badge);
});
return badge;
}
function expandProducts(optyNumber) {
const number = String(optyNumber);
if (productsContentState.expandedProductOptyNumbers.has(number)) return;
productsContentState.expandedProductOptyNumbers.add(number);
renderOpportunitiesTable();
}
function createProductsCountButton(optyNumber, productsResult) { function createProductsCountButton(optyNumber, productsResult) {
const normalizedOptyNumber = String(optyNumber); const normalizedOptyNumber = String(optyNumber);
@@ -44,8 +70,8 @@ function createProductsDetails({
if (isExpanded) { if (isExpanded) {
productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber); productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber);
} else { } else {
productsContentState.expandedProductOptyNumbers.add(normalizedOptyNumber); expandProducts(normalizedOptyNumber);
requestGenerateAiSummary(normalizedOptyNumber); return;
} }
renderOpportunitiesTable(); renderOpportunitiesTable();
@@ -77,7 +103,6 @@ function createProductsDetails({
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated"; summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
heading.append(title, summary); heading.append(title, summary);
panel.append(heading); panel.append(heading);
panel.append(createAiSummaryAlert(optyNumber));
if (productsResult && productsResult.error) { if (productsResult && productsResult.error) {
const error = document.createElement("p"); const error = document.createElement("p");
@@ -92,6 +117,7 @@ function createProductsDetails({
} else { } else {
const table = document.createElement("table"); const table = document.createElement("table");
table.className = "opportunities-extension-products-table"; table.className = "opportunities-extension-products-table";
table.setAttribute("data-table-layout", "standard");
table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`); table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`);
const columns = [ const columns = [
@@ -105,7 +131,8 @@ function createProductsDetails({
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" }, { label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" }, { label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
{ label: "Ramp Months", value: (item) => item.RampMonths_c === null || item.RampMonths_c === undefined || String(item.RampMonths_c).trim() === "" { label: "Ramp Months", value: (item) => item.RampMonths_c === null || item.RampMonths_c === undefined || String(item.RampMonths_c).trim() === ""
? "N/D" : formatProductNumber(item.RampMonths_c) } ? "N/A" : formatProductNumber(item.RampMonths_c) },
{ label: "Projection", render: (item) => createProjectionIndicator(item, opportunity) }
]; ];
const head = document.createElement("thead"); const head = document.createElement("thead");
@@ -113,6 +140,7 @@ function createProductsDetails({
columns.forEach((column) => { columns.forEach((column) => {
const header = document.createElement("th"); const header = document.createElement("th");
header.scope = "col"; header.scope = "col";
if (column.label === "Amount") header.setAttribute("data-align", "money");
header.textContent = column.label; header.textContent = column.label;
headerRow.append(header); headerRow.append(header);
}); });
@@ -123,7 +151,11 @@ function createProductsDetails({
const productRow = document.createElement("tr"); const productRow = document.createElement("tr");
columns.forEach((column) => { columns.forEach((column) => {
const productCell = document.createElement("td"); const productCell = document.createElement("td");
productCell.textContent = String(column.value(product)); if (column.label === "Amount") productCell.setAttribute("data-align", "money");
if (column.render) {
productCell.className = "opportunities-extension-projection-cell";
productCell.append(column.render(product));
} else productCell.textContent = String(column.value(product));
productRow.append(productCell); productRow.append(productCell);
}); });
body.append(productRow); body.append(productRow);
@@ -165,5 +197,5 @@ function createProductsDetails({
return Number.isFinite(number) ? `${number}%` : "-"; return Number.isFinite(number) ? `${number}%` : "-";
} }
return { createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability }; return { isBooking, expandProducts, createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
} }

View File

@@ -0,0 +1,47 @@
function createWorkloadForecastUpdate({ compare, ensureCurrentUser, getCurrentUserId, requestAuthorization,
sendRuntimeMessage, messageTypes, appendBackgroundRequestLog, productsContentState, queueProductsTableRender }) {
const pending = new Set();
async function save(product) {
const id = String(product?.workload?.id ?? "");
if (!/^\d+$/.test(id) || id !== String(product.RevnId)) throw new Error("The product does not have a matching workload ID.");
if (pending.has(id)) throw new Error("Estimates are already being updated.");
const comparison = compare(product);
if (comparison.status !== "divergent") throw new Error("No forecast differences are available to update.");
pending.add(id);
try {
await ensureCurrentUser();
const updatedBy = getCurrentUserId();
if (typeof updatedBy !== "string" || !updatedBy.trim()) throw new Error("The current user ID is unavailable.");
// Keep zero months before consumption unchanged, unless they also need correcting.
const payload = comparison.rows.filter(row => row.index >= 1 || row.divergent)
.map(row => ({ year: row.year, month: row.month, amount: row.calculated, updatedBy }));
const authorizations = await requestAuthorization();
const response = await sendRuntimeMessage({ type: messageTypes.requestWorkloadForecastUpdate,
workloadId: id, payload, authorization: authorizations?.workloadActions });
appendBackgroundRequestLog(response?.debugRequests);
if (!response?.ok) throw new Error(response?.error || "Unable to update workload estimates.");
const amounts = new Map(payload.map(item => [`${item.year}-${item.month}`, item.amount]));
const update = workload => {
if (String(workload?.id) !== id) return workload;
return { ...workload, forecast: workload.forecast.map(month => {
const key = `${Number(month.year)}-${Number(month.month)}`;
return amounts.has(key) ? { ...month, adjustedConsumptionAmount: amounts.get(key) } : month;
}) };
};
product.workload = update(product.workload);
for (const record of productsContentState.workloadsByCustomer.values()) {
if (record.status !== "ready") continue;
record.workloads = record.workloads.map(update);
record.byId = new Map(record.workloads.map(workload => [String(workload.id), workload]));
}
for (const record of productsContentState.opptyProductsByNumber.values()) {
record.items = record.items.map(item => ({ ...item, workload: update(item.workload) }));
}
queueProductsTableRender();
return response;
} finally {
pending.delete(id);
}
}
return { save };
}

View File

@@ -0,0 +1,248 @@
function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCloseIcon, expandProducts, saveEstimates, notify, formatAmount, formatDate, environment, tableSorting }) {
const { document } = environment;
let active = null;
function node(tag, className, text) {
const item = document.createElement(tag);
if (className) item.className = className;
if (text !== undefined) item.textContent = text;
return item;
}
function close(restoreFocus = true) {
if (!active) return false;
if (active.saving && restoreFocus) return true;
const previous = active;
active = null;
previous.modal.remove();
previous.siblings.forEach(([element, inert]) => { element.inert = inert; });
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function createIndicator(product, opportunity) {
const result = compare(product);
if (!product?.workload) {
const disabled = node("button", "opportunities-extension-icon-button opportunities-extension-projection-alert");
disabled.type = "button";
disabled.disabled = true;
disabled.title = "No Workload is available for this product.";
disabled.setAttribute("aria-label", disabled.title);
disabled.append(createAlertIcon());
return disabled;
}
if (result.status !== "divergent") {
const text = node("span", "opportunities-extension-projection-status", result.status === "matched" ? "OK" : "-");
text.title = result.status === "matched" ? "Monthly forecast matches the ramp calculation." : result.reason;
return text;
}
const button = node("button", "opportunities-extension-icon-button opportunities-extension-projection-alert");
button.type = "button";
button.title = `Consumption forecast differs in ${result.divergentCount} month${result.divergentCount === 1 ? "" : "s"}. View comparison.`;
button.setAttribute("aria-label", button.title);
button.setAttribute("aria-haspopup", "dialog");
button.append(createAlertIcon());
button.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
open(product, opportunity, button);
});
return button;
}
function createOpportunityIndicator(productsResult, opportunity, isExpanded) {
const products = productsResult?.items || [];
const count = products.filter(product => compare(product).status === "divergent").length;
if (!count) return null;
const singleProduct = products.length === 1;
const indicator = node("button", "opportunities-extension-icon-button opportunities-extension-projection-alert opportunities-extension-opportunity-projection-alert");
indicator.type = "button";
indicator.title = `Consumption forecast differs in ${count} product${count === 1 ? "" : "s"}. ${singleProduct ? "View comparison." : "View products."}`;
if (singleProduct) indicator.setAttribute("aria-haspopup", "dialog");
else indicator.setAttribute("aria-expanded", String(Boolean(isExpanded)));
indicator.setAttribute("aria-label", indicator.title);
indicator.append(createAlertIcon());
indicator.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
if (singleProduct) open(products[0], opportunity, indicator);
else expandProducts(opportunity.OptyNumber);
});
return indicator;
}
function open(product, opportunity, trigger, products = []) {
const host = document.getElementById(MODAL_ID);
const result = compare(product);
if (!host || host.hidden || !product) return;
close(false);
const modal = node("section", "opportunities-extension-action-form-modal opportunities-extension-projection-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-projection-title");
const dialog = node("div", "opportunities-extension-action-form-dialog opportunities-extension-projection-dialog");
const header = node("header", "opportunities-extension-account-plans-header");
const heading = node("div", "opportunities-extension-action-form-heading");
const title = node("h2", "", "Consumption projection");
title.id = "opportunities-extension-projection-title";
heading.append(title, node("p", "", `${opportunity.OptyNumber} - ${product.WorkloadName_c || product.workload.name || product.ProdGroupName || "Workload"}`));
const dismiss = node("button", "opportunities-extension-icon-button");
dismiss.type = "button";
dismiss.title = "Close consumption projection";
dismiss.setAttribute("aria-label", dismiss.title);
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => close());
header.append(heading, dismiss);
const body = node("div", "opportunities-extension-projection-body");
let productSelect = null;
if (products.length > 1) {
const label = node("label", "", "Product ");
productSelect = node("select", "opportunities-extension-projection-product");
productSelect.setAttribute("aria-label", "Product");
products.forEach((item, index) => {
const option = node("option", "", `${item.ProdGroupName || "Product"} | ${item.WorkloadName_c || item.RevnId || index + 1}`);
option.value = String(index); option.selected = item === product;
productSelect.append(option);
});
productSelect.addEventListener("change", () => open(products[Number(productSelect.value)], opportunity, trigger, products));
label.append(productSelect); body.append(label);
}
const details = node("dl", "opportunities-extension-projection-context");
const currency = product.RevnAmountCurcyCode || "USD";
for (const [label, value] of [
["Customer", opportunity.CustomerAccount?.PartyUniqueName || "-"], ["Product", product.ProdGroupName || "-"],
["Annual value", result.annual == null ? "-" : formatAmount(result.annual, currency)], ["Ramp months", String(result.ramp ?? "-")],
["Consumption start", result.start ? formatDate(result.start) : "-"], ["First month", result.firstMonthDays == null ? "-" : `${result.firstMonthDays}/${result.firstMonthTotalDays} days`]
]) details.append(node("dt", "", label), node("dd", "", value));
body.append(details);
const summary = node("p", "opportunities-extension-projection-summary", `${result.divergentCount} month${result.divergentCount === 1 ? "" : "s"} with a difference`);
body.append(summary);
if (result.reason) body.append(node("p", "opportunities-extension-projection-notice", result.reason));
const viewport = node("div", "opportunities-extension-projection-viewport");
viewport.tabIndex = 0;
viewport.setAttribute("role", "region");
viewport.setAttribute("aria-label", "Monthly consumption comparison");
const table = node("table", "opportunities-extension-projection-table");
table.setAttribute("data-table-layout", "standard");
const caption = node("caption", "opportunities-extension-visually-hidden", `Monthly consumption comparison (${currency}). Difference is forecast minus calculated.`);
const head = node("thead"), headRow = node("tr"), tbody = node("tbody");
const sorting = tableSorting.create("consumption-projection", [
{ key: "month", type: "number", value: item => item.year * 12 + item.month },
...["actual", "calculated", "difference"].map(key => ({ key, type: "number" }))
]);
const rowNodes = new Map();
const renderRows = () => tbody.replaceChildren(...sorting.select(result.rows).map(item => rowNodes.get(item)));
for (const [key, label] of [["month", "Month"], ["actual", "Forecast"], ["calculated", "Calculated"], ["difference", "Difference"]]) {
const th = node("th"); th.scope = "col"; if (key !== "month") th.setAttribute("data-align", "money");
sorting.header(th, key, label, renderRows); headRow.append(th);
}
head.append(headRow);
for (const item of result.rows) {
const tr = node("tr");
tr.setAttribute("data-divergent", String(item.divergent));
const month = node("th", "", `${String(item.month).padStart(2, "0")}/${item.year}`);
month.scope = "row";
if (item.prorated) month.append(node("span", "opportunities-extension-projection-prorated", " (Prorated)"));
tr.append(month);
for (const value of [item.actual, item.calculated, item.difference]) { const cell = node("td", "", value === null ? "-" : formatAmount(value, currency)); cell.setAttribute("data-align", "money"); tr.append(cell); }
rowNodes.set(item, tr);
}
renderRows();
table.append(caption, head, tbody);
viewport.append(table);
body.append(viewport);
const errorMessage = node("p", "opportunities-extension-projection-error");
errorMessage.setAttribute("role", "alert");
errorMessage.hidden = true;
body.append(errorMessage);
const footer = node("footer", "opportunities-extension-projection-footer");
const updateButton = node("button", "opportunities-extension-projection-update", "Update estimates");
updateButton.disabled = result.status !== "divergent";
updateButton.type = "button";
footer.append(node("span", "", "Difference = forecast - calculated."), updateButton);
dialog.append(header, body, footer);
modal.append(dialog);
const siblings = Array.from(host.children).map(element => [element, element.inert]);
siblings.forEach(([element]) => { element.inert = true; });
const current = { modal, siblings, trigger, saving: false };
active = current;
async function updateEstimates() {
if (current.saving) return;
current.saving = true;
updateButton.disabled = true;
dismiss.disabled = true;
updateButton.textContent = "Updating...";
modal.setAttribute("aria-busy", "true");
errorMessage.hidden = true;
try {
await saveEstimates(product);
current.saving = false;
if (active === current) close();
notify("Workload estimates updated successfully.");
} catch (error) {
const message = error.message || "Unable to update workload estimates.";
if (active === current) {
errorMessage.textContent = message;
errorMessage.hidden = false;
}
notify(message, true);
} finally {
current.saving = false;
updateButton.disabled = false;
dismiss.disabled = false;
updateButton.textContent = "Update estimates";
modal.setAttribute("aria-busy", "false");
}
}
updateButton.addEventListener("click", () => {
if (current.saving || current.confirmation) return;
const overlay = node("div", "opportunities-extension-action-form-modal");
const confirmation = node("section", "opportunities-extension-action-form-dialog opportunities-extension-projection-confirm");
confirmation.setAttribute("role", "alertdialog");
confirmation.setAttribute("aria-modal", "true");
confirmation.setAttribute("aria-labelledby", "projection-confirm-title");
confirmation.setAttribute("aria-describedby", "projection-confirm-message");
const title = node("h2", "", "Update estimates");
title.id = "projection-confirm-title";
const message = node("p", "", "The estimates will be updated directly in the SPA using the values in the Calculated column. Would you like to confirm the update?");
message.id = "projection-confirm-message";
const actions = node("div", "opportunities-extension-projection-confirm-actions");
const cancel = node("button", "opportunities-extension-projection-cancel", "Cancel");
const confirm = node("button", "opportunities-extension-projection-update", "Confirm update");
cancel.type = confirm.type = "button";
function dismissConfirmation() {
overlay.remove();
current.confirmation = null;
dialog.inert = false;
updateButton.focus();
}
cancel.addEventListener("click", dismissConfirmation);
confirm.addEventListener("click", () => {
if (current.confirmation !== overlay || active !== current) return;
dismissConfirmation();
return updateEstimates();
});
overlay.addEventListener("keydown", event => {
if (event.key === "Escape") {
event.preventDefault(); event.stopPropagation(); dismissConfirmation();
} else if (event.key === "Tab") {
event.preventDefault(); event.stopPropagation();
(document.activeElement === cancel ? confirm : cancel).focus();
}
});
actions.append(cancel, confirm);
confirmation.append(title, message, actions);
overlay.append(confirmation);
current.confirmation = overlay;
dialog.inert = true;
modal.append(overlay);
cancel.focus();
});
host.append(modal);
modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key === "Tab") {
const controls = [dismiss, productSelect, viewport, ...table.querySelectorAll("button"), updateButton].filter(control => control && !control.disabled);
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();
}
return { createIndicator, createOpportunityIndicator, open, close };
}

View File

@@ -0,0 +1,61 @@
.opportunities-extension-products-table th:last-child { width: 90px; padding-left: 6px; padding-right: 6px; white-space: nowrap; text-align: center; }
.opportunities-extension-products-table .opportunities-extension-projection-cell { text-align: center; vertical-align: middle; }
.opportunities-extension-projection-alert { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 0; color: #976000; width: 30px; height: 30px; flex: 0 0 30px; vertical-align: middle; }
.opportunities-extension-projection-alert svg { display: block; flex: none; width: 19px; height: 19px; }
.opportunities-extension-cell-actions > .opportunities-extension-opportunity-projection-alert { width: 20px; height: 20px; flex-basis: 20px; }
.opportunities-extension-projection-alert:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-modal .opportunities-extension-projection-alert:disabled { color: #8f8a85; opacity: .55; cursor: not-allowed; background: transparent; }
.opportunities-extension-products-ramp { cursor: pointer; font-family: inherit; }
.opportunities-extension-products-ramp[data-booking="true"] { cursor: default; color: #714016; background: #fff0df; border-color: #c58b50; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-ramp[data-booking="true"] { color: #ffdbb5; background: #4b321e; border-color: #c99760; }
.opportunities-extension-products-ramp:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-projection-product { max-width: 100%; margin: 0 0 16px; padding: 8px; font: inherit; color: inherit; background: inherit; }
.opportunities-extension-action-form-dialog.opportunities-extension-projection-dialog { width: min(850px, 100%); display: flex; flex-direction: column; overflow: hidden; }
.opportunities-extension-projection-dialog > header { flex: 0 0 auto; padding: 18px 20px; }
.opportunities-extension-projection-dialog .opportunities-extension-action-form-heading { min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-projection-body { min-height: 0; overflow: auto; padding: 16px 20px 0; }
.opportunities-extension-projection-context { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content minmax(0, 1fr); gap: 8px 12px; margin: 0 0 16px; font-size: 12px; }
.opportunities-extension-projection-context dt { font-weight: 600; }
.opportunities-extension-projection-context dd { margin: 0; overflow-wrap: anywhere; }
.opportunities-extension-projection-summary { font-size: 13px; font-weight: 600; color: #815400; margin: 0 0 12px; }
.opportunities-extension-projection-notice { font-size: 12px; }
.opportunities-extension-projection-viewport { overflow-x: auto; }
.opportunities-extension-projection-table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
.opportunities-extension-projection-table th, .opportunities-extension-projection-table td { padding: 9px 12px; border-bottom: 1px solid #dedbd7; text-align: right; white-space: nowrap; }
.opportunities-extension-projection-table th:first-child { text-align: left; }
.opportunities-extension-projection-table thead th { background: #f5f4f2; font-weight: 600; }
.opportunities-extension-projection-table .opportunities-extension-sort-button { min-height: 28px; padding: 4px 18px 4px 0; justify-content: flex-end; }
.opportunities-extension-projection-table th:first-child .opportunities-extension-sort-button { justify-content: flex-start; }
.opportunities-extension-projection-table .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }
.opportunities-extension-projection-table tbody th { font-weight: 400; }
.opportunities-extension-projection-table tr[data-divergent="true"] { background: #fff7e5; }
.opportunities-extension-projection-table tr[data-divergent="true"] td:last-child { color: #815400; font-weight: 700; }
.opportunities-extension-projection-prorated { display: inline; color: #625d58; font-size: 10px; }
.opportunities-extension-projection-footer { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0; padding: 14px 20px; font-size: 11px; color: #625d58; }
.opportunities-extension-projection-update { flex: none; padding: 8px 12px; border: 1px solid #006879; border-radius: 4px; background: #006879; color: #fff; font: inherit; font-size: 12px; cursor: pointer; }
.opportunities-extension-projection-update:hover:not(:disabled) { background: #005563; }
.opportunities-extension-projection-update:disabled { opacity: .6; cursor: wait; }
.opportunities-extension-projection-update:focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
.opportunities-extension-projection-confirm { padding: 20px; }
.opportunities-extension-projection-confirm h2 { margin: 0 0 12px; font-size: 18px; }
.opportunities-extension-projection-confirm p { margin: 0 0 20px; font-size: 13px; line-height: 1.5; }
.opportunities-extension-projection-confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.opportunities-extension-projection-cancel { padding: 8px 12px; border: 1px solid #8b8580; border-radius: 4px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; }
.opportunities-extension-projection-error { color: #b3261e; font-size: 12px; overflow-wrap: anywhere; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-error { color: #ffb4ab; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-alert,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-summary,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table tr[data-divergent="true"] td:last-child { color: #ffd080; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table thead th { background: #34312e; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table th,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table td { border-color: #504b46; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table tr[data-divergent="true"] { background: #3e3425; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-prorated,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-footer { color: #c9c3bc; }
@media (max-width: 600px) {
.opportunities-extension-projection-context { grid-template-columns: max-content minmax(0, 1fr); }
.opportunities-extension-projection-dialog > header { padding: 14px 12px; }
.opportunities-extension-projection-body { padding: 12px 12px 0; }
.opportunities-extension-projection-table th, .opportunities-extension-projection-table td { padding: 8px 6px; }
.opportunities-extension-projection-footer { padding: 12px; }
}

View File

@@ -0,0 +1,63 @@
function createRampProjection() {
function number(value) {
if ((typeof value !== "number" && typeof value !== "string") || String(value).trim() === "") return null;
const result = Number(value);
return Number.isFinite(result) ? result : null;
}
function startDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T|$)/.exec(String(value ?? ""));
if (!match) return null;
const [, year, month, day] = match.map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null;
return { year, month, day, days: new Date(Date.UTC(year, month, 0)).getUTCDate() };
}
function monthlyAmount(annual, ramp, month) {
if (month < 1) return 0;
// calculadora rampa.xlsx, rampas!D6:P17: exact values, before currency rounding.
return (2 * annual / (24 - ramp)) * Math.min(month / (ramp + 1), 1);
}
function cents(value) { return Math.round((value + Number.EPSILON * Math.abs(value)) * 100); }
function compare(product) {
const workload = product?.workload;
const unavailable = reason => ({ status: "unavailable", reason, rows: [], divergentCount: 0 });
if (!workload) return unavailable("Workload data is not available for this product.");
const annual = number(workload.adjustedACR);
const ramp = number(product.RampMonths_c);
const start = startDate(workload.consumptionStartDate);
if (annual === null || annual < 0) return unavailable("Annual value (adjustedACR) is missing or invalid.");
if (ramp === null || !Number.isInteger(ramp) || ramp < 0 || ramp > 11) {
return unavailable("RampMonths_c must be an integer from 0 to 11, as covered by the reference workbook.");
}
if (!start) return unavailable("Consumption start date is missing or invalid.");
if (!Array.isArray(workload.forecast) || !workload.forecast.length) return unavailable("Monthly forecast data is not available.");
const periods = new Set();
const rows = workload.forecast.map(forecast => {
const year = number(forecast?.year), month = number(forecast?.month);
const actual = number(forecast?.adjustedConsumptionAmount);
if (!Number.isInteger(year) || year < 1000 || year > 9999 || !Number.isInteger(month) || month < 1 || month > 12) return null;
const key = `${year}-${String(month).padStart(2, "0")}`;
if (periods.has(key)) return null;
periods.add(key);
const index = (year - start.year) * 12 + month - start.month + 1;
const prorated = index === 1 && start.day > 1;
const exact = monthlyAmount(annual, ramp, index) * (index === 1 ? (start.days - start.day + 1) / start.days : 1);
const calculatedCents = cents(exact);
const actualCents = actual === null ? null : cents(actual);
if (!Number.isSafeInteger(calculatedCents) || (actualCents !== null && !Number.isSafeInteger(actualCents))) return null;
const differenceCents = actualCents === null ? null : actualCents - calculatedCents;
return { key, year, month, index, prorated, actual: actualCents === null ? null : actualCents / 100,
calculated: calculatedCents / 100, difference: differenceCents === null ? null : differenceCents / 100,
divergent: differenceCents !== null && differenceCents !== 0 };
});
if (rows.some(row => row === null)) return unavailable("Forecast contains invalid or duplicate months, or values outside the supported numeric range.");
rows.sort((a, b) => a.key.localeCompare(b.key));
const divergentCount = rows.filter(row => row.divergent).length;
const missingCount = rows.filter(row => row.actual === null).length;
return { status: divergentCount ? "divergent" : missingCount ? "unavailable" : "matched",
reason: missingCount ? "Some months have no adjusted consumption amount." : "", rows, divergentCount, missingCount,
annual, ramp, start: `${start.year}-${String(start.month).padStart(2, "0")}-${String(start.day).padStart(2, "0")}`,
firstMonthDays: start.days - start.day + 1, firstMonthTotalDays: start.days };
}
return { compare, monthlyAmount };
}

View File

@@ -0,0 +1,51 @@
function createWorkloadsCache({ fetchAllWorkloads, storageLocalGet, storageLocalSet }) {
const KEY = "opportunitiesExtension.workloadsCache.v1";
let cachePromise = null;
let writePromise = Promise.resolve();
let generation = 0;
const inFlight = new Map();
function read() {
if (!cachePromise) cachePromise = storageLocalGet(KEY)
.then(result => result?.[KEY] && typeof result[KEY] === "object" && !Array.isArray(result[KEY]) ? result[KEY] : {})
.catch(() => ({}));
return cachePromise;
}
function write(cache) {
writePromise = writePromise.catch(() => {}).then(() => storageLocalSet({ [KEY]: cache }));
return writePromise;
}
async function invalidate() {
generation += 1;
inFlight.clear();
cachePromise = Promise.resolve({});
await write({});
}
async function get(customerId, token, maxAgeMs, principal) {
const scope = typeof principal === "string" ? principal.trim().toLowerCase() : "";
// isMyWorkload is user-specific. Never persist a result without a known principal.
if (!scope) return { ...await fetchAllWorkloads(customerId, token), fromCache: false, cachedAt: Date.now() };
const key = JSON.stringify([scope, customerId]);
const currentGeneration = generation;
const cache = await read();
if (generation !== currentGeneration) return get(customerId, token, maxAgeMs, principal);
const entry = cache[key];
const age = Date.now() - Number(entry?.fetchedAt);
if (Array.isArray(entry?.items) && age >= 0 && age < maxAgeMs) {
return { workloads: entry.items, cachedAt: Number(entry.fetchedAt), fromCache: true, debugRequests: [] };
}
if (!inFlight.has(key)) {
const flight = fetchAllWorkloads(customerId, token).then(async result => {
const cachedAt = Date.now();
if (generation === currentGeneration) {
cache[key] = { fetchedAt: cachedAt, items: result.workloads };
await write(cache).catch(() => {});
}
return { ...result, cachedAt, fromCache: false };
}).finally(() => { if (inFlight.get(key) === flight) inFlight.delete(key); });
inFlight.set(key, flight);
}
return inFlight.get(key);
}
return { get, invalidate };
}

View File

@@ -0,0 +1,78 @@
function createProductWorkloads({ productsContentState, opportunitiesQueryState, preferencesState, messageTypes,
requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, getPrincipal, queueProductsTableRender }) {
let cycle = null;
function reset() {
cycle = null;
productsContentState.workloadCustomerIds = [];
productsContentState.workloadsByCustomer = new Map();
}
function isCurrent(current) {
return cycle === current && current.requestId === productsContentState.currentProductsRequestId
&& current.version === opportunitiesQueryState.periodRequestVersion;
}
function joinProducts(optyNumber) {
if (!cycle || !isCurrent(cycle)) return;
const key = String(optyNumber);
const products = productsContentState.opptyProductsByNumber.get(key);
if (!products) return;
const customerId = cycle.customerByOpportunity.get(key);
const record = productsContentState.workloadsByCustomer.get(customerId);
products.items = products.items.map(product => ({ ...product,
workload: record?.status === "ready" ? record.byId.get(String(product.RevnId ?? "")) || null : null }));
}
async function load(opportunities, requestId) {
reset();
const current = { requestId, version: opportunitiesQueryState.periodRequestVersion,
customerByOpportunity: new Map(), opportunitiesByCustomer: new Map() };
cycle = current;
for (const opportunity of opportunities) {
const customerId = String(opportunity?.CustomerAccount?.PartyId ?? "").trim();
if (!/^\d+$/.test(customerId)) continue;
if (!current.opportunitiesByCustomer.has(customerId)) current.opportunitiesByCustomer.set(customerId, new Set());
if (opportunity.OptyNumber) {
const number = String(opportunity.OptyNumber);
current.customerByOpportunity.set(number, customerId);
current.opportunitiesByCustomer.get(customerId).add(number);
}
}
const ids = [...current.opportunitiesByCustomer.keys()];
productsContentState.workloadCustomerIds = ids;
const records = productsContentState.workloadsByCustomer;
for (const id of ids) records.set(id, { status: "loading", workloads: [], byId: new Map() });
if (!ids.length || !isCurrent(current)) return;
const principal = getPrincipal();
const cacheMaxAgeMs = preferencesState.opptyProductsCacheHours * 60 * 60 * 1000;
try {
const authorizations = await requestConsumerTokenServiceOnce();
if (!isCurrent(current)) return;
let next = 0;
const worker = async () => {
while (isCurrent(current) && next < ids.length) {
const customerId = ids[next++];
const record = records.get(customerId);
try {
const result = await sendRuntimeMessage({ type: messageTypes.requestWorkloads, customerId,
authorization: authorizations?.workloadActions, cacheMaxAgeMs, principal });
if (!isCurrent(current)) return;
appendBackgroundRequestLog(result?.debugRequests);
if (!result?.ok || !Array.isArray(result.workloads)) throw new Error(result?.error || "Unable to load workloads.");
Object.assign(record, { status: "ready", workloads: result.workloads,
byId: new Map(result.workloads.map(workload => [String(workload.id), workload])),
fromCache: Boolean(result.fromCache), cachedAt: Number(result.cachedAt) || 0 });
} catch (error) {
if (!isCurrent(current)) return;
record.status = "error";
record.error = error.message || "Unable to load workloads.";
}
for (const number of current.opportunitiesByCustomer.get(customerId)) joinProducts(number);
queueProductsTableRender();
}
};
await Promise.all(Array.from({ length: Math.min(4, ids.length) }, worker));
} catch (error) {
if (!isCurrent(current)) return;
for (const record of records.values()) { record.status = "error"; record.error = error.message || "Unable to load workloads."; }
}
}
return { load, joinProducts, reset };
}

View File

@@ -139,3 +139,8 @@
.opportunities-extension-ratecard-overlay, .opportunities-extension-ratecard-drawer, .opportunities-extension-ratecard-skeleton, .opportunities-extension-ratecard-overlay, .opportunities-extension-ratecard-drawer, .opportunities-extension-ratecard-skeleton,
.opportunities-extension-ratecard-overlay[data-closing="true"], .opportunities-extension-ratecard-overlay[data-closing="true"] .opportunities-extension-ratecard-drawer { animation: none; transition: none; } .opportunities-extension-ratecard-overlay[data-closing="true"], .opportunities-extension-ratecard-overlay[data-closing="true"] .opportunities-extension-ratecard-drawer { animation: none; transition: none; }
} }
.opportunities-extension-ratecard-toolbar { flex-wrap: wrap; }
.opportunities-extension-ratecard-order-filter { position: relative; min-width: 0; }
.opportunities-extension-ratecard-order-filter .opportunities-extension-customer-filter-trigger { min-height: 38px; padding-block: 8px; font-size: 13px; }
.opportunities-extension-ratecard-order-filter .opportunities-extension-customer-filter-panel { width: min(320px, calc(100vw - 80px)); max-height: 45vh; overflow: auto; }

View File

@@ -1,4 +1,4 @@
function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, selectItems, createConsumptionHeader, createPlanDetails, environment }) { function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, selectItems, createConsumptionHeader, createPlanDetails, tableSorting, environment }) {
const { document, setTimeout, clearTimeout, requestAnimationFrame, cancelAnimationFrame } = environment; const { document, setTimeout, clearTimeout, requestAnimationFrame, cancelAnimationFrame } = environment;
let active = null; let active = null;
let closing = null; let closing = null;
@@ -65,12 +65,84 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
search.setAttribute("aria-label", "Search rate card"); search.setAttribute("aria-label", "Search rate card");
const count = element("span", "count", "Loading rate card..."); const count = element("span", "count", "Loading rate card...");
count.setAttribute("role", "status"); count.setAttribute("role", "status");
toolbar.append(search, count); const orderFilter = element("div", "order-filter");
const filterElement = (tag, suffix, text) => {
const node = element(tag, null, text);
node.className = `opportunities-extension-customer-filter-${suffix}`;
return node;
};
const orderButton = filterElement("button", "trigger", "All order numbers");
orderButton.type = "button";
orderButton.disabled = true;
orderButton.setAttribute("aria-label", "Filter by Order Number");
orderButton.setAttribute("aria-expanded", "false");
const orderPanel = filterElement("section", "panel");
orderPanel.id = "opportunities-extension-ratecard-orders";
orderButton.setAttribute("aria-controls", orderPanel.id);
orderPanel.hidden = true;
const orderSearch = filterElement("input", "search");
orderSearch.type = "search";
orderSearch.placeholder = "Search order numbers";
orderSearch.setAttribute("aria-label", "Search order numbers");
const allLabel = filterElement("label", "select-all");
const allOrders = element("input");
allOrders.type = "checkbox";
allLabel.append(allOrders, element("span", null, "Select all"));
const orderList = filterElement("div", "list");
orderPanel.append(orderSearch, allLabel, orderList);
orderFilter.append(orderButton, orderPanel);
toolbar.append(search, orderFilter, count);
let orders = [], selectedOrders = new Set();
const orderKey = item => String(item.orderNumber ?? "").trim();
function closeOrders() {
orderPanel.hidden = true;
orderButton.setAttribute("aria-expanded", "false");
}
function updateOrders() {
const all = orders.length > 0 && selectedOrders.size === orders.length;
allOrders.checked = all;
allOrders.indeterminate = selectedOrders.size > 0 && !all;
orderButton.textContent = all ? "All order numbers" : `${selectedOrders.size} order numbers selected`;
orderList.textContent = "";
const query = orderSearch.value.trim().toLowerCase();
orders.filter(value => (value || "Not provided").toLowerCase().includes(query)).forEach(value => {
const label = filterElement("label", "option");
const checkbox = element("input");
checkbox.type = "checkbox";
checkbox.checked = selectedOrders.has(value);
checkbox.addEventListener("change", () => {
if (orders.every(order => selectedOrders.has(order))) {
selectedOrders.clear();
selectedOrders.add(value);
} else if (checkbox.checked) selectedOrders.add(value); else selectedOrders.delete(value);
allOrders.checked = selectedOrders.size === orders.length;
allOrders.indeterminate = selectedOrders.size > 0 && !allOrders.checked;
orderButton.textContent = allOrders.checked ? "All order numbers" : `${selectedOrders.size} order numbers selected`;
updateOrders();
renderRows();
});
label.append(checkbox, element("span", null, value || "Not provided"));
orderList.append(label);
});
}
orderButton.addEventListener("click", () => {
orderPanel.hidden = !orderPanel.hidden;
orderButton.setAttribute("aria-expanded", String(!orderPanel.hidden));
if (!orderPanel.hidden) orderSearch.focus();
});
orderSearch.addEventListener("input", updateOrders);
allOrders.addEventListener("change", () => {
selectedOrders = new Set(allOrders.checked ? orders : []);
updateOrders();
renderRows();
});
root.addEventListener("click", event => { if (!orderFilter.contains(event.target)) closeOrders(); });
const viewport = element("div", "viewport"); const viewport = element("div", "viewport");
viewport.tabIndex = 0; viewport.tabIndex = 0;
viewport.setAttribute("role", "region"); viewport.setAttribute("role", "region");
viewport.setAttribute("aria-label", "Rate card items"); viewport.setAttribute("aria-label", "Rate card items");
const table = element("table", "table"); const table = element("table", "table");
table.setAttribute("data-table-layout", "standard");
const caption = element("caption", null, "Rate card items"); const caption = element("caption", null, "Rate card items");
caption.className = "opportunities-extension-visually-hidden"; caption.className = "opportunities-extension-visually-hidden";
const colgroup = element("colgroup"); const colgroup = element("colgroup");
@@ -78,6 +150,9 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
const headRow = element("tr"); const headRow = element("tr");
const sortHeaders = []; const sortHeaders = [];
const current = { root, background, wasInert: background.inert, trigger, items: [], status: "loading", sortKey: "", direction: "asc", openFrame: null }; const current = { root, background, wasInert: background.inert, trigger, items: [], status: "loading", sortKey: "", direction: "asc", openFrame: null };
const savedSort = tableSorting.read("rate-card", columns);
current.sortKey = savedSort.key;
current.direction = savedSort.direction === "descending" ? "desc" : "asc";
active = current; active = current;
background.inert = true; background.inert = true;
const alive = () => active === current && root.isConnected && !parent.hidden; const alive = () => active === current && root.isConnected && !parent.hidden;
@@ -87,6 +162,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
colgroup.append(col); colgroup.append(col);
const th = element("th"); const th = element("th");
th.scope = "col"; th.scope = "col";
if (column.type === "price") th.setAttribute("data-align", "money");
th.setAttribute("aria-sort", "none"); th.setAttribute("aria-sort", "none");
const sort = element("button", "sort"); const sort = element("button", "sort");
sort.type = "button"; sort.type = "button";
@@ -98,6 +174,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
sort.addEventListener("click", () => { sort.addEventListener("click", () => {
current.direction = current.sortKey === column.key && current.direction === "asc" ? "desc" : "asc"; current.direction = current.sortKey === column.key && current.direction === "asc" ? "desc" : "asc";
current.sortKey = column.key; current.sortKey = column.key;
tableSorting.save("rate-card", { key: current.sortKey, direction: current.direction === "desc" ? "descending" : "ascending" });
updateSortHeaders(); updateSortHeaders();
if (current.status === "ready") renderRows(); if (current.status === "ready") renderRows();
}); });
@@ -106,6 +183,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
headRow.append(th); headRow.append(th);
}); });
head.append(headRow); head.append(headRow);
updateSortHeaders();
const body = element("tbody"); const body = element("tbody");
table.append(caption, colgroup, head, body); table.append(caption, colgroup, head, body);
viewport.append(table); viewport.append(table);
@@ -134,7 +212,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
function renderRows() { function renderRows() {
if (!alive()) return; if (!alive()) return;
body.textContent = ""; body.textContent = "";
const visible = selectItems(current.items, search.value, current.sortKey, current.direction); const visible = selectItems(current.items.filter(item => selectedOrders.has(orderKey(item))), search.value, current.sortKey, current.direction);
count.textContent = `${visible.length} of ${current.items.length} items`; count.textContent = `${visible.length} of ${current.items.length} items`;
if (!visible.length) { if (!visible.length) {
showNotice(current.items.length ? "No matching rate card items." : "No rate card items found for this plan."); showNotice(current.items.length ? "No matching rate card items." : "No rate card items found for this plan.");
@@ -143,6 +221,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
const row = element("tr"); const row = element("tr");
columns.forEach(column => { columns.forEach(column => {
const cell = element("td", null, formatValue(item, column)); const cell = element("td", null, formatValue(item, column));
if (column.type === "price") cell.setAttribute("data-align", "money");
if (["price", "number", "percent"].includes(column.type)) cell.className = "opportunities-extension-ratecard-numeric"; if (["price", "number", "percent"].includes(column.type)) cell.className = "opportunities-extension-ratecard-numeric";
row.append(cell); row.append(cell);
}); });
@@ -167,9 +246,12 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (!orderPanel.hidden) { closeOrders(); orderButton.focus(); return; }
closeRatecardDrawer(); closeRatecardDrawer();
} else if (event.key === "Tab") { } else if (event.key === "Tab") {
const controls = [close, search, viewport, ...headRow.querySelectorAll("button")]; const controls = [close, search, ...(!orderButton.disabled ? [orderButton] : []),
...(!orderPanel.hidden ? [orderSearch, allOrders, ...orderList.querySelectorAll("input")] : []),
viewport, ...headRow.querySelectorAll("button")];
const index = controls.indexOf(document.activeElement); const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey && index === 0) || (!event.shiftKey && index === controls.length - 1)) { if (index < 0 || (event.shiftKey && index === 0) || (!event.shiftKey && index === controls.length - 1)) {
event.preventDefault(); event.preventDefault();
@@ -191,6 +273,10 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
showItems(items) { showItems(items) {
if (!alive()) return; if (!alive()) return;
current.items = items; current.items = items;
orders = [...new Set(items.map(orderKey))].sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
selectedOrders = new Set(orders);
orderButton.disabled = !orders.length;
updateOrders();
current.status = "ready"; current.status = "ready";
panel.setAttribute("aria-busy", "false"); panel.setAttribute("aria-busy", "false");
renderRows(); renderRows();

View File

@@ -38,3 +38,5 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.opportunities-extension-service-requests-table .opportunities-extension-account-plans-skeleton-bar { animation: none; } .opportunities-extension-service-requests-table .opportunities-extension-account-plans-skeleton-bar { animation: none; }
} }
.opportunities-extension-service-requests-table .opportunities-extension-sort-button { min-height: 28px; padding: 4px 16px 4px 0; overflow-wrap: anywhere; }
.opportunities-extension-service-requests-table .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }

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