24 Commits

Author SHA1 Message Date
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
f512ef9928 Loading comcipapic to establish a session for SR requests 2026-10-02 02:37:35 -03:00
3fd89a307c The sessions required for Internal SR API calls are now loaded via an iframe 2026-09-29 17:48:03 -03:00
155 changed files with 22380 additions and 2070 deletions

158
README.md
View File

@@ -1,6 +1,74 @@
# Opportunities Extension 1.0.2
# Opportunities Extension 1.0.7
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
@@ -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,
Reference changes and modal reopenings reuse that result. Users with `SALES_REP`
do not trigger CRM Service Request or Workload Actions queries or see their badges.
If identity or roles cannot be validated, only these two features stay disabled;
other opportunity features continue. Reload the page to refresh identity/roles.
The unused identity token is discarded and redacted from debug responses.
Identity must contain a valid user (id, name, roles) and token before dependent
requests start. Failed identity validation keeps those requests waiting and retries
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
invalid JSON. It opens the existing SPA session URL in an inactive temporary tab,
@@ -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,
supported protection prefixes and JSON documents wrapped in strings are normalized
and indented, including while sensitive values are visible. Large JSON responses
are captured as bounded, valid JSON previews with an explicit omission notice,
preserving indentation, highlighting and sensitive-field masking. Already incomplete
are captured in full, preserving indentation, highlighting and sensitive-field
masking. Original sensitive values are retained for explicit reveal, including
identity, tokenrelay and catalog tokens. Already incomplete
or invalid JSON remains plain text. The visibility toggle retains its existing
behavior. Focus, expansion and timeline scroll position survive log updates.
@@ -316,6 +400,66 @@ a focused chart fixture. Stop the server with Ctrl+C when finished.
- Improved compact layouts, customer actions, session recovery and completion-badge fade-out behavior.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses automated, browser-independent tests; installed-browser smoke tests were not performed for this release.
## Release 1.0.3 - 2026-10-13
- Added monthly and weekly calendar views with stage colors, fiscal week labels, daily counts and overflow details.
- Added searchable Action forms, required-field validation, saving and local badge updates.
- Added Service Request creation with dynamic lookups, payload/header confirmation and session recovery.
- Added Internal Service Request details and creation. Save reads parent data from `requestSRInfo`, resolves the current resource user, and links the new record using the parent `SrId`.
- Internal SR creation uses Session authentication, JSON, Origin and the observed App Builder client ID. Customer deadlines remain date-only; internal deadlines use midnight UTC ISO timestamps.
- Improved activity filters, cache reuse, Refresh all, progress indicators and compact layouts.
- Debug retains complete response bodies and reveals original sensitive values through the visibility toggle; values are hidden by default.
- Regenerated Chromium/Edge and Firefox distributions. Automated tests use synthetic data; real Oracle creation and installed-browser smoke tests are not part of release validation.
## Release 1.0.4 - 2026-10-19
- Added Manage Time with weekly navigation, a persisted reference date, daily hour totals and loading skeletons.
- Added searchable Service Request, Activity and Task Type fields, expanded SR tooltips and separate Service/Non Service task types.
- Existing time entries populate weekly hours and Notes. Local drafts are preserved while navigating between weeks.
- Save creates entries with POST, updates changed entries with PUT and applies queued deletions with DELETE. Writes use Session, Origin and the observed App Builder client ID.
- Added required-field validation, the 0-24 hour range, duplicate SR selection prevention, temporary row highlighting and success/error toasts.
- Reused Cache duration across form queries and invalidated cached responses after successful writes. Resource user data is reused after SR session initialization.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
## 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.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.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.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.
## Loading in Development Mode
### Chrome or Microsoft Edge

File diff suppressed because it is too large Load Diff

4610
dist/chromium/content.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -2,10 +2,12 @@
"manifest_version": 3,
"name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.2",
"version": "1.0.8",
"permissions": [
"cookies",
"storage"
"storage",
"webRequest",
"declarativeNetRequestWithHostAccess"
],
"host_permissions": [
"https://eeho.fa.us2.oraclecloud.com/*",
@@ -23,6 +25,16 @@
],
"all_frames": true,
"run_at": "document_idle"
},
{
"matches": [
"https://spa.oracle.com/*"
],
"js": [
"spa-page.js"
],
"all_frames": false,
"run_at": "document_end"
}
],
"background": {

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

4610
dist/firefox/content.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -2,10 +2,12 @@
"manifest_version": 3,
"name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.2",
"version": "1.0.8",
"permissions": [
"cookies",
"storage"
"storage",
"webRequest",
"declarativeNetRequestWithHostAccess"
],
"host_permissions": [
"https://eeho.fa.us2.oraclecloud.com/*",
@@ -23,6 +25,16 @@
],
"all_frames": true,
"run_at": "document_idle"
},
{
"matches": [
"https://spa.oracle.com/*"
],
"js": [
"spa-page.js"
],
"all_frames": false,
"run_at": "document_end"
}
],
"background": {

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.
- Content scripts run at `document_idle`, in all matching frames on the two
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,
declared execution world, or web-accessible resources.
- 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.
- Load `dist/chromium` using Load unpacked in Chrome/Edge, or load
`dist/firefox/manifest.json` as a temporary Firefox add-on. Verify the source
directory and version 1.0.2; avoid testing a previously installed stale copy.
directory and version 1.0.7; avoid testing a previously installed stale copy.
- Keep debug secrets hidden. Record only status codes, request names, durations
and sanitized errors. Do not export real tokens, response bodies or account data.
- AI generation and tests requiring a deliberately expired session must use an
@@ -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 |
| 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 |
| 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 |
| 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 |

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const sources = require("./sources.cjs");
const vendorSources = { content: ["src/vendor/fullcalendar/index.global.min.js", "src/vendor/d3-hierarchy/d3-hierarchy.min.js"], background: [] };
const root = path.resolve(__dirname, "..");
@@ -16,15 +17,23 @@ function composeScript(target, { bootstrap = true } = {}) {
return `// Source: ${file}\n${source}`;
});
if (target === "content") {
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css"]
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");
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 output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`;
new vm.Script(output, { filename: `${target}.js` });
return output;
}
module.exports = { composeScript, sources };
module.exports = { composeScript, sources, vendorSources };

View File

@@ -17,6 +17,14 @@ const server = http.createServer(async (request, response) => {
return;
}
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") {
response.setHeader("Content-Type", "text/javascript");
response.end(`window.createFixtureApplication = ${composition.composeScript("content", { bootstrap: false })}`);

View File

@@ -1,16 +1,24 @@
module.exports = {
"spa-page": ["src/auth/spa-page.js"],
"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/debug-body.js",
"src/content/contracts.js",
"src/auth/session-state.js",
"src/auth/sales-common-state.js",
"src/features/opportunities/query-state.js",
"src/features/opportunities/table-state.js",
"src/features/opportunities/filter-state.js",
"src/features/preferences/state.js",
"src/features/preferences/request-cache.js",
"src/features/products/content-state.js",
"src/features/ai-summary/state.js",
"src/features/consumption/plans-state.js",
"src/features/consumption/trend-state.js",
"src/features/opportunities/dashboard-state.js",
@@ -25,7 +33,6 @@ module.exports = {
"src/content/styles.js",
"src/auth/content-session.js",
"src/auth/current-user.js",
"src/auth/sales-common.js",
"src/auth/token-relay.js",
"src/auth/xsrf-content.js",
"src/platform/content-messages.js",
@@ -39,11 +46,17 @@ module.exports = {
"src/features/opportunities/activity-progress.js",
"src/features/opportunities/activity-filters.js",
"src/features/opportunities/crm-sr-popover.js",
"src/features/opportunities/crm-sr-form.js",
"src/features/opportunities/internal-sr-form.js",
"src/features/opportunities/searchable-select.js",
"src/features/opportunities/internal-sr-session.js",
"src/features/opportunities/sr-session-preparation.js",
"src/features/opportunities/workload-actions.js",
"src/features/opportunities/workload-action-form.js",
"src/features/opportunities/workload-action-options.js",
"src/features/opportunities/fiscal-periods.js",
"src/features/opportunities/table.js",
"src/features/opportunities/calendar.js",
"src/features/opportunities/dashboard.js",
"src/features/opportunities/filters.js",
"src/features/opportunities/formatters.js",
@@ -51,8 +64,11 @@ module.exports = {
"src/features/preferences/storage.js",
"src/features/preferences/view.js",
"src/features/products/content-jobs.js",
"src/features/products/workloads.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/install-base/action.js",
"src/features/install-base/view.js",
@@ -75,19 +91,28 @@ module.exports = {
"src/content/application.js"
],
"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/debug-body.js",
"src/background/contracts.js",
"src/features/products/cache-state.js",
"src/auth/consumer-session.js",
"src/auth/spa-credentials.js",
"src/auth/sr-request-headers.js",
"src/services/spa/accounts.js",
"src/services/spa/install-base.js",
"src/services/spa/customer-service-requests.js",
"src/services/spa/workload-actions.js",
"src/services/spa/workloads.js",
"src/features/products/workloads-cache.js",
"src/services/spa/action-options.js",
"src/services/fusion/sr-details.js",
"src/services/fusion/sr-create.js",
"src/services/spa/current-user.js",
"src/services/spa/page-client.js",
"src/services/spa/consumption.js",
"src/services/spa/response-shapes.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,
supportsFirefoxCookieStoreTabs,
tabsCreate,
tabsUpdate,
tabsRemove,
responseHeaders,
cookieChanges,
inspectPage,
setTimeout,
clearTimeout,
wait
}) {
const sessionTabs = new Map();
async function requestConsumerTokenService(sender) {
const debugRequests = [];
@@ -150,7 +157,8 @@ function createAuthConsumerSession({
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];
try {
await tabsRemove(sessionTab.id);
await closeConsumerTokenSessionTab(sessionTab.id);
if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = receivedValidJson
? "closed-after-valid-json"
@@ -212,7 +220,7 @@ function createAuthConsumerSession({
}
}
function openConsumerTokenSessionTab(sender) {
async function openConsumerTokenSessionTab(sender) {
const details = {
url: CUSTOMER_TOKEN_SESSION_URL,
active: false
@@ -226,8 +234,184 @@ function createAuthConsumerSession({
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;
})
.catch((error) => {
authSessionState.tokenRelayRequest = null;
authSessionState.accessToken = "";
authSessionState.principal = "";
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 allowed = false;
let userId = "";
function ensure() {
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);
const roles = response?.user?.roles;
userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : "";
allowed = response?.ok === true && Array.isArray(roles)
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP");
const user = response?.user;
if (response?.ok === true && typeof user?.id === "string" && user.id.trim()
&& typeof user.name === "string" && user.name.trim()
&& Array.isArray(user.roles) && user.roles.every(role => typeof role === "string" && role.trim())) {
userId = user.id;
allowed = !user.roles.includes("SALES_REP");
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;
}

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

View File

@@ -1,15 +1,31 @@
function createBackgroundApplication(overrides = {}) {
const environment = {
indexedDB: globalThis.indexedDB,
fetch: globalThis.fetch,
browser: globalThis.browser,
chrome: globalThis.chrome,
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
...overrides
};
const messageTypes = createMessageTypes();
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
const orgChartStorage = createOrgChartStorage({ environment, model: createOrgChartModel() });
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
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 authConsumerSession = createAuthConsumerSession({
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
@@ -21,7 +37,13 @@ function createBackgroundApplication(overrides = {}) {
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsUpdate: (...args) => platformBackgroundApi.tabsUpdate(...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)
});
const authSpaCredentials = createAuthSpaCredentials({
@@ -56,8 +78,8 @@ function createBackgroundApplication(overrides = {}) {
});
const spaResponseShapes = createSpaResponseShapes();
const httpBackgroundJson = createHttpBackgroundJson({
observeResponseHeaders: (...args) => srRequestHeaders.observeResponseHeaders(...args),
previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
environment
});
const productsBackgroundJobs = createProductsBackgroundJobs({
@@ -114,6 +136,17 @@ function createBackgroundApplication(overrides = {}) {
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...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 spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
@@ -125,9 +158,44 @@ function createBackgroundApplication(overrides = {}) {
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
const fusionSrDetails = createFusionSrDetails({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const srRequestHeaders = createSrRequestHeaders({ runtimeApi });
const fusionSrCreate = createFusionSrCreate({
runtimeApi,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
tokenHeaders: (...args) => srRequestHeaders.tokenHeaders(...args),
internalServiceRequestHeaders: url => srRequestHeaders.internalServiceRequestHeaders(url),
previewTokenHeaders: url => srRequestHeaders.previewTokenHeaders(url)
});
const fusionSrDetails = createFusionSrDetails({
cookieChanges: runtimeApi?.cookies?.onChanged,
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
supportsFirefoxCookieStoreTabs: () => platformBackgroundApi.supportsFirefoxCookieStoreTabs(),
wait: (...args) => platformBackgroundApi.wait(...args),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args)
});
function start() {
startBackgroundApplication({ messageTypes, requestInternalServiceRequest: message => fusionSrDetails.requestInternalServiceRequest(message), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
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 {
start, contracts,
@@ -142,6 +210,8 @@ function createBackgroundApplication(overrides = {}) {
httpBackgroundJson,
productsBackgroundJobs,
productsCache,
workloadsCache,
spaWorkloads,
fusionProducts,
authXsrfRecovery,
authXsrfCookies,

View File

@@ -16,7 +16,7 @@ function createBackgroundContracts() {
const OPPTY_PRODUCTS_MAX_PAGES = 250;
const OPPTY_PRODUCTS_CONCURRENCY = 4;
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService";
const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html";
const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/";
const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5;
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;

View File

@@ -1,17 +1,41 @@
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
loadOrganizationChart,
saveOrganizationChart,
messageTypes,
requestWeekDetails,
requestTimeEntries,
requestTimeEntriesSave,
requestTimeEntriesDelete,
requestLookupTaskType,
requestServiceRequestExpand,
requestResourceUser,
requestActiveServiceRequests,
invalidateProductsCache,
invalidateResourceUser = async () => {},
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestCustomerServiceRequest,
requestWlActions,
requestWorkloads,
requestWorkloadForecastUpdate,
invalidateWorkloadsCache,
requestActionTeamMembers,
requestActionFindLookup,
requestMgmtAction,
requestPostServiceRequest,
requestPostInternalServiceRequest,
requestInternalServiceRequest,
requestSrLookupPrimaryPillar,
requestSrLookupWithSession,
prepareSrSession,
requestSrLookupLanguage,
requestSrLookupTechPlatform,
requestLookupTypePlatform,
requestInternalSrServices,
requestSrLookupOpportunityBusiness,
requestCurrentUser,
requestConsumerTokenService,
requestConsumptionTrend,
@@ -24,9 +48,63 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) {
return false;
}
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) {
prepareSrSession(sender).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
const srLookups = {
[messageTypes.requestLookupTypePlatform]: requestLookupTypePlatform,
[messageTypes.requestInternalSrServices]: requestInternalSrServices,
[messageTypes.requestSrLookupPrimaryPillar]: requestSrLookupPrimaryPillar,
[messageTypes.requestSrLookupLanguage]: requestSrLookupLanguage,
[messageTypes.requestSrLookupTechPlatform]: requestSrLookupTechPlatform,
[messageTypes.requestSrLookupOpportunityBusiness]: requestSrLookupOpportunityBusiness
};
if (Object.hasOwn(srLookups, message.type)) {
requestSrLookupWithSession(srLookups[message.type], message, sender).then(sendResponse).catch(error => sendResponse({ ok: false,
httpStatus: error.httpStatus, error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.invalidateProductsCache) {
invalidateProductsCache().then(() => 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;
}
if (message.type === messageTypes.getXsrfToken) {
@@ -99,7 +177,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
}
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.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
@@ -108,13 +186,22 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) {
const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
request(message).then(sendResponse).catch(error => sendResponse({
request(message, sender).then(sendResponse).catch(error => sendResponse({
ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if ([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) {
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workload actions.",

View File

@@ -3,8 +3,9 @@ function createContentApplication(overrides = {}) {
document: globalThis.document,
window: globalThis.window,
navigator: globalThis.navigator,
crypto: globalThis.crypto,
get localStorage() { return globalThis.localStorage; },
fetch: globalThis.fetch,
fetch: globalThis.fetch && ((...args) => globalThis.fetch.call(undefined, ...args)),
browser: globalThis.browser,
chrome: globalThis.chrome,
MutationObserver: globalThis.MutationObserver,
@@ -12,31 +13,54 @@ function createContentApplication(overrides = {}) {
requestAnimationFrame: globalThis.requestAnimationFrame,
performance: globalThis.performance,
cancelAnimationFrame: globalThis.cancelAnimationFrame,
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
setTimeout: globalThis.setTimeout && ((...args) => globalThis.setTimeout.call(undefined, ...args)),
clearTimeout: globalThis.clearTimeout && ((...args) => globalThis.clearTimeout.call(undefined, ...args)),
AbortController: globalThis.AbortController,
matchMedia: globalThis.matchMedia,
...overrides
};
const messageTypes = createMessageTypes();
const currentUser = createCurrentUser({
environment,
messageTypes,
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args)
});
const contracts = createContentContracts();
const searchableSelect = createSearchableSelect({ environment });
const authSessionState = createAuthSessionState(contracts);
const authSalesCommonState = createAuthSalesCommonState();
const opportunitiesQueryState = createOpportunitiesQueryState();
const 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 opportunitiesFilterState = createOpportunitiesFilterState(contracts);
const preferencesState = createPreferencesState(contracts);
const activityCache = createActivityCache({ authSessionState, preferencesState, environment });
const productsContentState = createProductsContentState();
const aiSummaryState = createAiSummaryState();
const consumptionPlansState = createConsumptionPlansState();
const consumptionTrendState = createConsumptionTrendState();
const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts);
const debugState = createDebugState();
const debugState = createDebugState(environment);
const columns = createOpportunitiesColumns({
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args),
@@ -55,7 +79,10 @@ function createContentApplication(overrides = {}) {
environment
});
const contentModal = createContentModal({
closeActionForm: (...args) => workloadActionForm.close(...args),
createManageTimeButton: () => manageTime.createButton(),
prepareSrSession: () => srSessionPreparation.ensure(),
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),
closeCrmSrPopover: () => crmSrPopover.close(),
ensureCurrentUser: () => currentUser.ensure(),
@@ -88,6 +115,7 @@ function createContentApplication(overrides = {}) {
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
closeInstallBaseModal: (...args) => installBaseView.closeInstallBaseModal(...args),
closeServiceRequestsModal: (...args) => serviceRequestsView.closeServiceRequestsModal(...args),
closeOrganizationChart: () => orgChart.close(),
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
@@ -132,6 +160,58 @@ function createContentApplication(overrides = {}) {
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 contentStyles = createContentStyles({
STYLE_ID: contracts.STYLE_ID,
redwoodStyles,
@@ -146,11 +226,6 @@ function createContentApplication(overrides = {}) {
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
updateAuthBadge: (...args) => contentModal.updateAuthBadge(...args)
});
const authSalesCommon = createAuthSalesCommon({
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
authSalesCommonState,
requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args)
});
const authTokenRelay = createAuthTokenRelay({
authSessionState,
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
@@ -170,12 +245,14 @@ function createContentApplication(overrides = {}) {
environment
});
const platformContentMessages = createPlatformContentMessages({
requestCache: createFormRequestCache({ getHours: () => preferencesState.opptyProductsCacheHours, getUserId: () => currentUser.getUserId() }),
messageTypes,
environment
});
const opportunitiesController = createOpportunitiesController({
ensureCurrentUser: () => currentUser.ensure(),
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
resetWorkloads: () => { rampProjectionView.close(false); productWorkloads.reset(); },
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
authSessionState,
@@ -196,6 +273,7 @@ function createContentApplication(overrides = {}) {
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
});
const fusionOpportunities = createFusionOpportunities({
resetDebugRequests: () => debugState.reset(),
OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT,
OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL,
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
@@ -216,19 +294,67 @@ function createContentApplication(overrides = {}) {
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
maskToken: (...args) => debugContentLog.maskToken(...args), environment
});
const crmSrPopover = createCrmSrPopover({
requestDetails: async srId => {
const internalSrSession = createInternalSrSession({
environment,
query: async srId => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load SR details.");
return result.items;
},
return result;
}
});
const crmSrPopover = createCrmSrPopover({
openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestDetails: srId => internalSrSession.request(srId),
MODAL_ID: contracts.MODAL_ID,
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
});
const opportunityBadgeContent = createOpportunityBadgeContent({ environment });
async function requestSrFormLookup(type, fields = {}) {
const result = await platformContentMessages.sendRuntimeMessage({ type, ...fields });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load Service Request options.");
return result.items;
}
const internalSrForm = createInternalSrForm({
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
requestSave: async payload => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestPostInternalServiceRequest, payload, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to create Internal Service Request.");
return result.result;
},
getOpportunity: number => number == null ? null : opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
MODAL_ID: contracts.MODAL_ID, environment, searchableSelect,
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestPlatform: () => requestSrFormLookup(messageTypes.requestLookupTypePlatform),
requestServices: parentServiceId => requestSrFormLookup(messageTypes.requestInternalSrServices, { parentServiceId, emailAddress: currentUser.getUserId() })
});
const crmSrForm = createCrmSrForm({
requestSave: async fields => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestPostServiceRequest, ...fields, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to save Service Request.");
return result;
},
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestLanguage: () => requestSrFormLookup(messageTypes.requestSrLookupLanguage),
requestPlatform: () => requestSrFormLookup(messageTypes.requestSrLookupTechPlatform),
requestServices: () => requestSrFormLookup(messageTypes.requestSrLookupOpportunityBusiness, { emailAddress: currentUser.getUserId() }),
searchableSelect,
MODAL_ID: contracts.MODAL_ID, environment,
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
isMissing: number => opportunityCrmServiceRequests.getSummary(number)?.state === "missing",
createCloseIcon: () => contentIcons.createCloseIcon(),
requestPillars: () => requestSrFormLookup(messageTypes.requestSrLookupPrimaryPillar)
});
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
createSrButton: number => crmSrForm.createButton(number),
closeSrForm: () => { internalSrForm.close(false); crmSrForm.close(false); },
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
activityCache,
reportProgress: (...args) => activityProgress.update("sr", ...args),
@@ -238,7 +364,29 @@ function createContentApplication(overrides = {}) {
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
});
const opportunityCalendar = createOpportunityCalendar({
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({
tableSorting,
renderCalendar: (...args) => opportunityCalendar.render(...args),
renderActivityFilters: () => activityFilters.render(),
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
@@ -249,10 +397,12 @@ function createContentApplication(overrides = {}) {
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
createInstallBaseButton: (...args) => installBaseAction.createInstallBaseButton(...args),
createServiceRequestsButton: (...args) => serviceRequestsAction.createServiceRequestsButton(...args),
createOrganizationChartButton: (...args) => orgChart.createButton(...args),
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args),
createProjectionIndicator: (...args) => rampProjectionView.createOpportunityIndicator(...args),
createCrmServiceRequestBadge: (...args) => opportunityCrmServiceRequests.createBadge(...args),
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
@@ -277,6 +427,13 @@ function createContentApplication(overrides = {}) {
environment
});
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),
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
@@ -344,7 +501,7 @@ function createContentApplication(overrides = {}) {
const refreshAll = createRefreshAll({
createRefreshIcon: () => contentIcons.createRefreshIcon(),
messageTypes, activityCache, opportunitiesQueryState, productsContentState,
consumptionPlansState, consumptionTrendState, aiSummaryState, environment,
consumptionPlansState, consumptionTrendState, environment,
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
reload: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
onFailure: error => opportunitiesTable.setOpportunitiesTableState({ status: "error", message: error.message || "Unable to refresh cached data." }),
@@ -352,6 +509,8 @@ function createContentApplication(overrides = {}) {
resetRelatedData: () => {
opportunityCrmServiceRequests.reset();
opportunityWorkloadActions.reset();
productWorkloads.reset();
rampProjectionView.close(false);
consumptionPlansView.closeAccountPlansModal();
installBaseView.closeInstallBaseModal();
serviceRequestsView.closeServiceRequestsModal();
@@ -363,7 +522,20 @@ function createContentApplication(overrides = {}) {
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({
async onReady() {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestResourceUser, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load resource user.");
},
ensureCurrentUser: () => currentUser.ensure(),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
sendRequest: () => platformContentMessages.sendRuntimeMessage({ type: messageTypes.prepareSrSession }),
reportProgress: state => { activityProgress.update("session", state); manageTime.setReady(state === "complete"); },
appendLog: entries => debugContentLog.appendBackgroundRequestLog(entries)
});
const activityFilters = createActivityFilters({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
@@ -383,6 +555,8 @@ function createContentApplication(overrides = {}) {
}
});
const productsContentJobs = createProductsContentJobs({
loadWorkloads: (...args) => productWorkloads.load(...args),
joinWorkloads: number => productWorkloads.joinProducts(number),
beginProductActivities: (...args) => productActivities.begin(...args),
completeProductActivities: (...args) => productActivities.complete(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
@@ -396,6 +570,14 @@ function createContentApplication(overrides = {}) {
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
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({
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
@@ -403,6 +585,7 @@ function createContentApplication(overrides = {}) {
messageTypes
});
const workloadActionForm = createWorkloadActionForm({
searchableSelect,
onActionSaved: (...args) => opportunityWorkloadActions.applySavedAction(...args),
saveAction: (...args) => workloadActionOptions.save(...args),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
@@ -433,27 +616,43 @@ function createContentApplication(overrides = {}) {
productsContentState, messageTypes, environment
});
const productsDetails = createProductsDetails({
createProjectionIndicator: (...args) => rampProjectionView.createIndicator(...args),
openProjection: (products, opportunity, trigger) => rampProjectionView.open(products[0], opportunity, trigger, products),
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
OPPORTUNITIES_COLUMNS: columns,
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
productsContentState,
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args),
environment
});
const aiSummaryContent = createAiSummaryContent({
aiSummaryState,
invalidateSalesCommonToken: (...args) => authSalesCommon.invalidateSalesCommonToken(...args),
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...args),
const rampProjection = createRampProjection();
const workloadForecastUpdate = createWorkloadForecastUpdate({
compare: product => rampProjection.compare(product),
ensureCurrentUser: () => currentUser.ensure(),
getCurrentUserId: () => currentUser.getUserId(),
requestAuthorization: () => authContentSession.requestConsumerTokenServiceOnce(),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...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
});
const customerTableSearch = createCustomerTableSearch({ environment });
const installBaseView = createInstallBaseView({
tableSorting,
createSearch: (...args) => customerTableSearch.createSearch(...args),
MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
@@ -469,6 +668,7 @@ function createContentApplication(overrides = {}) {
environment
});
const serviceRequestsView = createServiceRequestsView({
tableSorting,
createSearch: (...args) => customerTableSearch.createSearch(...args),
MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
@@ -514,6 +714,7 @@ function createContentApplication(overrides = {}) {
environment
});
const ratecardView = createRatecardView({
tableSorting,
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
columns: ratecardModel.columns,
formatValue: (...args) => ratecardModel.formatValue(...args),
@@ -579,7 +780,6 @@ function createContentApplication(overrides = {}) {
});
const debugContentLog = createDebugContentLog({
previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
debugState,
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args)
});
@@ -597,19 +797,19 @@ function createContentApplication(overrides = {}) {
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
}
return {
opportunityCalendar,
orgChart,
workloadActionForm,
activityFilters,
start, contracts,
opportunityWorkloadActions,
fusionCrmServiceRequests, opportunityCrmServiceRequests,
authSessionState,
authSalesCommonState,
opportunitiesQueryState,
opportunitiesTableState,
opportunitiesFilterState,
preferencesState,
productsContentState,
aiSummaryState,
consumptionPlansState,
consumptionTrendState,
opportunitiesDashboardState,
@@ -621,7 +821,6 @@ function createContentApplication(overrides = {}) {
contentStyles,
authContentSession,
currentUser,
authSalesCommon,
authTokenRelay,
authXsrfContent,
platformContentMessages,
@@ -636,12 +835,16 @@ function createContentApplication(overrides = {}) {
preferencesStorage,
preferencesView,
productsContentJobs,
productWorkloads,
productActivities,
activityProgress,
srSessionPreparation,
refreshAll,
activityCache,
productsDetails,
aiSummaryContent,
rampProjection,
workloadForecastUpdate,
rampProjectionView,
consumptionPlansAction,
installBaseAction,
installBaseView,

View File

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

View File

@@ -85,5 +85,67 @@ function createContentIcons({
svg.append(path);
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

@@ -28,13 +28,16 @@ function createContentModal({
closeAccountPlansModal,
closeInstallBaseModal,
closeServiceRequestsModal,
closeOrganizationChart,
closeActionForm,
closeCustomerFilterPanel,
closeOwnerFilterPanel,
closePreferencesModal,
createSettingsIcon,
createRefreshAllButton,
createManageTimeButton,
createActivityFilterControls,
mountOpportunityViews,
debugState,
ensureExtensionStyles,
getCustomerOptions,
@@ -57,6 +60,7 @@ function createContentModal({
closeCrmSrPopover,
requestTokenRelayOnce,
ensureCurrentUser,
prepareSrSession,
resetCustomerAndOwnerFiltersForPeriod,
resetOpportunitiesTable,
restoreFilterPreferences,
@@ -77,12 +81,15 @@ function createContentModal({
}
function openOpportunitiesModal() {
ensureExtensionStyles();
const oldDebug = document.getElementById(DEBUG_PANEL_ID);
if (oldDebug) { oldDebug.hidden = true; oldDebug.textContent = ""; }
debugState.debugSecretsVisible = false;
const existingModal = document.getElementById(MODAL_ID);
if (existingModal) {
existingModal.hidden = false;
prepareSrSession?.();
existingModal.querySelector("select").focus();
document.documentElement.classList.add("opportunities-extension-scroll-lock");
ensureEscapeKeyHandler();
@@ -95,7 +102,7 @@ function createContentModal({
}
const overlay = document.createElement("div");
debugState.requestLog = [];
overlay.setAttribute("lang", "en");
authSessionState.tokenRelayRequest = null;
authSessionState.consumerTokenServiceRequest = null;
authSessionState.accessToken = "";
@@ -150,7 +157,7 @@ function createContentModal({
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Fechar");
closeButton.setAttribute("aria-label", "Close");
closeButton.textContent = "X";
closeButton.addEventListener("click", closeOpportunitiesModal);
@@ -166,14 +173,14 @@ function createContentModal({
const preferencesButton = document.createElement("button");
preferencesButton.type = "button";
preferencesButton.className = "opportunities-extension-icon-button";
preferencesButton.setAttribute("aria-label", "Abrir preferências");
preferencesButton.setAttribute("aria-label", "Open preferences");
preferencesButton.title = "Preferences";
preferencesButton.append(createSettingsIcon());
preferencesButton.addEventListener("click", openPreferencesModal);
const headerActions = document.createElement("div");
headerActions.className = "opportunities-extension-header-actions";
headerActions.append(themeButton, createRefreshAllButton(() => select.value), preferencesButton, closeButton);
headerActions.append(themeButton, createRefreshAllButton(() => select.value), createManageTimeButton(), preferencesButton, closeButton);
header.append(titleBlock, headerActions);
@@ -398,10 +405,11 @@ function createContentModal({
const opportunitiesTable = document.createElement("table");
opportunitiesTable.id = OPPORTUNITIES_TABLE_ID;
opportunitiesTable.className = "opportunities-extension-table";
opportunitiesTable.setAttribute("data-table-layout", "standard");
opportunitiesTable.setAttribute("aria-label", "Opportunities");
tableSurface.append(opportunitiesTable);
body.append(tableSurface);
mountOpportunityViews(body, tableSurface);
const debugPanel = document.createElement("aside");
debugPanel.id = DEBUG_PANEL_ID;
@@ -420,6 +428,7 @@ function createContentModal({
renderOwnerFilter();
renderOpportunitiesTable();
requestOpportunitiesForPeriod(select.value);
prepareSrSession?.();
select.focus();
}
@@ -462,6 +471,7 @@ function createContentModal({
}
function closeOpportunitiesModal() {
if (closeOrganizationChart()) return;
resetAuthCompletion();
closeCrmSrPopover();
const modal = document.getElementById(MODAL_ID);
@@ -500,6 +510,7 @@ function createContentModal({
}
if (event.key === "Escape") {
if (closeOrganizationChart()) { event.preventDefault(); return; }
if (closeActionForm()) {
event.preventDefault();
return;

View File

@@ -0,0 +1,90 @@
/* Modal titles follow the customer Service Requests heading. Keep section and
calendar headings independent from dialog typography. */
.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;
}
.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 {
font-size: 13px;
}

View File

@@ -0,0 +1,127 @@
/* 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: 38px; vertical-align: top; }
.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({
previewDebugBody,
DEBUG_BODY_LIMIT,
debugState,
renderDebugPanel
}) {
@@ -10,14 +9,14 @@ function createDebugContentLog({
name: details.name,
method: details.method,
url: details.url,
headers: details.headers,
headers: details.headers || {},
status: "pending",
startedAt: new Date(),
completedAt: null,
durationMs: null,
httpStatus: null,
statusText: "",
requestHeaders: details.headers,
requestHeaders: details.headers || {},
requestBody: details.body || "",
metadata: details.metadata || {},
responseHeaders: {},
@@ -27,11 +26,13 @@ function createDebugContentLog({
};
debugState.requestLog.push(entry);
debugState.persist(entry);
renderDebugPanel();
return entry;
}
function logRequestSuccess(entry, response, responseBody) {
if (!debugState.requestLog.includes(entry)) return;
entry.status = response.ok ? "success" : "failed";
entry.completedAt = new Date();
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
@@ -40,14 +41,17 @@ function createDebugContentLog({
entry.responseHeaders = headersToObject(response.headers);
entry.responseBody = responseBody.value;
entry.responseBodyTruncated = responseBody.truncated;
debugState.persist(entry);
renderDebugPanel();
}
function logRequestFailure(entry, errorMessage) {
if (!debugState.requestLog.includes(entry)) return;
entry.status = "failed";
entry.completedAt = new Date();
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
entry.error = errorMessage;
debugState.persist(entry);
renderDebugPanel();
}
@@ -82,6 +86,7 @@ function createDebugContentLog({
};
if (existing) Object.assign(existing, normalized);
else debugState.requestLog.push(normalized);
debugState.persist(existing || normalized);
});
renderDebugPanel();
}
@@ -99,7 +104,7 @@ function createDebugContentLog({
}
function createDebugBody(text) {
return previewDebugBody(text, DEBUG_BODY_LIMIT);
return previewDebugBody(text);
}
function headersToObject(headers) {
@@ -113,7 +118,7 @@ function createDebugContentLog({
}
function formatHeaderBlock(headers) {
const entries = Object.entries(headers);
const entries = Object.entries(headers || {});
if (entries.length === 0) {
return "(none)";
@@ -126,7 +131,7 @@ function createDebugContentLog({
.join("\n");
}
function formatDebugBody(body) {
function formatDebugBody(body, requestName = "") {
const text = body || "(none)";
try {
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) {
try { value = JSON.parse(value); } catch { break; }
}
if (!debugState.debugSecretsVisible && requestName === "requestSrTokenCatalog") {
value = value && typeof value === "object" && !Array.isArray(value)
? { ...value, ...(Object.hasOwn(value, "result") ? { result: formatHiddenDebugValue(value.result) } : {}) }
: formatHiddenDebugValue(value);
}
return JSON.stringify(debugState.debugSecretsVisible ? value : maskSensitiveDebugValue(value), null, 2);
} catch (error) {
if (!debugState.debugSecretsVisible && /^(requestSrTokenCatalog|requestCurrentUser|requestTokenRelay.*)$/.test(requestName)) return formatHiddenDebugValue(text);
return text;
}
}
@@ -162,7 +173,7 @@ function createDebugContentLog({
}
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) {
@@ -174,14 +185,14 @@ function createDebugContentLog({
}
function formatMetadataBlock(metadata) {
const entries = Object.entries(metadata);
const entries = Object.entries(metadata || {});
if (entries.length === 0) {
return "(none)";
}
return entries
.map(([key, value]) => `${key}: ${value || "(none)"}`)
.map(([key, value]) => `${key}: ${value ?? "(none)"}`)
.join("\n");
}

View File

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

View File

@@ -1,6 +1,87 @@
function createDebugState() {
return {
requestLog: [],
debugSecretsVisible: false
};
function createDebugState(environment = {}) {
let storage;
let tabId;
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;
position: fixed;
inset: 0;
z-index: 50;
z-index: 2147483647;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
width: auto;

View File

@@ -43,7 +43,6 @@
width: 100%; min-width: 1120px; table-layout: fixed; border-collapse: collapse; font-size: 11px;
}
.opportunities-extension-install-base-table col { width: 7%; }
.opportunities-extension-install-base-table .install-base-col-partyName { width: 12%; }
.opportunities-extension-install-base-table .install-base-col-productGroupName { width: 13%; }
.opportunities-extension-install-base-table .install-base-col-productName { width: 13%; }
.opportunities-extension-install-base-table .install-base-col-quantity { width: 5%; }
@@ -78,3 +77,5 @@
@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-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;
let active = null;
const columns = [
["partyName", "Customer"], ["productGroupName", "Product Group"], ["productName", "Product"],
["productGroupName", "Product Group"], ["productName", "Product"],
["currentSupport", "Current Support", "amount"], ["orderNumber", "Order Number"],
["supportStatus", "Support Status", "status"], ["quantityType", "Quantity Type"],
["quantity", "Quantity", "quantity"], ["supportStartDate", "Support Start Date", "date"],
@@ -33,8 +33,10 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
}
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");
table.setAttribute("data-table-layout", "standard");
table.setAttribute("aria-label", "Installed base items");
const head = element("thead");
const header = element("tr");
@@ -43,13 +45,14 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
const col = element("col");
col.className = `install-base-col-${key}`;
group.append(col);
const th = element("th", "", label);
const th = element("th");
th.scope = "col";
sorting.header(th, key, label, onSort, loading);
header.append(th);
});
head.append(header);
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");
columns.forEach(([key, , type]) => {
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...");
status.setAttribute("role", "status");
let loadedItems = [], loaded = false, failure = "";
const sorting = tableSorting.create("installed-base", sortColumns);
const search = createSearch({ label: "Search installed base", onSearch: renderResults,
getValues: item => columns.flatMap(([key, , type]) => [item[key], formatValue(item[key], type)]) });
const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
@@ -115,7 +119,7 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
modal.addEventListener("keydown", event => {
if (event.key === "Tab") {
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);
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.");
status.setAttribute("role", failure ? "alert" : "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;
}
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

@@ -1,4 +1,4 @@
function createActivityProgress({ MODAL_ID, environment }) {
function createActivityProgress({ MODAL_ID, environment, retrySession = () => {} }) {
const { document, setTimeout, clearTimeout, matchMedia } = environment;
const entries = new Map();
function update(key, state, completed = 0, total = null) {
@@ -18,7 +18,7 @@ function createActivityProgress({ MODAL_ID, environment }) {
clearTimeout(entry.timer);
clearTimeout(entry.hideTimer);
entry.state = state;
const label = key === "sr" ? "Service Requests" : "Actions";
const label = key === "session" ? "SR session" : key === "sr" ? "Service Requests" : "Actions";
entry.badge.hidden = state === "idle";
entry.badge.setAttribute("data-state", state);
entry.badge.setAttribute("data-fading", "false");
@@ -27,7 +27,17 @@ function createActivityProgress({ MODAL_ID, environment }) {
entry.badge.textContent = state === "error" ? `${label} unavailable`
: state === "complete-with-errors" ? `${label} ${count} (errors)`
: state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`;
entry.badge.title = key === "sr" ? "CRM service request records loaded" : "Workload action requests completed";
if (key === "session") entry.badge.textContent = state === "complete" ? "SR session ready" : state === "error" ? "SR session unavailable" : "SR session loading";
entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Product workloads checked for Actions in the selected Reference, regardless of display filters";
if (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(() => {
entry.badge.setAttribute("data-fading", "true");
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,348 @@
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, getRange,
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon,
productTypeSelection, onProductTypeChange }) {
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");
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 (productTypeSelection.has(type)) productTypeSelection.delete(type); else productTypeSelection.add(type);
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
onProductTypeChange();
});
filters.append(button);
}
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 [
{ key: "name", label: "Name", value: (item) => item.Name, display: (item) => item.Name },
{ key: "optyNumber", label: "Opty Number", value: (item) => item.OptyNumber, display: (item) => item.OptyNumber },
{ key: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]) },
{ key: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyName"]) },
{ key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName },
{ key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue },
{ key: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) },
{ key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode },
{ key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
{ key: "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: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }
];

View File

@@ -15,6 +15,7 @@ function createOpportunitiesController({
requestOpportunitiesPage,
resetCrmServiceRequests,
resetWorkloadActions,
resetWorkloads,
requestOpptyProductsInBackground,
requestTokenRelayOnce,
ensureCurrentUser,
@@ -22,7 +23,10 @@ function createOpportunitiesController({
}) {
async function requestOpportunitiesForPeriod(period) {
const dateRange = getFiscalPeriodRange(period, new Date());
opportunitiesQueryState.dateRange = dateRange;
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
resetWorkloadActions();
resetWorkloads();
await ensureCurrentUser();
if (!isCurrentPeriodRequest(requestVersion)) return;
@@ -108,6 +112,7 @@ function createOpportunitiesController({
function resetOpportunitiesTable() {
resetWorkloadActions();
resetWorkloads();
resetCrmServiceRequests();
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);

View File

@@ -1,10 +1,10 @@
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, environment }) {
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, createSrButton, closeSrForm, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, formatDate, environment }) {
let status = "idle";
let byOpportunity = new Map();
let pending = null;
let generation = 0;
let loadedItems = [];
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); loadedItems = []; reportProgress("idle"); }
function reset() { closeSrForm(); closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); loadedItems = []; reportProgress("idle"); }
function load(token, version, cacheOptions = null) {
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
if (status === "ready") {
@@ -72,14 +72,15 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
const summary = getSummary(optyNumber);
const reportedHours = summary.reportedHours !== null
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
: "N/D";
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/D" ? "" : "h"}` : "0h");
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
: "N/A";
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/A" ? "" : "h"}` : "0h");
const display = value => value === null || value === undefined || value === "" ? "N/A" : String(value);
const descriptions = items.map(item => [
`Title: ${display(item.Title)}`,
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
`Reported Hours: ${display(item.ReportedHours_c)}`,
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/D"}`,
`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)}`,
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
].join("\n"));
@@ -89,6 +90,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
badge.setAttribute("aria-label", description);
badge.title = "Whether a Service Request has been created for the opportunities.";
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
if (!items.length && status === "ready") badge.append(createSrButton(String(optyNumber)));
badge.tabIndex = 0;
return badge;
}

View File

@@ -0,0 +1,347 @@
function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportunity, requestSave, notify, formatDate, requestPillars, requestLanguage, requestPlatform, requestServices, createCloseIcon, searchableSelect, environment }) {
const { document } = environment;
let active = null;
function node(tag, className, text) {
const element = document.createElement(tag);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
function close(restoreFocus = true) {
if (!active) return false;
if (active.saving) return true;
const previous = active;
active = null;
previous.modal.remove();
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function createButton(number) {
const button = node("button", "opportunities-extension-add-action", "+");
button.type = "button";
button.title = "Add Service Request";
button.setAttribute("aria-label", `Add Service Request for ${number}`);
button.setAttribute("aria-haspopup", "dialog");
button.addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); open(number, button); });
return button;
}
function open(number, trigger) {
if (active?.saving) return;
const host = document.getElementById(MODAL_ID);
if (!host || host.hidden || !allowsCrmActivities() || !isMissing(number)) return;
close(false);
const modal = node("section", "opportunities-extension-action-form-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-sr-form-title");
const dialog = node("div", "opportunities-extension-action-form-dialog opportunities-extension-sr-form-dialog");
const header = node("header", "opportunities-extension-account-plans-header");
const heading = node("div", "opportunities-extension-action-form-heading");
const title = node("h2", "", "Add Service Request");
title.id = "opportunities-extension-sr-form-title";
heading.append(title, node("p", "", String(number)));
const opportunity = getOpportunity(number);
const details = node("dl", "opportunities-extension-action-form-context");
for (const [label, value] of [
["Customer", opportunity?.CustomerAccount?.PartyUniqueName],
["Name", opportunity?.Name],
["Close Date", formatDate(opportunity?.EffectiveDate)]
]) {
details.append(node("dt", "", label), node("dd", "", value == null || value === "" ? "-" : String(value)));
}
heading.append(details);
const dismiss = node("button", "opportunities-extension-icon-button");
dismiss.type = "button";
dismiss.setAttribute("aria-label", "Close Service Request form");
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => close());
header.append(heading, dismiss);
const form = node("form", "opportunities-extension-action-form");
form.noValidate = true;
const fields = new Map();
const combos = new Map();
const loadingFields = new Set(["primaryPillar", "language", "platform", "services"]);
const availableFields = new Set();
let serviceItems = [];
const serviceRadios = [];
let serviceGroup;
const field = node("label", "opportunities-extension-action-form-field");
const select = node("select");
select.id = "opportunities-extension-sr-primary-pillar";
select.name = "primaryPillar";
select.required = true;
field.htmlFor = select.id;
field.append(node("span", "", "Primary pillar (required)"), select);
const autocomplete = searchableSelect(select, field);
fields.set("primaryPillar", select);
combos.set(select, autocomplete);
const status = node("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
status.setAttribute("role", "status");
const footer = node("footer", "opportunities-extension-action-form-footer");
const retry = node("button", "", "Retry");
retry.type = "button";
const cancel = node("button", "", "Cancel");
const save = node("button", "opportunities-extension-action-form-save", "Save");
save.type = "submit";
save.disabled = true;
cancel.type = "button";
cancel.addEventListener("click", () => close());
footer.append(retry, save, cancel);
form.append(field);
const extraControls = [];
const extraLoads = [];
function textField(name, label, type) {
const wrapper = node("label", "opportunities-extension-action-form-field" + (type === "textarea" ? " opportunities-extension-action-form-wide" : ""));
const input = node(type === "textarea" ? "textarea" : "input");
if (type === "textarea") input.rows = 4;
else input.type = type;
input.id = `opportunities-extension-sr-${name}`;
input.name = name;
input.required = true;
fields.set(name, input);
wrapper.htmlFor = input.id;
wrapper.append(node("span", "", `${label} (required)`), input);
form.append(wrapper);
extraControls.push(input);
}
textField("customerDeadline", "Customer deadline", "date");
function lookupField(name, label, request, radios = false) {
const wrapper = node(radios ? "fieldset" : "div", "opportunities-extension-sr-lookup" + (radios ? " opportunities-extension-action-form-wide" : ""));
const content = node("div", radios ? "opportunities-extension-sr-services" : "");
const message = node("p", "opportunities-extension-action-form-status");
const retryOption = node("button", "opportunities-extension-sr-lookup-retry", "Retry");
retryOption.type = "button";
retryOption.setAttribute("aria-label", `Retry ${label}`);
retryOption.hidden = true;
extraControls.push(retryOption);
let input, combo;
if (radios) { wrapper.append(node("legend", "", `${label} (required)`)); serviceGroup = wrapper; }
else {
const field = node("label", "opportunities-extension-action-form-field");
input = node("select");
input.id = `opportunities-extension-sr-${name}`;
input.name = name;
input.required = true;
fields.set(name, input);
field.htmlFor = input.id;
field.append(node("span", "", `${label} (required)`), input);
combo = searchableSelect(input, field);
combos.set(input, combo);
content.append(field);
extraControls.push(combo.input);
}
wrapper.append(content, message, retryOption);
form.append(wrapper);
let loading = false;
async function loadOptions() {
if (loading || !current()) return;
loading = true;
loadingFields.add(name);
availableFields.delete(name);
updateSave();
retryOption.hidden = true;
message.hidden = false;
message.setAttribute("role", "status");
message.textContent = `Loading ${label.toLowerCase()}...`;
wrapper.setAttribute("aria-busy", "true");
if (input) { input.disabled = true; input.textContent = ""; combo.sync(); }
try {
const items = await request();
if (!current()) return;
if (items.length) availableFields.add(name);
if (input) {
const blank = node("option", "", items.length ? `Select ${label.toLowerCase()}` : "No options available");
blank.value = "";
input.append(blank);
items.forEach(item => { const option = node("option", "", item.name); option.value = item.code; input.append(option); });
input.value = "";
input.disabled = !items.length;
} else {
content.textContent = "";
serviceItems = items;
serviceRadios.length = 0;
const seen = new Set();
items.forEach((item, index) => {
if (seen.has(String(item.Id))) return;
seen.add(String(item.Id));
const row = node("label", "opportunities-extension-sr-service-option");
const radio = node("input");
radio.type = "radio";
radio.name = "service";
radio.required = true;
serviceRadios.push(radio);
radio.value = String(item.Id);
radio.id = `opportunities-extension-sr-service-${index}`;
const copy = node("span");
const abstract = node("span", "opportunities-extension-sr-service-abstract", item.Abstract_c || "");
abstract.id = `${radio.id}-description`;
radio.setAttribute("aria-describedby", abstract.id);
row.htmlFor = radio.id;
copy.append(node("span", "opportunities-extension-sr-service-name", item.RecordName), abstract);
row.append(radio, copy);
content.append(row);
extraControls.push(radio);
});
}
message.textContent = items.length ? "" : "No options available.";
message.hidden = !!items.length;
} catch (error) {
if (!current()) return;
message.setAttribute("role", "alert");
message.textContent = error.message || `Unable to load ${label.toLowerCase()}.`;
retryOption.hidden = false;
} finally {
loading = false;
loadingFields.delete(name);
if (current()) updateSave();
if (current()) { combo?.sync(); wrapper.setAttribute("aria-busy", "false"); }
}
}
retryOption.addEventListener("click", loadOptions);
extraLoads.push(loadOptions);
}
lookupField("language", "Language of delivery", requestLanguage);
lookupField("platform", "Platform", requestPlatform);
textField("description", "Description", "textarea");
lookupField("services", "Services", requestServices, true);
form.append(status, footer);
dialog.append(header, form);
modal.append(dialog);
host.append(modal);
const view = { modal, trigger, loading: false };
active = view;
const current = () => active === view && allowsCrmActivities();
function updateSave() { save.disabled = loadingFields.size > 0 || availableFields.size < 4 || Boolean(view.saving); }
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); }
async function load() {
if (view.loading || !current()) return;
view.loading = true;
loadingFields.add("primaryPillar");
availableFields.delete("primaryPillar");
updateSave();
retry.hidden = true;
select.disabled = true;
select.textContent = "";
option("", "Loading...");
autocomplete.sync();
form.setAttribute("data-state", "loading");
form.setAttribute("aria-busy", "true");
status.hidden = false;
status.setAttribute("role", "status");
status.textContent = "Loading primary pillars...";
try {
const items = await requestPillars();
if (!current()) return;
select.textContent = "";
option("", items.length ? "Select primary pillar" : "No options available");
items.forEach(item => option(item.code, item.name));
select.value = "";
select.disabled = !items.length;
if (items.length) availableFields.add("primaryPillar");
form.setAttribute("data-state", "ready");
status.hidden = true;
} catch (error) {
if (!current()) return;
form.setAttribute("data-state", "error");
select.textContent = "";
option("", "Unavailable");
status.setAttribute("role", "alert");
status.textContent = error.message || "Unable to load primary pillars.";
retry.hidden = false;
} finally {
view.loading = false;
loadingFields.delete("primaryPillar");
if (current()) updateSave();
if (current()) { autocomplete.sync(); form.setAttribute("aria-busy", "false"); }
}
}
retry.addEventListener("click", load);
modal.addEventListener("click", event => { if (event.target === modal) close(); });
modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key !== "Tab") return;
const controls = [dismiss, autocomplete.input, ...extraControls, retry, save, cancel].filter(control => !control.disabled && !control.hidden);
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
});
dismiss.focus();
load();
extraLoads.forEach(loadOptions => loadOptions());
}
return { createButton, close, open };
}

View File

@@ -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;
let active = null;
let sequence = 0;
@@ -81,7 +81,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
heading.className = "opportunities-extension-sr-number";
const number = item.SrNumber;
const link = document.createElement(number ? "a" : "span");
link.textContent = number ? String(number) : "N/D";
link.textContent = number ? String(number) : "N/A";
if (number) {
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(number))}`;
link.target = "_blank";
@@ -91,6 +91,29 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
label.textContent = "SR Number:";
heading.append(label, link);
if (number) heading.append(createCopyButton(String(number), "SR number", "SR Number"));
const add = document.createElement("button");
add.type = "button";
add.className = "opportunities-extension-copy-button opportunities-extension-internal-sr-add";
add.title = "Create Internal Service Request";
add.setAttribute("aria-label", add.title);
add.setAttribute("aria-haspopup", "dialog");
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
icon.setAttribute("viewBox", "0 0 24 24");
icon.setAttribute("aria-hidden", "true");
icon.setAttribute("focusable", "false");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", "M12 5v14M5 12h14");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "2");
path.setAttribute("stroke-linecap", "round");
icon.append(path);
add.append(icon);
add.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
close();
openInternalSrForm(item, button);
});
heading.append(add);
const details = document.createElement("div");
details.className = "opportunities-extension-sr-details";
details.textContent = descriptions[index];
@@ -114,7 +137,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
const label = document.createElement("span");
label.textContent = "Internal SR Number:";
const link = document.createElement(record.SrNumber ? "a" : "span");
link.textContent = record.SrNumber ? String(record.SrNumber) : "N/D";
link.textContent = record.SrNumber ? String(record.SrNumber) : "N/A";
if (record.SrNumber) {
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(record.SrNumber))}&srId=${encodeURIComponent(String(record.SrId ?? ""))}`;
link.target = "_blank";
@@ -126,7 +149,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
body.className = "opportunities-extension-sr-details";
body.textContent = ["Title", "OpportunityName_c", "StatusCd", "ServiceNew_c", "AccountPartyName",
"LastUpdateDate", "SvcHealth_c", "AssigneePersonName", "OpenDate"].map(key =>
`${key === "ServiceNew_c" ? "Service Queue" : key}: ${record[key] == null || record[key] === "" ? "N/D" : String(record[key])}`).join("\n");
`${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);
children.append(child);
}

View File

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

View File

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

View File

@@ -25,6 +25,7 @@ function createOpportunitiesFilters({
saveFilterPreferences,
stringifySearchValue,
matchesActivities,
matchesProductType = () => true,
environment
}) {
const { document } = environment;
@@ -345,7 +346,8 @@ function createOpportunitiesFilters({
&& (allCustomers || opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)))
&& (allOwners || opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)))
&& matchesTableSearch(item, normalizedSearch)
&& matchesActivities(item);
&& matchesActivities(item)
&& matchesProductType(item);
});
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);

View File

@@ -1,15 +1,15 @@
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) {
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth();
const fiscalStartYear = currentMonth >= 5 ? currentYear : currentYear - 1;
const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3);
const fiscalStartYear = currentMonth >= FISCAL_START_MONTH ? currentYear : currentYear - 1;
const currentQuarterIndex = Math.floor(((currentMonth - FISCAL_START_MONTH + 12) % 12) / 3);
if (period === "Current Fiscal Year") {
const isBeforeFiscalYearStart = currentMonth < 5;
const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear;
const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1;
return createDateRange(startYear, 5, endYear, 4);
return createDateRange(fiscalStartYear, FISCAL_START_MONTH, fiscalStartYear + 1, FISCAL_START_MONTH);
}
let quarterOffset;
@@ -28,19 +28,14 @@ function createOpportunitiesFiscalPeriods() {
return null;
}
const start = new Date(fiscalStartYear, 5 + (quarterOffset * 3), 1);
const end = new Date(start.getFullYear(), start.getMonth() + (numberOfQuarters * 3), 0);
return {
startCloseDate: formatOracleDate(start),
endCloseDate: formatOracleDate(end)
};
const startMonth = FISCAL_START_MONTH + (quarterOffset * 3);
return createDateRange(fiscalStartYear, startMonth, fiscalStartYear, startMonth + (numberOfQuarters * 3));
}
function createDateRange(startYear, startMonth, endYear, endMonth) {
return {
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) {
return number.toLocaleString();
return number.toLocaleString("en-US");
}
try {
@@ -153,7 +153,7 @@ function createOpportunitiesFormatters({
currency
}).format(number);
} 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

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

View File

@@ -1,20 +1,22 @@
function createProductActivities({ productsContentState, opportunitiesQueryState,
loadCrm, loadActions, blockActions, render }) {
let cycle = null;
function begin(requestId, token) {
cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false };
function begin(requestId, token, opportunityNumbers = null) {
cycle = { requestId, token, numbers: opportunityNumbers && new Set(opportunityNumbers.map(String)), version: opportunitiesQueryState.periodRequestVersion, completed: false };
}
function complete(requestId) {
const current = cycle;
if (!current || current.completed || current.requestId !== requestId
|| productsContentState.currentProductsRequestId !== requestId) return;
current.completed = true;
const 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;
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId
&& opportunitiesQueryState.periodRequestVersion === current.version;
return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })).then(async success => {
// Product cache hits do not guarantee that the corresponding activity cache exists.
return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: false })).then(async success => {
if (!isActive()) return;
if (success === false) {
products.forEach(blockActions);
@@ -30,7 +32,7 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
seen.add(id);
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);
if (!isActive()) return;

View File

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

View File

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

View File

@@ -0,0 +1,205 @@
function createSearchableSelect({ environment }) {
const { document } = environment;
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function searchableSelect(select, field, onChange, { floating = false, tooltipContent, listWidth = 0, listMaxHeight = 180, 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 input = element("input");
input.type = "text";
input.autocomplete = "off";
input.required = !!select.required;
input.setAttribute("aria-required", String(!!select.required));
select.addEventListener("invalid", event => { event.preventDefault(); input.focus(); });
input.id = `${select.id}-search`;
field.htmlFor = input.id;
const list = element("div", "opportunities-extension-action-options");
list.id = `${select.id}-options`;
list.setAttribute("role", "listbox");
input.setAttribute("role", "combobox");
input.setAttribute("aria-autocomplete", "list");
input.setAttribute("aria-controls", list.id);
input.setAttribute("aria-expanded", "false");
select.hidden = true;
select.tabIndex = -1;
box.append(input, list);
field.append(box);
const tooltip = tooltipContent ? element("div", "opportunities-extension-select-tooltip") : null;
if (tooltip) {
tooltip.id = `${select.id}-tooltip`;
tooltip.setAttribute("role", "tooltip");
tooltip.hidden = true;
box.append(tooltip);
}
let tooltipAnchor = null;
function hideTooltip() {
if (!tooltip) return;
tooltip.hidden = true;
tooltipAnchor?.removeAttribute?.("aria-describedby");
tooltipAnchor = null;
}
function showTooltip(value, anchor) {
hideTooltip();
if (!tooltip || !value) return;
const content = tooltipContent(value);
if (!content) return;
tooltip.replaceChildren(content);
tooltip.hidden = false;
tooltipAnchor = anchor;
anchor.setAttribute("aria-describedby", tooltip.id);
const rect = anchor.getBoundingClientRect?.();
const bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
if (!rect || !bounds) return;
const size = tooltip.getBoundingClientRect();
const left = rect.right + 8 + size.width <= bounds.right ? rect.right + 8 : Math.max(bounds.left + 8, rect.left - size.width - 8);
tooltip.style.left = `${left}px`;
tooltip.style.top = `${Math.max(bounds.top + 8, Math.min(rect.top, bounds.bottom - size.height - 8))}px`;
}
function leaveTooltip(event) {
if (!tooltip?.contains?.(event.relatedTarget)) hideTooltip();
}
tooltip?.addEventListener("mouseleave", hideTooltip);
list.addEventListener("scroll", hideTooltip);
let matches = [], index = -1;
const options = () => Array.from(select.children).filter(item => item.value);
const normalize = value => String(value).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
function closeList() {
if (search) cancelSearch();
hideTooltip();
list.hidden = true;
input.setAttribute("aria-expanded", "false");
input.setAttribute("aria-activedescendant", "");
}
function sync() {
closeList();
input.disabled = !!select.disabled;
input.placeholder = select.children[0]?.textContent || "Select";
input.value = options().find(item => item.value === select.value)?.textContent || "";
input.title = tooltipContent ? "" : input.value;
}
function highlight(withTooltip = true) {
Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index)));
input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : "");
list.children[index]?.scrollIntoView?.({ block: "nearest" });
if (withTooltip && matches[index]) showTooltip(matches[index].value, list.children[index]);
}
function choose(i) {
if (!matches[i]) return;
const changed = select.value !== matches[i].value;
select.value = matches[i].value;
sync();
if (changed) onChange?.();
}
function positionList() {
let bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
const anchor = input.getBoundingClientRect?.();
if (!bounds || !anchor) return;
if (floating && listWidth && document.documentElement?.clientWidth) {
bounds = { top: 0, left: 0, right: document.documentElement.clientWidth, bottom: document.documentElement.clientHeight };
}
const below = Math.max(0, bounds.bottom - anchor.bottom - 8);
const above = Math.max(0, anchor.top - bounds.top - 8);
const opensAbove = below < listMaxHeight && above > below;
list.setAttribute("data-placement", opensAbove ? "above" : "below");
list.style.maxHeight = `${Math.min(listMaxHeight, opensAbove ? above : below)}px`;
if (floating) {
const width = listWidth ? Math.min(Math.max(anchor.width, listWidth), bounds.right - bounds.left - 16) : anchor.width;
list.style.position = "fixed";
list.style.left = `${listWidth ? Math.max(bounds.left + 8, Math.min(anchor.left, bounds.right - width - 8)) : anchor.left}px`;
list.style.width = `${width}px`;
list.style.right = "auto";
list.style.bottom = "auto";
list.style.top = `${opensAbove ? anchor.top - 4 : anchor.bottom + 4}px`;
list.style.transform = opensAbove ? "translateY(-100%)" : "none";
}
}
function render(query = "", statusText = "") {
if (input.disabled) return;
hideTooltip();
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 = "";
matches.forEach((item, i) => {
const row = element("div", "opportunities-extension-action-option", item.textContent);
const description = item.getAttribute?.("data-description");
if (description) row.append(element("small", "opportunities-extension-option-description", description));
row.id = `${list.id}-${i}`;
row.setAttribute("role", "option");
row.setAttribute("aria-selected", "false");
row.addEventListener("mousedown", event => event.preventDefault());
row.addEventListener("click", event => { event.preventDefault(); choose(i); });
if (tooltip) {
row.addEventListener("mouseenter", () => showTooltip(item.value, row));
row.addEventListener("mouseleave", leaveTooltip);
}
list.append(row);
});
if (!matches.length) {
const empty = element("div", "opportunities-extension-action-option", statusText || "No results");
empty.setAttribute("role", "status");
list.append(empty);
}
positionList();
list.hidden = false;
input.setAttribute("aria-expanded", "true");
highlight(false);
}
input.addEventListener("click", () => { render(); input.select?.(); });
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);
if (tooltip) {
input.addEventListener("mouseenter", () => { if (list.hidden) showTooltip(select.value, input); });
input.addEventListener("mouseleave", leaveTooltip);
}
input.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (list.hidden) render();
if (matches.length) index = (index + (event.key === "ArrowDown" ? 1 : index < 0 ? 0 : -1) + matches.length) % matches.length;
highlight();
} else if (event.key === "Enter" && !list.hidden) {
event.preventDefault();
choose(index < 0 && matches.length === 1 ? 0 : index);
} else if (event.key === "Escape" && (!list.hidden || (tooltip && !tooltip.hidden))) {
event.preventDefault();
event.stopPropagation();
sync();
} else if (event.key === "Tab") sync();
});
sync();
return { input, sync, destroy() { disposed = true; closeList(); } };
}
return searchableSelect;
}

View File

@@ -0,0 +1,30 @@
function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, sendRequest, reportProgress, appendLog, onReady = async () => {} }) {
let pending = null;
let failed = false;
function ensure() {
if (pending) return pending;
pending = (async () => {
failed = false;
try {
reportProgress("running");
// 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 || []);
if (!result?.ok) throw new Error(result?.error || "Session initialization failed.");
if (!allowsCrmActivities()) { reportProgress("idle"); return; }
reportProgress("complete");
await onReady();
} catch {
failed = true;
reportProgress("error");
}
})();
return pending;
}
function retry() {
if (failed) pending = null;
return ensure();
}
return { ensure, retry };
}

View File

@@ -309,6 +309,36 @@
.opportunities-extension-action-form-field textarea { resize: vertical; min-height: 96px; }
.opportunities-extension-action-form-field select { height: 38px; }
.opportunities-extension-action-form-field select[hidden] { display: none; }
.opportunities-extension-sr-form-dialog { width: min(900px, 100%); }
.opportunities-extension-sr-form-dialog > .opportunities-extension-account-plans-header { padding: 20px 24px; }
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { gap: 20px 24px; padding: 24px; align-items: start; }
.opportunities-extension-sr-form-dialog .opportunities-extension-action-form-field { gap: 8px; }
.opportunities-extension-sr-form-dialog textarea { min-height: 112px; }
.opportunities-extension-sr-form-dialog .opportunities-extension-sr-service-option { padding: 12px 0; gap: 10px; }
.opportunities-extension-sr-form-dialog .opportunities-extension-sr-service-abstract { font-size: 13px; line-height: 20px; }
@media (min-width: 1000px) {
.opportunities-extension-sr-form-dialog { width: min(1120px, 100%); }
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px 24px; }
.opportunities-extension-sr-services { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 28px; align-items: start; }
.opportunities-extension-sr-services .opportunities-extension-sr-service-option { margin: 0; border-top: 1px solid #aaa6a040; }
.opportunities-extension-sr-services .opportunities-extension-sr-service-option:nth-child(-n+2) { border-top: 0; }
}
@media (max-width: 600px) {
.opportunities-extension-sr-form-dialog > .opportunities-extension-account-plans-header { padding: 16px; }
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); gap: 18px; padding: 16px; }
}
.opportunities-extension-sr-lookup { min-width: 0; padding: 0; margin: 0; border: 0; font-size: 13px; }
.opportunities-extension-sr-lookup legend { padding: 0 0 8px; font-weight: 600; }
.opportunities-extension-sr-lookup[aria-busy="true"] > p { opacity: .65; }
.opportunities-extension-sr-service-option { display: flex; align-items: flex-start; gap: 8px; padding: 9px 0; cursor: pointer; }
.opportunities-extension-sr-service-option + .opportunities-extension-sr-service-option { border-top: 1px solid #aaa6a040; }
.opportunities-extension-sr-service-option input { flex: 0 0 16px; width: 16px; height: 16px; margin: 2px 0 0; accent-color: #00758f; }
.opportunities-extension-sr-service-option > span { min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-sr-service-name { display: block; font-weight: 600; line-height: 20px; }
.opportunities-extension-sr-service-abstract { display: block; margin-top: 3px; color: #625d58; font-size: 12px; line-height: 18px; white-space: pre-wrap; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-service-abstract { color: #c7c2bc; }
.opportunities-extension-sr-lookup-retry { color: inherit; background: transparent; border: 1px solid #8f8983; border-radius: 4px; padding: 5px 10px; cursor: pointer; }
.opportunities-extension-sr-lookup-retry[hidden] { display: none; }
.opportunities-extension-action-form [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form [aria-invalid="true"] { border-color: #ffaaa4; outline-color: #ffaaa4; }
.opportunities-extension-modal .opportunities-extension-copy-toast { z-index: 20; }
@@ -378,10 +408,18 @@
/* Keep layout rules on the outer table, never on expanded product tables. */
.opportunities-extension-table[data-opportunities-layout="inline-actions"] {
width: 100%;
min-width: 1120px;
min-width: 1168px;
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. */
.opportunities-extension-table > thead > tr > th[data-column="name"] { width: auto; }
.opportunities-extension-table > thead > tr > th[data-column="customer"] { width: auto; }
@@ -456,7 +494,7 @@
}
.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 {
@@ -585,3 +623,11 @@
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: none; }
}
.opportunities-extension-option-description { display: block; font-size: 12px; line-height: 1.4; margin-top: 4px; white-space: normal; overflow-wrap: anywhere; opacity: .85; }
.opportunities-extension-action-form-dialog.opportunities-extension-internal-sr-dialog { width: min(760px, 100%); }
.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({
tableSorting,
ACCOUNT_DETAIL_URL,
OPPORTUNITIES_COLUMNS,
OPPORTUNITIES_TABLE_ID,
@@ -6,10 +7,12 @@ function createOpportunitiesTable({
createCloudConsumptionButton,
createInstallBaseButton,
createServiceRequestsButton,
createOrganizationChartButton,
createCopyOpportunityButton,
createDetailLink,
createProductsCountButton,
createProductsRampBadge,
createProjectionIndicator,
createCrmServiceRequestBadge,
closeCrmSrPopover,
createWorkloadActionsBadge,
@@ -25,6 +28,7 @@ function createOpportunitiesTable({
renderOwnerFilter,
renderActivityFilters,
renderStageDashboard,
renderCalendar,
resetCustomerFilter,
resetOwnerFilter,
environment
@@ -39,8 +43,8 @@ function createOpportunitiesTable({
opportunitiesTableState.opportunitiesTableState = {
...opportunitiesTableState.opportunitiesTableState,
...nextState,
sortKey: nextState.items ? "" : opportunitiesTableState.opportunitiesTableState.sortKey,
sortDirection: nextState.items ? "ascending" : opportunitiesTableState.opportunitiesTableState.sortDirection
sortKey: tableSorting.read("overview", OPPORTUNITIES_COLUMNS).key,
sortDirection: tableSorting.read("overview", OPPORTUNITIES_COLUMNS).direction
};
renderOpportunitiesTable();
renderCustomerFilter();
@@ -62,7 +66,7 @@ function createOpportunitiesTable({
const caption = document.createElement("caption");
caption.className = "opportunities-extension-visually-hidden";
caption.textContent = "Opportunities";
caption.textContent = opportunitiesTableState.opportunitiesTableState.status === "loading" ? "Loading opportunities..." : "Opportunities";
const tableHead = document.createElement("thead");
const headerRow = document.createElement("tr");
@@ -72,6 +76,7 @@ function createOpportunitiesTable({
const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key;
header.scope = "col";
header.setAttribute("data-column", column.key);
if (column.key === "revenue") header.setAttribute("data-align", "money");
if (column.sortable === false) {
const label = document.createElement("span");
@@ -83,6 +88,7 @@ function createOpportunitiesTable({
const sortButton = document.createElement("button");
sortButton.type = "button";
sortButton.disabled = opportunitiesTableState.opportunitiesTableState.status === "loading";
sortButton.className = "opportunities-extension-sort-button";
sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`);
@@ -102,8 +108,35 @@ function createOpportunitiesTable({
const tableBody = document.createElement("tbody");
const items = getSortedOpportunities();
renderStageDashboard(items, opportunitiesTableState.opportunitiesTableState.items.length);
renderCalendar?.(items, opportunitiesTableState.opportunitiesTableState);
if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
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 cell = document.createElement("td");
cell.className = "opportunities-extension-table-status";
@@ -118,6 +151,7 @@ function createOpportunitiesTable({
OPPORTUNITIES_COLUMNS.forEach((column) => {
const cell = document.createElement("td");
cell.setAttribute("data-column", column.key);
if (column.key === "revenue") cell.setAttribute("data-align", "money");
if (column.key === "stage") {
cell.append(createStageBadge(column.display(item)));
@@ -137,9 +171,12 @@ function createOpportunitiesTable({
const productsResult = item.OptyNumber && productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
if (productsResult) {
productActions.append(createProductsCountButton(item.OptyNumber, productsResult));
const rampBadge = createProductsRampBadge(productsResult);
const rampBadge = createProductsRampBadge(productsResult, item);
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);
if (srBadge) productActions.append(srBadge);
const actionsBadge = createWorkloadActionsBadge(item.OptyNumber);
@@ -165,7 +202,7 @@ function createOpportunitiesTable({
);
const customerActions = document.createElement("span");
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);
cell.append(customerCellContent);
} else {
@@ -187,11 +224,13 @@ function createOpportunitiesTable({
}
function sortOpportunitiesBy(key) {
if (!OPPORTUNITIES_COLUMNS.some(column => column.key === key && column.sortable !== false)) return;
const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key;
opportunitiesTableState.opportunitiesTableState.sortKey = key;
opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending"
? "descending"
: "ascending";
tableSorting.save("overview", { key, direction: opportunitiesTableState.opportunitiesTableState.sortDirection });
renderOpportunitiesTable();
}

View File

@@ -1,5 +1,5 @@
function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, getProducts,
requestTeamMembers, requestLookup, getCurrentUserId, saveAction, onActionSaved, notify, formatDate, createCloseIcon, environment }) {
requestTeamMembers, requestLookup, getCurrentUserId, saveAction, onActionSaved, notify, formatDate, createCloseIcon, searchableSelect, environment }) {
const { document } = environment;
let active = null;
function element(tag, className, text) {
@@ -8,109 +8,6 @@ function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingActi
if (text !== undefined) node.textContent = text;
return node;
}
function searchableSelect(select, field, onChange) {
const box = element("div", "opportunities-extension-action-combobox");
const input = element("input");
input.type = "text";
input.autocomplete = "off";
input.required = !!select.required;
input.setAttribute("aria-required", String(!!select.required));
select.addEventListener("invalid", event => { event.preventDefault(); input.focus(); });
input.id = `${select.id}-search`;
field.htmlFor = input.id;
const list = element("div", "opportunities-extension-action-options");
list.id = `${select.id}-options`;
list.setAttribute("role", "listbox");
input.setAttribute("role", "combobox");
input.setAttribute("aria-autocomplete", "list");
input.setAttribute("aria-controls", list.id);
input.setAttribute("aria-expanded", "false");
select.hidden = true;
select.tabIndex = -1;
box.append(input, list);
field.append(box);
let matches = [], index = -1;
const options = () => Array.from(select.children).filter(item => item.value);
const normalize = value => String(value).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
function closeList() {
list.hidden = true;
input.setAttribute("aria-expanded", "false");
input.setAttribute("aria-activedescendant", "");
}
function sync() {
closeList();
input.disabled = !!select.disabled;
input.placeholder = select.children[0]?.textContent || "Select";
input.value = options().find(item => item.value === select.value)?.textContent || "";
input.title = input.value;
}
function highlight() {
Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index)));
input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : "");
list.children[index]?.scrollIntoView?.({ block: "nearest" });
}
function choose(i) {
if (!matches[i]) return;
const changed = select.value !== matches[i].value;
select.value = matches[i].value;
sync();
if (changed) onChange?.();
}
function positionList() {
const bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
const anchor = input.getBoundingClientRect?.();
if (!bounds || !anchor) return;
const below = Math.max(0, bounds.bottom - anchor.bottom - 8);
const above = Math.max(0, anchor.top - bounds.top - 8);
const opensAbove = below < 180 && above > below;
list.setAttribute("data-placement", opensAbove ? "above" : "below");
list.style.maxHeight = `${Math.min(180, opensAbove ? above : below)}px`;
}
function render(query = "") {
if (input.disabled) return;
matches = options().filter(item => normalize(`${item.textContent} ${item.value}`).includes(normalize(query)));
index = -1;
list.textContent = "";
matches.forEach((item, i) => {
const row = element("div", "opportunities-extension-action-option", item.textContent);
row.id = `${list.id}-${i}`;
row.setAttribute("role", "option");
row.setAttribute("aria-selected", "false");
row.addEventListener("mousedown", event => event.preventDefault());
row.addEventListener("click", event => { event.preventDefault(); choose(i); });
list.append(row);
});
if (!matches.length) {
const empty = element("div", "opportunities-extension-action-option", "No results");
empty.setAttribute("role", "status");
list.append(empty);
}
positionList();
list.hidden = false;
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-activedescendant", "");
}
input.addEventListener("click", () => { render(); input.select?.(); });
input.addEventListener("input", () => render(input.value));
input.addEventListener("blur", sync);
input.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (list.hidden) render();
if (matches.length) index = (index + (event.key === "ArrowDown" ? 1 : index < 0 ? 0 : -1) + matches.length) % matches.length;
highlight();
} else if (event.key === "Enter" && !list.hidden) {
event.preventDefault();
choose(index < 0 && matches.length === 1 ? 0 : index);
} else if (event.key === "Escape" && !list.hidden) {
event.preventDefault();
event.stopPropagation();
sync();
} else if (event.key === "Tab") sync();
});
sync();
return { input, sync };
}
function close(restoreFocus = true) {
if (!active) return false;
if (active.saving) return true;

View File

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

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

View File

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

View File

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

View File

@@ -1,28 +1,54 @@
function createProductsDetails({
OPPORTUNITIES_COLUMNS,
setBadgeContent,
createAiSummaryAlert,
createProjectionIndicator,
openProjection,
formatOracleResponseDate,
productsContentState,
renderOpportunitiesTable,
requestGenerateAiSummary,
environment
}) {
const { document } = environment;
function createProductsRampBadge(productsResult) {
const values = (Array.isArray(productsResult.items) ? productsResult.items : [])
.map(item => item && item.RampMonths_c)
.map(value => value === null || value === undefined || String(value).trim() === "" ? "N/D" : String(value).trim())
.filter((value, index, items) => value !== "N/D" || items.indexOf(value) === index);
if (!values.length) return null;
function isBooking(products) {
return Boolean(products?.length) && products.every(item => !String(item?.WorkloadName_c ?? "").trim() || String(item.WorkloadName_c).trim() === "-");
}
function createProductsRampBadge(productsResult, opportunity) {
const products = Array.isArray(productsResult.items) ? productsResult.items : [];
if (isBooking(products)) {
const badge = document.createElement("span");
badge.className = "opportunities-extension-products-ramp";
badge.setAttribute("data-missing", String(values.includes("N/D")));
setBadgeContent(badge, "ramp", values.join(","));
badge.title = `Ramp Months: ${values.join(", ")}`;
badge.setAttribute("data-booking", "true");
badge.textContent = "Booking";
badge.title = "Booking: no product has a Workload assigned.";
badge.setAttribute("aria-label", badge.title);
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) {
const normalizedOptyNumber = String(optyNumber);
@@ -44,8 +70,8 @@ function createProductsDetails({
if (isExpanded) {
productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber);
} else {
productsContentState.expandedProductOptyNumbers.add(normalizedOptyNumber);
requestGenerateAiSummary(normalizedOptyNumber);
expandProducts(normalizedOptyNumber);
return;
}
renderOpportunitiesTable();
@@ -77,7 +103,6 @@ function createProductsDetails({
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
heading.append(title, summary);
panel.append(heading);
panel.append(createAiSummaryAlert(optyNumber));
if (productsResult && productsResult.error) {
const error = document.createElement("p");
@@ -92,6 +117,7 @@ function createProductsDetails({
} else {
const table = document.createElement("table");
table.className = "opportunities-extension-products-table";
table.setAttribute("data-table-layout", "standard");
table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`);
const columns = [
@@ -105,7 +131,8 @@ function createProductsDetails({
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
{ label: "Ramp Months", value: (item) => item.RampMonths_c === null || item.RampMonths_c === undefined || String(item.RampMonths_c).trim() === ""
? "N/D" : formatProductNumber(item.RampMonths_c) }
? "N/A" : formatProductNumber(item.RampMonths_c) },
{ label: "Projection", render: (item) => createProjectionIndicator(item, opportunity) }
];
const head = document.createElement("thead");
@@ -113,6 +140,7 @@ function createProductsDetails({
columns.forEach((column) => {
const header = document.createElement("th");
header.scope = "col";
if (column.label === "Amount") header.setAttribute("data-align", "money");
header.textContent = column.label;
headerRow.append(header);
});
@@ -123,7 +151,11 @@ function createProductsDetails({
const productRow = document.createElement("tr");
columns.forEach((column) => {
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);
});
body.append(productRow);
@@ -165,5 +197,5 @@ function createProductsDetails({
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[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;
let active = null;
let closing = null;
@@ -65,12 +65,80 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
search.setAttribute("aria-label", "Search rate card");
const count = element("span", "count", "Loading rate card...");
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 (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`;
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");
viewport.tabIndex = 0;
viewport.setAttribute("role", "region");
viewport.setAttribute("aria-label", "Rate card items");
const table = element("table", "table");
table.setAttribute("data-table-layout", "standard");
const caption = element("caption", null, "Rate card items");
caption.className = "opportunities-extension-visually-hidden";
const colgroup = element("colgroup");
@@ -78,6 +146,9 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
const headRow = element("tr");
const sortHeaders = [];
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;
background.inert = true;
const alive = () => active === current && root.isConnected && !parent.hidden;
@@ -87,6 +158,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
colgroup.append(col);
const th = element("th");
th.scope = "col";
if (column.type === "price") th.setAttribute("data-align", "money");
th.setAttribute("aria-sort", "none");
const sort = element("button", "sort");
sort.type = "button";
@@ -98,6 +170,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
sort.addEventListener("click", () => {
current.direction = current.sortKey === column.key && current.direction === "asc" ? "desc" : "asc";
current.sortKey = column.key;
tableSorting.save("rate-card", { key: current.sortKey, direction: current.direction === "desc" ? "descending" : "ascending" });
updateSortHeaders();
if (current.status === "ready") renderRows();
});
@@ -106,6 +179,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
headRow.append(th);
});
head.append(headRow);
updateSortHeaders();
const body = element("tbody");
table.append(caption, colgroup, head, body);
viewport.append(table);
@@ -134,7 +208,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
function renderRows() {
if (!alive()) return;
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`;
if (!visible.length) {
showNotice(current.items.length ? "No matching rate card items." : "No rate card items found for this plan.");
@@ -143,6 +217,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
const row = element("tr");
columns.forEach(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";
row.append(cell);
});
@@ -167,9 +242,12 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
if (!orderPanel.hidden) { closeOrders(); orderButton.focus(); return; }
closeRatecardDrawer();
} 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);
if (index < 0 || (event.shiftKey && index === 0) || (!event.shiftKey && index === controls.length - 1)) {
event.preventDefault();
@@ -191,6 +269,10 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
showItems(items) {
if (!alive()) return;
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";
panel.setAttribute("aria-busy", "false");
renderRows();

View File

@@ -38,3 +38,5 @@
@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-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; }

View File

@@ -1,4 +1,4 @@
function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, environment }) {
function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, tableSorting, environment }) {
const { document } = environment;
let active = null;
const columns = [
@@ -31,21 +31,24 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
const text = value === null || value === undefined || value === "" ? "-" : String(value);
return text !== "-" && (key === "created" || key === "lastUpdated") ? formatOracleResponseDate(value) : text;
}
function createTable(items, loading = false) {
const sortColumns = columns.map(([key, , value]) => ({ key, value, type: ["created", "lastUpdated"].includes(key) ? "date" : "text" }));
function createTable(items, loading = false, sorting = tableSorting.create("service-requests", sortColumns), onSort) {
const table = element("table", "opportunities-extension-service-requests-table");
table.setAttribute("data-table-layout", "standard");
table.setAttribute("aria-label", "Customer service requests");
const group = element("colgroup");
const head = element("thead");
const header = element("tr");
columns.forEach(([key, label]) => {
group.append(element("col", `service-requests-col-${key}`));
const th = element("th", "", label);
const th = element("th");
th.scope = "col";
sorting.header(th, key, label, onSort, loading);
header.append(th);
});
head.append(header);
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");
columns.forEach(([key, , getValue]) => {
const cell = element("td");
@@ -99,6 +102,7 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
const status = element("p", "opportunities-extension-service-requests-message", "Loading service requests...");
status.setAttribute("role", "status");
let loadedItems = [], loaded = false, failure = "";
const sorting = tableSorting.create("service-requests", sortColumns);
const search = createSearch({ label: "Search service requests", onSearch: renderResults,
getValues: item => columns.flatMap(([key, , getValue]) => [getValue(item), formatValue(getValue(item), key)]) });
const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
@@ -111,7 +115,7 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
modal.addEventListener("keydown", event => {
if (event.key === "Tab") {
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);
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus();
}
@@ -126,7 +130,10 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
: "No open service requests found for this customer.");
status.setAttribute("role", failure ? "alert" : "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;
}
return { render(items, error = "") {

View File

@@ -29,6 +29,19 @@ function createPlatformBackgroundApi({
});
}
function tabsUpdate(tabId, details) {
if (runtimeApi.tabs.update.length <= 2) {
return runtimeApi.tabs.update(tabId, details);
}
return new Promise((resolve, reject) => {
runtimeApi.tabs.update(tabId, details, tab => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) reject(new Error(lastError.message));
else resolve(tab);
});
});
}
function tabsRemove(tabId) {
if (runtimeApi.tabs.remove.length <= 1) {
return runtimeApi.tabs.remove(tabId);
@@ -186,5 +199,5 @@ function createPlatformBackgroundApi({
);
}
return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues };
return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsUpdate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues };
}

View File

@@ -1,9 +1,13 @@
function createPlatformContentMessages({
messageTypes,
requestCache,
environment
}) {
const { browser, chrome } = environment;
function sendRuntimeMessage(message) {
return requestCache ? requestCache.send(message, sendUncached) : sendUncached(message);
}
function sendUncached(message) {
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {

View File

@@ -0,0 +1,105 @@
function createFusionAccountContact({ environment, getToken, logRequestStart, logRequestSuccess, logRequestFailure, readResponseBodyForDebug }) {
async function requestQueryAccountContact(accountId, keywords, isActive = () => true, contactPartyId = null) {
if (!/^\d+$/.test(String(accountId)) || !Number.isSafeInteger(Number(accountId))) throw new Error("The customer's PartyId is unavailable.");
if (contactPartyId != null && !/^\d+$/.test(String(contactPartyId))) throw new Error("The contact's PartyId is unavailable.");
if ((!String(keywords || "").trim() && contactPartyId == null) || !isActive()) return [];
const token = await getToken();
if (!isActive()) return [];
const url = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries";
const fields = ["PartyName", "PrimaryEmail.EmailAddress", "JobTitle", "PrimaryAccount.PartyName", "PartyId", "PartyNumber"];
const payload = { aggregationResults: false, skipInValidFields: true, skipHiddenFromUIFields: true,
language: "en", onlyData: true, keywords, entity: "Contact", fields, keywordsFields: fields,
q: { op: "$and", criteria: [{ op: "$or", innerQuery: true, criteria: [{ attribute: "PrimaryAccount.PartyId", op: "$eq", value: Number(accountId) }] }], innerQuery: true },
sort: [{ attribute: "_score", direction: "descending" }], limit: 15, offset: 0 };
if (contactPartyId != null) {
payload.keywords = "";
payload.q.criteria.push({ attribute: "PartyId", op: "$eq", value: String(contactPartyId) });
}
const headers = { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`, Preference: "transient", Origin: "https://eeho.fa.us2.oraclecloud.com" };
const entry = logRequestStart({ name: "requestQueryAccountContact", method: "POST", url, headers, body: JSON.stringify(payload, null, 2) });
try {
const response = await environment.fetch.call(environment.window, url, { method: "POST", credentials: "include", headers, body: JSON.stringify(payload) });
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
if (!response.ok) throw new Error(`requestQueryAccountContact failed with status ${response.status}.`);
const data = await response.json();
if (!data || !Array.isArray(data.items)) throw new Error("requestQueryAccountContact returned an invalid response.");
if (!isActive()) return [];
const contacts = new Map();
for (const item of data.items) if (item?.PartyId != null && typeof item.PartyName === "string" && item.PartyName.trim()) {
contacts.set(String(item.PartyId), { ...item, PartyId: String(item.PartyId) });
}
return [...contacts.values()];
} catch (error) { logRequestFailure(entry, error.message); throw error; }
}
async function requestPersistContact(account, values) {
if (!/^\d+$/.test(String(account?.PartyId)) || !String(account?.PartyNumber || "").trim()) throw new Error("The customer's PartyId or PartyNumber is unavailable.");
const phone = (number, country) => {
if (!number && !country) return "";
if (!number || !country) throw new Error("Select a country code and enter a phone number for each phone provided.");
const digits = number.replace(/[\s().-]/g, "");
if (!/^\d+$/.test(digits)) throw new Error("Enter the phone number using digits only, without the country code.");
return country + "-" + digits;
};
const payload = {
FirstName: values.firstName.trim(), LastName: values.lastName.trim(),
AccountPartyId: String(account.PartyId), AccountPartyNumber: String(account.PartyNumber),
JobTitle: values.jobTitle.trim(), Type: "ZCA_CONTACT",
EmailAddress: values.email.trim(),
RawWorkPhoneNumber: phone(values.workPhone.trim(), values.workCountry), WorkPhoneCountryCode: values.workCountry,
WorkPhoneExtension: values.workExtension.trim(), MobileCountryCode: values.mobileCountry,
RawMobileNumber: phone(values.mobilePhone.trim(), values.mobileCountry)
};
if (!payload.FirstName || !payload.LastName || !payload.EmailAddress) throw new Error("First Name, Last Name and Email Address are required.");
for (const key of Object.keys(payload)) if (payload[key] === "") delete payload[key];
for (const [choice, allow, deny] of [
[values.emailConsent, "DoContactEmailFlag", "DoNotContactEmailFlag"],
[values.workConsent, "DoCallWorkPhoneFlag", "DoNotCallWorkPhoneFlag"],
[values.mobileConsent, "DoCallMobilePhoneFlag", "DoNotCallMobileFlag"]
]) {
if (choice === "allow" || choice === "deny") {
payload[allow] = choice === "allow";
payload[deny] = choice === "deny";
}
}
const body = JSON.stringify(payload);
const token = await getToken();
const url = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/rest/rv:e75ce5f9-a69f-4c6a-8591-6b06a538829e/en/11.13.18.05:9/contacts?onlyData=true";
const headers = { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`, Origin: "https://eeho.fa.us2.oraclecloud.com" };
const entry = logRequestStart({ name: "requestPersistContact", method: "POST", url, headers, body: JSON.stringify(payload, null, 2) });
try {
const response = await environment.fetch.call(environment.window, url, { method: "POST", credentials: "include", headers, body });
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
if (!response.ok) throw new Error(`requestPersistContact failed with status ${response.status}.`);
const text = await response.text();
try { return text ? JSON.parse(text) : null; } catch { return null; }
} catch (error) { logRequestFailure(entry, error.message); throw error; }
}
async function requestOpportunitiesByContact(partyNumber, offset = 0, context = {}) {
const baseUrl = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/rest/rv:e75ce5f9-a69f-4c6a-8591-6b06a538829e/en/11.13.18.05:9/opportunities";
let entry;
try {
if (!String(partyNumber || "").trim() && context.resolvePartyNumber) partyNumber = await context.resolvePartyNumber();
partyNumber = String(partyNumber || "").trim();
if (!/^[A-Za-z0-9]{1,100}$/.test(partyNumber)) throw new Error("The person's PartyNumber is missing or invalid. The opportunities HTTP request was not sent.");
if (context.isActive && !context.isActive()) throw new Error("Opportunities lookup cancelled because its dialog was closed. HTTP request was not sent.");
const token = await getToken();
const fields = "SalesStageId,KeyContactId,EffectiveDate,WinProb,CurrencyCode,Revenue,SalesStage,Name,ManageRestrictedProfileDataFlag,OptyId,OptyNumber,ManageRevenueDataFlag,StatusCode";
const query = encodeURIComponent("(OpportunityContact.ContactPartyNumber='" + partyNumber + "')").replace(/[()']/g, character => "%" + character.charCodeAt(0).toString(16).toUpperCase());
const url = baseUrl + "?onlyData=true&q=" + query + "&totalResults=true&fields=" + encodeURIComponent(fields) + "&orderBy=LastUpdateDate%3Adesc&limit=5&offset=" + offset;
const headers = { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`, Preference: "transient", Origin: "https://eeho.fa.us2.oraclecloud.com" };
entry = logRequestStart({ name: "requestOpportunitiesByContact", method: "GET", url, headers,
metadata: { contactPartyId: context.contactPartyId || "", contactName: context.contactName || "", partyNumber, offset, httpRequestSent: true } });
const response = await environment.fetch.call(environment.window, url, { method: "GET", credentials: "include", headers });
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
if (!response.ok) throw new Error(`requestOpportunitiesByContact failed with status ${response.status}.`);
const data = await response.json();
if (!Array.isArray(data?.items)) throw new Error("requestOpportunitiesByContact returned an invalid response.");
return data;
} catch (error) {
if (!entry) entry = logRequestStart({ name: "resolveContactForOpportunities", method: "LOCAL", url: "(HTTP request not sent)",
metadata: { contactPartyId: context.contactPartyId || "", contactName: context.contactName || "", offset, httpRequestSent: false, targetRequest: "requestOpportunitiesByContact" } });
logRequestFailure(entry, error.message); throw error;
}
}
return { requestQueryAccountContact, requestPersistContact, requestOpportunitiesByContact };
}

View File

@@ -11,7 +11,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
] },
keywords: null,
fields: ["SrNumber", "OpportunityName_Id_c.OptyNumber", "ReportedHours_c", "_actions", "Title",
fields: ["SrNumber", "OpportunityName_Id_c.OptyNumber", "ReportedHours_c", "CustomerDeadline_c", "PrimaryPillarNew_c", "_actions", "Title",
"Owner.PersonFirstName", "Owner.PersonLastName", "Owner.PrimaryEmail.EmailAddress", "DoNotCallFlag", "DoNotEmailFlag"],
sort: [{ attribute: "_score", direction: "descending" }], language: "en",
keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c",
@@ -29,7 +29,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
};
const entry = logRequestStart({ name: "resquestCrmServiceRequest", method: "POST", url,
headers: { ...headers, Authorization: `Bearer ${maskToken(token)}` }, body: JSON.stringify(payload, null, 2), metadata: { offset, attempt } });
headers: { ...headers }, body: JSON.stringify(payload, null, 2), metadata: { offset, attempt } });
try {
const response = await fetch(url, {
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)

View File

@@ -8,23 +8,43 @@ function createFusionOpportunities({
maskToken,
getOpportunityTypeValues,
readResponseBodyForDebug,
resetDebugRequests = () => {},
environment
}) {
const { fetch } = environment;
async function requestOpportunitiesPage(token, period, dateRange, offset, accumulatedItems, requestVersion, page) {
async function requestOpportunitiesSavedSearch(token) {
const url = OPPORTUNITIES_QUERY_URL + "?access=userAdmin&applications=461e5791-9f15-4cd1-b5cd-19e8480cbce4&entity=Opportunity&language=en&limit=-1&returnIdentifierOnlyFields=true";
const headers = { Accept: "application/json", Authorization: `Bearer ${token}` };
const entry = logRequestStart({ name: "requestOpportunitiesSavedSearch", method: "GET", url, headers });
try {
const response = await fetch.call(environment.window, url, { method: "GET", credentials: "include", headers });
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
if (!response.ok) throw new Error(`requestOpportunitiesSavedSearch failed with status ${response.status}.`);
const data = await response.json();
if (!Array.isArray(data?.items)) throw new Error("requestOpportunitiesSavedSearch returned an invalid response.");
const defaults = data.items.filter(item => item.isDefault === true);
if (defaults.length !== 1 || typeof defaults[0].savedSearchNumber !== "string" || !defaults[0].savedSearchNumber.trim()) throw new Error("A unique default Opportunity saved search with savedSearchNumber was not returned.");
return defaults[0].savedSearchNumber.trim();
} catch (error) { logRequestFailure(entry, error.message); throw error; }
}
async function requestOpportunitiesPage(token, period, dateRange, offset, accumulatedItems, requestVersion, page, savedSearchNumber) {
if (!isCurrentPeriodRequest(requestVersion)) {
return null;
}
const payload = createOpportunitiesQueryPayload(dateRange, offset);
if (!savedSearchNumber) savedSearchNumber = await requestOpportunitiesSavedSearch(token);
if (!isCurrentPeriodRequest(requestVersion)) return null;
const payload = createOpportunitiesQueryPayload(dateRange, offset, savedSearchNumber);
const requestBody = JSON.stringify(payload);
// One history per query, preserving all pages of the same result set.
if (page === 1) resetDebugRequests();
const requestEntry = logRequestStart({
name: "requestOpportunities",
method: "POST",
url: OPPORTUNITIES_QUERY_URL,
headers: {
Accept: "application/json",
Authorization: `Bearer ${maskToken(token)}`,
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Origin: "https://eeho.fa.us2.oraclecloud.com",
Preference: "transient"
@@ -40,7 +60,7 @@ function createFusionOpportunities({
});
try {
const response = await fetch(OPPORTUNITIES_QUERY_URL, {
const response = await fetch.call(environment.window, OPPORTUNITIES_QUERY_URL, {
method: "POST",
credentials: "include",
headers: {
@@ -84,7 +104,8 @@ function createFusionOpportunities({
currentOffset + receivedCount,
allItems,
requestVersion,
page + 1
page + 1,
savedSearchNumber
);
} catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed.");
@@ -92,7 +113,8 @@ function createFusionOpportunities({
}
}
function createOpportunitiesQueryPayload(dateRange, offset) {
function createOpportunitiesQueryPayload(dateRange, offset, savedSearchNumber) {
if (typeof savedSearchNumber !== "string" || !savedSearchNumber.trim()) throw new Error("Missing default Opportunity savedSearchNumber.");
return {
aggregationResults: false,
applications: ["ORACLE-ISS-APP"],
@@ -102,19 +124,21 @@ function createFusionOpportunities({
op: "$and",
criteria: [
{
op: "$and",
criteria: [{
op: "$or",
criteria: Array.from(getOpportunityTypeValues(), (value) => ({
op: "$eq",
attribute: "RecordSet",
value
}))
}]
},
{
op: "$wi",
attribute: "EffectiveDate",
value1: dateRange.startCloseDate,
value2: dateRange.endCloseDate,
dynamicDate: false
value1: new Date(dateRange.startCloseDate).toISOString().replace(/Z$/, "+0000"),
value2: new Date(dateRange.endCloseDate).toISOString().replace(/Z$/, "+0000")
}
]
},
@@ -149,18 +173,21 @@ function createFusionOpportunities({
"PrimaryRevenue.RevnAmountCurcyCode",
"OptyId",
"CustomerAccount.PartyUniqueName",
"CustomerAccount.PartyName",
"CustomerAccount.PartyId",
"CustomerAccount.PartyNumber"
"CustomerAccount.PartyNumber",
"CustomerAccount.Region_c",
"CustomerAccount.PrimaryAddress.CountryCode"
],
sort: [],
sort: null,
language: "en",
skipInValidFields: true,
skipHiddenFromUIFields: true,
copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162",
copiedFrom: "queries/" + savedSearchNumber,
limit: OPPORTUNITIES_PAGE_LIMIT,
offset
};
}
return { requestOpportunitiesPage, createOpportunitiesQueryPayload };
return { requestOpportunitiesPage, createOpportunitiesQueryPayload, requestOpportunitiesSavedSearch };
}

View File

@@ -0,0 +1,181 @@
function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, runtimeApi }) {
const resourceRequests = new Map();
let resourceGeneration = 0;
const resourceCacheKey = "opportunitiesExtension.resourceUsers.v1";
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/";
const catalog = base + "crmRestApiResources/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/11.13.18.05/Catalogue_c";
const resourceUsers = base + "oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/resourceUsers";
const endpoint = base + "oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/serviceRequests";
function numericId(value, name) {
if (!/^\d+$/.test(String(value ?? "")) || !Number.isSafeInteger(Number(value)) || Number(value) <= 0) throw new Error(`Missing or invalid ${name}.`);
return Number(value);
}
function text(value, name) {
if (typeof value !== "string" || !value.trim()) throw new Error(`Missing ${name}.`);
return value.trim();
}
function dateOnly(value, name) {
const date = text(value, name);
if (!/^\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) throw new Error(`Invalid ${name}.`);
return date;
}
function payloadFor(input) {
const payload = {};
for (const name of ["AccountPartyId", "OpportunityName_Id_c", "ServiceNew_Id_c"]) payload[name] = numericId(input?.[name], name);
for (const name of ["Title", "RegionText_c", "ProblemDescription", "Language_c", "CustomerDeadline_c", "TechPlatformLookUp_c", "CountryText_c", "PrimaryPillarNew_c"]) payload[name] = text(input?.[name], name);
dateOnly(payload.CustomerDeadline_c, "Customer deadline");
return { ...payload, SvcQuestionsCollection_c: [], SourceCd: "SVC_OPPORTUNITY_SCREEN", SeverityCd: "ORA_SVC_SEV2", ChannelTypeCd: "ORA_SVC_WEB" };
}
async function requestResourceUser(message, debugRequests) {
try {
const result = await getResourceUser(message);
debugRequests.push(...result.debugRequests);
return numericId(result.resource.resourcePartyId, "resourcePartyId");
} catch (error) { debugRequests.push(...(error.debugRequests || [])); throw error; }
}
async function getResourceUser(message) {
const emailAddress = text(message.emailAddress, "current user email").toLowerCase();
const duration = Number(message.cacheMaxAgeMs) > 0 ? Math.min(Number(message.cacheMaxAgeMs), 168 * 3600000) : 24 * 3600000;
const fresh = timestamp => Number.isFinite(timestamp) && Date.now() >= timestamp && Date.now() - timestamp < duration;
const current = resourceRequests.get(emailAddress);
if (current && (current.fetchedAt === null || fresh(current.fetchedAt))) return { ok: true, resource: await current.promise, debugRequests: [] };
const generation = resourceGeneration;
const record = { promise: null, fetchedAt: null };
const debugRequests = [];
const pending = (async () => {
try {
const stored = await runtimeApi?.storage?.session?.get(resourceCacheKey);
const cached = stored?.[resourceCacheKey];
if (cached?.emailAddress === emailAddress && cached.resource?.resourcePartyId && fresh(cached.fetchedAt)) {
record.fetchedAt = cached.fetchedAt;
return cached.resource;
}
} catch { /* Session storage is optional; retain the in-memory cache. */ }
const resource = await fetchJson(`${resourceUsers}?emailAddress=${encodeURIComponent(emailAddress)}`, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestResourceUser", debugRequests);
numericId(resource?.resourcePartyId, "resourcePartyId");
record.fetchedAt = Date.now();
if (generation === resourceGeneration) {
try { await runtimeApi?.storage?.session?.set({ [resourceCacheKey]: { emailAddress, resource, fetchedAt: record.fetchedAt } }); } catch { /* Keep the successful response in memory. */ }
}
return resource;
})();
record.promise = pending;
resourceRequests.set(emailAddress, record);
try { return { ok: true, resource: await pending, debugRequests }; }
catch (error) { if (resourceRequests.get(emailAddress) === record) resourceRequests.delete(emailAddress); error.debugRequests = debugRequests; throw error; }
}
async function requestPostInternalServiceRequest(message) {
const debugRequests = [];
try {
const source = message.payload;
const parentSrId = numericId(source?.ServiceRequest_Id_SRToInternalSR, "parent SrId");
const payload = {
Title: text(source?.Title, "Title"),
ServiceNew_Id_c: numericId(source?.ServiceNew_Id_c, "ServiceNew_Id_c"),
ProblemDescription: text(source?.ProblemDescription, "Description"),
CustomerDeadline_c: dateOnly(source?.CustomerDeadline_c, "Customer deadline"),
TechPlatformLookUp_c: text(source?.TechPlatformLookUp_c, "Platform"),
InternalDeadline_c: `${dateOnly(source?.InternalDeadline_c, "Internal Deadline")}T00:00:00.000Z`,
SvcQuestionsCollection_c: []
};
const parent = await requestSRInfo(parentSrId, debugRequests);
payload.ServiceRequest_Id_SRToInternalSR = String(parentSrId);
for (const name of ["AccountPartyId", "OpportunityName_Id_c"]) payload[name] = numericId(parent[name], name);
for (const name of ["OpportunityName_c", "RegionText_c", "SvcHealth_c", "Language_c", "CountryText_c", "PrimaryPillarNew_c"]) payload[name] = text(parent[name], name);
payload.SecondaryPillarNew_c = parent.SecondaryPillarNew_c == null ? null : text(parent.SecondaryPillarNew_c, "SecondaryPillarNew_c");
payload.ReportedByPartyId = await requestResourceUser(message, debugRequests);
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/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 result = await fetchJson(url, { method: "POST", credentials: "include",
headers: await internalServiceRequestHeaders(url),
body: JSON.stringify(payload)
}, "requestPostInternalServiceRequest", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestSRInfo(parentSrId, debugRequests) {
const url = new URL("https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests");
url.searchParams.set("fields", "PrimaryPillarNew_c,SecondaryPillarNew_c,SvcHealth_c,SrNumber,AccountPartyId,CountryText_c,Language_c,OpportunityName_Id_c,OpportunityName_c,RegionText_c,ServiceNew_Id_c");
url.searchParams.set("onlyData", "true");
url.searchParams.set("orderBy", "LastUpdateDate:desc");
url.searchParams.set("q", `SrId=${parentSrId}`);
const result = await fetchJson(url.href, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestSRInfo", debugRequests);
if (!Array.isArray(result?.items) || result.items.length !== 1 || !result.items[0]
|| (result.items[0].SrId != null && String(result.items[0].SrId) !== String(parentSrId))) {
throw new Error("requestSRInfo did not return the selected parent Service Request.");
}
return result.items[0];
}
async function requestPostServiceRequest(message) {
const debugRequests = [];
try {
if (message.phase !== "prepare" && message.phase !== "confirm") throw new Error("Service Request confirmation is required.");
const payload = payloadFor(message.payload);
if (message.phase === "prepare") {
payload.ReportedByPartyId = await requestResourceUser(message, debugRequests);
const sharedHeaders = await previewTokenHeaders(catalog);
return { ok: true, payload, debugRequests, headers: {
requestSrTokenCatalog: sharedHeaders,
requestPostServiceRequest: { ...sharedHeaders, "vb-proxy-header-x-oracle-ectoken": "(generated after confirmation)" }
} };
}
payload.ReportedByPartyId = numericId(message.payload?.ReportedByPartyId, "ReportedByPartyId");
const headers = await requestCreationHeaders(message.catalogId, endpoint, debugRequests);
const result = await fetchJson(endpoint, {
method: "POST", credentials: "include", headers,
body: JSON.stringify(payload)
}, "requestPostServiceRequest", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
} finally {
annotateSigningRequest(debugRequests);
}
}
async function requestCreationHeaders(id, target, debugRequests) {
if (id == null) {
const result = await fetchJson(`${catalog}?onlyData=true&fields=Id&limit=1`, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestSrCatalog", debugRequests);
id = result?.items?.[0]?.Id;
}
id = numericId(id, "Catalogue Id");
const tokenUrl = `${catalog}/${id}`;
let catalogHeaders = await tokenHeaders(tokenUrl, target);
const requestToken = () => fetchJson(tokenUrl, {
method: "POST", credentials: "include", headers: catalogHeaders, body: JSON.stringify({ name: "getJwtToken" })
}, "requestSrTokenCatalog", debugRequests);
let token;
try {
token = await requestToken();
} catch (error) {
if (error.httpStatus !== 403) throw error;
const refreshed = await previewTokenHeaders(tokenUrl);
if (!refreshed["x-appbuilder-client-id"] || refreshed["x-appbuilder-client-id"] === catalogHeaders["x-appbuilder-client-id"]) throw error;
catalogHeaders = refreshed;
token = await requestToken();
}
if (debugRequests.at(-1)?.httpStatus !== 200 || typeof token?.result !== "string" || !token.result.trim()) {
throw new Error("requestSrTokenCatalog did not return HTTP 200 with a token.");
}
return { ...catalogHeaders, "vb-proxy-header-x-oracle-ectoken": token.result.trim() };
}
function annotateSigningRequest(debugRequests) {
for (const entry of debugRequests) {
if (entry.name === "requestSrTokenCatalog") {
entry.metadata.appBuilderClientId = entry.requestHeaders?.["x-appbuilder-client-id"] ? "Observed in SeaaS requests" : "Not observed in SeaaS requests";
}
}
}
async function invalidateResourceUser() {
resourceGeneration++;
resourceRequests.clear();
await runtimeApi?.storage?.session?.set({ [resourceCacheKey]: null });
}
return { requestPostServiceRequest, requestPostInternalServiceRequest, getResourceUser, invalidateResourceUser };
}

View File

@@ -1,4 +1,146 @@
function createFusionSrDetails({ fetchJson }) {
function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFirefoxCookieStoreTabs, cookiesGetAll, cookieChanges, wait }) {
const sessions = new Map();
function cookiePart(cookie) {
if (!cookie) return null;
const match = /^ORA_OCIS_CG_SESSION_idcs-([a-zA-Z0-9-]+)_comcipapic-oalprod\.integration\.ocp\.oraclecloud\.com_([12])$/.exec(cookie.name);
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const cookieDomain = cookie.domain?.replace(/^\./, "");
if (!match || (cookieDomain && cookieDomain !== domain && !domain.endsWith(`.${cookieDomain}`))) return null;
return { key: `${match[1]}|${cookie.path || "/"}|${cookie.storeId || ""}`, part: match[2],
valid: Boolean(cookie.value) && (cookie.expirationDate == null || cookie.expirationDate > Date.now() / 1000) };
}
function watchSession(session, sender) {
const pairs = new Map();
let available;
session.cookieReady = new Promise(resolve => { available = resolve; });
const listener = change => {
const cookie = change.cookie;
const part = cookiePart(cookie);
if (!part) return;
if (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId) return;
if (!pairs.has(part.key)) pairs.set(part.key, new Map());
const pair = pairs.get(part.key);
if (change.removed || !part.valid) pair.delete(part.part);
else pair.set(part.part, cookie);
if (pair.size === 2 && [...pair.values()].every(item => cookiePart(item)?.valid)) available(new Set([part.key]));
session.wake?.();
};
cookieChanges?.addListener(listener);
session.unwatch = () => cookieChanges?.removeListener(listener);
}
function waitForSessionCookies(session, sender) {
if (session.cookieWait) return session.cookieWait;
session.cookieWait = (async () => {
const deadline = Date.now() + 1000;
for (let attempt = 0; attempt < 5; attempt += 1) {
// Install the wake-up before reading so a renewal during getAll is not missed.
const changed = new Promise(resolve => { session.wake = resolve; });
const current = await sessionCookies(sender);
const renewed = [...current].some(pair => !session.baseline.has(pair));
if (renewed || Date.now() >= deadline) {
session.baseline = current;
return current;
}
await Promise.race([changed, wait(250)]);
}
return sessionCookies(sender);
})().finally(() => { session.cookieWait = null; session.wake = null; });
return session.cookieWait;
}
async function sessionCookies(sender) {
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const details = { domain };
if (sender?.tab?.cookieStoreId) details.storeId = sender.tab.cookieStoreId;
try {
const groups = new Map();
for (const cookie of await cookiesGetAll(details)) {
const part = cookiePart(cookie);
if (!part?.valid) continue;
if (!groups.has(part.key)) groups.set(part.key, {});
groups.get(part.key)[part.part] = [cookie.name, cookie.value, cookie.expirationDate ?? null];
}
// Fingerprints remain in memory only; the browser sends the actual Cookie header.
return new Set([...groups.values()].filter(pair => pair[1] && pair[2]).map(pair => JSON.stringify([pair[1], pair[2]])));
} catch { return new Set(); }
}
async function requestLookupWithSession(operation, message, sender, initialize = false) {
const existingCookies = initialize ? await sessionCookies(sender) : null;
if (existingCookies?.size) return { ok: true, debugRequests: [] };
const logs = [];
const query = async () => {
try {
const result = await operation(message);
logs.push(...(result.debugRequests || []));
return { ...result, debugRequests: logs };
} catch (error) {
logs.push(...(error.debugRequests || []));
error.debugRequests = logs;
throw error;
}
};
if (!initialize) {
try { return await query(); } catch (error) { if (error.httpStatus !== 401) throw error; }
}
const key = sender?.tab?.cookieStoreId || "default";
let session = sessions.get(key);
if (!session) {
const details = {
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/",
active: false
};
if (Number.isInteger(sender?.tab?.windowId)) details.windowId = sender.tab.windowId;
if (supportsFirefoxCookieStoreTabs() && sender?.tab?.cookieStoreId) details.cookieStoreId = sender.tab.cookieStoreId;
session = { users: 0, closed: null, baseline: null };
watchSession(session, sender);
session.tab = (async () => {
session.baseline = existingCookies || await sessionCookies(sender);
return tabsCreate(details);
})();
sessions.set(key, session);
}
session.users += 1;
let tab;
try {
tab = await session.tab;
const deadline = Date.now() + 70000;
for (let attempt = 0; attempt < 280 && Date.now() < deadline; attempt += 1) {
const pendingCookies = initialize && attempt === 0
? sessionCookies(sender) : waitForSessionCookies(session, sender);
const cookies = await (initialize ? Promise.race([session.cookieReady, pendingCookies]) : pendingCookies);
if (initialize) {
// Proactive preparation observes cookies only; API requests belong to the form.
if (!cookies.size) continue;
await closeSessionTab();
return { ok: true, debugRequests: [] };
}
try {
const result = await query();
// Any successful lookup confirms the shared session; stop the page immediately.
await closeSessionTab();
return result;
} catch (error) {
if (error.httpStatus !== 401) throw error;
}
}
throw new Error("Service Request session could not be established after 70 seconds. Sign in to Oracle and retry.");
} catch (error) {
error.debugRequests = logs;
throw error;
} finally {
session.users -= 1;
if (session.users === 0) {
sessions.delete(key);
session.unwatch();
await closeSessionTab();
}
}
function closeSessionTab() {
if (!session.closed && Number.isInteger(tab?.id)) {
session.closed = tabsRemove(tab.id).catch(() => {});
}
return session.closed;
}
}
async function requestInternalServiceRequest(message) {
const debugRequests = [];
try {
@@ -7,10 +149,14 @@ function createFusionSrDetails({ fetchJson }) {
const fields = "Title,SrNumber,SrId,OpportunityName_c,QueueManagers_c,StatusCd,resourceMembers;ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c,Title,OpportunityName_c,StatusCd,SrNumber,ServiceNew_c,AccountPartyName,LastUpdateDate,SvcHealth_c,AssigneePersonName,OpenDate,SrId";
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests"
+ `?fields=${encodeURIComponent(fields)}&onlyData=true&q=${encodeURIComponent(`SrId=${id}`)}`;
const query = async () => {
const result = await fetchJson(url, { method: "GET", credentials: "include",
headers: { Accept: "application/json", Authorization: "Session" }
}, "requestInternalServiceRequest", debugRequests);
if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response.");
return result;
};
const result = await query();
const items = result.items.flatMap(item => {
const children = item.ServiceRequest_SRToInternalSR_Tgt;
if (children == null) return [];
@@ -23,5 +169,60 @@ function createFusionSrDetails({ fetchJson }) {
throw error;
}
}
return { requestInternalServiceRequest };
async function requestLookup(lookupType, requestName) {
const debugRequests = [];
try {
const url = `https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/lookups?lookupType=${lookupType}&type=STANDARD`;
const result = await fetchJson(url, { method: "GET", credentials: "include",
headers: { Accept: "application/json", Authorization: "Session" }
}, requestName, debugRequests);
if (!Array.isArray(result?.items) || result.items.some(item => typeof item?.code !== "string" || typeof item?.name !== "string")) throw new Error(`Invalid ${requestName} response.`);
const items = result.items.filter(item => item.enabledFlag !== "N")
.sort((a, b) => (Number(a.displaySequence) || 0) - (Number(b.displaySequence) || 0))
.map(({ code, name }) => ({ code, name }));
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestSrLookupOpportunityBusiness(message) {
const debugRequests = [];
try {
if (typeof message.emailAddress !== "string" || !message.emailAddress.trim()) throw new Error("Current user email is unavailable.");
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/services"
+ `?emailAddress=${encodeURIComponent(message.emailAddress.trim())}&salesObject=OPPORTUNITY&serviceType=BUSINESS`;
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" } }, "requestSrLookupOpportunityBusiness", debugRequests);
if (!Array.isArray(result?.items) || result.items.some(item => !/^\d+$/.test(String(item?.Id ?? "")) || typeof item?.RecordName !== "string")) throw new Error("Invalid Services response.");
return { ok: true, items: result.items.map(({ Id, RecordName, Abstract_c }) => ({ Id: String(Id), RecordName, Abstract_c: Abstract_c == null ? "" : String(Abstract_c) })), debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestInternalSrOptions(message, services = false) {
const debugRequests = [];
const name = services ? "requestInternalSrServices" : "requestLookupTypePlatform";
try {
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetSeaaSOKECustomRestAPI/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/";
let path = "lookups?type=STANDARD&lookupType=SENGG_TECH_PLATFORM";
if (services) {
const id = String(message.parentServiceId ?? "");
if (!/^\d+$/.test(id)) throw new Error("A numeric parent SrId is required.");
if (typeof message.emailAddress !== "string" || !message.emailAddress.trim()) throw new Error("Current user email is unavailable.");
path = `services?emailAddress=${encodeURIComponent(message.emailAddress.trim())}&parentServiceId=${encodeURIComponent(id)}&serviceType=INTERNAL`;
}
const result = await fetchJson(base + path, { method: "GET", credentials: "include",
headers: { Accept: "application/json", Authorization: "Session" }
}, name, debugRequests);
if (!Array.isArray(result?.items) || result.items.some(item => services
? !/^\d+$/.test(String(item?.Id ?? "")) || typeof item?.RecordName !== "string"
: typeof item?.code !== "string" || typeof item?.name !== "string")) throw new Error(`Invalid ${name} response.`);
const items = services
? result.items.map(item => ({ Id: String(item.Id), RecordName: item.RecordName, Description_c: String(item.Description_c ?? "") }))
: result.items.filter(item => item.enabledFlag !== "N").map(({ code, name }) => ({ code, name }));
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
return { requestLookupWithSession, requestInternalServiceRequest, requestSrLookupOpportunityBusiness,
requestLookupTypePlatform: () => requestInternalSrOptions({}),
requestInternalSrServices: message => requestInternalSrOptions(message, true),
prepareSession: sender => requestLookupWithSession(null, {}, sender, true),
requestSrLookupPrimaryPillar: () => requestLookup("SENGG_PRIMARY_PILLAR", "requestSrLookupPrimaryPillar"),
requestSrLookupLanguage: () => requestLookup("SENGG_LANGUAGE", "requestSrLookupLanguage"),
requestSrLookupTechPlatform: () => requestLookup("SENGG_TECH_PLATFORM", "requestSrLookupTechPlatform") };
}

View File

@@ -0,0 +1,118 @@
function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
const endpoint = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/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/weekDetails";
async function requestWeekDetails(message) {
const debugRequests = [];
try {
const date = dates.toApi(message.dateReference);
const items = await fetchJson(`${endpoint}?dateValue=${encodeURIComponent(date)}`, {
method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" }
}, "requestWeekDetails", debugRequests);
if (!Array.isArray(items) || items.length !== 7 || items.some(item => !dates.fromApi(item?.Date) || !dates.fromApi(item?.EndDate))
|| new Set(items.map(item => item.Date)).size !== 7) throw new Error("Invalid week details response.");
return { ok: true, items, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
}
}
async function requestTimeEntries(message) {
const debugRequests = [];
try {
if (!/^\d+$/.test(String(message.weekId)) || Number(message.weekId) <= 0) throw new Error("Missing or invalid WeekId.");
const resource = await getResourceUser(message);
debugRequests.push(...resource.debugRequests);
const url = endpoint.replace(/\/weekDetails$/, "") + `?resourceId=${encodeURIComponent(resource.resource.resourcePartyId)}&sortBy=createdDate&sortOrder=ASC&weekId=${encodeURIComponent(message.weekId)}`;
const items = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestTimeEntries", debugRequests);
if (!Array.isArray(items)) throw new Error("Invalid time entries response.");
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
}
async function requestTimeEntriesDelete(message) {
const debugRequests = [];
try {
if (!/^[1-9]\d*$/.test(String(message.id))) throw new Error("Missing or invalid time entry ID.");
const url = endpoint.replace(/\/weekDetails$/, "") + `/${message.id}`;
const headers = await saveHeaders(url);
const result = await fetchJson(url, { method: "DELETE", credentials: "include", headers },
"requestTimeEntriesDelete", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestTimeEntriesSave(message) {
const debugRequests = [];
try {
if (!["POST", "PUT"].includes(message.method)) throw new Error("Invalid time entry save method.");
if (!Array.isArray(message.items) || !message.items.length) throw new Error("No time entries to save.");
const hours = ["saturdayTaskHours", "sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours"];
for (const item of message.items) {
if (!item || !["SERVICE", "NON_SERVICE"].includes(item.tag) || !item.taskType
|| (item.tag === "SERVICE" && (!/^SR\d+$/.test(item.srNumber) || !item.activityCode))
|| !/^\d+$/.test(String(item.weekId)) || Number(item.weekId) <= 0
|| hours.some(key => typeof item[key] !== "number" || !Number.isFinite(item[key]) || item[key] < 0 || item[key] > 24)
|| !hours.some(key => item[key] > 0)
|| (message.method === "PUT" && (!/^\d+$/.test(String(item.id)) || Number(item.id) <= 0))) throw new Error("Invalid or incomplete time entry.");
}
const resource = await getResourceUser(message);
debugRequests.push(...resource.debugRequests);
const items = message.items.map(item => ({
...(message.method === "PUT" ? { id: item.id } : {}),
srNumber: item.tag === "NON_SERVICE" ? "non-service-sr" : item.srNumber,
activityCode: item.activityCode || "", taskType: item.taskType, deliveryType: item.deliveryType || "",
...Object.fromEntries(hours.map(key => [key, item[key]])), notes: String(item.notes || ""),
weekId: item.weekId, resourceId: String(resource.resource.resourcePartyId), tag: item.tag
}));
const url = endpoint.replace(/\/weekDetails$/, "");
const headers = await saveHeaders(url);
const result = await fetchJson(url, {
method: message.method, credentials: "include",
headers, body: JSON.stringify(items)
}, "requestTimeEntriesSave", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
}
async function requestActiveServiceRequests(message) {
const debugRequests = [];
try {
if (!/^\d+$/.test(String(message.weekId)) || Number(message.weekId) <= 0) throw new Error("Missing or invalid WeekId.");
const resource = await getResourceUser(message);
debugRequests.push(...resource.debugRequests);
const url = endpoint.replace(/weekDetails$/, "activeServiceRequests") + `?resourceId=${encodeURIComponent(resource.resource.resourcePartyId)}&weekId=${encodeURIComponent(message.weekId)}`;
const items = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestActiveServiceRequests", debugRequests);
if (!Array.isArray(items)) throw new Error("Invalid active Service Requests response.");
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
}
async function requestServiceRequestExpand(message) {
const debugRequests = [];
try {
if (!Array.isArray(message.srNumbers)) throw new Error("Missing Service Request numbers.");
const numbers = [...new Set(message.srNumbers.map(value => String(value || "").trim()).filter(Boolean))].filter(value => value !== "non-service-sr");
if (numbers.some(value => !/^SR\d+$/.test(value))) throw new Error("Invalid Service Request number.");
const batches = [];
for (let index = 0; index < numbers.length; index += 50) batches.push(numbers.slice(index, index + 50));
if (!batches.length || batches[batches.length - 1].length === 50) batches.push([]);
batches[batches.length - 1].push("non-service-sr");
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/crmRestApiSearchResourcesLatestCustomActions/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
const fields = "SrNumber,SRActivities_c,ProblemDescription,AccountPartyUniqueName,ServiceNew_c,OptyOwner_c,OptyName_c,OptyNumber_c,ReportedByPartyUniqueName,StatusCdMeaning,CountryText_c,AssigneePersonName,OptyStatus_c";
const items = [];
for (const batch of batches) {
const query = `SrNumber in (${batch.map(value => `'${value}'`).join(",")})`;
const url = `${base}?expand=ServiceRequest_SRToInternalSR_Tgt&fields=${encodeURIComponent(fields)}&limit=200&onlyData=true&q=${encodeURIComponent(query).replace(/'/g, "%27")}`;
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json", "vb-proxy-header-rest-framework-version": "3" } }, "requestServiceRequestExpand", debugRequests);
if (!Array.isArray(result?.items)) throw new Error("Invalid Service Request expansion response.");
items.push(...result.items);
}
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestLookupTaskType() {
const debugRequests = [];
try {
const url = endpoint.replace(/mgmt\/timeEntries\/weekDetails$/, "crm/lookups?lookupType=SCTA_TASK_TYPE&type=CUSTOM");
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestLookupTaskType", debugRequests);
if (!Array.isArray(result?.items)) throw new Error("Invalid Task Type lookup response.");
return { ok: true, items: result.items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
return { requestWeekDetails, requestTimeEntries, requestTimeEntriesSave, requestTimeEntriesDelete, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType };
}

View File

@@ -1,6 +1,6 @@
function createHttpBackgroundJson({
previewDebugBody,
DEBUG_BODY_LIMIT,
observeResponseHeaders = () => {},
environment
}) {
const { fetch } = environment;
@@ -17,6 +17,7 @@ function createHttpBackgroundJson({
throw error;
}
observeResponseHeaders(response.url || url, response.headers);
const responsePayload = await readResponsePayload(response);
const responseBody = createDebugBody(responsePayload.text);
completeBackgroundDebugEntry(debugEntry, response, responseBody, "");
@@ -125,7 +126,7 @@ function createHttpBackgroundJson({
}
function createDebugBody(text) {
return previewDebugBody(text, DEBUG_BODY_LIMIT);
return previewDebugBody(text);
}
function normalizeDebugHeaders(headers) {

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