diff --git a/README.md b/README.md index 23bc3f2..66bff1c 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,16 @@ Browser extension that adds the "Opportunities Extension" shortcut to the permit ## Development Structure -- `src/content.js`: single source file maintained by the project. +- `src/content/`: page integration, modal shell, theme and content composition. +- `src/background/`: synchronous message registration and background composition. +- `src/features/`: opportunities, products, consumption, AI summaries, preferences and debug. +- `src/auth/`: credential-specific selection, reuse and session recovery. +- `src/services/`: Fusion/SPA operations and background HTTP/JSON transport. +- `src/platform/`: narrow browser messaging, cookies, tabs and storage adapters. +- `src/shared/messages.js`: names of the existing cross-context messages. - `scripts/build.mjs`: generates browser-specific packages. +- `tests/`: isolated synthetic regression tests and a visual fixture. +- `docs/`: architecture/contracts and incremental refactoring record. - `dist/chromium/`: output for Google Chrome and Microsoft Edge. - `dist/firefox/`: output for Firefox. @@ -19,6 +27,41 @@ npm run build The build recreates `dist/chromium` and `dist/firefox` from the same source. +Do not edit files under `dist` directly. Both targets use Manifest V3, with a +Chromium service worker and Firefox background scripts. The build composes classic +factory modules from `scripts/sources.cjs` into one isolated IIFE per execution +context. It serializes `src/content/styles/redwood.css` at build time. There is no +runtime module loader, browser-side eval, ES-module requirement or new dependency. + +## Tests and Maintenance + +```bash +npm test +npm run check +``` + +Tests use built-in Node.js modules and synthetic credentials, without real Fusion +requests. The characterization suite verifies manifests, source/output equality, +fiscal dates, payloads, preferences, pagination, cache/progress and both browser API +styles. Architecture tests enforce source inclusion, explicit factory dependencies, +isolated state and unchanged CSS. These complement consumption, session recovery +and AI suites. `check` also fails if generated packages are stale. +There is no separate lint configuration or runtime dependency installation. + +See [architecture and contracts](docs/architecture.md) and the +[incremental refactoring record](docs/refactoring.md) for execution boundaries, +authentication, storage, known risks and verification limits. +Mock-based tests do not replace testing the installed extension in each browser. + +Without npm, run `node scripts/build.mjs`, `node scripts/test.cjs` and +`node scripts/build.mjs --check`. Development checks were run with Node 24.19.0. + +For a local UI smoke test, run `node scripts/preview.mjs` and open the loopback URL +printed by the command. The fixture uses synthetic opportunities, tokens, products, +AI text and consumption; unexpected requests fail closed. It does not authenticate +against Fusion or read real extension storage. `/consumption-trend.html` provides +a focused chart fixture. Stop the server with Ctrl+C when finished. + ## Release 1.0.0 - Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search. @@ -37,7 +80,7 @@ The build recreates `dist/chromium` and `dist/firefox` from the same source. ## Loading in Development Mode -### Chrome ou Microsoft Edge +### Chrome or Microsoft Edge 1. Open `chrome://extensions` or `edge://extensions`. 2. Enable Developer mode. diff --git a/dist/chromium/background.js b/dist/chromium/background.js index 35a162f..c235f28 100644 --- a/dist/chromium/background.js +++ b/dist/chromium/background.js @@ -1,6 +1,21 @@ (function () { "use strict"; +// Source: src/shared/messages.js +/** Stable wire names. All payloads remain plain, serializable objects. */ +function createMessageTypes() { + return Object.freeze({ + getXsrfToken: "opportunitiesExtension.getXsrfToken", + refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie", + requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts", + opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress", + requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService", + requestAccountPlans: "opportunitiesExtension.requestAccountPlans", + requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend" + }); +} +// Source: src/background/contracts.js +function createBackgroundContracts() { const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com"; const ORACLE_COOKIE_URLS = [ "https://eeho.fa.us2.oraclecloud.com/", @@ -67,116 +82,32 @@ "PrevQuantity_c", "PrevRevenueType_c", "PrevServicePeriod_c", "PrevStatus_c", "SubscriptionID_c", "WorkloadName_c", "ConsumptionStartDate_c", "RampMonths_c" ]; - const runtimeApi = typeof browser !== "undefined" ? browser : chrome; - let productsCachePromise = null; - let productsCacheWritePromise = Promise.resolve(); - const inFlightProducts = new Map(); + return { ORACLE_DOMAIN, ORACLE_COOKIE_URLS, XSRF_COOKIE_NAME, XSRF_COOKIE_PREFIX, OPPORTUNITIES_LIST_URL, COOKIE_REFRESH_TIMEOUT_MS, COOKIE_REFRESH_POLL_MS, OPPTY_PRODUCTS_CACHE_KEY, OPPTY_PRODUCTS_PAGE_LIMIT, OPPTY_PRODUCTS_MAX_PAGES, OPPTY_PRODUCTS_CONCURRENCY, CUSTOMER_TOKEN_SERVICE_URL, CUSTOMER_TOKEN_SESSION_URL, CUSTOMER_TOKEN_MAX_ATTEMPTS, CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS, CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS, ACCOUNT_SUMMARY_URL, ACCOUNT_PLANS_URL, CONSUMPTION_TREND_URL, CONSUMPTION_TREND_REQUESTS, ACCOUNT_PLANS_PAYLOAD, DEBUG_BODY_LIMIT, OPPTY_PRODUCTS_FIELDS }; +} - runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (!message || !message.type) { - return false; - } - - if (message.type === "opportunitiesExtension.getXsrfToken") { - getXsrfTokenCookie() - .then((result) => { - sendResponse({ - ok: true, - cookieName: result.cookie ? result.cookie.name : "", - token: result.cookie ? result.cookie.value : "", - matchedCookieNames: result.matchedCookieNames, - lookupDetails: result.lookupDetails - }); - }) - .catch((error) => { - sendResponse({ - ok: false, - cookieName: "", - token: "", - matchedCookieNames: [], - lookupDetails: [], - error: error.message || "Unable to read cookies." - }); - }); - - return true; - } - - if (message.type === "opportunitiesExtension.refreshXsrfCookie") { - refreshXsrfCookie(sender) - .then(sendResponse) - .catch((error) => { - sendResponse({ - ok: false, - refreshed: false, - cookieName: "", - durationMs: 0, - error: error.message || "Unable to refresh XSRF cookie." - }); - }); - - return true; - } - - if (message.type === "opportunitiesExtension.requestOpptyProducts") { - requestOpptyProducts(message, sender) - .then(sendResponse) - .catch((error) => { - sendResponse({ - ok: false, - requestId: message.requestId || "", - error: error.message || "Unable to load opportunity products." - }); - }); - - return true; - } - - if (message.type === "opportunitiesExtension.requestConsumerTokenService") { - requestConsumerTokenService(sender) - .then(sendResponse) - .catch((error) => { - sendResponse({ - ok: false, - error: error.message || "Unable to load consumer token service.", - debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] - }); - }); - - return true; - } - - if (message.type === "opportunitiesExtension.requestAccountPlans") { - requestAccountPlans(message) - .then(sendResponse) - .catch((error) => { - sendResponse({ - ok: false, - error: error.message || "Unable to load account plans.", - debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] - }); - }); - - return true; - } - - if (message.type === "opportunitiesExtension.requestConsumptionTrend") { - requestConsumptionTrend(message) - .then(sendResponse) - .catch((error) => { - sendResponse({ - ok: false, - error: error.message || "Unable to load consumption trend.", - debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] - }); - }); - - return true; - } - - return false; - }); +// Source: src/features/products/cache-state.js +function createProductsCacheState() { + return { + productsCachePromise: null, + productsCacheWritePromise: Promise.resolve(), + inFlightProducts: new Map() + }; +} +// Source: src/auth/consumer-session.js +function createAuthConsumerSession({ + CUSTOMER_TOKEN_MAX_ATTEMPTS, + CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS, + CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS, + CUSTOMER_TOKEN_SERVICE_URL, + CUSTOMER_TOKEN_SESSION_URL, + extractTokenServiceAuthorization, + fetchJson, + supportsFirefoxCookieStoreTabs, + tabsCreate, + tabsRemove, + wait +}) { async function requestConsumerTokenService(sender) { const debugRequests = []; @@ -384,6 +315,61 @@ return tabsCreate(details); } + return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab }; +} + +// Source: src/auth/spa-credentials.js +function createAuthSpaCredentials({ + findNestedObject +}) { + function normalizeMessageAuthorization(authorization, fallbackHeaderName) { + const headerName = authorization && typeof authorization.name === "string" + ? authorization.name.trim() + : ""; + const headerValue = authorization && typeof authorization.value === "string" + ? authorization.value.trim() + : ""; + + return { + name: headerName || fallbackHeaderName, + value: headerValue + }; + } + + function extractTokenServiceAuthorization(response, tokenServiceName, fallbackHeaderName) { + const tokenService = Array.isArray(response) + ? response.find((entry) => entry && entry.tokenService === tokenServiceName) + : findNestedObject(response, (entry) => entry.tokenService === tokenServiceName); + const authorizationHeader = tokenService && tokenService.uiaasHeader; + const headerName = authorizationHeader && typeof authorizationHeader.label === "string" + ? authorizationHeader.label.trim() + : ""; + const headerValue = authorizationHeader && typeof authorizationHeader.value === "string" + ? authorizationHeader.value.trim() + : ""; + + return { + name: headerName || fallbackHeaderName, + value: headerValue, + expires: authorizationHeader && authorizationHeader.expires + ? String(authorizationHeader.expires) + : "" + }; + } + + return { normalizeMessageAuthorization, extractTokenServiceAuthorization }; +} + +// Source: src/services/spa/accounts.js +function createSpaAccounts({ + ACCOUNT_PLANS_PAYLOAD, + ACCOUNT_PLANS_URL, + ACCOUNT_SUMMARY_URL, + extractAccountPlans, + fetchJson, + findAccountPlanId, + normalizeMessageAuthorization +}) { async function requestAccountPlans(message) { const partyNumber = typeof message.partyNumber === "string" ? message.partyNumber.trim() : ""; const accountSummaryAuthorization = normalizeMessageAuthorization( @@ -448,6 +434,16 @@ } } + return { requestAccountPlans }; +} + +// Source: src/services/spa/consumption.js +function createSpaConsumption({ + CONSUMPTION_TREND_REQUESTS, + CONSUMPTION_TREND_URL, + fetchJson, + normalizeMessageAuthorization +}) { async function requestConsumptionTrend(message) { const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : ""; const period = typeof message.period === "string" ? message.period.toLowerCase() : ""; @@ -498,41 +494,78 @@ } } - function normalizeMessageAuthorization(authorization, fallbackHeaderName) { - const headerName = authorization && typeof authorization.name === "string" - ? authorization.name.trim() - : ""; - const headerValue = authorization && typeof authorization.value === "string" - ? authorization.value.trim() - : ""; + return { requestConsumptionTrend }; +} - return { - name: headerName || fallbackHeaderName, - value: headerValue - }; +// Source: src/services/spa/response-shapes.js +function createSpaResponseShapes() { + function findNestedObject(value, predicate, visited = new Set()) { + if (!value || typeof value !== "object" || visited.has(value)) { + return null; + } + + visited.add(value); + + if (!Array.isArray(value) && predicate(value)) { + return value; + } + + for (const child of Object.values(value)) { + const match = findNestedObject(child, predicate, visited); + + if (match) { + return match; + } + } + + return null; } - function extractTokenServiceAuthorization(response, tokenServiceName, fallbackHeaderName) { - const tokenService = Array.isArray(response) - ? response.find((entry) => entry && entry.tokenService === tokenServiceName) - : findNestedObject(response, (entry) => entry.tokenService === tokenServiceName); - const authorizationHeader = tokenService && tokenService.uiaasHeader; - const headerName = authorizationHeader && typeof authorizationHeader.label === "string" - ? authorizationHeader.label.trim() - : ""; - const headerValue = authorizationHeader && typeof authorizationHeader.value === "string" - ? authorizationHeader.value.trim() - : ""; - - return { - name: headerName || fallbackHeaderName, - value: headerValue, - expires: authorizationHeader && authorizationHeader.expires - ? String(authorizationHeader.expires) - : "" - }; + function findAccountPlanId(response) { + const account = findNestedObject(response, (value) => ( + Object.prototype.hasOwnProperty.call(value, "id") + && value.id !== null + && value.id !== undefined + && value.id !== "" + )); + return account ? account.id : ""; } + function extractAccountPlans(response) { + const plans = []; + const visited = new Set(); + + const visit = (value) => { + if (!value || typeof value !== "object" || visited.has(value)) { + return; + } + + visited.add(value); + + if (!Array.isArray(value) && ( + Object.prototype.hasOwnProperty.call(value, "planType") + || Object.prototype.hasOwnProperty.call(value, "subPlanNum") + )) { + plans.push(value); + return; + } + + Object.values(value).forEach(visit); + }; + + visit(response); + return plans; + } + + return { findNestedObject, findAccountPlanId, extractAccountPlans }; +} + +// Source: src/services/http/background-json.js +function createHttpBackgroundJson({ + DEBUG_BODY_LIMIT, + environment +}) { + const { fetch } = environment; async function fetchJson(url, options, requestName, debugRequests = null) { const startedAt = new Date(); const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt); @@ -695,64 +728,17 @@ } } - function findNestedObject(value, predicate, visited = new Set()) { - if (!value || typeof value !== "object" || visited.has(value)) { - return null; - } - - visited.add(value); - - if (!Array.isArray(value) && predicate(value)) { - return value; - } - - for (const child of Object.values(value)) { - const match = findNestedObject(child, predicate, visited); - - if (match) { - return match; - } - } - - return null; - } - - function findAccountPlanId(response) { - const account = findNestedObject(response, (value) => ( - Object.prototype.hasOwnProperty.call(value, "id") - && value.id !== null - && value.id !== undefined - && value.id !== "" - )); - return account ? account.id : ""; - } - - function extractAccountPlans(response) { - const plans = []; - const visited = new Set(); - - const visit = (value) => { - if (!value || typeof value !== "object" || visited.has(value)) { - return; - } - - visited.add(value); - - if (!Array.isArray(value) && ( - Object.prototype.hasOwnProperty.call(value, "planType") - || Object.prototype.hasOwnProperty.call(value, "subPlanNum") - )) { - plans.push(value); - return; - } - - Object.values(value).forEach(visit); - }; - - visit(response); - return plans; - } + return { fetchJson, createBackgroundDebugEntry, completeBackgroundDebugEntry, appendBackgroundDebugEntry, readResponsePayload, parseJsonResponse, createDebugBody, normalizeDebugHeaders, headersToObject, formatDebugRequestBody }; +} +// Source: src/features/products/background-jobs.js +function createProductsBackgroundJobs({ + messageTypes, + OPPTY_PRODUCTS_CONCURRENCY, + getOpptyProducts, + normalizeCacheMaxAge, + tabsSendMessage +}) { async function requestOpptyProducts(message, sender) { const requestId = typeof message.requestId === "string" ? message.requestId : ""; const accessToken = typeof message.accessToken === "string" ? message.accessToken : ""; @@ -787,7 +773,7 @@ } await sendProductsProgress(sender, { - type: "opportunitiesExtension.opptyProductsProgress", + type: messageTypes.opptyProductsProgress, requestId, state: "running", optyNumber, @@ -803,7 +789,7 @@ completed += 1; failed += 1; await sendProductsProgress(sender, { - type: "opportunitiesExtension.opptyProductsProgress", + type: messageTypes.opptyProductsProgress, requestId, state: "running", optyNumber, @@ -823,7 +809,7 @@ const state = failed > 0 ? "complete-with-errors" : "complete"; await sendProductsProgress(sender, { - type: "opportunitiesExtension.opptyProductsProgress", + type: messageTypes.opptyProductsProgress, requestId, state, completed, @@ -842,6 +828,26 @@ }; } + function sendProductsProgress(sender, message) { + if (!sender || !sender.tab || !Number.isInteger(sender.tab.id)) { + return Promise.resolve(); + } + + const options = Number.isInteger(sender.frameId) ? { frameId: sender.frameId } : undefined; + return tabsSendMessage(sender.tab.id, message, options).catch(() => {}); + } + + return { requestOpptyProducts, sendProductsProgress }; +} + +// Source: src/features/products/cache.js +function createProductsCache({ + OPPTY_PRODUCTS_CACHE_KEY, + fetchAllOpptyProducts, + productsCacheState, + storageLocalGet, + storageLocalSet +}) { async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) { const cache = await getProductsCache(); const cachedEntry = cache[optyNumber]; @@ -850,7 +856,7 @@ return { items: cachedEntry.items, fromCache: true, cachedAt: Number(cachedEntry.fetchedAt) }; } - if (!inFlightProducts.has(optyNumber)) { + if (!productsCacheState.inFlightProducts.has(optyNumber)) { const request = fetchAllOpptyProducts(optyNumber, accessToken) .then(async (items) => { const fetchedAt = Date.now(); @@ -862,12 +868,12 @@ return { items, fetchedAt }; }) .finally(() => { - inFlightProducts.delete(optyNumber); + productsCacheState.inFlightProducts.delete(optyNumber); }); - inFlightProducts.set(optyNumber, request); + productsCacheState.inFlightProducts.set(optyNumber, request); } - const fetchedResult = await inFlightProducts.get(optyNumber); + const fetchedResult = await productsCacheState.inFlightProducts.get(optyNumber); return { items: fetchedResult.items, cachedAt: fetchedResult.fetchedAt, @@ -875,6 +881,46 @@ }; } + function normalizeCacheMaxAge(value) { + const maxAge = Number(value); + const defaultMaxAge = 24 * 60 * 60 * 1000; + const maximumMaxAge = 30 * 24 * 60 * 60 * 1000; + return Number.isFinite(maxAge) && maxAge > 0 + ? Math.min(maxAge, maximumMaxAge) + : defaultMaxAge; + } + + async function getProductsCache() { + if (!productsCacheState.productsCachePromise) { + productsCacheState.productsCachePromise = storageLocalGet(OPPTY_PRODUCTS_CACHE_KEY) + .then((result) => result && typeof result[OPPTY_PRODUCTS_CACHE_KEY] === "object" + ? result[OPPTY_PRODUCTS_CACHE_KEY] + : {}) + .catch(() => ({})); + } + + return productsCacheState.productsCachePromise; + } + + function saveProductsCache(cache) { + productsCacheState.productsCacheWritePromise = productsCacheState.productsCacheWritePromise + .catch(() => {}) + .then(() => storageLocalSet({ [OPPTY_PRODUCTS_CACHE_KEY]: cache })); + return productsCacheState.productsCacheWritePromise; + } + + return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache }; +} + +// Source: src/services/fusion/products.js +function createFusionProducts({ + OPPTY_PRODUCTS_FIELDS, + OPPTY_PRODUCTS_MAX_PAGES, + OPPTY_PRODUCTS_PAGE_LIMIT, + ORACLE_DOMAIN, + environment +}) { + const { fetch } = environment; async function fetchAllOpptyProducts(optyNumber, accessToken) { let offset = 0; let page = 0; @@ -930,43 +976,20 @@ return url.toString(); } - function normalizeCacheMaxAge(value) { - const maxAge = Number(value); - const defaultMaxAge = 24 * 60 * 60 * 1000; - const maximumMaxAge = 30 * 24 * 60 * 60 * 1000; - return Number.isFinite(maxAge) && maxAge > 0 - ? Math.min(maxAge, maximumMaxAge) - : defaultMaxAge; - } - - async function getProductsCache() { - if (!productsCachePromise) { - productsCachePromise = storageLocalGet(OPPTY_PRODUCTS_CACHE_KEY) - .then((result) => result && typeof result[OPPTY_PRODUCTS_CACHE_KEY] === "object" - ? result[OPPTY_PRODUCTS_CACHE_KEY] - : {}) - .catch(() => ({})); - } - - return productsCachePromise; - } - - function saveProductsCache(cache) { - productsCacheWritePromise = productsCacheWritePromise - .catch(() => {}) - .then(() => storageLocalSet({ [OPPTY_PRODUCTS_CACHE_KEY]: cache })); - return productsCacheWritePromise; - } - - function sendProductsProgress(sender, message) { - if (!sender || !sender.tab || !Number.isInteger(sender.tab.id)) { - return Promise.resolve(); - } - - const options = Number.isInteger(sender.frameId) ? { frameId: sender.frameId } : undefined; - return tabsSendMessage(sender.tab.id, message, options).catch(() => {}); - } + return { fetchAllOpptyProducts, createOpptyProductsUrl }; +} +// Source: src/auth/xsrf-recovery.js +function createAuthXsrfRecovery({ + COOKIE_REFRESH_POLL_MS, + COOKIE_REFRESH_TIMEOUT_MS, + OPPORTUNITIES_LIST_URL, + getXsrfTokenCookie, + supportsFirefoxCookieStoreTabs, + tabsCreate, + tabsRemove, + wait +}) { async function refreshXsrfCookie(sender) { const startedAt = Date.now(); const previous = (await getXsrfTokenCookie()).cookie; @@ -1039,6 +1062,133 @@ || previousCookie.expirationDate !== currentCookie.expirationDate; } + return { refreshXsrfCookie, waitForXsrfCookieChange, hasCookieChanged }; +} + +// Source: src/auth/xsrf-cookies.js +function createAuthXsrfCookies({ + ORACLE_COOKIE_URLS, + ORACLE_DOMAIN, + XSRF_COOKIE_NAME, + XSRF_COOKIE_PREFIX, + cookiesGet, + cookiesGetAll, + cookiesGetAllCookieStores, + removeUndefinedValues +}) { + async function getXsrfTokenCookie() { + const lookupDetails = []; + const allCookies = []; + const stores = await getCookieStores(lookupDetails); + + for (const store of stores) { + const exactCookie = await getExactCookieFromUrls(store.id, lookupDetails); + + if (exactCookie) { + allCookies.push(exactCookie); + } + } + + for (const store of stores) { + await collectCookies({ + name: XSRF_COOKIE_NAME, + storeId: store.id + }, allCookies, lookupDetails); + } + + for (const store of stores) { + await collectCookies({ + domain: ORACLE_DOMAIN, + storeId: store.id + }, allCookies, lookupDetails); + + await collectCookies({ + domain: `.${ORACLE_DOMAIN}`, + storeId: store.id + }, allCookies, lookupDetails); + } + + for (const store of stores) { + for (const url of ORACLE_COOKIE_URLS) { + await collectCookies({ + url, + storeId: store.id + }, allCookies, lookupDetails); + } + } + + const uniqueCookies = dedupeCookies(allCookies); + const xsrfCookies = uniqueCookies.filter((cookie) => cookie.name.startsWith(XSRF_COOKIE_PREFIX)); + const exactCookie = xsrfCookies.find((cookie) => cookie.name === XSRF_COOKIE_NAME); + const hostCookie = xsrfCookies.find((cookie) => cookie.domain === ORACLE_DOMAIN || cookie.domain === `.${ORACLE_DOMAIN}`); + + return { + cookie: exactCookie || hostCookie || xsrfCookies[0] || null, + matchedCookieNames: xsrfCookies.map((cookie) => `${cookie.name} (${cookie.domain}${cookie.path})`), + lookupDetails + }; + } + + async function getExactCookieFromUrls(storeId, lookupDetails) { + for (const url of ORACLE_COOKIE_URLS) { + const cookie = await cookiesGet({ + url, + name: XSRF_COOKIE_NAME, + storeId + }); + + lookupDetails.push(`${JSON.stringify({ url, name: XSRF_COOKIE_NAME, storeId })} => ${cookie ? "found" : "not found"}`); + + if (cookie) { + return cookie; + } + } + + return null; + } + + async function getCookieStores(lookupDetails) { + try { + const stores = await cookiesGetAllCookieStores(); + lookupDetails.push(`getAllCookieStores => ${stores.length} store(s)`); + return stores.length ? stores : [{ id: undefined }]; + } catch (error) { + lookupDetails.push(`getAllCookieStores => unavailable (${error.message || "unknown error"})`); + return [{ id: undefined }]; + } + } + + async function collectCookies(details, target, lookupDetails) { + const cleanDetails = removeUndefinedValues(details); + const cookies = await cookiesGetAll(cleanDetails); + target.push(...cookies); + lookupDetails.push(`${JSON.stringify(cleanDetails)} => ${cookies.length} cookie(s)`); + } + + function dedupeCookies(cookies) { + const seen = new Set(); + + return cookies.filter((cookie) => { + const key = `${cookie.name}|${cookie.domain}|${cookie.path}|${cookie.storeId || ""}`; + + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); + } + + return { getXsrfTokenCookie, getExactCookieFromUrls, getCookieStores, collectCookies, dedupeCookies }; +} + +// Source: src/platform/background-api.js +function createPlatformBackgroundApi({ + runtimeApi, + environment +}) { + const { browser, setTimeout } = environment; function supportsFirefoxCookieStoreTabs() { return Boolean( runtimeApi.runtime @@ -1153,110 +1303,6 @@ return new Promise((resolve) => setTimeout(resolve, durationMs)); } - async function getXsrfTokenCookie() { - const lookupDetails = []; - const allCookies = []; - const stores = await getCookieStores(lookupDetails); - - for (const store of stores) { - const exactCookie = await getExactCookieFromUrls(store.id, lookupDetails); - - if (exactCookie) { - allCookies.push(exactCookie); - } - } - - for (const store of stores) { - await collectCookies({ - name: XSRF_COOKIE_NAME, - storeId: store.id - }, allCookies, lookupDetails); - } - - for (const store of stores) { - await collectCookies({ - domain: ORACLE_DOMAIN, - storeId: store.id - }, allCookies, lookupDetails); - - await collectCookies({ - domain: `.${ORACLE_DOMAIN}`, - storeId: store.id - }, allCookies, lookupDetails); - } - - for (const store of stores) { - for (const url of ORACLE_COOKIE_URLS) { - await collectCookies({ - url, - storeId: store.id - }, allCookies, lookupDetails); - } - } - - const uniqueCookies = dedupeCookies(allCookies); - const xsrfCookies = uniqueCookies.filter((cookie) => cookie.name.startsWith(XSRF_COOKIE_PREFIX)); - const exactCookie = xsrfCookies.find((cookie) => cookie.name === XSRF_COOKIE_NAME); - const hostCookie = xsrfCookies.find((cookie) => cookie.domain === ORACLE_DOMAIN || cookie.domain === `.${ORACLE_DOMAIN}`); - - return { - cookie: exactCookie || hostCookie || xsrfCookies[0] || null, - matchedCookieNames: xsrfCookies.map((cookie) => `${cookie.name} (${cookie.domain}${cookie.path})`), - lookupDetails - }; - } - - async function getExactCookieFromUrls(storeId, lookupDetails) { - for (const url of ORACLE_COOKIE_URLS) { - const cookie = await cookiesGet({ - url, - name: XSRF_COOKIE_NAME, - storeId - }); - - lookupDetails.push(`${JSON.stringify({ url, name: XSRF_COOKIE_NAME, storeId })} => ${cookie ? "found" : "not found"}`); - - if (cookie) { - return cookie; - } - } - - return null; - } - - async function getCookieStores(lookupDetails) { - try { - const stores = await cookiesGetAllCookieStores(); - lookupDetails.push(`getAllCookieStores => ${stores.length} store(s)`); - return stores.length ? stores : [{ id: undefined }]; - } catch (error) { - lookupDetails.push(`getAllCookieStores => unavailable (${error.message || "unknown error"})`); - return [{ id: undefined }]; - } - } - - async function collectCookies(details, target, lookupDetails) { - const cleanDetails = removeUndefinedValues(details); - const cookies = await cookiesGetAll(cleanDetails); - target.push(...cookies); - lookupDetails.push(`${JSON.stringify(cleanDetails)} => ${cookies.length} cookie(s)`); - } - - function dedupeCookies(cookies) { - const seen = new Set(); - - return cookies.filter((cookie) => { - const key = `${cookie.name}|${cookie.domain}|${cookie.path}|${cookie.storeId || ""}`; - - if (seen.has(key)) { - return false; - } - - seen.add(key); - return true; - }); - } - function cookiesGetAll(details) { if (runtimeApi.cookies.getAll.length <= 1) { return runtimeApi.cookies.getAll(details); @@ -1325,4 +1371,253 @@ Object.entries(details).filter((entry) => entry[1] !== undefined) ); } + + return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues }; +} + +// Source: src/background/main.js +function startBackgroundApplication(dependencies) { + registerBackgroundMessages(dependencies); +} + +// Source: src/background/messages.js +/** Register synchronously; replies retain the WebExtension sendResponse contract. */ +function registerBackgroundMessages({ + messageTypes, + getXsrfTokenCookie, + refreshXsrfCookie, + requestAccountPlans, + requestConsumerTokenService, + requestConsumptionTrend, + requestOpptyProducts, + runtimeApi +}) { +runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!message || !message.type) { + return false; + } + + if (message.type === messageTypes.getXsrfToken) { + getXsrfTokenCookie() + .then((result) => { + sendResponse({ + ok: true, + cookieName: result.cookie ? result.cookie.name : "", + token: result.cookie ? result.cookie.value : "", + matchedCookieNames: result.matchedCookieNames, + lookupDetails: result.lookupDetails + }); + }) + .catch((error) => { + sendResponse({ + ok: false, + cookieName: "", + token: "", + matchedCookieNames: [], + lookupDetails: [], + error: error.message || "Unable to read cookies." + }); + }); + + return true; + } + + if (message.type === messageTypes.refreshXsrfCookie) { + refreshXsrfCookie(sender) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + error: error.message || "Unable to refresh XSRF cookie." + }); + }); + + return true; + } + + if (message.type === messageTypes.requestOpptyProducts) { + requestOpptyProducts(message, sender) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + requestId: message.requestId || "", + error: error.message || "Unable to load opportunity products." + }); + }); + + return true; + } + + if (message.type === messageTypes.requestConsumerTokenService) { + requestConsumerTokenService(sender) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + error: error.message || "Unable to load consumer token service.", + debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] + }); + }); + + return true; + } + + if (message.type === messageTypes.requestAccountPlans) { + requestAccountPlans(message) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + error: error.message || "Unable to load account plans.", + debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] + }); + }); + + return true; + } + + if (message.type === messageTypes.requestConsumptionTrend) { + requestConsumptionTrend(message) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + error: error.message || "Unable to load consumption trend.", + debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] + }); + }); + + return true; + } + + return false; + }); +} + +// Source: src/background/application.js +function createBackgroundApplication(overrides = {}) { + const environment = { + fetch: globalThis.fetch, + browser: globalThis.browser, + chrome: globalThis.chrome, + setTimeout: globalThis.setTimeout, + ...overrides + }; + const messageTypes = createMessageTypes(); + const contracts = createBackgroundContracts(); + const productsCacheState = createProductsCacheState(); + const authConsumerSession = createAuthConsumerSession({ + CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS, + CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS: contracts.CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS, + CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS: contracts.CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS, + CUSTOMER_TOKEN_SERVICE_URL: contracts.CUSTOMER_TOKEN_SERVICE_URL, + CUSTOMER_TOKEN_SESSION_URL: contracts.CUSTOMER_TOKEN_SESSION_URL, + extractTokenServiceAuthorization: (...args) => authSpaCredentials.extractTokenServiceAuthorization(...args), + fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), + supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args), + tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args), + tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args), + wait: (...args) => platformBackgroundApi.wait(...args) + }); + const authSpaCredentials = createAuthSpaCredentials({ + findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args) + }); + const spaAccounts = createSpaAccounts({ + ACCOUNT_PLANS_PAYLOAD: contracts.ACCOUNT_PLANS_PAYLOAD, + ACCOUNT_PLANS_URL: contracts.ACCOUNT_PLANS_URL, + ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL, + extractAccountPlans: (...args) => spaResponseShapes.extractAccountPlans(...args), + fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), + findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args), + normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args) + }); + const spaConsumption = createSpaConsumption({ + CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS, + CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL, + fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), + normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args) + }); + const spaResponseShapes = createSpaResponseShapes(); + const httpBackgroundJson = createHttpBackgroundJson({ + DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT, + environment + }); + const productsBackgroundJobs = createProductsBackgroundJobs({ + messageTypes, + OPPTY_PRODUCTS_CONCURRENCY: contracts.OPPTY_PRODUCTS_CONCURRENCY, + getOpptyProducts: (...args) => productsCache.getOpptyProducts(...args), + normalizeCacheMaxAge: (...args) => productsCache.normalizeCacheMaxAge(...args), + tabsSendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args) + }); + const productsCache = createProductsCache({ + OPPTY_PRODUCTS_CACHE_KEY: contracts.OPPTY_PRODUCTS_CACHE_KEY, + fetchAllOpptyProducts: (...args) => fusionProducts.fetchAllOpptyProducts(...args), + productsCacheState, + storageLocalGet: (...args) => platformBackgroundApi.storageLocalGet(...args), + storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args) + }); + const fusionProducts = createFusionProducts({ + OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS, + OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES, + OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT, + ORACLE_DOMAIN: contracts.ORACLE_DOMAIN, + environment + }); + const authXsrfRecovery = createAuthXsrfRecovery({ + COOKIE_REFRESH_POLL_MS: contracts.COOKIE_REFRESH_POLL_MS, + COOKIE_REFRESH_TIMEOUT_MS: contracts.COOKIE_REFRESH_TIMEOUT_MS, + OPPORTUNITIES_LIST_URL: contracts.OPPORTUNITIES_LIST_URL, + getXsrfTokenCookie: (...args) => authXsrfCookies.getXsrfTokenCookie(...args), + supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args), + tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args), + tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args), + wait: (...args) => platformBackgroundApi.wait(...args) + }); + const authXsrfCookies = createAuthXsrfCookies({ + ORACLE_COOKIE_URLS: contracts.ORACLE_COOKIE_URLS, + ORACLE_DOMAIN: contracts.ORACLE_DOMAIN, + XSRF_COOKIE_NAME: contracts.XSRF_COOKIE_NAME, + XSRF_COOKIE_PREFIX: contracts.XSRF_COOKIE_PREFIX, + cookiesGet: (...args) => platformBackgroundApi.cookiesGet(...args), + cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args), + cookiesGetAllCookieStores: (...args) => platformBackgroundApi.cookiesGetAllCookieStores(...args), + removeUndefinedValues: (...args) => platformBackgroundApi.removeUndefinedValues(...args) + }); + const platformBackgroundApi = createPlatformBackgroundApi({ + runtimeApi: environment.browser || environment.chrome, + environment + }); + const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args); + const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args); + const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args); + const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args); + const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args); + const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args); + const runtimeApi = environment.browser || environment.chrome; + function start() { + startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi }); + } + return { + start, contracts, + productsCacheState, + authConsumerSession, + authSpaCredentials, + spaAccounts, + spaConsumption, + spaResponseShapes, + httpBackgroundJson, + productsBackgroundJobs, + productsCache, + fusionProducts, + authXsrfRecovery, + authXsrfCookies, + platformBackgroundApi + }; +} + +createBackgroundApplication().start(); })(); diff --git a/dist/chromium/content.js b/dist/chromium/content.js index 5543b77..de5e63c 100644 --- a/dist/chromium/content.js +++ b/dist/chromium/content.js @@ -1,6 +1,22 @@ (function () { "use strict"; +const redwoodStyles = "\n .opportunities-extension-scroll-lock {\n overflow: hidden !important;\n }\n\n .opportunities-extension-modal,\n .opportunities-extension-modal * {\n box-sizing: border-box;\n font-family: \"Oracle Sans\", Arial, Helvetica, sans-serif;\n }\n\n .opportunities-extension-modal {\n position: fixed;\n inset: 0;\n z-index: 2147483647;\n background: #f5f4f2;\n color: #000000;\n }\n\n .opportunities-extension-modal[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-shell {\n height: 100vh;\n min-height: 0;\n display: grid;\n grid-template-rows: auto minmax(0, 1fr);\n background: #f5f4f2;\n }\n\n .opportunities-extension-header {\n position: relative;\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 24px;\n padding: 28px 40px 22px;\n border-bottom: 1px solid #dedbd7;\n background:\n linear-gradient(90deg, #8f624a 0 11%, #c74634 11% 23%, #6f5a7f 23% 36%, #00758f 36% 51%, #d4b06a 51% 63%, transparent 63% 100%) top left / 100% 6px no-repeat,\n #ffffff;\n }\n\n .opportunities-extension-header::before {\n position: absolute;\n left: 40px;\n bottom: -1px;\n width: 64px;\n height: 3px;\n background: #00758f;\n content: \"\";\n }\n\n .opportunities-extension-header h1 {\n margin: 0;\n color: #000000;\n font-size: 24px;\n font-weight: 700;\n line-height: 1.2;\n letter-spacing: 0;\n }\n\n .opportunities-extension-header p {\n margin: 0;\n color: #5f5a55;\n font-size: 14px;\n line-height: 1.4;\n }\n\n .opportunities-extension-subtitle-row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n margin-top: 6px;\n }\n\n .opportunities-extension-auth-badge {\n display: inline-flex;\n align-items: center;\n min-height: 22px;\n padding: 2px 8px;\n border-radius: 999px;\n color: #ffffff;\n font-size: 12px;\n font-weight: 700;\n line-height: 1.2;\n }\n\n .opportunities-extension-auth-badge-authenticated {\n background: #3f6f17;\n }\n\n .opportunities-extension-auth-badge-unauthenticated {\n background: #c5331f;\n }\n\n .opportunities-extension-auth-badge-pending {\n background: #6f5a7f;\n }\n\n .opportunities-extension-products-progress {\n display: inline-flex;\n align-items: center;\n min-height: 22px;\n padding: 2px 8px;\n border-radius: 999px;\n background: #e8f4f6;\n color: #006d7a;\n font-size: 12px;\n font-weight: 700;\n line-height: 1.2;\n }\n\n .opportunities-extension-products-progress[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-products-cache-timestamp {\n display: inline-flex;\n align-items: center;\n min-height: 22px;\n padding: 2px 8px;\n border-radius: 999px;\n background: #f0eeeb;\n color: #5f5a55;\n font-size: 11px;\n font-weight: 600;\n line-height: 1.2;\n }\n\n .opportunities-extension-products-cache-timestamp[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-products-progress[data-state=\"running\"]::before {\n width: 12px;\n height: 12px;\n margin-right: 6px;\n border: 2px solid currentColor;\n border-right-color: transparent;\n border-radius: 50%;\n content: \"\";\n animation: opportunities-extension-spin .8s linear infinite;\n }\n\n .opportunities-extension-products-progress[data-state=\"complete\"] {\n background: #e2f2e5;\n color: #356d19;\n }\n\n .opportunities-extension-products-progress[data-state=\"complete-with-errors\"],\n .opportunities-extension-products-progress[data-state=\"error\"] {\n background: #f9e3e1;\n color: #a52b1c;\n }\n\n @keyframes opportunities-extension-spin {\n to { transform: rotate(360deg); }\n }\n\n .opportunities-extension-icon-button {\n width: 36px;\n height: 36px;\n flex: 0 0 36px;\n border: 1px solid transparent;\n border-radius: 4px;\n background: transparent;\n color: #312d2a;\n font-size: 18px;\n font-weight: 600;\n line-height: 1;\n cursor: pointer;\n }\n\n .opportunities-extension-header-actions {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n }\n\n .opportunities-extension-icon-button svg {\n width: 18px;\n height: 18px;\n vertical-align: middle;\n }\n\n .opportunities-extension-icon-button .opportunities-extension-settings-icon {\n width: 20px;\n height: 20px;\n }\n\n .opportunities-extension-icon-button:hover,\n .opportunities-extension-icon-button:focus {\n border-color: #b8b2ad;\n background: #f5f4f2;\n outline: none;\n }\n\n .opportunities-extension-body {\n display: grid;\n min-height: 0;\n grid-template-rows: auto auto minmax(0, 1fr);\n gap: 16px;\n padding: 22px 40px 36px;\n background: #f5f4f2;\n overflow: hidden;\n }\n\n .opportunities-extension-form {\n display: flex;\n align-items: end;\n flex-wrap: wrap;\n gap: 24px;\n min-height: 82px;\n max-width: none;\n margin: 0;\n padding: 14px 16px;\n border: 1px solid #dedbd7;\n border-radius: 4px;\n background: #ffffff;\n }\n\n .opportunities-extension-field {\n display: grid;\n max-width: max-content;\n min-width: 270px;\n width: max-content;\n gap: 6px;\n color: #312d2a;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .opportunities-extension-stage-dashboard {\n display: grid;\n grid-template-columns: repeat(6, minmax(0, 1fr));\n gap: 12px;\n }\n\n .opportunities-extension-stage-dashboard-card {\n display: grid;\n gap: 6px;\n min-width: 0;\n padding: 12px 16px;\n border: 1px solid #dedbd7;\n border-top: 3px solid #5f5a55;\n border-radius: 4px;\n background: #ffffff;\n }\n\n .opportunities-extension-stage-dashboard-card span {\n color: #5f5a55;\n font-size: 11px;\n font-weight: 700;\n line-height: 1.2;\n }\n\n .opportunities-extension-stage-dashboard-value {\n overflow: hidden;\n color: #312d2a;\n font-size: 22px;\n font-weight: 700;\n line-height: 1.2;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .opportunities-extension-stage-dashboard-card[data-stage=\"SQL\"] {\n border-top-color: #008aa6;\n }\n\n .opportunities-extension-total-dashboard-card {\n border-top-color: #312d2a;\n }\n\n .opportunities-extension-stage-dashboard-card[data-stage=\"PIPELINE\"] {\n border-top-color: #6f6863;\n }\n\n .opportunities-extension-stage-dashboard-card[data-stage=\"UPSIDE\"] {\n border-top-color: #c87b00;\n }\n\n .opportunities-extension-stage-dashboard-card[data-stage=\"FORECAST\"] {\n border-top-color: #755b8b;\n }\n\n .opportunities-extension-stage-dashboard-card[data-stage=\"WON\"] {\n border-top-color: #4b7f22;\n }\n\n .opportunities-extension-stage-filter {\n display: grid;\n grid-template-rows: auto 44px;\n gap: 6px;\n min-width: 0;\n }\n\n .opportunities-extension-stage-filter-label {\n color: #312d2a;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .opportunities-extension-stage-filter-controls {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n }\n\n .opportunities-extension-stage-filter-button {\n min-height: 36px;\n padding: 6px 12px;\n border: 1px solid currentColor;\n border-radius: 18px;\n background: #ffffff;\n color: #5f5a55;\n font-size: 12px;\n font-weight: 700;\n letter-spacing: 0;\n line-height: 1.2;\n cursor: pointer;\n }\n\n .opportunities-extension-stage-filter-button[data-stage=\"SQL\"] {\n color: #006b84;\n }\n\n .opportunities-extension-stage-filter-button[data-stage=\"PIPELINE\"] {\n color: #5f5a55;\n }\n\n .opportunities-extension-stage-filter-button[data-stage=\"UPSIDE\"] {\n color: #945400;\n }\n\n .opportunities-extension-stage-filter-button[data-stage=\"FORECAST\"] {\n color: #624e74;\n }\n\n .opportunities-extension-stage-filter-button[data-stage=\"WON\"] {\n color: #3f6f17;\n }\n\n .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"SQL\"] {\n border-color: #006b84;\n background: #006b84;\n color: #ffffff;\n }\n\n .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"PIPELINE\"] {\n border-color: #5f5a55;\n background: #5f5a55;\n color: #ffffff;\n }\n\n .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"UPSIDE\"] {\n border-color: #945400;\n background: #945400;\n color: #ffffff;\n }\n\n .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"FORECAST\"] {\n border-color: #624e74;\n background: #624e74;\n color: #ffffff;\n }\n\n .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"WON\"] {\n border-color: #3f6f17;\n background: #3f6f17;\n color: #ffffff;\n }\n\n .opportunities-extension-stage-filter-button:focus-visible {\n outline: 2px solid #00758f;\n outline-offset: 2px;\n }\n\n .opportunities-extension-status-filter {\n display: grid;\n grid-template-rows: auto 44px;\n gap: 6px;\n min-width: 0;\n }\n\n .opportunities-extension-status-filter-label {\n color: #312d2a;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .opportunities-extension-status-filter-controls {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n }\n\n .opportunities-extension-status-filter-button {\n min-height: 36px;\n padding: 6px 12px;\n border: 1px solid currentColor;\n border-radius: 18px;\n background: #ffffff;\n font-size: 12px;\n font-weight: 700;\n letter-spacing: 0;\n line-height: 1.2;\n cursor: pointer;\n }\n\n .opportunities-extension-status-filter-button[data-status=\"OPEN\"] {\n color: #197a3d;\n }\n\n .opportunities-extension-status-filter-button[data-status=\"CLOSED\"] {\n color: #4b5563;\n }\n\n .opportunities-extension-status-filter-button[data-status=\"LOST\"] {\n color: #b13b34;\n }\n\n .opportunities-extension-status-filter-button[data-status=\"WON\"] {\n color: #1f5f99;\n }\n\n .opportunities-extension-status-filter-button[aria-pressed=\"true\"][data-status=\"OPEN\"] {\n border-color: #197a3d;\n background: #197a3d;\n color: #ffffff;\n }\n\n .opportunities-extension-status-filter-button[aria-pressed=\"true\"][data-status=\"CLOSED\"] {\n border-color: #4b5563;\n background: #4b5563;\n color: #ffffff;\n }\n\n .opportunities-extension-status-filter-button[aria-pressed=\"true\"][data-status=\"LOST\"] {\n border-color: #b13b34;\n background: #b13b34;\n color: #ffffff;\n }\n\n .opportunities-extension-status-filter-button[aria-pressed=\"true\"][data-status=\"WON\"] {\n border-color: #1f5f99;\n background: #1f5f99;\n color: #ffffff;\n }\n\n .opportunities-extension-status-filter-button:focus-visible {\n outline: 2px solid #00758f;\n outline-offset: 2px;\n }\n\n .opportunities-extension-customer-filter {\n position: relative;\n display: grid;\n grid-template-rows: auto 44px;\n gap: 6px;\n min-width: 220px;\n }\n\n .opportunities-extension-customer-filter-label {\n color: #312d2a;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .opportunities-extension-customer-filter-trigger {\n position: relative;\n min-width: 220px;\n min-height: 44px;\n padding: 10px 38px 10px 12px;\n border: 1px solid #b8b2ad;\n border-radius: 3px;\n background: #ffffff;\n color: #312d2a;\n font-size: 14px;\n line-height: 1.3;\n text-align: left;\n cursor: pointer;\n }\n\n .opportunities-extension-customer-filter-trigger::after {\n position: absolute;\n top: 50%;\n right: 16px;\n width: 0;\n height: 0;\n border-top: 6px solid #312d2a;\n border-right: 5px solid transparent;\n border-left: 5px solid transparent;\n content: \"\";\n pointer-events: none;\n transform: translateY(-50%);\n }\n\n .opportunities-extension-customer-filter-trigger:disabled {\n cursor: not-allowed;\n background: #f5f4f2;\n color: #7d7772;\n }\n\n .opportunities-extension-customer-filter-trigger:focus-visible {\n border-color: #312d2a;\n box-shadow: 0 0 0 1px #312d2a;\n outline: none;\n }\n\n .opportunities-extension-customer-filter-panel {\n position: absolute;\n top: calc(100% + 8px);\n left: 0;\n z-index: 4;\n display: grid;\n width: min(360px, calc(100vw - 80px));\n gap: 10px;\n padding: 12px;\n border: 1px solid #8f8a85;\n border-radius: 4px;\n background: #ffffff;\n box-shadow: 0 4px 12px rgba(0, 0, 0, .18);\n }\n\n .opportunities-extension-customer-filter-panel[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-customer-filter-search {\n width: 100%;\n min-height: 40px;\n padding: 8px 10px;\n border: 1px solid #b8b2ad;\n border-radius: 3px;\n background: #ffffff;\n color: #312d2a;\n font-size: 14px;\n }\n\n .opportunities-extension-customer-filter-search:focus {\n border-color: #312d2a;\n box-shadow: 0 0 0 1px #312d2a;\n outline: none;\n }\n\n .opportunities-extension-customer-filter-select-all,\n .opportunities-extension-customer-filter-option {\n display: flex;\n align-items: center;\n gap: 8px;\n color: #312d2a;\n font-size: 13px;\n line-height: 1.35;\n }\n\n .opportunities-extension-customer-filter-select-all {\n min-height: 32px;\n padding-bottom: 8px;\n border-bottom: 1px solid #dedbd7;\n font-weight: 700;\n }\n\n .opportunities-extension-customer-filter-panel input[type=\"checkbox\"] {\n width: 16px;\n height: 16px;\n flex: 0 0 16px;\n accent-color: #00758f;\n }\n\n .opportunities-extension-customer-filter-list {\n display: block;\n max-height: 250px;\n overflow: auto;\n }\n\n .opportunities-extension-customer-filter-option {\n display: grid;\n grid-template-columns: 16px minmax(0, 1fr);\n align-items: start;\n min-height: 0;\n height: auto !important;\n padding: 8px 4px;\n cursor: pointer;\n }\n\n .opportunities-extension-customer-filter-option input[type=\"checkbox\"] {\n margin-top: 1px;\n }\n\n .opportunities-extension-customer-filter-option span {\n display: block;\n min-width: 0;\n line-height: 18px;\n overflow-wrap: anywhere;\n white-space: normal;\n }\n\n .opportunities-extension-customer-filter-option:hover {\n background: #f5f4f2;\n }\n\n .opportunities-extension-customer-filter-empty {\n margin: 4px 0;\n color: #5f5a55;\n font-size: 13px;\n }\n\n .opportunities-extension-table-search-field {\n display: grid;\n grid-template-rows: auto 44px;\n gap: 6px;\n width: 260px;\n color: #312d2a;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .opportunities-extension-table-search-field input {\n width: 100%;\n min-height: 44px;\n padding: 10px 12px;\n border: 1px solid #b8b2ad;\n border-radius: 3px;\n background: #ffffff;\n color: #312d2a;\n font-size: 14px;\n line-height: 1.3;\n }\n\n .opportunities-extension-table-search-field input:focus {\n border-color: #312d2a;\n box-shadow: 0 0 0 1px #312d2a;\n outline: none;\n }\n\n .opportunities-extension-select-wrap {\n position: relative;\n display: block;\n width: 270px;\n max-width: 100%;\n }\n\n .opportunities-extension-select-wrap::after {\n position: absolute;\n top: 50%;\n right: 16px;\n width: 0;\n height: 0;\n border-left: 5px solid transparent;\n border-right: 5px solid transparent;\n border-top: 6px solid #000000;\n content: \"\";\n pointer-events: none;\n transform: translateY(-50%);\n }\n\n .opportunities-extension-field select {\n width: 100%;\n min-width: 0;\n max-width: 100%;\n min-height: 44px;\n box-sizing: border-box;\n appearance: none;\n border: 1px solid #b8b2ad;\n border-radius: 3px;\n background: #ffffff;\n color: #000000;\n font-size: 14px;\n line-height: 1.3;\n padding: 11px 44px 11px 12px;\n }\n\n .opportunities-extension-field select:focus {\n border-color: #312d2a;\n box-shadow: 0 0 0 1px #312d2a;\n outline: none;\n }\n\n .opportunities-extension-table-surface {\n min-height: 0;\n height: 100%;\n border: 1px solid #dedbd7;\n border-radius: 4px;\n background: #ffffff;\n overflow-x: auto;\n overflow-y: auto;\n }\n\n .opportunities-extension-table {\n width: 100%;\n min-width: 0;\n border-collapse: collapse;\n table-layout: fixed;\n color: #312d2a;\n font-size: 13px;\n line-height: 1.35;\n }\n\n .opportunities-extension-table thead {\n background: #faf9f8;\n }\n\n .opportunities-extension-table th {\n position: sticky;\n top: 0;\n z-index: 1;\n height: 44px;\n border-bottom: 1px solid #b8b2ad;\n background: #faf9f8;\n color: #312d2a;\n font-size: 12px;\n font-weight: 700;\n text-align: left;\n white-space: nowrap;\n }\n\n .opportunities-extension-table td {\n min-height: 48px;\n padding: 12px 16px;\n border-bottom: 1px solid #ebe8e5;\n vertical-align: middle;\n overflow-wrap: anywhere;\n }\n\n .opportunities-extension-table th:nth-child(1) {\n width: 16%;\n }\n\n .opportunities-extension-table th:nth-child(2) {\n width: 8%;\n }\n\n .opportunities-extension-table th:nth-child(3) {\n width: 18%;\n }\n\n .opportunities-extension-table th:nth-child(4) {\n width: 11%;\n }\n\n .opportunities-extension-table th:nth-child(5) {\n width: 8%;\n }\n\n .opportunities-extension-table th:nth-child(6) {\n width: 8%;\n }\n\n .opportunities-extension-table th:nth-child(7) {\n width: 7%;\n }\n\n .opportunities-extension-table th:nth-child(8) {\n width: 6%;\n }\n\n .opportunities-extension-table th:nth-child(9) {\n width: 7%;\n }\n\n .opportunities-extension-table th:nth-child(10) {\n width: 7%;\n }\n\n .opportunities-extension-table th:nth-child(11) {\n width: 4%;\n }\n\n .opportunities-extension-table td:nth-child(2),\n .opportunities-extension-table td:nth-child(5),\n .opportunities-extension-table td:nth-child(6),\n .opportunities-extension-table td:nth-child(8) {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .opportunities-extension-table td:nth-child(9) {\n padding-right: 6px;\n padding-left: 6px;\n white-space: nowrap;\n overflow: visible;\n }\n\n .opportunities-extension-table td:nth-child(7) {\n padding-right: 6px;\n padding-left: 6px;\n white-space: nowrap;\n overflow: visible;\n }\n\n .opportunities-extension-table td:nth-child(10) {\n padding-right: 6px;\n padding-left: 6px;\n font-size: 12px;\n white-space: nowrap;\n }\n\n .opportunities-extension-table td:nth-child(11) {\n padding-right: 6px;\n padding-left: 6px;\n text-align: center;\n white-space: nowrap;\n }\n\n .opportunities-extension-table tbody tr:hover {\n background: #f7f6f4;\n }\n\n .opportunities-extension-table tbody tr:last-child td {\n border-bottom: 0;\n }\n\n .opportunities-extension-table a {\n color: #006b84;\n text-decoration: underline;\n text-decoration-thickness: 1px;\n text-underline-offset: 2px;\n }\n\n .opportunities-extension-table a:hover {\n color: #004f63;\n }\n\n .opportunities-extension-opty-number-content {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n max-width: 100%;\n }\n\n .opportunities-extension-name-content {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n }\n\n .opportunities-extension-name-content > a {\n min-width: 0;\n }\n\n .opportunities-extension-actions-content {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 4px;\n white-space: nowrap;\n }\n\n .opportunities-extension-products-count {\n display: inline-grid;\n width: auto;\n min-width: 20px;\n height: 20px;\n place-items: center;\n flex: 0 0 auto;\n box-sizing: border-box;\n padding: 0 3px;\n border: 1px solid #7ca7b2;\n border-radius: 999px;\n background: #e8f4f6;\n color: #006d7a;\n font-size: 10px;\n font-weight: 700;\n line-height: 1;\n text-align: center;\n vertical-align: middle;\n cursor: pointer;\n }\n\n .opportunities-extension-products-count:hover,\n .opportunities-extension-products-count:focus-visible,\n .opportunities-extension-products-count[aria-expanded=\"true\"] {\n border-color: #00758f;\n background: #00758f;\n color: #ffffff;\n outline: none;\n }\n\n .opportunities-extension-products-count-error {\n border-color: #d8887e;\n background: #f9e3e1;\n color: #a52b1c;\n }\n\n .opportunities-extension-products-detail-row:hover {\n background: transparent !important;\n }\n\n .opportunities-extension-products-detail-cell {\n padding: 0 !important;\n }\n\n .opportunities-extension-products-detail {\n padding: 14px 20px 18px;\n border-bottom: 1px solid #c9c5c1;\n background: #f0eeeb;\n }\n\n .opportunities-extension-products-detail-heading {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n margin-bottom: 10px;\n color: #312d2a;\n }\n\n .opportunities-extension-ai-summary {\n box-sizing: border-box;\n width: 100%;\n min-width: 0;\n margin: 0 0 12px;\n padding: 12px 16px;\n border: 1px solid #dedbd7;\n border-radius: 6px;\n background: #ffffff;\n color: #312d2a;\n font-size: 13px;\n line-height: 1.5;\n text-align: left;\n white-space: normal;\n overflow-wrap: anywhere;\n }\n\n .opportunities-extension-ai-summary strong {\n display: block;\n font-size: 13px;\n font-weight: 600;\n }\n\n .opportunities-extension-ai-summary p {\n margin: 4px 0 0;\n font: inherit;\n white-space: pre-wrap;\n }\n\n .opportunities-extension-ai-summary-error {\n color: #b42318;\n border-color: #d9928b;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-ai-summary {\n background: #292827;\n color: #f6f4f2;\n border-color: #5f5a55;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-ai-summary-error {\n color: #ffb4ab;\n border-color: #a45c55;\n }\n\n .opportunities-extension-products-detail-heading strong {\n font-size: 13px;\n }\n\n .opportunities-extension-products-detail-heading span {\n color: #5f5a55;\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n }\n\n .opportunities-extension-products-table {\n width: 100%;\n table-layout: fixed;\n border-collapse: collapse;\n border: 1px solid #dedbd7;\n background: #ffffff;\n font-size: 12px;\n }\n\n .opportunities-extension-products-table th,\n .opportunities-extension-products-table td {\n position: static;\n height: auto;\n padding: 8px 10px;\n border-bottom: 1px solid #ebe8e5;\n background: transparent;\n overflow-wrap: anywhere;\n vertical-align: middle;\n white-space: normal;\n }\n\n .opportunities-extension-products-table th {\n background: #faf9f8;\n font-size: 11px;\n }\n\n .opportunities-extension-products-table th:nth-child(1) { width: 16%; }\n .opportunities-extension-products-table th:nth-child(2) { width: 16%; }\n .opportunities-extension-products-table th:nth-child(3) { width: 8%; }\n .opportunities-extension-products-table th:nth-child(4) { width: 10%; }\n .opportunities-extension-products-table th:nth-child(5) { width: 8%; }\n .opportunities-extension-products-table th:nth-child(6) { width: 8%; }\n .opportunities-extension-products-table th:nth-child(7) { width: 7%; }\n .opportunities-extension-products-table th:nth-child(8) { width: 10%; }\n .opportunities-extension-products-table th:nth-child(9) { width: 12%; }\n .opportunities-extension-products-table th:nth-child(10) { width: 5%; }\n\n .opportunities-extension-products-empty {\n margin: 0;\n padding: 10px 0;\n color: #5f5a55;\n font-size: 13px;\n }\n\n .opportunities-extension-copy-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n width: 24px;\n height: 24px;\n padding: 3px;\n border: 0;\n border-radius: 3px;\n background: transparent;\n color: #00758f;\n cursor: pointer;\n }\n\n .opportunities-extension-copy-button svg {\n width: 16px;\n height: 16px;\n fill: currentColor;\n }\n\n .opportunities-extension-copy-button:hover,\n .opportunities-extension-copy-button:focus-visible {\n background: #e8f4f6;\n color: #004f63;\n outline: none;\n }\n\n .opportunities-extension-action-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n height: 32px;\n padding: 5px;\n border: 1px solid transparent;\n border-radius: 4px;\n background: transparent;\n color: #00758f;\n cursor: pointer;\n }\n\n .opportunities-extension-action-button svg {\n width: 21px;\n height: 21px;\n }\n\n .opportunities-extension-action-button:hover,\n .opportunities-extension-action-button:focus-visible {\n border-color: #7ca7b2;\n background: #e8f4f6;\n color: #004f63;\n outline: none;\n }\n\n .opportunities-extension-action-button:disabled {\n cursor: wait;\n opacity: .72;\n }\n\n .opportunities-extension-action-button-loading svg {\n animation: opportunities-extension-pulse 1s ease-in-out infinite;\n }\n\n @keyframes opportunities-extension-pulse {\n 50% { opacity: .35; }\n }\n\n .opportunities-extension-copy-toast {\n position: fixed;\n right: 28px;\n bottom: 28px;\n z-index: 3;\n max-width: min(360px, calc(100vw - 56px));\n padding: 12px 16px;\n border-radius: 4px;\n background: #2f6f18;\n box-shadow: 0 4px 12px rgba(0, 0, 0, .18);\n color: #ffffff;\n font-size: 13px;\n font-weight: 700;\n }\n\n .opportunities-extension-copy-toast-error {\n background: #a52b1c;\n }\n\n .opportunities-extension-copy-toast[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-stage-badge {\n display: inline-flex;\n align-items: center;\n min-height: 24px;\n padding: 3px 8px;\n border-radius: 12px;\n background: #ebe8e5;\n color: #312d2a;\n font-size: 11px;\n font-weight: 700;\n line-height: 1.2;\n white-space: nowrap;\n }\n\n .opportunities-extension-stage-badge[data-stage=\"SQL\"] {\n background: #d9f0f5;\n color: #006b84;\n }\n\n .opportunities-extension-stage-badge[data-stage=\"PIPELINE\"] {\n background: #ebe8e5;\n color: #5f5a55;\n }\n\n .opportunities-extension-stage-badge[data-stage=\"UPSIDE\"] {\n background: #fff0d8;\n color: #945400;\n }\n\n .opportunities-extension-stage-badge[data-stage=\"FORECAST\"] {\n background: #eee8f5;\n color: #624e74;\n }\n\n .opportunities-extension-stage-badge[data-stage=\"WON\"] {\n background: #e5f1d9;\n color: #3f6f17;\n }\n\n .opportunities-extension-status-badge {\n display: inline-flex;\n align-items: center;\n min-height: 24px;\n padding: 3px 8px;\n border-radius: 12px;\n background: #ebe8e5;\n color: #312d2a;\n font-size: 11px;\n font-weight: 700;\n line-height: 1.2;\n white-space: nowrap;\n }\n\n .opportunities-extension-status-badge[data-status=\"OPEN\"] {\n background: #e2f2e5;\n color: #197a3d;\n }\n\n .opportunities-extension-status-badge[data-status=\"CLOSED\"] {\n background: #e5e7eb;\n color: #4b5563;\n }\n\n .opportunities-extension-status-badge[data-status=\"LOST\"] {\n background: #f9e3e1;\n color: #b13b34;\n }\n\n .opportunities-extension-status-badge[data-status=\"WON\"] {\n background: #e3edf8;\n color: #1f5f99;\n }\n\n .opportunities-extension-sort-button {\n position: relative;\n display: inline-flex;\n align-items: center;\n width: 100%;\n min-height: 44px;\n padding: 8px 42px 8px 16px;\n border: 0;\n background: transparent;\n color: inherit;\n font: inherit;\n font-weight: inherit;\n letter-spacing: 0;\n line-height: 1.2;\n text-align: left;\n white-space: normal;\n cursor: pointer;\n }\n\n .opportunities-extension-table-header-label {\n display: inline-flex;\n align-items: center;\n min-height: 44px;\n padding: 8px 6px;\n }\n\n .opportunities-extension-sort-button::after {\n position: absolute;\n right: 15px;\n color: #5f5a55;\n content: \"↕\";\n font-size: 16px;\n font-weight: 400;\n }\n\n .opportunities-extension-sort-button[data-sort-direction=\"ascending\"]::after {\n color: #00758f;\n content: \"↑\";\n }\n\n .opportunities-extension-sort-button[data-sort-direction=\"descending\"]::after {\n color: #00758f;\n content: \"↓\";\n }\n\n .opportunities-extension-sort-button:hover {\n background: #f0eeeb;\n }\n\n .opportunities-extension-sort-button:focus-visible {\n outline: 2px solid #00758f;\n outline-offset: -2px;\n }\n\n .opportunities-extension-table-status {\n height: 152px;\n color: #5f5a55;\n font-size: 14px;\n text-align: center;\n }\n\n .opportunities-extension-visually-hidden {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n }\n\n .opportunities-extension-preferences-modal {\n position: absolute;\n inset: 0;\n z-index: 3;\n display: grid;\n place-items: center;\n padding: 24px;\n background: rgba(0, 0, 0, .42);\n }\n\n .opportunities-extension-preferences-modal[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-account-plans-modal {\n position: absolute;\n inset: 0;\n z-index: 4;\n display: grid;\n place-items: center;\n padding: 12px;\n background: rgba(0, 0, 0, .42);\n }\n\n .opportunities-extension-account-plans-modal[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-account-plans-dialog {\n display: grid;\n width: min(1180px, 100%);\n height: min(760px, calc(100vh - 24px));\n min-width: 0;\n grid-template-rows: auto minmax(0, 1fr);\n grid-template-columns: minmax(0, 1fr);\n overflow: hidden;\n border: 1px solid #8f8a85;\n border-radius: 4px;\n background: #ffffff;\n box-shadow: 0 8px 24px rgba(0, 0, 0, .24);\n color: #312d2a;\n }\n\n .opportunities-extension-account-plans-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 16px;\n padding: 14px 20px;\n border-bottom: 1px solid #dedbd7;\n }\n\n .opportunities-extension-account-plans-header h2 {\n margin: 0;\n font-size: 20px;\n line-height: 1.2;\n }\n\n .opportunities-extension-account-plans-header p {\n margin: 5px 0 0;\n color: #5f5a55;\n font-size: 13px;\n line-height: 1.35;\n }\n\n .opportunities-extension-account-plans-content {\n display: grid;\n min-width: 0;\n grid-template-rows: auto minmax(0, 1fr);\n grid-template-columns: minmax(0, 1fr);\n min-height: 0;\n overflow: hidden;\n }\n\n .opportunities-extension-account-plans-content > .opportunities-extension-account-plans-skeleton {\n grid-row: 1 / -1;\n }\n\n .opportunities-extension-account-plans-tabs-navigation {\n position: relative;\n min-width: 0;\n border-bottom: 1px solid #dedbd7;\n }\n\n .opportunities-extension-account-plans-tabs-navigation .opportunities-extension-account-plans-tab-navigation-button[hidden] {\n display: none;\n }\n\n .opportunities-extension-account-plans-tabs {\n display: flex;\n min-width: 0;\n gap: 4px;\n overflow-x: auto;\n padding: 8px 20px 0;\n scrollbar-width: none;\n }\n\n .opportunities-extension-account-plans-tabs::-webkit-scrollbar {\n display: none;\n }\n\n .opportunities-extension-account-plans-tab-navigation-button {\n display: grid;\n position: absolute;\n z-index: 1;\n top: 50%;\n width: 32px;\n height: 32px;\n place-items: center;\n padding: 0;\n border: 1px solid #8f8a85;\n border-radius: 4px;\n background: #ffffff;\n color: #312d2a;\n box-shadow: 0 2px 6px rgba(0, 0, 0, .16);\n transform: translateY(-50%);\n cursor: pointer;\n }\n\n .opportunities-extension-account-plans-tab-navigation-button:first-child {\n left: 8px;\n }\n\n .opportunities-extension-account-plans-tab-navigation-button:last-child {\n right: 8px;\n }\n\n .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) {\n border-color: #00758f;\n background: #f0eeeb;\n color: #005e73;\n }\n\n .opportunities-extension-account-plans-tab-navigation-button:disabled {\n border-color: #dedbd7;\n color: #b8b2ad;\n cursor: default;\n }\n\n .opportunities-extension-account-plans-tab-navigation-button:focus-visible {\n outline: 2px solid #00758f;\n outline-offset: 2px;\n }\n\n .opportunities-extension-account-plans-tab-navigation-button svg {\n width: 18px;\n height: 18px;\n }\n\n .opportunities-extension-account-plans-panels {\n min-width: 0;\n min-height: 0;\n overflow: hidden;\n }\n\n .opportunities-extension-account-plan-tab {\n min-height: 38px;\n flex: 0 0 auto;\n padding: 7px 12px;\n border: 0;\n border-bottom: 3px solid transparent;\n background: transparent;\n color: #5f5a55;\n font-size: 13px;\n font-weight: 600;\n cursor: pointer;\n }\n\n .opportunities-extension-account-plan-tab:hover {\n background: #f0eeeb;\n color: #312d2a;\n }\n\n .opportunities-extension-account-plan-tab[aria-selected=\"true\"] {\n border-bottom-color: #00758f;\n color: #006d7a;\n }\n\n .opportunities-extension-account-plan-tab:focus-visible {\n outline: 2px solid #00758f;\n outline-offset: -2px;\n }\n\n .opportunities-extension-account-plan-panel {\n display: grid;\n height: 100%;\n min-height: 0;\n grid-template-rows: auto minmax(0, 1fr);\n box-sizing: border-box;\n padding: 16px 20px;\n }\n\n .opportunities-extension-account-plan-panel[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-account-plan-details {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 0 24px;\n margin: 0;\n }\n\n .opportunities-extension-account-plan-details > div {\n display: grid;\n grid-template-columns: minmax(104px, 38%) minmax(0, 1fr);\n gap: 10px;\n min-height: 42px;\n align-items: center;\n padding: 7px 0;\n border-bottom: 1px solid #ebe8e5;\n }\n\n .opportunities-extension-account-plan-details dt {\n color: #5f5a55;\n font-size: 12px;\n font-weight: 700;\n }\n\n .opportunities-extension-account-plan-details dd {\n min-width: 0;\n margin: 0;\n color: #312d2a;\n font-size: 13px;\n overflow-wrap: anywhere;\n }\n\n .opportunities-extension-consumption-trend {\n display: grid;\n min-height: 0;\n grid-template-rows: auto minmax(0, 1fr);\n margin-top: 14px;\n padding-top: 12px;\n border-top: 1px solid #dedbd7;\n }\n\n .opportunities-extension-consumption-trend-header {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 24px;\n }\n\n .opportunities-extension-consumption-periods {\n display: flex;\n align-items: center;\n gap: 4px;\n }\n\n .opportunities-extension-consumption-periods button {\n min-height: 38px;\n padding: 7px 14px;\n border: 1px solid transparent;\n border-radius: 4px;\n background: transparent;\n color: #312d2a;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.2;\n cursor: pointer;\n }\n\n .opportunities-extension-consumption-periods button:hover {\n background: #f0eeeb;\n }\n\n .opportunities-extension-consumption-periods button[aria-pressed=\"true\"] {\n border-color: #00758f;\n background: #e7f6f8;\n color: #005e73;\n }\n\n .opportunities-extension-consumption-periods button:focus-visible,\n .opportunities-extension-consumption-navigation-button:focus-visible {\n outline: 2px solid #00758f;\n outline-offset: 2px;\n }\n\n .opportunities-extension-consumption-chart {\n display: grid;\n min-height: 0;\n grid-template-rows: auto minmax(0, 1fr);\n margin-top: 4px;\n }\n\n .opportunities-extension-consumption-navigation {\n display: grid;\n grid-template-columns: 36px minmax(130px, auto) 36px;\n align-items: center;\n justify-content: center;\n gap: 8px;\n min-height: 40px;\n margin-bottom: 2px;\n }\n\n .opportunities-extension-consumption-navigation strong {\n color: #312d2a;\n font-size: 13px;\n text-align: center;\n }\n\n .opportunities-extension-consumption-navigation-button {\n display: grid;\n width: 36px;\n height: 36px;\n place-items: center;\n padding: 0;\n border: 1px solid #8f8a85;\n border-radius: 4px;\n background: #ffffff;\n color: #312d2a;\n cursor: pointer;\n }\n\n .opportunities-extension-consumption-navigation-button:hover:not(:disabled) {\n border-color: #00758f;\n background: #f0eeeb;\n color: #005e73;\n }\n\n .opportunities-extension-consumption-navigation-button:disabled {\n border-color: #dedbd7;\n color: #b8b2ad;\n cursor: default;\n }\n\n .opportunities-extension-consumption-navigation-button svg {\n width: 18px;\n height: 18px;\n }\n\n .opportunities-extension-consumption-figure {\n position: relative;\n min-width: 0;\n min-height: 0;\n margin: 0;\n scrollbar-width: none;\n }\n\n .opportunities-extension-consumption-figure::-webkit-scrollbar {\n display: none;\n }\n\n .opportunities-extension-consumption-visual {\n display: grid;\n height: 100%;\n min-height: 0;\n grid-template-columns: minmax(0, 1fr) 176px;\n align-items: center;\n gap: 16px;\n }\n\n .opportunities-extension-consumption-svg {\n display: block;\n width: 100%;\n height: 300px;\n min-height: 0;\n overflow: visible;\n }\n\n .opportunities-extension-consumption-svg .consumption-grid-line {\n stroke: #dedbd7;\n stroke-width: 1;\n shape-rendering: crispEdges;\n }\n\n .opportunities-extension-consumption-svg .consumption-axis-label,\n .opportunities-extension-consumption-svg .consumption-x-label {\n fill: #5f5a55;\n font-family: Arial, sans-serif;\n font-size: 12px;\n }\n\n .opportunities-extension-consumption-svg .consumption-value-label {\n fill: #312d2a;\n font-family: Arial, sans-serif;\n font-size: 12px;\n font-weight: 700;\n }\n\n .opportunities-extension-consumption-svg .consumption-bar-funded {\n fill: #b77888;\n }\n\n .opportunities-extension-consumption-svg .consumption-bar-overage {\n fill: #de7907;\n }\n\n .opportunities-extension-consumption-svg .consumption-average-line {\n stroke: #de7907;\n stroke-width: 1.5;\n stroke-dasharray: 6 6;\n }\n\n .opportunities-extension-consumption-svg .consumption-bar-group {\n cursor: default;\n outline: none;\n }\n\n .opportunities-extension-consumption-svg .consumption-bar-group rect {\n transition: filter .16s ease, opacity .16s ease, stroke .16s ease;\n }\n\n .opportunities-extension-consumption-svg .consumption-bar-group:hover rect,\n .opportunities-extension-consumption-svg .consumption-bar-group:focus rect {\n filter: brightness(1.06);\n stroke: #312d2a;\n stroke-width: 1.5;\n }\n\n .opportunities-extension-consumption-svg .consumption-animated-label {\n opacity: 0;\n animation: opportunities-extension-consumption-label-in .24s ease-out forwards;\n }\n\n .opportunities-extension-consumption-svg .consumption-animated-average {\n opacity: 0;\n animation: opportunities-extension-consumption-average-in .35s .32s ease-out forwards;\n }\n\n @keyframes opportunities-extension-consumption-label-in {\n from { opacity: 0; transform: translateY(4px); }\n to { opacity: 1; transform: translateY(0); }\n }\n\n @keyframes opportunities-extension-consumption-average-in {\n from { opacity: 0; }\n to { opacity: 1; }\n }\n\n .opportunities-extension-consumption-legend {\n display: grid;\n align-content: center;\n gap: 4px;\n }\n\n .opportunities-extension-consumption-legend button {\n display: grid;\n min-width: 0;\n grid-template-columns: 16px minmax(0, 1fr);\n align-items: center;\n gap: 8px;\n padding: 6px 4px;\n border: 0;\n border-radius: 4px;\n background: transparent;\n color: #312d2a;\n font-size: 12px;\n line-height: 1.25;\n text-align: left;\n cursor: pointer;\n }\n\n .opportunities-extension-consumption-legend button:hover {\n background: #f0eeeb;\n }\n\n .opportunities-extension-consumption-legend button:focus-visible {\n outline: 2px solid #00758f;\n outline-offset: 1px;\n }\n\n .opportunities-extension-consumption-legend button[aria-pressed=\"false\"] {\n color: #77726d;\n opacity: .55;\n }\n\n .opportunities-extension-consumption-legend-swatch {\n display: block;\n width: 12px;\n height: 12px;\n justify-self: center;\n }\n\n .opportunities-extension-consumption-legend-funded {\n background: #b77888;\n }\n\n .opportunities-extension-consumption-legend-overage {\n background: #de7907;\n }\n\n .opportunities-extension-consumption-legend-average {\n height: 0;\n border-top: 2px dashed #de7907;\n }\n\n .opportunities-extension-consumption-tooltip {\n position: absolute;\n z-index: 2;\n display: grid;\n min-width: 190px;\n max-width: 260px;\n gap: 4px;\n padding: 10px 12px;\n border: 1px solid #8f8a85;\n border-radius: 4px;\n background: #ffffff;\n box-shadow: 0 4px 12px rgba(0, 0, 0, .2);\n color: #312d2a;\n font-size: 12px;\n line-height: 1.3;\n pointer-events: none;\n }\n\n .opportunities-extension-consumption-tooltip[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-consumption-tooltip strong {\n margin-bottom: 2px;\n font-size: 13px;\n }\n\n .opportunities-extension-consumption-chart-skeleton {\n height: 100%;\n min-height: 0;\n padding: 34px 62px 24px;\n box-sizing: border-box;\n }\n\n .opportunities-extension-consumption-chart-skeleton-plot {\n display: flex;\n height: 100%;\n min-height: 220px;\n align-items: flex-end;\n justify-content: space-around;\n gap: 12px;\n padding: 0 14px;\n border-bottom: 1px solid #dedbd7;\n border-left: 1px solid #dedbd7;\n }\n\n .opportunities-extension-consumption-chart-skeleton-plot span {\n width: min(38px, 6%);\n border-radius: 2px 2px 0 0;\n background: #dedbd7;\n animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate;\n }\n\n .opportunities-extension-consumption-chart-empty,\n .opportunities-extension-consumption-chart-error {\n display: grid;\n min-height: 220px;\n place-items: center;\n margin: 0;\n color: #5f5a55;\n font-size: 14px;\n text-align: center;\n }\n\n .opportunities-extension-consumption-chart-error {\n color: #9f2d20;\n }\n\n .opportunities-extension-account-plan-status-indicator {\n display: inline-block;\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: #9b9691;\n box-shadow: 0 0 0 3px #efedeb;\n vertical-align: middle;\n }\n\n .opportunities-extension-account-plan-status-indicator[data-active=\"true\"] {\n background: #3f6f17;\n box-shadow: 0 0 0 3px #e2f2e5;\n }\n\n .opportunities-extension-account-plan-tab .opportunities-extension-account-plan-status-indicator {\n margin-right: 8px;\n }\n\n .opportunities-extension-account-plans-skeleton-tabs {\n display: flex;\n min-height: 46px;\n align-items: center;\n padding: 0 20px;\n border-bottom: 1px solid #dedbd7;\n }\n\n .opportunities-extension-account-plans-skeleton {\n display: grid;\n height: 100%;\n min-height: 0;\n grid-template-rows: auto minmax(0, 1fr);\n overflow: hidden;\n }\n\n .opportunities-extension-account-plans-skeleton-panel {\n padding: 16px 20px;\n }\n\n .opportunities-extension-account-plans-skeleton-tab {\n display: inline-flex;\n align-items: center;\n gap: 9px;\n min-height: 38px;\n padding: 0 12px;\n border-bottom: 3px solid #00758f;\n }\n\n .opportunities-extension-account-plans-skeleton-status {\n display: block;\n width: 10px;\n height: 10px;\n flex: 0 0 auto;\n border-radius: 50%;\n background: #dedbd7;\n box-shadow: 0 0 0 3px #efedeb;\n animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate;\n }\n\n .opportunities-extension-account-plans-skeleton-details {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 0 24px;\n padding: 0;\n }\n\n .opportunities-extension-account-plans-skeleton-details > div {\n display: grid;\n grid-template-columns: minmax(104px, 38%) minmax(0, 1fr);\n align-items: center;\n gap: 10px;\n min-height: 42px;\n padding: 7px 0;\n border-bottom: 1px solid #ebe8e5;\n }\n\n .opportunities-extension-account-plans-skeleton-bar {\n display: block;\n height: 12px;\n border-radius: 3px;\n background: #dedbd7;\n animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate;\n }\n\n .opportunities-extension-account-plans-skeleton-tabs .opportunities-extension-account-plans-skeleton-bar {\n height: 14px;\n }\n\n .opportunities-extension-account-plans-skeleton-label {\n width: 74%;\n height: 10px;\n }\n\n .opportunities-extension-account-plans-skeleton-trend {\n display: grid;\n min-height: 0;\n grid-template-rows: auto auto minmax(0, 1fr);\n margin: 14px 0 0;\n padding-top: 12px;\n border-top: 1px solid #dedbd7;\n }\n\n .opportunities-extension-account-plans-skeleton-trend-header {\n display: flex;\n justify-content: flex-end;\n min-height: 38px;\n }\n\n .opportunities-extension-account-plans-skeleton-periods {\n display: flex;\n align-items: center;\n gap: 16px;\n }\n\n .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar {\n height: 12px;\n }\n\n .opportunities-extension-account-plans-skeleton-period-active {\n height: 38px !important;\n border: 1px solid #8cc8d3;\n background: #e7f6f8;\n }\n\n .opportunities-extension-account-plans-skeleton-navigation {\n display: grid;\n grid-template-columns: 32px 124px 32px;\n align-items: center;\n justify-content: center;\n gap: 8px;\n min-height: 54px;\n }\n\n .opportunities-extension-account-plans-skeleton-navigation .opportunities-extension-account-plans-skeleton-bar {\n justify-self: center;\n }\n\n .opportunities-extension-account-plans-skeleton-chart {\n display: grid;\n min-height: 0;\n grid-template-columns: minmax(0, 1fr) 150px;\n align-items: center;\n gap: 28px;\n padding: 12px 14px 0 62px;\n }\n\n .opportunities-extension-account-plans-skeleton-plot {\n display: flex;\n position: relative;\n height: 300px;\n min-height: 0;\n box-sizing: border-box;\n align-items: flex-end;\n justify-content: space-around;\n gap: 12px;\n padding: 0 14px 64px;\n background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #ebe8e5 58px, #ebe8e5 59px) left top / 100% 236px no-repeat;\n }\n\n .opportunities-extension-account-plans-skeleton-plot::before,\n .opportunities-extension-account-plans-skeleton-plot::after {\n position: absolute;\n display: block;\n content: \"\";\n background: #dedbd7;\n }\n\n .opportunities-extension-account-plans-skeleton-plot::before {\n top: 0;\n bottom: 64px;\n left: 0;\n width: 1px;\n }\n\n .opportunities-extension-account-plans-skeleton-plot::after {\n right: 0;\n bottom: 64px;\n left: 0;\n height: 1px;\n }\n\n .opportunities-extension-account-plans-skeleton-plot .opportunities-extension-account-plans-skeleton-bar {\n width: min(38px, 7%);\n flex: 0 1 38px;\n border-radius: 2px 2px 0 0;\n }\n\n .opportunities-extension-account-plans-skeleton-legend {\n display: grid;\n align-content: center;\n gap: 18px;\n min-height: 0;\n }\n\n .opportunities-extension-account-plans-skeleton-legend .opportunities-extension-account-plans-skeleton-bar {\n height: 11px;\n }\n\n @keyframes opportunities-extension-skeleton-pulse {\n from { opacity: .48; }\n to { opacity: 1; }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .opportunities-extension-account-plans-skeleton-bar,\n .opportunities-extension-consumption-chart-skeleton-plot span,\n .opportunities-extension-consumption-svg .consumption-animated-label,\n .opportunities-extension-consumption-svg .consumption-animated-average {\n animation: none;\n opacity: 1;\n }\n }\n\n .opportunities-extension-account-plans-empty,\n .opportunities-extension-account-plans-error {\n margin: 0;\n padding: 28px 20px;\n color: #5f5a55;\n font-size: 14px;\n }\n\n .opportunities-extension-account-plans-error {\n color: #9f2d20;\n }\n\n .opportunities-extension-preferences-dialog {\n display: grid;\n width: min(680px, 100%);\n max-height: min(720px, calc(100vh - 48px));\n grid-template-rows: auto minmax(0, 1fr) auto;\n overflow: hidden;\n border: 1px solid #8f8a85;\n border-radius: 4px;\n background: #ffffff;\n box-shadow: 0 8px 24px rgba(0, 0, 0, .24);\n color: #312d2a;\n }\n\n .opportunities-extension-preferences-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n padding: 18px 20px;\n border-bottom: 1px solid #dedbd7;\n }\n\n .opportunities-extension-preferences-header h2 {\n margin: 0;\n font-size: 20px;\n font-weight: 700;\n line-height: 1.2;\n }\n\n .opportunities-extension-preferences-content {\n min-height: 0;\n overflow: auto;\n padding: 20px;\n }\n\n .opportunities-extension-preferences-fieldset {\n min-width: 0;\n margin: 0;\n padding: 0;\n border: 0;\n }\n\n .opportunities-extension-preferences-fieldset legend {\n margin-bottom: 12px;\n padding: 0;\n color: #312d2a;\n font-size: 15px;\n font-weight: 700;\n line-height: 1.3;\n }\n\n .opportunities-extension-preferences-options {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 4px 20px;\n }\n\n .opportunities-extension-preferences-option {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n min-width: 0;\n padding: 9px 8px;\n border-radius: 4px;\n color: #312d2a;\n font-size: 14px;\n line-height: 1.35;\n cursor: pointer;\n }\n\n .opportunities-extension-preferences-option:hover {\n background: #f0eeeb;\n }\n\n .opportunities-extension-preferences-option input {\n width: 18px;\n height: 18px;\n flex: 0 0 18px;\n margin: 0;\n accent-color: #00758f;\n cursor: pointer;\n }\n\n .opportunities-extension-preferences-cache-field {\n display: grid;\n width: min(280px, 100%);\n gap: 6px;\n margin-top: 24px;\n color: #312d2a;\n font-size: 14px;\n font-weight: 700;\n }\n\n .opportunities-extension-preferences-cache-field select {\n width: 100%;\n height: 40px;\n padding: 0 36px 0 12px;\n border: 1px solid #8f8a85;\n border-radius: 4px;\n background: #ffffff;\n color: #312d2a;\n font-size: 14px;\n font-weight: 400;\n }\n\n .opportunities-extension-preferences-cache-field select:focus {\n border-color: #00758f;\n outline: 2px solid #bde7ee;\n outline-offset: 0;\n }\n\n .opportunities-extension-preferences-footer {\n display: flex;\n justify-content: flex-end;\n padding: 14px 20px;\n border-top: 1px solid #dedbd7;\n }\n\n .opportunities-extension-button {\n min-width: 88px;\n min-height: 36px;\n padding: 7px 16px;\n border: 1px solid transparent;\n border-radius: 4px;\n font-size: 14px;\n font-weight: 600;\n line-height: 1.2;\n cursor: pointer;\n }\n\n .opportunities-extension-button-primary {\n border-color: #00758f;\n background: #00758f;\n color: #ffffff;\n }\n\n .opportunities-extension-button-primary:hover,\n .opportunities-extension-button-primary:focus-visible {\n border-color: #005e73;\n background: #005e73;\n outline: none;\n }\n\n .opportunities-extension-debug-panel {\n position: fixed;\n right: 24px;\n bottom: 24px;\n z-index: 1;\n width: min(640px, calc(100vw - 48px));\n max-height: min(560px, calc(100vh - 120px));\n overflow: auto;\n border: 1px solid #8f8a85;\n border-radius: 4px;\n background: #ffffff;\n box-shadow: 0 2px 8px rgba(0, 0, 0, .16);\n padding: 16px;\n color: #000000;\n }\n\n .opportunities-extension-debug-panel[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-debug-panel h2 {\n margin: 0;\n font-size: 18px;\n font-weight: 700;\n line-height: 1.25;\n }\n\n .opportunities-extension-debug-panel p {\n margin: 4px 0 0;\n color: #5f5a55;\n font-size: 13px;\n }\n\n .opportunities-extension-debug-panel-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 12px;\n margin-bottom: 12px;\n }\n\n .opportunities-extension-debug-visibility-button {\n width: 34px;\n height: 34px;\n flex: 0 0 34px;\n color: #312d2a;\n }\n\n .opportunities-extension-debug-visibility-button svg {\n width: 20px;\n height: 20px;\n }\n\n .opportunities-extension-debug-list {\n display: grid;\n gap: 10px;\n }\n\n .opportunities-extension-debug-item {\n border: 1px solid #dedbd7;\n border-left-width: 4px;\n border-radius: 4px;\n background: #faf9f8;\n }\n\n .opportunities-extension-debug-item[open] {\n background: #ffffff;\n }\n\n .opportunities-extension-debug-item-success {\n border-left-color: #3f6f17;\n }\n\n .opportunities-extension-debug-item-success .opportunities-extension-debug-status {\n background: #e2f2e5;\n color: #356d19;\n }\n\n .opportunities-extension-debug-item-failed {\n border-left-color: #c5331f;\n }\n\n .opportunities-extension-debug-item-failed .opportunities-extension-debug-status {\n background: #f9e3e1;\n color: #a52b1c;\n }\n\n .opportunities-extension-debug-item-pending {\n border-left-color: #6f5a7f;\n }\n\n .opportunities-extension-debug-item-pending .opportunities-extension-debug-status {\n background: #eee8f2;\n color: #6f5a7f;\n }\n\n .opportunities-extension-debug-heading {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n min-height: 42px;\n padding: 9px 12px 9px 10px;\n cursor: pointer;\n list-style: none;\n }\n\n .opportunities-extension-debug-heading::-webkit-details-marker {\n display: none;\n }\n\n .opportunities-extension-debug-heading::before {\n content: \"⌄\";\n width: 14px;\n color: #5f5a55;\n font-size: 16px;\n line-height: 1;\n transform: rotate(-90deg);\n transition: transform .15s ease;\n }\n\n .opportunities-extension-debug-item[open] .opportunities-extension-debug-heading::before {\n transform: rotate(0deg);\n }\n\n .opportunities-extension-debug-summary-meta {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n margin-left: auto;\n }\n\n .opportunities-extension-debug-http-status,\n .opportunities-extension-debug-status {\n display: inline-flex;\n align-items: center;\n min-height: 22px;\n padding: 0 8px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 700;\n line-height: 1;\n text-transform: uppercase;\n }\n\n .opportunities-extension-debug-http-status {\n background: #e8f4f6;\n color: #006d7a;\n }\n\n .opportunities-extension-debug-status {\n background: #ebe8e5;\n color: #5f5a55;\n }\n\n .opportunities-extension-debug-heading strong {\n font-size: 13px;\n }\n\n .opportunities-extension-debug-item pre {\n margin: 0;\n padding: 8px 12px 12px;\n color: #312d2a;\n font-family: Consolas, \"Courier New\", monospace;\n font-size: 12px;\n line-height: 1.45;\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n .opportunities-extension-debug-empty {\n padding: 14px;\n border: 1px dashed #b8b2ad;\n border-radius: 4px;\n color: #5f5a55;\n font-size: 13px;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] {\n background: #1f1f1f;\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-shell,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-body {\n background: #1f1f1f;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-header {\n border-bottom-color: #4e4a46;\n background:\n linear-gradient(90deg, #a8795d 0 11%, #dd5a47 11% 23%, #876e9b 23% 36%, #00a6be 36% 51%, #dfbd76 51% 63%, transparent 63% 100%) top left / 100% 6px no-repeat,\n #292827;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-header h1,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-dashboard-value,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-field,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-label,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-label,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-label,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table-search-field {\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-header p,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-header p,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-dashboard-card span,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-empty,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-detail-heading span,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-empty,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-empty,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plan-details dt,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-debug-panel p,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-debug-empty {\n color: #c9c5c1;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-error {\n color: #ffb4a8;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-chart-error {\n color: #ffb4a8;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-bar {\n background: #5b5753;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-status {\n background: #5b5753;\n box-shadow: 0 0 0 3px #3b3937;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-period-active {\n border-color: #3f7580;\n background: #23474e;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-chart-skeleton-plot span {\n background: #5b5753;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plan-status-indicator {\n background: #8f8a85;\n box-shadow: 0 0 0 3px #3b3937;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plan-status-indicator[data-active=\"true\"] {\n background: #7fb05b;\n box-shadow: 0 0 0 3px #314527;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-form,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-dashboard-card,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table-surface,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-panel,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-dialog,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-dialog,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-table,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-debug-panel,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-debug-item[open] {\n border-color: #4e4a46;\n background: #292827;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-trigger,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-search,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table-search-field input,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-field select,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-cache-field select,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button {\n border-color: #6a6560;\n background: #333130;\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-trigger::after {\n border-top-color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-select-wrap::after {\n border-top-color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-trigger:disabled {\n background: #292827;\n color: #918b86;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-search::placeholder,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table-search-field input::placeholder {\n color: #b5b0ab;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-select-all,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-option,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-header h2,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-header h2,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-fieldset legend,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-option,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-cache-field,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-detail-heading,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plan-details dd,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-debug-heading,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-debug-item pre,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-debug-panel h2 {\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-navigation strong {\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-chart-empty {\n color: #c9c5c1;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-select-all,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table th {\n border-bottom-color: #4e4a46;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-header,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-footer,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-header,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-tabs-navigation,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-tabs,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plan-details > div,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-tabs,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-details > div {\n border-color: #4e4a46;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-trend,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-chart-skeleton-plot,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-trend,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-plot {\n border-color: #4e4a46;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-plot {\n background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #4e4a46 58px, #4e4a46 59px) left top / 100% 236px no-repeat;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-plot::before,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-plot::after {\n background: #4e4a46;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plan-tab {\n color: #c9c5c1;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plan-tab:hover {\n background: #3b3937;\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plan-tab[aria-selected=\"true\"] {\n border-bottom-color: #43c4d5;\n color: #91e4ed;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-periods button {\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-periods button:hover {\n background: #3b3937;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-periods button[aria-pressed=\"true\"] {\n border-color: #43c4d5;\n background: #173e43;\n color: #91e4ed;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-navigation-button,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-tab-navigation-button {\n border-color: #6a6560;\n background: #333130;\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-navigation-button:hover:not(:disabled),\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) {\n border-color: #43c4d5;\n background: #3b3937;\n color: #91e4ed;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-navigation-button:disabled,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-tab-navigation-button:disabled {\n border-color: #4e4a46;\n color: #746f6a;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-grid-line {\n stroke: #4e4a46;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-axis-label,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-x-label {\n fill: #c9c5c1;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-value-label {\n fill: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-bar-funded {\n fill: #ca8798;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-bar-overage {\n fill: #f09a32;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-average-line {\n stroke: #f09a32;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-bar-group:hover rect,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-bar-group:focus rect {\n stroke: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-legend button {\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-legend button:hover {\n background: #3b3937;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-legend button[aria-pressed=\"false\"] {\n color: #a8a39e;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-legend-funded {\n background: #ca8798;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-legend-overage {\n background: #f09a32;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-legend-average {\n border-top-color: #f09a32;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-tooltip {\n border-color: #6a6560;\n background: #333130;\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-option:hover {\n background: #3b3937;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-option input {\n accent-color: #43c4d5;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-button-primary {\n border-color: #00a6be;\n background: #008aa6;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-button-primary:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-button-primary:focus-visible {\n border-color: #43c4d5;\n background: #006d7a;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-option:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table tbody tr:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-sort-button:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-icon-button:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-icon-button:focus {\n background: #3b3937;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table thead,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table th,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-table th {\n background: #333130;\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table td {\n border-bottom-color: #3f3c39;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-detail {\n border-bottom-color: #4e4a46;\n background: #252423;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-table th,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-table td {\n border-bottom-color: #3f3c39;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-count {\n border-color: #2f9bae;\n background: #1d4e55;\n color: #91e4ed;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-count:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-count:focus-visible,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-count[aria-expanded=\"true\"] {\n border-color: #43c4d5;\n background: #008aa6;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-progress[data-state=\"running\"] {\n background: #1d4e55;\n color: #91e4ed;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-progress[data-state=\"complete\"] {\n background: #244f2e;\n color: #a8dfb7;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-progress[data-state=\"complete-with-errors\"],\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-progress[data-state=\"error\"],\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-count-error {\n border-color: #b13b34;\n background: #5f2926;\n color: #ffb4ad;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-cache-timestamp {\n background: #3b3937;\n color: #c9c5c1;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table a,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-copy-button,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-action-button {\n color: #43c4d5;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table a:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-copy-button:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-copy-button:focus-visible,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-action-button:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-action-button:focus-visible {\n background: #1d4e55;\n color: #91e4ed;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[data-stage=\"SQL\"] {\n color: #43c4d5;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[data-stage=\"PIPELINE\"] {\n color: #d1cbc6;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[data-stage=\"UPSIDE\"] {\n color: #ffbf62;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[data-stage=\"FORECAST\"] {\n color: #d2b7e9;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[data-stage=\"WON\"] {\n color: #9bd46a;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-dashboard-card[data-stage=\"SQL\"] {\n border-top-color: #43c4d5;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-total-dashboard-card {\n border-top-color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-dashboard-card[data-stage=\"PIPELINE\"] {\n border-top-color: #c0b9b3;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-dashboard-card[data-stage=\"UPSIDE\"] {\n border-top-color: #ffb84d;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-dashboard-card[data-stage=\"FORECAST\"] {\n border-top-color: #cba7e7;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-dashboard-card[data-stage=\"WON\"] {\n border-top-color: #92cf5b;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"SQL\"] {\n border-color: #008aa6;\n background: #008aa6;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"PIPELINE\"] {\n border-color: #6f6863;\n background: #6f6863;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"UPSIDE\"] {\n border-color: #b96e00;\n background: #b96e00;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"FORECAST\"] {\n border-color: #755b8b;\n background: #755b8b;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"WON\"] {\n border-color: #4b7f22;\n background: #4b7f22;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button[data-status=\"OPEN\"] {\n color: #69c985;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button[data-status=\"CLOSED\"] {\n color: #c3cad3;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button[data-status=\"LOST\"] {\n color: #ff9d94;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button[data-status=\"WON\"] {\n color: #83bfff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button[aria-pressed=\"true\"][data-status=\"OPEN\"] {\n border-color: #197a3d;\n background: #197a3d;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button[aria-pressed=\"true\"][data-status=\"CLOSED\"] {\n border-color: #4b5563;\n background: #4b5563;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button[aria-pressed=\"true\"][data-status=\"LOST\"] {\n border-color: #b13b34;\n background: #b13b34;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button[aria-pressed=\"true\"][data-status=\"WON\"] {\n border-color: #1f5f99;\n background: #1f5f99;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-debug-item {\n border-color: #4e4a46;\n background: #252423;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-icon-button {\n color: #f6f4f2;\n }\n\n @media (min-width: 1201px) and (max-width: 1450px) {\n .opportunities-extension-table th:nth-child(1) {\n width: 16%;\n }\n\n .opportunities-extension-table th:nth-child(3) {\n width: 18%;\n }\n\n .opportunities-extension-table th:nth-child(4) {\n width: 12%;\n }\n\n .opportunities-extension-table th:nth-child(6) {\n width: 7%;\n }\n\n .opportunities-extension-table th:nth-child(7) {\n width: 8%;\n }\n\n .opportunities-extension-table th:nth-child(10) {\n width: 10%;\n }\n }\n\n @media (max-width: 1200px) {\n .opportunities-extension-table {\n min-width: 1180px;\n }\n\n .opportunities-extension-table th:nth-child(1) {\n width: 15%;\n }\n\n .opportunities-extension-table th:nth-child(3) {\n width: 17%;\n }\n\n .opportunities-extension-table th:nth-child(4) {\n width: 13%;\n }\n\n .opportunities-extension-table th:nth-child(6) {\n width: 6%;\n }\n\n .opportunities-extension-table th:nth-child(7) {\n width: 9%;\n }\n\n .opportunities-extension-table th:nth-child(10) {\n width: 11%;\n }\n }\n\n @media (max-width: 700px) {\n .opportunities-extension-header {\n padding: 24px 20px 18px;\n }\n\n .opportunities-extension-header::before {\n left: 20px;\n }\n\n .opportunities-extension-header h1 {\n font-size: 22px;\n }\n\n .opportunities-extension-header p {\n font-size: 14px;\n }\n\n .opportunities-extension-body {\n padding: 16px 20px 24px;\n }\n\n .opportunities-extension-form {\n align-items: stretch;\n flex-wrap: wrap;\n gap: 14px;\n min-height: auto;\n padding: 12px;\n }\n\n .opportunities-extension-field {\n max-width: none;\n width: 100%;\n }\n\n .opportunities-extension-customer-filter {\n width: 100%;\n }\n\n .opportunities-extension-customer-filter-trigger {\n width: 100%;\n }\n\n .opportunities-extension-customer-filter-panel {\n width: min(360px, calc(100vw - 64px));\n }\n\n .opportunities-extension-table-search-field {\n width: 100%;\n }\n\n .opportunities-extension-stage-dashboard {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n\n .opportunities-extension-stage-dashboard-value {\n font-size: 18px;\n }\n\n .opportunities-extension-preferences-modal {\n padding: 12px;\n }\n\n .opportunities-extension-account-plans-modal {\n padding: 12px;\n }\n\n .opportunities-extension-preferences-dialog {\n max-height: calc(100vh - 24px);\n }\n\n .opportunities-extension-account-plans-dialog {\n height: calc(100vh - 24px);\n }\n\n .opportunities-extension-account-plan-panel {\n padding: 12px 14px;\n }\n\n .opportunities-extension-account-plan-details {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 0 14px;\n }\n\n .opportunities-extension-account-plan-details > div {\n grid-template-columns: 1fr;\n align-content: center;\n gap: 3px;\n min-height: 54px;\n padding: 5px 0;\n }\n\n .opportunities-extension-account-plans-skeleton-details {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 0 14px;\n padding: 0;\n }\n\n .opportunities-extension-account-plans-skeleton-details > div {\n grid-template-columns: 1fr;\n align-content: center;\n gap: 5px;\n min-height: 54px;\n padding: 5px 0;\n }\n\n .opportunities-extension-account-plans-skeleton-trend {\n margin: 14px 0 0;\n }\n\n .opportunities-extension-account-plans-skeleton-periods {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n align-items: center;\n gap: 6px;\n }\n\n .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar {\n width: 100% !important;\n }\n\n .opportunities-extension-account-plans-skeleton-chart {\n grid-template-columns: minmax(0, 1fr);\n gap: 12px;\n padding: 12px 8px 0 34px;\n }\n\n .opportunities-extension-account-plans-skeleton-legend {\n display: flex;\n min-height: 24px;\n align-items: center;\n justify-content: center;\n gap: 12px;\n }\n\n .opportunities-extension-consumption-trend-header {\n align-items: stretch;\n }\n\n .opportunities-extension-consumption-periods {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n gap: 3px;\n }\n\n .opportunities-extension-consumption-periods button {\n min-width: 0;\n padding: 7px 4px;\n font-size: 12px;\n }\n\n .opportunities-extension-consumption-chart {\n min-height: 0;\n }\n\n .opportunities-extension-consumption-svg {\n width: 760px;\n height: 245px;\n }\n\n .opportunities-extension-consumption-visual {\n width: 920px;\n grid-template-columns: 760px 144px;\n gap: 12px;\n }\n\n .opportunities-extension-consumption-figure {\n overflow-x: auto;\n overscroll-behavior-inline: contain;\n }\n\n .opportunities-extension-consumption-chart-skeleton {\n min-height: 0;\n padding: 24px 18px 18px;\n }\n\n .opportunities-extension-consumption-chart-skeleton-plot {\n min-height: 180px;\n gap: 4px;\n padding: 0 6px;\n }\n\n .opportunities-extension-preferences-options {\n grid-template-columns: 1fr;\n }\n\n .opportunities-extension-stage-filter-controls {\n gap: 6px;\n }\n\n .opportunities-extension-status-filter-controls {\n gap: 6px;\n }\n\n .opportunities-extension-table-surface {\n min-width: 0;\n min-height: calc(100vh - 230px);\n }\n\n .opportunities-extension-debug-panel {\n right: 12px;\n bottom: 12px;\n width: calc(100vw - 24px);\n max-height: calc(100vh - 96px);\n }\n }\n "; +// Source: src/shared/messages.js +/** Stable wire names. All payloads remain plain, serializable objects. */ +function createMessageTypes() { + return Object.freeze({ + getXsrfToken: "opportunitiesExtension.getXsrfToken", + refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie", + requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts", + opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress", + requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService", + requestAccountPlans: "opportunitiesExtension.requestAccountPlans", + requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend" + }); +} +// Source: src/content/contracts.js +function createContentContracts() { const TILE_ID = "c_5c719a88b5624b1eaf81defe2c2ex4x5"; const TILE_LABEL_ID = `${TILE_ID}_0`; const SALES_GROUP_SELECTOR = "#yourapps_groupNode_sales"; @@ -86,53 +102,6 @@ { label: "48 hours", value: 48 }, { label: "7 days", value: 168 } ]; - let tokenRelayRequest = null; - let consumerTokenServiceRequest = null; - let accessToken = ""; - let salesCommonToken = null; - let salesCommonTokenRequest = null; - let periodRequestVersion = 0; - let authStatus = AUTH_STATUS.idle; - let requestLog = []; - let debugSecretsVisible = false; - let selectedStages = new Set(STAGE_OPTIONS); - let selectedStatuses = new Set(STATUS_OPTIONS); - let selectedCustomerKeys = new Set(); - let customerSearch = ""; - let selectedOwnerKeys = new Set(); - let ownerSearch = ""; - let tableSearch = ""; - let selectedOpportunityTypeValues = new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES); - let opptyProductsCacheHours = DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; - let currentProductsRequestId = ""; - let productsRenderQueued = false; - let opptyProductsByNumber = new Map(); - let expandedProductOptyNumbers = new Set(); - const aiSummariesByOptyNumber = new Map(); - let productsProgress = { - state: "idle", - completed: 0, - total: 0, - failed: 0, - cacheHits: 0 - }; - let productsLastCachedAt = 0; - let accountPlansRequestState = new Map(); - let consumptionTrendCache = new Map(); - let consumptionTrendWindowOffsets = new Map(); - let consumptionTrendSeriesFilters = new Map(); - let hasSavedCustomerFilter = false; - let hasSavedOwnerFilter = false; - let customerFilterInitialized = false; - let ownerFilterInitialized = false; - let stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); - let opportunitiesTableState = { - items: [], - status: "idle", - message: "", - sortKey: "", - sortDirection: "ascending" - }; const PERIOD_OPTIONS = [ "Current Quarter", "Next Quarter", @@ -141,7 +110,136 @@ "4 Rolling Quarters (CQ + 3)", "RENEWALS (Current + Past Due)" ]; - const OPPORTUNITIES_COLUMNS = [ + const ALLOWED_PATHS = [ + "/hcmUI/faces/FuseWelcome", + "/fscmUI/faces/FuseWelcome" + ]; + return { TILE_ID, TILE_LABEL_ID, SALES_GROUP_SELECTOR, ADD_ICON_SELECTOR, MODAL_ID, STYLE_ID, ESCAPE_LISTENER_KEY, DEBUG_PANEL_ID, OPPORTUNITIES_TABLE_ID, STAGE_FILTERS_ID, STATUS_FILTERS_ID, CUSTOMER_FILTER_BUTTON_ID, CUSTOMER_FILTER_PANEL_ID, CUSTOMER_FILTER_SEARCH_ID, CUSTOMER_FILTER_SELECT_ALL_ID, CUSTOMER_FILTER_LIST_ID, OWNER_FILTER_BUTTON_ID, OWNER_FILTER_PANEL_ID, OWNER_FILTER_SEARCH_ID, OWNER_FILTER_SELECT_ALL_ID, OWNER_FILTER_LIST_ID, TABLE_SEARCH_ID, STAGE_DASHBOARD_ID, PRODUCTS_PROGRESS_ID, PRODUCTS_CACHE_TIMESTAMP_ID, FILTER_PREFERENCES_STORAGE_KEY, THEME_STORAGE_KEY, OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, PREFERENCES_MODAL_ID, ACCOUNT_PLANS_MODAL_ID, PRODUCTS_MESSAGE_LISTENER_KEY, OPPORTUNITY_DETAIL_URL, ACCOUNT_DETAIL_URL, DEBUG_BODY_LIMIT, OPPORTUNITIES_PAGE_LIMIT, DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, TOKEN_RELAY_URL, OPPORTUNITIES_QUERY_URL, AUTH_STATUS, CONSUMPTION_TREND_PERIODS, CONSUMPTION_TREND_SERIES, DEFAULT_CONSUMPTION_TREND_PERIOD, SVG_NAMESPACE, STAGE_OPTIONS, STATUS_OPTIONS, OPPORTUNITY_TYPE_OPTIONS, DEFAULT_OPPORTUNITY_TYPE_VALUES, OPPTY_PRODUCTS_CACHE_OPTIONS, PERIOD_OPTIONS, ALLOWED_PATHS }; +} + +// Source: src/auth/session-state.js +function createAuthSessionState({ AUTH_STATUS } = {}) { + return { + tokenRelayRequest: null, + consumerTokenServiceRequest: null, + accessToken: "", + authStatus: AUTH_STATUS.idle + }; +} + +// Source: src/auth/sales-common-state.js +/** @returns {{salesCommonToken: null | {access_token: string, expiresAt: number}, salesCommonTokenRequest: null | Promise}} */ +function createAuthSalesCommonState() { + return { + salesCommonToken: null, + salesCommonTokenRequest: null + }; +} + +// Source: src/features/opportunities/query-state.js +function createOpportunitiesQueryState() { + return { + periodRequestVersion: 0 + }; +} + +// Source: src/features/opportunities/table-state.js +function createOpportunitiesTableState() { + return { + opportunitiesTableState: { + items: [], + status: "idle", + message: "", + sortKey: "", + sortDirection: "ascending" + } + }; +} + +// Source: src/features/opportunities/filter-state.js +function createOpportunitiesFilterState({ STAGE_OPTIONS, STATUS_OPTIONS } = {}) { + return { + selectedStages: new Set(STAGE_OPTIONS), + selectedStatuses: new Set(STATUS_OPTIONS), + selectedCustomerKeys: new Set(), + customerSearch: "", + selectedOwnerKeys: new Set(), + ownerSearch: "", + tableSearch: "", + hasSavedCustomerFilter: false, + hasSavedOwnerFilter: false, + customerFilterInitialized: false, + ownerFilterInitialized: false + }; +} + +// Source: src/features/preferences/state.js +function createPreferencesState({ DEFAULT_OPPORTUNITY_TYPE_VALUES, DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS } = {}) { + return { + selectedOpportunityTypeValues: new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES), + opptyProductsCacheHours: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS + }; +} + +// Source: src/features/products/content-state.js +function createProductsContentState() { + return { + currentProductsRequestId: "", + productsRenderQueued: false, + opptyProductsByNumber: new Map(), + expandedProductOptyNumbers: new Set(), + productsProgress: { + state: "idle", + completed: 0, + total: 0, + failed: 0, + cacheHits: 0 + }, + productsLastCachedAt: 0 + }; +} + +// Source: src/features/ai-summary/state.js +function createAiSummaryState() { + return { + aiSummariesByOptyNumber: new Map() + }; +} + +// Source: src/features/consumption/plans-state.js +function createConsumptionPlansState() { + return { + accountPlansRequestState: new Map() + }; +} + +// Source: src/features/consumption/trend-state.js +function createConsumptionTrendState() { + return { + consumptionTrendCache: new Map(), + consumptionTrendWindowOffsets: new Map(), + consumptionTrendSeriesFilters: new Map() + }; +} + +// Source: src/features/opportunities/dashboard-state.js +function createOpportunitiesDashboardState({ STAGE_OPTIONS } = {}) { + return { + stageDashboardAmounts: new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]) + }; +} + +// Source: src/features/debug/state.js +function createDebugState() { + return { + requestLog: [], + debugSecretsVisible: false + }; +} + +// Source: src/features/opportunities/columns.js +function createOpportunitiesColumns({ formatOracleResponseDate, formatRevenue, formatWinProbability, getNestedValue, getOwnerName }) { + return [ { key: "name", label: "Name", value: (item) => item.Name, display: (item) => item.Name }, { key: "optyNumber", label: "Opty Number", value: (item) => item.OptyNumber, display: (item) => item.OptyNumber }, { key: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]) }, @@ -154,18 +252,20 @@ { key: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }, { key: "actions", label: "Actions", value: () => "", display: () => "", sortable: false } ]; - const ALLOWED_PATHS = [ - "/hcmUI/faces/FuseWelcome", - "/fscmUI/faces/FuseWelcome" - ]; - - if (!isAllowedPage()) { - return; - } - - ensureExtensionStyles(); - ensureProductsBackgroundMessageHandler(); +} +// Source: src/content/fusion-page.js +function createContentFusionPage({ + ADD_ICON_SELECTOR, + ALLOWED_PATHS, + SALES_GROUP_SELECTOR, + TILE_ID, + TILE_LABEL_ID, + appendPath, + openOpportunitiesModal, + environment +}) { + const { Node, document, window } = environment; function createOpportunitiesTile() { const wrapper = document.createElement("div"); wrapper.className = "flat-grid-cell"; @@ -213,22 +313,122 @@ return wrapper; } - function appendPath(svg, className, d) { - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute("class", className); - path.setAttribute("d", d); - svg.append(path); - } - function handleTileClick(event) { event.preventDefault(); event.stopPropagation(); openOpportunitiesModal(); } + function isAllowedPage() { + if (window.location.hostname !== "eeho.fa.us2.oraclecloud.com") { + return false; + } + + return ALLOWED_PATHS.some((path) => { + return window.location.pathname === path || window.location.pathname.startsWith(`${path}/`); + }); + } + + function findInsertionPoint(salesGroup) { + const addIconInsideGroup = salesGroup.querySelector(ADD_ICON_SELECTOR); + + if (addIconInsideGroup) { + return addIconInsideGroup; + } + + const container = salesGroup.closest(".flat-grid, .flat-grid-container, [id*='yourapps'], [id*='groupNode']") || salesGroup.parentElement || document.body; + const addIconCells = Array.from(container.querySelectorAll(ADD_ICON_SELECTOR)); + + return addIconCells.find((cell) => { + return Boolean(salesGroup.compareDocumentPosition(cell) & Node.DOCUMENT_POSITION_FOLLOWING); + }) || null; + } + + function insertTile() { + if (document.getElementById(TILE_ID)) { + return true; + } + + const salesGroup = document.querySelector(SALES_GROUP_SELECTOR); + const addIconCell = salesGroup ? findInsertionPoint(salesGroup) : null; + + if (!salesGroup || !addIconCell) { + return false; + } + + addIconCell.before(createOpportunitiesTile()); + return true; + } + + return { createOpportunitiesTile, handleTileClick, isAllowedPage, findInsertionPoint, insertTile }; +} + +// Source: src/content/modal.js +function createContentModal({ + ACCOUNT_PLANS_MODAL_ID, + AUTH_STATUS, + CUSTOMER_FILTER_BUTTON_ID, + CUSTOMER_FILTER_LIST_ID, + CUSTOMER_FILTER_PANEL_ID, + CUSTOMER_FILTER_SEARCH_ID, + CUSTOMER_FILTER_SELECT_ALL_ID, + DEBUG_PANEL_ID, + ESCAPE_LISTENER_KEY, + MODAL_ID, + OPPORTUNITIES_TABLE_ID, + OWNER_FILTER_BUTTON_ID, + OWNER_FILTER_LIST_ID, + OWNER_FILTER_PANEL_ID, + OWNER_FILTER_SEARCH_ID, + OWNER_FILTER_SELECT_ALL_ID, + PERIOD_OPTIONS, + PREFERENCES_MODAL_ID, + PRODUCTS_CACHE_TIMESTAMP_ID, + PRODUCTS_PROGRESS_ID, + STAGE_DASHBOARD_ID, + STAGE_FILTERS_ID, + STATUS_FILTERS_ID, + TABLE_SEARCH_ID, + applyTheme, + authSessionState, + closeAccountPlansModal, + closeCustomerFilterPanel, + closeOwnerFilterPanel, + closePreferencesModal, + createSettingsIcon, + debugState, + ensureExtensionStyles, + getCustomerOptions, + getOwnerOptions, + getSavedOpportunityTypeValues, + getSavedOpptyProductsCacheHours, + getSavedTheme, + openPreferencesModal, + opportunitiesFilterState, + opportunitiesQueryState, + preferencesState, + renderCustomerFilter, + renderCustomerFilterList, + renderOpportunitiesTable, + renderOwnerFilter, + renderOwnerFilterList, + renderStageFilterButtons, + renderStatusFilterButtons, + requestOpportunitiesForPeriod, + requestTokenRelayOnce, + resetCustomerAndOwnerFiltersForPeriod, + resetOpportunitiesTable, + restoreFilterPreferences, + saveFilterPreferences, + toggleCustomerFilterPanel, + toggleDebugPanel, + toggleOwnerFilterPanel, + environment +}) { + const { document, window } = environment; function openOpportunitiesModal() { ensureExtensionStyles(); - debugSecretsVisible = false; + debugState.debugSecretsVisible = false; const existingModal = document.getElementById(MODAL_ID); @@ -242,15 +442,15 @@ } const overlay = document.createElement("div"); - requestLog = []; - tokenRelayRequest = null; - consumerTokenServiceRequest = null; - accessToken = ""; - periodRequestVersion = 0; - authStatus = AUTH_STATUS.idle; + debugState.requestLog = []; + authSessionState.tokenRelayRequest = null; + authSessionState.consumerTokenServiceRequest = null; + authSessionState.accessToken = ""; + opportunitiesQueryState.periodRequestVersion = 0; + authSessionState.authStatus = AUTH_STATUS.idle; resetOpportunitiesTable(); - selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues()); - opptyProductsCacheHours = getSavedOpptyProductsCacheHours(); + preferencesState.selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues()); + preferencesState.opptyProductsCacheHours = getSavedOpptyProductsCacheHours(); overlay.id = MODAL_ID; overlay.className = "opportunities-extension-modal"; overlay.setAttribute("data-theme", getSavedTheme()); @@ -404,7 +604,7 @@ customerSearchInput.placeholder = "Search customers"; customerSearchInput.setAttribute("aria-label", "Search customers"); customerSearchInput.addEventListener("input", () => { - customerSearch = customerSearchInput.value; + opportunitiesFilterState.customerSearch = customerSearchInput.value; saveFilterPreferences(); renderCustomerFilterList(); }); @@ -417,10 +617,10 @@ selectAllCheckbox.type = "checkbox"; selectAllCheckbox.addEventListener("change", () => { const customerOptions = getCustomerOptions(); - selectedCustomerKeys = selectAllCheckbox.checked + opportunitiesFilterState.selectedCustomerKeys = selectAllCheckbox.checked ? new Set(customerOptions.map((customer) => customer.key)) : new Set(); - hasSavedCustomerFilter = true; + opportunitiesFilterState.hasSavedCustomerFilter = true; saveFilterPreferences(); renderCustomerFilter(); renderOpportunitiesTable(); @@ -464,7 +664,7 @@ ownerSearchInput.placeholder = "Search owners"; ownerSearchInput.setAttribute("aria-label", "Search owners"); ownerSearchInput.addEventListener("input", () => { - ownerSearch = ownerSearchInput.value; + opportunitiesFilterState.ownerSearch = ownerSearchInput.value; saveFilterPreferences(); renderOwnerFilterList(); }); @@ -477,10 +677,10 @@ ownerSelectAllCheckbox.type = "checkbox"; ownerSelectAllCheckbox.addEventListener("change", () => { const ownerOptions = getOwnerOptions(); - selectedOwnerKeys = ownerSelectAllCheckbox.checked + opportunitiesFilterState.selectedOwnerKeys = ownerSelectAllCheckbox.checked ? new Set(ownerOptions.map((owner) => owner.key)) : new Set(); - hasSavedOwnerFilter = true; + opportunitiesFilterState.hasSavedOwnerFilter = true; saveFilterPreferences(); renderOwnerFilter(); renderOpportunitiesTable(); @@ -508,9 +708,9 @@ tableSearchInput.type = "search"; tableSearchInput.placeholder = "Search opportunities"; tableSearchInput.setAttribute("aria-label", "Search opportunities"); - tableSearchInput.value = tableSearch; + tableSearchInput.value = opportunitiesFilterState.tableSearch; tableSearchInput.addEventListener("input", () => { - tableSearch = tableSearchInput.value; + opportunitiesFilterState.tableSearch = tableSearchInput.value; saveFilterPreferences(); renderOpportunitiesTable(); }); @@ -572,36 +772,304 @@ select.focus(); } + function updateAuthBadge(status) { + const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`); + + if (!badge) { + return; + } + + const badgeStatus = status === AUTH_STATUS.authenticated || status === AUTH_STATUS.unauthenticated + ? status + : AUTH_STATUS.pending; + + badge.setAttribute("data-auth-status", badgeStatus); + badge.className = `opportunities-extension-auth-badge opportunities-extension-auth-badge-${badgeStatus}`; + badge.textContent = badgeStatus === AUTH_STATUS.authenticated + ? "Authenticated" + : badgeStatus === AUTH_STATUS.unauthenticated + ? "Unauthenticated" + : "Authenticating"; + } + + function closeOpportunitiesModal() { + const modal = document.getElementById(MODAL_ID); + + if (modal) { + closePreferencesModal(); + closeAccountPlansModal(); + modal.hidden = true; + } + + debugState.debugSecretsVisible = false; + document.documentElement.classList.remove("opportunities-extension-scroll-lock"); + } + + function ensureEscapeKeyHandler() { + if (window[ESCAPE_LISTENER_KEY]) { + return; + } + + window[ESCAPE_LISTENER_KEY] = true; + document.addEventListener("keydown", (event) => { + const modal = document.getElementById(MODAL_ID); + + if (!modal || modal.hidden) { + return; + } + + if (event.ctrlKey && event.key.toLowerCase() === "d") { + event.preventDefault(); + event.stopPropagation(); + toggleDebugPanel(); + return; + } + + if (event.key === "Escape") { + const accountPlansModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); + + if (accountPlansModal && !accountPlansModal.hidden) { + closeAccountPlansModal(); + event.preventDefault(); + return; + } + + const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID); + + if (preferencesModal && !preferencesModal.hidden) { + closePreferencesModal(); + event.preventDefault(); + return; + } + + if (closeCustomerFilterPanel()) { + event.preventDefault(); + return; + } + + if (closeOwnerFilterPanel()) { + event.preventDefault(); + return; + } + + closeOpportunitiesModal(); + } + }); + + document.addEventListener("pointerdown", (event) => { + const modal = document.getElementById(MODAL_ID); + const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); + const ownerPanel = document.getElementById(OWNER_FILTER_PANEL_ID); + + if (!modal || modal.hidden) { + return; + } + + if (panel && !panel.hidden) { + const customerFilter = panel.closest(".opportunities-extension-customer-filter"); + + if (customerFilter && !customerFilter.contains(event.target)) { + closeCustomerFilterPanel(false); + } + } + + if (ownerPanel && !ownerPanel.hidden) { + const ownerFilter = ownerPanel.closest(".opportunities-extension-customer-filter"); + + if (ownerFilter && !ownerFilter.contains(event.target)) { + closeOwnerFilterPanel(false); + } + } + }); + } + + return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler }; +} + +// Source: src/content/theme.js +function createContentTheme({ + THEME_STORAGE_KEY, + createThemeIcon, + environment +}) { + const getLocalStorage = () => environment.localStorage; + function getSavedTheme() { + try { + return getLocalStorage().getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light"; + } catch (error) { + return "light"; + } + } + + function applyTheme(modal, button, theme) { + const isDarkTheme = theme === "dark"; + + modal.setAttribute("data-theme", isDarkTheme ? "dark" : "light"); + button.textContent = ""; + button.setAttribute("aria-label", isDarkTheme ? "Ativar modo claro" : "Ativar modo escuro"); + button.title = isDarkTheme ? "Light mode" : "Dark mode"; + button.append(createThemeIcon(isDarkTheme ? "sun" : "moon")); + + try { + getLocalStorage().setItem(THEME_STORAGE_KEY, isDarkTheme ? "dark" : "light"); + } catch (error) { + // Continue with the selected theme when browser storage is unavailable. + } + } + + return { getSavedTheme, applyTheme }; +} + +// Source: src/content/icons.js +function createContentIcons({ + appendPath, + environment +}) { + const { document } = environment; + function createThemeIcon(iconName) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + + if (iconName === "sun") { + path.setAttribute("d", "M12 3V1m0 22v-2m9-9h2M1 12h2m15.36 6.36 1.42 1.42M4.22 4.22l1.42 1.42m12.72 0 1.42-1.42M4.22 19.78l1.42-1.42M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z"); + } else { + path.setAttribute("d", "M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5a8.5 8.5 0 1 0 10.7 10.7Z"); + } + + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + path.setAttribute("stroke-width", "1.8"); + svg.append(path); + return svg; + } + + function createSettingsIcon() { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const upperControl = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + const lowerControl = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("class", "opportunities-extension-settings-icon"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + appendPath(svg, "", "M4 7h16M4 17h16"); + + [upperControl, lowerControl].forEach((control, index) => { + control.setAttribute("cx", index === 0 ? "9" : "15"); + control.setAttribute("cy", index === 0 ? "7" : "17"); + control.setAttribute("r", "2.5"); + control.setAttribute("fill", "currentColor"); + control.setAttribute("stroke", "currentColor"); + control.setAttribute("stroke-width", "1.8"); + }); + + svg.querySelector("path").setAttribute("fill", "none"); + svg.querySelector("path").setAttribute("stroke", "currentColor"); + svg.querySelector("path").setAttribute("stroke-linecap", "round"); + svg.querySelector("path").setAttribute("stroke-width", "1.8"); + svg.append(upperControl, lowerControl); + return svg; + } + + function createCloseIcon() { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + path.setAttribute("d", "m6 6 12 12M18 6 6 18"); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-width", "1.8"); + svg.append(path); + return svg; + } + + return { createThemeIcon, createSettingsIcon, createCloseIcon }; +} + +// Source: src/content/svg.js +function createSvgPaths({ environment }) { + const { document } = environment; + function appendPath(svg, className, d) { + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("class", className); + path.setAttribute("d", d); + svg.append(path); + } + return { appendPath }; +} + +// Source: src/content/styles.js +function createContentStyles({ + STYLE_ID, + redwoodStyles, + environment +}) { + const { document } = environment; + function ensureExtensionStyles() { + if (document.getElementById(STYLE_ID)) { + return; + } + + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = redwoodStyles; + + document.head.append(style); + } + + return { ensureExtensionStyles }; +} + +// Source: src/auth/content-session.js +function createAuthContentSession({ + messageTypes, + AUTH_STATUS, + appendBackgroundRequestLog, + authSessionState, + requestTokenRelay, + sendRuntimeMessage, + updateAuthBadge +}) { function requestTokenRelayOnce() { - if (!tokenRelayRequest) { - authStatus = AUTH_STATUS.pending; - updateAuthBadge(authStatus); - tokenRelayRequest = requestTokenRelay() + if (!authSessionState.tokenRelayRequest) { + authSessionState.authStatus = AUTH_STATUS.pending; + updateAuthBadge(authSessionState.authStatus); + authSessionState.tokenRelayRequest = requestTokenRelay() .then((token) => { - accessToken = token; - authStatus = AUTH_STATUS.authenticated; + authSessionState.accessToken = token; + authSessionState.authStatus = AUTH_STATUS.authenticated; requestConsumerTokenServiceOnce().catch(() => {}); return token; }) .catch((error) => { - accessToken = ""; - authStatus = AUTH_STATUS.unauthenticated; + authSessionState.accessToken = ""; + authSessionState.authStatus = AUTH_STATUS.unauthenticated; throw error; }) .finally(() => { - updateAuthBadge(authStatus); + updateAuthBadge(authSessionState.authStatus); }); } else { - updateAuthBadge(authStatus); + updateAuthBadge(authSessionState.authStatus); } - return tokenRelayRequest; + return authSessionState.tokenRelayRequest; } function requestConsumerTokenServiceOnce() { - if (!consumerTokenServiceRequest) { - consumerTokenServiceRequest = sendRuntimeMessage({ - type: "opportunitiesExtension.requestConsumerTokenService" + if (!authSessionState.consumerTokenServiceRequest) { + authSessionState.consumerTokenServiceRequest = sendRuntimeMessage({ + type: messageTypes.requestConsumerTokenService }).then((response) => { appendBackgroundRequestLog(response && response.debugRequests); @@ -615,29 +1083,62 @@ }); } - return consumerTokenServiceRequest; + return authSessionState.consumerTokenServiceRequest; } + return { requestTokenRelayOnce, requestConsumerTokenServiceOnce }; +} + +// Source: src/auth/sales-common.js +function createAuthSalesCommon({ + TOKEN_RELAY_URL, + authSalesCommonState, + requestTokenRelay +}) { function requestTokenRelaySalesCommon() { - if (salesCommonToken && Date.now() < salesCommonToken.expiresAt) { - return Promise.resolve(salesCommonToken.access_token); + if (authSalesCommonState.salesCommonToken && Date.now() < authSalesCommonState.salesCommonToken.expiresAt) { + return Promise.resolve(authSalesCommonState.salesCommonToken.access_token); } - if (salesCommonTokenRequest) { - return salesCommonTokenRequest; + if (authSalesCommonState.salesCommonTokenRequest) { + return authSalesCommonState.salesCommonTokenRequest; } - salesCommonTokenRequest = requestTokenRelay({ + authSalesCommonState.salesCommonTokenRequest = requestTokenRelay({ name: "requestTokenRelaySalesCommon", url: `${TOKEN_RELAY_URL}?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F`, includeExpiry: true }).then((token) => { - salesCommonToken = token; + authSalesCommonState.salesCommonToken = token; return token.access_token; }).finally(() => { - salesCommonTokenRequest = null; + authSalesCommonState.salesCommonTokenRequest = null; }); - return salesCommonTokenRequest; + return authSalesCommonState.salesCommonTokenRequest; } + function invalidateSalesCommonToken(rejectedToken) { + if (authSalesCommonState.salesCommonToken && authSalesCommonState.salesCommonToken.access_token === rejectedToken) { + authSalesCommonState.salesCommonToken = null; + } + } + + return { requestTokenRelaySalesCommon, invalidateSalesCommonToken }; +} + +// Source: src/auth/token-relay.js +function createAuthTokenRelay({ + TOKEN_RELAY_URL, + createDebugBody, + getXsrfToken, + logRequestFailure, + logRequestStart, + logRequestSuccess, + maskToken, + readResponseBodyForDebug, + refreshXsrfCookie, + renderDebugPanel, + environment +}) { + const { fetch } = environment; async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) { let refreshResult = null; let refreshAttempted = false; @@ -775,11 +1276,200 @@ return `${result.reason}; ${outcome}; cookie=${cookieName}; duration=${duration}; ${result.error || ""}`.trim(); } + return { requestTokenRelay, createTokenRelayRequestEntry, isInvalidXsrfStatus, formatXsrfRefreshResult }; +} + +// Source: src/auth/xsrf-content.js +function createAuthXsrfContent({ + getXsrfTokenFromExtensionCookies, + environment +}) { + const { document } = environment; + async function getXsrfToken() { + const extensionCookie = await getXsrfTokenFromExtensionCookies(); + + if (extensionCookie.token) { + return extensionCookie; + } + + const xsrfCookie = document.cookie + .split(";") + .map((cookie) => cookie.trim()) + .find((cookie) => cookie.startsWith("XSRF-TOKEN-")); + + if (!xsrfCookie) { + return { + token: "", + cookieName: "", + source: extensionCookie.error ? "document.cookie fallback after cookies API error" : "document.cookie fallback", + matchedCookieNames: extensionCookie.matchedCookieNames || [], + lookupDetails: extensionCookie.lookupDetails || [], + error: extensionCookie.error || "" + }; + } + + const cookieName = xsrfCookie.slice(0, xsrfCookie.indexOf("=")); + const tokenValue = xsrfCookie.slice(xsrfCookie.indexOf("=") + 1); + return { + token: decodeURIComponent(tokenValue), + cookieName, + source: "document.cookie", + matchedCookieNames: extensionCookie.matchedCookieNames || [], + lookupDetails: extensionCookie.lookupDetails || [], + error: "" + }; + } + + return { getXsrfToken }; +} + +// Source: src/platform/content-messages.js +function createPlatformContentMessages({ + messageTypes, + environment +}) { + const { browser, chrome } = environment; + function sendRuntimeMessage(message) { + const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + + if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { + return Promise.reject(new Error("Runtime messaging API unavailable.")); + } + + if (typeof browser !== "undefined") { + return runtimeApi.runtime.sendMessage(message); + } + + return new Promise((resolve, reject) => { + runtimeApi.runtime.sendMessage(message, (response) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(response); + }); + }); + } + + function getXsrfTokenFromExtensionCookies() { + const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + + if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { + return Promise.resolve({ + token: "", + cookieName: "", + source: "unavailable cookies API", + error: "Runtime messaging API unavailable." + }); + } + + return new Promise((resolve) => { + runtimeApi.runtime.sendMessage({ + type: messageTypes.getXsrfToken + }, (response) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + resolve({ + token: "", + cookieName: "", + source: "cookies API", + matchedCookieNames: [], + lookupDetails: [], + error: lastError.message + }); + return; + } + + resolve({ + token: response && response.ok ? response.token : "", + cookieName: response && response.ok ? response.cookieName : "", + source: "cookies API", + matchedCookieNames: response && response.matchedCookieNames ? response.matchedCookieNames : [], + lookupDetails: response && response.lookupDetails ? response.lookupDetails : [], + error: response && response.error ? response.error : "" + }); + }); + }); + } + + function refreshXsrfCookie(reason) { + const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + + if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { + return Promise.resolve({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + reason, + error: "Runtime messaging API unavailable." + }); + } + + return new Promise((resolve) => { + runtimeApi.runtime.sendMessage({ + type: messageTypes.refreshXsrfCookie + }, (response) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + resolve({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + reason, + error: lastError.message + }); + return; + } + + resolve({ + ...(response || { + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + error: "Empty refresh response." + }), + reason + }); + }); + }); + } + + return { sendRuntimeMessage, getXsrfTokenFromExtensionCookies, refreshXsrfCookie }; +} + +// Source: src/features/opportunities/controller.js +function createOpportunitiesController({ + STAGE_OPTIONS, + STATUS_OPTIONS, + authSessionState, + consumptionPlansState, + consumptionTrendState, + getFiscalPeriodRange, + opportunitiesDashboardState, + opportunitiesFilterState, + opportunitiesQueryState, + opportunitiesTableState, + preferencesState, + productsContentState, + renderProductsProgress, + requestOpportunitiesPage, + requestOpptyProductsInBackground, + requestTokenRelayOnce, + setOpportunitiesTableState +}) { async function requestOpportunitiesForPeriod(period) { const dateRange = getFiscalPeriodRange(period, new Date()); - const requestVersion = ++periodRequestVersion; + const requestVersion = ++opportunitiesQueryState.periodRequestVersion; - if (selectedOpportunityTypeValues.size === 0) { + if (preferencesState.selectedOpportunityTypeValues.size === 0) { setOpportunitiesTableState({ items: [], status: "empty", @@ -798,9 +1488,9 @@ } try { - const token = accessToken || await requestTokenRelayOnce(); + const token = authSessionState.accessToken || await requestTokenRelayOnce(); - if (requestVersion !== periodRequestVersion) { + if (requestVersion !== opportunitiesQueryState.periodRequestVersion) { return; } @@ -812,21 +1502,21 @@ async function requestOpportunities(token, period, dateRange, requestVersion) { if (isCurrentPeriodRequest(requestVersion)) { - currentProductsRequestId = ""; - opptyProductsByNumber = new Map(); - expandedProductOptyNumbers = new Set(); - productsProgress = { + productsContentState.currentProductsRequestId = ""; + productsContentState.opptyProductsByNumber = new Map(); + productsContentState.expandedProductOptyNumbers = new Set(); + productsContentState.productsProgress = { state: "idle", completed: 0, total: 0, failed: 0, cacheHits: 0 }; - productsLastCachedAt = 0; - accountPlansRequestState = new Map(); - consumptionTrendCache = new Map(); - consumptionTrendWindowOffsets = new Map(); - consumptionTrendSeriesFilters = new Map(); + productsContentState.productsLastCachedAt = 0; + consumptionPlansState.accountPlansRequestState = new Map(); + consumptionTrendState.consumptionTrendCache = new Map(); + consumptionTrendState.consumptionTrendWindowOffsets = new Map(); + consumptionTrendState.consumptionTrendSeriesFilters = new Map(); renderProductsProgress(); setOpportunitiesTableState({ items: [], @@ -859,6 +1549,64 @@ } } + function resetOpportunitiesTable() { + opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS); + opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS); + opportunitiesFilterState.selectedCustomerKeys = new Set(); + opportunitiesFilterState.customerSearch = ""; + opportunitiesFilterState.selectedOwnerKeys = new Set(); + opportunitiesFilterState.ownerSearch = ""; + opportunitiesFilterState.tableSearch = ""; + opportunitiesFilterState.hasSavedCustomerFilter = false; + opportunitiesFilterState.hasSavedOwnerFilter = false; + opportunitiesFilterState.customerFilterInitialized = false; + opportunitiesFilterState.ownerFilterInitialized = false; + productsContentState.currentProductsRequestId = ""; + productsContentState.opptyProductsByNumber = new Map(); + productsContentState.expandedProductOptyNumbers = new Set(); + productsContentState.productsProgress = { + state: "idle", + completed: 0, + total: 0, + failed: 0, + cacheHits: 0 + }; + productsContentState.productsLastCachedAt = 0; + consumptionPlansState.accountPlansRequestState = new Map(); + consumptionTrendState.consumptionTrendCache = new Map(); + consumptionTrendState.consumptionTrendWindowOffsets = new Map(); + consumptionTrendState.consumptionTrendSeriesFilters = new Map(); + opportunitiesDashboardState.stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); + opportunitiesTableState.opportunitiesTableState = { + items: [], + status: "idle", + message: "", + sortKey: "", + sortDirection: "ascending" + }; + } + + function isCurrentPeriodRequest(requestVersion) { + return requestVersion === opportunitiesQueryState.periodRequestVersion; + } + + return { requestOpportunitiesForPeriod, requestOpportunities, resetOpportunitiesTable, isCurrentPeriodRequest }; +} + +// Source: src/services/fusion/opportunities.js +function createFusionOpportunities({ + OPPORTUNITIES_PAGE_LIMIT, + OPPORTUNITIES_QUERY_URL, + isCurrentPeriodRequest, + logRequestFailure, + logRequestStart, + logRequestSuccess, + maskToken, + getOpportunityTypeValues, + readResponseBodyForDebug, + environment +}) { + const { fetch } = environment; async function requestOpportunitiesPage(token, period, dateRange, offset, accumulatedItems, requestVersion, page) { if (!isCurrentPeriodRequest(requestVersion)) { return null; @@ -940,305 +1688,173 @@ } } - function requestOpptyProductsInBackground(opportunities, token) { - const optyNumbers = Array.from(new Set(opportunities - .map((item) => item && item.OptyNumber) - .filter(Boolean) - .map(String))); - - const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - currentProductsRequestId = requestId; - opptyProductsByNumber = new Map(); - expandedProductOptyNumbers = new Set(); - productsProgress = { - state: optyNumbers.length ? "running" : "complete", - completed: 0, - total: optyNumbers.length, - failed: 0, - cacheHits: 0 + function createOpportunitiesQueryPayload(dateRange, offset) { + return { + aggregationResults: false, + applications: ["ORACLE-ISS-APP"], + onlyData: true, + entity: "Opportunity", + q: { + 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 + } + ] + }, + keywords: null, + keywordsFields: [ + "Name", + "OptyNumber", + "PrimaryRevenue.WinProb", + "CustomerAccount", + "PrimaryRevenue.RevnAmount", + "EffectiveDate", + "StatusCode", + "DealRisk_c", + "Owner", + "ForecastGroup_c", + "LastUpdateDate", + "PrimaryRevenue.RevnAmountCurcyCode", + "OptyId" + ], + fields: [ + "Name", + "OptyNumber", + "PrimaryRevenue.WinProb", + "CustomerAccount", + "PrimaryRevenue.RevnAmount", + "EffectiveDate", + "StatusCode", + "DealRisk_c", + "Owner", + "ForecastGroup_c", + "LastUpdateDate", + "PrimaryRevenue.RevnAmountCurcyCode", + "OptyId", + "CustomerAccount.PartyUniqueName", + "CustomerAccount.PartyId", + "CustomerAccount.PartyNumber" + ], + sort: [], + language: "en", + skipInValidFields: true, + skipHiddenFromUIFields: true, + copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162", + limit: OPPORTUNITIES_PAGE_LIMIT, + offset }; - renderProductsProgress(); - - if (optyNumbers.length === 0) { - return; - } - - sendRuntimeMessage({ - type: "opportunitiesExtension.requestOpptyProducts", - requestId, - accessToken: token, - optyNumbers, - cacheMaxAgeMs: opptyProductsCacheHours * 60 * 60 * 1000 - }).then((response) => { - if (response && response.ok === false && response.requestId === currentProductsRequestId && productsProgress.state === "running") { - productsProgress.state = "error"; - productsProgress.failed = productsProgress.total; - renderProductsProgress(); - } - }).catch(() => { - if (requestId === currentProductsRequestId && productsProgress.state === "running") { - productsProgress.state = "error"; - productsProgress.failed = productsProgress.total; - renderProductsProgress(); - } - }); } - function ensureProductsBackgroundMessageHandler() { - if (window[PRODUCTS_MESSAGE_LISTENER_KEY]) { - return; + return { requestOpportunitiesPage, createOpportunitiesQueryPayload }; +} + +// Source: src/features/opportunities/fiscal-periods.js +function createOpportunitiesFiscalPeriods() { + function getFiscalPeriodRange(period, currentDate) { + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth(); + const fiscalStartYear = currentMonth >= 5 ? currentYear : currentYear - 1; + const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3); + + if (period === "Current Fiscal Year") { + const isBeforeFiscalYearStart = currentMonth < 5; + const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear; + const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1; + return createDateRange(startYear, 5, endYear, 4); } - const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + let quarterOffset; + let numberOfQuarters = 1; - if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.onMessage) { - return; - } - - window[PRODUCTS_MESSAGE_LISTENER_KEY] = true; - runtimeApi.runtime.onMessage.addListener((message) => { - if (!message || message.type !== "opportunitiesExtension.opptyProductsProgress" || message.requestId !== currentProductsRequestId) { - return false; - } - - if (message.optyNumber) { - opptyProductsByNumber.set(String(message.optyNumber), { - items: Array.isArray(message.items) ? message.items : [], - fromCache: Boolean(message.fromCache), - cachedAt: Number(message.cachedAt) || 0, - error: message.error || "" - }); - } - - if (Number(message.cachedAt) > productsLastCachedAt) { - productsLastCachedAt = Number(message.cachedAt); - } - - productsProgress = { - state: message.state || productsProgress.state, - completed: Number(message.completed) || 0, - total: Number(message.total) || productsProgress.total, - failed: Number(message.failed) || 0, - cacheHits: Number(message.cacheHits) || 0 - }; - renderProductsProgress(); - queueProductsTableRender(); - return false; - }); - } - - function sendRuntimeMessage(message) { - const runtimeApi = typeof browser !== "undefined" ? browser : chrome; - - if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { - return Promise.reject(new Error("Runtime messaging API unavailable.")); - } - - if (typeof browser !== "undefined") { - return runtimeApi.runtime.sendMessage(message); - } - - return new Promise((resolve, reject) => { - runtimeApi.runtime.sendMessage(message, (response) => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - reject(new Error(lastError.message)); - return; - } - - resolve(response); - }); - }); - } - - function renderProductsProgress() { - const badge = document.getElementById(PRODUCTS_PROGRESS_ID); - - if (!badge) { - return; - } - - badge.hidden = productsProgress.state === "idle"; - badge.setAttribute("data-state", productsProgress.state); - - if (productsProgress.state === "running") { - badge.textContent = `Products ${productsProgress.completed}/${productsProgress.total}`; - badge.title = `${productsProgress.cacheHits} loaded from cache`; - } else if (productsProgress.state === "complete") { - badge.textContent = `Products ready ${productsProgress.total}`; - badge.title = `${productsProgress.cacheHits} loaded from cache`; - } else if (productsProgress.state === "complete-with-errors") { - badge.textContent = `Products ${productsProgress.total - productsProgress.failed}/${productsProgress.total}`; - badge.title = `${productsProgress.failed} product requests failed`; - } else if (productsProgress.state === "error") { - badge.textContent = "Products unavailable"; - badge.title = "Unable to load opportunity products"; + if (period === "Current Quarter") { + quarterOffset = currentQuarterIndex; + } else if (period === "Next Quarter") { + quarterOffset = currentQuarterIndex + 1; + } else if (period === "Previous Quarter") { + quarterOffset = currentQuarterIndex - 1; + } else if (period === "4 Rolling Quarters (CQ + 3)") { + quarterOffset = currentQuarterIndex; + numberOfQuarters = 4; } else { - badge.textContent = "Products ready 0"; + return null; } - const timestamp = document.getElementById(PRODUCTS_CACHE_TIMESTAMP_ID); + const start = new Date(fiscalStartYear, 5 + (quarterOffset * 3), 1); + const end = new Date(start.getFullYear(), start.getMonth() + (numberOfQuarters * 3), 0); - if (timestamp) { - timestamp.hidden = !productsLastCachedAt; - timestamp.textContent = productsLastCachedAt - ? `Last update: ${formatProductsCacheTimestamp(productsLastCachedAt)}` - : ""; - timestamp.title = productsLastCachedAt - ? `Latest product update: ${formatProductsCacheTimestamp(productsLastCachedAt)}` - : ""; - } - } - - function formatProductsCacheTimestamp(timestamp) { - try { - return new Intl.DateTimeFormat("en-GB", { - dateStyle: "short", - timeStyle: "short" - }).format(new Date(timestamp)); - } catch (error) { - return new Date(timestamp).toLocaleString(); - } - } - - function queueProductsTableRender() { - if (productsRenderQueued) { - return; - } - - productsRenderQueued = true; - window.requestAnimationFrame(() => { - productsRenderQueued = false; - renderOpportunitiesTable(); - }); - } - - function resetOpportunitiesTable() { - selectedStages = new Set(STAGE_OPTIONS); - selectedStatuses = new Set(STATUS_OPTIONS); - selectedCustomerKeys = new Set(); - customerSearch = ""; - selectedOwnerKeys = new Set(); - ownerSearch = ""; - tableSearch = ""; - hasSavedCustomerFilter = false; - hasSavedOwnerFilter = false; - customerFilterInitialized = false; - ownerFilterInitialized = false; - currentProductsRequestId = ""; - opptyProductsByNumber = new Map(); - expandedProductOptyNumbers = new Set(); - productsProgress = { - state: "idle", - completed: 0, - total: 0, - failed: 0, - cacheHits: 0 - }; - productsLastCachedAt = 0; - accountPlansRequestState = new Map(); - consumptionTrendCache = new Map(); - consumptionTrendWindowOffsets = new Map(); - consumptionTrendSeriesFilters = new Map(); - stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); - opportunitiesTableState = { - items: [], - status: "idle", - message: "", - sortKey: "", - sortDirection: "ascending" + return { + startCloseDate: formatOracleDate(start), + endCloseDate: formatOracleDate(end) }; } - function resetCustomerAndOwnerFiltersForPeriod() { - selectedCustomerKeys = new Set(); - selectedOwnerKeys = new Set(); - customerSearch = ""; - ownerSearch = ""; - hasSavedCustomerFilter = false; - hasSavedOwnerFilter = false; - customerFilterInitialized = false; - ownerFilterInitialized = false; + function createDateRange(startYear, startMonth, endYear, endMonth) { + return { + startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)), + endCloseDate: formatOracleDate(new Date(endYear, endMonth + 1, 0)) + }; } - function restoreFilterPreferences(select) { - try { - const rawPreferences = localStorage.getItem(FILTER_PREFERENCES_STORAGE_KEY); - - if (!rawPreferences) { - return; - } - - const preferences = JSON.parse(rawPreferences); - const savedStages = Array.isArray(preferences.stages) - ? preferences.stages.filter((stage) => STAGE_OPTIONS.includes(stage)) - : null; - const savedStatuses = Array.isArray(preferences.statuses) - ? preferences.statuses.filter((status) => STATUS_OPTIONS.includes(status)) - : null; - - if (savedStages) { - selectedStages = new Set(savedStages); - } - - if (savedStatuses) { - selectedStatuses = new Set(savedStatuses); - } - - if (Array.isArray(preferences.customerKeys)) { - selectedCustomerKeys = new Set(preferences.customerKeys.map(String)); - hasSavedCustomerFilter = true; - customerFilterInitialized = true; - } - - if (Array.isArray(preferences.ownerKeys)) { - selectedOwnerKeys = new Set(preferences.ownerKeys.map(String)); - hasSavedOwnerFilter = true; - ownerFilterInitialized = true; - } - - customerSearch = typeof preferences.customerSearch === "string" ? preferences.customerSearch : ""; - ownerSearch = typeof preferences.ownerSearch === "string" ? preferences.ownerSearch : ""; - tableSearch = typeof preferences.tableSearch === "string" ? preferences.tableSearch : ""; - - if (PERIOD_OPTIONS.includes(preferences.period)) { - select.value = preferences.period; - } - } catch (error) { - // Ignore malformed or unavailable browser storage and use default filters. - } + function formatOracleDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}T00:00:00.000Z`; } - function saveFilterPreferences(period) { - try { - const currentPeriod = period || document.querySelector(`#${MODAL_ID} select`)?.value || PERIOD_OPTIONS[0]; - localStorage.setItem(FILTER_PREFERENCES_STORAGE_KEY, JSON.stringify({ - period: currentPeriod, - stages: Array.from(selectedStages), - statuses: Array.from(selectedStatuses), - customerKeys: hasSavedCustomerFilter ? Array.from(selectedCustomerKeys) : null, - ownerKeys: hasSavedOwnerFilter ? Array.from(selectedOwnerKeys) : null, - customerSearch, - ownerSearch, - tableSearch - })); - } catch (error) { - // Ignore unavailable browser storage; filters still work for the current modal. - } - } + return { getFiscalPeriodRange, createDateRange, formatOracleDate }; +} +// Source: src/features/opportunities/table.js +function createOpportunitiesTable({ + ACCOUNT_DETAIL_URL, + OPPORTUNITIES_COLUMNS, + OPPORTUNITIES_TABLE_ID, + OPPORTUNITY_DETAIL_URL, + createCloudConsumptionButton, + createCopyOpportunityButton, + createDetailLink, + createProductsCountButton, + createProductsDetailRow, + createStageBadge, + createStatusBadge, + displayOpportunityValue, + getNestedValue, + getSortedOpportunities, + opportunitiesTableState, + productsContentState, + renderCustomerFilter, + renderOwnerFilter, + renderStageDashboard, + resetCustomerFilter, + resetOwnerFilter, + environment +}) { + const { document } = environment; function setOpportunitiesTableState(nextState) { if (nextState.status === "ready" && Array.isArray(nextState.items)) { resetCustomerFilter(nextState.items); resetOwnerFilter(nextState.items); } - opportunitiesTableState = { - ...opportunitiesTableState, + opportunitiesTableState.opportunitiesTableState = { + ...opportunitiesTableState.opportunitiesTableState, ...nextState, - sortKey: nextState.items ? "" : opportunitiesTableState.sortKey, - sortDirection: nextState.items ? "ascending" : opportunitiesTableState.sortDirection + sortKey: nextState.items ? "" : opportunitiesTableState.opportunitiesTableState.sortKey, + sortDirection: nextState.items ? "ascending" : opportunitiesTableState.opportunitiesTableState.sortDirection }; renderOpportunitiesTable(); renderCustomerFilter(); @@ -1253,7 +1869,7 @@ } table.textContent = ""; - table.setAttribute("aria-busy", opportunitiesTableState.status === "loading" ? "true" : "false"); + table.setAttribute("aria-busy", opportunitiesTableState.opportunitiesTableState.status === "loading" ? "true" : "false"); const caption = document.createElement("caption"); caption.className = "opportunities-extension-visually-hidden"; @@ -1264,7 +1880,7 @@ OPPORTUNITIES_COLUMNS.forEach((column) => { const header = document.createElement("th"); - const isSorted = opportunitiesTableState.sortKey === column.key; + const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key; header.scope = "col"; if (column.sortable === false) { @@ -1273,13 +1889,13 @@ label.textContent = column.label; header.append(label); } else { - header.setAttribute("aria-sort", isSorted ? opportunitiesTableState.sortDirection : "none"); + header.setAttribute("aria-sort", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none"); const sortButton = document.createElement("button"); sortButton.type = "button"; sortButton.className = "opportunities-extension-sort-button"; - sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.sortDirection : "none"); - sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.sortDirection}` : ""}`); + sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none"); + sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`); sortButton.textContent = column.label; sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key)); header.append(sortButton); @@ -1294,12 +1910,12 @@ const items = getSortedOpportunities(); renderStageDashboard(items); - if (opportunitiesTableState.status === "loading" || opportunitiesTableState.status === "error" || opportunitiesTableState.status === "empty" || (opportunitiesTableState.status === "ready" && items.length === 0)) { + if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) { const row = document.createElement("tr"); const cell = document.createElement("td"); cell.className = "opportunities-extension-table-status"; cell.colSpan = OPPORTUNITIES_COLUMNS.length; - cell.textContent = opportunitiesTableState.message || "No opportunities found."; + cell.textContent = opportunitiesTableState.opportunitiesTableState.message || "No opportunities found."; row.append(cell); tableBody.append(row); } else { @@ -1316,7 +1932,7 @@ } else if (column.key === "actions") { const actionsContent = document.createElement("span"); actionsContent.className = "opportunities-extension-actions-content"; - const productsResult = opptyProductsByNumber.get(String(item.OptyNumber)); + const productsResult = productsContentState.opptyProductsByNumber.get(String(item.OptyNumber)); if (productsResult) { actionsContent.append(createProductsCountButton(item.OptyNumber, productsResult)); @@ -1359,7 +1975,7 @@ tableBody.append(row); - if (item.OptyNumber && expandedProductOptyNumbers.has(String(item.OptyNumber))) { + if (item.OptyNumber && productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber))) { tableBody.append(createProductsDetailRow(item)); } }); @@ -1368,6 +1984,27 @@ table.append(caption, tableHead, tableBody); } + function sortOpportunitiesBy(key) { + const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key; + opportunitiesTableState.opportunitiesTableState.sortKey = key; + opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending" + ? "descending" + : "ascending"; + renderOpportunitiesTable(); + } + + return { setOpportunitiesTableState, renderOpportunitiesTable, sortOpportunitiesBy }; +} + +// Source: src/features/opportunities/dashboard.js +function createOpportunitiesDashboard({ + STAGE_DASHBOARD_ID, + STAGE_OPTIONS, + getNestedValue, + opportunitiesDashboardState, + environment +}) { + const { document, requestAnimationFrame, performance } = environment; function renderStageDashboard(items) { const dashboard = document.getElementById(STAGE_DASHBOARD_ID); @@ -1449,8 +2086,8 @@ } function animateStageDashboardValue(element, stage, target, format) { - const current = stageDashboardAmounts.get(stage) || 0; - stageDashboardAmounts.set(stage, target); + const current = opportunitiesDashboardState.stageDashboardAmounts.get(stage) || 0; + opportunitiesDashboardState.stageDashboardAmounts.set(stage, target); if (current === target) { element.textContent = format(target); @@ -1477,13 +2114,48 @@ requestAnimationFrame(tick); } - function sortOpportunitiesBy(key) { - const isSameColumn = opportunitiesTableState.sortKey === key; - opportunitiesTableState.sortKey = key; - opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.sortDirection === "ascending" - ? "descending" - : "ascending"; - renderOpportunitiesTable(); + return { renderStageDashboard, createStageAmountFormatter, animateStageDashboardValue }; +} + +// Source: src/features/opportunities/filters.js +function createOpportunitiesFilters({ + CUSTOMER_FILTER_BUTTON_ID, + CUSTOMER_FILTER_LIST_ID, + CUSTOMER_FILTER_PANEL_ID, + CUSTOMER_FILTER_SEARCH_ID, + CUSTOMER_FILTER_SELECT_ALL_ID, + OPPORTUNITIES_COLUMNS, + OWNER_FILTER_BUTTON_ID, + OWNER_FILTER_LIST_ID, + OWNER_FILTER_PANEL_ID, + OWNER_FILTER_SEARCH_ID, + OWNER_FILTER_SELECT_ALL_ID, + STAGE_FILTERS_ID, + STAGE_OPTIONS, + STATUS_FILTERS_ID, + STATUS_OPTIONS, + getCustomerFilterSummary, + getCustomerKey, + getCustomerOptions, + getOwnerKey, + getOwnerOptions, + opportunitiesFilterState, + opportunitiesTableState, + renderOpportunitiesTable, + saveFilterPreferences, + stringifySearchValue, + environment +}) { + const { document } = environment; + function resetCustomerAndOwnerFiltersForPeriod() { + opportunitiesFilterState.selectedCustomerKeys = new Set(); + opportunitiesFilterState.selectedOwnerKeys = new Set(); + opportunitiesFilterState.customerSearch = ""; + opportunitiesFilterState.ownerSearch = ""; + opportunitiesFilterState.hasSavedCustomerFilter = false; + opportunitiesFilterState.hasSavedOwnerFilter = false; + opportunitiesFilterState.customerFilterInitialized = false; + opportunitiesFilterState.ownerFilterInitialized = false; } function renderStageFilterButtons() { @@ -1497,7 +2169,7 @@ STAGE_OPTIONS.forEach((stage) => { const button = document.createElement("button"); - const isPressed = selectedStages.has(stage); + const isPressed = opportunitiesFilterState.selectedStages.has(stage); button.type = "button"; button.className = "opportunities-extension-stage-filter-button"; @@ -1510,10 +2182,10 @@ } function toggleStageFilter(stage) { - if (selectedStages.has(stage)) { - selectedStages.delete(stage); + if (opportunitiesFilterState.selectedStages.has(stage)) { + opportunitiesFilterState.selectedStages.delete(stage); } else { - selectedStages.add(stage); + opportunitiesFilterState.selectedStages.add(stage); } saveFilterPreferences(); @@ -1532,7 +2204,7 @@ STATUS_OPTIONS.forEach((status) => { const button = document.createElement("button"); - const isPressed = selectedStatuses.has(status); + const isPressed = opportunitiesFilterState.selectedStatuses.has(status); button.type = "button"; button.className = "opportunities-extension-status-filter-button"; @@ -1545,10 +2217,10 @@ } function toggleStatusFilter(status) { - if (selectedStatuses.has(status)) { - selectedStatuses.delete(status); + if (opportunitiesFilterState.selectedStatuses.has(status)) { + opportunitiesFilterState.selectedStatuses.delete(status); } else { - selectedStatuses.add(status); + opportunitiesFilterState.selectedStatuses.add(status); } saveFilterPreferences(); @@ -1628,17 +2300,17 @@ function resetCustomerFilter(items) { const customerOptions = getCustomerOptions(items); - if (!customerFilterInitialized) { - selectedCustomerKeys = new Set(customerOptions.map((customer) => customer.key)); - customerFilterInitialized = true; + if (!opportunitiesFilterState.customerFilterInitialized) { + opportunitiesFilterState.selectedCustomerKeys = new Set(customerOptions.map((customer) => customer.key)); + opportunitiesFilterState.customerFilterInitialized = true; } } function resetOwnerFilter(items) { const ownerOptions = getOwnerOptions(items); - if (!ownerFilterInitialized) { - selectedOwnerKeys = new Set(ownerOptions.map((owner) => owner.key)); - ownerFilterInitialized = true; + if (!opportunitiesFilterState.ownerFilterInitialized) { + opportunitiesFilterState.selectedOwnerKeys = new Set(ownerOptions.map((owner) => owner.key)); + opportunitiesFilterState.ownerFilterInitialized = true; } } @@ -1652,10 +2324,10 @@ return; } - const selectedCount = customerOptions.filter((customer) => selectedCustomerKeys.has(customer.key)).length; + const selectedCount = customerOptions.filter((customer) => opportunitiesFilterState.selectedCustomerKeys.has(customer.key)).length; trigger.disabled = customerOptions.length === 0; trigger.textContent = getCustomerFilterSummary(customerOptions.length, selectedCount); - searchInput.value = customerSearch; + searchInput.value = opportunitiesFilterState.customerSearch; selectAllCheckbox.disabled = customerOptions.length === 0; selectAllCheckbox.checked = customerOptions.length > 0 && selectedCount === customerOptions.length; selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < customerOptions.length; @@ -1669,7 +2341,7 @@ return; } - const normalizedSearch = customerSearch.trim().toLocaleLowerCase(); + const normalizedSearch = opportunitiesFilterState.customerSearch.trim().toLocaleLowerCase(); const customerOptions = getCustomerOptions().filter((customer) => { return customer.label.toLocaleLowerCase().includes(normalizedSearch); }); @@ -1679,7 +2351,7 @@ if (customerOptions.length === 0) { const empty = document.createElement("p"); empty.className = "opportunities-extension-customer-filter-empty"; - empty.textContent = customerSearch ? "No matching customers." : "No customers available."; + empty.textContent = opportunitiesFilterState.customerSearch ? "No matching customers." : "No customers available."; list.append(empty); return; } @@ -1690,15 +2362,15 @@ const checkbox = document.createElement("input"); checkbox.type = "checkbox"; - checkbox.checked = selectedCustomerKeys.has(customer.key); + checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key); checkbox.addEventListener("change", () => { if (checkbox.checked) { - selectedCustomerKeys.add(customer.key); + opportunitiesFilterState.selectedCustomerKeys.add(customer.key); } else { - selectedCustomerKeys.delete(customer.key); + opportunitiesFilterState.selectedCustomerKeys.delete(customer.key); } - hasSavedCustomerFilter = true; + opportunitiesFilterState.hasSavedCustomerFilter = true; saveFilterPreferences(); renderCustomerFilter(); renderOpportunitiesTable(); @@ -1711,10 +2383,152 @@ }); } + function renderOwnerFilter() { + const trigger = document.getElementById(OWNER_FILTER_BUTTON_ID); + const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID); + const selectAllCheckbox = document.getElementById(OWNER_FILTER_SELECT_ALL_ID); + const ownerOptions = getOwnerOptions(); + + if (!trigger || !searchInput || !selectAllCheckbox) { + return; + } + + const selectedCount = ownerOptions.filter((owner) => opportunitiesFilterState.selectedOwnerKeys.has(owner.key)).length; + trigger.disabled = ownerOptions.length === 0; + trigger.textContent = getCustomerFilterSummary(ownerOptions.length, selectedCount) + .replace("customer", "owner"); + searchInput.value = opportunitiesFilterState.ownerSearch; + selectAllCheckbox.disabled = ownerOptions.length === 0; + selectAllCheckbox.checked = ownerOptions.length > 0 && selectedCount === ownerOptions.length; + selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < ownerOptions.length; + renderOwnerFilterList(); + } + + function renderOwnerFilterList() { + const list = document.getElementById(OWNER_FILTER_LIST_ID); + + if (!list) { + return; + } + + const normalizedSearch = opportunitiesFilterState.ownerSearch.trim().toLocaleLowerCase(); + const ownerOptions = getOwnerOptions().filter((owner) => { + return owner.label.toLocaleLowerCase().includes(normalizedSearch); + }); + + list.textContent = ""; + + if (ownerOptions.length === 0) { + const empty = document.createElement("p"); + empty.className = "opportunities-extension-customer-filter-empty"; + empty.textContent = opportunitiesFilterState.ownerSearch ? "No matching owners." : "No owners available."; + list.append(empty); + return; + } + + ownerOptions.forEach((owner) => { + const option = document.createElement("label"); + option.className = "opportunities-extension-customer-filter-option"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key); + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + opportunitiesFilterState.selectedOwnerKeys.add(owner.key); + } else { + opportunitiesFilterState.selectedOwnerKeys.delete(owner.key); + } + + opportunitiesFilterState.hasSavedOwnerFilter = true; + saveFilterPreferences(); + renderOwnerFilter(); + renderOpportunitiesTable(); + }); + + const label = document.createElement("span"); + label.textContent = owner.label; + option.append(checkbox, label); + list.append(option); + }); + } + + function getSortedOpportunities() { + const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase(); + const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => { + return opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c) + && matchesSelectedStatus(item.StatusCode) + && opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)) + && opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)) + && matchesTableSearch(item, normalizedSearch); + }); + const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey); + + if (!column) { + return items; + } + + const direction = opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending" ? 1 : -1; + + return items.sort((first, second) => { + const firstValue = column.value(first); + const secondValue = column.value(second); + const firstIsEmpty = firstValue === null || firstValue === undefined || firstValue === ""; + const secondIsEmpty = secondValue === null || secondValue === undefined || secondValue === ""; + + if (firstIsEmpty || secondIsEmpty) { + if (firstIsEmpty && secondIsEmpty) { + return 0; + } + + return firstIsEmpty ? 1 : -1; + } + + const firstNumber = Number(firstValue); + const secondNumber = Number(secondValue); + + if (Number.isFinite(firstNumber) && Number.isFinite(secondNumber)) { + return (firstNumber - secondNumber) * direction; + } + + return String(firstValue).localeCompare(String(secondValue), undefined, { + numeric: true, + sensitivity: "base" + }) * direction; + }); + } + + function matchesTableSearch(item, normalizedSearch) { + if (!normalizedSearch) { + return true; + } + + return OPPORTUNITIES_COLUMNS.some((column) => { + const rawValue = stringifySearchValue(column.value(item)); + const displayValue = stringifySearchValue(column.display(item)); + return `${rawValue} ${displayValue}`.toLocaleLowerCase().includes(normalizedSearch); + }); + } + + function matchesSelectedStatus(statusCode) { + if (statusCode === "WON_PENDING") { + return opportunitiesFilterState.selectedStatuses.has("WON"); + } + + return opportunitiesFilterState.selectedStatuses.has(statusCode); + } + + return { resetCustomerAndOwnerFiltersForPeriod, renderStageFilterButtons, toggleStageFilter, renderStatusFilterButtons, toggleStatusFilter, toggleCustomerFilterPanel, closeCustomerFilterPanel, toggleOwnerFilterPanel, closeOwnerFilterPanel, resetCustomerFilter, resetOwnerFilter, renderCustomerFilter, renderCustomerFilterList, renderOwnerFilter, renderOwnerFilterList, getSortedOpportunities, matchesTableSearch, matchesSelectedStatus }; +} + +// Source: src/features/opportunities/formatters.js +function createOpportunitiesFormatters({ + opportunitiesTableState +}) { function getCustomerOptions(items) { const optionsByKey = new Map(); - (items || opportunitiesTableState.items).forEach((item) => { + (items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => { const key = getCustomerKey(item); const label = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]); @@ -1784,80 +2598,10 @@ return `${selected} customer${selected === 1 ? "" : "s"}`; } - function renderOwnerFilter() { - const trigger = document.getElementById(OWNER_FILTER_BUTTON_ID); - const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID); - const selectAllCheckbox = document.getElementById(OWNER_FILTER_SELECT_ALL_ID); - const ownerOptions = getOwnerOptions(); - - if (!trigger || !searchInput || !selectAllCheckbox) { - return; - } - - const selectedCount = ownerOptions.filter((owner) => selectedOwnerKeys.has(owner.key)).length; - trigger.disabled = ownerOptions.length === 0; - trigger.textContent = getCustomerFilterSummary(ownerOptions.length, selectedCount) - .replace("customer", "owner"); - searchInput.value = ownerSearch; - selectAllCheckbox.disabled = ownerOptions.length === 0; - selectAllCheckbox.checked = ownerOptions.length > 0 && selectedCount === ownerOptions.length; - selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < ownerOptions.length; - renderOwnerFilterList(); - } - - function renderOwnerFilterList() { - const list = document.getElementById(OWNER_FILTER_LIST_ID); - - if (!list) { - return; - } - - const normalizedSearch = ownerSearch.trim().toLocaleLowerCase(); - const ownerOptions = getOwnerOptions().filter((owner) => { - return owner.label.toLocaleLowerCase().includes(normalizedSearch); - }); - - list.textContent = ""; - - if (ownerOptions.length === 0) { - const empty = document.createElement("p"); - empty.className = "opportunities-extension-customer-filter-empty"; - empty.textContent = ownerSearch ? "No matching owners." : "No owners available."; - list.append(empty); - return; - } - - ownerOptions.forEach((owner) => { - const option = document.createElement("label"); - option.className = "opportunities-extension-customer-filter-option"; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.checked = selectedOwnerKeys.has(owner.key); - checkbox.addEventListener("change", () => { - if (checkbox.checked) { - selectedOwnerKeys.add(owner.key); - } else { - selectedOwnerKeys.delete(owner.key); - } - - hasSavedOwnerFilter = true; - saveFilterPreferences(); - renderOwnerFilter(); - renderOpportunitiesTable(); - }); - - const label = document.createElement("span"); - label.textContent = owner.label; - option.append(checkbox, label); - list.append(option); - }); - } - function getOwnerOptions(items) { const optionsByKey = new Map(); - (items || opportunitiesTableState.items).forEach((item) => { + (items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => { const key = getOwnerKey(item); const label = getOwnerName(item); @@ -1885,71 +2629,6 @@ return name ? `name:${name}` : ""; } - function getSortedOpportunities() { - const normalizedSearch = tableSearch.trim().toLocaleLowerCase(); - const items = opportunitiesTableState.items.filter((item) => { - return selectedStages.has(item.ForecastGroup_c) - && matchesSelectedStatus(item.StatusCode) - && selectedCustomerKeys.has(getCustomerKey(item)) - && selectedOwnerKeys.has(getOwnerKey(item)) - && matchesTableSearch(item, normalizedSearch); - }); - const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.sortKey); - - if (!column) { - return items; - } - - const direction = opportunitiesTableState.sortDirection === "ascending" ? 1 : -1; - - return items.sort((first, second) => { - const firstValue = column.value(first); - const secondValue = column.value(second); - const firstIsEmpty = firstValue === null || firstValue === undefined || firstValue === ""; - const secondIsEmpty = secondValue === null || secondValue === undefined || secondValue === ""; - - if (firstIsEmpty || secondIsEmpty) { - if (firstIsEmpty && secondIsEmpty) { - return 0; - } - - return firstIsEmpty ? 1 : -1; - } - - const firstNumber = Number(firstValue); - const secondNumber = Number(secondValue); - - if (Number.isFinite(firstNumber) && Number.isFinite(secondNumber)) { - return (firstNumber - secondNumber) * direction; - } - - return String(firstValue).localeCompare(String(secondValue), undefined, { - numeric: true, - sensitivity: "base" - }) * direction; - }); - } - - function matchesTableSearch(item, normalizedSearch) { - if (!normalizedSearch) { - return true; - } - - return OPPORTUNITIES_COLUMNS.some((column) => { - const rawValue = stringifySearchValue(column.value(item)); - const displayValue = stringifySearchValue(column.display(item)); - return `${rawValue} ${displayValue}`.toLocaleLowerCase().includes(normalizedSearch); - }); - } - - function matchesSelectedStatus(statusCode) { - if (statusCode === "WON_PENDING") { - return selectedStatuses.has("WON"); - } - - return selectedStatuses.has(statusCode); - } - function stringifySearchValue(value) { if (value === null || value === undefined) { return ""; @@ -1967,6 +2646,63 @@ return value === null || value === undefined || value === "" ? "-" : String(value); } + function getNestedValue(value, path) { + return path.reduce((result, key) => result && result[key], value); + } + + function formatWinProbability(item) { + const value = getNestedValue(item, ["PrimaryRevenue", "WinProb"]); + const number = Number(value); + return Number.isFinite(number) ? `${number}%` : value; + } + + function formatRevenue(item) { + const amount = getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]); + const currency = getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]); + const number = Number(amount); + + if (!Number.isFinite(number)) { + return amount; + } + + if (!currency) { + return number.toLocaleString(); + } + + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency + }).format(number); + } catch (error) { + return `${currency} ${number.toLocaleString()}`; + } + } + + function formatOracleResponseDate(value, includeTime) { + if (typeof value !== "string") { + return value; + } + + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/); + + if (!match) { + return value; + } + + const date = `${match[3]}/${match[2]}/${match[1]}`; + return includeTime && match[4] ? `${date} ${match[4]}:${match[5]}` : date; + } + + return { getCustomerOptions, getCustomerKey, getOwnerName, getCustomerFilterSummary, getOwnerOptions, getOwnerKey, stringifySearchValue, displayOpportunityValue, getNestedValue, formatWinProbability, formatRevenue, formatOracleResponseDate }; +} + +// Source: src/features/opportunities/links.js +function createOpportunitiesLinks({ + MODAL_ID, + environment +}) { + const { document, navigator, window } = environment; function createDetailLink(label, href) { if (label === null || label === undefined || label === "") { const placeholder = document.createElement("span"); @@ -2011,11 +2747,812 @@ return button; } + async function copyOpportunityNumber(optyNumber) { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(optyNumber); + } else { + const input = document.createElement("textarea"); + input.value = optyNumber; + input.setAttribute("readonly", "true"); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.append(input); + input.select(); + document.execCommand("copy"); + input.remove(); + } + + showCopyConfirmation(`Opty Number ${optyNumber} copied.`); + } catch (error) { + showCopyConfirmation("Unable to copy opportunity number.", true); + } + } + + function showCopyConfirmation(message, isError = false) { + const modal = document.getElementById(MODAL_ID); + + if (!modal) { + return; + } + + let toast = modal.querySelector(".opportunities-extension-copy-toast"); + + if (!toast) { + toast = document.createElement("div"); + toast.className = "opportunities-extension-copy-toast"; + toast.setAttribute("role", "status"); + modal.append(toast); + } + + toast.classList.toggle("opportunities-extension-copy-toast-error", isError); + toast.textContent = message; + toast.hidden = false; + window.clearTimeout(toast.copyTimeout); + toast.copyTimeout = window.setTimeout(() => { + toast.hidden = true; + }, 2400); + } + + function createStageBadge(stage) { + const badge = document.createElement("span"); + const normalizedStage = typeof stage === "string" ? stage.toUpperCase() : ""; + + badge.className = "opportunities-extension-stage-badge"; + badge.setAttribute("data-stage", normalizedStage); + badge.textContent = normalizedStage || "-"; + return badge; + } + + function createStatusBadge(status) { + const badge = document.createElement("span"); + const normalizedStatus = typeof status === "string" ? status.toUpperCase() : ""; + + badge.className = "opportunities-extension-status-badge"; + badge.setAttribute("data-status", normalizedStatus); + badge.textContent = normalizedStatus || "-"; + return badge; + } + + return { createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge }; +} + +// Source: src/features/preferences/storage.js +function createPreferencesStorage({ + DEFAULT_OPPORTUNITY_TYPE_VALUES, + DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, + FILTER_PREFERENCES_STORAGE_KEY, + MODAL_ID, + OPPORTUNITY_TYPE_OPTIONS, + OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, + OPPTY_PRODUCTS_CACHE_OPTIONS, + PERIOD_OPTIONS, + STAGE_OPTIONS, + STATUS_OPTIONS, + opportunitiesFilterState, + environment +}) { + const { document } = environment; + const getLocalStorage = () => environment.localStorage; + function restoreFilterPreferences(select) { + try { + const rawPreferences = getLocalStorage().getItem(FILTER_PREFERENCES_STORAGE_KEY); + + if (!rawPreferences) { + return; + } + + const preferences = JSON.parse(rawPreferences); + const savedStages = Array.isArray(preferences.stages) + ? preferences.stages.filter((stage) => STAGE_OPTIONS.includes(stage)) + : null; + const savedStatuses = Array.isArray(preferences.statuses) + ? preferences.statuses.filter((status) => STATUS_OPTIONS.includes(status)) + : null; + + if (savedStages) { + opportunitiesFilterState.selectedStages = new Set(savedStages); + } + + if (savedStatuses) { + opportunitiesFilterState.selectedStatuses = new Set(savedStatuses); + } + + if (Array.isArray(preferences.customerKeys)) { + opportunitiesFilterState.selectedCustomerKeys = new Set(preferences.customerKeys.map(String)); + opportunitiesFilterState.hasSavedCustomerFilter = true; + opportunitiesFilterState.customerFilterInitialized = true; + } + + if (Array.isArray(preferences.ownerKeys)) { + opportunitiesFilterState.selectedOwnerKeys = new Set(preferences.ownerKeys.map(String)); + opportunitiesFilterState.hasSavedOwnerFilter = true; + opportunitiesFilterState.ownerFilterInitialized = true; + } + + opportunitiesFilterState.customerSearch = typeof preferences.customerSearch === "string" ? preferences.customerSearch : ""; + opportunitiesFilterState.ownerSearch = typeof preferences.ownerSearch === "string" ? preferences.ownerSearch : ""; + opportunitiesFilterState.tableSearch = typeof preferences.tableSearch === "string" ? preferences.tableSearch : ""; + + if (PERIOD_OPTIONS.includes(preferences.period)) { + select.value = preferences.period; + } + } catch (error) { + // Ignore malformed or unavailable browser storage and use default filters. + } + } + + function saveFilterPreferences(period) { + try { + const currentPeriod = period || document.querySelector(`#${MODAL_ID} select`)?.value || PERIOD_OPTIONS[0]; + getLocalStorage().setItem(FILTER_PREFERENCES_STORAGE_KEY, JSON.stringify({ + period: currentPeriod, + stages: Array.from(opportunitiesFilterState.selectedStages), + statuses: Array.from(opportunitiesFilterState.selectedStatuses), + customerKeys: opportunitiesFilterState.hasSavedCustomerFilter ? Array.from(opportunitiesFilterState.selectedCustomerKeys) : null, + ownerKeys: opportunitiesFilterState.hasSavedOwnerFilter ? Array.from(opportunitiesFilterState.selectedOwnerKeys) : null, + customerSearch: opportunitiesFilterState.customerSearch, + ownerSearch: opportunitiesFilterState.ownerSearch, + tableSearch: opportunitiesFilterState.tableSearch + })); + } catch (error) { + // Ignore unavailable browser storage; filters still work for the current modal. + } + } + + function getSavedOpportunityTypeValues() { + try { + const rawPreferences = getLocalStorage().getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY); + + if (!rawPreferences) { + return DEFAULT_OPPORTUNITY_TYPE_VALUES; + } + + const preferences = JSON.parse(rawPreferences); + const values = Array.isArray(preferences.opportunityTypeValues) ? preferences.opportunityTypeValues : null; + + if (!values) { + return DEFAULT_OPPORTUNITY_TYPE_VALUES; + } + + const allowedValues = new Set(OPPORTUNITY_TYPE_OPTIONS.map((option) => option.value)); + return values.filter((value) => allowedValues.has(value)); + } catch (error) { + return DEFAULT_OPPORTUNITY_TYPE_VALUES; + } + } + + function getSavedOpptyProductsCacheHours() { + try { + const rawPreferences = getLocalStorage().getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY); + + if (!rawPreferences) { + return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; + } + + const preferences = JSON.parse(rawPreferences); + const cacheHours = Number(preferences.opptyProductsCacheHours); + return OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === cacheHours) + ? cacheHours + : DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; + } catch (error) { + return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; + } + } + + return { restoreFilterPreferences, saveFilterPreferences, getSavedOpportunityTypeValues, getSavedOpptyProductsCacheHours }; +} + +// Source: src/features/preferences/view.js +function createPreferencesView({ + DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, + MODAL_ID, + OPPORTUNITY_TYPE_OPTIONS, + OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, + OPPTY_PRODUCTS_CACHE_OPTIONS, + PERIOD_OPTIONS, + PREFERENCES_MODAL_ID, + createCloseIcon, + getSavedOpportunityTypeValues, + getSavedOpptyProductsCacheHours, + preferencesState, + requestOpportunitiesForPeriod, + environment +}) { + const { document } = environment; + const getLocalStorage = () => environment.localStorage; + function openPreferencesModal() { + const overlay = document.getElementById(MODAL_ID); + + if (!overlay) { + return; + } + + const existingModal = document.getElementById(PREFERENCES_MODAL_ID); + + if (existingModal) { + const savedValues = new Set(getSavedOpportunityTypeValues()); + existingModal.querySelectorAll("input[name='opportunityTypeView']").forEach((checkbox) => { + checkbox.checked = savedValues.has(checkbox.value); + }); + const cacheSelect = existingModal.querySelector("select[name='opptyProductsCacheHours']"); + + if (cacheSelect) { + cacheSelect.value = String(getSavedOpptyProductsCacheHours()); + } + + existingModal.hidden = false; + existingModal.querySelector("input")?.focus(); + return; + } + + const preferencesModal = document.createElement("section"); + preferencesModal.id = PREFERENCES_MODAL_ID; + preferencesModal.className = "opportunities-extension-preferences-modal"; + preferencesModal.setAttribute("role", "dialog"); + preferencesModal.setAttribute("aria-modal", "true"); + preferencesModal.setAttribute("aria-labelledby", "opportunities-extension-preferences-title"); + + const dialog = document.createElement("div"); + dialog.className = "opportunities-extension-preferences-dialog"; + + const header = document.createElement("header"); + header.className = "opportunities-extension-preferences-header"; + + const title = document.createElement("h2"); + title.id = "opportunities-extension-preferences-title"; + title.textContent = "Preferences"; + + const closeButton = document.createElement("button"); + closeButton.type = "button"; + closeButton.className = "opportunities-extension-icon-button"; + closeButton.setAttribute("aria-label", "Fechar preferências"); + closeButton.title = "Close"; + closeButton.append(createCloseIcon()); + closeButton.addEventListener("click", closePreferencesModal); + header.append(title, closeButton); + + const content = document.createElement("div"); + content.className = "opportunities-extension-preferences-content"; + + const fieldset = document.createElement("fieldset"); + fieldset.className = "opportunities-extension-preferences-fieldset"; + + const legend = document.createElement("legend"); + legend.textContent = "Opportunity Type View"; + fieldset.append(legend); + + const selectedValues = new Set(getSavedOpportunityTypeValues()); + const options = document.createElement("div"); + options.className = "opportunities-extension-preferences-options"; + + OPPORTUNITY_TYPE_OPTIONS.forEach((option) => { + const label = document.createElement("label"); + label.className = "opportunities-extension-preferences-option"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.name = "opportunityTypeView"; + checkbox.value = option.value; + checkbox.checked = selectedValues.has(option.value); + + const text = document.createElement("span"); + text.textContent = option.label; + label.append(checkbox, text); + options.append(label); + }); + + fieldset.append(options); + + const cacheField = document.createElement("label"); + cacheField.className = "opportunities-extension-preferences-cache-field"; + + const cacheLabel = document.createElement("span"); + cacheLabel.textContent = "Product cache duration"; + + const cacheSelect = document.createElement("select"); + cacheSelect.name = "opptyProductsCacheHours"; + cacheSelect.setAttribute("aria-label", "Product cache duration"); + OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => { + const optionElement = document.createElement("option"); + optionElement.value = String(option.value); + optionElement.textContent = option.label; + optionElement.selected = option.value === getSavedOpptyProductsCacheHours(); + cacheSelect.append(optionElement); + }); + cacheField.append(cacheLabel, cacheSelect); + content.append(fieldset, cacheField); + + const footer = document.createElement("footer"); + footer.className = "opportunities-extension-preferences-footer"; + + const saveButton = document.createElement("button"); + saveButton.type = "button"; + saveButton.className = "opportunities-extension-button opportunities-extension-button-primary"; + saveButton.textContent = "Save"; + saveButton.addEventListener("click", () => { + const selectedValues = Array.from(preferencesModal.querySelectorAll("input[name='opportunityTypeView']:checked"), (checkbox) => checkbox.value); + const selectedCacheHours = Number(preferencesModal.querySelector("select[name='opptyProductsCacheHours']")?.value); + preferencesState.selectedOpportunityTypeValues = new Set(selectedValues); + preferencesState.opptyProductsCacheHours = OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === selectedCacheHours) + ? selectedCacheHours + : DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; + + try { + getLocalStorage().setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({ + opportunityTypeValues: selectedValues, + opptyProductsCacheHours: preferencesState.opptyProductsCacheHours + })); + } catch (error) { + // Continue with the saved selection for the active modal when storage is unavailable. + } + + closePreferencesModal(); + const periodSelect = overlay.querySelector("select[name='opportunityPeriod']"); + requestOpportunitiesForPeriod(periodSelect?.value || PERIOD_OPTIONS[0]); + }); + + footer.append(saveButton); + dialog.append(header, content, footer); + preferencesModal.append(dialog); + overlay.append(preferencesModal); + preferencesModal.querySelector("input")?.focus(); + } + + function closePreferencesModal() { + const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID); + + if (preferencesModal) { + preferencesModal.hidden = true; + } + } + + return { openPreferencesModal, closePreferencesModal }; +} + +// Source: src/features/products/content-jobs.js +function createProductsContentJobs({ + messageTypes, + PRODUCTS_CACHE_TIMESTAMP_ID, + PRODUCTS_MESSAGE_LISTENER_KEY, + PRODUCTS_PROGRESS_ID, + preferencesState, + productsContentState, + renderOpportunitiesTable, + sendRuntimeMessage, + environment +}) { + const { browser, chrome, document, window } = environment; + function requestOpptyProductsInBackground(opportunities, token) { + const optyNumbers = Array.from(new Set(opportunities + .map((item) => item && item.OptyNumber) + .filter(Boolean) + .map(String))); + + const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + productsContentState.currentProductsRequestId = requestId; + productsContentState.opptyProductsByNumber = new Map(); + productsContentState.expandedProductOptyNumbers = new Set(); + productsContentState.productsProgress = { + state: optyNumbers.length ? "running" : "complete", + completed: 0, + total: optyNumbers.length, + failed: 0, + cacheHits: 0 + }; + renderProductsProgress(); + + if (optyNumbers.length === 0) { + return; + } + + sendRuntimeMessage({ + type: messageTypes.requestOpptyProducts, + requestId, + accessToken: token, + optyNumbers, + cacheMaxAgeMs: preferencesState.opptyProductsCacheHours * 60 * 60 * 1000 + }).then((response) => { + if (response && response.ok === false && response.requestId === productsContentState.currentProductsRequestId && productsContentState.productsProgress.state === "running") { + productsContentState.productsProgress.state = "error"; + productsContentState.productsProgress.failed = productsContentState.productsProgress.total; + renderProductsProgress(); + } + }).catch(() => { + if (requestId === productsContentState.currentProductsRequestId && productsContentState.productsProgress.state === "running") { + productsContentState.productsProgress.state = "error"; + productsContentState.productsProgress.failed = productsContentState.productsProgress.total; + renderProductsProgress(); + } + }); + } + + function ensureProductsBackgroundMessageHandler() { + if (window[PRODUCTS_MESSAGE_LISTENER_KEY]) { + return; + } + + const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + + if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.onMessage) { + return; + } + + window[PRODUCTS_MESSAGE_LISTENER_KEY] = true; + runtimeApi.runtime.onMessage.addListener((message) => { + if (!message || message.type !== messageTypes.opptyProductsProgress || message.requestId !== productsContentState.currentProductsRequestId) { + return false; + } + + if (message.optyNumber) { + productsContentState.opptyProductsByNumber.set(String(message.optyNumber), { + items: Array.isArray(message.items) ? message.items : [], + fromCache: Boolean(message.fromCache), + cachedAt: Number(message.cachedAt) || 0, + error: message.error || "" + }); + } + + if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) { + productsContentState.productsLastCachedAt = Number(message.cachedAt); + } + + productsContentState.productsProgress = { + state: message.state || productsContentState.productsProgress.state, + completed: Number(message.completed) || 0, + total: Number(message.total) || productsContentState.productsProgress.total, + failed: Number(message.failed) || 0, + cacheHits: Number(message.cacheHits) || 0 + }; + renderProductsProgress(); + queueProductsTableRender(); + return false; + }); + } + + function renderProductsProgress() { + const badge = document.getElementById(PRODUCTS_PROGRESS_ID); + + if (!badge) { + return; + } + + badge.hidden = productsContentState.productsProgress.state === "idle"; + badge.setAttribute("data-state", productsContentState.productsProgress.state); + + if (productsContentState.productsProgress.state === "running") { + badge.textContent = `Products ${productsContentState.productsProgress.completed}/${productsContentState.productsProgress.total}`; + badge.title = `${productsContentState.productsProgress.cacheHits} loaded from cache`; + } else if (productsContentState.productsProgress.state === "complete") { + badge.textContent = `Products ready ${productsContentState.productsProgress.total}`; + badge.title = `${productsContentState.productsProgress.cacheHits} loaded from cache`; + } else if (productsContentState.productsProgress.state === "complete-with-errors") { + badge.textContent = `Products ${productsContentState.productsProgress.total - productsContentState.productsProgress.failed}/${productsContentState.productsProgress.total}`; + badge.title = `${productsContentState.productsProgress.failed} product requests failed`; + } else if (productsContentState.productsProgress.state === "error") { + badge.textContent = "Products unavailable"; + badge.title = "Unable to load opportunity products"; + } else { + badge.textContent = "Products ready 0"; + } + + const timestamp = document.getElementById(PRODUCTS_CACHE_TIMESTAMP_ID); + + if (timestamp) { + timestamp.hidden = !productsContentState.productsLastCachedAt; + timestamp.textContent = productsContentState.productsLastCachedAt + ? `Last update: ${formatProductsCacheTimestamp(productsContentState.productsLastCachedAt)}` + : ""; + timestamp.title = productsContentState.productsLastCachedAt + ? `Latest product update: ${formatProductsCacheTimestamp(productsContentState.productsLastCachedAt)}` + : ""; + } + } + + function formatProductsCacheTimestamp(timestamp) { + try { + return new Intl.DateTimeFormat("en-GB", { + dateStyle: "short", + timeStyle: "short" + }).format(new Date(timestamp)); + } catch (error) { + return new Date(timestamp).toLocaleString(); + } + } + + function queueProductsTableRender() { + if (productsContentState.productsRenderQueued) { + return; + } + + productsContentState.productsRenderQueued = true; + window.requestAnimationFrame(() => { + productsContentState.productsRenderQueued = false; + renderOpportunitiesTable(); + }); + } + + return { requestOpptyProductsInBackground, ensureProductsBackgroundMessageHandler, renderProductsProgress, formatProductsCacheTimestamp, queueProductsTableRender }; +} + +// Source: src/features/products/details.js +function createProductsDetails({ + OPPORTUNITIES_COLUMNS, + createAiSummaryAlert, + formatOracleResponseDate, + productsContentState, + renderOpportunitiesTable, + requestGenerateAiSummary, + environment +}) { + const { document } = environment; + function createProductsCountButton(optyNumber, productsResult) { + const normalizedOptyNumber = String(optyNumber); + const button = document.createElement("button"); + const count = Array.isArray(productsResult.items) ? productsResult.items.length : 0; + const isExpanded = productsContentState.expandedProductOptyNumbers.has(normalizedOptyNumber); + + button.type = "button"; + button.className = "opportunities-extension-products-count"; + button.textContent = String(count); + button.setAttribute("aria-expanded", String(isExpanded)); + button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`); + button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`; + button.classList.toggle("opportunities-extension-products-count-error", Boolean(productsResult.error)); + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + + if (isExpanded) { + productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber); + } else { + productsContentState.expandedProductOptyNumbers.add(normalizedOptyNumber); + requestGenerateAiSummary(normalizedOptyNumber); + } + + renderOpportunitiesTable(); + }); + return button; + } + + function createProductsDetailRow(opportunity) { + const row = document.createElement("tr"); + const cell = document.createElement("td"); + const optyNumber = String(opportunity.OptyNumber); + const productsResult = productsContentState.opptyProductsByNumber.get(optyNumber); + const products = productsResult && Array.isArray(productsResult.items) ? productsResult.items : []; + + row.className = "opportunities-extension-products-detail-row"; + cell.colSpan = OPPORTUNITIES_COLUMNS.length; + cell.className = "opportunities-extension-products-detail-cell"; + + const panel = document.createElement("section"); + panel.className = "opportunities-extension-products-detail"; + + const heading = document.createElement("div"); + heading.className = "opportunities-extension-products-detail-heading"; + + const title = document.createElement("strong"); + title.textContent = `Products for ${optyNumber}`; + + const summary = document.createElement("span"); + summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated"; + heading.append(title, summary); + panel.append(heading); + panel.append(createAiSummaryAlert(optyNumber)); + + if (productsResult && productsResult.error) { + const error = document.createElement("p"); + error.className = "opportunities-extension-products-empty"; + error.textContent = productsResult.error; + panel.append(error); + } else if (products.length === 0) { + const empty = document.createElement("p"); + empty.className = "opportunities-extension-products-empty"; + empty.textContent = "No products found for this opportunity."; + panel.append(empty); + } else { + const table = document.createElement("table"); + table.className = "opportunities-extension-products-table"; + table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`); + + const columns = [ + { label: "Product Group", value: (item) => item.ProdGroupName || "-" }, + { label: "Workload", value: (item) => item.WorkloadName_c || "-" }, + { label: "Currency", value: (item) => item.RevnAmountCurcyCode || "-" }, + { label: "Amount", value: (item) => formatProductAmount(item.RevnAmount, item.RevnAmountCurcyCode) }, + { label: "Type", value: (item) => item.TypeCode || "-" }, + { label: "Status", value: (item) => item.StatusCode || "-" }, + { label: "Win Probability", value: (item) => formatProductWinProbability(item.WinProb) }, + { label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" }, + { label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" }, + { label: "Ramp Months", value: (item) => formatProductNumber(item.RampMonths_c) } + ]; + + const head = document.createElement("thead"); + const headerRow = document.createElement("tr"); + columns.forEach((column) => { + const header = document.createElement("th"); + header.scope = "col"; + header.textContent = column.label; + headerRow.append(header); + }); + head.append(headerRow); + + const body = document.createElement("tbody"); + products.forEach((product) => { + const productRow = document.createElement("tr"); + columns.forEach((column) => { + const productCell = document.createElement("td"); + productCell.textContent = String(column.value(product)); + productRow.append(productCell); + }); + body.append(productRow); + }); + + table.append(head, body); + panel.append(table); + } + + cell.append(panel); + row.append(cell); + return row; + } + + function formatProductNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(number) : "-"; + } + + function formatProductAmount(value, currency) { + const amount = Number(value); + + if (!Number.isFinite(amount)) { + return "-"; + } + + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency || "USD" + }).format(amount); + } catch (error) { + return `${currency || ""} ${formatProductNumber(amount)}`.trim(); + } + } + + function formatProductWinProbability(value) { + const number = Number(value); + return Number.isFinite(number) ? `${number}%` : "-"; + } + + return { createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability }; +} + +// Source: src/features/ai-summary/content.js +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 }; +} + +// Source: src/features/consumption/plans-action.js +function createConsumptionPlansAction({ + messageTypes, + appendBackgroundRequestLog, + consumptionPlansState, + getNestedValue, + openAccountPlansModal, + renderOpportunitiesTable, + requestConsumerTokenServiceOnce, + sendRuntimeMessage, + showCopyConfirmation, + environment +}) { + const { document } = environment; function createCloudConsumptionButton(opportunity) { const button = document.createElement("button"); const optyNumber = String(opportunity.OptyNumber || ""); const partyNumber = getNestedValue(opportunity, ["CustomerAccount", "PartyNumber"]); - const requestState = accountPlansRequestState.get(optyNumber); + const requestState = consumptionPlansState.accountPlansRequestState.get(optyNumber); const isLoading = requestState && requestState.status === "loading"; button.type = "button"; @@ -2031,12 +3568,12 @@ return; } - accountPlansRequestState.set(optyNumber, { status: "loading" }); + consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "loading" }); openAccountPlansModal(opportunity, [], "loading"); renderOpportunitiesTable(); requestConsumerTokenServiceOnce() .then((authorizations) => sendRuntimeMessage({ - type: "opportunitiesExtension.requestAccountPlans", + type: messageTypes.requestAccountPlans, partyNumber: String(partyNumber), authorizations })) @@ -2047,7 +3584,7 @@ throw new Error(response && response.error ? response.error : "Unable to load cloud consumption."); } - accountPlansRequestState.set(optyNumber, { + consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "ready", plans: Array.isArray(response.plans) ? response.plans : [] }); @@ -2055,7 +3592,7 @@ openAccountPlansModal(opportunity, response.plans || [], "ready"); }).catch((error) => { const errorMessage = error.message || "Unable to load cloud consumption."; - accountPlansRequestState.set(optyNumber, { + consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "error", error: errorMessage }); @@ -2089,6 +3626,23 @@ return svg; } + return { createCloudConsumptionButton, createCloudConsumptionIcon }; +} + +// Source: src/features/consumption/plans-view.js +function createConsumptionPlansView({ + ACCOUNT_PLANS_MODAL_ID, + DEFAULT_CONSUMPTION_TREND_PERIOD, + MODAL_ID, + createCloseIcon, + createConsumptionTrendSection, + createSvgElement, + formatOracleResponseDate, + getNestedValue, + loadConsumptionTrendForPanel, + environment +}) { + const { document, requestAnimationFrame, setTimeout, window } = environment; function openAccountPlansModal(opportunity, plans, state = "ready", errorMessage = "") { const overlay = document.getElementById(MODAL_ID); const optyNumber = String(opportunity.OptyNumber || ""); @@ -2424,6 +3978,30 @@ return details; } + function closeAccountPlansModal() { + const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); + + if (modal) { + modal.hidden = true; + } + } + + return { openAccountPlansModal, createAccountPlansTabsNavigation, createAccountPlansTabNavigationButton, createAccountPlansSkeleton, createAccountPlanStatusIndicator, createAccountPlanDetails, closeAccountPlansModal }; +} + +// Source: src/features/consumption/trend-controller.js +function createConsumptionTrendController({ + messageTypes, + CONSUMPTION_TREND_PERIODS, + DEFAULT_CONSUMPTION_TREND_PERIOD, + appendBackgroundRequestLog, + consumptionTrendState, + renderConsumptionTrendChart, + requestConsumerTokenServiceOnce, + sendRuntimeMessage, + environment +}) { + const { document } = environment; function createConsumptionTrendSection(plan) { const section = document.createElement("section"); section.className = "opportunities-extension-consumption-trend"; @@ -2446,7 +4024,7 @@ button.textContent = periodOption.label; button.addEventListener("click", () => { section.dataset.period = periodOption.value; - consumptionTrendWindowOffsets.set( + consumptionTrendState.consumptionTrendWindowOffsets.set( createConsumptionTrendKey(section.dataset.subPlanNum, periodOption.value), 0 ); @@ -2489,7 +4067,7 @@ return; } - const cached = consumptionTrendCache.get(key); + const cached = consumptionTrendState.consumptionTrendCache.get(key); renderConsumptionTrendSkeleton(chart); @@ -2502,7 +4080,7 @@ const request = requestConsumerTokenServiceOnce() .then((authorizations) => sendRuntimeMessage({ - type: "opportunitiesExtension.requestConsumptionTrend", + type: messageTypes.requestConsumptionTrend, subPlanNum, period: selectedPeriod, authorization: authorizations && authorizations.accountPlans @@ -2517,12 +4095,12 @@ return Array.isArray(response.items) ? response.items : []; }) .finally(() => { - if (consumptionTrendCache.get(key)?.promise === request) { - consumptionTrendCache.delete(key); + if (consumptionTrendState.consumptionTrendCache.get(key)?.promise === request) { + consumptionTrendState.consumptionTrendCache.delete(key); } }); - consumptionTrendCache.set(key, { status: "loading", promise: request }); + consumptionTrendState.consumptionTrendCache.set(key, { status: "loading", promise: request }); request .then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key)) .catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key)); @@ -2581,6 +4159,26 @@ chart.append(error); } + return { createConsumptionTrendSection, loadConsumptionTrendForPanel, createConsumptionTrendKey, updateConsumptionPeriodButtons, renderConsumptionTrendIfCurrent, renderConsumptionTrendErrorIfCurrent, renderConsumptionTrendSkeleton, renderConsumptionTrendError }; +} + +// Source: src/features/consumption/chart.js +function createConsumptionChart({ + CONSUMPTION_TREND_PERIODS, + CONSUMPTION_TREND_SERIES, + SVG_NAMESPACE, + calculateAverageConsumption, + consumptionTrendState, + createConsumptionTrendKey, + createNiceConsumptionMaximum, + formatCompactConsumptionNumber, + formatConsumptionCurrency, + formatConsumptionPointLabel, + formatConsumptionRange, + normalizeConsumptionTrendItems, + environment +}) { + const { document, window } = environment; function renderConsumptionTrendChart(section, chart, items, period, subPlanNum) { chart.textContent = ""; chart.setAttribute("aria-busy", "false"); @@ -2598,11 +4196,11 @@ const windowSize = option ? option.windowSize : points.length; const offsetKey = createConsumptionTrendKey(subPlanNum, period); const maxOffset = Math.max(0, Math.ceil(points.length / windowSize) - 1); - const offset = Math.min(consumptionTrendWindowOffsets.get(offsetKey) || 0, maxOffset); + const offset = Math.min(consumptionTrendState.consumptionTrendWindowOffsets.get(offsetKey) || 0, maxOffset); const end = Math.max(0, points.length - (offset * windowSize)); const start = Math.max(0, end - windowSize); const visiblePoints = points.slice(start, end); - consumptionTrendWindowOffsets.set(offsetKey, offset); + consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, offset); const navigation = document.createElement("div"); navigation.className = "opportunities-extension-consumption-navigation"; @@ -2611,11 +4209,11 @@ const range = document.createElement("strong"); range.textContent = formatConsumptionRange(visiblePoints, period); olderButton.addEventListener("click", () => { - consumptionTrendWindowOffsets.set(offsetKey, Math.min(maxOffset, offset + 1)); + consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.min(maxOffset, offset + 1)); renderConsumptionTrendChart(section, chart, items, period, subPlanNum); }); newerButton.addEventListener("click", () => { - consumptionTrendWindowOffsets.set(offsetKey, Math.max(0, offset - 1)); + consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.max(0, offset - 1)); renderConsumptionTrendChart(section, chart, items, period, subPlanNum); }); navigation.append(olderButton, range, newerButton); @@ -2650,14 +4248,14 @@ } function getConsumptionTrendSeriesFilter(subPlanNum) { - if (!consumptionTrendSeriesFilters.has(subPlanNum)) { - consumptionTrendSeriesFilters.set( + if (!consumptionTrendState.consumptionTrendSeriesFilters.has(subPlanNum)) { + consumptionTrendState.consumptionTrendSeriesFilters.set( subPlanNum, new Set(CONSUMPTION_TREND_SERIES.map((series) => series.value)) ); } - return consumptionTrendSeriesFilters.get(subPlanNum); + return consumptionTrendState.consumptionTrendSeriesFilters.get(subPlanNum); } function createConsumptionLegend(section, chart, items, period, subPlanNum, activeSeries) { @@ -2718,47 +4316,6 @@ return button; } - function normalizeConsumptionTrendItems(items) { - const points = new Map(); - - (Array.isArray(items) ? items : []).forEach((item) => { - const dateKey = String(item.fyDate || (Array.isArray(item.fiscalTime) ? item.fiscalTime.join("|") : "")); - - if (!dateKey) { - return; - } - - if (!points.has(dateKey)) { - points.set(dateKey, { - dateKey, - date: parseConsumptionDate(item.fyDate), - fiscalTime: Array.isArray(item.fiscalTime) ? item.fiscalTime.slice() : [], - fiscalQtr: item.fiscalQtr || "", - funded: 0, - overage: 0, - arr: 0, - currencyCode: item.currencyCode || "USD" - }); - } - - const point = points.get(dateKey); - const amount = toFiniteNumber(item.usedAmount); - const category = String(item.usageCategory || "Funded Allocation").toUpperCase(); - - if (category === "OVERAGE") { - point.overage += amount; - } else { - point.funded += amount; - } - - point.arr = Math.max(point.arr, toFiniteNumber(item.arrCd)); - }); - - return Array.from(points.values()) - .map((point) => ({ ...point, total: point.funded + point.overage })) - .sort((left, right) => left.dateKey.localeCompare(right.dateKey)); - } - function createConsumptionSvg(points, period, hasPartialLatestPoint, activeSeries, tooltip) { const width = 1000; const height = 320; @@ -2893,22 +4450,6 @@ return svg; } - function getConsumptionPointTotal(point, activeSeries) { - const selectedSeries = activeSeries && typeof activeSeries.has === "function" - ? activeSeries - : new Set(["funded", "overage"]); - return (selectedSeries.has("funded") ? point.funded : 0) - + (selectedSeries.has("overage") ? point.overage : 0); - } - - function calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries) { - const completePoints = hasPartialLatestPoint && points.length > 1 ? points.slice(0, -1) : points; - return completePoints.reduce( - (sum, point) => sum + getConsumptionPointTotal(point, activeSeries), - 0 - ) / Math.max(completePoints.length, 1); - } - function appendConsumptionBarAnimation(element, fromY, toY, height, delayMs) { if (prefersReducedMotion()) { return; @@ -3003,6 +4544,68 @@ return text; } + return { renderConsumptionTrendChart, getConsumptionTrendSeriesFilter, createConsumptionLegend, createConsumptionNavigationButton, createConsumptionSvg, appendConsumptionBarAnimation, prefersReducedMotion, createConsumptionPointAriaLabel, showConsumptionTooltip, createSvgElement, createSvgText }; +} + +// Source: src/features/consumption/model.js +function createConsumptionModel() { + function normalizeConsumptionTrendItems(items) { + const points = new Map(); + + (Array.isArray(items) ? items : []).forEach((item) => { + const dateKey = String(item.fyDate || (Array.isArray(item.fiscalTime) ? item.fiscalTime.join("|") : "")); + + if (!dateKey) { + return; + } + + if (!points.has(dateKey)) { + points.set(dateKey, { + dateKey, + date: parseConsumptionDate(item.fyDate), + fiscalTime: Array.isArray(item.fiscalTime) ? item.fiscalTime.slice() : [], + fiscalQtr: item.fiscalQtr || "", + funded: 0, + overage: 0, + arr: 0, + currencyCode: item.currencyCode || "USD" + }); + } + + const point = points.get(dateKey); + const amount = toFiniteNumber(item.usedAmount); + const category = String(item.usageCategory || "Funded Allocation").toUpperCase(); + + if (category === "OVERAGE") { + point.overage += amount; + } else { + point.funded += amount; + } + + point.arr = Math.max(point.arr, toFiniteNumber(item.arrCd)); + }); + + return Array.from(points.values()) + .map((point) => ({ ...point, total: point.funded + point.overage })) + .sort((left, right) => left.dateKey.localeCompare(right.dateKey)); + } + + function getConsumptionPointTotal(point, activeSeries) { + const selectedSeries = activeSeries && typeof activeSeries.has === "function" + ? activeSeries + : new Set(["funded", "overage"]); + return (selectedSeries.has("funded") ? point.funded : 0) + + (selectedSeries.has("overage") ? point.overage : 0); + } + + function calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries) { + const completePoints = hasPartialLatestPoint && points.length > 1 ? points.slice(0, -1) : points; + return completePoints.reduce( + (sum, point) => sum + getConsumptionPointTotal(point, activeSeries), + 0 + ) / Math.max(completePoints.length, 1); + } + function createNiceConsumptionMaximum(value) { if (!Number.isFinite(value) || value <= 0) { return 1; @@ -3098,490 +4701,18 @@ return Number.isFinite(number) ? number : 0; } - function closeAccountPlansModal() { - const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); - - if (modal) { - modal.hidden = true; - } - } - - function createProductsCountButton(optyNumber, productsResult) { - const normalizedOptyNumber = String(optyNumber); - const button = document.createElement("button"); - const count = Array.isArray(productsResult.items) ? productsResult.items.length : 0; - const isExpanded = expandedProductOptyNumbers.has(normalizedOptyNumber); - - button.type = "button"; - button.className = "opportunities-extension-products-count"; - button.textContent = String(count); - button.setAttribute("aria-expanded", String(isExpanded)); - button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`); - button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`; - button.classList.toggle("opportunities-extension-products-count-error", Boolean(productsResult.error)); - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - - if (isExpanded) { - expandedProductOptyNumbers.delete(normalizedOptyNumber); - } else { - expandedProductOptyNumbers.add(normalizedOptyNumber); - requestGenerateAiSummary(normalizedOptyNumber); - } - - renderOpportunitiesTable(); - }); - return button; - } - - function requestGenerateAiSummary(optyNumber) { - const key = String(optyNumber); - const existing = aiSummariesByOptyNumber.get(key); - if (existing && existing.status === "loading") { - return existing.promise; - } - - const state = { status: "loading", summaryText: "", error: "", promise: null }; - 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. - if (salesCommonToken && salesCommonToken.access_token === token) { - salesCommonToken = null; - } - 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 = 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; - } - - function createProductsDetailRow(opportunity) { - const row = document.createElement("tr"); - const cell = document.createElement("td"); - const optyNumber = String(opportunity.OptyNumber); - const productsResult = opptyProductsByNumber.get(optyNumber); - const products = productsResult && Array.isArray(productsResult.items) ? productsResult.items : []; - - row.className = "opportunities-extension-products-detail-row"; - cell.colSpan = OPPORTUNITIES_COLUMNS.length; - cell.className = "opportunities-extension-products-detail-cell"; - - const panel = document.createElement("section"); - panel.className = "opportunities-extension-products-detail"; - - const heading = document.createElement("div"); - heading.className = "opportunities-extension-products-detail-heading"; - - const title = document.createElement("strong"); - title.textContent = `Products for ${optyNumber}`; - - const summary = document.createElement("span"); - summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated"; - heading.append(title, summary); - panel.append(heading); - panel.append(createAiSummaryAlert(optyNumber)); - - if (productsResult && productsResult.error) { - const error = document.createElement("p"); - error.className = "opportunities-extension-products-empty"; - error.textContent = productsResult.error; - panel.append(error); - } else if (products.length === 0) { - const empty = document.createElement("p"); - empty.className = "opportunities-extension-products-empty"; - empty.textContent = "No products found for this opportunity."; - panel.append(empty); - } else { - const table = document.createElement("table"); - table.className = "opportunities-extension-products-table"; - table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`); - - const columns = [ - { label: "Product Group", value: (item) => item.ProdGroupName || "-" }, - { label: "Workload", value: (item) => item.WorkloadName_c || "-" }, - { label: "Currency", value: (item) => item.RevnAmountCurcyCode || "-" }, - { label: "Amount", value: (item) => formatProductAmount(item.RevnAmount, item.RevnAmountCurcyCode) }, - { label: "Type", value: (item) => item.TypeCode || "-" }, - { label: "Status", value: (item) => item.StatusCode || "-" }, - { label: "Win Probability", value: (item) => formatProductWinProbability(item.WinProb) }, - { label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" }, - { label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" }, - { label: "Ramp Months", value: (item) => formatProductNumber(item.RampMonths_c) } - ]; - - const head = document.createElement("thead"); - const headerRow = document.createElement("tr"); - columns.forEach((column) => { - const header = document.createElement("th"); - header.scope = "col"; - header.textContent = column.label; - headerRow.append(header); - }); - head.append(headerRow); - - const body = document.createElement("tbody"); - products.forEach((product) => { - const productRow = document.createElement("tr"); - columns.forEach((column) => { - const productCell = document.createElement("td"); - productCell.textContent = String(column.value(product)); - productRow.append(productCell); - }); - body.append(productRow); - }); - - table.append(head, body); - panel.append(table); - } - - cell.append(panel); - row.append(cell); - return row; - } - - function formatProductNumber(value) { - const number = Number(value); - return Number.isFinite(number) ? new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(number) : "-"; - } - - function formatProductAmount(value, currency) { - const amount = Number(value); - - if (!Number.isFinite(amount)) { - return "-"; - } - - try { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: currency || "USD" - }).format(amount); - } catch (error) { - return `${currency || ""} ${formatProductNumber(amount)}`.trim(); - } - } - - function formatProductWinProbability(value) { - const number = Number(value); - return Number.isFinite(number) ? `${number}%` : "-"; - } - - async function copyOpportunityNumber(optyNumber) { - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(optyNumber); - } else { - const input = document.createElement("textarea"); - input.value = optyNumber; - input.setAttribute("readonly", "true"); - input.style.position = "fixed"; - input.style.opacity = "0"; - document.body.append(input); - input.select(); - document.execCommand("copy"); - input.remove(); - } - - showCopyConfirmation(`Opty Number ${optyNumber} copied.`); - } catch (error) { - showCopyConfirmation("Unable to copy opportunity number.", true); - } - } - - function showCopyConfirmation(message, isError = false) { - const modal = document.getElementById(MODAL_ID); - - if (!modal) { - return; - } - - let toast = modal.querySelector(".opportunities-extension-copy-toast"); - - if (!toast) { - toast = document.createElement("div"); - toast.className = "opportunities-extension-copy-toast"; - toast.setAttribute("role", "status"); - modal.append(toast); - } - - toast.classList.toggle("opportunities-extension-copy-toast-error", isError); - toast.textContent = message; - toast.hidden = false; - window.clearTimeout(toast.copyTimeout); - toast.copyTimeout = window.setTimeout(() => { - toast.hidden = true; - }, 2400); - } - - function createStageBadge(stage) { - const badge = document.createElement("span"); - const normalizedStage = typeof stage === "string" ? stage.toUpperCase() : ""; - - badge.className = "opportunities-extension-stage-badge"; - badge.setAttribute("data-stage", normalizedStage); - badge.textContent = normalizedStage || "-"; - return badge; - } - - function createStatusBadge(status) { - const badge = document.createElement("span"); - const normalizedStatus = typeof status === "string" ? status.toUpperCase() : ""; - - badge.className = "opportunities-extension-status-badge"; - badge.setAttribute("data-status", normalizedStatus); - badge.textContent = normalizedStatus || "-"; - return badge; - } - - function getNestedValue(value, path) { - return path.reduce((result, key) => result && result[key], value); - } - - function formatWinProbability(item) { - const value = getNestedValue(item, ["PrimaryRevenue", "WinProb"]); - const number = Number(value); - return Number.isFinite(number) ? `${number}%` : value; - } - - function formatRevenue(item) { - const amount = getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]); - const currency = getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]); - const number = Number(amount); - - if (!Number.isFinite(number)) { - return amount; - } - - if (!currency) { - return number.toLocaleString(); - } - - try { - return new Intl.NumberFormat(undefined, { - style: "currency", - currency - }).format(number); - } catch (error) { - return `${currency} ${number.toLocaleString()}`; - } - } - - function formatOracleResponseDate(value, includeTime) { - if (typeof value !== "string") { - return value; - } - - const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/); - - if (!match) { - return value; - } - - const date = `${match[3]}/${match[2]}/${match[1]}`; - return includeTime && match[4] ? `${date} ${match[4]}:${match[5]}` : date; - } - - function isCurrentPeriodRequest(requestVersion) { - return requestVersion === periodRequestVersion; - } - - function createOpportunitiesQueryPayload(dateRange, offset) { - return { - aggregationResults: false, - applications: ["ORACLE-ISS-APP"], - onlyData: true, - entity: "Opportunity", - q: { - op: "$and", - criteria: [ - { - op: "$or", - criteria: Array.from(selectedOpportunityTypeValues, (value) => ({ - op: "$eq", - attribute: "RecordSet", - value - })) - }, - { - op: "$wi", - attribute: "EffectiveDate", - value1: dateRange.startCloseDate, - value2: dateRange.endCloseDate, - dynamicDate: false - } - ] - }, - keywords: null, - keywordsFields: [ - "Name", - "OptyNumber", - "PrimaryRevenue.WinProb", - "CustomerAccount", - "PrimaryRevenue.RevnAmount", - "EffectiveDate", - "StatusCode", - "DealRisk_c", - "Owner", - "ForecastGroup_c", - "LastUpdateDate", - "PrimaryRevenue.RevnAmountCurcyCode", - "OptyId" - ], - fields: [ - "Name", - "OptyNumber", - "PrimaryRevenue.WinProb", - "CustomerAccount", - "PrimaryRevenue.RevnAmount", - "EffectiveDate", - "StatusCode", - "DealRisk_c", - "Owner", - "ForecastGroup_c", - "LastUpdateDate", - "PrimaryRevenue.RevnAmountCurcyCode", - "OptyId", - "CustomerAccount.PartyUniqueName", - "CustomerAccount.PartyId", - "CustomerAccount.PartyNumber" - ], - sort: [], - language: "en", - skipInValidFields: true, - skipHiddenFromUIFields: true, - copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162", - limit: OPPORTUNITIES_PAGE_LIMIT, - offset - }; - } - - function getFiscalPeriodRange(period, currentDate) { - const currentYear = currentDate.getFullYear(); - const currentMonth = currentDate.getMonth(); - const fiscalStartYear = currentMonth >= 5 ? currentYear : currentYear - 1; - const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3); - - if (period === "Current Fiscal Year") { - const isBeforeFiscalYearStart = currentMonth < 5; - const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear; - const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1; - return createDateRange(startYear, 5, endYear, 4); - } - - let quarterOffset; - let numberOfQuarters = 1; - - if (period === "Current Quarter") { - quarterOffset = currentQuarterIndex; - } else if (period === "Next Quarter") { - quarterOffset = currentQuarterIndex + 1; - } else if (period === "Previous Quarter") { - quarterOffset = currentQuarterIndex - 1; - } else if (period === "4 Rolling Quarters (CQ + 3)") { - quarterOffset = currentQuarterIndex; - numberOfQuarters = 4; - } else { - return null; - } - - const start = new Date(fiscalStartYear, 5 + (quarterOffset * 3), 1); - const end = new Date(start.getFullYear(), start.getMonth() + (numberOfQuarters * 3), 0); - - return { - startCloseDate: formatOracleDate(start), - endCloseDate: formatOracleDate(end) - }; - } - - function createDateRange(startYear, startMonth, endYear, endMonth) { - return { - startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)), - endCloseDate: formatOracleDate(new Date(endYear, endMonth + 1, 0)) - }; - } - - function formatOracleDate(date) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}T00:00:00.000Z`; - } + return { normalizeConsumptionTrendItems, getConsumptionPointTotal, calculateAverageConsumption, createNiceConsumptionMaximum, formatConsumptionPointLabel, formatConsumptionRange, parseConsumptionDate, formatConsumptionDate, formatCompactConsumptionNumber, formatConsumptionCurrency, toFiniteNumber }; +} +// Source: src/features/debug/content-log.js +function createDebugContentLog({ + DEBUG_BODY_LIMIT, + debugState, + renderDebugPanel +}) { function logRequestStart(details) { const entry = { - id: requestLog.length + 1, + id: debugState.requestLog.length + 1, name: details.name, method: details.method, url: details.url, @@ -3601,7 +4732,7 @@ error: "" }; - requestLog.push(entry); + debugState.requestLog.push(entry); renderDebugPanel(); return entry; } @@ -3632,8 +4763,8 @@ } entries.forEach((entry) => { - requestLog.push({ - id: requestLog.length + 1, + debugState.requestLog.push({ + id: debugState.requestLog.length + 1, name: entry.name || "backgroundRequest", method: entry.method || "GET", url: entry.url || "", @@ -3700,7 +4831,7 @@ } return entries - .map(([key, value]) => `${key}: ${debugSecretsVisible || !isSensitiveDebugKey(key) + .map(([key, value]) => `${key}: ${debugState.debugSecretsVisible || !isSensitiveDebugKey(key) ? value : formatHiddenDebugValue(value)}`) .join("\n"); @@ -3709,7 +4840,7 @@ function formatDebugBody(body) { const text = body || "(none)"; - if (debugSecretsVisible) { + if (debugState.debugSecretsVisible) { return text; } @@ -3770,6 +4901,20 @@ return `${token.slice(0, 4)}...${token.slice(-4)}`; } + return { logRequestStart, logRequestSuccess, logRequestFailure, appendBackgroundRequestLog, readResponseBodyForDebug, createDebugBody, headersToObject, formatHeaderBlock, formatDebugBody, maskSensitiveDebugValue, isSensitiveDebugKey, formatHiddenDebugValue, formatMetadataBlock, maskToken }; +} + +// Source: src/features/debug/content-view.js +function createDebugContentView({ + DEBUG_PANEL_ID, + MODAL_ID, + debugState, + formatDebugBody, + formatHeaderBlock, + formatMetadataBlock, + environment +}) { + const { document } = environment; function toggleDebugPanel() { const modal = document.getElementById(MODAL_ID); @@ -3804,18 +4949,18 @@ title.textContent = "Debug Requests"; const summary = document.createElement("p"); - summary.textContent = `${requestLog.length} request${requestLog.length === 1 ? "" : "s"} since modal opened.`; + summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`; heading.append(title, summary); const visibilityButton = document.createElement("button"); visibilityButton.type = "button"; visibilityButton.className = "opportunities-extension-icon-button opportunities-extension-debug-visibility-button"; - visibilityButton.setAttribute("aria-pressed", String(debugSecretsVisible)); - visibilityButton.setAttribute("aria-label", debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"); - visibilityButton.title = debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"; - visibilityButton.append(createDebugVisibilityIcon(debugSecretsVisible)); + visibilityButton.setAttribute("aria-pressed", String(debugState.debugSecretsVisible)); + visibilityButton.setAttribute("aria-label", debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"); + visibilityButton.title = debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"; + visibilityButton.append(createDebugVisibilityIcon(debugState.debugSecretsVisible)); visibilityButton.addEventListener("click", () => { - debugSecretsVisible = !debugSecretsVisible; + debugState.debugSecretsVisible = !debugState.debugSecretsVisible; renderDebugPanel(); }); panelHeader.append(heading, visibilityButton); @@ -3823,14 +4968,14 @@ const list = document.createElement("div"); list.className = "opportunities-extension-debug-list"; - if (requestLog.length === 0) { + if (debugState.requestLog.length === 0) { const empty = document.createElement("div"); empty.className = "opportunities-extension-debug-empty"; empty.textContent = "No requests recorded yet."; list.append(empty); } - requestLog.forEach((entry) => { + debugState.requestLog.forEach((entry) => { const item = document.createElement("details"); item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`; @@ -3932,3705 +5077,455 @@ }); } - async function getXsrfToken() { - const extensionCookie = await getXsrfTokenFromExtensionCookies(); - - if (extensionCookie.token) { - return extensionCookie; - } - - const xsrfCookie = document.cookie - .split(";") - .map((cookie) => cookie.trim()) - .find((cookie) => cookie.startsWith("XSRF-TOKEN-")); - - if (!xsrfCookie) { - return { - token: "", - cookieName: "", - source: extensionCookie.error ? "document.cookie fallback after cookies API error" : "document.cookie fallback", - matchedCookieNames: extensionCookie.matchedCookieNames || [], - lookupDetails: extensionCookie.lookupDetails || [], - error: extensionCookie.error || "" - }; - } - - const cookieName = xsrfCookie.slice(0, xsrfCookie.indexOf("=")); - const tokenValue = xsrfCookie.slice(xsrfCookie.indexOf("=") + 1); - return { - token: decodeURIComponent(tokenValue), - cookieName, - source: "document.cookie", - matchedCookieNames: extensionCookie.matchedCookieNames || [], - lookupDetails: extensionCookie.lookupDetails || [], - error: "" - }; - } - - function getXsrfTokenFromExtensionCookies() { - const runtimeApi = typeof browser !== "undefined" ? browser : chrome; - - if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { - return Promise.resolve({ - token: "", - cookieName: "", - source: "unavailable cookies API", - error: "Runtime messaging API unavailable." - }); - } - - return new Promise((resolve) => { - runtimeApi.runtime.sendMessage({ - type: "opportunitiesExtension.getXsrfToken" - }, (response) => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - resolve({ - token: "", - cookieName: "", - source: "cookies API", - matchedCookieNames: [], - lookupDetails: [], - error: lastError.message - }); - return; - } - - resolve({ - token: response && response.ok ? response.token : "", - cookieName: response && response.ok ? response.cookieName : "", - source: "cookies API", - matchedCookieNames: response && response.matchedCookieNames ? response.matchedCookieNames : [], - lookupDetails: response && response.lookupDetails ? response.lookupDetails : [], - error: response && response.error ? response.error : "" - }); - }); - }); - } - - function refreshXsrfCookie(reason) { - const runtimeApi = typeof browser !== "undefined" ? browser : chrome; - - if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { - return Promise.resolve({ - ok: false, - refreshed: false, - cookieName: "", - durationMs: 0, - reason, - error: "Runtime messaging API unavailable." - }); - } - - return new Promise((resolve) => { - runtimeApi.runtime.sendMessage({ - type: "opportunitiesExtension.refreshXsrfCookie" - }, (response) => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - resolve({ - ok: false, - refreshed: false, - cookieName: "", - durationMs: 0, - reason, - error: lastError.message - }); - return; - } - - resolve({ - ...(response || { - ok: false, - refreshed: false, - cookieName: "", - durationMs: 0, - error: "Empty refresh response." - }), - reason - }); - }); - }); - } - - function updateAuthBadge(status) { - const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`); - - if (!badge) { - return; - } - - const badgeStatus = status === AUTH_STATUS.authenticated || status === AUTH_STATUS.unauthenticated - ? status - : AUTH_STATUS.pending; - - badge.setAttribute("data-auth-status", badgeStatus); - badge.className = `opportunities-extension-auth-badge opportunities-extension-auth-badge-${badgeStatus}`; - badge.textContent = badgeStatus === AUTH_STATUS.authenticated - ? "Authenticated" - : badgeStatus === AUTH_STATUS.unauthenticated - ? "Unauthenticated" - : "Authenticating"; - } - - function closeOpportunitiesModal() { - const modal = document.getElementById(MODAL_ID); - - if (modal) { - closePreferencesModal(); - closeAccountPlansModal(); - modal.hidden = true; - } - - debugSecretsVisible = false; - document.documentElement.classList.remove("opportunities-extension-scroll-lock"); - } - - function getSavedTheme() { - try { - return localStorage.getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light"; - } catch (error) { - return "light"; - } - } - - function applyTheme(modal, button, theme) { - const isDarkTheme = theme === "dark"; - - modal.setAttribute("data-theme", isDarkTheme ? "dark" : "light"); - button.textContent = ""; - button.setAttribute("aria-label", isDarkTheme ? "Ativar modo claro" : "Ativar modo escuro"); - button.title = isDarkTheme ? "Light mode" : "Dark mode"; - button.append(createThemeIcon(isDarkTheme ? "sun" : "moon")); - - try { - localStorage.setItem(THEME_STORAGE_KEY, isDarkTheme ? "dark" : "light"); - } catch (error) { - // Continue with the selected theme when browser storage is unavailable. - } - } - - function createThemeIcon(iconName) { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - - svg.setAttribute("viewBox", "0 0 24 24"); - svg.setAttribute("aria-hidden", "true"); - svg.setAttribute("focusable", "false"); - - if (iconName === "sun") { - path.setAttribute("d", "M12 3V1m0 22v-2m9-9h2M1 12h2m15.36 6.36 1.42 1.42M4.22 4.22l1.42 1.42m12.72 0 1.42-1.42M4.22 19.78l1.42-1.42M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z"); - } else { - path.setAttribute("d", "M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5a8.5 8.5 0 1 0 10.7 10.7Z"); - } - - path.setAttribute("fill", "none"); - path.setAttribute("stroke", "currentColor"); - path.setAttribute("stroke-linecap", "round"); - path.setAttribute("stroke-linejoin", "round"); - path.setAttribute("stroke-width", "1.8"); - svg.append(path); - return svg; - } - - function createSettingsIcon() { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - const upperControl = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - const lowerControl = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - - svg.setAttribute("viewBox", "0 0 24 24"); - svg.setAttribute("class", "opportunities-extension-settings-icon"); - svg.setAttribute("aria-hidden", "true"); - svg.setAttribute("focusable", "false"); - appendPath(svg, "", "M4 7h16M4 17h16"); - - [upperControl, lowerControl].forEach((control, index) => { - control.setAttribute("cx", index === 0 ? "9" : "15"); - control.setAttribute("cy", index === 0 ? "7" : "17"); - control.setAttribute("r", "2.5"); - control.setAttribute("fill", "currentColor"); - control.setAttribute("stroke", "currentColor"); - control.setAttribute("stroke-width", "1.8"); - }); - - svg.querySelector("path").setAttribute("fill", "none"); - svg.querySelector("path").setAttribute("stroke", "currentColor"); - svg.querySelector("path").setAttribute("stroke-linecap", "round"); - svg.querySelector("path").setAttribute("stroke-width", "1.8"); - svg.append(upperControl, lowerControl); - return svg; - } - - function createCloseIcon() { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - - svg.setAttribute("viewBox", "0 0 24 24"); - svg.setAttribute("aria-hidden", "true"); - svg.setAttribute("focusable", "false"); - path.setAttribute("d", "m6 6 12 12M18 6 6 18"); - path.setAttribute("fill", "none"); - path.setAttribute("stroke", "currentColor"); - path.setAttribute("stroke-linecap", "round"); - path.setAttribute("stroke-width", "1.8"); - svg.append(path); - return svg; - } - - function getSavedOpportunityTypeValues() { - try { - const rawPreferences = localStorage.getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY); - - if (!rawPreferences) { - return DEFAULT_OPPORTUNITY_TYPE_VALUES; - } - - const preferences = JSON.parse(rawPreferences); - const values = Array.isArray(preferences.opportunityTypeValues) ? preferences.opportunityTypeValues : null; - - if (!values) { - return DEFAULT_OPPORTUNITY_TYPE_VALUES; - } - - const allowedValues = new Set(OPPORTUNITY_TYPE_OPTIONS.map((option) => option.value)); - return values.filter((value) => allowedValues.has(value)); - } catch (error) { - return DEFAULT_OPPORTUNITY_TYPE_VALUES; - } - } - - function getSavedOpptyProductsCacheHours() { - try { - const rawPreferences = localStorage.getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY); - - if (!rawPreferences) { - return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; - } - - const preferences = JSON.parse(rawPreferences); - const cacheHours = Number(preferences.opptyProductsCacheHours); - return OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === cacheHours) - ? cacheHours - : DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; - } catch (error) { - return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; - } - } - - function openPreferencesModal() { - const overlay = document.getElementById(MODAL_ID); - - if (!overlay) { - return; - } - - const existingModal = document.getElementById(PREFERENCES_MODAL_ID); - - if (existingModal) { - const savedValues = new Set(getSavedOpportunityTypeValues()); - existingModal.querySelectorAll("input[name='opportunityTypeView']").forEach((checkbox) => { - checkbox.checked = savedValues.has(checkbox.value); - }); - const cacheSelect = existingModal.querySelector("select[name='opptyProductsCacheHours']"); - - if (cacheSelect) { - cacheSelect.value = String(getSavedOpptyProductsCacheHours()); - } - - existingModal.hidden = false; - existingModal.querySelector("input")?.focus(); - return; - } - - const preferencesModal = document.createElement("section"); - preferencesModal.id = PREFERENCES_MODAL_ID; - preferencesModal.className = "opportunities-extension-preferences-modal"; - preferencesModal.setAttribute("role", "dialog"); - preferencesModal.setAttribute("aria-modal", "true"); - preferencesModal.setAttribute("aria-labelledby", "opportunities-extension-preferences-title"); - - const dialog = document.createElement("div"); - dialog.className = "opportunities-extension-preferences-dialog"; - - const header = document.createElement("header"); - header.className = "opportunities-extension-preferences-header"; - - const title = document.createElement("h2"); - title.id = "opportunities-extension-preferences-title"; - title.textContent = "Preferences"; - - const closeButton = document.createElement("button"); - closeButton.type = "button"; - closeButton.className = "opportunities-extension-icon-button"; - closeButton.setAttribute("aria-label", "Fechar preferências"); - closeButton.title = "Close"; - closeButton.append(createCloseIcon()); - closeButton.addEventListener("click", closePreferencesModal); - header.append(title, closeButton); - - const content = document.createElement("div"); - content.className = "opportunities-extension-preferences-content"; - - const fieldset = document.createElement("fieldset"); - fieldset.className = "opportunities-extension-preferences-fieldset"; - - const legend = document.createElement("legend"); - legend.textContent = "Opportunity Type View"; - fieldset.append(legend); - - const selectedValues = new Set(getSavedOpportunityTypeValues()); - const options = document.createElement("div"); - options.className = "opportunities-extension-preferences-options"; - - OPPORTUNITY_TYPE_OPTIONS.forEach((option) => { - const label = document.createElement("label"); - label.className = "opportunities-extension-preferences-option"; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.name = "opportunityTypeView"; - checkbox.value = option.value; - checkbox.checked = selectedValues.has(option.value); - - const text = document.createElement("span"); - text.textContent = option.label; - label.append(checkbox, text); - options.append(label); - }); - - fieldset.append(options); - - const cacheField = document.createElement("label"); - cacheField.className = "opportunities-extension-preferences-cache-field"; - - const cacheLabel = document.createElement("span"); - cacheLabel.textContent = "Product cache duration"; - - const cacheSelect = document.createElement("select"); - cacheSelect.name = "opptyProductsCacheHours"; - cacheSelect.setAttribute("aria-label", "Product cache duration"); - OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => { - const optionElement = document.createElement("option"); - optionElement.value = String(option.value); - optionElement.textContent = option.label; - optionElement.selected = option.value === getSavedOpptyProductsCacheHours(); - cacheSelect.append(optionElement); - }); - cacheField.append(cacheLabel, cacheSelect); - content.append(fieldset, cacheField); - - const footer = document.createElement("footer"); - footer.className = "opportunities-extension-preferences-footer"; - - const saveButton = document.createElement("button"); - saveButton.type = "button"; - saveButton.className = "opportunities-extension-button opportunities-extension-button-primary"; - saveButton.textContent = "Save"; - saveButton.addEventListener("click", () => { - const selectedValues = Array.from(preferencesModal.querySelectorAll("input[name='opportunityTypeView']:checked"), (checkbox) => checkbox.value); - const selectedCacheHours = Number(preferencesModal.querySelector("select[name='opptyProductsCacheHours']")?.value); - selectedOpportunityTypeValues = new Set(selectedValues); - opptyProductsCacheHours = OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === selectedCacheHours) - ? selectedCacheHours - : DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; - - try { - localStorage.setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({ - opportunityTypeValues: selectedValues, - opptyProductsCacheHours - })); - } catch (error) { - // Continue with the saved selection for the active modal when storage is unavailable. - } - - closePreferencesModal(); - const periodSelect = overlay.querySelector("select[name='opportunityPeriod']"); - requestOpportunitiesForPeriod(periodSelect?.value || PERIOD_OPTIONS[0]); - }); - - footer.append(saveButton); - dialog.append(header, content, footer); - preferencesModal.append(dialog); - overlay.append(preferencesModal); - preferencesModal.querySelector("input")?.focus(); - } - - function closePreferencesModal() { - const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID); - - if (preferencesModal) { - preferencesModal.hidden = true; - } - } - - function ensureEscapeKeyHandler() { - if (window[ESCAPE_LISTENER_KEY]) { - return; - } - - window[ESCAPE_LISTENER_KEY] = true; - document.addEventListener("keydown", (event) => { - const modal = document.getElementById(MODAL_ID); - - if (!modal || modal.hidden) { - return; - } - - if (event.ctrlKey && event.key.toLowerCase() === "d") { - event.preventDefault(); - event.stopPropagation(); - toggleDebugPanel(); - return; - } - - if (event.key === "Escape") { - const accountPlansModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); - - if (accountPlansModal && !accountPlansModal.hidden) { - closeAccountPlansModal(); - event.preventDefault(); - return; - } - - const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID); - - if (preferencesModal && !preferencesModal.hidden) { - closePreferencesModal(); - event.preventDefault(); - return; - } - - if (closeCustomerFilterPanel()) { - event.preventDefault(); - return; - } - - if (closeOwnerFilterPanel()) { - event.preventDefault(); - return; - } - - closeOpportunitiesModal(); - } - }); - - document.addEventListener("pointerdown", (event) => { - const modal = document.getElementById(MODAL_ID); - const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); - const ownerPanel = document.getElementById(OWNER_FILTER_PANEL_ID); - - if (!modal || modal.hidden) { - return; - } - - if (panel && !panel.hidden) { - const customerFilter = panel.closest(".opportunities-extension-customer-filter"); - - if (customerFilter && !customerFilter.contains(event.target)) { - closeCustomerFilterPanel(false); - } - } - - if (ownerPanel && !ownerPanel.hidden) { - const ownerFilter = ownerPanel.closest(".opportunities-extension-customer-filter"); - - if (ownerFilter && !ownerFilter.contains(event.target)) { - closeOwnerFilterPanel(false); - } - } - }); - } - - function ensureExtensionStyles() { - if (document.getElementById(STYLE_ID)) { - return; - } - - const style = document.createElement("style"); - style.id = STYLE_ID; - style.textContent = ` - .opportunities-extension-scroll-lock { - overflow: hidden !important; - } - - .opportunities-extension-modal, - .opportunities-extension-modal * { - box-sizing: border-box; - font-family: "Oracle Sans", Arial, Helvetica, sans-serif; - } - - .opportunities-extension-modal { - position: fixed; - inset: 0; - z-index: 2147483647; - background: #f5f4f2; - color: #000000; - } - - .opportunities-extension-modal[hidden] { - display: none !important; - } - - .opportunities-extension-shell { - height: 100vh; - min-height: 0; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - background: #f5f4f2; - } - - .opportunities-extension-header { - position: relative; - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 24px; - padding: 28px 40px 22px; - border-bottom: 1px solid #dedbd7; - background: - linear-gradient(90deg, #8f624a 0 11%, #c74634 11% 23%, #6f5a7f 23% 36%, #00758f 36% 51%, #d4b06a 51% 63%, transparent 63% 100%) top left / 100% 6px no-repeat, - #ffffff; - } - - .opportunities-extension-header::before { - position: absolute; - left: 40px; - bottom: -1px; - width: 64px; - height: 3px; - background: #00758f; - content: ""; - } - - .opportunities-extension-header h1 { - margin: 0; - color: #000000; - font-size: 24px; - font-weight: 700; - line-height: 1.2; - letter-spacing: 0; - } - - .opportunities-extension-header p { - margin: 0; - color: #5f5a55; - font-size: 14px; - line-height: 1.4; - } - - .opportunities-extension-subtitle-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - margin-top: 6px; - } - - .opportunities-extension-auth-badge { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 2px 8px; - border-radius: 999px; - color: #ffffff; - font-size: 12px; - font-weight: 700; - line-height: 1.2; - } - - .opportunities-extension-auth-badge-authenticated { - background: #3f6f17; - } - - .opportunities-extension-auth-badge-unauthenticated { - background: #c5331f; - } - - .opportunities-extension-auth-badge-pending { - background: #6f5a7f; - } - - .opportunities-extension-products-progress { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 2px 8px; - border-radius: 999px; - background: #e8f4f6; - color: #006d7a; - font-size: 12px; - font-weight: 700; - line-height: 1.2; - } - - .opportunities-extension-products-progress[hidden] { - display: none !important; - } - - .opportunities-extension-products-cache-timestamp { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 2px 8px; - border-radius: 999px; - background: #f0eeeb; - color: #5f5a55; - font-size: 11px; - font-weight: 600; - line-height: 1.2; - } - - .opportunities-extension-products-cache-timestamp[hidden] { - display: none !important; - } - - .opportunities-extension-products-progress[data-state="running"]::before { - width: 12px; - height: 12px; - margin-right: 6px; - border: 2px solid currentColor; - border-right-color: transparent; - border-radius: 50%; - content: ""; - animation: opportunities-extension-spin .8s linear infinite; - } - - .opportunities-extension-products-progress[data-state="complete"] { - background: #e2f2e5; - color: #356d19; - } - - .opportunities-extension-products-progress[data-state="complete-with-errors"], - .opportunities-extension-products-progress[data-state="error"] { - background: #f9e3e1; - color: #a52b1c; - } - - @keyframes opportunities-extension-spin { - to { transform: rotate(360deg); } - } - - .opportunities-extension-icon-button { - width: 36px; - height: 36px; - flex: 0 0 36px; - border: 1px solid transparent; - border-radius: 4px; - background: transparent; - color: #312d2a; - font-size: 18px; - font-weight: 600; - line-height: 1; - cursor: pointer; - } - - .opportunities-extension-header-actions { - display: inline-flex; - align-items: center; - gap: 6px; - } - - .opportunities-extension-icon-button svg { - width: 18px; - height: 18px; - vertical-align: middle; - } - - .opportunities-extension-icon-button .opportunities-extension-settings-icon { - width: 20px; - height: 20px; - } - - .opportunities-extension-icon-button:hover, - .opportunities-extension-icon-button:focus { - border-color: #b8b2ad; - background: #f5f4f2; - outline: none; - } - - .opportunities-extension-body { - display: grid; - min-height: 0; - grid-template-rows: auto auto minmax(0, 1fr); - gap: 16px; - padding: 22px 40px 36px; - background: #f5f4f2; - overflow: hidden; - } - - .opportunities-extension-form { - display: flex; - align-items: end; - flex-wrap: wrap; - gap: 24px; - min-height: 82px; - max-width: none; - margin: 0; - padding: 14px 16px; - border: 1px solid #dedbd7; - border-radius: 4px; - background: #ffffff; - } - - .opportunities-extension-field { - display: grid; - max-width: max-content; - min-width: 270px; - width: max-content; - gap: 6px; - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - } - - .opportunities-extension-stage-dashboard { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 12px; - } - - .opportunities-extension-stage-dashboard-card { - display: grid; - gap: 6px; - min-width: 0; - padding: 12px 16px; - border: 1px solid #dedbd7; - border-top: 3px solid #5f5a55; - border-radius: 4px; - background: #ffffff; - } - - .opportunities-extension-stage-dashboard-card span { - color: #5f5a55; - font-size: 11px; - font-weight: 700; - line-height: 1.2; - } - - .opportunities-extension-stage-dashboard-value { - overflow: hidden; - color: #312d2a; - font-size: 22px; - font-weight: 700; - line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; - } - - .opportunities-extension-stage-dashboard-card[data-stage="SQL"] { - border-top-color: #008aa6; - } - - .opportunities-extension-total-dashboard-card { - border-top-color: #312d2a; - } - - .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { - border-top-color: #6f6863; - } - - .opportunities-extension-stage-dashboard-card[data-stage="UPSIDE"] { - border-top-color: #c87b00; - } - - .opportunities-extension-stage-dashboard-card[data-stage="FORECAST"] { - border-top-color: #755b8b; - } - - .opportunities-extension-stage-dashboard-card[data-stage="WON"] { - border-top-color: #4b7f22; - } - - .opportunities-extension-stage-filter { - display: grid; - grid-template-rows: auto 44px; - gap: 6px; - min-width: 0; - } - - .opportunities-extension-stage-filter-label { - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - } - - .opportunities-extension-stage-filter-controls { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - } - - .opportunities-extension-stage-filter-button { - min-height: 36px; - padding: 6px 12px; - border: 1px solid currentColor; - border-radius: 18px; - background: #ffffff; - color: #5f5a55; - font-size: 12px; - font-weight: 700; - letter-spacing: 0; - line-height: 1.2; - cursor: pointer; - } - - .opportunities-extension-stage-filter-button[data-stage="SQL"] { - color: #006b84; - } - - .opportunities-extension-stage-filter-button[data-stage="PIPELINE"] { - color: #5f5a55; - } - - .opportunities-extension-stage-filter-button[data-stage="UPSIDE"] { - color: #945400; - } - - .opportunities-extension-stage-filter-button[data-stage="FORECAST"] { - color: #624e74; - } - - .opportunities-extension-stage-filter-button[data-stage="WON"] { - color: #3f6f17; - } - - .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="SQL"] { - border-color: #006b84; - background: #006b84; - color: #ffffff; - } - - .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="PIPELINE"] { - border-color: #5f5a55; - background: #5f5a55; - color: #ffffff; - } - - .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="UPSIDE"] { - border-color: #945400; - background: #945400; - color: #ffffff; - } - - .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="FORECAST"] { - border-color: #624e74; - background: #624e74; - color: #ffffff; - } - - .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="WON"] { - border-color: #3f6f17; - background: #3f6f17; - color: #ffffff; - } - - .opportunities-extension-stage-filter-button:focus-visible { - outline: 2px solid #00758f; - outline-offset: 2px; - } - - .opportunities-extension-status-filter { - display: grid; - grid-template-rows: auto 44px; - gap: 6px; - min-width: 0; - } - - .opportunities-extension-status-filter-label { - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - } - - .opportunities-extension-status-filter-controls { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - } - - .opportunities-extension-status-filter-button { - min-height: 36px; - padding: 6px 12px; - border: 1px solid currentColor; - border-radius: 18px; - background: #ffffff; - font-size: 12px; - font-weight: 700; - letter-spacing: 0; - line-height: 1.2; - cursor: pointer; - } - - .opportunities-extension-status-filter-button[data-status="OPEN"] { - color: #197a3d; - } - - .opportunities-extension-status-filter-button[data-status="CLOSED"] { - color: #4b5563; - } - - .opportunities-extension-status-filter-button[data-status="LOST"] { - color: #b13b34; - } - - .opportunities-extension-status-filter-button[data-status="WON"] { - color: #1f5f99; - } - - .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="OPEN"] { - border-color: #197a3d; - background: #197a3d; - color: #ffffff; - } - - .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="CLOSED"] { - border-color: #4b5563; - background: #4b5563; - color: #ffffff; - } - - .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="LOST"] { - border-color: #b13b34; - background: #b13b34; - color: #ffffff; - } - - .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="WON"] { - border-color: #1f5f99; - background: #1f5f99; - color: #ffffff; - } - - .opportunities-extension-status-filter-button:focus-visible { - outline: 2px solid #00758f; - outline-offset: 2px; - } - - .opportunities-extension-customer-filter { - position: relative; - display: grid; - grid-template-rows: auto 44px; - gap: 6px; - min-width: 220px; - } - - .opportunities-extension-customer-filter-label { - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - } - - .opportunities-extension-customer-filter-trigger { - position: relative; - min-width: 220px; - min-height: 44px; - padding: 10px 38px 10px 12px; - border: 1px solid #b8b2ad; - border-radius: 3px; - background: #ffffff; - color: #312d2a; - font-size: 14px; - line-height: 1.3; - text-align: left; - cursor: pointer; - } - - .opportunities-extension-customer-filter-trigger::after { - position: absolute; - top: 50%; - right: 16px; - width: 0; - height: 0; - border-top: 6px solid #312d2a; - border-right: 5px solid transparent; - border-left: 5px solid transparent; - content: ""; - pointer-events: none; - transform: translateY(-50%); - } - - .opportunities-extension-customer-filter-trigger:disabled { - cursor: not-allowed; - background: #f5f4f2; - color: #7d7772; - } - - .opportunities-extension-customer-filter-trigger:focus-visible { - border-color: #312d2a; - box-shadow: 0 0 0 1px #312d2a; - outline: none; - } - - .opportunities-extension-customer-filter-panel { - position: absolute; - top: calc(100% + 8px); - left: 0; - z-index: 4; - display: grid; - width: min(360px, calc(100vw - 80px)); - gap: 10px; - padding: 12px; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - box-shadow: 0 4px 12px rgba(0, 0, 0, .18); - } - - .opportunities-extension-customer-filter-panel[hidden] { - display: none !important; - } - - .opportunities-extension-customer-filter-search { - width: 100%; - min-height: 40px; - padding: 8px 10px; - border: 1px solid #b8b2ad; - border-radius: 3px; - background: #ffffff; - color: #312d2a; - font-size: 14px; - } - - .opportunities-extension-customer-filter-search:focus { - border-color: #312d2a; - box-shadow: 0 0 0 1px #312d2a; - outline: none; - } - - .opportunities-extension-customer-filter-select-all, - .opportunities-extension-customer-filter-option { - display: flex; - align-items: center; - gap: 8px; - color: #312d2a; - font-size: 13px; - line-height: 1.35; - } - - .opportunities-extension-customer-filter-select-all { - min-height: 32px; - padding-bottom: 8px; - border-bottom: 1px solid #dedbd7; - font-weight: 700; - } - - .opportunities-extension-customer-filter-panel input[type="checkbox"] { - width: 16px; - height: 16px; - flex: 0 0 16px; - accent-color: #00758f; - } - - .opportunities-extension-customer-filter-list { - display: block; - max-height: 250px; - overflow: auto; - } - - .opportunities-extension-customer-filter-option { - display: grid; - grid-template-columns: 16px minmax(0, 1fr); - align-items: start; - min-height: 0; - height: auto !important; - padding: 8px 4px; - cursor: pointer; - } - - .opportunities-extension-customer-filter-option input[type="checkbox"] { - margin-top: 1px; - } - - .opportunities-extension-customer-filter-option span { - display: block; - min-width: 0; - line-height: 18px; - overflow-wrap: anywhere; - white-space: normal; - } - - .opportunities-extension-customer-filter-option:hover { - background: #f5f4f2; - } - - .opportunities-extension-customer-filter-empty { - margin: 4px 0; - color: #5f5a55; - font-size: 13px; - } - - .opportunities-extension-table-search-field { - display: grid; - grid-template-rows: auto 44px; - gap: 6px; - width: 260px; - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - } - - .opportunities-extension-table-search-field input { - width: 100%; - min-height: 44px; - padding: 10px 12px; - border: 1px solid #b8b2ad; - border-radius: 3px; - background: #ffffff; - color: #312d2a; - font-size: 14px; - line-height: 1.3; - } - - .opportunities-extension-table-search-field input:focus { - border-color: #312d2a; - box-shadow: 0 0 0 1px #312d2a; - outline: none; - } - - .opportunities-extension-select-wrap { - position: relative; - display: block; - width: 270px; - max-width: 100%; - } - - .opportunities-extension-select-wrap::after { - position: absolute; - top: 50%; - right: 16px; - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-top: 6px solid #000000; - content: ""; - pointer-events: none; - transform: translateY(-50%); - } - - .opportunities-extension-field select { - width: 100%; - min-width: 0; - max-width: 100%; - min-height: 44px; - box-sizing: border-box; - appearance: none; - border: 1px solid #b8b2ad; - border-radius: 3px; - background: #ffffff; - color: #000000; - font-size: 14px; - line-height: 1.3; - padding: 11px 44px 11px 12px; - } - - .opportunities-extension-field select:focus { - border-color: #312d2a; - box-shadow: 0 0 0 1px #312d2a; - outline: none; - } - - .opportunities-extension-table-surface { - min-height: 0; - height: 100%; - border: 1px solid #dedbd7; - border-radius: 4px; - background: #ffffff; - overflow-x: auto; - overflow-y: auto; - } - - .opportunities-extension-table { - width: 100%; - min-width: 0; - border-collapse: collapse; - table-layout: fixed; - color: #312d2a; - font-size: 13px; - line-height: 1.35; - } - - .opportunities-extension-table thead { - background: #faf9f8; - } - - .opportunities-extension-table th { - position: sticky; - top: 0; - z-index: 1; - height: 44px; - border-bottom: 1px solid #b8b2ad; - background: #faf9f8; - color: #312d2a; - font-size: 12px; - font-weight: 700; - text-align: left; - white-space: nowrap; - } - - .opportunities-extension-table td { - min-height: 48px; - padding: 12px 16px; - border-bottom: 1px solid #ebe8e5; - vertical-align: middle; - overflow-wrap: anywhere; - } - - .opportunities-extension-table th:nth-child(1) { - width: 16%; - } - - .opportunities-extension-table th:nth-child(2) { - width: 8%; - } - - .opportunities-extension-table th:nth-child(3) { - width: 18%; - } - - .opportunities-extension-table th:nth-child(4) { - width: 11%; - } - - .opportunities-extension-table th:nth-child(5) { - width: 8%; - } - - .opportunities-extension-table th:nth-child(6) { - width: 8%; - } - - .opportunities-extension-table th:nth-child(7) { - width: 7%; - } - - .opportunities-extension-table th:nth-child(8) { - width: 6%; - } - - .opportunities-extension-table th:nth-child(9) { - width: 7%; - } - - .opportunities-extension-table th:nth-child(10) { - width: 7%; - } - - .opportunities-extension-table th:nth-child(11) { - width: 4%; - } - - .opportunities-extension-table td:nth-child(2), - .opportunities-extension-table td:nth-child(5), - .opportunities-extension-table td:nth-child(6), - .opportunities-extension-table td:nth-child(8) { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .opportunities-extension-table td:nth-child(9) { - padding-right: 6px; - padding-left: 6px; - white-space: nowrap; - overflow: visible; - } - - .opportunities-extension-table td:nth-child(7) { - padding-right: 6px; - padding-left: 6px; - white-space: nowrap; - overflow: visible; - } - - .opportunities-extension-table td:nth-child(10) { - padding-right: 6px; - padding-left: 6px; - font-size: 12px; - white-space: nowrap; - } - - .opportunities-extension-table td:nth-child(11) { - padding-right: 6px; - padding-left: 6px; - text-align: center; - white-space: nowrap; - } - - .opportunities-extension-table tbody tr:hover { - background: #f7f6f4; - } - - .opportunities-extension-table tbody tr:last-child td { - border-bottom: 0; - } - - .opportunities-extension-table a { - color: #006b84; - text-decoration: underline; - text-decoration-thickness: 1px; - text-underline-offset: 2px; - } - - .opportunities-extension-table a:hover { - color: #004f63; - } - - .opportunities-extension-opty-number-content { - display: inline-flex; - align-items: center; - gap: 6px; - max-width: 100%; - } - - .opportunities-extension-name-content { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - } - - .opportunities-extension-name-content > a { - min-width: 0; - } - - .opportunities-extension-actions-content { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 4px; - white-space: nowrap; - } - - .opportunities-extension-products-count { - display: inline-grid; - width: auto; - min-width: 20px; - height: 20px; - place-items: center; - flex: 0 0 auto; - box-sizing: border-box; - padding: 0 3px; - border: 1px solid #7ca7b2; - border-radius: 999px; - background: #e8f4f6; - color: #006d7a; - font-size: 10px; - font-weight: 700; - line-height: 1; - text-align: center; - vertical-align: middle; - cursor: pointer; - } - - .opportunities-extension-products-count:hover, - .opportunities-extension-products-count:focus-visible, - .opportunities-extension-products-count[aria-expanded="true"] { - border-color: #00758f; - background: #00758f; - color: #ffffff; - outline: none; - } - - .opportunities-extension-products-count-error { - border-color: #d8887e; - background: #f9e3e1; - color: #a52b1c; - } - - .opportunities-extension-products-detail-row:hover { - background: transparent !important; - } - - .opportunities-extension-products-detail-cell { - padding: 0 !important; - } - - .opportunities-extension-products-detail { - padding: 14px 20px 18px; - border-bottom: 1px solid #c9c5c1; - background: #f0eeeb; - } - - .opportunities-extension-products-detail-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 10px; - 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; - } - - .opportunities-extension-products-detail-heading span { - color: #5f5a55; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - } - - .opportunities-extension-products-table { - width: 100%; - table-layout: fixed; - border-collapse: collapse; - border: 1px solid #dedbd7; - background: #ffffff; - font-size: 12px; - } - - .opportunities-extension-products-table th, - .opportunities-extension-products-table td { - position: static; - height: auto; - padding: 8px 10px; - border-bottom: 1px solid #ebe8e5; - background: transparent; - overflow-wrap: anywhere; - vertical-align: middle; - white-space: normal; - } - - .opportunities-extension-products-table th { - background: #faf9f8; - font-size: 11px; - } - - .opportunities-extension-products-table th:nth-child(1) { width: 16%; } - .opportunities-extension-products-table th:nth-child(2) { width: 16%; } - .opportunities-extension-products-table th:nth-child(3) { width: 8%; } - .opportunities-extension-products-table th:nth-child(4) { width: 10%; } - .opportunities-extension-products-table th:nth-child(5) { width: 8%; } - .opportunities-extension-products-table th:nth-child(6) { width: 8%; } - .opportunities-extension-products-table th:nth-child(7) { width: 7%; } - .opportunities-extension-products-table th:nth-child(8) { width: 10%; } - .opportunities-extension-products-table th:nth-child(9) { width: 12%; } - .opportunities-extension-products-table th:nth-child(10) { width: 5%; } - - .opportunities-extension-products-empty { - margin: 0; - padding: 10px 0; - color: #5f5a55; - font-size: 13px; - } - - .opportunities-extension-copy-button { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - width: 24px; - height: 24px; - padding: 3px; - border: 0; - border-radius: 3px; - background: transparent; - color: #00758f; - cursor: pointer; - } - - .opportunities-extension-copy-button svg { - width: 16px; - height: 16px; - fill: currentColor; - } - - .opportunities-extension-copy-button:hover, - .opportunities-extension-copy-button:focus-visible { - background: #e8f4f6; - color: #004f63; - outline: none; - } - - .opportunities-extension-action-button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 5px; - border: 1px solid transparent; - border-radius: 4px; - background: transparent; - color: #00758f; - cursor: pointer; - } - - .opportunities-extension-action-button svg { - width: 21px; - height: 21px; - } - - .opportunities-extension-action-button:hover, - .opportunities-extension-action-button:focus-visible { - border-color: #7ca7b2; - background: #e8f4f6; - color: #004f63; - outline: none; - } - - .opportunities-extension-action-button:disabled { - cursor: wait; - opacity: .72; - } - - .opportunities-extension-action-button-loading svg { - animation: opportunities-extension-pulse 1s ease-in-out infinite; - } - - @keyframes opportunities-extension-pulse { - 50% { opacity: .35; } - } - - .opportunities-extension-copy-toast { - position: fixed; - right: 28px; - bottom: 28px; - z-index: 3; - max-width: min(360px, calc(100vw - 56px)); - padding: 12px 16px; - border-radius: 4px; - background: #2f6f18; - box-shadow: 0 4px 12px rgba(0, 0, 0, .18); - color: #ffffff; - font-size: 13px; - font-weight: 700; - } - - .opportunities-extension-copy-toast-error { - background: #a52b1c; - } - - .opportunities-extension-copy-toast[hidden] { - display: none !important; - } - - .opportunities-extension-stage-badge { - display: inline-flex; - align-items: center; - min-height: 24px; - padding: 3px 8px; - border-radius: 12px; - background: #ebe8e5; - color: #312d2a; - font-size: 11px; - font-weight: 700; - line-height: 1.2; - white-space: nowrap; - } - - .opportunities-extension-stage-badge[data-stage="SQL"] { - background: #d9f0f5; - color: #006b84; - } - - .opportunities-extension-stage-badge[data-stage="PIPELINE"] { - background: #ebe8e5; - color: #5f5a55; - } - - .opportunities-extension-stage-badge[data-stage="UPSIDE"] { - background: #fff0d8; - color: #945400; - } - - .opportunities-extension-stage-badge[data-stage="FORECAST"] { - background: #eee8f5; - color: #624e74; - } - - .opportunities-extension-stage-badge[data-stage="WON"] { - background: #e5f1d9; - color: #3f6f17; - } - - .opportunities-extension-status-badge { - display: inline-flex; - align-items: center; - min-height: 24px; - padding: 3px 8px; - border-radius: 12px; - background: #ebe8e5; - color: #312d2a; - font-size: 11px; - font-weight: 700; - line-height: 1.2; - white-space: nowrap; - } - - .opportunities-extension-status-badge[data-status="OPEN"] { - background: #e2f2e5; - color: #197a3d; - } - - .opportunities-extension-status-badge[data-status="CLOSED"] { - background: #e5e7eb; - color: #4b5563; - } - - .opportunities-extension-status-badge[data-status="LOST"] { - background: #f9e3e1; - color: #b13b34; - } - - .opportunities-extension-status-badge[data-status="WON"] { - background: #e3edf8; - color: #1f5f99; - } - - .opportunities-extension-sort-button { - position: relative; - display: inline-flex; - align-items: center; - width: 100%; - min-height: 44px; - padding: 8px 42px 8px 16px; - border: 0; - background: transparent; - color: inherit; - font: inherit; - font-weight: inherit; - letter-spacing: 0; - line-height: 1.2; - text-align: left; - white-space: normal; - cursor: pointer; - } - - .opportunities-extension-table-header-label { - display: inline-flex; - align-items: center; - min-height: 44px; - padding: 8px 6px; - } - - .opportunities-extension-sort-button::after { - position: absolute; - right: 15px; - color: #5f5a55; - content: "↕"; - font-size: 16px; - font-weight: 400; - } - - .opportunities-extension-sort-button[data-sort-direction="ascending"]::after { - color: #00758f; - content: "↑"; - } - - .opportunities-extension-sort-button[data-sort-direction="descending"]::after { - color: #00758f; - content: "↓"; - } - - .opportunities-extension-sort-button:hover { - background: #f0eeeb; - } - - .opportunities-extension-sort-button:focus-visible { - outline: 2px solid #00758f; - outline-offset: -2px; - } - - .opportunities-extension-table-status { - height: 152px; - color: #5f5a55; - font-size: 14px; - text-align: center; - } - - .opportunities-extension-visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; - } - - .opportunities-extension-preferences-modal { - position: absolute; - inset: 0; - z-index: 3; - display: grid; - place-items: center; - padding: 24px; - background: rgba(0, 0, 0, .42); - } - - .opportunities-extension-preferences-modal[hidden] { - display: none !important; - } - - .opportunities-extension-account-plans-modal { - position: absolute; - inset: 0; - z-index: 4; - display: grid; - place-items: center; - padding: 12px; - background: rgba(0, 0, 0, .42); - } - - .opportunities-extension-account-plans-modal[hidden] { - display: none !important; - } - - .opportunities-extension-account-plans-dialog { - display: grid; - width: min(1180px, 100%); - height: min(760px, calc(100vh - 24px)); - min-width: 0; - grid-template-rows: auto minmax(0, 1fr); - grid-template-columns: minmax(0, 1fr); - overflow: hidden; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - box-shadow: 0 8px 24px rgba(0, 0, 0, .24); - color: #312d2a; - } - - .opportunities-extension-account-plans-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - padding: 14px 20px; - border-bottom: 1px solid #dedbd7; - } - - .opportunities-extension-account-plans-header h2 { - margin: 0; - font-size: 20px; - line-height: 1.2; - } - - .opportunities-extension-account-plans-header p { - margin: 5px 0 0; - color: #5f5a55; - font-size: 13px; - line-height: 1.35; - } - - .opportunities-extension-account-plans-content { - display: grid; - min-width: 0; - grid-template-rows: auto minmax(0, 1fr); - grid-template-columns: minmax(0, 1fr); - min-height: 0; - overflow: hidden; - } - - .opportunities-extension-account-plans-content > .opportunities-extension-account-plans-skeleton { - grid-row: 1 / -1; - } - - .opportunities-extension-account-plans-tabs-navigation { - position: relative; - min-width: 0; - border-bottom: 1px solid #dedbd7; - } - - .opportunities-extension-account-plans-tabs-navigation .opportunities-extension-account-plans-tab-navigation-button[hidden] { - display: none; - } - - .opportunities-extension-account-plans-tabs { - display: flex; - min-width: 0; - gap: 4px; - overflow-x: auto; - padding: 8px 20px 0; - scrollbar-width: none; - } - - .opportunities-extension-account-plans-tabs::-webkit-scrollbar { - display: none; - } - - .opportunities-extension-account-plans-tab-navigation-button { - display: grid; - position: absolute; - z-index: 1; - top: 50%; - width: 32px; - height: 32px; - place-items: center; - padding: 0; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - color: #312d2a; - box-shadow: 0 2px 6px rgba(0, 0, 0, .16); - transform: translateY(-50%); - cursor: pointer; - } - - .opportunities-extension-account-plans-tab-navigation-button:first-child { - left: 8px; - } - - .opportunities-extension-account-plans-tab-navigation-button:last-child { - right: 8px; - } - - .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) { - border-color: #00758f; - background: #f0eeeb; - color: #005e73; - } - - .opportunities-extension-account-plans-tab-navigation-button:disabled { - border-color: #dedbd7; - color: #b8b2ad; - cursor: default; - } - - .opportunities-extension-account-plans-tab-navigation-button:focus-visible { - outline: 2px solid #00758f; - outline-offset: 2px; - } - - .opportunities-extension-account-plans-tab-navigation-button svg { - width: 18px; - height: 18px; - } - - .opportunities-extension-account-plans-panels { - min-width: 0; - min-height: 0; - overflow: hidden; - } - - .opportunities-extension-account-plan-tab { - min-height: 38px; - flex: 0 0 auto; - padding: 7px 12px; - border: 0; - border-bottom: 3px solid transparent; - background: transparent; - color: #5f5a55; - font-size: 13px; - font-weight: 600; - cursor: pointer; - } - - .opportunities-extension-account-plan-tab:hover { - background: #f0eeeb; - color: #312d2a; - } - - .opportunities-extension-account-plan-tab[aria-selected="true"] { - border-bottom-color: #00758f; - color: #006d7a; - } - - .opportunities-extension-account-plan-tab:focus-visible { - outline: 2px solid #00758f; - outline-offset: -2px; - } - - .opportunities-extension-account-plan-panel { - display: grid; - height: 100%; - min-height: 0; - grid-template-rows: auto minmax(0, 1fr); - box-sizing: border-box; - padding: 16px 20px; - } - - .opportunities-extension-account-plan-panel[hidden] { - display: none !important; - } - - .opportunities-extension-account-plan-details { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0 24px; - margin: 0; - } - - .opportunities-extension-account-plan-details > div { - display: grid; - grid-template-columns: minmax(104px, 38%) minmax(0, 1fr); - gap: 10px; - min-height: 42px; - align-items: center; - padding: 7px 0; - border-bottom: 1px solid #ebe8e5; - } - - .opportunities-extension-account-plan-details dt { - color: #5f5a55; - font-size: 12px; - font-weight: 700; - } - - .opportunities-extension-account-plan-details dd { - min-width: 0; - margin: 0; - color: #312d2a; - font-size: 13px; - overflow-wrap: anywhere; - } - - .opportunities-extension-consumption-trend { - display: grid; - min-height: 0; - grid-template-rows: auto minmax(0, 1fr); - margin-top: 14px; - padding-top: 12px; - border-top: 1px solid #dedbd7; - } - - .opportunities-extension-consumption-trend-header { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 24px; - } - - .opportunities-extension-consumption-periods { - display: flex; - align-items: center; - gap: 4px; - } - - .opportunities-extension-consumption-periods button { - min-height: 38px; - padding: 7px 14px; - border: 1px solid transparent; - border-radius: 4px; - background: transparent; - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.2; - cursor: pointer; - } - - .opportunities-extension-consumption-periods button:hover { - background: #f0eeeb; - } - - .opportunities-extension-consumption-periods button[aria-pressed="true"] { - border-color: #00758f; - background: #e7f6f8; - color: #005e73; - } - - .opportunities-extension-consumption-periods button:focus-visible, - .opportunities-extension-consumption-navigation-button:focus-visible { - outline: 2px solid #00758f; - outline-offset: 2px; - } - - .opportunities-extension-consumption-chart { - display: grid; - min-height: 0; - grid-template-rows: auto minmax(0, 1fr); - margin-top: 4px; - } - - .opportunities-extension-consumption-navigation { - display: grid; - grid-template-columns: 36px minmax(130px, auto) 36px; - align-items: center; - justify-content: center; - gap: 8px; - min-height: 40px; - margin-bottom: 2px; - } - - .opportunities-extension-consumption-navigation strong { - color: #312d2a; - font-size: 13px; - text-align: center; - } - - .opportunities-extension-consumption-navigation-button { - display: grid; - width: 36px; - height: 36px; - place-items: center; - padding: 0; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - color: #312d2a; - cursor: pointer; - } - - .opportunities-extension-consumption-navigation-button:hover:not(:disabled) { - border-color: #00758f; - background: #f0eeeb; - color: #005e73; - } - - .opportunities-extension-consumption-navigation-button:disabled { - border-color: #dedbd7; - color: #b8b2ad; - cursor: default; - } - - .opportunities-extension-consumption-navigation-button svg { - width: 18px; - height: 18px; - } - - .opportunities-extension-consumption-figure { - position: relative; - min-width: 0; - min-height: 0; - margin: 0; - scrollbar-width: none; - } - - .opportunities-extension-consumption-figure::-webkit-scrollbar { - display: none; - } - - .opportunities-extension-consumption-visual { - display: grid; - height: 100%; - min-height: 0; - grid-template-columns: minmax(0, 1fr) 176px; - align-items: center; - gap: 16px; - } - - .opportunities-extension-consumption-svg { - display: block; - width: 100%; - height: 300px; - min-height: 0; - overflow: visible; - } - - .opportunities-extension-consumption-svg .consumption-grid-line { - stroke: #dedbd7; - stroke-width: 1; - shape-rendering: crispEdges; - } - - .opportunities-extension-consumption-svg .consumption-axis-label, - .opportunities-extension-consumption-svg .consumption-x-label { - fill: #5f5a55; - font-family: Arial, sans-serif; - font-size: 12px; - } - - .opportunities-extension-consumption-svg .consumption-value-label { - fill: #312d2a; - font-family: Arial, sans-serif; - font-size: 12px; - font-weight: 700; - } - - .opportunities-extension-consumption-svg .consumption-bar-funded { - fill: #b77888; - } - - .opportunities-extension-consumption-svg .consumption-bar-overage { - fill: #de7907; - } - - .opportunities-extension-consumption-svg .consumption-average-line { - stroke: #de7907; - stroke-width: 1.5; - stroke-dasharray: 6 6; - } - - .opportunities-extension-consumption-svg .consumption-bar-group { - cursor: default; - outline: none; - } - - .opportunities-extension-consumption-svg .consumption-bar-group rect { - transition: filter .16s ease, opacity .16s ease, stroke .16s ease; - } - - .opportunities-extension-consumption-svg .consumption-bar-group:hover rect, - .opportunities-extension-consumption-svg .consumption-bar-group:focus rect { - filter: brightness(1.06); - stroke: #312d2a; - stroke-width: 1.5; - } - - .opportunities-extension-consumption-svg .consumption-animated-label { - opacity: 0; - animation: opportunities-extension-consumption-label-in .24s ease-out forwards; - } - - .opportunities-extension-consumption-svg .consumption-animated-average { - opacity: 0; - animation: opportunities-extension-consumption-average-in .35s .32s ease-out forwards; - } - - @keyframes opportunities-extension-consumption-label-in { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: translateY(0); } - } - - @keyframes opportunities-extension-consumption-average-in { - from { opacity: 0; } - to { opacity: 1; } - } - - .opportunities-extension-consumption-legend { - display: grid; - align-content: center; - gap: 4px; - } - - .opportunities-extension-consumption-legend button { - display: grid; - min-width: 0; - grid-template-columns: 16px minmax(0, 1fr); - align-items: center; - gap: 8px; - padding: 6px 4px; - border: 0; - border-radius: 4px; - background: transparent; - color: #312d2a; - font-size: 12px; - line-height: 1.25; - text-align: left; - cursor: pointer; - } - - .opportunities-extension-consumption-legend button:hover { - background: #f0eeeb; - } - - .opportunities-extension-consumption-legend button:focus-visible { - outline: 2px solid #00758f; - outline-offset: 1px; - } - - .opportunities-extension-consumption-legend button[aria-pressed="false"] { - color: #77726d; - opacity: .55; - } - - .opportunities-extension-consumption-legend-swatch { - display: block; - width: 12px; - height: 12px; - justify-self: center; - } - - .opportunities-extension-consumption-legend-funded { - background: #b77888; - } - - .opportunities-extension-consumption-legend-overage { - background: #de7907; - } - - .opportunities-extension-consumption-legend-average { - height: 0; - border-top: 2px dashed #de7907; - } - - .opportunities-extension-consumption-tooltip { - position: absolute; - z-index: 2; - display: grid; - min-width: 190px; - max-width: 260px; - gap: 4px; - padding: 10px 12px; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - box-shadow: 0 4px 12px rgba(0, 0, 0, .2); - color: #312d2a; - font-size: 12px; - line-height: 1.3; - pointer-events: none; - } - - .opportunities-extension-consumption-tooltip[hidden] { - display: none !important; - } - - .opportunities-extension-consumption-tooltip strong { - margin-bottom: 2px; - font-size: 13px; - } - - .opportunities-extension-consumption-chart-skeleton { - height: 100%; - min-height: 0; - padding: 34px 62px 24px; - box-sizing: border-box; - } - - .opportunities-extension-consumption-chart-skeleton-plot { - display: flex; - height: 100%; - min-height: 220px; - align-items: flex-end; - justify-content: space-around; - gap: 12px; - padding: 0 14px; - border-bottom: 1px solid #dedbd7; - border-left: 1px solid #dedbd7; - } - - .opportunities-extension-consumption-chart-skeleton-plot span { - width: min(38px, 6%); - border-radius: 2px 2px 0 0; - background: #dedbd7; - animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; - } - - .opportunities-extension-consumption-chart-empty, - .opportunities-extension-consumption-chart-error { - display: grid; - min-height: 220px; - place-items: center; - margin: 0; - color: #5f5a55; - font-size: 14px; - text-align: center; - } - - .opportunities-extension-consumption-chart-error { - color: #9f2d20; - } - - .opportunities-extension-account-plan-status-indicator { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 50%; - background: #9b9691; - box-shadow: 0 0 0 3px #efedeb; - vertical-align: middle; - } - - .opportunities-extension-account-plan-status-indicator[data-active="true"] { - background: #3f6f17; - box-shadow: 0 0 0 3px #e2f2e5; - } - - .opportunities-extension-account-plan-tab .opportunities-extension-account-plan-status-indicator { - margin-right: 8px; - } - - .opportunities-extension-account-plans-skeleton-tabs { - display: flex; - min-height: 46px; - align-items: center; - padding: 0 20px; - border-bottom: 1px solid #dedbd7; - } - - .opportunities-extension-account-plans-skeleton { - display: grid; - height: 100%; - min-height: 0; - grid-template-rows: auto minmax(0, 1fr); - overflow: hidden; - } - - .opportunities-extension-account-plans-skeleton-panel { - padding: 16px 20px; - } - - .opportunities-extension-account-plans-skeleton-tab { - display: inline-flex; - align-items: center; - gap: 9px; - min-height: 38px; - padding: 0 12px; - border-bottom: 3px solid #00758f; - } - - .opportunities-extension-account-plans-skeleton-status { - display: block; - width: 10px; - height: 10px; - flex: 0 0 auto; - border-radius: 50%; - background: #dedbd7; - box-shadow: 0 0 0 3px #efedeb; - animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; - } - - .opportunities-extension-account-plans-skeleton-details { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0 24px; - padding: 0; - } - - .opportunities-extension-account-plans-skeleton-details > div { - display: grid; - grid-template-columns: minmax(104px, 38%) minmax(0, 1fr); - align-items: center; - gap: 10px; - min-height: 42px; - padding: 7px 0; - border-bottom: 1px solid #ebe8e5; - } - - .opportunities-extension-account-plans-skeleton-bar { - display: block; - height: 12px; - border-radius: 3px; - background: #dedbd7; - animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; - } - - .opportunities-extension-account-plans-skeleton-tabs .opportunities-extension-account-plans-skeleton-bar { - height: 14px; - } - - .opportunities-extension-account-plans-skeleton-label { - width: 74%; - height: 10px; - } - - .opportunities-extension-account-plans-skeleton-trend { - display: grid; - min-height: 0; - grid-template-rows: auto auto minmax(0, 1fr); - margin: 14px 0 0; - padding-top: 12px; - border-top: 1px solid #dedbd7; - } - - .opportunities-extension-account-plans-skeleton-trend-header { - display: flex; - justify-content: flex-end; - min-height: 38px; - } - - .opportunities-extension-account-plans-skeleton-periods { - display: flex; - align-items: center; - gap: 16px; - } - - .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar { - height: 12px; - } - - .opportunities-extension-account-plans-skeleton-period-active { - height: 38px !important; - border: 1px solid #8cc8d3; - background: #e7f6f8; - } - - .opportunities-extension-account-plans-skeleton-navigation { - display: grid; - grid-template-columns: 32px 124px 32px; - align-items: center; - justify-content: center; - gap: 8px; - min-height: 54px; - } - - .opportunities-extension-account-plans-skeleton-navigation .opportunities-extension-account-plans-skeleton-bar { - justify-self: center; - } - - .opportunities-extension-account-plans-skeleton-chart { - display: grid; - min-height: 0; - grid-template-columns: minmax(0, 1fr) 150px; - align-items: center; - gap: 28px; - padding: 12px 14px 0 62px; - } - - .opportunities-extension-account-plans-skeleton-plot { - display: flex; - position: relative; - height: 300px; - min-height: 0; - box-sizing: border-box; - align-items: flex-end; - justify-content: space-around; - gap: 12px; - padding: 0 14px 64px; - background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #ebe8e5 58px, #ebe8e5 59px) left top / 100% 236px no-repeat; - } - - .opportunities-extension-account-plans-skeleton-plot::before, - .opportunities-extension-account-plans-skeleton-plot::after { - position: absolute; - display: block; - content: ""; - background: #dedbd7; - } - - .opportunities-extension-account-plans-skeleton-plot::before { - top: 0; - bottom: 64px; - left: 0; - width: 1px; - } - - .opportunities-extension-account-plans-skeleton-plot::after { - right: 0; - bottom: 64px; - left: 0; - height: 1px; - } - - .opportunities-extension-account-plans-skeleton-plot .opportunities-extension-account-plans-skeleton-bar { - width: min(38px, 7%); - flex: 0 1 38px; - border-radius: 2px 2px 0 0; - } - - .opportunities-extension-account-plans-skeleton-legend { - display: grid; - align-content: center; - gap: 18px; - min-height: 0; - } - - .opportunities-extension-account-plans-skeleton-legend .opportunities-extension-account-plans-skeleton-bar { - height: 11px; - } - - @keyframes opportunities-extension-skeleton-pulse { - from { opacity: .48; } - to { opacity: 1; } - } - - @media (prefers-reduced-motion: reduce) { - .opportunities-extension-account-plans-skeleton-bar, - .opportunities-extension-consumption-chart-skeleton-plot span, - .opportunities-extension-consumption-svg .consumption-animated-label, - .opportunities-extension-consumption-svg .consumption-animated-average { - animation: none; - opacity: 1; - } - } - - .opportunities-extension-account-plans-empty, - .opportunities-extension-account-plans-error { - margin: 0; - padding: 28px 20px; - color: #5f5a55; - font-size: 14px; - } - - .opportunities-extension-account-plans-error { - color: #9f2d20; - } - - .opportunities-extension-preferences-dialog { - display: grid; - width: min(680px, 100%); - max-height: min(720px, calc(100vh - 48px)); - grid-template-rows: auto minmax(0, 1fr) auto; - overflow: hidden; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - box-shadow: 0 8px 24px rgba(0, 0, 0, .24); - color: #312d2a; - } - - .opportunities-extension-preferences-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 18px 20px; - border-bottom: 1px solid #dedbd7; - } - - .opportunities-extension-preferences-header h2 { - margin: 0; - font-size: 20px; - font-weight: 700; - line-height: 1.2; - } - - .opportunities-extension-preferences-content { - min-height: 0; - overflow: auto; - padding: 20px; - } - - .opportunities-extension-preferences-fieldset { - min-width: 0; - margin: 0; - padding: 0; - border: 0; - } - - .opportunities-extension-preferences-fieldset legend { - margin-bottom: 12px; - padding: 0; - color: #312d2a; - font-size: 15px; - font-weight: 700; - line-height: 1.3; - } - - .opportunities-extension-preferences-options { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 4px 20px; - } - - .opportunities-extension-preferences-option { - display: flex; - align-items: flex-start; - gap: 10px; - min-width: 0; - padding: 9px 8px; - border-radius: 4px; - color: #312d2a; - font-size: 14px; - line-height: 1.35; - cursor: pointer; - } - - .opportunities-extension-preferences-option:hover { - background: #f0eeeb; - } - - .opportunities-extension-preferences-option input { - width: 18px; - height: 18px; - flex: 0 0 18px; - margin: 0; - accent-color: #00758f; - cursor: pointer; - } - - .opportunities-extension-preferences-cache-field { - display: grid; - width: min(280px, 100%); - gap: 6px; - margin-top: 24px; - color: #312d2a; - font-size: 14px; - font-weight: 700; - } - - .opportunities-extension-preferences-cache-field select { - width: 100%; - height: 40px; - padding: 0 36px 0 12px; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - color: #312d2a; - font-size: 14px; - font-weight: 400; - } - - .opportunities-extension-preferences-cache-field select:focus { - border-color: #00758f; - outline: 2px solid #bde7ee; - outline-offset: 0; - } - - .opportunities-extension-preferences-footer { - display: flex; - justify-content: flex-end; - padding: 14px 20px; - border-top: 1px solid #dedbd7; - } - - .opportunities-extension-button { - min-width: 88px; - min-height: 36px; - padding: 7px 16px; - border: 1px solid transparent; - border-radius: 4px; - font-size: 14px; - font-weight: 600; - line-height: 1.2; - cursor: pointer; - } - - .opportunities-extension-button-primary { - border-color: #00758f; - background: #00758f; - color: #ffffff; - } - - .opportunities-extension-button-primary:hover, - .opportunities-extension-button-primary:focus-visible { - border-color: #005e73; - background: #005e73; - outline: none; - } - - .opportunities-extension-debug-panel { - position: fixed; - right: 24px; - bottom: 24px; - z-index: 1; - width: min(640px, calc(100vw - 48px)); - max-height: min(560px, calc(100vh - 120px)); - overflow: auto; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - box-shadow: 0 2px 8px rgba(0, 0, 0, .16); - padding: 16px; - color: #000000; - } - - .opportunities-extension-debug-panel[hidden] { - display: none !important; - } - - .opportunities-extension-debug-panel h2 { - margin: 0; - font-size: 18px; - font-weight: 700; - line-height: 1.25; - } - - .opportunities-extension-debug-panel p { - margin: 4px 0 0; - color: #5f5a55; - font-size: 13px; - } - - .opportunities-extension-debug-panel-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; - } - - .opportunities-extension-debug-visibility-button { - width: 34px; - height: 34px; - flex: 0 0 34px; - color: #312d2a; - } - - .opportunities-extension-debug-visibility-button svg { - width: 20px; - height: 20px; - } - - .opportunities-extension-debug-list { - display: grid; - gap: 10px; - } - - .opportunities-extension-debug-item { - border: 1px solid #dedbd7; - border-left-width: 4px; - border-radius: 4px; - background: #faf9f8; - } - - .opportunities-extension-debug-item[open] { - background: #ffffff; - } - - .opportunities-extension-debug-item-success { - border-left-color: #3f6f17; - } - - .opportunities-extension-debug-item-success .opportunities-extension-debug-status { - background: #e2f2e5; - color: #356d19; - } - - .opportunities-extension-debug-item-failed { - border-left-color: #c5331f; - } - - .opportunities-extension-debug-item-failed .opportunities-extension-debug-status { - background: #f9e3e1; - color: #a52b1c; - } - - .opportunities-extension-debug-item-pending { - border-left-color: #6f5a7f; - } - - .opportunities-extension-debug-item-pending .opportunities-extension-debug-status { - background: #eee8f2; - color: #6f5a7f; - } - - .opportunities-extension-debug-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-height: 42px; - padding: 9px 12px 9px 10px; - cursor: pointer; - list-style: none; - } - - .opportunities-extension-debug-heading::-webkit-details-marker { - display: none; - } - - .opportunities-extension-debug-heading::before { - content: "⌄"; - width: 14px; - color: #5f5a55; - font-size: 16px; - line-height: 1; - transform: rotate(-90deg); - transition: transform .15s ease; - } - - .opportunities-extension-debug-item[open] .opportunities-extension-debug-heading::before { - transform: rotate(0deg); - } - - .opportunities-extension-debug-summary-meta { - display: inline-flex; - align-items: center; - gap: 8px; - margin-left: auto; - } - - .opportunities-extension-debug-http-status, - .opportunities-extension-debug-status { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 0 8px; - border-radius: 999px; - font-size: 11px; - font-weight: 700; - line-height: 1; - text-transform: uppercase; - } - - .opportunities-extension-debug-http-status { - background: #e8f4f6; - color: #006d7a; - } - - .opportunities-extension-debug-status { - background: #ebe8e5; - color: #5f5a55; - } - - .opportunities-extension-debug-heading strong { - font-size: 13px; - } - - .opportunities-extension-debug-item pre { - margin: 0; - padding: 8px 12px 12px; - color: #312d2a; - font-family: Consolas, "Courier New", monospace; - font-size: 12px; - line-height: 1.45; - white-space: pre-wrap; - word-break: break-word; - } - - .opportunities-extension-debug-empty { - padding: 14px; - border: 1px dashed #b8b2ad; - border-radius: 4px; - color: #5f5a55; - font-size: 13px; - } - - .opportunities-extension-modal[data-theme="dark"] { - background: #1f1f1f; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-shell, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-body { - background: #1f1f1f; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-header { - border-bottom-color: #4e4a46; - background: - linear-gradient(90deg, #a8795d 0 11%, #dd5a47 11% 23%, #876e9b 23% 36%, #00a6be 36% 51%, #dfbd76 51% 63%, transparent 63% 100%) top left / 100% 6px no-repeat, - #292827; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-header h1, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-value, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-field, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-label, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-label, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-label, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-search-field { - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-header p, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header p, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card span, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-empty, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail-heading span, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-empty, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-empty, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details dt, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel p, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-empty { - color: #c9c5c1; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-error { - color: #ffb4a8; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-error { - color: #ffb4a8; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-bar { - background: #5b5753; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-status { - background: #5b5753; - box-shadow: 0 0 0 3px #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-period-active { - border-color: #3f7580; - background: #23474e; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot span { - background: #5b5753; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator { - background: #8f8a85; - box-shadow: 0 0 0 3px #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator[data-active="true"] { - background: #7fb05b; - box-shadow: 0 0 0 3px #314527; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-form, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-surface, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-panel, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-dialog, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-dialog, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] { - border-color: #4e4a46; - background: #292827; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-trigger, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-search, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-search-field input, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-field select, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-cache-field select, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button { - border-color: #6a6560; - background: #333130; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-trigger::after { - border-top-color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-select-wrap::after { - border-top-color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-trigger:disabled { - background: #292827; - color: #918b86; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-search::placeholder, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-search-field input::placeholder { - color: #b5b0ab; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header h2, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header h2, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-fieldset legend, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-cache-field, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail-heading, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details dd, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-heading, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 { - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation strong { - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-empty { - color: #c9c5c1; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table th { - border-bottom-color: #4e4a46; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-footer, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs-navigation, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details > div, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-tabs, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-details > div { - border-color: #4e4a46; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-trend, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-trend, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot { - border-color: #4e4a46; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot { - background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #4e4a46 58px, #4e4a46 59px) left top / 100% 236px no-repeat; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot::before, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot::after { - background: #4e4a46; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab { - color: #c9c5c1; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab:hover { - background: #3b3937; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab[aria-selected="true"] { - border-bottom-color: #43c4d5; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button { - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button:hover { - background: #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button[aria-pressed="true"] { - border-color: #43c4d5; - background: #173e43; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button { - border-color: #6a6560; - background: #333130; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:hover:not(:disabled), - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) { - border-color: #43c4d5; - background: #3b3937; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:disabled, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button:disabled { - border-color: #4e4a46; - color: #746f6a; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-grid-line { - stroke: #4e4a46; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-axis-label, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-x-label { - fill: #c9c5c1; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-value-label { - fill: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-funded { - fill: #ca8798; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-overage { - fill: #f09a32; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-average-line { - stroke: #f09a32; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-group:hover rect, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-group:focus rect { - stroke: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button { - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button:hover { - background: #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button[aria-pressed="false"] { - color: #a8a39e; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-funded { - background: #ca8798; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-overage { - background: #f09a32; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-average { - border-top-color: #f09a32; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-tooltip { - border-color: #6a6560; - background: #333130; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option:hover { - background: #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option input { - accent-color: #43c4d5; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary { - border-color: #00a6be; - background: #008aa6; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary:focus-visible { - border-color: #43c4d5; - background: #006d7a; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table tbody tr:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sort-button:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-icon-button:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-icon-button:focus { - background: #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table thead, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table th, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table th { - background: #333130; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table td { - border-bottom-color: #3f3c39; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail { - border-bottom-color: #4e4a46; - background: #252423; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table th, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table td { - border-bottom-color: #3f3c39; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count { - border-color: #2f9bae; - background: #1d4e55; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count:focus-visible, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count[aria-expanded="true"] { - border-color: #43c4d5; - background: #008aa6; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-progress[data-state="running"] { - background: #1d4e55; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-progress[data-state="complete"] { - background: #244f2e; - color: #a8dfb7; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-progress[data-state="complete-with-errors"], - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-progress[data-state="error"], - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count-error { - border-color: #b13b34; - background: #5f2926; - color: #ffb4ad; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-cache-timestamp { - background: #3b3937; - color: #c9c5c1; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table a, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button { - color: #43c4d5; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table a:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:focus-visible, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button:focus-visible { - background: #1d4e55; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="SQL"] { - color: #43c4d5; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="PIPELINE"] { - color: #d1cbc6; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="UPSIDE"] { - color: #ffbf62; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="FORECAST"] { - color: #d2b7e9; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="WON"] { - color: #9bd46a; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="SQL"] { - border-top-color: #43c4d5; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-total-dashboard-card { - border-top-color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { - border-top-color: #c0b9b3; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="UPSIDE"] { - border-top-color: #ffb84d; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="FORECAST"] { - border-top-color: #cba7e7; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="WON"] { - border-top-color: #92cf5b; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="SQL"] { - border-color: #008aa6; - background: #008aa6; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="PIPELINE"] { - border-color: #6f6863; - background: #6f6863; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="UPSIDE"] { - border-color: #b96e00; - background: #b96e00; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="FORECAST"] { - border-color: #755b8b; - background: #755b8b; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="WON"] { - border-color: #4b7f22; - background: #4b7f22; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[data-status="OPEN"] { - color: #69c985; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[data-status="CLOSED"] { - color: #c3cad3; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[data-status="LOST"] { - color: #ff9d94; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[data-status="WON"] { - color: #83bfff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="OPEN"] { - border-color: #197a3d; - background: #197a3d; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="CLOSED"] { - border-color: #4b5563; - background: #4b5563; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="LOST"] { - border-color: #b13b34; - background: #b13b34; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="WON"] { - border-color: #1f5f99; - background: #1f5f99; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item { - border-color: #4e4a46; - background: #252423; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-icon-button { - color: #f6f4f2; - } - - @media (min-width: 1201px) and (max-width: 1450px) { - .opportunities-extension-table th:nth-child(1) { - width: 16%; - } - - .opportunities-extension-table th:nth-child(3) { - width: 18%; - } - - .opportunities-extension-table th:nth-child(4) { - width: 12%; - } - - .opportunities-extension-table th:nth-child(6) { - width: 7%; - } - - .opportunities-extension-table th:nth-child(7) { - width: 8%; - } - - .opportunities-extension-table th:nth-child(10) { - width: 10%; - } - } - - @media (max-width: 1200px) { - .opportunities-extension-table { - min-width: 1180px; - } - - .opportunities-extension-table th:nth-child(1) { - width: 15%; - } - - .opportunities-extension-table th:nth-child(3) { - width: 17%; - } - - .opportunities-extension-table th:nth-child(4) { - width: 13%; - } - - .opportunities-extension-table th:nth-child(6) { - width: 6%; - } - - .opportunities-extension-table th:nth-child(7) { - width: 9%; - } - - .opportunities-extension-table th:nth-child(10) { - width: 11%; - } - } - - @media (max-width: 700px) { - .opportunities-extension-header { - padding: 24px 20px 18px; - } - - .opportunities-extension-header::before { - left: 20px; - } - - .opportunities-extension-header h1 { - font-size: 22px; - } - - .opportunities-extension-header p { - font-size: 14px; - } - - .opportunities-extension-body { - padding: 16px 20px 24px; - } - - .opportunities-extension-form { - align-items: stretch; - flex-wrap: wrap; - gap: 14px; - min-height: auto; - padding: 12px; - } - - .opportunities-extension-field { - max-width: none; - width: 100%; - } - - .opportunities-extension-customer-filter { - width: 100%; - } - - .opportunities-extension-customer-filter-trigger { - width: 100%; - } - - .opportunities-extension-customer-filter-panel { - width: min(360px, calc(100vw - 64px)); - } - - .opportunities-extension-table-search-field { - width: 100%; - } - - .opportunities-extension-stage-dashboard { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .opportunities-extension-stage-dashboard-value { - font-size: 18px; - } - - .opportunities-extension-preferences-modal { - padding: 12px; - } - - .opportunities-extension-account-plans-modal { - padding: 12px; - } - - .opportunities-extension-preferences-dialog { - max-height: calc(100vh - 24px); - } - - .opportunities-extension-account-plans-dialog { - height: calc(100vh - 24px); - } - - .opportunities-extension-account-plan-panel { - padding: 12px 14px; - } - - .opportunities-extension-account-plan-details { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 14px; - } - - .opportunities-extension-account-plan-details > div { - grid-template-columns: 1fr; - align-content: center; - gap: 3px; - min-height: 54px; - padding: 5px 0; - } - - .opportunities-extension-account-plans-skeleton-details { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 14px; - padding: 0; - } - - .opportunities-extension-account-plans-skeleton-details > div { - grid-template-columns: 1fr; - align-content: center; - gap: 5px; - min-height: 54px; - padding: 5px 0; - } - - .opportunities-extension-account-plans-skeleton-trend { - margin: 14px 0 0; - } - - .opportunities-extension-account-plans-skeleton-periods { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - align-items: center; - gap: 6px; - } - - .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar { - width: 100% !important; - } - - .opportunities-extension-account-plans-skeleton-chart { - grid-template-columns: minmax(0, 1fr); - gap: 12px; - padding: 12px 8px 0 34px; - } - - .opportunities-extension-account-plans-skeleton-legend { - display: flex; - min-height: 24px; - align-items: center; - justify-content: center; - gap: 12px; - } - - .opportunities-extension-consumption-trend-header { - align-items: stretch; - } - - .opportunities-extension-consumption-periods { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 3px; - } - - .opportunities-extension-consumption-periods button { - min-width: 0; - padding: 7px 4px; - font-size: 12px; - } - - .opportunities-extension-consumption-chart { - min-height: 0; - } - - .opportunities-extension-consumption-svg { - width: 760px; - height: 245px; - } - - .opportunities-extension-consumption-visual { - width: 920px; - grid-template-columns: 760px 144px; - gap: 12px; - } - - .opportunities-extension-consumption-figure { - overflow-x: auto; - overscroll-behavior-inline: contain; - } - - .opportunities-extension-consumption-chart-skeleton { - min-height: 0; - padding: 24px 18px 18px; - } - - .opportunities-extension-consumption-chart-skeleton-plot { - min-height: 180px; - gap: 4px; - padding: 0 6px; - } - - .opportunities-extension-preferences-options { - grid-template-columns: 1fr; - } - - .opportunities-extension-stage-filter-controls { - gap: 6px; - } - - .opportunities-extension-status-filter-controls { - gap: 6px; - } - - .opportunities-extension-table-surface { - min-width: 0; - min-height: calc(100vh - 230px); - } - - .opportunities-extension-debug-panel { - right: 12px; - bottom: 12px; - width: calc(100vw - 24px); - max-height: calc(100vh - 96px); - } - } - `; - - document.head.append(style); - } - - function isAllowedPage() { - if (window.location.hostname !== "eeho.fa.us2.oraclecloud.com") { - return false; - } - - return ALLOWED_PATHS.some((path) => { - return window.location.pathname === path || window.location.pathname.startsWith(`${path}/`); - }); - } - - function findInsertionPoint(salesGroup) { - const addIconInsideGroup = salesGroup.querySelector(ADD_ICON_SELECTOR); - - if (addIconInsideGroup) { - return addIconInsideGroup; - } - - const container = salesGroup.closest(".flat-grid, .flat-grid-container, [id*='yourapps'], [id*='groupNode']") || salesGroup.parentElement || document.body; - const addIconCells = Array.from(container.querySelectorAll(ADD_ICON_SELECTOR)); - - return addIconCells.find((cell) => { - return Boolean(salesGroup.compareDocumentPosition(cell) & Node.DOCUMENT_POSITION_FOLLOWING); - }) || null; - } - - function insertTile() { - if (document.getElementById(TILE_ID)) { - return true; - } - - const salesGroup = document.querySelector(SALES_GROUP_SELECTOR); - const addIconCell = salesGroup ? findInsertionPoint(salesGroup) : null; - - if (!salesGroup || !addIconCell) { - return false; - } - - addIconCell.before(createOpportunitiesTile()); - return true; - } - - if (insertTile()) { - return; - } - - const observer = new MutationObserver(() => { - insertTile(); + return { toggleDebugPanel, renderDebugPanel, createDebugVisibilityIcon, appendDebugBlock, formatDebugTime }; +} + +// Source: src/content/main.js +/** Preserve the existing document_idle initialization and observer behavior. */ +function startContentApplication({ environment, page, styles, products }) { + if (!page.isAllowedPage()) return; + styles.ensureExtensionStyles(); + products.ensureProductsBackgroundMessageHandler(); + if (page.insertTile()) return; + const observer = new environment.MutationObserver(() => page.insertTile()); + observer.observe(environment.document.documentElement, { childList: true, subtree: true }); +} + +// Source: src/content/application.js +function createContentApplication(overrides = {}) { + const environment = { + document: globalThis.document, + window: globalThis.window, + navigator: globalThis.navigator, + get localStorage() { return globalThis.localStorage; }, + fetch: globalThis.fetch, + browser: globalThis.browser, + chrome: globalThis.chrome, + MutationObserver: globalThis.MutationObserver, + Node: globalThis.Node, + requestAnimationFrame: globalThis.requestAnimationFrame, + performance: globalThis.performance, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + matchMedia: globalThis.matchMedia, + ...overrides + }; + const messageTypes = createMessageTypes(); + const contracts = createContentContracts(); + const authSessionState = createAuthSessionState(contracts); + const authSalesCommonState = createAuthSalesCommonState(); + const opportunitiesQueryState = createOpportunitiesQueryState(); + const opportunitiesTableState = createOpportunitiesTableState(); + const opportunitiesFilterState = createOpportunitiesFilterState(contracts); + const preferencesState = createPreferencesState(contracts); + const productsContentState = createProductsContentState(); + const aiSummaryState = createAiSummaryState(); + const consumptionPlansState = createConsumptionPlansState(); + const consumptionTrendState = createConsumptionTrendState(); + const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts); + const debugState = createDebugState(); + const columns = createOpportunitiesColumns({ + formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), + formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args), + formatWinProbability: (...args) => opportunitiesFormatters.formatWinProbability(...args), + getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args), + getOwnerName: (...args) => opportunitiesFormatters.getOwnerName(...args) }); - - observer.observe(document.documentElement, { - childList: true, - subtree: true + const contentFusionPage = createContentFusionPage({ + ADD_ICON_SELECTOR: contracts.ADD_ICON_SELECTOR, + ALLOWED_PATHS: contracts.ALLOWED_PATHS, + SALES_GROUP_SELECTOR: contracts.SALES_GROUP_SELECTOR, + TILE_ID: contracts.TILE_ID, + TILE_LABEL_ID: contracts.TILE_LABEL_ID, + appendPath: (...args) => svgPaths.appendPath(...args), + openOpportunitiesModal: (...args) => contentModal.openOpportunitiesModal(...args), + environment }); + const contentModal = createContentModal({ + ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID, + AUTH_STATUS: contracts.AUTH_STATUS, + CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID, + CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID, + CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID, + CUSTOMER_FILTER_SEARCH_ID: contracts.CUSTOMER_FILTER_SEARCH_ID, + CUSTOMER_FILTER_SELECT_ALL_ID: contracts.CUSTOMER_FILTER_SELECT_ALL_ID, + DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID, + ESCAPE_LISTENER_KEY: contracts.ESCAPE_LISTENER_KEY, + MODAL_ID: contracts.MODAL_ID, + OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID, + OWNER_FILTER_BUTTON_ID: contracts.OWNER_FILTER_BUTTON_ID, + OWNER_FILTER_LIST_ID: contracts.OWNER_FILTER_LIST_ID, + OWNER_FILTER_PANEL_ID: contracts.OWNER_FILTER_PANEL_ID, + OWNER_FILTER_SEARCH_ID: contracts.OWNER_FILTER_SEARCH_ID, + OWNER_FILTER_SELECT_ALL_ID: contracts.OWNER_FILTER_SELECT_ALL_ID, + PERIOD_OPTIONS: contracts.PERIOD_OPTIONS, + PREFERENCES_MODAL_ID: contracts.PREFERENCES_MODAL_ID, + PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID, + PRODUCTS_PROGRESS_ID: contracts.PRODUCTS_PROGRESS_ID, + STAGE_DASHBOARD_ID: contracts.STAGE_DASHBOARD_ID, + STAGE_FILTERS_ID: contracts.STAGE_FILTERS_ID, + STATUS_FILTERS_ID: contracts.STATUS_FILTERS_ID, + TABLE_SEARCH_ID: contracts.TABLE_SEARCH_ID, + applyTheme: (...args) => contentTheme.applyTheme(...args), + authSessionState, + closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args), + closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args), + closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args), + closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args), + createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args), + debugState, + ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args), + getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args), + getOwnerOptions: (...args) => opportunitiesFormatters.getOwnerOptions(...args), + getSavedOpportunityTypeValues: (...args) => preferencesStorage.getSavedOpportunityTypeValues(...args), + getSavedOpptyProductsCacheHours: (...args) => preferencesStorage.getSavedOpptyProductsCacheHours(...args), + getSavedTheme: (...args) => contentTheme.getSavedTheme(...args), + openPreferencesModal: (...args) => preferencesView.openPreferencesModal(...args), + opportunitiesFilterState, + opportunitiesQueryState, + preferencesState, + renderCustomerFilter: (...args) => opportunitiesFilters.renderCustomerFilter(...args), + renderCustomerFilterList: (...args) => opportunitiesFilters.renderCustomerFilterList(...args), + renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), + renderOwnerFilter: (...args) => opportunitiesFilters.renderOwnerFilter(...args), + renderOwnerFilterList: (...args) => opportunitiesFilters.renderOwnerFilterList(...args), + renderStageFilterButtons: (...args) => opportunitiesFilters.renderStageFilterButtons(...args), + renderStatusFilterButtons: (...args) => opportunitiesFilters.renderStatusFilterButtons(...args), + requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args), + requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args), + resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args), + resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args), + restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args), + saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args), + toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args), + toggleDebugPanel: (...args) => debugContentView.toggleDebugPanel(...args), + toggleOwnerFilterPanel: (...args) => opportunitiesFilters.toggleOwnerFilterPanel(...args), + environment + }); + const contentTheme = createContentTheme({ + THEME_STORAGE_KEY: contracts.THEME_STORAGE_KEY, + createThemeIcon: (...args) => contentIcons.createThemeIcon(...args), + environment + }); + const contentIcons = createContentIcons({ + appendPath: (...args) => svgPaths.appendPath(...args), + environment + }); + const svgPaths = createSvgPaths({ environment }); + const contentStyles = createContentStyles({ + STYLE_ID: contracts.STYLE_ID, + redwoodStyles, + environment + }); + const authContentSession = createAuthContentSession({ + messageTypes, + AUTH_STATUS: contracts.AUTH_STATUS, + appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args), + authSessionState, + requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args), + sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), + updateAuthBadge: (...args) => contentModal.updateAuthBadge(...args) + }); + const authSalesCommon = createAuthSalesCommon({ + TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL, + authSalesCommonState, + requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args) + }); + const authTokenRelay = createAuthTokenRelay({ + TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL, + createDebugBody: (...args) => debugContentLog.createDebugBody(...args), + getXsrfToken: (...args) => authXsrfContent.getXsrfToken(...args), + logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args), + logRequestStart: (...args) => debugContentLog.logRequestStart(...args), + logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args), + maskToken: (...args) => debugContentLog.maskToken(...args), + readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args), + refreshXsrfCookie: (...args) => platformContentMessages.refreshXsrfCookie(...args), + renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args), + environment + }); + const authXsrfContent = createAuthXsrfContent({ + getXsrfTokenFromExtensionCookies: (...args) => platformContentMessages.getXsrfTokenFromExtensionCookies(...args), + environment + }); + const platformContentMessages = createPlatformContentMessages({ + messageTypes, + environment + }); + const opportunitiesController = createOpportunitiesController({ + STAGE_OPTIONS: contracts.STAGE_OPTIONS, + STATUS_OPTIONS: contracts.STATUS_OPTIONS, + authSessionState, + consumptionPlansState, + consumptionTrendState, + getFiscalPeriodRange: (...args) => opportunitiesFiscalPeriods.getFiscalPeriodRange(...args), + opportunitiesDashboardState, + opportunitiesFilterState, + opportunitiesQueryState, + opportunitiesTableState, + preferencesState, + productsContentState, + renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args), + requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args), + requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args), + requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args), + setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args) + }); + const fusionOpportunities = createFusionOpportunities({ + OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT, + OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL, + isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), + logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args), + logRequestStart: (...args) => debugContentLog.logRequestStart(...args), + logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args), + maskToken: (...args) => debugContentLog.maskToken(...args), + getOpportunityTypeValues: () => preferencesState.selectedOpportunityTypeValues, + readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args), + environment + }); + const opportunitiesFiscalPeriods = createOpportunitiesFiscalPeriods(); + const opportunitiesTable = createOpportunitiesTable({ + ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL, + OPPORTUNITIES_COLUMNS: columns, + OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID, + OPPORTUNITY_DETAIL_URL: contracts.OPPORTUNITY_DETAIL_URL, + createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args), + createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args), + createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args), + createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args), + createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args), + createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args), + createStatusBadge: (...args) => opportunitiesLinks.createStatusBadge(...args), + displayOpportunityValue: (...args) => opportunitiesFormatters.displayOpportunityValue(...args), + getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args), + getSortedOpportunities: (...args) => opportunitiesFilters.getSortedOpportunities(...args), + opportunitiesTableState, + productsContentState, + renderCustomerFilter: (...args) => opportunitiesFilters.renderCustomerFilter(...args), + renderOwnerFilter: (...args) => opportunitiesFilters.renderOwnerFilter(...args), + renderStageDashboard: (...args) => opportunitiesDashboard.renderStageDashboard(...args), + resetCustomerFilter: (...args) => opportunitiesFilters.resetCustomerFilter(...args), + resetOwnerFilter: (...args) => opportunitiesFilters.resetOwnerFilter(...args), + environment + }); + const opportunitiesDashboard = createOpportunitiesDashboard({ + STAGE_DASHBOARD_ID: contracts.STAGE_DASHBOARD_ID, + STAGE_OPTIONS: contracts.STAGE_OPTIONS, + getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args), + opportunitiesDashboardState, + environment + }); + const opportunitiesFilters = createOpportunitiesFilters({ + CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID, + CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID, + CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID, + CUSTOMER_FILTER_SEARCH_ID: contracts.CUSTOMER_FILTER_SEARCH_ID, + CUSTOMER_FILTER_SELECT_ALL_ID: contracts.CUSTOMER_FILTER_SELECT_ALL_ID, + OPPORTUNITIES_COLUMNS: columns, + OWNER_FILTER_BUTTON_ID: contracts.OWNER_FILTER_BUTTON_ID, + OWNER_FILTER_LIST_ID: contracts.OWNER_FILTER_LIST_ID, + OWNER_FILTER_PANEL_ID: contracts.OWNER_FILTER_PANEL_ID, + OWNER_FILTER_SEARCH_ID: contracts.OWNER_FILTER_SEARCH_ID, + OWNER_FILTER_SELECT_ALL_ID: contracts.OWNER_FILTER_SELECT_ALL_ID, + STAGE_FILTERS_ID: contracts.STAGE_FILTERS_ID, + STAGE_OPTIONS: contracts.STAGE_OPTIONS, + STATUS_FILTERS_ID: contracts.STATUS_FILTERS_ID, + STATUS_OPTIONS: contracts.STATUS_OPTIONS, + getCustomerFilterSummary: (...args) => opportunitiesFormatters.getCustomerFilterSummary(...args), + getCustomerKey: (...args) => opportunitiesFormatters.getCustomerKey(...args), + getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args), + getOwnerKey: (...args) => opportunitiesFormatters.getOwnerKey(...args), + getOwnerOptions: (...args) => opportunitiesFormatters.getOwnerOptions(...args), + opportunitiesFilterState, + opportunitiesTableState, + renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), + saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args), + stringifySearchValue: (...args) => opportunitiesFormatters.stringifySearchValue(...args), + environment + }); + const opportunitiesFormatters = createOpportunitiesFormatters({ + opportunitiesTableState + }); + const opportunitiesLinks = createOpportunitiesLinks({ + MODAL_ID: contracts.MODAL_ID, + environment + }); + const preferencesStorage = createPreferencesStorage({ + DEFAULT_OPPORTUNITY_TYPE_VALUES: contracts.DEFAULT_OPPORTUNITY_TYPE_VALUES, + DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS: contracts.DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, + FILTER_PREFERENCES_STORAGE_KEY: contracts.FILTER_PREFERENCES_STORAGE_KEY, + MODAL_ID: contracts.MODAL_ID, + OPPORTUNITY_TYPE_OPTIONS: contracts.OPPORTUNITY_TYPE_OPTIONS, + OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY: contracts.OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, + OPPTY_PRODUCTS_CACHE_OPTIONS: contracts.OPPTY_PRODUCTS_CACHE_OPTIONS, + PERIOD_OPTIONS: contracts.PERIOD_OPTIONS, + STAGE_OPTIONS: contracts.STAGE_OPTIONS, + STATUS_OPTIONS: contracts.STATUS_OPTIONS, + opportunitiesFilterState, + environment + }); + const preferencesView = createPreferencesView({ + DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS: contracts.DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, + MODAL_ID: contracts.MODAL_ID, + OPPORTUNITY_TYPE_OPTIONS: contracts.OPPORTUNITY_TYPE_OPTIONS, + OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY: contracts.OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, + OPPTY_PRODUCTS_CACHE_OPTIONS: contracts.OPPTY_PRODUCTS_CACHE_OPTIONS, + PERIOD_OPTIONS: contracts.PERIOD_OPTIONS, + PREFERENCES_MODAL_ID: contracts.PREFERENCES_MODAL_ID, + createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), + getSavedOpportunityTypeValues: (...args) => preferencesStorage.getSavedOpportunityTypeValues(...args), + getSavedOpptyProductsCacheHours: (...args) => preferencesStorage.getSavedOpptyProductsCacheHours(...args), + preferencesState, + requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args), + environment + }); + const productsContentJobs = createProductsContentJobs({ + messageTypes, + PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID, + PRODUCTS_MESSAGE_LISTENER_KEY: contracts.PRODUCTS_MESSAGE_LISTENER_KEY, + PRODUCTS_PROGRESS_ID: contracts.PRODUCTS_PROGRESS_ID, + preferencesState, + productsContentState, + renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), + sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), + environment + }); + const productsDetails = createProductsDetails({ + OPPORTUNITIES_COLUMNS: columns, + createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args), + formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), + productsContentState, + renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), + requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args), + environment + }); + const aiSummaryContent = createAiSummaryContent({ + aiSummaryState, + invalidateSalesCommonToken: (...args) => authSalesCommon.invalidateSalesCommonToken(...args), + logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args), + logRequestStart: (...args) => debugContentLog.logRequestStart(...args), + logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args), + readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args), + requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...args), + environment + }); + const consumptionPlansAction = createConsumptionPlansAction({ + messageTypes, + appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args), + consumptionPlansState, + getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args), + openAccountPlansModal: (...args) => consumptionPlansView.openAccountPlansModal(...args), + renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), + requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args), + sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), + showCopyConfirmation: (...args) => opportunitiesLinks.showCopyConfirmation(...args), + environment + }); + const consumptionPlansView = createConsumptionPlansView({ + ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID, + DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD, + MODAL_ID: contracts.MODAL_ID, + createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), + createConsumptionTrendSection: (...args) => consumptionTrendController.createConsumptionTrendSection(...args), + createSvgElement: (...args) => consumptionChart.createSvgElement(...args), + formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), + getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args), + loadConsumptionTrendForPanel: (...args) => consumptionTrendController.loadConsumptionTrendForPanel(...args), + environment + }); + const consumptionTrendController = createConsumptionTrendController({ + messageTypes, + CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS, + DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD, + appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args), + consumptionTrendState, + renderConsumptionTrendChart: (...args) => consumptionChart.renderConsumptionTrendChart(...args), + requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args), + sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), + environment + }); + const consumptionChart = createConsumptionChart({ + CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS, + CONSUMPTION_TREND_SERIES: contracts.CONSUMPTION_TREND_SERIES, + SVG_NAMESPACE: contracts.SVG_NAMESPACE, + calculateAverageConsumption: (...args) => consumptionModel.calculateAverageConsumption(...args), + consumptionTrendState, + createConsumptionTrendKey: (...args) => consumptionTrendController.createConsumptionTrendKey(...args), + createNiceConsumptionMaximum: (...args) => consumptionModel.createNiceConsumptionMaximum(...args), + formatCompactConsumptionNumber: (...args) => consumptionModel.formatCompactConsumptionNumber(...args), + formatConsumptionCurrency: (...args) => consumptionModel.formatConsumptionCurrency(...args), + formatConsumptionPointLabel: (...args) => consumptionModel.formatConsumptionPointLabel(...args), + formatConsumptionRange: (...args) => consumptionModel.formatConsumptionRange(...args), + normalizeConsumptionTrendItems: (...args) => consumptionModel.normalizeConsumptionTrendItems(...args), + environment + }); + const consumptionModel = createConsumptionModel({ + + }); + const debugContentLog = createDebugContentLog({ + DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT, + debugState, + renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args) + }); + const debugContentView = createDebugContentView({ + DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID, + MODAL_ID: contracts.MODAL_ID, + debugState, + formatDebugBody: (...args) => debugContentLog.formatDebugBody(...args), + formatHeaderBlock: (...args) => debugContentLog.formatHeaderBlock(...args), + formatMetadataBlock: (...args) => debugContentLog.formatMetadataBlock(...args), + environment + }); + function start() { + startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs }); + } + return { + start, contracts, + authSessionState, + authSalesCommonState, + opportunitiesQueryState, + opportunitiesTableState, + opportunitiesFilterState, + preferencesState, + productsContentState, + aiSummaryState, + consumptionPlansState, + consumptionTrendState, + opportunitiesDashboardState, + debugState, + contentFusionPage, + contentModal, + contentTheme, + contentIcons, + contentStyles, + authContentSession, + authSalesCommon, + authTokenRelay, + authXsrfContent, + platformContentMessages, + opportunitiesController, + fusionOpportunities, + opportunitiesFiscalPeriods, + opportunitiesTable, + opportunitiesDashboard, + opportunitiesFilters, + opportunitiesFormatters, + opportunitiesLinks, + preferencesStorage, + preferencesView, + productsContentJobs, + productsDetails, + aiSummaryContent, + consumptionPlansAction, + consumptionPlansView, + consumptionTrendController, + consumptionChart, + consumptionModel, + debugContentLog, + debugContentView + }; +} + +createContentApplication().start(); })(); diff --git a/dist/firefox/background.js b/dist/firefox/background.js index 35a162f..c235f28 100644 --- a/dist/firefox/background.js +++ b/dist/firefox/background.js @@ -1,6 +1,21 @@ (function () { "use strict"; +// Source: src/shared/messages.js +/** Stable wire names. All payloads remain plain, serializable objects. */ +function createMessageTypes() { + return Object.freeze({ + getXsrfToken: "opportunitiesExtension.getXsrfToken", + refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie", + requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts", + opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress", + requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService", + requestAccountPlans: "opportunitiesExtension.requestAccountPlans", + requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend" + }); +} +// Source: src/background/contracts.js +function createBackgroundContracts() { const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com"; const ORACLE_COOKIE_URLS = [ "https://eeho.fa.us2.oraclecloud.com/", @@ -67,116 +82,32 @@ "PrevQuantity_c", "PrevRevenueType_c", "PrevServicePeriod_c", "PrevStatus_c", "SubscriptionID_c", "WorkloadName_c", "ConsumptionStartDate_c", "RampMonths_c" ]; - const runtimeApi = typeof browser !== "undefined" ? browser : chrome; - let productsCachePromise = null; - let productsCacheWritePromise = Promise.resolve(); - const inFlightProducts = new Map(); + return { ORACLE_DOMAIN, ORACLE_COOKIE_URLS, XSRF_COOKIE_NAME, XSRF_COOKIE_PREFIX, OPPORTUNITIES_LIST_URL, COOKIE_REFRESH_TIMEOUT_MS, COOKIE_REFRESH_POLL_MS, OPPTY_PRODUCTS_CACHE_KEY, OPPTY_PRODUCTS_PAGE_LIMIT, OPPTY_PRODUCTS_MAX_PAGES, OPPTY_PRODUCTS_CONCURRENCY, CUSTOMER_TOKEN_SERVICE_URL, CUSTOMER_TOKEN_SESSION_URL, CUSTOMER_TOKEN_MAX_ATTEMPTS, CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS, CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS, ACCOUNT_SUMMARY_URL, ACCOUNT_PLANS_URL, CONSUMPTION_TREND_URL, CONSUMPTION_TREND_REQUESTS, ACCOUNT_PLANS_PAYLOAD, DEBUG_BODY_LIMIT, OPPTY_PRODUCTS_FIELDS }; +} - runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (!message || !message.type) { - return false; - } - - if (message.type === "opportunitiesExtension.getXsrfToken") { - getXsrfTokenCookie() - .then((result) => { - sendResponse({ - ok: true, - cookieName: result.cookie ? result.cookie.name : "", - token: result.cookie ? result.cookie.value : "", - matchedCookieNames: result.matchedCookieNames, - lookupDetails: result.lookupDetails - }); - }) - .catch((error) => { - sendResponse({ - ok: false, - cookieName: "", - token: "", - matchedCookieNames: [], - lookupDetails: [], - error: error.message || "Unable to read cookies." - }); - }); - - return true; - } - - if (message.type === "opportunitiesExtension.refreshXsrfCookie") { - refreshXsrfCookie(sender) - .then(sendResponse) - .catch((error) => { - sendResponse({ - ok: false, - refreshed: false, - cookieName: "", - durationMs: 0, - error: error.message || "Unable to refresh XSRF cookie." - }); - }); - - return true; - } - - if (message.type === "opportunitiesExtension.requestOpptyProducts") { - requestOpptyProducts(message, sender) - .then(sendResponse) - .catch((error) => { - sendResponse({ - ok: false, - requestId: message.requestId || "", - error: error.message || "Unable to load opportunity products." - }); - }); - - return true; - } - - if (message.type === "opportunitiesExtension.requestConsumerTokenService") { - requestConsumerTokenService(sender) - .then(sendResponse) - .catch((error) => { - sendResponse({ - ok: false, - error: error.message || "Unable to load consumer token service.", - debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] - }); - }); - - return true; - } - - if (message.type === "opportunitiesExtension.requestAccountPlans") { - requestAccountPlans(message) - .then(sendResponse) - .catch((error) => { - sendResponse({ - ok: false, - error: error.message || "Unable to load account plans.", - debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] - }); - }); - - return true; - } - - if (message.type === "opportunitiesExtension.requestConsumptionTrend") { - requestConsumptionTrend(message) - .then(sendResponse) - .catch((error) => { - sendResponse({ - ok: false, - error: error.message || "Unable to load consumption trend.", - debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] - }); - }); - - return true; - } - - return false; - }); +// Source: src/features/products/cache-state.js +function createProductsCacheState() { + return { + productsCachePromise: null, + productsCacheWritePromise: Promise.resolve(), + inFlightProducts: new Map() + }; +} +// Source: src/auth/consumer-session.js +function createAuthConsumerSession({ + CUSTOMER_TOKEN_MAX_ATTEMPTS, + CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS, + CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS, + CUSTOMER_TOKEN_SERVICE_URL, + CUSTOMER_TOKEN_SESSION_URL, + extractTokenServiceAuthorization, + fetchJson, + supportsFirefoxCookieStoreTabs, + tabsCreate, + tabsRemove, + wait +}) { async function requestConsumerTokenService(sender) { const debugRequests = []; @@ -384,6 +315,61 @@ return tabsCreate(details); } + return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab }; +} + +// Source: src/auth/spa-credentials.js +function createAuthSpaCredentials({ + findNestedObject +}) { + function normalizeMessageAuthorization(authorization, fallbackHeaderName) { + const headerName = authorization && typeof authorization.name === "string" + ? authorization.name.trim() + : ""; + const headerValue = authorization && typeof authorization.value === "string" + ? authorization.value.trim() + : ""; + + return { + name: headerName || fallbackHeaderName, + value: headerValue + }; + } + + function extractTokenServiceAuthorization(response, tokenServiceName, fallbackHeaderName) { + const tokenService = Array.isArray(response) + ? response.find((entry) => entry && entry.tokenService === tokenServiceName) + : findNestedObject(response, (entry) => entry.tokenService === tokenServiceName); + const authorizationHeader = tokenService && tokenService.uiaasHeader; + const headerName = authorizationHeader && typeof authorizationHeader.label === "string" + ? authorizationHeader.label.trim() + : ""; + const headerValue = authorizationHeader && typeof authorizationHeader.value === "string" + ? authorizationHeader.value.trim() + : ""; + + return { + name: headerName || fallbackHeaderName, + value: headerValue, + expires: authorizationHeader && authorizationHeader.expires + ? String(authorizationHeader.expires) + : "" + }; + } + + return { normalizeMessageAuthorization, extractTokenServiceAuthorization }; +} + +// Source: src/services/spa/accounts.js +function createSpaAccounts({ + ACCOUNT_PLANS_PAYLOAD, + ACCOUNT_PLANS_URL, + ACCOUNT_SUMMARY_URL, + extractAccountPlans, + fetchJson, + findAccountPlanId, + normalizeMessageAuthorization +}) { async function requestAccountPlans(message) { const partyNumber = typeof message.partyNumber === "string" ? message.partyNumber.trim() : ""; const accountSummaryAuthorization = normalizeMessageAuthorization( @@ -448,6 +434,16 @@ } } + return { requestAccountPlans }; +} + +// Source: src/services/spa/consumption.js +function createSpaConsumption({ + CONSUMPTION_TREND_REQUESTS, + CONSUMPTION_TREND_URL, + fetchJson, + normalizeMessageAuthorization +}) { async function requestConsumptionTrend(message) { const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : ""; const period = typeof message.period === "string" ? message.period.toLowerCase() : ""; @@ -498,41 +494,78 @@ } } - function normalizeMessageAuthorization(authorization, fallbackHeaderName) { - const headerName = authorization && typeof authorization.name === "string" - ? authorization.name.trim() - : ""; - const headerValue = authorization && typeof authorization.value === "string" - ? authorization.value.trim() - : ""; + return { requestConsumptionTrend }; +} - return { - name: headerName || fallbackHeaderName, - value: headerValue - }; +// Source: src/services/spa/response-shapes.js +function createSpaResponseShapes() { + function findNestedObject(value, predicate, visited = new Set()) { + if (!value || typeof value !== "object" || visited.has(value)) { + return null; + } + + visited.add(value); + + if (!Array.isArray(value) && predicate(value)) { + return value; + } + + for (const child of Object.values(value)) { + const match = findNestedObject(child, predicate, visited); + + if (match) { + return match; + } + } + + return null; } - function extractTokenServiceAuthorization(response, tokenServiceName, fallbackHeaderName) { - const tokenService = Array.isArray(response) - ? response.find((entry) => entry && entry.tokenService === tokenServiceName) - : findNestedObject(response, (entry) => entry.tokenService === tokenServiceName); - const authorizationHeader = tokenService && tokenService.uiaasHeader; - const headerName = authorizationHeader && typeof authorizationHeader.label === "string" - ? authorizationHeader.label.trim() - : ""; - const headerValue = authorizationHeader && typeof authorizationHeader.value === "string" - ? authorizationHeader.value.trim() - : ""; - - return { - name: headerName || fallbackHeaderName, - value: headerValue, - expires: authorizationHeader && authorizationHeader.expires - ? String(authorizationHeader.expires) - : "" - }; + function findAccountPlanId(response) { + const account = findNestedObject(response, (value) => ( + Object.prototype.hasOwnProperty.call(value, "id") + && value.id !== null + && value.id !== undefined + && value.id !== "" + )); + return account ? account.id : ""; } + function extractAccountPlans(response) { + const plans = []; + const visited = new Set(); + + const visit = (value) => { + if (!value || typeof value !== "object" || visited.has(value)) { + return; + } + + visited.add(value); + + if (!Array.isArray(value) && ( + Object.prototype.hasOwnProperty.call(value, "planType") + || Object.prototype.hasOwnProperty.call(value, "subPlanNum") + )) { + plans.push(value); + return; + } + + Object.values(value).forEach(visit); + }; + + visit(response); + return plans; + } + + return { findNestedObject, findAccountPlanId, extractAccountPlans }; +} + +// Source: src/services/http/background-json.js +function createHttpBackgroundJson({ + DEBUG_BODY_LIMIT, + environment +}) { + const { fetch } = environment; async function fetchJson(url, options, requestName, debugRequests = null) { const startedAt = new Date(); const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt); @@ -695,64 +728,17 @@ } } - function findNestedObject(value, predicate, visited = new Set()) { - if (!value || typeof value !== "object" || visited.has(value)) { - return null; - } - - visited.add(value); - - if (!Array.isArray(value) && predicate(value)) { - return value; - } - - for (const child of Object.values(value)) { - const match = findNestedObject(child, predicate, visited); - - if (match) { - return match; - } - } - - return null; - } - - function findAccountPlanId(response) { - const account = findNestedObject(response, (value) => ( - Object.prototype.hasOwnProperty.call(value, "id") - && value.id !== null - && value.id !== undefined - && value.id !== "" - )); - return account ? account.id : ""; - } - - function extractAccountPlans(response) { - const plans = []; - const visited = new Set(); - - const visit = (value) => { - if (!value || typeof value !== "object" || visited.has(value)) { - return; - } - - visited.add(value); - - if (!Array.isArray(value) && ( - Object.prototype.hasOwnProperty.call(value, "planType") - || Object.prototype.hasOwnProperty.call(value, "subPlanNum") - )) { - plans.push(value); - return; - } - - Object.values(value).forEach(visit); - }; - - visit(response); - return plans; - } + return { fetchJson, createBackgroundDebugEntry, completeBackgroundDebugEntry, appendBackgroundDebugEntry, readResponsePayload, parseJsonResponse, createDebugBody, normalizeDebugHeaders, headersToObject, formatDebugRequestBody }; +} +// Source: src/features/products/background-jobs.js +function createProductsBackgroundJobs({ + messageTypes, + OPPTY_PRODUCTS_CONCURRENCY, + getOpptyProducts, + normalizeCacheMaxAge, + tabsSendMessage +}) { async function requestOpptyProducts(message, sender) { const requestId = typeof message.requestId === "string" ? message.requestId : ""; const accessToken = typeof message.accessToken === "string" ? message.accessToken : ""; @@ -787,7 +773,7 @@ } await sendProductsProgress(sender, { - type: "opportunitiesExtension.opptyProductsProgress", + type: messageTypes.opptyProductsProgress, requestId, state: "running", optyNumber, @@ -803,7 +789,7 @@ completed += 1; failed += 1; await sendProductsProgress(sender, { - type: "opportunitiesExtension.opptyProductsProgress", + type: messageTypes.opptyProductsProgress, requestId, state: "running", optyNumber, @@ -823,7 +809,7 @@ const state = failed > 0 ? "complete-with-errors" : "complete"; await sendProductsProgress(sender, { - type: "opportunitiesExtension.opptyProductsProgress", + type: messageTypes.opptyProductsProgress, requestId, state, completed, @@ -842,6 +828,26 @@ }; } + function sendProductsProgress(sender, message) { + if (!sender || !sender.tab || !Number.isInteger(sender.tab.id)) { + return Promise.resolve(); + } + + const options = Number.isInteger(sender.frameId) ? { frameId: sender.frameId } : undefined; + return tabsSendMessage(sender.tab.id, message, options).catch(() => {}); + } + + return { requestOpptyProducts, sendProductsProgress }; +} + +// Source: src/features/products/cache.js +function createProductsCache({ + OPPTY_PRODUCTS_CACHE_KEY, + fetchAllOpptyProducts, + productsCacheState, + storageLocalGet, + storageLocalSet +}) { async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) { const cache = await getProductsCache(); const cachedEntry = cache[optyNumber]; @@ -850,7 +856,7 @@ return { items: cachedEntry.items, fromCache: true, cachedAt: Number(cachedEntry.fetchedAt) }; } - if (!inFlightProducts.has(optyNumber)) { + if (!productsCacheState.inFlightProducts.has(optyNumber)) { const request = fetchAllOpptyProducts(optyNumber, accessToken) .then(async (items) => { const fetchedAt = Date.now(); @@ -862,12 +868,12 @@ return { items, fetchedAt }; }) .finally(() => { - inFlightProducts.delete(optyNumber); + productsCacheState.inFlightProducts.delete(optyNumber); }); - inFlightProducts.set(optyNumber, request); + productsCacheState.inFlightProducts.set(optyNumber, request); } - const fetchedResult = await inFlightProducts.get(optyNumber); + const fetchedResult = await productsCacheState.inFlightProducts.get(optyNumber); return { items: fetchedResult.items, cachedAt: fetchedResult.fetchedAt, @@ -875,6 +881,46 @@ }; } + function normalizeCacheMaxAge(value) { + const maxAge = Number(value); + const defaultMaxAge = 24 * 60 * 60 * 1000; + const maximumMaxAge = 30 * 24 * 60 * 60 * 1000; + return Number.isFinite(maxAge) && maxAge > 0 + ? Math.min(maxAge, maximumMaxAge) + : defaultMaxAge; + } + + async function getProductsCache() { + if (!productsCacheState.productsCachePromise) { + productsCacheState.productsCachePromise = storageLocalGet(OPPTY_PRODUCTS_CACHE_KEY) + .then((result) => result && typeof result[OPPTY_PRODUCTS_CACHE_KEY] === "object" + ? result[OPPTY_PRODUCTS_CACHE_KEY] + : {}) + .catch(() => ({})); + } + + return productsCacheState.productsCachePromise; + } + + function saveProductsCache(cache) { + productsCacheState.productsCacheWritePromise = productsCacheState.productsCacheWritePromise + .catch(() => {}) + .then(() => storageLocalSet({ [OPPTY_PRODUCTS_CACHE_KEY]: cache })); + return productsCacheState.productsCacheWritePromise; + } + + return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache }; +} + +// Source: src/services/fusion/products.js +function createFusionProducts({ + OPPTY_PRODUCTS_FIELDS, + OPPTY_PRODUCTS_MAX_PAGES, + OPPTY_PRODUCTS_PAGE_LIMIT, + ORACLE_DOMAIN, + environment +}) { + const { fetch } = environment; async function fetchAllOpptyProducts(optyNumber, accessToken) { let offset = 0; let page = 0; @@ -930,43 +976,20 @@ return url.toString(); } - function normalizeCacheMaxAge(value) { - const maxAge = Number(value); - const defaultMaxAge = 24 * 60 * 60 * 1000; - const maximumMaxAge = 30 * 24 * 60 * 60 * 1000; - return Number.isFinite(maxAge) && maxAge > 0 - ? Math.min(maxAge, maximumMaxAge) - : defaultMaxAge; - } - - async function getProductsCache() { - if (!productsCachePromise) { - productsCachePromise = storageLocalGet(OPPTY_PRODUCTS_CACHE_KEY) - .then((result) => result && typeof result[OPPTY_PRODUCTS_CACHE_KEY] === "object" - ? result[OPPTY_PRODUCTS_CACHE_KEY] - : {}) - .catch(() => ({})); - } - - return productsCachePromise; - } - - function saveProductsCache(cache) { - productsCacheWritePromise = productsCacheWritePromise - .catch(() => {}) - .then(() => storageLocalSet({ [OPPTY_PRODUCTS_CACHE_KEY]: cache })); - return productsCacheWritePromise; - } - - function sendProductsProgress(sender, message) { - if (!sender || !sender.tab || !Number.isInteger(sender.tab.id)) { - return Promise.resolve(); - } - - const options = Number.isInteger(sender.frameId) ? { frameId: sender.frameId } : undefined; - return tabsSendMessage(sender.tab.id, message, options).catch(() => {}); - } + return { fetchAllOpptyProducts, createOpptyProductsUrl }; +} +// Source: src/auth/xsrf-recovery.js +function createAuthXsrfRecovery({ + COOKIE_REFRESH_POLL_MS, + COOKIE_REFRESH_TIMEOUT_MS, + OPPORTUNITIES_LIST_URL, + getXsrfTokenCookie, + supportsFirefoxCookieStoreTabs, + tabsCreate, + tabsRemove, + wait +}) { async function refreshXsrfCookie(sender) { const startedAt = Date.now(); const previous = (await getXsrfTokenCookie()).cookie; @@ -1039,6 +1062,133 @@ || previousCookie.expirationDate !== currentCookie.expirationDate; } + return { refreshXsrfCookie, waitForXsrfCookieChange, hasCookieChanged }; +} + +// Source: src/auth/xsrf-cookies.js +function createAuthXsrfCookies({ + ORACLE_COOKIE_URLS, + ORACLE_DOMAIN, + XSRF_COOKIE_NAME, + XSRF_COOKIE_PREFIX, + cookiesGet, + cookiesGetAll, + cookiesGetAllCookieStores, + removeUndefinedValues +}) { + async function getXsrfTokenCookie() { + const lookupDetails = []; + const allCookies = []; + const stores = await getCookieStores(lookupDetails); + + for (const store of stores) { + const exactCookie = await getExactCookieFromUrls(store.id, lookupDetails); + + if (exactCookie) { + allCookies.push(exactCookie); + } + } + + for (const store of stores) { + await collectCookies({ + name: XSRF_COOKIE_NAME, + storeId: store.id + }, allCookies, lookupDetails); + } + + for (const store of stores) { + await collectCookies({ + domain: ORACLE_DOMAIN, + storeId: store.id + }, allCookies, lookupDetails); + + await collectCookies({ + domain: `.${ORACLE_DOMAIN}`, + storeId: store.id + }, allCookies, lookupDetails); + } + + for (const store of stores) { + for (const url of ORACLE_COOKIE_URLS) { + await collectCookies({ + url, + storeId: store.id + }, allCookies, lookupDetails); + } + } + + const uniqueCookies = dedupeCookies(allCookies); + const xsrfCookies = uniqueCookies.filter((cookie) => cookie.name.startsWith(XSRF_COOKIE_PREFIX)); + const exactCookie = xsrfCookies.find((cookie) => cookie.name === XSRF_COOKIE_NAME); + const hostCookie = xsrfCookies.find((cookie) => cookie.domain === ORACLE_DOMAIN || cookie.domain === `.${ORACLE_DOMAIN}`); + + return { + cookie: exactCookie || hostCookie || xsrfCookies[0] || null, + matchedCookieNames: xsrfCookies.map((cookie) => `${cookie.name} (${cookie.domain}${cookie.path})`), + lookupDetails + }; + } + + async function getExactCookieFromUrls(storeId, lookupDetails) { + for (const url of ORACLE_COOKIE_URLS) { + const cookie = await cookiesGet({ + url, + name: XSRF_COOKIE_NAME, + storeId + }); + + lookupDetails.push(`${JSON.stringify({ url, name: XSRF_COOKIE_NAME, storeId })} => ${cookie ? "found" : "not found"}`); + + if (cookie) { + return cookie; + } + } + + return null; + } + + async function getCookieStores(lookupDetails) { + try { + const stores = await cookiesGetAllCookieStores(); + lookupDetails.push(`getAllCookieStores => ${stores.length} store(s)`); + return stores.length ? stores : [{ id: undefined }]; + } catch (error) { + lookupDetails.push(`getAllCookieStores => unavailable (${error.message || "unknown error"})`); + return [{ id: undefined }]; + } + } + + async function collectCookies(details, target, lookupDetails) { + const cleanDetails = removeUndefinedValues(details); + const cookies = await cookiesGetAll(cleanDetails); + target.push(...cookies); + lookupDetails.push(`${JSON.stringify(cleanDetails)} => ${cookies.length} cookie(s)`); + } + + function dedupeCookies(cookies) { + const seen = new Set(); + + return cookies.filter((cookie) => { + const key = `${cookie.name}|${cookie.domain}|${cookie.path}|${cookie.storeId || ""}`; + + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); + } + + return { getXsrfTokenCookie, getExactCookieFromUrls, getCookieStores, collectCookies, dedupeCookies }; +} + +// Source: src/platform/background-api.js +function createPlatformBackgroundApi({ + runtimeApi, + environment +}) { + const { browser, setTimeout } = environment; function supportsFirefoxCookieStoreTabs() { return Boolean( runtimeApi.runtime @@ -1153,110 +1303,6 @@ return new Promise((resolve) => setTimeout(resolve, durationMs)); } - async function getXsrfTokenCookie() { - const lookupDetails = []; - const allCookies = []; - const stores = await getCookieStores(lookupDetails); - - for (const store of stores) { - const exactCookie = await getExactCookieFromUrls(store.id, lookupDetails); - - if (exactCookie) { - allCookies.push(exactCookie); - } - } - - for (const store of stores) { - await collectCookies({ - name: XSRF_COOKIE_NAME, - storeId: store.id - }, allCookies, lookupDetails); - } - - for (const store of stores) { - await collectCookies({ - domain: ORACLE_DOMAIN, - storeId: store.id - }, allCookies, lookupDetails); - - await collectCookies({ - domain: `.${ORACLE_DOMAIN}`, - storeId: store.id - }, allCookies, lookupDetails); - } - - for (const store of stores) { - for (const url of ORACLE_COOKIE_URLS) { - await collectCookies({ - url, - storeId: store.id - }, allCookies, lookupDetails); - } - } - - const uniqueCookies = dedupeCookies(allCookies); - const xsrfCookies = uniqueCookies.filter((cookie) => cookie.name.startsWith(XSRF_COOKIE_PREFIX)); - const exactCookie = xsrfCookies.find((cookie) => cookie.name === XSRF_COOKIE_NAME); - const hostCookie = xsrfCookies.find((cookie) => cookie.domain === ORACLE_DOMAIN || cookie.domain === `.${ORACLE_DOMAIN}`); - - return { - cookie: exactCookie || hostCookie || xsrfCookies[0] || null, - matchedCookieNames: xsrfCookies.map((cookie) => `${cookie.name} (${cookie.domain}${cookie.path})`), - lookupDetails - }; - } - - async function getExactCookieFromUrls(storeId, lookupDetails) { - for (const url of ORACLE_COOKIE_URLS) { - const cookie = await cookiesGet({ - url, - name: XSRF_COOKIE_NAME, - storeId - }); - - lookupDetails.push(`${JSON.stringify({ url, name: XSRF_COOKIE_NAME, storeId })} => ${cookie ? "found" : "not found"}`); - - if (cookie) { - return cookie; - } - } - - return null; - } - - async function getCookieStores(lookupDetails) { - try { - const stores = await cookiesGetAllCookieStores(); - lookupDetails.push(`getAllCookieStores => ${stores.length} store(s)`); - return stores.length ? stores : [{ id: undefined }]; - } catch (error) { - lookupDetails.push(`getAllCookieStores => unavailable (${error.message || "unknown error"})`); - return [{ id: undefined }]; - } - } - - async function collectCookies(details, target, lookupDetails) { - const cleanDetails = removeUndefinedValues(details); - const cookies = await cookiesGetAll(cleanDetails); - target.push(...cookies); - lookupDetails.push(`${JSON.stringify(cleanDetails)} => ${cookies.length} cookie(s)`); - } - - function dedupeCookies(cookies) { - const seen = new Set(); - - return cookies.filter((cookie) => { - const key = `${cookie.name}|${cookie.domain}|${cookie.path}|${cookie.storeId || ""}`; - - if (seen.has(key)) { - return false; - } - - seen.add(key); - return true; - }); - } - function cookiesGetAll(details) { if (runtimeApi.cookies.getAll.length <= 1) { return runtimeApi.cookies.getAll(details); @@ -1325,4 +1371,253 @@ Object.entries(details).filter((entry) => entry[1] !== undefined) ); } + + return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues }; +} + +// Source: src/background/main.js +function startBackgroundApplication(dependencies) { + registerBackgroundMessages(dependencies); +} + +// Source: src/background/messages.js +/** Register synchronously; replies retain the WebExtension sendResponse contract. */ +function registerBackgroundMessages({ + messageTypes, + getXsrfTokenCookie, + refreshXsrfCookie, + requestAccountPlans, + requestConsumerTokenService, + requestConsumptionTrend, + requestOpptyProducts, + runtimeApi +}) { +runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!message || !message.type) { + return false; + } + + if (message.type === messageTypes.getXsrfToken) { + getXsrfTokenCookie() + .then((result) => { + sendResponse({ + ok: true, + cookieName: result.cookie ? result.cookie.name : "", + token: result.cookie ? result.cookie.value : "", + matchedCookieNames: result.matchedCookieNames, + lookupDetails: result.lookupDetails + }); + }) + .catch((error) => { + sendResponse({ + ok: false, + cookieName: "", + token: "", + matchedCookieNames: [], + lookupDetails: [], + error: error.message || "Unable to read cookies." + }); + }); + + return true; + } + + if (message.type === messageTypes.refreshXsrfCookie) { + refreshXsrfCookie(sender) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + error: error.message || "Unable to refresh XSRF cookie." + }); + }); + + return true; + } + + if (message.type === messageTypes.requestOpptyProducts) { + requestOpptyProducts(message, sender) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + requestId: message.requestId || "", + error: error.message || "Unable to load opportunity products." + }); + }); + + return true; + } + + if (message.type === messageTypes.requestConsumerTokenService) { + requestConsumerTokenService(sender) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + error: error.message || "Unable to load consumer token service.", + debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] + }); + }); + + return true; + } + + if (message.type === messageTypes.requestAccountPlans) { + requestAccountPlans(message) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + error: error.message || "Unable to load account plans.", + debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] + }); + }); + + return true; + } + + if (message.type === messageTypes.requestConsumptionTrend) { + requestConsumptionTrend(message) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + error: error.message || "Unable to load consumption trend.", + debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] + }); + }); + + return true; + } + + return false; + }); +} + +// Source: src/background/application.js +function createBackgroundApplication(overrides = {}) { + const environment = { + fetch: globalThis.fetch, + browser: globalThis.browser, + chrome: globalThis.chrome, + setTimeout: globalThis.setTimeout, + ...overrides + }; + const messageTypes = createMessageTypes(); + const contracts = createBackgroundContracts(); + const productsCacheState = createProductsCacheState(); + const authConsumerSession = createAuthConsumerSession({ + CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS, + CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS: contracts.CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS, + CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS: contracts.CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS, + CUSTOMER_TOKEN_SERVICE_URL: contracts.CUSTOMER_TOKEN_SERVICE_URL, + CUSTOMER_TOKEN_SESSION_URL: contracts.CUSTOMER_TOKEN_SESSION_URL, + extractTokenServiceAuthorization: (...args) => authSpaCredentials.extractTokenServiceAuthorization(...args), + fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), + supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args), + tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args), + tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args), + wait: (...args) => platformBackgroundApi.wait(...args) + }); + const authSpaCredentials = createAuthSpaCredentials({ + findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args) + }); + const spaAccounts = createSpaAccounts({ + ACCOUNT_PLANS_PAYLOAD: contracts.ACCOUNT_PLANS_PAYLOAD, + ACCOUNT_PLANS_URL: contracts.ACCOUNT_PLANS_URL, + ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL, + extractAccountPlans: (...args) => spaResponseShapes.extractAccountPlans(...args), + fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), + findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args), + normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args) + }); + const spaConsumption = createSpaConsumption({ + CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS, + CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL, + fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), + normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args) + }); + const spaResponseShapes = createSpaResponseShapes(); + const httpBackgroundJson = createHttpBackgroundJson({ + DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT, + environment + }); + const productsBackgroundJobs = createProductsBackgroundJobs({ + messageTypes, + OPPTY_PRODUCTS_CONCURRENCY: contracts.OPPTY_PRODUCTS_CONCURRENCY, + getOpptyProducts: (...args) => productsCache.getOpptyProducts(...args), + normalizeCacheMaxAge: (...args) => productsCache.normalizeCacheMaxAge(...args), + tabsSendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args) + }); + const productsCache = createProductsCache({ + OPPTY_PRODUCTS_CACHE_KEY: contracts.OPPTY_PRODUCTS_CACHE_KEY, + fetchAllOpptyProducts: (...args) => fusionProducts.fetchAllOpptyProducts(...args), + productsCacheState, + storageLocalGet: (...args) => platformBackgroundApi.storageLocalGet(...args), + storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args) + }); + const fusionProducts = createFusionProducts({ + OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS, + OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES, + OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT, + ORACLE_DOMAIN: contracts.ORACLE_DOMAIN, + environment + }); + const authXsrfRecovery = createAuthXsrfRecovery({ + COOKIE_REFRESH_POLL_MS: contracts.COOKIE_REFRESH_POLL_MS, + COOKIE_REFRESH_TIMEOUT_MS: contracts.COOKIE_REFRESH_TIMEOUT_MS, + OPPORTUNITIES_LIST_URL: contracts.OPPORTUNITIES_LIST_URL, + getXsrfTokenCookie: (...args) => authXsrfCookies.getXsrfTokenCookie(...args), + supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args), + tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args), + tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args), + wait: (...args) => platformBackgroundApi.wait(...args) + }); + const authXsrfCookies = createAuthXsrfCookies({ + ORACLE_COOKIE_URLS: contracts.ORACLE_COOKIE_URLS, + ORACLE_DOMAIN: contracts.ORACLE_DOMAIN, + XSRF_COOKIE_NAME: contracts.XSRF_COOKIE_NAME, + XSRF_COOKIE_PREFIX: contracts.XSRF_COOKIE_PREFIX, + cookiesGet: (...args) => platformBackgroundApi.cookiesGet(...args), + cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args), + cookiesGetAllCookieStores: (...args) => platformBackgroundApi.cookiesGetAllCookieStores(...args), + removeUndefinedValues: (...args) => platformBackgroundApi.removeUndefinedValues(...args) + }); + const platformBackgroundApi = createPlatformBackgroundApi({ + runtimeApi: environment.browser || environment.chrome, + environment + }); + const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args); + const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args); + const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args); + const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args); + const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args); + const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args); + const runtimeApi = environment.browser || environment.chrome; + function start() { + startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi }); + } + return { + start, contracts, + productsCacheState, + authConsumerSession, + authSpaCredentials, + spaAccounts, + spaConsumption, + spaResponseShapes, + httpBackgroundJson, + productsBackgroundJobs, + productsCache, + fusionProducts, + authXsrfRecovery, + authXsrfCookies, + platformBackgroundApi + }; +} + +createBackgroundApplication().start(); })(); diff --git a/dist/firefox/content.js b/dist/firefox/content.js index 5543b77..de5e63c 100644 --- a/dist/firefox/content.js +++ b/dist/firefox/content.js @@ -1,6 +1,22 @@ (function () { "use strict"; +const redwoodStyles = "\n .opportunities-extension-scroll-lock {\n overflow: hidden !important;\n }\n\n .opportunities-extension-modal,\n .opportunities-extension-modal * {\n box-sizing: border-box;\n font-family: \"Oracle Sans\", Arial, Helvetica, sans-serif;\n }\n\n .opportunities-extension-modal {\n position: fixed;\n inset: 0;\n z-index: 2147483647;\n background: #f5f4f2;\n color: #000000;\n }\n\n .opportunities-extension-modal[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-shell {\n height: 100vh;\n min-height: 0;\n display: grid;\n grid-template-rows: auto minmax(0, 1fr);\n background: #f5f4f2;\n }\n\n .opportunities-extension-header {\n position: relative;\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 24px;\n padding: 28px 40px 22px;\n border-bottom: 1px solid #dedbd7;\n background:\n linear-gradient(90deg, #8f624a 0 11%, #c74634 11% 23%, #6f5a7f 23% 36%, #00758f 36% 51%, #d4b06a 51% 63%, transparent 63% 100%) top left / 100% 6px no-repeat,\n #ffffff;\n }\n\n .opportunities-extension-header::before {\n position: absolute;\n left: 40px;\n bottom: -1px;\n width: 64px;\n height: 3px;\n background: #00758f;\n content: \"\";\n }\n\n .opportunities-extension-header h1 {\n margin: 0;\n color: #000000;\n font-size: 24px;\n font-weight: 700;\n line-height: 1.2;\n letter-spacing: 0;\n }\n\n .opportunities-extension-header p {\n margin: 0;\n color: #5f5a55;\n font-size: 14px;\n line-height: 1.4;\n }\n\n .opportunities-extension-subtitle-row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n margin-top: 6px;\n }\n\n .opportunities-extension-auth-badge {\n display: inline-flex;\n align-items: center;\n min-height: 22px;\n padding: 2px 8px;\n border-radius: 999px;\n color: #ffffff;\n font-size: 12px;\n font-weight: 700;\n line-height: 1.2;\n }\n\n .opportunities-extension-auth-badge-authenticated {\n background: #3f6f17;\n }\n\n .opportunities-extension-auth-badge-unauthenticated {\n background: #c5331f;\n }\n\n .opportunities-extension-auth-badge-pending {\n background: #6f5a7f;\n }\n\n .opportunities-extension-products-progress {\n display: inline-flex;\n align-items: center;\n min-height: 22px;\n padding: 2px 8px;\n border-radius: 999px;\n background: #e8f4f6;\n color: #006d7a;\n font-size: 12px;\n font-weight: 700;\n line-height: 1.2;\n }\n\n .opportunities-extension-products-progress[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-products-cache-timestamp {\n display: inline-flex;\n align-items: center;\n min-height: 22px;\n padding: 2px 8px;\n border-radius: 999px;\n background: #f0eeeb;\n color: #5f5a55;\n font-size: 11px;\n font-weight: 600;\n line-height: 1.2;\n }\n\n .opportunities-extension-products-cache-timestamp[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-products-progress[data-state=\"running\"]::before {\n width: 12px;\n height: 12px;\n margin-right: 6px;\n border: 2px solid currentColor;\n border-right-color: transparent;\n border-radius: 50%;\n content: \"\";\n animation: opportunities-extension-spin .8s linear infinite;\n }\n\n .opportunities-extension-products-progress[data-state=\"complete\"] {\n background: #e2f2e5;\n color: #356d19;\n }\n\n .opportunities-extension-products-progress[data-state=\"complete-with-errors\"],\n .opportunities-extension-products-progress[data-state=\"error\"] {\n background: #f9e3e1;\n color: #a52b1c;\n }\n\n @keyframes opportunities-extension-spin {\n to { transform: rotate(360deg); }\n }\n\n .opportunities-extension-icon-button {\n width: 36px;\n height: 36px;\n flex: 0 0 36px;\n border: 1px solid transparent;\n border-radius: 4px;\n background: transparent;\n color: #312d2a;\n font-size: 18px;\n font-weight: 600;\n line-height: 1;\n cursor: pointer;\n }\n\n .opportunities-extension-header-actions {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n }\n\n .opportunities-extension-icon-button svg {\n width: 18px;\n height: 18px;\n vertical-align: middle;\n }\n\n .opportunities-extension-icon-button .opportunities-extension-settings-icon {\n width: 20px;\n height: 20px;\n }\n\n .opportunities-extension-icon-button:hover,\n .opportunities-extension-icon-button:focus {\n border-color: #b8b2ad;\n background: #f5f4f2;\n outline: none;\n }\n\n .opportunities-extension-body {\n display: grid;\n min-height: 0;\n grid-template-rows: auto auto minmax(0, 1fr);\n gap: 16px;\n padding: 22px 40px 36px;\n background: #f5f4f2;\n overflow: hidden;\n }\n\n .opportunities-extension-form {\n display: flex;\n align-items: end;\n flex-wrap: wrap;\n gap: 24px;\n min-height: 82px;\n max-width: none;\n margin: 0;\n padding: 14px 16px;\n border: 1px solid #dedbd7;\n border-radius: 4px;\n background: #ffffff;\n }\n\n .opportunities-extension-field {\n display: grid;\n max-width: max-content;\n min-width: 270px;\n width: max-content;\n gap: 6px;\n color: #312d2a;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .opportunities-extension-stage-dashboard {\n display: grid;\n grid-template-columns: repeat(6, minmax(0, 1fr));\n gap: 12px;\n }\n\n .opportunities-extension-stage-dashboard-card {\n display: grid;\n gap: 6px;\n min-width: 0;\n padding: 12px 16px;\n border: 1px solid #dedbd7;\n border-top: 3px solid #5f5a55;\n border-radius: 4px;\n background: #ffffff;\n }\n\n .opportunities-extension-stage-dashboard-card span {\n color: #5f5a55;\n font-size: 11px;\n font-weight: 700;\n line-height: 1.2;\n }\n\n .opportunities-extension-stage-dashboard-value {\n overflow: hidden;\n color: #312d2a;\n font-size: 22px;\n font-weight: 700;\n line-height: 1.2;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .opportunities-extension-stage-dashboard-card[data-stage=\"SQL\"] {\n border-top-color: #008aa6;\n }\n\n .opportunities-extension-total-dashboard-card {\n border-top-color: #312d2a;\n }\n\n .opportunities-extension-stage-dashboard-card[data-stage=\"PIPELINE\"] {\n border-top-color: #6f6863;\n }\n\n .opportunities-extension-stage-dashboard-card[data-stage=\"UPSIDE\"] {\n border-top-color: #c87b00;\n }\n\n .opportunities-extension-stage-dashboard-card[data-stage=\"FORECAST\"] {\n border-top-color: #755b8b;\n }\n\n .opportunities-extension-stage-dashboard-card[data-stage=\"WON\"] {\n border-top-color: #4b7f22;\n }\n\n .opportunities-extension-stage-filter {\n display: grid;\n grid-template-rows: auto 44px;\n gap: 6px;\n min-width: 0;\n }\n\n .opportunities-extension-stage-filter-label {\n color: #312d2a;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .opportunities-extension-stage-filter-controls {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n }\n\n .opportunities-extension-stage-filter-button {\n min-height: 36px;\n padding: 6px 12px;\n border: 1px solid currentColor;\n border-radius: 18px;\n background: #ffffff;\n color: #5f5a55;\n font-size: 12px;\n font-weight: 700;\n letter-spacing: 0;\n line-height: 1.2;\n cursor: pointer;\n }\n\n .opportunities-extension-stage-filter-button[data-stage=\"SQL\"] {\n color: #006b84;\n }\n\n .opportunities-extension-stage-filter-button[data-stage=\"PIPELINE\"] {\n color: #5f5a55;\n }\n\n .opportunities-extension-stage-filter-button[data-stage=\"UPSIDE\"] {\n color: #945400;\n }\n\n .opportunities-extension-stage-filter-button[data-stage=\"FORECAST\"] {\n color: #624e74;\n }\n\n .opportunities-extension-stage-filter-button[data-stage=\"WON\"] {\n color: #3f6f17;\n }\n\n .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"SQL\"] {\n border-color: #006b84;\n background: #006b84;\n color: #ffffff;\n }\n\n .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"PIPELINE\"] {\n border-color: #5f5a55;\n background: #5f5a55;\n color: #ffffff;\n }\n\n .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"UPSIDE\"] {\n border-color: #945400;\n background: #945400;\n color: #ffffff;\n }\n\n .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"FORECAST\"] {\n border-color: #624e74;\n background: #624e74;\n color: #ffffff;\n }\n\n .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"WON\"] {\n border-color: #3f6f17;\n background: #3f6f17;\n color: #ffffff;\n }\n\n .opportunities-extension-stage-filter-button:focus-visible {\n outline: 2px solid #00758f;\n outline-offset: 2px;\n }\n\n .opportunities-extension-status-filter {\n display: grid;\n grid-template-rows: auto 44px;\n gap: 6px;\n min-width: 0;\n }\n\n .opportunities-extension-status-filter-label {\n color: #312d2a;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .opportunities-extension-status-filter-controls {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n }\n\n .opportunities-extension-status-filter-button {\n min-height: 36px;\n padding: 6px 12px;\n border: 1px solid currentColor;\n border-radius: 18px;\n background: #ffffff;\n font-size: 12px;\n font-weight: 700;\n letter-spacing: 0;\n line-height: 1.2;\n cursor: pointer;\n }\n\n .opportunities-extension-status-filter-button[data-status=\"OPEN\"] {\n color: #197a3d;\n }\n\n .opportunities-extension-status-filter-button[data-status=\"CLOSED\"] {\n color: #4b5563;\n }\n\n .opportunities-extension-status-filter-button[data-status=\"LOST\"] {\n color: #b13b34;\n }\n\n .opportunities-extension-status-filter-button[data-status=\"WON\"] {\n color: #1f5f99;\n }\n\n .opportunities-extension-status-filter-button[aria-pressed=\"true\"][data-status=\"OPEN\"] {\n border-color: #197a3d;\n background: #197a3d;\n color: #ffffff;\n }\n\n .opportunities-extension-status-filter-button[aria-pressed=\"true\"][data-status=\"CLOSED\"] {\n border-color: #4b5563;\n background: #4b5563;\n color: #ffffff;\n }\n\n .opportunities-extension-status-filter-button[aria-pressed=\"true\"][data-status=\"LOST\"] {\n border-color: #b13b34;\n background: #b13b34;\n color: #ffffff;\n }\n\n .opportunities-extension-status-filter-button[aria-pressed=\"true\"][data-status=\"WON\"] {\n border-color: #1f5f99;\n background: #1f5f99;\n color: #ffffff;\n }\n\n .opportunities-extension-status-filter-button:focus-visible {\n outline: 2px solid #00758f;\n outline-offset: 2px;\n }\n\n .opportunities-extension-customer-filter {\n position: relative;\n display: grid;\n grid-template-rows: auto 44px;\n gap: 6px;\n min-width: 220px;\n }\n\n .opportunities-extension-customer-filter-label {\n color: #312d2a;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .opportunities-extension-customer-filter-trigger {\n position: relative;\n min-width: 220px;\n min-height: 44px;\n padding: 10px 38px 10px 12px;\n border: 1px solid #b8b2ad;\n border-radius: 3px;\n background: #ffffff;\n color: #312d2a;\n font-size: 14px;\n line-height: 1.3;\n text-align: left;\n cursor: pointer;\n }\n\n .opportunities-extension-customer-filter-trigger::after {\n position: absolute;\n top: 50%;\n right: 16px;\n width: 0;\n height: 0;\n border-top: 6px solid #312d2a;\n border-right: 5px solid transparent;\n border-left: 5px solid transparent;\n content: \"\";\n pointer-events: none;\n transform: translateY(-50%);\n }\n\n .opportunities-extension-customer-filter-trigger:disabled {\n cursor: not-allowed;\n background: #f5f4f2;\n color: #7d7772;\n }\n\n .opportunities-extension-customer-filter-trigger:focus-visible {\n border-color: #312d2a;\n box-shadow: 0 0 0 1px #312d2a;\n outline: none;\n }\n\n .opportunities-extension-customer-filter-panel {\n position: absolute;\n top: calc(100% + 8px);\n left: 0;\n z-index: 4;\n display: grid;\n width: min(360px, calc(100vw - 80px));\n gap: 10px;\n padding: 12px;\n border: 1px solid #8f8a85;\n border-radius: 4px;\n background: #ffffff;\n box-shadow: 0 4px 12px rgba(0, 0, 0, .18);\n }\n\n .opportunities-extension-customer-filter-panel[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-customer-filter-search {\n width: 100%;\n min-height: 40px;\n padding: 8px 10px;\n border: 1px solid #b8b2ad;\n border-radius: 3px;\n background: #ffffff;\n color: #312d2a;\n font-size: 14px;\n }\n\n .opportunities-extension-customer-filter-search:focus {\n border-color: #312d2a;\n box-shadow: 0 0 0 1px #312d2a;\n outline: none;\n }\n\n .opportunities-extension-customer-filter-select-all,\n .opportunities-extension-customer-filter-option {\n display: flex;\n align-items: center;\n gap: 8px;\n color: #312d2a;\n font-size: 13px;\n line-height: 1.35;\n }\n\n .opportunities-extension-customer-filter-select-all {\n min-height: 32px;\n padding-bottom: 8px;\n border-bottom: 1px solid #dedbd7;\n font-weight: 700;\n }\n\n .opportunities-extension-customer-filter-panel input[type=\"checkbox\"] {\n width: 16px;\n height: 16px;\n flex: 0 0 16px;\n accent-color: #00758f;\n }\n\n .opportunities-extension-customer-filter-list {\n display: block;\n max-height: 250px;\n overflow: auto;\n }\n\n .opportunities-extension-customer-filter-option {\n display: grid;\n grid-template-columns: 16px minmax(0, 1fr);\n align-items: start;\n min-height: 0;\n height: auto !important;\n padding: 8px 4px;\n cursor: pointer;\n }\n\n .opportunities-extension-customer-filter-option input[type=\"checkbox\"] {\n margin-top: 1px;\n }\n\n .opportunities-extension-customer-filter-option span {\n display: block;\n min-width: 0;\n line-height: 18px;\n overflow-wrap: anywhere;\n white-space: normal;\n }\n\n .opportunities-extension-customer-filter-option:hover {\n background: #f5f4f2;\n }\n\n .opportunities-extension-customer-filter-empty {\n margin: 4px 0;\n color: #5f5a55;\n font-size: 13px;\n }\n\n .opportunities-extension-table-search-field {\n display: grid;\n grid-template-rows: auto 44px;\n gap: 6px;\n width: 260px;\n color: #312d2a;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.3;\n }\n\n .opportunities-extension-table-search-field input {\n width: 100%;\n min-height: 44px;\n padding: 10px 12px;\n border: 1px solid #b8b2ad;\n border-radius: 3px;\n background: #ffffff;\n color: #312d2a;\n font-size: 14px;\n line-height: 1.3;\n }\n\n .opportunities-extension-table-search-field input:focus {\n border-color: #312d2a;\n box-shadow: 0 0 0 1px #312d2a;\n outline: none;\n }\n\n .opportunities-extension-select-wrap {\n position: relative;\n display: block;\n width: 270px;\n max-width: 100%;\n }\n\n .opportunities-extension-select-wrap::after {\n position: absolute;\n top: 50%;\n right: 16px;\n width: 0;\n height: 0;\n border-left: 5px solid transparent;\n border-right: 5px solid transparent;\n border-top: 6px solid #000000;\n content: \"\";\n pointer-events: none;\n transform: translateY(-50%);\n }\n\n .opportunities-extension-field select {\n width: 100%;\n min-width: 0;\n max-width: 100%;\n min-height: 44px;\n box-sizing: border-box;\n appearance: none;\n border: 1px solid #b8b2ad;\n border-radius: 3px;\n background: #ffffff;\n color: #000000;\n font-size: 14px;\n line-height: 1.3;\n padding: 11px 44px 11px 12px;\n }\n\n .opportunities-extension-field select:focus {\n border-color: #312d2a;\n box-shadow: 0 0 0 1px #312d2a;\n outline: none;\n }\n\n .opportunities-extension-table-surface {\n min-height: 0;\n height: 100%;\n border: 1px solid #dedbd7;\n border-radius: 4px;\n background: #ffffff;\n overflow-x: auto;\n overflow-y: auto;\n }\n\n .opportunities-extension-table {\n width: 100%;\n min-width: 0;\n border-collapse: collapse;\n table-layout: fixed;\n color: #312d2a;\n font-size: 13px;\n line-height: 1.35;\n }\n\n .opportunities-extension-table thead {\n background: #faf9f8;\n }\n\n .opportunities-extension-table th {\n position: sticky;\n top: 0;\n z-index: 1;\n height: 44px;\n border-bottom: 1px solid #b8b2ad;\n background: #faf9f8;\n color: #312d2a;\n font-size: 12px;\n font-weight: 700;\n text-align: left;\n white-space: nowrap;\n }\n\n .opportunities-extension-table td {\n min-height: 48px;\n padding: 12px 16px;\n border-bottom: 1px solid #ebe8e5;\n vertical-align: middle;\n overflow-wrap: anywhere;\n }\n\n .opportunities-extension-table th:nth-child(1) {\n width: 16%;\n }\n\n .opportunities-extension-table th:nth-child(2) {\n width: 8%;\n }\n\n .opportunities-extension-table th:nth-child(3) {\n width: 18%;\n }\n\n .opportunities-extension-table th:nth-child(4) {\n width: 11%;\n }\n\n .opportunities-extension-table th:nth-child(5) {\n width: 8%;\n }\n\n .opportunities-extension-table th:nth-child(6) {\n width: 8%;\n }\n\n .opportunities-extension-table th:nth-child(7) {\n width: 7%;\n }\n\n .opportunities-extension-table th:nth-child(8) {\n width: 6%;\n }\n\n .opportunities-extension-table th:nth-child(9) {\n width: 7%;\n }\n\n .opportunities-extension-table th:nth-child(10) {\n width: 7%;\n }\n\n .opportunities-extension-table th:nth-child(11) {\n width: 4%;\n }\n\n .opportunities-extension-table td:nth-child(2),\n .opportunities-extension-table td:nth-child(5),\n .opportunities-extension-table td:nth-child(6),\n .opportunities-extension-table td:nth-child(8) {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .opportunities-extension-table td:nth-child(9) {\n padding-right: 6px;\n padding-left: 6px;\n white-space: nowrap;\n overflow: visible;\n }\n\n .opportunities-extension-table td:nth-child(7) {\n padding-right: 6px;\n padding-left: 6px;\n white-space: nowrap;\n overflow: visible;\n }\n\n .opportunities-extension-table td:nth-child(10) {\n padding-right: 6px;\n padding-left: 6px;\n font-size: 12px;\n white-space: nowrap;\n }\n\n .opportunities-extension-table td:nth-child(11) {\n padding-right: 6px;\n padding-left: 6px;\n text-align: center;\n white-space: nowrap;\n }\n\n .opportunities-extension-table tbody tr:hover {\n background: #f7f6f4;\n }\n\n .opportunities-extension-table tbody tr:last-child td {\n border-bottom: 0;\n }\n\n .opportunities-extension-table a {\n color: #006b84;\n text-decoration: underline;\n text-decoration-thickness: 1px;\n text-underline-offset: 2px;\n }\n\n .opportunities-extension-table a:hover {\n color: #004f63;\n }\n\n .opportunities-extension-opty-number-content {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n max-width: 100%;\n }\n\n .opportunities-extension-name-content {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n }\n\n .opportunities-extension-name-content > a {\n min-width: 0;\n }\n\n .opportunities-extension-actions-content {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 4px;\n white-space: nowrap;\n }\n\n .opportunities-extension-products-count {\n display: inline-grid;\n width: auto;\n min-width: 20px;\n height: 20px;\n place-items: center;\n flex: 0 0 auto;\n box-sizing: border-box;\n padding: 0 3px;\n border: 1px solid #7ca7b2;\n border-radius: 999px;\n background: #e8f4f6;\n color: #006d7a;\n font-size: 10px;\n font-weight: 700;\n line-height: 1;\n text-align: center;\n vertical-align: middle;\n cursor: pointer;\n }\n\n .opportunities-extension-products-count:hover,\n .opportunities-extension-products-count:focus-visible,\n .opportunities-extension-products-count[aria-expanded=\"true\"] {\n border-color: #00758f;\n background: #00758f;\n color: #ffffff;\n outline: none;\n }\n\n .opportunities-extension-products-count-error {\n border-color: #d8887e;\n background: #f9e3e1;\n color: #a52b1c;\n }\n\n .opportunities-extension-products-detail-row:hover {\n background: transparent !important;\n }\n\n .opportunities-extension-products-detail-cell {\n padding: 0 !important;\n }\n\n .opportunities-extension-products-detail {\n padding: 14px 20px 18px;\n border-bottom: 1px solid #c9c5c1;\n background: #f0eeeb;\n }\n\n .opportunities-extension-products-detail-heading {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n margin-bottom: 10px;\n color: #312d2a;\n }\n\n .opportunities-extension-ai-summary {\n box-sizing: border-box;\n width: 100%;\n min-width: 0;\n margin: 0 0 12px;\n padding: 12px 16px;\n border: 1px solid #dedbd7;\n border-radius: 6px;\n background: #ffffff;\n color: #312d2a;\n font-size: 13px;\n line-height: 1.5;\n text-align: left;\n white-space: normal;\n overflow-wrap: anywhere;\n }\n\n .opportunities-extension-ai-summary strong {\n display: block;\n font-size: 13px;\n font-weight: 600;\n }\n\n .opportunities-extension-ai-summary p {\n margin: 4px 0 0;\n font: inherit;\n white-space: pre-wrap;\n }\n\n .opportunities-extension-ai-summary-error {\n color: #b42318;\n border-color: #d9928b;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-ai-summary {\n background: #292827;\n color: #f6f4f2;\n border-color: #5f5a55;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-ai-summary-error {\n color: #ffb4ab;\n border-color: #a45c55;\n }\n\n .opportunities-extension-products-detail-heading strong {\n font-size: 13px;\n }\n\n .opportunities-extension-products-detail-heading span {\n color: #5f5a55;\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n }\n\n .opportunities-extension-products-table {\n width: 100%;\n table-layout: fixed;\n border-collapse: collapse;\n border: 1px solid #dedbd7;\n background: #ffffff;\n font-size: 12px;\n }\n\n .opportunities-extension-products-table th,\n .opportunities-extension-products-table td {\n position: static;\n height: auto;\n padding: 8px 10px;\n border-bottom: 1px solid #ebe8e5;\n background: transparent;\n overflow-wrap: anywhere;\n vertical-align: middle;\n white-space: normal;\n }\n\n .opportunities-extension-products-table th {\n background: #faf9f8;\n font-size: 11px;\n }\n\n .opportunities-extension-products-table th:nth-child(1) { width: 16%; }\n .opportunities-extension-products-table th:nth-child(2) { width: 16%; }\n .opportunities-extension-products-table th:nth-child(3) { width: 8%; }\n .opportunities-extension-products-table th:nth-child(4) { width: 10%; }\n .opportunities-extension-products-table th:nth-child(5) { width: 8%; }\n .opportunities-extension-products-table th:nth-child(6) { width: 8%; }\n .opportunities-extension-products-table th:nth-child(7) { width: 7%; }\n .opportunities-extension-products-table th:nth-child(8) { width: 10%; }\n .opportunities-extension-products-table th:nth-child(9) { width: 12%; }\n .opportunities-extension-products-table th:nth-child(10) { width: 5%; }\n\n .opportunities-extension-products-empty {\n margin: 0;\n padding: 10px 0;\n color: #5f5a55;\n font-size: 13px;\n }\n\n .opportunities-extension-copy-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n width: 24px;\n height: 24px;\n padding: 3px;\n border: 0;\n border-radius: 3px;\n background: transparent;\n color: #00758f;\n cursor: pointer;\n }\n\n .opportunities-extension-copy-button svg {\n width: 16px;\n height: 16px;\n fill: currentColor;\n }\n\n .opportunities-extension-copy-button:hover,\n .opportunities-extension-copy-button:focus-visible {\n background: #e8f4f6;\n color: #004f63;\n outline: none;\n }\n\n .opportunities-extension-action-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n height: 32px;\n padding: 5px;\n border: 1px solid transparent;\n border-radius: 4px;\n background: transparent;\n color: #00758f;\n cursor: pointer;\n }\n\n .opportunities-extension-action-button svg {\n width: 21px;\n height: 21px;\n }\n\n .opportunities-extension-action-button:hover,\n .opportunities-extension-action-button:focus-visible {\n border-color: #7ca7b2;\n background: #e8f4f6;\n color: #004f63;\n outline: none;\n }\n\n .opportunities-extension-action-button:disabled {\n cursor: wait;\n opacity: .72;\n }\n\n .opportunities-extension-action-button-loading svg {\n animation: opportunities-extension-pulse 1s ease-in-out infinite;\n }\n\n @keyframes opportunities-extension-pulse {\n 50% { opacity: .35; }\n }\n\n .opportunities-extension-copy-toast {\n position: fixed;\n right: 28px;\n bottom: 28px;\n z-index: 3;\n max-width: min(360px, calc(100vw - 56px));\n padding: 12px 16px;\n border-radius: 4px;\n background: #2f6f18;\n box-shadow: 0 4px 12px rgba(0, 0, 0, .18);\n color: #ffffff;\n font-size: 13px;\n font-weight: 700;\n }\n\n .opportunities-extension-copy-toast-error {\n background: #a52b1c;\n }\n\n .opportunities-extension-copy-toast[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-stage-badge {\n display: inline-flex;\n align-items: center;\n min-height: 24px;\n padding: 3px 8px;\n border-radius: 12px;\n background: #ebe8e5;\n color: #312d2a;\n font-size: 11px;\n font-weight: 700;\n line-height: 1.2;\n white-space: nowrap;\n }\n\n .opportunities-extension-stage-badge[data-stage=\"SQL\"] {\n background: #d9f0f5;\n color: #006b84;\n }\n\n .opportunities-extension-stage-badge[data-stage=\"PIPELINE\"] {\n background: #ebe8e5;\n color: #5f5a55;\n }\n\n .opportunities-extension-stage-badge[data-stage=\"UPSIDE\"] {\n background: #fff0d8;\n color: #945400;\n }\n\n .opportunities-extension-stage-badge[data-stage=\"FORECAST\"] {\n background: #eee8f5;\n color: #624e74;\n }\n\n .opportunities-extension-stage-badge[data-stage=\"WON\"] {\n background: #e5f1d9;\n color: #3f6f17;\n }\n\n .opportunities-extension-status-badge {\n display: inline-flex;\n align-items: center;\n min-height: 24px;\n padding: 3px 8px;\n border-radius: 12px;\n background: #ebe8e5;\n color: #312d2a;\n font-size: 11px;\n font-weight: 700;\n line-height: 1.2;\n white-space: nowrap;\n }\n\n .opportunities-extension-status-badge[data-status=\"OPEN\"] {\n background: #e2f2e5;\n color: #197a3d;\n }\n\n .opportunities-extension-status-badge[data-status=\"CLOSED\"] {\n background: #e5e7eb;\n color: #4b5563;\n }\n\n .opportunities-extension-status-badge[data-status=\"LOST\"] {\n background: #f9e3e1;\n color: #b13b34;\n }\n\n .opportunities-extension-status-badge[data-status=\"WON\"] {\n background: #e3edf8;\n color: #1f5f99;\n }\n\n .opportunities-extension-sort-button {\n position: relative;\n display: inline-flex;\n align-items: center;\n width: 100%;\n min-height: 44px;\n padding: 8px 42px 8px 16px;\n border: 0;\n background: transparent;\n color: inherit;\n font: inherit;\n font-weight: inherit;\n letter-spacing: 0;\n line-height: 1.2;\n text-align: left;\n white-space: normal;\n cursor: pointer;\n }\n\n .opportunities-extension-table-header-label {\n display: inline-flex;\n align-items: center;\n min-height: 44px;\n padding: 8px 6px;\n }\n\n .opportunities-extension-sort-button::after {\n position: absolute;\n right: 15px;\n color: #5f5a55;\n content: \"↕\";\n font-size: 16px;\n font-weight: 400;\n }\n\n .opportunities-extension-sort-button[data-sort-direction=\"ascending\"]::after {\n color: #00758f;\n content: \"↑\";\n }\n\n .opportunities-extension-sort-button[data-sort-direction=\"descending\"]::after {\n color: #00758f;\n content: \"↓\";\n }\n\n .opportunities-extension-sort-button:hover {\n background: #f0eeeb;\n }\n\n .opportunities-extension-sort-button:focus-visible {\n outline: 2px solid #00758f;\n outline-offset: -2px;\n }\n\n .opportunities-extension-table-status {\n height: 152px;\n color: #5f5a55;\n font-size: 14px;\n text-align: center;\n }\n\n .opportunities-extension-visually-hidden {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n }\n\n .opportunities-extension-preferences-modal {\n position: absolute;\n inset: 0;\n z-index: 3;\n display: grid;\n place-items: center;\n padding: 24px;\n background: rgba(0, 0, 0, .42);\n }\n\n .opportunities-extension-preferences-modal[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-account-plans-modal {\n position: absolute;\n inset: 0;\n z-index: 4;\n display: grid;\n place-items: center;\n padding: 12px;\n background: rgba(0, 0, 0, .42);\n }\n\n .opportunities-extension-account-plans-modal[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-account-plans-dialog {\n display: grid;\n width: min(1180px, 100%);\n height: min(760px, calc(100vh - 24px));\n min-width: 0;\n grid-template-rows: auto minmax(0, 1fr);\n grid-template-columns: minmax(0, 1fr);\n overflow: hidden;\n border: 1px solid #8f8a85;\n border-radius: 4px;\n background: #ffffff;\n box-shadow: 0 8px 24px rgba(0, 0, 0, .24);\n color: #312d2a;\n }\n\n .opportunities-extension-account-plans-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 16px;\n padding: 14px 20px;\n border-bottom: 1px solid #dedbd7;\n }\n\n .opportunities-extension-account-plans-header h2 {\n margin: 0;\n font-size: 20px;\n line-height: 1.2;\n }\n\n .opportunities-extension-account-plans-header p {\n margin: 5px 0 0;\n color: #5f5a55;\n font-size: 13px;\n line-height: 1.35;\n }\n\n .opportunities-extension-account-plans-content {\n display: grid;\n min-width: 0;\n grid-template-rows: auto minmax(0, 1fr);\n grid-template-columns: minmax(0, 1fr);\n min-height: 0;\n overflow: hidden;\n }\n\n .opportunities-extension-account-plans-content > .opportunities-extension-account-plans-skeleton {\n grid-row: 1 / -1;\n }\n\n .opportunities-extension-account-plans-tabs-navigation {\n position: relative;\n min-width: 0;\n border-bottom: 1px solid #dedbd7;\n }\n\n .opportunities-extension-account-plans-tabs-navigation .opportunities-extension-account-plans-tab-navigation-button[hidden] {\n display: none;\n }\n\n .opportunities-extension-account-plans-tabs {\n display: flex;\n min-width: 0;\n gap: 4px;\n overflow-x: auto;\n padding: 8px 20px 0;\n scrollbar-width: none;\n }\n\n .opportunities-extension-account-plans-tabs::-webkit-scrollbar {\n display: none;\n }\n\n .opportunities-extension-account-plans-tab-navigation-button {\n display: grid;\n position: absolute;\n z-index: 1;\n top: 50%;\n width: 32px;\n height: 32px;\n place-items: center;\n padding: 0;\n border: 1px solid #8f8a85;\n border-radius: 4px;\n background: #ffffff;\n color: #312d2a;\n box-shadow: 0 2px 6px rgba(0, 0, 0, .16);\n transform: translateY(-50%);\n cursor: pointer;\n }\n\n .opportunities-extension-account-plans-tab-navigation-button:first-child {\n left: 8px;\n }\n\n .opportunities-extension-account-plans-tab-navigation-button:last-child {\n right: 8px;\n }\n\n .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) {\n border-color: #00758f;\n background: #f0eeeb;\n color: #005e73;\n }\n\n .opportunities-extension-account-plans-tab-navigation-button:disabled {\n border-color: #dedbd7;\n color: #b8b2ad;\n cursor: default;\n }\n\n .opportunities-extension-account-plans-tab-navigation-button:focus-visible {\n outline: 2px solid #00758f;\n outline-offset: 2px;\n }\n\n .opportunities-extension-account-plans-tab-navigation-button svg {\n width: 18px;\n height: 18px;\n }\n\n .opportunities-extension-account-plans-panels {\n min-width: 0;\n min-height: 0;\n overflow: hidden;\n }\n\n .opportunities-extension-account-plan-tab {\n min-height: 38px;\n flex: 0 0 auto;\n padding: 7px 12px;\n border: 0;\n border-bottom: 3px solid transparent;\n background: transparent;\n color: #5f5a55;\n font-size: 13px;\n font-weight: 600;\n cursor: pointer;\n }\n\n .opportunities-extension-account-plan-tab:hover {\n background: #f0eeeb;\n color: #312d2a;\n }\n\n .opportunities-extension-account-plan-tab[aria-selected=\"true\"] {\n border-bottom-color: #00758f;\n color: #006d7a;\n }\n\n .opportunities-extension-account-plan-tab:focus-visible {\n outline: 2px solid #00758f;\n outline-offset: -2px;\n }\n\n .opportunities-extension-account-plan-panel {\n display: grid;\n height: 100%;\n min-height: 0;\n grid-template-rows: auto minmax(0, 1fr);\n box-sizing: border-box;\n padding: 16px 20px;\n }\n\n .opportunities-extension-account-plan-panel[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-account-plan-details {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 0 24px;\n margin: 0;\n }\n\n .opportunities-extension-account-plan-details > div {\n display: grid;\n grid-template-columns: minmax(104px, 38%) minmax(0, 1fr);\n gap: 10px;\n min-height: 42px;\n align-items: center;\n padding: 7px 0;\n border-bottom: 1px solid #ebe8e5;\n }\n\n .opportunities-extension-account-plan-details dt {\n color: #5f5a55;\n font-size: 12px;\n font-weight: 700;\n }\n\n .opportunities-extension-account-plan-details dd {\n min-width: 0;\n margin: 0;\n color: #312d2a;\n font-size: 13px;\n overflow-wrap: anywhere;\n }\n\n .opportunities-extension-consumption-trend {\n display: grid;\n min-height: 0;\n grid-template-rows: auto minmax(0, 1fr);\n margin-top: 14px;\n padding-top: 12px;\n border-top: 1px solid #dedbd7;\n }\n\n .opportunities-extension-consumption-trend-header {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 24px;\n }\n\n .opportunities-extension-consumption-periods {\n display: flex;\n align-items: center;\n gap: 4px;\n }\n\n .opportunities-extension-consumption-periods button {\n min-height: 38px;\n padding: 7px 14px;\n border: 1px solid transparent;\n border-radius: 4px;\n background: transparent;\n color: #312d2a;\n font-size: 13px;\n font-weight: 600;\n line-height: 1.2;\n cursor: pointer;\n }\n\n .opportunities-extension-consumption-periods button:hover {\n background: #f0eeeb;\n }\n\n .opportunities-extension-consumption-periods button[aria-pressed=\"true\"] {\n border-color: #00758f;\n background: #e7f6f8;\n color: #005e73;\n }\n\n .opportunities-extension-consumption-periods button:focus-visible,\n .opportunities-extension-consumption-navigation-button:focus-visible {\n outline: 2px solid #00758f;\n outline-offset: 2px;\n }\n\n .opportunities-extension-consumption-chart {\n display: grid;\n min-height: 0;\n grid-template-rows: auto minmax(0, 1fr);\n margin-top: 4px;\n }\n\n .opportunities-extension-consumption-navigation {\n display: grid;\n grid-template-columns: 36px minmax(130px, auto) 36px;\n align-items: center;\n justify-content: center;\n gap: 8px;\n min-height: 40px;\n margin-bottom: 2px;\n }\n\n .opportunities-extension-consumption-navigation strong {\n color: #312d2a;\n font-size: 13px;\n text-align: center;\n }\n\n .opportunities-extension-consumption-navigation-button {\n display: grid;\n width: 36px;\n height: 36px;\n place-items: center;\n padding: 0;\n border: 1px solid #8f8a85;\n border-radius: 4px;\n background: #ffffff;\n color: #312d2a;\n cursor: pointer;\n }\n\n .opportunities-extension-consumption-navigation-button:hover:not(:disabled) {\n border-color: #00758f;\n background: #f0eeeb;\n color: #005e73;\n }\n\n .opportunities-extension-consumption-navigation-button:disabled {\n border-color: #dedbd7;\n color: #b8b2ad;\n cursor: default;\n }\n\n .opportunities-extension-consumption-navigation-button svg {\n width: 18px;\n height: 18px;\n }\n\n .opportunities-extension-consumption-figure {\n position: relative;\n min-width: 0;\n min-height: 0;\n margin: 0;\n scrollbar-width: none;\n }\n\n .opportunities-extension-consumption-figure::-webkit-scrollbar {\n display: none;\n }\n\n .opportunities-extension-consumption-visual {\n display: grid;\n height: 100%;\n min-height: 0;\n grid-template-columns: minmax(0, 1fr) 176px;\n align-items: center;\n gap: 16px;\n }\n\n .opportunities-extension-consumption-svg {\n display: block;\n width: 100%;\n height: 300px;\n min-height: 0;\n overflow: visible;\n }\n\n .opportunities-extension-consumption-svg .consumption-grid-line {\n stroke: #dedbd7;\n stroke-width: 1;\n shape-rendering: crispEdges;\n }\n\n .opportunities-extension-consumption-svg .consumption-axis-label,\n .opportunities-extension-consumption-svg .consumption-x-label {\n fill: #5f5a55;\n font-family: Arial, sans-serif;\n font-size: 12px;\n }\n\n .opportunities-extension-consumption-svg .consumption-value-label {\n fill: #312d2a;\n font-family: Arial, sans-serif;\n font-size: 12px;\n font-weight: 700;\n }\n\n .opportunities-extension-consumption-svg .consumption-bar-funded {\n fill: #b77888;\n }\n\n .opportunities-extension-consumption-svg .consumption-bar-overage {\n fill: #de7907;\n }\n\n .opportunities-extension-consumption-svg .consumption-average-line {\n stroke: #de7907;\n stroke-width: 1.5;\n stroke-dasharray: 6 6;\n }\n\n .opportunities-extension-consumption-svg .consumption-bar-group {\n cursor: default;\n outline: none;\n }\n\n .opportunities-extension-consumption-svg .consumption-bar-group rect {\n transition: filter .16s ease, opacity .16s ease, stroke .16s ease;\n }\n\n .opportunities-extension-consumption-svg .consumption-bar-group:hover rect,\n .opportunities-extension-consumption-svg .consumption-bar-group:focus rect {\n filter: brightness(1.06);\n stroke: #312d2a;\n stroke-width: 1.5;\n }\n\n .opportunities-extension-consumption-svg .consumption-animated-label {\n opacity: 0;\n animation: opportunities-extension-consumption-label-in .24s ease-out forwards;\n }\n\n .opportunities-extension-consumption-svg .consumption-animated-average {\n opacity: 0;\n animation: opportunities-extension-consumption-average-in .35s .32s ease-out forwards;\n }\n\n @keyframes opportunities-extension-consumption-label-in {\n from { opacity: 0; transform: translateY(4px); }\n to { opacity: 1; transform: translateY(0); }\n }\n\n @keyframes opportunities-extension-consumption-average-in {\n from { opacity: 0; }\n to { opacity: 1; }\n }\n\n .opportunities-extension-consumption-legend {\n display: grid;\n align-content: center;\n gap: 4px;\n }\n\n .opportunities-extension-consumption-legend button {\n display: grid;\n min-width: 0;\n grid-template-columns: 16px minmax(0, 1fr);\n align-items: center;\n gap: 8px;\n padding: 6px 4px;\n border: 0;\n border-radius: 4px;\n background: transparent;\n color: #312d2a;\n font-size: 12px;\n line-height: 1.25;\n text-align: left;\n cursor: pointer;\n }\n\n .opportunities-extension-consumption-legend button:hover {\n background: #f0eeeb;\n }\n\n .opportunities-extension-consumption-legend button:focus-visible {\n outline: 2px solid #00758f;\n outline-offset: 1px;\n }\n\n .opportunities-extension-consumption-legend button[aria-pressed=\"false\"] {\n color: #77726d;\n opacity: .55;\n }\n\n .opportunities-extension-consumption-legend-swatch {\n display: block;\n width: 12px;\n height: 12px;\n justify-self: center;\n }\n\n .opportunities-extension-consumption-legend-funded {\n background: #b77888;\n }\n\n .opportunities-extension-consumption-legend-overage {\n background: #de7907;\n }\n\n .opportunities-extension-consumption-legend-average {\n height: 0;\n border-top: 2px dashed #de7907;\n }\n\n .opportunities-extension-consumption-tooltip {\n position: absolute;\n z-index: 2;\n display: grid;\n min-width: 190px;\n max-width: 260px;\n gap: 4px;\n padding: 10px 12px;\n border: 1px solid #8f8a85;\n border-radius: 4px;\n background: #ffffff;\n box-shadow: 0 4px 12px rgba(0, 0, 0, .2);\n color: #312d2a;\n font-size: 12px;\n line-height: 1.3;\n pointer-events: none;\n }\n\n .opportunities-extension-consumption-tooltip[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-consumption-tooltip strong {\n margin-bottom: 2px;\n font-size: 13px;\n }\n\n .opportunities-extension-consumption-chart-skeleton {\n height: 100%;\n min-height: 0;\n padding: 34px 62px 24px;\n box-sizing: border-box;\n }\n\n .opportunities-extension-consumption-chart-skeleton-plot {\n display: flex;\n height: 100%;\n min-height: 220px;\n align-items: flex-end;\n justify-content: space-around;\n gap: 12px;\n padding: 0 14px;\n border-bottom: 1px solid #dedbd7;\n border-left: 1px solid #dedbd7;\n }\n\n .opportunities-extension-consumption-chart-skeleton-plot span {\n width: min(38px, 6%);\n border-radius: 2px 2px 0 0;\n background: #dedbd7;\n animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate;\n }\n\n .opportunities-extension-consumption-chart-empty,\n .opportunities-extension-consumption-chart-error {\n display: grid;\n min-height: 220px;\n place-items: center;\n margin: 0;\n color: #5f5a55;\n font-size: 14px;\n text-align: center;\n }\n\n .opportunities-extension-consumption-chart-error {\n color: #9f2d20;\n }\n\n .opportunities-extension-account-plan-status-indicator {\n display: inline-block;\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: #9b9691;\n box-shadow: 0 0 0 3px #efedeb;\n vertical-align: middle;\n }\n\n .opportunities-extension-account-plan-status-indicator[data-active=\"true\"] {\n background: #3f6f17;\n box-shadow: 0 0 0 3px #e2f2e5;\n }\n\n .opportunities-extension-account-plan-tab .opportunities-extension-account-plan-status-indicator {\n margin-right: 8px;\n }\n\n .opportunities-extension-account-plans-skeleton-tabs {\n display: flex;\n min-height: 46px;\n align-items: center;\n padding: 0 20px;\n border-bottom: 1px solid #dedbd7;\n }\n\n .opportunities-extension-account-plans-skeleton {\n display: grid;\n height: 100%;\n min-height: 0;\n grid-template-rows: auto minmax(0, 1fr);\n overflow: hidden;\n }\n\n .opportunities-extension-account-plans-skeleton-panel {\n padding: 16px 20px;\n }\n\n .opportunities-extension-account-plans-skeleton-tab {\n display: inline-flex;\n align-items: center;\n gap: 9px;\n min-height: 38px;\n padding: 0 12px;\n border-bottom: 3px solid #00758f;\n }\n\n .opportunities-extension-account-plans-skeleton-status {\n display: block;\n width: 10px;\n height: 10px;\n flex: 0 0 auto;\n border-radius: 50%;\n background: #dedbd7;\n box-shadow: 0 0 0 3px #efedeb;\n animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate;\n }\n\n .opportunities-extension-account-plans-skeleton-details {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 0 24px;\n padding: 0;\n }\n\n .opportunities-extension-account-plans-skeleton-details > div {\n display: grid;\n grid-template-columns: minmax(104px, 38%) minmax(0, 1fr);\n align-items: center;\n gap: 10px;\n min-height: 42px;\n padding: 7px 0;\n border-bottom: 1px solid #ebe8e5;\n }\n\n .opportunities-extension-account-plans-skeleton-bar {\n display: block;\n height: 12px;\n border-radius: 3px;\n background: #dedbd7;\n animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate;\n }\n\n .opportunities-extension-account-plans-skeleton-tabs .opportunities-extension-account-plans-skeleton-bar {\n height: 14px;\n }\n\n .opportunities-extension-account-plans-skeleton-label {\n width: 74%;\n height: 10px;\n }\n\n .opportunities-extension-account-plans-skeleton-trend {\n display: grid;\n min-height: 0;\n grid-template-rows: auto auto minmax(0, 1fr);\n margin: 14px 0 0;\n padding-top: 12px;\n border-top: 1px solid #dedbd7;\n }\n\n .opportunities-extension-account-plans-skeleton-trend-header {\n display: flex;\n justify-content: flex-end;\n min-height: 38px;\n }\n\n .opportunities-extension-account-plans-skeleton-periods {\n display: flex;\n align-items: center;\n gap: 16px;\n }\n\n .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar {\n height: 12px;\n }\n\n .opportunities-extension-account-plans-skeleton-period-active {\n height: 38px !important;\n border: 1px solid #8cc8d3;\n background: #e7f6f8;\n }\n\n .opportunities-extension-account-plans-skeleton-navigation {\n display: grid;\n grid-template-columns: 32px 124px 32px;\n align-items: center;\n justify-content: center;\n gap: 8px;\n min-height: 54px;\n }\n\n .opportunities-extension-account-plans-skeleton-navigation .opportunities-extension-account-plans-skeleton-bar {\n justify-self: center;\n }\n\n .opportunities-extension-account-plans-skeleton-chart {\n display: grid;\n min-height: 0;\n grid-template-columns: minmax(0, 1fr) 150px;\n align-items: center;\n gap: 28px;\n padding: 12px 14px 0 62px;\n }\n\n .opportunities-extension-account-plans-skeleton-plot {\n display: flex;\n position: relative;\n height: 300px;\n min-height: 0;\n box-sizing: border-box;\n align-items: flex-end;\n justify-content: space-around;\n gap: 12px;\n padding: 0 14px 64px;\n background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #ebe8e5 58px, #ebe8e5 59px) left top / 100% 236px no-repeat;\n }\n\n .opportunities-extension-account-plans-skeleton-plot::before,\n .opportunities-extension-account-plans-skeleton-plot::after {\n position: absolute;\n display: block;\n content: \"\";\n background: #dedbd7;\n }\n\n .opportunities-extension-account-plans-skeleton-plot::before {\n top: 0;\n bottom: 64px;\n left: 0;\n width: 1px;\n }\n\n .opportunities-extension-account-plans-skeleton-plot::after {\n right: 0;\n bottom: 64px;\n left: 0;\n height: 1px;\n }\n\n .opportunities-extension-account-plans-skeleton-plot .opportunities-extension-account-plans-skeleton-bar {\n width: min(38px, 7%);\n flex: 0 1 38px;\n border-radius: 2px 2px 0 0;\n }\n\n .opportunities-extension-account-plans-skeleton-legend {\n display: grid;\n align-content: center;\n gap: 18px;\n min-height: 0;\n }\n\n .opportunities-extension-account-plans-skeleton-legend .opportunities-extension-account-plans-skeleton-bar {\n height: 11px;\n }\n\n @keyframes opportunities-extension-skeleton-pulse {\n from { opacity: .48; }\n to { opacity: 1; }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .opportunities-extension-account-plans-skeleton-bar,\n .opportunities-extension-consumption-chart-skeleton-plot span,\n .opportunities-extension-consumption-svg .consumption-animated-label,\n .opportunities-extension-consumption-svg .consumption-animated-average {\n animation: none;\n opacity: 1;\n }\n }\n\n .opportunities-extension-account-plans-empty,\n .opportunities-extension-account-plans-error {\n margin: 0;\n padding: 28px 20px;\n color: #5f5a55;\n font-size: 14px;\n }\n\n .opportunities-extension-account-plans-error {\n color: #9f2d20;\n }\n\n .opportunities-extension-preferences-dialog {\n display: grid;\n width: min(680px, 100%);\n max-height: min(720px, calc(100vh - 48px));\n grid-template-rows: auto minmax(0, 1fr) auto;\n overflow: hidden;\n border: 1px solid #8f8a85;\n border-radius: 4px;\n background: #ffffff;\n box-shadow: 0 8px 24px rgba(0, 0, 0, .24);\n color: #312d2a;\n }\n\n .opportunities-extension-preferences-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n padding: 18px 20px;\n border-bottom: 1px solid #dedbd7;\n }\n\n .opportunities-extension-preferences-header h2 {\n margin: 0;\n font-size: 20px;\n font-weight: 700;\n line-height: 1.2;\n }\n\n .opportunities-extension-preferences-content {\n min-height: 0;\n overflow: auto;\n padding: 20px;\n }\n\n .opportunities-extension-preferences-fieldset {\n min-width: 0;\n margin: 0;\n padding: 0;\n border: 0;\n }\n\n .opportunities-extension-preferences-fieldset legend {\n margin-bottom: 12px;\n padding: 0;\n color: #312d2a;\n font-size: 15px;\n font-weight: 700;\n line-height: 1.3;\n }\n\n .opportunities-extension-preferences-options {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 4px 20px;\n }\n\n .opportunities-extension-preferences-option {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n min-width: 0;\n padding: 9px 8px;\n border-radius: 4px;\n color: #312d2a;\n font-size: 14px;\n line-height: 1.35;\n cursor: pointer;\n }\n\n .opportunities-extension-preferences-option:hover {\n background: #f0eeeb;\n }\n\n .opportunities-extension-preferences-option input {\n width: 18px;\n height: 18px;\n flex: 0 0 18px;\n margin: 0;\n accent-color: #00758f;\n cursor: pointer;\n }\n\n .opportunities-extension-preferences-cache-field {\n display: grid;\n width: min(280px, 100%);\n gap: 6px;\n margin-top: 24px;\n color: #312d2a;\n font-size: 14px;\n font-weight: 700;\n }\n\n .opportunities-extension-preferences-cache-field select {\n width: 100%;\n height: 40px;\n padding: 0 36px 0 12px;\n border: 1px solid #8f8a85;\n border-radius: 4px;\n background: #ffffff;\n color: #312d2a;\n font-size: 14px;\n font-weight: 400;\n }\n\n .opportunities-extension-preferences-cache-field select:focus {\n border-color: #00758f;\n outline: 2px solid #bde7ee;\n outline-offset: 0;\n }\n\n .opportunities-extension-preferences-footer {\n display: flex;\n justify-content: flex-end;\n padding: 14px 20px;\n border-top: 1px solid #dedbd7;\n }\n\n .opportunities-extension-button {\n min-width: 88px;\n min-height: 36px;\n padding: 7px 16px;\n border: 1px solid transparent;\n border-radius: 4px;\n font-size: 14px;\n font-weight: 600;\n line-height: 1.2;\n cursor: pointer;\n }\n\n .opportunities-extension-button-primary {\n border-color: #00758f;\n background: #00758f;\n color: #ffffff;\n }\n\n .opportunities-extension-button-primary:hover,\n .opportunities-extension-button-primary:focus-visible {\n border-color: #005e73;\n background: #005e73;\n outline: none;\n }\n\n .opportunities-extension-debug-panel {\n position: fixed;\n right: 24px;\n bottom: 24px;\n z-index: 1;\n width: min(640px, calc(100vw - 48px));\n max-height: min(560px, calc(100vh - 120px));\n overflow: auto;\n border: 1px solid #8f8a85;\n border-radius: 4px;\n background: #ffffff;\n box-shadow: 0 2px 8px rgba(0, 0, 0, .16);\n padding: 16px;\n color: #000000;\n }\n\n .opportunities-extension-debug-panel[hidden] {\n display: none !important;\n }\n\n .opportunities-extension-debug-panel h2 {\n margin: 0;\n font-size: 18px;\n font-weight: 700;\n line-height: 1.25;\n }\n\n .opportunities-extension-debug-panel p {\n margin: 4px 0 0;\n color: #5f5a55;\n font-size: 13px;\n }\n\n .opportunities-extension-debug-panel-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 12px;\n margin-bottom: 12px;\n }\n\n .opportunities-extension-debug-visibility-button {\n width: 34px;\n height: 34px;\n flex: 0 0 34px;\n color: #312d2a;\n }\n\n .opportunities-extension-debug-visibility-button svg {\n width: 20px;\n height: 20px;\n }\n\n .opportunities-extension-debug-list {\n display: grid;\n gap: 10px;\n }\n\n .opportunities-extension-debug-item {\n border: 1px solid #dedbd7;\n border-left-width: 4px;\n border-radius: 4px;\n background: #faf9f8;\n }\n\n .opportunities-extension-debug-item[open] {\n background: #ffffff;\n }\n\n .opportunities-extension-debug-item-success {\n border-left-color: #3f6f17;\n }\n\n .opportunities-extension-debug-item-success .opportunities-extension-debug-status {\n background: #e2f2e5;\n color: #356d19;\n }\n\n .opportunities-extension-debug-item-failed {\n border-left-color: #c5331f;\n }\n\n .opportunities-extension-debug-item-failed .opportunities-extension-debug-status {\n background: #f9e3e1;\n color: #a52b1c;\n }\n\n .opportunities-extension-debug-item-pending {\n border-left-color: #6f5a7f;\n }\n\n .opportunities-extension-debug-item-pending .opportunities-extension-debug-status {\n background: #eee8f2;\n color: #6f5a7f;\n }\n\n .opportunities-extension-debug-heading {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n min-height: 42px;\n padding: 9px 12px 9px 10px;\n cursor: pointer;\n list-style: none;\n }\n\n .opportunities-extension-debug-heading::-webkit-details-marker {\n display: none;\n }\n\n .opportunities-extension-debug-heading::before {\n content: \"⌄\";\n width: 14px;\n color: #5f5a55;\n font-size: 16px;\n line-height: 1;\n transform: rotate(-90deg);\n transition: transform .15s ease;\n }\n\n .opportunities-extension-debug-item[open] .opportunities-extension-debug-heading::before {\n transform: rotate(0deg);\n }\n\n .opportunities-extension-debug-summary-meta {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n margin-left: auto;\n }\n\n .opportunities-extension-debug-http-status,\n .opportunities-extension-debug-status {\n display: inline-flex;\n align-items: center;\n min-height: 22px;\n padding: 0 8px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 700;\n line-height: 1;\n text-transform: uppercase;\n }\n\n .opportunities-extension-debug-http-status {\n background: #e8f4f6;\n color: #006d7a;\n }\n\n .opportunities-extension-debug-status {\n background: #ebe8e5;\n color: #5f5a55;\n }\n\n .opportunities-extension-debug-heading strong {\n font-size: 13px;\n }\n\n .opportunities-extension-debug-item pre {\n margin: 0;\n padding: 8px 12px 12px;\n color: #312d2a;\n font-family: Consolas, \"Courier New\", monospace;\n font-size: 12px;\n line-height: 1.45;\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n .opportunities-extension-debug-empty {\n padding: 14px;\n border: 1px dashed #b8b2ad;\n border-radius: 4px;\n color: #5f5a55;\n font-size: 13px;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] {\n background: #1f1f1f;\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-shell,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-body {\n background: #1f1f1f;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-header {\n border-bottom-color: #4e4a46;\n background:\n linear-gradient(90deg, #a8795d 0 11%, #dd5a47 11% 23%, #876e9b 23% 36%, #00a6be 36% 51%, #dfbd76 51% 63%, transparent 63% 100%) top left / 100% 6px no-repeat,\n #292827;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-header h1,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-dashboard-value,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-field,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-label,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-label,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-label,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table-search-field {\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-header p,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-header p,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-dashboard-card span,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-empty,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-detail-heading span,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-empty,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-empty,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plan-details dt,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-debug-panel p,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-debug-empty {\n color: #c9c5c1;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-error {\n color: #ffb4a8;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-chart-error {\n color: #ffb4a8;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-bar {\n background: #5b5753;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-status {\n background: #5b5753;\n box-shadow: 0 0 0 3px #3b3937;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-period-active {\n border-color: #3f7580;\n background: #23474e;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-chart-skeleton-plot span {\n background: #5b5753;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plan-status-indicator {\n background: #8f8a85;\n box-shadow: 0 0 0 3px #3b3937;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plan-status-indicator[data-active=\"true\"] {\n background: #7fb05b;\n box-shadow: 0 0 0 3px #314527;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-form,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-dashboard-card,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table-surface,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-panel,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-dialog,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-dialog,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-table,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-debug-panel,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-debug-item[open] {\n border-color: #4e4a46;\n background: #292827;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-trigger,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-search,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table-search-field input,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-field select,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-cache-field select,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button {\n border-color: #6a6560;\n background: #333130;\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-trigger::after {\n border-top-color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-select-wrap::after {\n border-top-color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-trigger:disabled {\n background: #292827;\n color: #918b86;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-search::placeholder,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table-search-field input::placeholder {\n color: #b5b0ab;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-select-all,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-option,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-header h2,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-header h2,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-fieldset legend,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-option,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-cache-field,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-detail-heading,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plan-details dd,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-debug-heading,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-debug-item pre,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-debug-panel h2 {\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-navigation strong {\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-chart-empty {\n color: #c9c5c1;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-select-all,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table th {\n border-bottom-color: #4e4a46;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-header,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-footer,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-header,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-tabs-navigation,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-tabs,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plan-details > div,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-tabs,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-details > div {\n border-color: #4e4a46;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-trend,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-chart-skeleton-plot,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-trend,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-plot {\n border-color: #4e4a46;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-plot {\n background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #4e4a46 58px, #4e4a46 59px) left top / 100% 236px no-repeat;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-plot::before,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-skeleton-plot::after {\n background: #4e4a46;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plan-tab {\n color: #c9c5c1;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plan-tab:hover {\n background: #3b3937;\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plan-tab[aria-selected=\"true\"] {\n border-bottom-color: #43c4d5;\n color: #91e4ed;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-periods button {\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-periods button:hover {\n background: #3b3937;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-periods button[aria-pressed=\"true\"] {\n border-color: #43c4d5;\n background: #173e43;\n color: #91e4ed;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-navigation-button,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-tab-navigation-button {\n border-color: #6a6560;\n background: #333130;\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-navigation-button:hover:not(:disabled),\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) {\n border-color: #43c4d5;\n background: #3b3937;\n color: #91e4ed;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-navigation-button:disabled,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-account-plans-tab-navigation-button:disabled {\n border-color: #4e4a46;\n color: #746f6a;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-grid-line {\n stroke: #4e4a46;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-axis-label,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-x-label {\n fill: #c9c5c1;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-value-label {\n fill: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-bar-funded {\n fill: #ca8798;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-bar-overage {\n fill: #f09a32;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-average-line {\n stroke: #f09a32;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-bar-group:hover rect,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-svg .consumption-bar-group:focus rect {\n stroke: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-legend button {\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-legend button:hover {\n background: #3b3937;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-legend button[aria-pressed=\"false\"] {\n color: #a8a39e;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-legend-funded {\n background: #ca8798;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-legend-overage {\n background: #f09a32;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-legend-average {\n border-top-color: #f09a32;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-consumption-tooltip {\n border-color: #6a6560;\n background: #333130;\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-option:hover {\n background: #3b3937;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-preferences-option input {\n accent-color: #43c4d5;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-button-primary {\n border-color: #00a6be;\n background: #008aa6;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-button-primary:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-button-primary:focus-visible {\n border-color: #43c4d5;\n background: #006d7a;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-customer-filter-option:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table tbody tr:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-sort-button:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-icon-button:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-icon-button:focus {\n background: #3b3937;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table thead,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table th,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-table th {\n background: #333130;\n color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table td {\n border-bottom-color: #3f3c39;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-detail {\n border-bottom-color: #4e4a46;\n background: #252423;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-table th,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-table td {\n border-bottom-color: #3f3c39;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-count {\n border-color: #2f9bae;\n background: #1d4e55;\n color: #91e4ed;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-count:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-count:focus-visible,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-count[aria-expanded=\"true\"] {\n border-color: #43c4d5;\n background: #008aa6;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-progress[data-state=\"running\"] {\n background: #1d4e55;\n color: #91e4ed;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-progress[data-state=\"complete\"] {\n background: #244f2e;\n color: #a8dfb7;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-progress[data-state=\"complete-with-errors\"],\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-progress[data-state=\"error\"],\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-count-error {\n border-color: #b13b34;\n background: #5f2926;\n color: #ffb4ad;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-products-cache-timestamp {\n background: #3b3937;\n color: #c9c5c1;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table a,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-copy-button,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-action-button {\n color: #43c4d5;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-table a:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-copy-button:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-copy-button:focus-visible,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-action-button:hover,\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-action-button:focus-visible {\n background: #1d4e55;\n color: #91e4ed;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[data-stage=\"SQL\"] {\n color: #43c4d5;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[data-stage=\"PIPELINE\"] {\n color: #d1cbc6;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[data-stage=\"UPSIDE\"] {\n color: #ffbf62;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[data-stage=\"FORECAST\"] {\n color: #d2b7e9;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[data-stage=\"WON\"] {\n color: #9bd46a;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-dashboard-card[data-stage=\"SQL\"] {\n border-top-color: #43c4d5;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-total-dashboard-card {\n border-top-color: #f6f4f2;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-dashboard-card[data-stage=\"PIPELINE\"] {\n border-top-color: #c0b9b3;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-dashboard-card[data-stage=\"UPSIDE\"] {\n border-top-color: #ffb84d;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-dashboard-card[data-stage=\"FORECAST\"] {\n border-top-color: #cba7e7;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-dashboard-card[data-stage=\"WON\"] {\n border-top-color: #92cf5b;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"SQL\"] {\n border-color: #008aa6;\n background: #008aa6;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"PIPELINE\"] {\n border-color: #6f6863;\n background: #6f6863;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"UPSIDE\"] {\n border-color: #b96e00;\n background: #b96e00;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"FORECAST\"] {\n border-color: #755b8b;\n background: #755b8b;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-stage-filter-button[aria-pressed=\"true\"][data-stage=\"WON\"] {\n border-color: #4b7f22;\n background: #4b7f22;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button[data-status=\"OPEN\"] {\n color: #69c985;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button[data-status=\"CLOSED\"] {\n color: #c3cad3;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button[data-status=\"LOST\"] {\n color: #ff9d94;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button[data-status=\"WON\"] {\n color: #83bfff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button[aria-pressed=\"true\"][data-status=\"OPEN\"] {\n border-color: #197a3d;\n background: #197a3d;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button[aria-pressed=\"true\"][data-status=\"CLOSED\"] {\n border-color: #4b5563;\n background: #4b5563;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button[aria-pressed=\"true\"][data-status=\"LOST\"] {\n border-color: #b13b34;\n background: #b13b34;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-status-filter-button[aria-pressed=\"true\"][data-status=\"WON\"] {\n border-color: #1f5f99;\n background: #1f5f99;\n color: #ffffff;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-debug-item {\n border-color: #4e4a46;\n background: #252423;\n }\n\n .opportunities-extension-modal[data-theme=\"dark\"] .opportunities-extension-icon-button {\n color: #f6f4f2;\n }\n\n @media (min-width: 1201px) and (max-width: 1450px) {\n .opportunities-extension-table th:nth-child(1) {\n width: 16%;\n }\n\n .opportunities-extension-table th:nth-child(3) {\n width: 18%;\n }\n\n .opportunities-extension-table th:nth-child(4) {\n width: 12%;\n }\n\n .opportunities-extension-table th:nth-child(6) {\n width: 7%;\n }\n\n .opportunities-extension-table th:nth-child(7) {\n width: 8%;\n }\n\n .opportunities-extension-table th:nth-child(10) {\n width: 10%;\n }\n }\n\n @media (max-width: 1200px) {\n .opportunities-extension-table {\n min-width: 1180px;\n }\n\n .opportunities-extension-table th:nth-child(1) {\n width: 15%;\n }\n\n .opportunities-extension-table th:nth-child(3) {\n width: 17%;\n }\n\n .opportunities-extension-table th:nth-child(4) {\n width: 13%;\n }\n\n .opportunities-extension-table th:nth-child(6) {\n width: 6%;\n }\n\n .opportunities-extension-table th:nth-child(7) {\n width: 9%;\n }\n\n .opportunities-extension-table th:nth-child(10) {\n width: 11%;\n }\n }\n\n @media (max-width: 700px) {\n .opportunities-extension-header {\n padding: 24px 20px 18px;\n }\n\n .opportunities-extension-header::before {\n left: 20px;\n }\n\n .opportunities-extension-header h1 {\n font-size: 22px;\n }\n\n .opportunities-extension-header p {\n font-size: 14px;\n }\n\n .opportunities-extension-body {\n padding: 16px 20px 24px;\n }\n\n .opportunities-extension-form {\n align-items: stretch;\n flex-wrap: wrap;\n gap: 14px;\n min-height: auto;\n padding: 12px;\n }\n\n .opportunities-extension-field {\n max-width: none;\n width: 100%;\n }\n\n .opportunities-extension-customer-filter {\n width: 100%;\n }\n\n .opportunities-extension-customer-filter-trigger {\n width: 100%;\n }\n\n .opportunities-extension-customer-filter-panel {\n width: min(360px, calc(100vw - 64px));\n }\n\n .opportunities-extension-table-search-field {\n width: 100%;\n }\n\n .opportunities-extension-stage-dashboard {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n\n .opportunities-extension-stage-dashboard-value {\n font-size: 18px;\n }\n\n .opportunities-extension-preferences-modal {\n padding: 12px;\n }\n\n .opportunities-extension-account-plans-modal {\n padding: 12px;\n }\n\n .opportunities-extension-preferences-dialog {\n max-height: calc(100vh - 24px);\n }\n\n .opportunities-extension-account-plans-dialog {\n height: calc(100vh - 24px);\n }\n\n .opportunities-extension-account-plan-panel {\n padding: 12px 14px;\n }\n\n .opportunities-extension-account-plan-details {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 0 14px;\n }\n\n .opportunities-extension-account-plan-details > div {\n grid-template-columns: 1fr;\n align-content: center;\n gap: 3px;\n min-height: 54px;\n padding: 5px 0;\n }\n\n .opportunities-extension-account-plans-skeleton-details {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 0 14px;\n padding: 0;\n }\n\n .opportunities-extension-account-plans-skeleton-details > div {\n grid-template-columns: 1fr;\n align-content: center;\n gap: 5px;\n min-height: 54px;\n padding: 5px 0;\n }\n\n .opportunities-extension-account-plans-skeleton-trend {\n margin: 14px 0 0;\n }\n\n .opportunities-extension-account-plans-skeleton-periods {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n align-items: center;\n gap: 6px;\n }\n\n .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar {\n width: 100% !important;\n }\n\n .opportunities-extension-account-plans-skeleton-chart {\n grid-template-columns: minmax(0, 1fr);\n gap: 12px;\n padding: 12px 8px 0 34px;\n }\n\n .opportunities-extension-account-plans-skeleton-legend {\n display: flex;\n min-height: 24px;\n align-items: center;\n justify-content: center;\n gap: 12px;\n }\n\n .opportunities-extension-consumption-trend-header {\n align-items: stretch;\n }\n\n .opportunities-extension-consumption-periods {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n gap: 3px;\n }\n\n .opportunities-extension-consumption-periods button {\n min-width: 0;\n padding: 7px 4px;\n font-size: 12px;\n }\n\n .opportunities-extension-consumption-chart {\n min-height: 0;\n }\n\n .opportunities-extension-consumption-svg {\n width: 760px;\n height: 245px;\n }\n\n .opportunities-extension-consumption-visual {\n width: 920px;\n grid-template-columns: 760px 144px;\n gap: 12px;\n }\n\n .opportunities-extension-consumption-figure {\n overflow-x: auto;\n overscroll-behavior-inline: contain;\n }\n\n .opportunities-extension-consumption-chart-skeleton {\n min-height: 0;\n padding: 24px 18px 18px;\n }\n\n .opportunities-extension-consumption-chart-skeleton-plot {\n min-height: 180px;\n gap: 4px;\n padding: 0 6px;\n }\n\n .opportunities-extension-preferences-options {\n grid-template-columns: 1fr;\n }\n\n .opportunities-extension-stage-filter-controls {\n gap: 6px;\n }\n\n .opportunities-extension-status-filter-controls {\n gap: 6px;\n }\n\n .opportunities-extension-table-surface {\n min-width: 0;\n min-height: calc(100vh - 230px);\n }\n\n .opportunities-extension-debug-panel {\n right: 12px;\n bottom: 12px;\n width: calc(100vw - 24px);\n max-height: calc(100vh - 96px);\n }\n }\n "; +// Source: src/shared/messages.js +/** Stable wire names. All payloads remain plain, serializable objects. */ +function createMessageTypes() { + return Object.freeze({ + getXsrfToken: "opportunitiesExtension.getXsrfToken", + refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie", + requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts", + opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress", + requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService", + requestAccountPlans: "opportunitiesExtension.requestAccountPlans", + requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend" + }); +} +// Source: src/content/contracts.js +function createContentContracts() { const TILE_ID = "c_5c719a88b5624b1eaf81defe2c2ex4x5"; const TILE_LABEL_ID = `${TILE_ID}_0`; const SALES_GROUP_SELECTOR = "#yourapps_groupNode_sales"; @@ -86,53 +102,6 @@ { label: "48 hours", value: 48 }, { label: "7 days", value: 168 } ]; - let tokenRelayRequest = null; - let consumerTokenServiceRequest = null; - let accessToken = ""; - let salesCommonToken = null; - let salesCommonTokenRequest = null; - let periodRequestVersion = 0; - let authStatus = AUTH_STATUS.idle; - let requestLog = []; - let debugSecretsVisible = false; - let selectedStages = new Set(STAGE_OPTIONS); - let selectedStatuses = new Set(STATUS_OPTIONS); - let selectedCustomerKeys = new Set(); - let customerSearch = ""; - let selectedOwnerKeys = new Set(); - let ownerSearch = ""; - let tableSearch = ""; - let selectedOpportunityTypeValues = new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES); - let opptyProductsCacheHours = DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; - let currentProductsRequestId = ""; - let productsRenderQueued = false; - let opptyProductsByNumber = new Map(); - let expandedProductOptyNumbers = new Set(); - const aiSummariesByOptyNumber = new Map(); - let productsProgress = { - state: "idle", - completed: 0, - total: 0, - failed: 0, - cacheHits: 0 - }; - let productsLastCachedAt = 0; - let accountPlansRequestState = new Map(); - let consumptionTrendCache = new Map(); - let consumptionTrendWindowOffsets = new Map(); - let consumptionTrendSeriesFilters = new Map(); - let hasSavedCustomerFilter = false; - let hasSavedOwnerFilter = false; - let customerFilterInitialized = false; - let ownerFilterInitialized = false; - let stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); - let opportunitiesTableState = { - items: [], - status: "idle", - message: "", - sortKey: "", - sortDirection: "ascending" - }; const PERIOD_OPTIONS = [ "Current Quarter", "Next Quarter", @@ -141,7 +110,136 @@ "4 Rolling Quarters (CQ + 3)", "RENEWALS (Current + Past Due)" ]; - const OPPORTUNITIES_COLUMNS = [ + const ALLOWED_PATHS = [ + "/hcmUI/faces/FuseWelcome", + "/fscmUI/faces/FuseWelcome" + ]; + return { TILE_ID, TILE_LABEL_ID, SALES_GROUP_SELECTOR, ADD_ICON_SELECTOR, MODAL_ID, STYLE_ID, ESCAPE_LISTENER_KEY, DEBUG_PANEL_ID, OPPORTUNITIES_TABLE_ID, STAGE_FILTERS_ID, STATUS_FILTERS_ID, CUSTOMER_FILTER_BUTTON_ID, CUSTOMER_FILTER_PANEL_ID, CUSTOMER_FILTER_SEARCH_ID, CUSTOMER_FILTER_SELECT_ALL_ID, CUSTOMER_FILTER_LIST_ID, OWNER_FILTER_BUTTON_ID, OWNER_FILTER_PANEL_ID, OWNER_FILTER_SEARCH_ID, OWNER_FILTER_SELECT_ALL_ID, OWNER_FILTER_LIST_ID, TABLE_SEARCH_ID, STAGE_DASHBOARD_ID, PRODUCTS_PROGRESS_ID, PRODUCTS_CACHE_TIMESTAMP_ID, FILTER_PREFERENCES_STORAGE_KEY, THEME_STORAGE_KEY, OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, PREFERENCES_MODAL_ID, ACCOUNT_PLANS_MODAL_ID, PRODUCTS_MESSAGE_LISTENER_KEY, OPPORTUNITY_DETAIL_URL, ACCOUNT_DETAIL_URL, DEBUG_BODY_LIMIT, OPPORTUNITIES_PAGE_LIMIT, DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, TOKEN_RELAY_URL, OPPORTUNITIES_QUERY_URL, AUTH_STATUS, CONSUMPTION_TREND_PERIODS, CONSUMPTION_TREND_SERIES, DEFAULT_CONSUMPTION_TREND_PERIOD, SVG_NAMESPACE, STAGE_OPTIONS, STATUS_OPTIONS, OPPORTUNITY_TYPE_OPTIONS, DEFAULT_OPPORTUNITY_TYPE_VALUES, OPPTY_PRODUCTS_CACHE_OPTIONS, PERIOD_OPTIONS, ALLOWED_PATHS }; +} + +// Source: src/auth/session-state.js +function createAuthSessionState({ AUTH_STATUS } = {}) { + return { + tokenRelayRequest: null, + consumerTokenServiceRequest: null, + accessToken: "", + authStatus: AUTH_STATUS.idle + }; +} + +// Source: src/auth/sales-common-state.js +/** @returns {{salesCommonToken: null | {access_token: string, expiresAt: number}, salesCommonTokenRequest: null | Promise}} */ +function createAuthSalesCommonState() { + return { + salesCommonToken: null, + salesCommonTokenRequest: null + }; +} + +// Source: src/features/opportunities/query-state.js +function createOpportunitiesQueryState() { + return { + periodRequestVersion: 0 + }; +} + +// Source: src/features/opportunities/table-state.js +function createOpportunitiesTableState() { + return { + opportunitiesTableState: { + items: [], + status: "idle", + message: "", + sortKey: "", + sortDirection: "ascending" + } + }; +} + +// Source: src/features/opportunities/filter-state.js +function createOpportunitiesFilterState({ STAGE_OPTIONS, STATUS_OPTIONS } = {}) { + return { + selectedStages: new Set(STAGE_OPTIONS), + selectedStatuses: new Set(STATUS_OPTIONS), + selectedCustomerKeys: new Set(), + customerSearch: "", + selectedOwnerKeys: new Set(), + ownerSearch: "", + tableSearch: "", + hasSavedCustomerFilter: false, + hasSavedOwnerFilter: false, + customerFilterInitialized: false, + ownerFilterInitialized: false + }; +} + +// Source: src/features/preferences/state.js +function createPreferencesState({ DEFAULT_OPPORTUNITY_TYPE_VALUES, DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS } = {}) { + return { + selectedOpportunityTypeValues: new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES), + opptyProductsCacheHours: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS + }; +} + +// Source: src/features/products/content-state.js +function createProductsContentState() { + return { + currentProductsRequestId: "", + productsRenderQueued: false, + opptyProductsByNumber: new Map(), + expandedProductOptyNumbers: new Set(), + productsProgress: { + state: "idle", + completed: 0, + total: 0, + failed: 0, + cacheHits: 0 + }, + productsLastCachedAt: 0 + }; +} + +// Source: src/features/ai-summary/state.js +function createAiSummaryState() { + return { + aiSummariesByOptyNumber: new Map() + }; +} + +// Source: src/features/consumption/plans-state.js +function createConsumptionPlansState() { + return { + accountPlansRequestState: new Map() + }; +} + +// Source: src/features/consumption/trend-state.js +function createConsumptionTrendState() { + return { + consumptionTrendCache: new Map(), + consumptionTrendWindowOffsets: new Map(), + consumptionTrendSeriesFilters: new Map() + }; +} + +// Source: src/features/opportunities/dashboard-state.js +function createOpportunitiesDashboardState({ STAGE_OPTIONS } = {}) { + return { + stageDashboardAmounts: new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]) + }; +} + +// Source: src/features/debug/state.js +function createDebugState() { + return { + requestLog: [], + debugSecretsVisible: false + }; +} + +// Source: src/features/opportunities/columns.js +function createOpportunitiesColumns({ formatOracleResponseDate, formatRevenue, formatWinProbability, getNestedValue, getOwnerName }) { + return [ { key: "name", label: "Name", value: (item) => item.Name, display: (item) => item.Name }, { key: "optyNumber", label: "Opty Number", value: (item) => item.OptyNumber, display: (item) => item.OptyNumber }, { key: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]) }, @@ -154,18 +252,20 @@ { key: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }, { key: "actions", label: "Actions", value: () => "", display: () => "", sortable: false } ]; - const ALLOWED_PATHS = [ - "/hcmUI/faces/FuseWelcome", - "/fscmUI/faces/FuseWelcome" - ]; - - if (!isAllowedPage()) { - return; - } - - ensureExtensionStyles(); - ensureProductsBackgroundMessageHandler(); +} +// Source: src/content/fusion-page.js +function createContentFusionPage({ + ADD_ICON_SELECTOR, + ALLOWED_PATHS, + SALES_GROUP_SELECTOR, + TILE_ID, + TILE_LABEL_ID, + appendPath, + openOpportunitiesModal, + environment +}) { + const { Node, document, window } = environment; function createOpportunitiesTile() { const wrapper = document.createElement("div"); wrapper.className = "flat-grid-cell"; @@ -213,22 +313,122 @@ return wrapper; } - function appendPath(svg, className, d) { - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute("class", className); - path.setAttribute("d", d); - svg.append(path); - } - function handleTileClick(event) { event.preventDefault(); event.stopPropagation(); openOpportunitiesModal(); } + function isAllowedPage() { + if (window.location.hostname !== "eeho.fa.us2.oraclecloud.com") { + return false; + } + + return ALLOWED_PATHS.some((path) => { + return window.location.pathname === path || window.location.pathname.startsWith(`${path}/`); + }); + } + + function findInsertionPoint(salesGroup) { + const addIconInsideGroup = salesGroup.querySelector(ADD_ICON_SELECTOR); + + if (addIconInsideGroup) { + return addIconInsideGroup; + } + + const container = salesGroup.closest(".flat-grid, .flat-grid-container, [id*='yourapps'], [id*='groupNode']") || salesGroup.parentElement || document.body; + const addIconCells = Array.from(container.querySelectorAll(ADD_ICON_SELECTOR)); + + return addIconCells.find((cell) => { + return Boolean(salesGroup.compareDocumentPosition(cell) & Node.DOCUMENT_POSITION_FOLLOWING); + }) || null; + } + + function insertTile() { + if (document.getElementById(TILE_ID)) { + return true; + } + + const salesGroup = document.querySelector(SALES_GROUP_SELECTOR); + const addIconCell = salesGroup ? findInsertionPoint(salesGroup) : null; + + if (!salesGroup || !addIconCell) { + return false; + } + + addIconCell.before(createOpportunitiesTile()); + return true; + } + + return { createOpportunitiesTile, handleTileClick, isAllowedPage, findInsertionPoint, insertTile }; +} + +// Source: src/content/modal.js +function createContentModal({ + ACCOUNT_PLANS_MODAL_ID, + AUTH_STATUS, + CUSTOMER_FILTER_BUTTON_ID, + CUSTOMER_FILTER_LIST_ID, + CUSTOMER_FILTER_PANEL_ID, + CUSTOMER_FILTER_SEARCH_ID, + CUSTOMER_FILTER_SELECT_ALL_ID, + DEBUG_PANEL_ID, + ESCAPE_LISTENER_KEY, + MODAL_ID, + OPPORTUNITIES_TABLE_ID, + OWNER_FILTER_BUTTON_ID, + OWNER_FILTER_LIST_ID, + OWNER_FILTER_PANEL_ID, + OWNER_FILTER_SEARCH_ID, + OWNER_FILTER_SELECT_ALL_ID, + PERIOD_OPTIONS, + PREFERENCES_MODAL_ID, + PRODUCTS_CACHE_TIMESTAMP_ID, + PRODUCTS_PROGRESS_ID, + STAGE_DASHBOARD_ID, + STAGE_FILTERS_ID, + STATUS_FILTERS_ID, + TABLE_SEARCH_ID, + applyTheme, + authSessionState, + closeAccountPlansModal, + closeCustomerFilterPanel, + closeOwnerFilterPanel, + closePreferencesModal, + createSettingsIcon, + debugState, + ensureExtensionStyles, + getCustomerOptions, + getOwnerOptions, + getSavedOpportunityTypeValues, + getSavedOpptyProductsCacheHours, + getSavedTheme, + openPreferencesModal, + opportunitiesFilterState, + opportunitiesQueryState, + preferencesState, + renderCustomerFilter, + renderCustomerFilterList, + renderOpportunitiesTable, + renderOwnerFilter, + renderOwnerFilterList, + renderStageFilterButtons, + renderStatusFilterButtons, + requestOpportunitiesForPeriod, + requestTokenRelayOnce, + resetCustomerAndOwnerFiltersForPeriod, + resetOpportunitiesTable, + restoreFilterPreferences, + saveFilterPreferences, + toggleCustomerFilterPanel, + toggleDebugPanel, + toggleOwnerFilterPanel, + environment +}) { + const { document, window } = environment; function openOpportunitiesModal() { ensureExtensionStyles(); - debugSecretsVisible = false; + debugState.debugSecretsVisible = false; const existingModal = document.getElementById(MODAL_ID); @@ -242,15 +442,15 @@ } const overlay = document.createElement("div"); - requestLog = []; - tokenRelayRequest = null; - consumerTokenServiceRequest = null; - accessToken = ""; - periodRequestVersion = 0; - authStatus = AUTH_STATUS.idle; + debugState.requestLog = []; + authSessionState.tokenRelayRequest = null; + authSessionState.consumerTokenServiceRequest = null; + authSessionState.accessToken = ""; + opportunitiesQueryState.periodRequestVersion = 0; + authSessionState.authStatus = AUTH_STATUS.idle; resetOpportunitiesTable(); - selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues()); - opptyProductsCacheHours = getSavedOpptyProductsCacheHours(); + preferencesState.selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues()); + preferencesState.opptyProductsCacheHours = getSavedOpptyProductsCacheHours(); overlay.id = MODAL_ID; overlay.className = "opportunities-extension-modal"; overlay.setAttribute("data-theme", getSavedTheme()); @@ -404,7 +604,7 @@ customerSearchInput.placeholder = "Search customers"; customerSearchInput.setAttribute("aria-label", "Search customers"); customerSearchInput.addEventListener("input", () => { - customerSearch = customerSearchInput.value; + opportunitiesFilterState.customerSearch = customerSearchInput.value; saveFilterPreferences(); renderCustomerFilterList(); }); @@ -417,10 +617,10 @@ selectAllCheckbox.type = "checkbox"; selectAllCheckbox.addEventListener("change", () => { const customerOptions = getCustomerOptions(); - selectedCustomerKeys = selectAllCheckbox.checked + opportunitiesFilterState.selectedCustomerKeys = selectAllCheckbox.checked ? new Set(customerOptions.map((customer) => customer.key)) : new Set(); - hasSavedCustomerFilter = true; + opportunitiesFilterState.hasSavedCustomerFilter = true; saveFilterPreferences(); renderCustomerFilter(); renderOpportunitiesTable(); @@ -464,7 +664,7 @@ ownerSearchInput.placeholder = "Search owners"; ownerSearchInput.setAttribute("aria-label", "Search owners"); ownerSearchInput.addEventListener("input", () => { - ownerSearch = ownerSearchInput.value; + opportunitiesFilterState.ownerSearch = ownerSearchInput.value; saveFilterPreferences(); renderOwnerFilterList(); }); @@ -477,10 +677,10 @@ ownerSelectAllCheckbox.type = "checkbox"; ownerSelectAllCheckbox.addEventListener("change", () => { const ownerOptions = getOwnerOptions(); - selectedOwnerKeys = ownerSelectAllCheckbox.checked + opportunitiesFilterState.selectedOwnerKeys = ownerSelectAllCheckbox.checked ? new Set(ownerOptions.map((owner) => owner.key)) : new Set(); - hasSavedOwnerFilter = true; + opportunitiesFilterState.hasSavedOwnerFilter = true; saveFilterPreferences(); renderOwnerFilter(); renderOpportunitiesTable(); @@ -508,9 +708,9 @@ tableSearchInput.type = "search"; tableSearchInput.placeholder = "Search opportunities"; tableSearchInput.setAttribute("aria-label", "Search opportunities"); - tableSearchInput.value = tableSearch; + tableSearchInput.value = opportunitiesFilterState.tableSearch; tableSearchInput.addEventListener("input", () => { - tableSearch = tableSearchInput.value; + opportunitiesFilterState.tableSearch = tableSearchInput.value; saveFilterPreferences(); renderOpportunitiesTable(); }); @@ -572,36 +772,304 @@ select.focus(); } + function updateAuthBadge(status) { + const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`); + + if (!badge) { + return; + } + + const badgeStatus = status === AUTH_STATUS.authenticated || status === AUTH_STATUS.unauthenticated + ? status + : AUTH_STATUS.pending; + + badge.setAttribute("data-auth-status", badgeStatus); + badge.className = `opportunities-extension-auth-badge opportunities-extension-auth-badge-${badgeStatus}`; + badge.textContent = badgeStatus === AUTH_STATUS.authenticated + ? "Authenticated" + : badgeStatus === AUTH_STATUS.unauthenticated + ? "Unauthenticated" + : "Authenticating"; + } + + function closeOpportunitiesModal() { + const modal = document.getElementById(MODAL_ID); + + if (modal) { + closePreferencesModal(); + closeAccountPlansModal(); + modal.hidden = true; + } + + debugState.debugSecretsVisible = false; + document.documentElement.classList.remove("opportunities-extension-scroll-lock"); + } + + function ensureEscapeKeyHandler() { + if (window[ESCAPE_LISTENER_KEY]) { + return; + } + + window[ESCAPE_LISTENER_KEY] = true; + document.addEventListener("keydown", (event) => { + const modal = document.getElementById(MODAL_ID); + + if (!modal || modal.hidden) { + return; + } + + if (event.ctrlKey && event.key.toLowerCase() === "d") { + event.preventDefault(); + event.stopPropagation(); + toggleDebugPanel(); + return; + } + + if (event.key === "Escape") { + const accountPlansModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); + + if (accountPlansModal && !accountPlansModal.hidden) { + closeAccountPlansModal(); + event.preventDefault(); + return; + } + + const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID); + + if (preferencesModal && !preferencesModal.hidden) { + closePreferencesModal(); + event.preventDefault(); + return; + } + + if (closeCustomerFilterPanel()) { + event.preventDefault(); + return; + } + + if (closeOwnerFilterPanel()) { + event.preventDefault(); + return; + } + + closeOpportunitiesModal(); + } + }); + + document.addEventListener("pointerdown", (event) => { + const modal = document.getElementById(MODAL_ID); + const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); + const ownerPanel = document.getElementById(OWNER_FILTER_PANEL_ID); + + if (!modal || modal.hidden) { + return; + } + + if (panel && !panel.hidden) { + const customerFilter = panel.closest(".opportunities-extension-customer-filter"); + + if (customerFilter && !customerFilter.contains(event.target)) { + closeCustomerFilterPanel(false); + } + } + + if (ownerPanel && !ownerPanel.hidden) { + const ownerFilter = ownerPanel.closest(".opportunities-extension-customer-filter"); + + if (ownerFilter && !ownerFilter.contains(event.target)) { + closeOwnerFilterPanel(false); + } + } + }); + } + + return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler }; +} + +// Source: src/content/theme.js +function createContentTheme({ + THEME_STORAGE_KEY, + createThemeIcon, + environment +}) { + const getLocalStorage = () => environment.localStorage; + function getSavedTheme() { + try { + return getLocalStorage().getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light"; + } catch (error) { + return "light"; + } + } + + function applyTheme(modal, button, theme) { + const isDarkTheme = theme === "dark"; + + modal.setAttribute("data-theme", isDarkTheme ? "dark" : "light"); + button.textContent = ""; + button.setAttribute("aria-label", isDarkTheme ? "Ativar modo claro" : "Ativar modo escuro"); + button.title = isDarkTheme ? "Light mode" : "Dark mode"; + button.append(createThemeIcon(isDarkTheme ? "sun" : "moon")); + + try { + getLocalStorage().setItem(THEME_STORAGE_KEY, isDarkTheme ? "dark" : "light"); + } catch (error) { + // Continue with the selected theme when browser storage is unavailable. + } + } + + return { getSavedTheme, applyTheme }; +} + +// Source: src/content/icons.js +function createContentIcons({ + appendPath, + environment +}) { + const { document } = environment; + function createThemeIcon(iconName) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + + if (iconName === "sun") { + path.setAttribute("d", "M12 3V1m0 22v-2m9-9h2M1 12h2m15.36 6.36 1.42 1.42M4.22 4.22l1.42 1.42m12.72 0 1.42-1.42M4.22 19.78l1.42-1.42M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z"); + } else { + path.setAttribute("d", "M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5a8.5 8.5 0 1 0 10.7 10.7Z"); + } + + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + path.setAttribute("stroke-width", "1.8"); + svg.append(path); + return svg; + } + + function createSettingsIcon() { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const upperControl = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + const lowerControl = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("class", "opportunities-extension-settings-icon"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + appendPath(svg, "", "M4 7h16M4 17h16"); + + [upperControl, lowerControl].forEach((control, index) => { + control.setAttribute("cx", index === 0 ? "9" : "15"); + control.setAttribute("cy", index === 0 ? "7" : "17"); + control.setAttribute("r", "2.5"); + control.setAttribute("fill", "currentColor"); + control.setAttribute("stroke", "currentColor"); + control.setAttribute("stroke-width", "1.8"); + }); + + svg.querySelector("path").setAttribute("fill", "none"); + svg.querySelector("path").setAttribute("stroke", "currentColor"); + svg.querySelector("path").setAttribute("stroke-linecap", "round"); + svg.querySelector("path").setAttribute("stroke-width", "1.8"); + svg.append(upperControl, lowerControl); + return svg; + } + + function createCloseIcon() { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + path.setAttribute("d", "m6 6 12 12M18 6 6 18"); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-width", "1.8"); + svg.append(path); + return svg; + } + + return { createThemeIcon, createSettingsIcon, createCloseIcon }; +} + +// Source: src/content/svg.js +function createSvgPaths({ environment }) { + const { document } = environment; + function appendPath(svg, className, d) { + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("class", className); + path.setAttribute("d", d); + svg.append(path); + } + return { appendPath }; +} + +// Source: src/content/styles.js +function createContentStyles({ + STYLE_ID, + redwoodStyles, + environment +}) { + const { document } = environment; + function ensureExtensionStyles() { + if (document.getElementById(STYLE_ID)) { + return; + } + + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = redwoodStyles; + + document.head.append(style); + } + + return { ensureExtensionStyles }; +} + +// Source: src/auth/content-session.js +function createAuthContentSession({ + messageTypes, + AUTH_STATUS, + appendBackgroundRequestLog, + authSessionState, + requestTokenRelay, + sendRuntimeMessage, + updateAuthBadge +}) { function requestTokenRelayOnce() { - if (!tokenRelayRequest) { - authStatus = AUTH_STATUS.pending; - updateAuthBadge(authStatus); - tokenRelayRequest = requestTokenRelay() + if (!authSessionState.tokenRelayRequest) { + authSessionState.authStatus = AUTH_STATUS.pending; + updateAuthBadge(authSessionState.authStatus); + authSessionState.tokenRelayRequest = requestTokenRelay() .then((token) => { - accessToken = token; - authStatus = AUTH_STATUS.authenticated; + authSessionState.accessToken = token; + authSessionState.authStatus = AUTH_STATUS.authenticated; requestConsumerTokenServiceOnce().catch(() => {}); return token; }) .catch((error) => { - accessToken = ""; - authStatus = AUTH_STATUS.unauthenticated; + authSessionState.accessToken = ""; + authSessionState.authStatus = AUTH_STATUS.unauthenticated; throw error; }) .finally(() => { - updateAuthBadge(authStatus); + updateAuthBadge(authSessionState.authStatus); }); } else { - updateAuthBadge(authStatus); + updateAuthBadge(authSessionState.authStatus); } - return tokenRelayRequest; + return authSessionState.tokenRelayRequest; } function requestConsumerTokenServiceOnce() { - if (!consumerTokenServiceRequest) { - consumerTokenServiceRequest = sendRuntimeMessage({ - type: "opportunitiesExtension.requestConsumerTokenService" + if (!authSessionState.consumerTokenServiceRequest) { + authSessionState.consumerTokenServiceRequest = sendRuntimeMessage({ + type: messageTypes.requestConsumerTokenService }).then((response) => { appendBackgroundRequestLog(response && response.debugRequests); @@ -615,29 +1083,62 @@ }); } - return consumerTokenServiceRequest; + return authSessionState.consumerTokenServiceRequest; } + return { requestTokenRelayOnce, requestConsumerTokenServiceOnce }; +} + +// Source: src/auth/sales-common.js +function createAuthSalesCommon({ + TOKEN_RELAY_URL, + authSalesCommonState, + requestTokenRelay +}) { function requestTokenRelaySalesCommon() { - if (salesCommonToken && Date.now() < salesCommonToken.expiresAt) { - return Promise.resolve(salesCommonToken.access_token); + if (authSalesCommonState.salesCommonToken && Date.now() < authSalesCommonState.salesCommonToken.expiresAt) { + return Promise.resolve(authSalesCommonState.salesCommonToken.access_token); } - if (salesCommonTokenRequest) { - return salesCommonTokenRequest; + if (authSalesCommonState.salesCommonTokenRequest) { + return authSalesCommonState.salesCommonTokenRequest; } - salesCommonTokenRequest = requestTokenRelay({ + authSalesCommonState.salesCommonTokenRequest = requestTokenRelay({ name: "requestTokenRelaySalesCommon", url: `${TOKEN_RELAY_URL}?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F`, includeExpiry: true }).then((token) => { - salesCommonToken = token; + authSalesCommonState.salesCommonToken = token; return token.access_token; }).finally(() => { - salesCommonTokenRequest = null; + authSalesCommonState.salesCommonTokenRequest = null; }); - return salesCommonTokenRequest; + return authSalesCommonState.salesCommonTokenRequest; } + function invalidateSalesCommonToken(rejectedToken) { + if (authSalesCommonState.salesCommonToken && authSalesCommonState.salesCommonToken.access_token === rejectedToken) { + authSalesCommonState.salesCommonToken = null; + } + } + + return { requestTokenRelaySalesCommon, invalidateSalesCommonToken }; +} + +// Source: src/auth/token-relay.js +function createAuthTokenRelay({ + TOKEN_RELAY_URL, + createDebugBody, + getXsrfToken, + logRequestFailure, + logRequestStart, + logRequestSuccess, + maskToken, + readResponseBodyForDebug, + refreshXsrfCookie, + renderDebugPanel, + environment +}) { + const { fetch } = environment; async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) { let refreshResult = null; let refreshAttempted = false; @@ -775,11 +1276,200 @@ return `${result.reason}; ${outcome}; cookie=${cookieName}; duration=${duration}; ${result.error || ""}`.trim(); } + return { requestTokenRelay, createTokenRelayRequestEntry, isInvalidXsrfStatus, formatXsrfRefreshResult }; +} + +// Source: src/auth/xsrf-content.js +function createAuthXsrfContent({ + getXsrfTokenFromExtensionCookies, + environment +}) { + const { document } = environment; + async function getXsrfToken() { + const extensionCookie = await getXsrfTokenFromExtensionCookies(); + + if (extensionCookie.token) { + return extensionCookie; + } + + const xsrfCookie = document.cookie + .split(";") + .map((cookie) => cookie.trim()) + .find((cookie) => cookie.startsWith("XSRF-TOKEN-")); + + if (!xsrfCookie) { + return { + token: "", + cookieName: "", + source: extensionCookie.error ? "document.cookie fallback after cookies API error" : "document.cookie fallback", + matchedCookieNames: extensionCookie.matchedCookieNames || [], + lookupDetails: extensionCookie.lookupDetails || [], + error: extensionCookie.error || "" + }; + } + + const cookieName = xsrfCookie.slice(0, xsrfCookie.indexOf("=")); + const tokenValue = xsrfCookie.slice(xsrfCookie.indexOf("=") + 1); + return { + token: decodeURIComponent(tokenValue), + cookieName, + source: "document.cookie", + matchedCookieNames: extensionCookie.matchedCookieNames || [], + lookupDetails: extensionCookie.lookupDetails || [], + error: "" + }; + } + + return { getXsrfToken }; +} + +// Source: src/platform/content-messages.js +function createPlatformContentMessages({ + messageTypes, + environment +}) { + const { browser, chrome } = environment; + function sendRuntimeMessage(message) { + const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + + if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { + return Promise.reject(new Error("Runtime messaging API unavailable.")); + } + + if (typeof browser !== "undefined") { + return runtimeApi.runtime.sendMessage(message); + } + + return new Promise((resolve, reject) => { + runtimeApi.runtime.sendMessage(message, (response) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(response); + }); + }); + } + + function getXsrfTokenFromExtensionCookies() { + const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + + if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { + return Promise.resolve({ + token: "", + cookieName: "", + source: "unavailable cookies API", + error: "Runtime messaging API unavailable." + }); + } + + return new Promise((resolve) => { + runtimeApi.runtime.sendMessage({ + type: messageTypes.getXsrfToken + }, (response) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + resolve({ + token: "", + cookieName: "", + source: "cookies API", + matchedCookieNames: [], + lookupDetails: [], + error: lastError.message + }); + return; + } + + resolve({ + token: response && response.ok ? response.token : "", + cookieName: response && response.ok ? response.cookieName : "", + source: "cookies API", + matchedCookieNames: response && response.matchedCookieNames ? response.matchedCookieNames : [], + lookupDetails: response && response.lookupDetails ? response.lookupDetails : [], + error: response && response.error ? response.error : "" + }); + }); + }); + } + + function refreshXsrfCookie(reason) { + const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + + if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { + return Promise.resolve({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + reason, + error: "Runtime messaging API unavailable." + }); + } + + return new Promise((resolve) => { + runtimeApi.runtime.sendMessage({ + type: messageTypes.refreshXsrfCookie + }, (response) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + resolve({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + reason, + error: lastError.message + }); + return; + } + + resolve({ + ...(response || { + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + error: "Empty refresh response." + }), + reason + }); + }); + }); + } + + return { sendRuntimeMessage, getXsrfTokenFromExtensionCookies, refreshXsrfCookie }; +} + +// Source: src/features/opportunities/controller.js +function createOpportunitiesController({ + STAGE_OPTIONS, + STATUS_OPTIONS, + authSessionState, + consumptionPlansState, + consumptionTrendState, + getFiscalPeriodRange, + opportunitiesDashboardState, + opportunitiesFilterState, + opportunitiesQueryState, + opportunitiesTableState, + preferencesState, + productsContentState, + renderProductsProgress, + requestOpportunitiesPage, + requestOpptyProductsInBackground, + requestTokenRelayOnce, + setOpportunitiesTableState +}) { async function requestOpportunitiesForPeriod(period) { const dateRange = getFiscalPeriodRange(period, new Date()); - const requestVersion = ++periodRequestVersion; + const requestVersion = ++opportunitiesQueryState.periodRequestVersion; - if (selectedOpportunityTypeValues.size === 0) { + if (preferencesState.selectedOpportunityTypeValues.size === 0) { setOpportunitiesTableState({ items: [], status: "empty", @@ -798,9 +1488,9 @@ } try { - const token = accessToken || await requestTokenRelayOnce(); + const token = authSessionState.accessToken || await requestTokenRelayOnce(); - if (requestVersion !== periodRequestVersion) { + if (requestVersion !== opportunitiesQueryState.periodRequestVersion) { return; } @@ -812,21 +1502,21 @@ async function requestOpportunities(token, period, dateRange, requestVersion) { if (isCurrentPeriodRequest(requestVersion)) { - currentProductsRequestId = ""; - opptyProductsByNumber = new Map(); - expandedProductOptyNumbers = new Set(); - productsProgress = { + productsContentState.currentProductsRequestId = ""; + productsContentState.opptyProductsByNumber = new Map(); + productsContentState.expandedProductOptyNumbers = new Set(); + productsContentState.productsProgress = { state: "idle", completed: 0, total: 0, failed: 0, cacheHits: 0 }; - productsLastCachedAt = 0; - accountPlansRequestState = new Map(); - consumptionTrendCache = new Map(); - consumptionTrendWindowOffsets = new Map(); - consumptionTrendSeriesFilters = new Map(); + productsContentState.productsLastCachedAt = 0; + consumptionPlansState.accountPlansRequestState = new Map(); + consumptionTrendState.consumptionTrendCache = new Map(); + consumptionTrendState.consumptionTrendWindowOffsets = new Map(); + consumptionTrendState.consumptionTrendSeriesFilters = new Map(); renderProductsProgress(); setOpportunitiesTableState({ items: [], @@ -859,6 +1549,64 @@ } } + function resetOpportunitiesTable() { + opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS); + opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS); + opportunitiesFilterState.selectedCustomerKeys = new Set(); + opportunitiesFilterState.customerSearch = ""; + opportunitiesFilterState.selectedOwnerKeys = new Set(); + opportunitiesFilterState.ownerSearch = ""; + opportunitiesFilterState.tableSearch = ""; + opportunitiesFilterState.hasSavedCustomerFilter = false; + opportunitiesFilterState.hasSavedOwnerFilter = false; + opportunitiesFilterState.customerFilterInitialized = false; + opportunitiesFilterState.ownerFilterInitialized = false; + productsContentState.currentProductsRequestId = ""; + productsContentState.opptyProductsByNumber = new Map(); + productsContentState.expandedProductOptyNumbers = new Set(); + productsContentState.productsProgress = { + state: "idle", + completed: 0, + total: 0, + failed: 0, + cacheHits: 0 + }; + productsContentState.productsLastCachedAt = 0; + consumptionPlansState.accountPlansRequestState = new Map(); + consumptionTrendState.consumptionTrendCache = new Map(); + consumptionTrendState.consumptionTrendWindowOffsets = new Map(); + consumptionTrendState.consumptionTrendSeriesFilters = new Map(); + opportunitiesDashboardState.stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); + opportunitiesTableState.opportunitiesTableState = { + items: [], + status: "idle", + message: "", + sortKey: "", + sortDirection: "ascending" + }; + } + + function isCurrentPeriodRequest(requestVersion) { + return requestVersion === opportunitiesQueryState.periodRequestVersion; + } + + return { requestOpportunitiesForPeriod, requestOpportunities, resetOpportunitiesTable, isCurrentPeriodRequest }; +} + +// Source: src/services/fusion/opportunities.js +function createFusionOpportunities({ + OPPORTUNITIES_PAGE_LIMIT, + OPPORTUNITIES_QUERY_URL, + isCurrentPeriodRequest, + logRequestFailure, + logRequestStart, + logRequestSuccess, + maskToken, + getOpportunityTypeValues, + readResponseBodyForDebug, + environment +}) { + const { fetch } = environment; async function requestOpportunitiesPage(token, period, dateRange, offset, accumulatedItems, requestVersion, page) { if (!isCurrentPeriodRequest(requestVersion)) { return null; @@ -940,305 +1688,173 @@ } } - function requestOpptyProductsInBackground(opportunities, token) { - const optyNumbers = Array.from(new Set(opportunities - .map((item) => item && item.OptyNumber) - .filter(Boolean) - .map(String))); - - const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - currentProductsRequestId = requestId; - opptyProductsByNumber = new Map(); - expandedProductOptyNumbers = new Set(); - productsProgress = { - state: optyNumbers.length ? "running" : "complete", - completed: 0, - total: optyNumbers.length, - failed: 0, - cacheHits: 0 + function createOpportunitiesQueryPayload(dateRange, offset) { + return { + aggregationResults: false, + applications: ["ORACLE-ISS-APP"], + onlyData: true, + entity: "Opportunity", + q: { + 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 + } + ] + }, + keywords: null, + keywordsFields: [ + "Name", + "OptyNumber", + "PrimaryRevenue.WinProb", + "CustomerAccount", + "PrimaryRevenue.RevnAmount", + "EffectiveDate", + "StatusCode", + "DealRisk_c", + "Owner", + "ForecastGroup_c", + "LastUpdateDate", + "PrimaryRevenue.RevnAmountCurcyCode", + "OptyId" + ], + fields: [ + "Name", + "OptyNumber", + "PrimaryRevenue.WinProb", + "CustomerAccount", + "PrimaryRevenue.RevnAmount", + "EffectiveDate", + "StatusCode", + "DealRisk_c", + "Owner", + "ForecastGroup_c", + "LastUpdateDate", + "PrimaryRevenue.RevnAmountCurcyCode", + "OptyId", + "CustomerAccount.PartyUniqueName", + "CustomerAccount.PartyId", + "CustomerAccount.PartyNumber" + ], + sort: [], + language: "en", + skipInValidFields: true, + skipHiddenFromUIFields: true, + copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162", + limit: OPPORTUNITIES_PAGE_LIMIT, + offset }; - renderProductsProgress(); - - if (optyNumbers.length === 0) { - return; - } - - sendRuntimeMessage({ - type: "opportunitiesExtension.requestOpptyProducts", - requestId, - accessToken: token, - optyNumbers, - cacheMaxAgeMs: opptyProductsCacheHours * 60 * 60 * 1000 - }).then((response) => { - if (response && response.ok === false && response.requestId === currentProductsRequestId && productsProgress.state === "running") { - productsProgress.state = "error"; - productsProgress.failed = productsProgress.total; - renderProductsProgress(); - } - }).catch(() => { - if (requestId === currentProductsRequestId && productsProgress.state === "running") { - productsProgress.state = "error"; - productsProgress.failed = productsProgress.total; - renderProductsProgress(); - } - }); } - function ensureProductsBackgroundMessageHandler() { - if (window[PRODUCTS_MESSAGE_LISTENER_KEY]) { - return; + return { requestOpportunitiesPage, createOpportunitiesQueryPayload }; +} + +// Source: src/features/opportunities/fiscal-periods.js +function createOpportunitiesFiscalPeriods() { + function getFiscalPeriodRange(period, currentDate) { + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth(); + const fiscalStartYear = currentMonth >= 5 ? currentYear : currentYear - 1; + const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3); + + if (period === "Current Fiscal Year") { + const isBeforeFiscalYearStart = currentMonth < 5; + const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear; + const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1; + return createDateRange(startYear, 5, endYear, 4); } - const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + let quarterOffset; + let numberOfQuarters = 1; - if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.onMessage) { - return; - } - - window[PRODUCTS_MESSAGE_LISTENER_KEY] = true; - runtimeApi.runtime.onMessage.addListener((message) => { - if (!message || message.type !== "opportunitiesExtension.opptyProductsProgress" || message.requestId !== currentProductsRequestId) { - return false; - } - - if (message.optyNumber) { - opptyProductsByNumber.set(String(message.optyNumber), { - items: Array.isArray(message.items) ? message.items : [], - fromCache: Boolean(message.fromCache), - cachedAt: Number(message.cachedAt) || 0, - error: message.error || "" - }); - } - - if (Number(message.cachedAt) > productsLastCachedAt) { - productsLastCachedAt = Number(message.cachedAt); - } - - productsProgress = { - state: message.state || productsProgress.state, - completed: Number(message.completed) || 0, - total: Number(message.total) || productsProgress.total, - failed: Number(message.failed) || 0, - cacheHits: Number(message.cacheHits) || 0 - }; - renderProductsProgress(); - queueProductsTableRender(); - return false; - }); - } - - function sendRuntimeMessage(message) { - const runtimeApi = typeof browser !== "undefined" ? browser : chrome; - - if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { - return Promise.reject(new Error("Runtime messaging API unavailable.")); - } - - if (typeof browser !== "undefined") { - return runtimeApi.runtime.sendMessage(message); - } - - return new Promise((resolve, reject) => { - runtimeApi.runtime.sendMessage(message, (response) => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - reject(new Error(lastError.message)); - return; - } - - resolve(response); - }); - }); - } - - function renderProductsProgress() { - const badge = document.getElementById(PRODUCTS_PROGRESS_ID); - - if (!badge) { - return; - } - - badge.hidden = productsProgress.state === "idle"; - badge.setAttribute("data-state", productsProgress.state); - - if (productsProgress.state === "running") { - badge.textContent = `Products ${productsProgress.completed}/${productsProgress.total}`; - badge.title = `${productsProgress.cacheHits} loaded from cache`; - } else if (productsProgress.state === "complete") { - badge.textContent = `Products ready ${productsProgress.total}`; - badge.title = `${productsProgress.cacheHits} loaded from cache`; - } else if (productsProgress.state === "complete-with-errors") { - badge.textContent = `Products ${productsProgress.total - productsProgress.failed}/${productsProgress.total}`; - badge.title = `${productsProgress.failed} product requests failed`; - } else if (productsProgress.state === "error") { - badge.textContent = "Products unavailable"; - badge.title = "Unable to load opportunity products"; + if (period === "Current Quarter") { + quarterOffset = currentQuarterIndex; + } else if (period === "Next Quarter") { + quarterOffset = currentQuarterIndex + 1; + } else if (period === "Previous Quarter") { + quarterOffset = currentQuarterIndex - 1; + } else if (period === "4 Rolling Quarters (CQ + 3)") { + quarterOffset = currentQuarterIndex; + numberOfQuarters = 4; } else { - badge.textContent = "Products ready 0"; + return null; } - const timestamp = document.getElementById(PRODUCTS_CACHE_TIMESTAMP_ID); + const start = new Date(fiscalStartYear, 5 + (quarterOffset * 3), 1); + const end = new Date(start.getFullYear(), start.getMonth() + (numberOfQuarters * 3), 0); - if (timestamp) { - timestamp.hidden = !productsLastCachedAt; - timestamp.textContent = productsLastCachedAt - ? `Last update: ${formatProductsCacheTimestamp(productsLastCachedAt)}` - : ""; - timestamp.title = productsLastCachedAt - ? `Latest product update: ${formatProductsCacheTimestamp(productsLastCachedAt)}` - : ""; - } - } - - function formatProductsCacheTimestamp(timestamp) { - try { - return new Intl.DateTimeFormat("en-GB", { - dateStyle: "short", - timeStyle: "short" - }).format(new Date(timestamp)); - } catch (error) { - return new Date(timestamp).toLocaleString(); - } - } - - function queueProductsTableRender() { - if (productsRenderQueued) { - return; - } - - productsRenderQueued = true; - window.requestAnimationFrame(() => { - productsRenderQueued = false; - renderOpportunitiesTable(); - }); - } - - function resetOpportunitiesTable() { - selectedStages = new Set(STAGE_OPTIONS); - selectedStatuses = new Set(STATUS_OPTIONS); - selectedCustomerKeys = new Set(); - customerSearch = ""; - selectedOwnerKeys = new Set(); - ownerSearch = ""; - tableSearch = ""; - hasSavedCustomerFilter = false; - hasSavedOwnerFilter = false; - customerFilterInitialized = false; - ownerFilterInitialized = false; - currentProductsRequestId = ""; - opptyProductsByNumber = new Map(); - expandedProductOptyNumbers = new Set(); - productsProgress = { - state: "idle", - completed: 0, - total: 0, - failed: 0, - cacheHits: 0 - }; - productsLastCachedAt = 0; - accountPlansRequestState = new Map(); - consumptionTrendCache = new Map(); - consumptionTrendWindowOffsets = new Map(); - consumptionTrendSeriesFilters = new Map(); - stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); - opportunitiesTableState = { - items: [], - status: "idle", - message: "", - sortKey: "", - sortDirection: "ascending" + return { + startCloseDate: formatOracleDate(start), + endCloseDate: formatOracleDate(end) }; } - function resetCustomerAndOwnerFiltersForPeriod() { - selectedCustomerKeys = new Set(); - selectedOwnerKeys = new Set(); - customerSearch = ""; - ownerSearch = ""; - hasSavedCustomerFilter = false; - hasSavedOwnerFilter = false; - customerFilterInitialized = false; - ownerFilterInitialized = false; + function createDateRange(startYear, startMonth, endYear, endMonth) { + return { + startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)), + endCloseDate: formatOracleDate(new Date(endYear, endMonth + 1, 0)) + }; } - function restoreFilterPreferences(select) { - try { - const rawPreferences = localStorage.getItem(FILTER_PREFERENCES_STORAGE_KEY); - - if (!rawPreferences) { - return; - } - - const preferences = JSON.parse(rawPreferences); - const savedStages = Array.isArray(preferences.stages) - ? preferences.stages.filter((stage) => STAGE_OPTIONS.includes(stage)) - : null; - const savedStatuses = Array.isArray(preferences.statuses) - ? preferences.statuses.filter((status) => STATUS_OPTIONS.includes(status)) - : null; - - if (savedStages) { - selectedStages = new Set(savedStages); - } - - if (savedStatuses) { - selectedStatuses = new Set(savedStatuses); - } - - if (Array.isArray(preferences.customerKeys)) { - selectedCustomerKeys = new Set(preferences.customerKeys.map(String)); - hasSavedCustomerFilter = true; - customerFilterInitialized = true; - } - - if (Array.isArray(preferences.ownerKeys)) { - selectedOwnerKeys = new Set(preferences.ownerKeys.map(String)); - hasSavedOwnerFilter = true; - ownerFilterInitialized = true; - } - - customerSearch = typeof preferences.customerSearch === "string" ? preferences.customerSearch : ""; - ownerSearch = typeof preferences.ownerSearch === "string" ? preferences.ownerSearch : ""; - tableSearch = typeof preferences.tableSearch === "string" ? preferences.tableSearch : ""; - - if (PERIOD_OPTIONS.includes(preferences.period)) { - select.value = preferences.period; - } - } catch (error) { - // Ignore malformed or unavailable browser storage and use default filters. - } + function formatOracleDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}T00:00:00.000Z`; } - function saveFilterPreferences(period) { - try { - const currentPeriod = period || document.querySelector(`#${MODAL_ID} select`)?.value || PERIOD_OPTIONS[0]; - localStorage.setItem(FILTER_PREFERENCES_STORAGE_KEY, JSON.stringify({ - period: currentPeriod, - stages: Array.from(selectedStages), - statuses: Array.from(selectedStatuses), - customerKeys: hasSavedCustomerFilter ? Array.from(selectedCustomerKeys) : null, - ownerKeys: hasSavedOwnerFilter ? Array.from(selectedOwnerKeys) : null, - customerSearch, - ownerSearch, - tableSearch - })); - } catch (error) { - // Ignore unavailable browser storage; filters still work for the current modal. - } - } + return { getFiscalPeriodRange, createDateRange, formatOracleDate }; +} +// Source: src/features/opportunities/table.js +function createOpportunitiesTable({ + ACCOUNT_DETAIL_URL, + OPPORTUNITIES_COLUMNS, + OPPORTUNITIES_TABLE_ID, + OPPORTUNITY_DETAIL_URL, + createCloudConsumptionButton, + createCopyOpportunityButton, + createDetailLink, + createProductsCountButton, + createProductsDetailRow, + createStageBadge, + createStatusBadge, + displayOpportunityValue, + getNestedValue, + getSortedOpportunities, + opportunitiesTableState, + productsContentState, + renderCustomerFilter, + renderOwnerFilter, + renderStageDashboard, + resetCustomerFilter, + resetOwnerFilter, + environment +}) { + const { document } = environment; function setOpportunitiesTableState(nextState) { if (nextState.status === "ready" && Array.isArray(nextState.items)) { resetCustomerFilter(nextState.items); resetOwnerFilter(nextState.items); } - opportunitiesTableState = { - ...opportunitiesTableState, + opportunitiesTableState.opportunitiesTableState = { + ...opportunitiesTableState.opportunitiesTableState, ...nextState, - sortKey: nextState.items ? "" : opportunitiesTableState.sortKey, - sortDirection: nextState.items ? "ascending" : opportunitiesTableState.sortDirection + sortKey: nextState.items ? "" : opportunitiesTableState.opportunitiesTableState.sortKey, + sortDirection: nextState.items ? "ascending" : opportunitiesTableState.opportunitiesTableState.sortDirection }; renderOpportunitiesTable(); renderCustomerFilter(); @@ -1253,7 +1869,7 @@ } table.textContent = ""; - table.setAttribute("aria-busy", opportunitiesTableState.status === "loading" ? "true" : "false"); + table.setAttribute("aria-busy", opportunitiesTableState.opportunitiesTableState.status === "loading" ? "true" : "false"); const caption = document.createElement("caption"); caption.className = "opportunities-extension-visually-hidden"; @@ -1264,7 +1880,7 @@ OPPORTUNITIES_COLUMNS.forEach((column) => { const header = document.createElement("th"); - const isSorted = opportunitiesTableState.sortKey === column.key; + const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key; header.scope = "col"; if (column.sortable === false) { @@ -1273,13 +1889,13 @@ label.textContent = column.label; header.append(label); } else { - header.setAttribute("aria-sort", isSorted ? opportunitiesTableState.sortDirection : "none"); + header.setAttribute("aria-sort", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none"); const sortButton = document.createElement("button"); sortButton.type = "button"; sortButton.className = "opportunities-extension-sort-button"; - sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.sortDirection : "none"); - sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.sortDirection}` : ""}`); + sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none"); + sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`); sortButton.textContent = column.label; sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key)); header.append(sortButton); @@ -1294,12 +1910,12 @@ const items = getSortedOpportunities(); renderStageDashboard(items); - if (opportunitiesTableState.status === "loading" || opportunitiesTableState.status === "error" || opportunitiesTableState.status === "empty" || (opportunitiesTableState.status === "ready" && items.length === 0)) { + if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) { const row = document.createElement("tr"); const cell = document.createElement("td"); cell.className = "opportunities-extension-table-status"; cell.colSpan = OPPORTUNITIES_COLUMNS.length; - cell.textContent = opportunitiesTableState.message || "No opportunities found."; + cell.textContent = opportunitiesTableState.opportunitiesTableState.message || "No opportunities found."; row.append(cell); tableBody.append(row); } else { @@ -1316,7 +1932,7 @@ } else if (column.key === "actions") { const actionsContent = document.createElement("span"); actionsContent.className = "opportunities-extension-actions-content"; - const productsResult = opptyProductsByNumber.get(String(item.OptyNumber)); + const productsResult = productsContentState.opptyProductsByNumber.get(String(item.OptyNumber)); if (productsResult) { actionsContent.append(createProductsCountButton(item.OptyNumber, productsResult)); @@ -1359,7 +1975,7 @@ tableBody.append(row); - if (item.OptyNumber && expandedProductOptyNumbers.has(String(item.OptyNumber))) { + if (item.OptyNumber && productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber))) { tableBody.append(createProductsDetailRow(item)); } }); @@ -1368,6 +1984,27 @@ table.append(caption, tableHead, tableBody); } + function sortOpportunitiesBy(key) { + const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key; + opportunitiesTableState.opportunitiesTableState.sortKey = key; + opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending" + ? "descending" + : "ascending"; + renderOpportunitiesTable(); + } + + return { setOpportunitiesTableState, renderOpportunitiesTable, sortOpportunitiesBy }; +} + +// Source: src/features/opportunities/dashboard.js +function createOpportunitiesDashboard({ + STAGE_DASHBOARD_ID, + STAGE_OPTIONS, + getNestedValue, + opportunitiesDashboardState, + environment +}) { + const { document, requestAnimationFrame, performance } = environment; function renderStageDashboard(items) { const dashboard = document.getElementById(STAGE_DASHBOARD_ID); @@ -1449,8 +2086,8 @@ } function animateStageDashboardValue(element, stage, target, format) { - const current = stageDashboardAmounts.get(stage) || 0; - stageDashboardAmounts.set(stage, target); + const current = opportunitiesDashboardState.stageDashboardAmounts.get(stage) || 0; + opportunitiesDashboardState.stageDashboardAmounts.set(stage, target); if (current === target) { element.textContent = format(target); @@ -1477,13 +2114,48 @@ requestAnimationFrame(tick); } - function sortOpportunitiesBy(key) { - const isSameColumn = opportunitiesTableState.sortKey === key; - opportunitiesTableState.sortKey = key; - opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.sortDirection === "ascending" - ? "descending" - : "ascending"; - renderOpportunitiesTable(); + return { renderStageDashboard, createStageAmountFormatter, animateStageDashboardValue }; +} + +// Source: src/features/opportunities/filters.js +function createOpportunitiesFilters({ + CUSTOMER_FILTER_BUTTON_ID, + CUSTOMER_FILTER_LIST_ID, + CUSTOMER_FILTER_PANEL_ID, + CUSTOMER_FILTER_SEARCH_ID, + CUSTOMER_FILTER_SELECT_ALL_ID, + OPPORTUNITIES_COLUMNS, + OWNER_FILTER_BUTTON_ID, + OWNER_FILTER_LIST_ID, + OWNER_FILTER_PANEL_ID, + OWNER_FILTER_SEARCH_ID, + OWNER_FILTER_SELECT_ALL_ID, + STAGE_FILTERS_ID, + STAGE_OPTIONS, + STATUS_FILTERS_ID, + STATUS_OPTIONS, + getCustomerFilterSummary, + getCustomerKey, + getCustomerOptions, + getOwnerKey, + getOwnerOptions, + opportunitiesFilterState, + opportunitiesTableState, + renderOpportunitiesTable, + saveFilterPreferences, + stringifySearchValue, + environment +}) { + const { document } = environment; + function resetCustomerAndOwnerFiltersForPeriod() { + opportunitiesFilterState.selectedCustomerKeys = new Set(); + opportunitiesFilterState.selectedOwnerKeys = new Set(); + opportunitiesFilterState.customerSearch = ""; + opportunitiesFilterState.ownerSearch = ""; + opportunitiesFilterState.hasSavedCustomerFilter = false; + opportunitiesFilterState.hasSavedOwnerFilter = false; + opportunitiesFilterState.customerFilterInitialized = false; + opportunitiesFilterState.ownerFilterInitialized = false; } function renderStageFilterButtons() { @@ -1497,7 +2169,7 @@ STAGE_OPTIONS.forEach((stage) => { const button = document.createElement("button"); - const isPressed = selectedStages.has(stage); + const isPressed = opportunitiesFilterState.selectedStages.has(stage); button.type = "button"; button.className = "opportunities-extension-stage-filter-button"; @@ -1510,10 +2182,10 @@ } function toggleStageFilter(stage) { - if (selectedStages.has(stage)) { - selectedStages.delete(stage); + if (opportunitiesFilterState.selectedStages.has(stage)) { + opportunitiesFilterState.selectedStages.delete(stage); } else { - selectedStages.add(stage); + opportunitiesFilterState.selectedStages.add(stage); } saveFilterPreferences(); @@ -1532,7 +2204,7 @@ STATUS_OPTIONS.forEach((status) => { const button = document.createElement("button"); - const isPressed = selectedStatuses.has(status); + const isPressed = opportunitiesFilterState.selectedStatuses.has(status); button.type = "button"; button.className = "opportunities-extension-status-filter-button"; @@ -1545,10 +2217,10 @@ } function toggleStatusFilter(status) { - if (selectedStatuses.has(status)) { - selectedStatuses.delete(status); + if (opportunitiesFilterState.selectedStatuses.has(status)) { + opportunitiesFilterState.selectedStatuses.delete(status); } else { - selectedStatuses.add(status); + opportunitiesFilterState.selectedStatuses.add(status); } saveFilterPreferences(); @@ -1628,17 +2300,17 @@ function resetCustomerFilter(items) { const customerOptions = getCustomerOptions(items); - if (!customerFilterInitialized) { - selectedCustomerKeys = new Set(customerOptions.map((customer) => customer.key)); - customerFilterInitialized = true; + if (!opportunitiesFilterState.customerFilterInitialized) { + opportunitiesFilterState.selectedCustomerKeys = new Set(customerOptions.map((customer) => customer.key)); + opportunitiesFilterState.customerFilterInitialized = true; } } function resetOwnerFilter(items) { const ownerOptions = getOwnerOptions(items); - if (!ownerFilterInitialized) { - selectedOwnerKeys = new Set(ownerOptions.map((owner) => owner.key)); - ownerFilterInitialized = true; + if (!opportunitiesFilterState.ownerFilterInitialized) { + opportunitiesFilterState.selectedOwnerKeys = new Set(ownerOptions.map((owner) => owner.key)); + opportunitiesFilterState.ownerFilterInitialized = true; } } @@ -1652,10 +2324,10 @@ return; } - const selectedCount = customerOptions.filter((customer) => selectedCustomerKeys.has(customer.key)).length; + const selectedCount = customerOptions.filter((customer) => opportunitiesFilterState.selectedCustomerKeys.has(customer.key)).length; trigger.disabled = customerOptions.length === 0; trigger.textContent = getCustomerFilterSummary(customerOptions.length, selectedCount); - searchInput.value = customerSearch; + searchInput.value = opportunitiesFilterState.customerSearch; selectAllCheckbox.disabled = customerOptions.length === 0; selectAllCheckbox.checked = customerOptions.length > 0 && selectedCount === customerOptions.length; selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < customerOptions.length; @@ -1669,7 +2341,7 @@ return; } - const normalizedSearch = customerSearch.trim().toLocaleLowerCase(); + const normalizedSearch = opportunitiesFilterState.customerSearch.trim().toLocaleLowerCase(); const customerOptions = getCustomerOptions().filter((customer) => { return customer.label.toLocaleLowerCase().includes(normalizedSearch); }); @@ -1679,7 +2351,7 @@ if (customerOptions.length === 0) { const empty = document.createElement("p"); empty.className = "opportunities-extension-customer-filter-empty"; - empty.textContent = customerSearch ? "No matching customers." : "No customers available."; + empty.textContent = opportunitiesFilterState.customerSearch ? "No matching customers." : "No customers available."; list.append(empty); return; } @@ -1690,15 +2362,15 @@ const checkbox = document.createElement("input"); checkbox.type = "checkbox"; - checkbox.checked = selectedCustomerKeys.has(customer.key); + checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key); checkbox.addEventListener("change", () => { if (checkbox.checked) { - selectedCustomerKeys.add(customer.key); + opportunitiesFilterState.selectedCustomerKeys.add(customer.key); } else { - selectedCustomerKeys.delete(customer.key); + opportunitiesFilterState.selectedCustomerKeys.delete(customer.key); } - hasSavedCustomerFilter = true; + opportunitiesFilterState.hasSavedCustomerFilter = true; saveFilterPreferences(); renderCustomerFilter(); renderOpportunitiesTable(); @@ -1711,10 +2383,152 @@ }); } + function renderOwnerFilter() { + const trigger = document.getElementById(OWNER_FILTER_BUTTON_ID); + const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID); + const selectAllCheckbox = document.getElementById(OWNER_FILTER_SELECT_ALL_ID); + const ownerOptions = getOwnerOptions(); + + if (!trigger || !searchInput || !selectAllCheckbox) { + return; + } + + const selectedCount = ownerOptions.filter((owner) => opportunitiesFilterState.selectedOwnerKeys.has(owner.key)).length; + trigger.disabled = ownerOptions.length === 0; + trigger.textContent = getCustomerFilterSummary(ownerOptions.length, selectedCount) + .replace("customer", "owner"); + searchInput.value = opportunitiesFilterState.ownerSearch; + selectAllCheckbox.disabled = ownerOptions.length === 0; + selectAllCheckbox.checked = ownerOptions.length > 0 && selectedCount === ownerOptions.length; + selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < ownerOptions.length; + renderOwnerFilterList(); + } + + function renderOwnerFilterList() { + const list = document.getElementById(OWNER_FILTER_LIST_ID); + + if (!list) { + return; + } + + const normalizedSearch = opportunitiesFilterState.ownerSearch.trim().toLocaleLowerCase(); + const ownerOptions = getOwnerOptions().filter((owner) => { + return owner.label.toLocaleLowerCase().includes(normalizedSearch); + }); + + list.textContent = ""; + + if (ownerOptions.length === 0) { + const empty = document.createElement("p"); + empty.className = "opportunities-extension-customer-filter-empty"; + empty.textContent = opportunitiesFilterState.ownerSearch ? "No matching owners." : "No owners available."; + list.append(empty); + return; + } + + ownerOptions.forEach((owner) => { + const option = document.createElement("label"); + option.className = "opportunities-extension-customer-filter-option"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key); + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + opportunitiesFilterState.selectedOwnerKeys.add(owner.key); + } else { + opportunitiesFilterState.selectedOwnerKeys.delete(owner.key); + } + + opportunitiesFilterState.hasSavedOwnerFilter = true; + saveFilterPreferences(); + renderOwnerFilter(); + renderOpportunitiesTable(); + }); + + const label = document.createElement("span"); + label.textContent = owner.label; + option.append(checkbox, label); + list.append(option); + }); + } + + function getSortedOpportunities() { + const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase(); + const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => { + return opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c) + && matchesSelectedStatus(item.StatusCode) + && opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)) + && opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)) + && matchesTableSearch(item, normalizedSearch); + }); + const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey); + + if (!column) { + return items; + } + + const direction = opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending" ? 1 : -1; + + return items.sort((first, second) => { + const firstValue = column.value(first); + const secondValue = column.value(second); + const firstIsEmpty = firstValue === null || firstValue === undefined || firstValue === ""; + const secondIsEmpty = secondValue === null || secondValue === undefined || secondValue === ""; + + if (firstIsEmpty || secondIsEmpty) { + if (firstIsEmpty && secondIsEmpty) { + return 0; + } + + return firstIsEmpty ? 1 : -1; + } + + const firstNumber = Number(firstValue); + const secondNumber = Number(secondValue); + + if (Number.isFinite(firstNumber) && Number.isFinite(secondNumber)) { + return (firstNumber - secondNumber) * direction; + } + + return String(firstValue).localeCompare(String(secondValue), undefined, { + numeric: true, + sensitivity: "base" + }) * direction; + }); + } + + function matchesTableSearch(item, normalizedSearch) { + if (!normalizedSearch) { + return true; + } + + return OPPORTUNITIES_COLUMNS.some((column) => { + const rawValue = stringifySearchValue(column.value(item)); + const displayValue = stringifySearchValue(column.display(item)); + return `${rawValue} ${displayValue}`.toLocaleLowerCase().includes(normalizedSearch); + }); + } + + function matchesSelectedStatus(statusCode) { + if (statusCode === "WON_PENDING") { + return opportunitiesFilterState.selectedStatuses.has("WON"); + } + + return opportunitiesFilterState.selectedStatuses.has(statusCode); + } + + return { resetCustomerAndOwnerFiltersForPeriod, renderStageFilterButtons, toggleStageFilter, renderStatusFilterButtons, toggleStatusFilter, toggleCustomerFilterPanel, closeCustomerFilterPanel, toggleOwnerFilterPanel, closeOwnerFilterPanel, resetCustomerFilter, resetOwnerFilter, renderCustomerFilter, renderCustomerFilterList, renderOwnerFilter, renderOwnerFilterList, getSortedOpportunities, matchesTableSearch, matchesSelectedStatus }; +} + +// Source: src/features/opportunities/formatters.js +function createOpportunitiesFormatters({ + opportunitiesTableState +}) { function getCustomerOptions(items) { const optionsByKey = new Map(); - (items || opportunitiesTableState.items).forEach((item) => { + (items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => { const key = getCustomerKey(item); const label = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]); @@ -1784,80 +2598,10 @@ return `${selected} customer${selected === 1 ? "" : "s"}`; } - function renderOwnerFilter() { - const trigger = document.getElementById(OWNER_FILTER_BUTTON_ID); - const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID); - const selectAllCheckbox = document.getElementById(OWNER_FILTER_SELECT_ALL_ID); - const ownerOptions = getOwnerOptions(); - - if (!trigger || !searchInput || !selectAllCheckbox) { - return; - } - - const selectedCount = ownerOptions.filter((owner) => selectedOwnerKeys.has(owner.key)).length; - trigger.disabled = ownerOptions.length === 0; - trigger.textContent = getCustomerFilterSummary(ownerOptions.length, selectedCount) - .replace("customer", "owner"); - searchInput.value = ownerSearch; - selectAllCheckbox.disabled = ownerOptions.length === 0; - selectAllCheckbox.checked = ownerOptions.length > 0 && selectedCount === ownerOptions.length; - selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < ownerOptions.length; - renderOwnerFilterList(); - } - - function renderOwnerFilterList() { - const list = document.getElementById(OWNER_FILTER_LIST_ID); - - if (!list) { - return; - } - - const normalizedSearch = ownerSearch.trim().toLocaleLowerCase(); - const ownerOptions = getOwnerOptions().filter((owner) => { - return owner.label.toLocaleLowerCase().includes(normalizedSearch); - }); - - list.textContent = ""; - - if (ownerOptions.length === 0) { - const empty = document.createElement("p"); - empty.className = "opportunities-extension-customer-filter-empty"; - empty.textContent = ownerSearch ? "No matching owners." : "No owners available."; - list.append(empty); - return; - } - - ownerOptions.forEach((owner) => { - const option = document.createElement("label"); - option.className = "opportunities-extension-customer-filter-option"; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.checked = selectedOwnerKeys.has(owner.key); - checkbox.addEventListener("change", () => { - if (checkbox.checked) { - selectedOwnerKeys.add(owner.key); - } else { - selectedOwnerKeys.delete(owner.key); - } - - hasSavedOwnerFilter = true; - saveFilterPreferences(); - renderOwnerFilter(); - renderOpportunitiesTable(); - }); - - const label = document.createElement("span"); - label.textContent = owner.label; - option.append(checkbox, label); - list.append(option); - }); - } - function getOwnerOptions(items) { const optionsByKey = new Map(); - (items || opportunitiesTableState.items).forEach((item) => { + (items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => { const key = getOwnerKey(item); const label = getOwnerName(item); @@ -1885,71 +2629,6 @@ return name ? `name:${name}` : ""; } - function getSortedOpportunities() { - const normalizedSearch = tableSearch.trim().toLocaleLowerCase(); - const items = opportunitiesTableState.items.filter((item) => { - return selectedStages.has(item.ForecastGroup_c) - && matchesSelectedStatus(item.StatusCode) - && selectedCustomerKeys.has(getCustomerKey(item)) - && selectedOwnerKeys.has(getOwnerKey(item)) - && matchesTableSearch(item, normalizedSearch); - }); - const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.sortKey); - - if (!column) { - return items; - } - - const direction = opportunitiesTableState.sortDirection === "ascending" ? 1 : -1; - - return items.sort((first, second) => { - const firstValue = column.value(first); - const secondValue = column.value(second); - const firstIsEmpty = firstValue === null || firstValue === undefined || firstValue === ""; - const secondIsEmpty = secondValue === null || secondValue === undefined || secondValue === ""; - - if (firstIsEmpty || secondIsEmpty) { - if (firstIsEmpty && secondIsEmpty) { - return 0; - } - - return firstIsEmpty ? 1 : -1; - } - - const firstNumber = Number(firstValue); - const secondNumber = Number(secondValue); - - if (Number.isFinite(firstNumber) && Number.isFinite(secondNumber)) { - return (firstNumber - secondNumber) * direction; - } - - return String(firstValue).localeCompare(String(secondValue), undefined, { - numeric: true, - sensitivity: "base" - }) * direction; - }); - } - - function matchesTableSearch(item, normalizedSearch) { - if (!normalizedSearch) { - return true; - } - - return OPPORTUNITIES_COLUMNS.some((column) => { - const rawValue = stringifySearchValue(column.value(item)); - const displayValue = stringifySearchValue(column.display(item)); - return `${rawValue} ${displayValue}`.toLocaleLowerCase().includes(normalizedSearch); - }); - } - - function matchesSelectedStatus(statusCode) { - if (statusCode === "WON_PENDING") { - return selectedStatuses.has("WON"); - } - - return selectedStatuses.has(statusCode); - } - function stringifySearchValue(value) { if (value === null || value === undefined) { return ""; @@ -1967,6 +2646,63 @@ return value === null || value === undefined || value === "" ? "-" : String(value); } + function getNestedValue(value, path) { + return path.reduce((result, key) => result && result[key], value); + } + + function formatWinProbability(item) { + const value = getNestedValue(item, ["PrimaryRevenue", "WinProb"]); + const number = Number(value); + return Number.isFinite(number) ? `${number}%` : value; + } + + function formatRevenue(item) { + const amount = getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]); + const currency = getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]); + const number = Number(amount); + + if (!Number.isFinite(number)) { + return amount; + } + + if (!currency) { + return number.toLocaleString(); + } + + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency + }).format(number); + } catch (error) { + return `${currency} ${number.toLocaleString()}`; + } + } + + function formatOracleResponseDate(value, includeTime) { + if (typeof value !== "string") { + return value; + } + + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/); + + if (!match) { + return value; + } + + const date = `${match[3]}/${match[2]}/${match[1]}`; + return includeTime && match[4] ? `${date} ${match[4]}:${match[5]}` : date; + } + + return { getCustomerOptions, getCustomerKey, getOwnerName, getCustomerFilterSummary, getOwnerOptions, getOwnerKey, stringifySearchValue, displayOpportunityValue, getNestedValue, formatWinProbability, formatRevenue, formatOracleResponseDate }; +} + +// Source: src/features/opportunities/links.js +function createOpportunitiesLinks({ + MODAL_ID, + environment +}) { + const { document, navigator, window } = environment; function createDetailLink(label, href) { if (label === null || label === undefined || label === "") { const placeholder = document.createElement("span"); @@ -2011,11 +2747,812 @@ return button; } + async function copyOpportunityNumber(optyNumber) { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(optyNumber); + } else { + const input = document.createElement("textarea"); + input.value = optyNumber; + input.setAttribute("readonly", "true"); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.append(input); + input.select(); + document.execCommand("copy"); + input.remove(); + } + + showCopyConfirmation(`Opty Number ${optyNumber} copied.`); + } catch (error) { + showCopyConfirmation("Unable to copy opportunity number.", true); + } + } + + function showCopyConfirmation(message, isError = false) { + const modal = document.getElementById(MODAL_ID); + + if (!modal) { + return; + } + + let toast = modal.querySelector(".opportunities-extension-copy-toast"); + + if (!toast) { + toast = document.createElement("div"); + toast.className = "opportunities-extension-copy-toast"; + toast.setAttribute("role", "status"); + modal.append(toast); + } + + toast.classList.toggle("opportunities-extension-copy-toast-error", isError); + toast.textContent = message; + toast.hidden = false; + window.clearTimeout(toast.copyTimeout); + toast.copyTimeout = window.setTimeout(() => { + toast.hidden = true; + }, 2400); + } + + function createStageBadge(stage) { + const badge = document.createElement("span"); + const normalizedStage = typeof stage === "string" ? stage.toUpperCase() : ""; + + badge.className = "opportunities-extension-stage-badge"; + badge.setAttribute("data-stage", normalizedStage); + badge.textContent = normalizedStage || "-"; + return badge; + } + + function createStatusBadge(status) { + const badge = document.createElement("span"); + const normalizedStatus = typeof status === "string" ? status.toUpperCase() : ""; + + badge.className = "opportunities-extension-status-badge"; + badge.setAttribute("data-status", normalizedStatus); + badge.textContent = normalizedStatus || "-"; + return badge; + } + + return { createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge }; +} + +// Source: src/features/preferences/storage.js +function createPreferencesStorage({ + DEFAULT_OPPORTUNITY_TYPE_VALUES, + DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, + FILTER_PREFERENCES_STORAGE_KEY, + MODAL_ID, + OPPORTUNITY_TYPE_OPTIONS, + OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, + OPPTY_PRODUCTS_CACHE_OPTIONS, + PERIOD_OPTIONS, + STAGE_OPTIONS, + STATUS_OPTIONS, + opportunitiesFilterState, + environment +}) { + const { document } = environment; + const getLocalStorage = () => environment.localStorage; + function restoreFilterPreferences(select) { + try { + const rawPreferences = getLocalStorage().getItem(FILTER_PREFERENCES_STORAGE_KEY); + + if (!rawPreferences) { + return; + } + + const preferences = JSON.parse(rawPreferences); + const savedStages = Array.isArray(preferences.stages) + ? preferences.stages.filter((stage) => STAGE_OPTIONS.includes(stage)) + : null; + const savedStatuses = Array.isArray(preferences.statuses) + ? preferences.statuses.filter((status) => STATUS_OPTIONS.includes(status)) + : null; + + if (savedStages) { + opportunitiesFilterState.selectedStages = new Set(savedStages); + } + + if (savedStatuses) { + opportunitiesFilterState.selectedStatuses = new Set(savedStatuses); + } + + if (Array.isArray(preferences.customerKeys)) { + opportunitiesFilterState.selectedCustomerKeys = new Set(preferences.customerKeys.map(String)); + opportunitiesFilterState.hasSavedCustomerFilter = true; + opportunitiesFilterState.customerFilterInitialized = true; + } + + if (Array.isArray(preferences.ownerKeys)) { + opportunitiesFilterState.selectedOwnerKeys = new Set(preferences.ownerKeys.map(String)); + opportunitiesFilterState.hasSavedOwnerFilter = true; + opportunitiesFilterState.ownerFilterInitialized = true; + } + + opportunitiesFilterState.customerSearch = typeof preferences.customerSearch === "string" ? preferences.customerSearch : ""; + opportunitiesFilterState.ownerSearch = typeof preferences.ownerSearch === "string" ? preferences.ownerSearch : ""; + opportunitiesFilterState.tableSearch = typeof preferences.tableSearch === "string" ? preferences.tableSearch : ""; + + if (PERIOD_OPTIONS.includes(preferences.period)) { + select.value = preferences.period; + } + } catch (error) { + // Ignore malformed or unavailable browser storage and use default filters. + } + } + + function saveFilterPreferences(period) { + try { + const currentPeriod = period || document.querySelector(`#${MODAL_ID} select`)?.value || PERIOD_OPTIONS[0]; + getLocalStorage().setItem(FILTER_PREFERENCES_STORAGE_KEY, JSON.stringify({ + period: currentPeriod, + stages: Array.from(opportunitiesFilterState.selectedStages), + statuses: Array.from(opportunitiesFilterState.selectedStatuses), + customerKeys: opportunitiesFilterState.hasSavedCustomerFilter ? Array.from(opportunitiesFilterState.selectedCustomerKeys) : null, + ownerKeys: opportunitiesFilterState.hasSavedOwnerFilter ? Array.from(opportunitiesFilterState.selectedOwnerKeys) : null, + customerSearch: opportunitiesFilterState.customerSearch, + ownerSearch: opportunitiesFilterState.ownerSearch, + tableSearch: opportunitiesFilterState.tableSearch + })); + } catch (error) { + // Ignore unavailable browser storage; filters still work for the current modal. + } + } + + function getSavedOpportunityTypeValues() { + try { + const rawPreferences = getLocalStorage().getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY); + + if (!rawPreferences) { + return DEFAULT_OPPORTUNITY_TYPE_VALUES; + } + + const preferences = JSON.parse(rawPreferences); + const values = Array.isArray(preferences.opportunityTypeValues) ? preferences.opportunityTypeValues : null; + + if (!values) { + return DEFAULT_OPPORTUNITY_TYPE_VALUES; + } + + const allowedValues = new Set(OPPORTUNITY_TYPE_OPTIONS.map((option) => option.value)); + return values.filter((value) => allowedValues.has(value)); + } catch (error) { + return DEFAULT_OPPORTUNITY_TYPE_VALUES; + } + } + + function getSavedOpptyProductsCacheHours() { + try { + const rawPreferences = getLocalStorage().getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY); + + if (!rawPreferences) { + return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; + } + + const preferences = JSON.parse(rawPreferences); + const cacheHours = Number(preferences.opptyProductsCacheHours); + return OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === cacheHours) + ? cacheHours + : DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; + } catch (error) { + return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; + } + } + + return { restoreFilterPreferences, saveFilterPreferences, getSavedOpportunityTypeValues, getSavedOpptyProductsCacheHours }; +} + +// Source: src/features/preferences/view.js +function createPreferencesView({ + DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, + MODAL_ID, + OPPORTUNITY_TYPE_OPTIONS, + OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, + OPPTY_PRODUCTS_CACHE_OPTIONS, + PERIOD_OPTIONS, + PREFERENCES_MODAL_ID, + createCloseIcon, + getSavedOpportunityTypeValues, + getSavedOpptyProductsCacheHours, + preferencesState, + requestOpportunitiesForPeriod, + environment +}) { + const { document } = environment; + const getLocalStorage = () => environment.localStorage; + function openPreferencesModal() { + const overlay = document.getElementById(MODAL_ID); + + if (!overlay) { + return; + } + + const existingModal = document.getElementById(PREFERENCES_MODAL_ID); + + if (existingModal) { + const savedValues = new Set(getSavedOpportunityTypeValues()); + existingModal.querySelectorAll("input[name='opportunityTypeView']").forEach((checkbox) => { + checkbox.checked = savedValues.has(checkbox.value); + }); + const cacheSelect = existingModal.querySelector("select[name='opptyProductsCacheHours']"); + + if (cacheSelect) { + cacheSelect.value = String(getSavedOpptyProductsCacheHours()); + } + + existingModal.hidden = false; + existingModal.querySelector("input")?.focus(); + return; + } + + const preferencesModal = document.createElement("section"); + preferencesModal.id = PREFERENCES_MODAL_ID; + preferencesModal.className = "opportunities-extension-preferences-modal"; + preferencesModal.setAttribute("role", "dialog"); + preferencesModal.setAttribute("aria-modal", "true"); + preferencesModal.setAttribute("aria-labelledby", "opportunities-extension-preferences-title"); + + const dialog = document.createElement("div"); + dialog.className = "opportunities-extension-preferences-dialog"; + + const header = document.createElement("header"); + header.className = "opportunities-extension-preferences-header"; + + const title = document.createElement("h2"); + title.id = "opportunities-extension-preferences-title"; + title.textContent = "Preferences"; + + const closeButton = document.createElement("button"); + closeButton.type = "button"; + closeButton.className = "opportunities-extension-icon-button"; + closeButton.setAttribute("aria-label", "Fechar preferências"); + closeButton.title = "Close"; + closeButton.append(createCloseIcon()); + closeButton.addEventListener("click", closePreferencesModal); + header.append(title, closeButton); + + const content = document.createElement("div"); + content.className = "opportunities-extension-preferences-content"; + + const fieldset = document.createElement("fieldset"); + fieldset.className = "opportunities-extension-preferences-fieldset"; + + const legend = document.createElement("legend"); + legend.textContent = "Opportunity Type View"; + fieldset.append(legend); + + const selectedValues = new Set(getSavedOpportunityTypeValues()); + const options = document.createElement("div"); + options.className = "opportunities-extension-preferences-options"; + + OPPORTUNITY_TYPE_OPTIONS.forEach((option) => { + const label = document.createElement("label"); + label.className = "opportunities-extension-preferences-option"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.name = "opportunityTypeView"; + checkbox.value = option.value; + checkbox.checked = selectedValues.has(option.value); + + const text = document.createElement("span"); + text.textContent = option.label; + label.append(checkbox, text); + options.append(label); + }); + + fieldset.append(options); + + const cacheField = document.createElement("label"); + cacheField.className = "opportunities-extension-preferences-cache-field"; + + const cacheLabel = document.createElement("span"); + cacheLabel.textContent = "Product cache duration"; + + const cacheSelect = document.createElement("select"); + cacheSelect.name = "opptyProductsCacheHours"; + cacheSelect.setAttribute("aria-label", "Product cache duration"); + OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => { + const optionElement = document.createElement("option"); + optionElement.value = String(option.value); + optionElement.textContent = option.label; + optionElement.selected = option.value === getSavedOpptyProductsCacheHours(); + cacheSelect.append(optionElement); + }); + cacheField.append(cacheLabel, cacheSelect); + content.append(fieldset, cacheField); + + const footer = document.createElement("footer"); + footer.className = "opportunities-extension-preferences-footer"; + + const saveButton = document.createElement("button"); + saveButton.type = "button"; + saveButton.className = "opportunities-extension-button opportunities-extension-button-primary"; + saveButton.textContent = "Save"; + saveButton.addEventListener("click", () => { + const selectedValues = Array.from(preferencesModal.querySelectorAll("input[name='opportunityTypeView']:checked"), (checkbox) => checkbox.value); + const selectedCacheHours = Number(preferencesModal.querySelector("select[name='opptyProductsCacheHours']")?.value); + preferencesState.selectedOpportunityTypeValues = new Set(selectedValues); + preferencesState.opptyProductsCacheHours = OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === selectedCacheHours) + ? selectedCacheHours + : DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; + + try { + getLocalStorage().setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({ + opportunityTypeValues: selectedValues, + opptyProductsCacheHours: preferencesState.opptyProductsCacheHours + })); + } catch (error) { + // Continue with the saved selection for the active modal when storage is unavailable. + } + + closePreferencesModal(); + const periodSelect = overlay.querySelector("select[name='opportunityPeriod']"); + requestOpportunitiesForPeriod(periodSelect?.value || PERIOD_OPTIONS[0]); + }); + + footer.append(saveButton); + dialog.append(header, content, footer); + preferencesModal.append(dialog); + overlay.append(preferencesModal); + preferencesModal.querySelector("input")?.focus(); + } + + function closePreferencesModal() { + const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID); + + if (preferencesModal) { + preferencesModal.hidden = true; + } + } + + return { openPreferencesModal, closePreferencesModal }; +} + +// Source: src/features/products/content-jobs.js +function createProductsContentJobs({ + messageTypes, + PRODUCTS_CACHE_TIMESTAMP_ID, + PRODUCTS_MESSAGE_LISTENER_KEY, + PRODUCTS_PROGRESS_ID, + preferencesState, + productsContentState, + renderOpportunitiesTable, + sendRuntimeMessage, + environment +}) { + const { browser, chrome, document, window } = environment; + function requestOpptyProductsInBackground(opportunities, token) { + const optyNumbers = Array.from(new Set(opportunities + .map((item) => item && item.OptyNumber) + .filter(Boolean) + .map(String))); + + const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + productsContentState.currentProductsRequestId = requestId; + productsContentState.opptyProductsByNumber = new Map(); + productsContentState.expandedProductOptyNumbers = new Set(); + productsContentState.productsProgress = { + state: optyNumbers.length ? "running" : "complete", + completed: 0, + total: optyNumbers.length, + failed: 0, + cacheHits: 0 + }; + renderProductsProgress(); + + if (optyNumbers.length === 0) { + return; + } + + sendRuntimeMessage({ + type: messageTypes.requestOpptyProducts, + requestId, + accessToken: token, + optyNumbers, + cacheMaxAgeMs: preferencesState.opptyProductsCacheHours * 60 * 60 * 1000 + }).then((response) => { + if (response && response.ok === false && response.requestId === productsContentState.currentProductsRequestId && productsContentState.productsProgress.state === "running") { + productsContentState.productsProgress.state = "error"; + productsContentState.productsProgress.failed = productsContentState.productsProgress.total; + renderProductsProgress(); + } + }).catch(() => { + if (requestId === productsContentState.currentProductsRequestId && productsContentState.productsProgress.state === "running") { + productsContentState.productsProgress.state = "error"; + productsContentState.productsProgress.failed = productsContentState.productsProgress.total; + renderProductsProgress(); + } + }); + } + + function ensureProductsBackgroundMessageHandler() { + if (window[PRODUCTS_MESSAGE_LISTENER_KEY]) { + return; + } + + const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + + if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.onMessage) { + return; + } + + window[PRODUCTS_MESSAGE_LISTENER_KEY] = true; + runtimeApi.runtime.onMessage.addListener((message) => { + if (!message || message.type !== messageTypes.opptyProductsProgress || message.requestId !== productsContentState.currentProductsRequestId) { + return false; + } + + if (message.optyNumber) { + productsContentState.opptyProductsByNumber.set(String(message.optyNumber), { + items: Array.isArray(message.items) ? message.items : [], + fromCache: Boolean(message.fromCache), + cachedAt: Number(message.cachedAt) || 0, + error: message.error || "" + }); + } + + if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) { + productsContentState.productsLastCachedAt = Number(message.cachedAt); + } + + productsContentState.productsProgress = { + state: message.state || productsContentState.productsProgress.state, + completed: Number(message.completed) || 0, + total: Number(message.total) || productsContentState.productsProgress.total, + failed: Number(message.failed) || 0, + cacheHits: Number(message.cacheHits) || 0 + }; + renderProductsProgress(); + queueProductsTableRender(); + return false; + }); + } + + function renderProductsProgress() { + const badge = document.getElementById(PRODUCTS_PROGRESS_ID); + + if (!badge) { + return; + } + + badge.hidden = productsContentState.productsProgress.state === "idle"; + badge.setAttribute("data-state", productsContentState.productsProgress.state); + + if (productsContentState.productsProgress.state === "running") { + badge.textContent = `Products ${productsContentState.productsProgress.completed}/${productsContentState.productsProgress.total}`; + badge.title = `${productsContentState.productsProgress.cacheHits} loaded from cache`; + } else if (productsContentState.productsProgress.state === "complete") { + badge.textContent = `Products ready ${productsContentState.productsProgress.total}`; + badge.title = `${productsContentState.productsProgress.cacheHits} loaded from cache`; + } else if (productsContentState.productsProgress.state === "complete-with-errors") { + badge.textContent = `Products ${productsContentState.productsProgress.total - productsContentState.productsProgress.failed}/${productsContentState.productsProgress.total}`; + badge.title = `${productsContentState.productsProgress.failed} product requests failed`; + } else if (productsContentState.productsProgress.state === "error") { + badge.textContent = "Products unavailable"; + badge.title = "Unable to load opportunity products"; + } else { + badge.textContent = "Products ready 0"; + } + + const timestamp = document.getElementById(PRODUCTS_CACHE_TIMESTAMP_ID); + + if (timestamp) { + timestamp.hidden = !productsContentState.productsLastCachedAt; + timestamp.textContent = productsContentState.productsLastCachedAt + ? `Last update: ${formatProductsCacheTimestamp(productsContentState.productsLastCachedAt)}` + : ""; + timestamp.title = productsContentState.productsLastCachedAt + ? `Latest product update: ${formatProductsCacheTimestamp(productsContentState.productsLastCachedAt)}` + : ""; + } + } + + function formatProductsCacheTimestamp(timestamp) { + try { + return new Intl.DateTimeFormat("en-GB", { + dateStyle: "short", + timeStyle: "short" + }).format(new Date(timestamp)); + } catch (error) { + return new Date(timestamp).toLocaleString(); + } + } + + function queueProductsTableRender() { + if (productsContentState.productsRenderQueued) { + return; + } + + productsContentState.productsRenderQueued = true; + window.requestAnimationFrame(() => { + productsContentState.productsRenderQueued = false; + renderOpportunitiesTable(); + }); + } + + return { requestOpptyProductsInBackground, ensureProductsBackgroundMessageHandler, renderProductsProgress, formatProductsCacheTimestamp, queueProductsTableRender }; +} + +// Source: src/features/products/details.js +function createProductsDetails({ + OPPORTUNITIES_COLUMNS, + createAiSummaryAlert, + formatOracleResponseDate, + productsContentState, + renderOpportunitiesTable, + requestGenerateAiSummary, + environment +}) { + const { document } = environment; + function createProductsCountButton(optyNumber, productsResult) { + const normalizedOptyNumber = String(optyNumber); + const button = document.createElement("button"); + const count = Array.isArray(productsResult.items) ? productsResult.items.length : 0; + const isExpanded = productsContentState.expandedProductOptyNumbers.has(normalizedOptyNumber); + + button.type = "button"; + button.className = "opportunities-extension-products-count"; + button.textContent = String(count); + button.setAttribute("aria-expanded", String(isExpanded)); + button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`); + button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`; + button.classList.toggle("opportunities-extension-products-count-error", Boolean(productsResult.error)); + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + + if (isExpanded) { + productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber); + } else { + productsContentState.expandedProductOptyNumbers.add(normalizedOptyNumber); + requestGenerateAiSummary(normalizedOptyNumber); + } + + renderOpportunitiesTable(); + }); + return button; + } + + function createProductsDetailRow(opportunity) { + const row = document.createElement("tr"); + const cell = document.createElement("td"); + const optyNumber = String(opportunity.OptyNumber); + const productsResult = productsContentState.opptyProductsByNumber.get(optyNumber); + const products = productsResult && Array.isArray(productsResult.items) ? productsResult.items : []; + + row.className = "opportunities-extension-products-detail-row"; + cell.colSpan = OPPORTUNITIES_COLUMNS.length; + cell.className = "opportunities-extension-products-detail-cell"; + + const panel = document.createElement("section"); + panel.className = "opportunities-extension-products-detail"; + + const heading = document.createElement("div"); + heading.className = "opportunities-extension-products-detail-heading"; + + const title = document.createElement("strong"); + title.textContent = `Products for ${optyNumber}`; + + const summary = document.createElement("span"); + summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated"; + heading.append(title, summary); + panel.append(heading); + panel.append(createAiSummaryAlert(optyNumber)); + + if (productsResult && productsResult.error) { + const error = document.createElement("p"); + error.className = "opportunities-extension-products-empty"; + error.textContent = productsResult.error; + panel.append(error); + } else if (products.length === 0) { + const empty = document.createElement("p"); + empty.className = "opportunities-extension-products-empty"; + empty.textContent = "No products found for this opportunity."; + panel.append(empty); + } else { + const table = document.createElement("table"); + table.className = "opportunities-extension-products-table"; + table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`); + + const columns = [ + { label: "Product Group", value: (item) => item.ProdGroupName || "-" }, + { label: "Workload", value: (item) => item.WorkloadName_c || "-" }, + { label: "Currency", value: (item) => item.RevnAmountCurcyCode || "-" }, + { label: "Amount", value: (item) => formatProductAmount(item.RevnAmount, item.RevnAmountCurcyCode) }, + { label: "Type", value: (item) => item.TypeCode || "-" }, + { label: "Status", value: (item) => item.StatusCode || "-" }, + { label: "Win Probability", value: (item) => formatProductWinProbability(item.WinProb) }, + { label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" }, + { label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" }, + { label: "Ramp Months", value: (item) => formatProductNumber(item.RampMonths_c) } + ]; + + const head = document.createElement("thead"); + const headerRow = document.createElement("tr"); + columns.forEach((column) => { + const header = document.createElement("th"); + header.scope = "col"; + header.textContent = column.label; + headerRow.append(header); + }); + head.append(headerRow); + + const body = document.createElement("tbody"); + products.forEach((product) => { + const productRow = document.createElement("tr"); + columns.forEach((column) => { + const productCell = document.createElement("td"); + productCell.textContent = String(column.value(product)); + productRow.append(productCell); + }); + body.append(productRow); + }); + + table.append(head, body); + panel.append(table); + } + + cell.append(panel); + row.append(cell); + return row; + } + + function formatProductNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(number) : "-"; + } + + function formatProductAmount(value, currency) { + const amount = Number(value); + + if (!Number.isFinite(amount)) { + return "-"; + } + + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency || "USD" + }).format(amount); + } catch (error) { + return `${currency || ""} ${formatProductNumber(amount)}`.trim(); + } + } + + function formatProductWinProbability(value) { + const number = Number(value); + return Number.isFinite(number) ? `${number}%` : "-"; + } + + return { createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability }; +} + +// Source: src/features/ai-summary/content.js +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 }; +} + +// Source: src/features/consumption/plans-action.js +function createConsumptionPlansAction({ + messageTypes, + appendBackgroundRequestLog, + consumptionPlansState, + getNestedValue, + openAccountPlansModal, + renderOpportunitiesTable, + requestConsumerTokenServiceOnce, + sendRuntimeMessage, + showCopyConfirmation, + environment +}) { + const { document } = environment; function createCloudConsumptionButton(opportunity) { const button = document.createElement("button"); const optyNumber = String(opportunity.OptyNumber || ""); const partyNumber = getNestedValue(opportunity, ["CustomerAccount", "PartyNumber"]); - const requestState = accountPlansRequestState.get(optyNumber); + const requestState = consumptionPlansState.accountPlansRequestState.get(optyNumber); const isLoading = requestState && requestState.status === "loading"; button.type = "button"; @@ -2031,12 +3568,12 @@ return; } - accountPlansRequestState.set(optyNumber, { status: "loading" }); + consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "loading" }); openAccountPlansModal(opportunity, [], "loading"); renderOpportunitiesTable(); requestConsumerTokenServiceOnce() .then((authorizations) => sendRuntimeMessage({ - type: "opportunitiesExtension.requestAccountPlans", + type: messageTypes.requestAccountPlans, partyNumber: String(partyNumber), authorizations })) @@ -2047,7 +3584,7 @@ throw new Error(response && response.error ? response.error : "Unable to load cloud consumption."); } - accountPlansRequestState.set(optyNumber, { + consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "ready", plans: Array.isArray(response.plans) ? response.plans : [] }); @@ -2055,7 +3592,7 @@ openAccountPlansModal(opportunity, response.plans || [], "ready"); }).catch((error) => { const errorMessage = error.message || "Unable to load cloud consumption."; - accountPlansRequestState.set(optyNumber, { + consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "error", error: errorMessage }); @@ -2089,6 +3626,23 @@ return svg; } + return { createCloudConsumptionButton, createCloudConsumptionIcon }; +} + +// Source: src/features/consumption/plans-view.js +function createConsumptionPlansView({ + ACCOUNT_PLANS_MODAL_ID, + DEFAULT_CONSUMPTION_TREND_PERIOD, + MODAL_ID, + createCloseIcon, + createConsumptionTrendSection, + createSvgElement, + formatOracleResponseDate, + getNestedValue, + loadConsumptionTrendForPanel, + environment +}) { + const { document, requestAnimationFrame, setTimeout, window } = environment; function openAccountPlansModal(opportunity, plans, state = "ready", errorMessage = "") { const overlay = document.getElementById(MODAL_ID); const optyNumber = String(opportunity.OptyNumber || ""); @@ -2424,6 +3978,30 @@ return details; } + function closeAccountPlansModal() { + const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); + + if (modal) { + modal.hidden = true; + } + } + + return { openAccountPlansModal, createAccountPlansTabsNavigation, createAccountPlansTabNavigationButton, createAccountPlansSkeleton, createAccountPlanStatusIndicator, createAccountPlanDetails, closeAccountPlansModal }; +} + +// Source: src/features/consumption/trend-controller.js +function createConsumptionTrendController({ + messageTypes, + CONSUMPTION_TREND_PERIODS, + DEFAULT_CONSUMPTION_TREND_PERIOD, + appendBackgroundRequestLog, + consumptionTrendState, + renderConsumptionTrendChart, + requestConsumerTokenServiceOnce, + sendRuntimeMessage, + environment +}) { + const { document } = environment; function createConsumptionTrendSection(plan) { const section = document.createElement("section"); section.className = "opportunities-extension-consumption-trend"; @@ -2446,7 +4024,7 @@ button.textContent = periodOption.label; button.addEventListener("click", () => { section.dataset.period = periodOption.value; - consumptionTrendWindowOffsets.set( + consumptionTrendState.consumptionTrendWindowOffsets.set( createConsumptionTrendKey(section.dataset.subPlanNum, periodOption.value), 0 ); @@ -2489,7 +4067,7 @@ return; } - const cached = consumptionTrendCache.get(key); + const cached = consumptionTrendState.consumptionTrendCache.get(key); renderConsumptionTrendSkeleton(chart); @@ -2502,7 +4080,7 @@ const request = requestConsumerTokenServiceOnce() .then((authorizations) => sendRuntimeMessage({ - type: "opportunitiesExtension.requestConsumptionTrend", + type: messageTypes.requestConsumptionTrend, subPlanNum, period: selectedPeriod, authorization: authorizations && authorizations.accountPlans @@ -2517,12 +4095,12 @@ return Array.isArray(response.items) ? response.items : []; }) .finally(() => { - if (consumptionTrendCache.get(key)?.promise === request) { - consumptionTrendCache.delete(key); + if (consumptionTrendState.consumptionTrendCache.get(key)?.promise === request) { + consumptionTrendState.consumptionTrendCache.delete(key); } }); - consumptionTrendCache.set(key, { status: "loading", promise: request }); + consumptionTrendState.consumptionTrendCache.set(key, { status: "loading", promise: request }); request .then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key)) .catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key)); @@ -2581,6 +4159,26 @@ chart.append(error); } + return { createConsumptionTrendSection, loadConsumptionTrendForPanel, createConsumptionTrendKey, updateConsumptionPeriodButtons, renderConsumptionTrendIfCurrent, renderConsumptionTrendErrorIfCurrent, renderConsumptionTrendSkeleton, renderConsumptionTrendError }; +} + +// Source: src/features/consumption/chart.js +function createConsumptionChart({ + CONSUMPTION_TREND_PERIODS, + CONSUMPTION_TREND_SERIES, + SVG_NAMESPACE, + calculateAverageConsumption, + consumptionTrendState, + createConsumptionTrendKey, + createNiceConsumptionMaximum, + formatCompactConsumptionNumber, + formatConsumptionCurrency, + formatConsumptionPointLabel, + formatConsumptionRange, + normalizeConsumptionTrendItems, + environment +}) { + const { document, window } = environment; function renderConsumptionTrendChart(section, chart, items, period, subPlanNum) { chart.textContent = ""; chart.setAttribute("aria-busy", "false"); @@ -2598,11 +4196,11 @@ const windowSize = option ? option.windowSize : points.length; const offsetKey = createConsumptionTrendKey(subPlanNum, period); const maxOffset = Math.max(0, Math.ceil(points.length / windowSize) - 1); - const offset = Math.min(consumptionTrendWindowOffsets.get(offsetKey) || 0, maxOffset); + const offset = Math.min(consumptionTrendState.consumptionTrendWindowOffsets.get(offsetKey) || 0, maxOffset); const end = Math.max(0, points.length - (offset * windowSize)); const start = Math.max(0, end - windowSize); const visiblePoints = points.slice(start, end); - consumptionTrendWindowOffsets.set(offsetKey, offset); + consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, offset); const navigation = document.createElement("div"); navigation.className = "opportunities-extension-consumption-navigation"; @@ -2611,11 +4209,11 @@ const range = document.createElement("strong"); range.textContent = formatConsumptionRange(visiblePoints, period); olderButton.addEventListener("click", () => { - consumptionTrendWindowOffsets.set(offsetKey, Math.min(maxOffset, offset + 1)); + consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.min(maxOffset, offset + 1)); renderConsumptionTrendChart(section, chart, items, period, subPlanNum); }); newerButton.addEventListener("click", () => { - consumptionTrendWindowOffsets.set(offsetKey, Math.max(0, offset - 1)); + consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.max(0, offset - 1)); renderConsumptionTrendChart(section, chart, items, period, subPlanNum); }); navigation.append(olderButton, range, newerButton); @@ -2650,14 +4248,14 @@ } function getConsumptionTrendSeriesFilter(subPlanNum) { - if (!consumptionTrendSeriesFilters.has(subPlanNum)) { - consumptionTrendSeriesFilters.set( + if (!consumptionTrendState.consumptionTrendSeriesFilters.has(subPlanNum)) { + consumptionTrendState.consumptionTrendSeriesFilters.set( subPlanNum, new Set(CONSUMPTION_TREND_SERIES.map((series) => series.value)) ); } - return consumptionTrendSeriesFilters.get(subPlanNum); + return consumptionTrendState.consumptionTrendSeriesFilters.get(subPlanNum); } function createConsumptionLegend(section, chart, items, period, subPlanNum, activeSeries) { @@ -2718,47 +4316,6 @@ return button; } - function normalizeConsumptionTrendItems(items) { - const points = new Map(); - - (Array.isArray(items) ? items : []).forEach((item) => { - const dateKey = String(item.fyDate || (Array.isArray(item.fiscalTime) ? item.fiscalTime.join("|") : "")); - - if (!dateKey) { - return; - } - - if (!points.has(dateKey)) { - points.set(dateKey, { - dateKey, - date: parseConsumptionDate(item.fyDate), - fiscalTime: Array.isArray(item.fiscalTime) ? item.fiscalTime.slice() : [], - fiscalQtr: item.fiscalQtr || "", - funded: 0, - overage: 0, - arr: 0, - currencyCode: item.currencyCode || "USD" - }); - } - - const point = points.get(dateKey); - const amount = toFiniteNumber(item.usedAmount); - const category = String(item.usageCategory || "Funded Allocation").toUpperCase(); - - if (category === "OVERAGE") { - point.overage += amount; - } else { - point.funded += amount; - } - - point.arr = Math.max(point.arr, toFiniteNumber(item.arrCd)); - }); - - return Array.from(points.values()) - .map((point) => ({ ...point, total: point.funded + point.overage })) - .sort((left, right) => left.dateKey.localeCompare(right.dateKey)); - } - function createConsumptionSvg(points, period, hasPartialLatestPoint, activeSeries, tooltip) { const width = 1000; const height = 320; @@ -2893,22 +4450,6 @@ return svg; } - function getConsumptionPointTotal(point, activeSeries) { - const selectedSeries = activeSeries && typeof activeSeries.has === "function" - ? activeSeries - : new Set(["funded", "overage"]); - return (selectedSeries.has("funded") ? point.funded : 0) - + (selectedSeries.has("overage") ? point.overage : 0); - } - - function calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries) { - const completePoints = hasPartialLatestPoint && points.length > 1 ? points.slice(0, -1) : points; - return completePoints.reduce( - (sum, point) => sum + getConsumptionPointTotal(point, activeSeries), - 0 - ) / Math.max(completePoints.length, 1); - } - function appendConsumptionBarAnimation(element, fromY, toY, height, delayMs) { if (prefersReducedMotion()) { return; @@ -3003,6 +4544,68 @@ return text; } + return { renderConsumptionTrendChart, getConsumptionTrendSeriesFilter, createConsumptionLegend, createConsumptionNavigationButton, createConsumptionSvg, appendConsumptionBarAnimation, prefersReducedMotion, createConsumptionPointAriaLabel, showConsumptionTooltip, createSvgElement, createSvgText }; +} + +// Source: src/features/consumption/model.js +function createConsumptionModel() { + function normalizeConsumptionTrendItems(items) { + const points = new Map(); + + (Array.isArray(items) ? items : []).forEach((item) => { + const dateKey = String(item.fyDate || (Array.isArray(item.fiscalTime) ? item.fiscalTime.join("|") : "")); + + if (!dateKey) { + return; + } + + if (!points.has(dateKey)) { + points.set(dateKey, { + dateKey, + date: parseConsumptionDate(item.fyDate), + fiscalTime: Array.isArray(item.fiscalTime) ? item.fiscalTime.slice() : [], + fiscalQtr: item.fiscalQtr || "", + funded: 0, + overage: 0, + arr: 0, + currencyCode: item.currencyCode || "USD" + }); + } + + const point = points.get(dateKey); + const amount = toFiniteNumber(item.usedAmount); + const category = String(item.usageCategory || "Funded Allocation").toUpperCase(); + + if (category === "OVERAGE") { + point.overage += amount; + } else { + point.funded += amount; + } + + point.arr = Math.max(point.arr, toFiniteNumber(item.arrCd)); + }); + + return Array.from(points.values()) + .map((point) => ({ ...point, total: point.funded + point.overage })) + .sort((left, right) => left.dateKey.localeCompare(right.dateKey)); + } + + function getConsumptionPointTotal(point, activeSeries) { + const selectedSeries = activeSeries && typeof activeSeries.has === "function" + ? activeSeries + : new Set(["funded", "overage"]); + return (selectedSeries.has("funded") ? point.funded : 0) + + (selectedSeries.has("overage") ? point.overage : 0); + } + + function calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries) { + const completePoints = hasPartialLatestPoint && points.length > 1 ? points.slice(0, -1) : points; + return completePoints.reduce( + (sum, point) => sum + getConsumptionPointTotal(point, activeSeries), + 0 + ) / Math.max(completePoints.length, 1); + } + function createNiceConsumptionMaximum(value) { if (!Number.isFinite(value) || value <= 0) { return 1; @@ -3098,490 +4701,18 @@ return Number.isFinite(number) ? number : 0; } - function closeAccountPlansModal() { - const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); - - if (modal) { - modal.hidden = true; - } - } - - function createProductsCountButton(optyNumber, productsResult) { - const normalizedOptyNumber = String(optyNumber); - const button = document.createElement("button"); - const count = Array.isArray(productsResult.items) ? productsResult.items.length : 0; - const isExpanded = expandedProductOptyNumbers.has(normalizedOptyNumber); - - button.type = "button"; - button.className = "opportunities-extension-products-count"; - button.textContent = String(count); - button.setAttribute("aria-expanded", String(isExpanded)); - button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`); - button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`; - button.classList.toggle("opportunities-extension-products-count-error", Boolean(productsResult.error)); - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - - if (isExpanded) { - expandedProductOptyNumbers.delete(normalizedOptyNumber); - } else { - expandedProductOptyNumbers.add(normalizedOptyNumber); - requestGenerateAiSummary(normalizedOptyNumber); - } - - renderOpportunitiesTable(); - }); - return button; - } - - function requestGenerateAiSummary(optyNumber) { - const key = String(optyNumber); - const existing = aiSummariesByOptyNumber.get(key); - if (existing && existing.status === "loading") { - return existing.promise; - } - - const state = { status: "loading", summaryText: "", error: "", promise: null }; - 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. - if (salesCommonToken && salesCommonToken.access_token === token) { - salesCommonToken = null; - } - 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 = 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; - } - - function createProductsDetailRow(opportunity) { - const row = document.createElement("tr"); - const cell = document.createElement("td"); - const optyNumber = String(opportunity.OptyNumber); - const productsResult = opptyProductsByNumber.get(optyNumber); - const products = productsResult && Array.isArray(productsResult.items) ? productsResult.items : []; - - row.className = "opportunities-extension-products-detail-row"; - cell.colSpan = OPPORTUNITIES_COLUMNS.length; - cell.className = "opportunities-extension-products-detail-cell"; - - const panel = document.createElement("section"); - panel.className = "opportunities-extension-products-detail"; - - const heading = document.createElement("div"); - heading.className = "opportunities-extension-products-detail-heading"; - - const title = document.createElement("strong"); - title.textContent = `Products for ${optyNumber}`; - - const summary = document.createElement("span"); - summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated"; - heading.append(title, summary); - panel.append(heading); - panel.append(createAiSummaryAlert(optyNumber)); - - if (productsResult && productsResult.error) { - const error = document.createElement("p"); - error.className = "opportunities-extension-products-empty"; - error.textContent = productsResult.error; - panel.append(error); - } else if (products.length === 0) { - const empty = document.createElement("p"); - empty.className = "opportunities-extension-products-empty"; - empty.textContent = "No products found for this opportunity."; - panel.append(empty); - } else { - const table = document.createElement("table"); - table.className = "opportunities-extension-products-table"; - table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`); - - const columns = [ - { label: "Product Group", value: (item) => item.ProdGroupName || "-" }, - { label: "Workload", value: (item) => item.WorkloadName_c || "-" }, - { label: "Currency", value: (item) => item.RevnAmountCurcyCode || "-" }, - { label: "Amount", value: (item) => formatProductAmount(item.RevnAmount, item.RevnAmountCurcyCode) }, - { label: "Type", value: (item) => item.TypeCode || "-" }, - { label: "Status", value: (item) => item.StatusCode || "-" }, - { label: "Win Probability", value: (item) => formatProductWinProbability(item.WinProb) }, - { label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" }, - { label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" }, - { label: "Ramp Months", value: (item) => formatProductNumber(item.RampMonths_c) } - ]; - - const head = document.createElement("thead"); - const headerRow = document.createElement("tr"); - columns.forEach((column) => { - const header = document.createElement("th"); - header.scope = "col"; - header.textContent = column.label; - headerRow.append(header); - }); - head.append(headerRow); - - const body = document.createElement("tbody"); - products.forEach((product) => { - const productRow = document.createElement("tr"); - columns.forEach((column) => { - const productCell = document.createElement("td"); - productCell.textContent = String(column.value(product)); - productRow.append(productCell); - }); - body.append(productRow); - }); - - table.append(head, body); - panel.append(table); - } - - cell.append(panel); - row.append(cell); - return row; - } - - function formatProductNumber(value) { - const number = Number(value); - return Number.isFinite(number) ? new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(number) : "-"; - } - - function formatProductAmount(value, currency) { - const amount = Number(value); - - if (!Number.isFinite(amount)) { - return "-"; - } - - try { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: currency || "USD" - }).format(amount); - } catch (error) { - return `${currency || ""} ${formatProductNumber(amount)}`.trim(); - } - } - - function formatProductWinProbability(value) { - const number = Number(value); - return Number.isFinite(number) ? `${number}%` : "-"; - } - - async function copyOpportunityNumber(optyNumber) { - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(optyNumber); - } else { - const input = document.createElement("textarea"); - input.value = optyNumber; - input.setAttribute("readonly", "true"); - input.style.position = "fixed"; - input.style.opacity = "0"; - document.body.append(input); - input.select(); - document.execCommand("copy"); - input.remove(); - } - - showCopyConfirmation(`Opty Number ${optyNumber} copied.`); - } catch (error) { - showCopyConfirmation("Unable to copy opportunity number.", true); - } - } - - function showCopyConfirmation(message, isError = false) { - const modal = document.getElementById(MODAL_ID); - - if (!modal) { - return; - } - - let toast = modal.querySelector(".opportunities-extension-copy-toast"); - - if (!toast) { - toast = document.createElement("div"); - toast.className = "opportunities-extension-copy-toast"; - toast.setAttribute("role", "status"); - modal.append(toast); - } - - toast.classList.toggle("opportunities-extension-copy-toast-error", isError); - toast.textContent = message; - toast.hidden = false; - window.clearTimeout(toast.copyTimeout); - toast.copyTimeout = window.setTimeout(() => { - toast.hidden = true; - }, 2400); - } - - function createStageBadge(stage) { - const badge = document.createElement("span"); - const normalizedStage = typeof stage === "string" ? stage.toUpperCase() : ""; - - badge.className = "opportunities-extension-stage-badge"; - badge.setAttribute("data-stage", normalizedStage); - badge.textContent = normalizedStage || "-"; - return badge; - } - - function createStatusBadge(status) { - const badge = document.createElement("span"); - const normalizedStatus = typeof status === "string" ? status.toUpperCase() : ""; - - badge.className = "opportunities-extension-status-badge"; - badge.setAttribute("data-status", normalizedStatus); - badge.textContent = normalizedStatus || "-"; - return badge; - } - - function getNestedValue(value, path) { - return path.reduce((result, key) => result && result[key], value); - } - - function formatWinProbability(item) { - const value = getNestedValue(item, ["PrimaryRevenue", "WinProb"]); - const number = Number(value); - return Number.isFinite(number) ? `${number}%` : value; - } - - function formatRevenue(item) { - const amount = getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]); - const currency = getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]); - const number = Number(amount); - - if (!Number.isFinite(number)) { - return amount; - } - - if (!currency) { - return number.toLocaleString(); - } - - try { - return new Intl.NumberFormat(undefined, { - style: "currency", - currency - }).format(number); - } catch (error) { - return `${currency} ${number.toLocaleString()}`; - } - } - - function formatOracleResponseDate(value, includeTime) { - if (typeof value !== "string") { - return value; - } - - const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/); - - if (!match) { - return value; - } - - const date = `${match[3]}/${match[2]}/${match[1]}`; - return includeTime && match[4] ? `${date} ${match[4]}:${match[5]}` : date; - } - - function isCurrentPeriodRequest(requestVersion) { - return requestVersion === periodRequestVersion; - } - - function createOpportunitiesQueryPayload(dateRange, offset) { - return { - aggregationResults: false, - applications: ["ORACLE-ISS-APP"], - onlyData: true, - entity: "Opportunity", - q: { - op: "$and", - criteria: [ - { - op: "$or", - criteria: Array.from(selectedOpportunityTypeValues, (value) => ({ - op: "$eq", - attribute: "RecordSet", - value - })) - }, - { - op: "$wi", - attribute: "EffectiveDate", - value1: dateRange.startCloseDate, - value2: dateRange.endCloseDate, - dynamicDate: false - } - ] - }, - keywords: null, - keywordsFields: [ - "Name", - "OptyNumber", - "PrimaryRevenue.WinProb", - "CustomerAccount", - "PrimaryRevenue.RevnAmount", - "EffectiveDate", - "StatusCode", - "DealRisk_c", - "Owner", - "ForecastGroup_c", - "LastUpdateDate", - "PrimaryRevenue.RevnAmountCurcyCode", - "OptyId" - ], - fields: [ - "Name", - "OptyNumber", - "PrimaryRevenue.WinProb", - "CustomerAccount", - "PrimaryRevenue.RevnAmount", - "EffectiveDate", - "StatusCode", - "DealRisk_c", - "Owner", - "ForecastGroup_c", - "LastUpdateDate", - "PrimaryRevenue.RevnAmountCurcyCode", - "OptyId", - "CustomerAccount.PartyUniqueName", - "CustomerAccount.PartyId", - "CustomerAccount.PartyNumber" - ], - sort: [], - language: "en", - skipInValidFields: true, - skipHiddenFromUIFields: true, - copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162", - limit: OPPORTUNITIES_PAGE_LIMIT, - offset - }; - } - - function getFiscalPeriodRange(period, currentDate) { - const currentYear = currentDate.getFullYear(); - const currentMonth = currentDate.getMonth(); - const fiscalStartYear = currentMonth >= 5 ? currentYear : currentYear - 1; - const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3); - - if (period === "Current Fiscal Year") { - const isBeforeFiscalYearStart = currentMonth < 5; - const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear; - const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1; - return createDateRange(startYear, 5, endYear, 4); - } - - let quarterOffset; - let numberOfQuarters = 1; - - if (period === "Current Quarter") { - quarterOffset = currentQuarterIndex; - } else if (period === "Next Quarter") { - quarterOffset = currentQuarterIndex + 1; - } else if (period === "Previous Quarter") { - quarterOffset = currentQuarterIndex - 1; - } else if (period === "4 Rolling Quarters (CQ + 3)") { - quarterOffset = currentQuarterIndex; - numberOfQuarters = 4; - } else { - return null; - } - - const start = new Date(fiscalStartYear, 5 + (quarterOffset * 3), 1); - const end = new Date(start.getFullYear(), start.getMonth() + (numberOfQuarters * 3), 0); - - return { - startCloseDate: formatOracleDate(start), - endCloseDate: formatOracleDate(end) - }; - } - - function createDateRange(startYear, startMonth, endYear, endMonth) { - return { - startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)), - endCloseDate: formatOracleDate(new Date(endYear, endMonth + 1, 0)) - }; - } - - function formatOracleDate(date) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}T00:00:00.000Z`; - } + return { normalizeConsumptionTrendItems, getConsumptionPointTotal, calculateAverageConsumption, createNiceConsumptionMaximum, formatConsumptionPointLabel, formatConsumptionRange, parseConsumptionDate, formatConsumptionDate, formatCompactConsumptionNumber, formatConsumptionCurrency, toFiniteNumber }; +} +// Source: src/features/debug/content-log.js +function createDebugContentLog({ + DEBUG_BODY_LIMIT, + debugState, + renderDebugPanel +}) { function logRequestStart(details) { const entry = { - id: requestLog.length + 1, + id: debugState.requestLog.length + 1, name: details.name, method: details.method, url: details.url, @@ -3601,7 +4732,7 @@ error: "" }; - requestLog.push(entry); + debugState.requestLog.push(entry); renderDebugPanel(); return entry; } @@ -3632,8 +4763,8 @@ } entries.forEach((entry) => { - requestLog.push({ - id: requestLog.length + 1, + debugState.requestLog.push({ + id: debugState.requestLog.length + 1, name: entry.name || "backgroundRequest", method: entry.method || "GET", url: entry.url || "", @@ -3700,7 +4831,7 @@ } return entries - .map(([key, value]) => `${key}: ${debugSecretsVisible || !isSensitiveDebugKey(key) + .map(([key, value]) => `${key}: ${debugState.debugSecretsVisible || !isSensitiveDebugKey(key) ? value : formatHiddenDebugValue(value)}`) .join("\n"); @@ -3709,7 +4840,7 @@ function formatDebugBody(body) { const text = body || "(none)"; - if (debugSecretsVisible) { + if (debugState.debugSecretsVisible) { return text; } @@ -3770,6 +4901,20 @@ return `${token.slice(0, 4)}...${token.slice(-4)}`; } + return { logRequestStart, logRequestSuccess, logRequestFailure, appendBackgroundRequestLog, readResponseBodyForDebug, createDebugBody, headersToObject, formatHeaderBlock, formatDebugBody, maskSensitiveDebugValue, isSensitiveDebugKey, formatHiddenDebugValue, formatMetadataBlock, maskToken }; +} + +// Source: src/features/debug/content-view.js +function createDebugContentView({ + DEBUG_PANEL_ID, + MODAL_ID, + debugState, + formatDebugBody, + formatHeaderBlock, + formatMetadataBlock, + environment +}) { + const { document } = environment; function toggleDebugPanel() { const modal = document.getElementById(MODAL_ID); @@ -3804,18 +4949,18 @@ title.textContent = "Debug Requests"; const summary = document.createElement("p"); - summary.textContent = `${requestLog.length} request${requestLog.length === 1 ? "" : "s"} since modal opened.`; + summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`; heading.append(title, summary); const visibilityButton = document.createElement("button"); visibilityButton.type = "button"; visibilityButton.className = "opportunities-extension-icon-button opportunities-extension-debug-visibility-button"; - visibilityButton.setAttribute("aria-pressed", String(debugSecretsVisible)); - visibilityButton.setAttribute("aria-label", debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"); - visibilityButton.title = debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"; - visibilityButton.append(createDebugVisibilityIcon(debugSecretsVisible)); + visibilityButton.setAttribute("aria-pressed", String(debugState.debugSecretsVisible)); + visibilityButton.setAttribute("aria-label", debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"); + visibilityButton.title = debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"; + visibilityButton.append(createDebugVisibilityIcon(debugState.debugSecretsVisible)); visibilityButton.addEventListener("click", () => { - debugSecretsVisible = !debugSecretsVisible; + debugState.debugSecretsVisible = !debugState.debugSecretsVisible; renderDebugPanel(); }); panelHeader.append(heading, visibilityButton); @@ -3823,14 +4968,14 @@ const list = document.createElement("div"); list.className = "opportunities-extension-debug-list"; - if (requestLog.length === 0) { + if (debugState.requestLog.length === 0) { const empty = document.createElement("div"); empty.className = "opportunities-extension-debug-empty"; empty.textContent = "No requests recorded yet."; list.append(empty); } - requestLog.forEach((entry) => { + debugState.requestLog.forEach((entry) => { const item = document.createElement("details"); item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`; @@ -3932,3705 +5077,455 @@ }); } - async function getXsrfToken() { - const extensionCookie = await getXsrfTokenFromExtensionCookies(); - - if (extensionCookie.token) { - return extensionCookie; - } - - const xsrfCookie = document.cookie - .split(";") - .map((cookie) => cookie.trim()) - .find((cookie) => cookie.startsWith("XSRF-TOKEN-")); - - if (!xsrfCookie) { - return { - token: "", - cookieName: "", - source: extensionCookie.error ? "document.cookie fallback after cookies API error" : "document.cookie fallback", - matchedCookieNames: extensionCookie.matchedCookieNames || [], - lookupDetails: extensionCookie.lookupDetails || [], - error: extensionCookie.error || "" - }; - } - - const cookieName = xsrfCookie.slice(0, xsrfCookie.indexOf("=")); - const tokenValue = xsrfCookie.slice(xsrfCookie.indexOf("=") + 1); - return { - token: decodeURIComponent(tokenValue), - cookieName, - source: "document.cookie", - matchedCookieNames: extensionCookie.matchedCookieNames || [], - lookupDetails: extensionCookie.lookupDetails || [], - error: "" - }; - } - - function getXsrfTokenFromExtensionCookies() { - const runtimeApi = typeof browser !== "undefined" ? browser : chrome; - - if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { - return Promise.resolve({ - token: "", - cookieName: "", - source: "unavailable cookies API", - error: "Runtime messaging API unavailable." - }); - } - - return new Promise((resolve) => { - runtimeApi.runtime.sendMessage({ - type: "opportunitiesExtension.getXsrfToken" - }, (response) => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - resolve({ - token: "", - cookieName: "", - source: "cookies API", - matchedCookieNames: [], - lookupDetails: [], - error: lastError.message - }); - return; - } - - resolve({ - token: response && response.ok ? response.token : "", - cookieName: response && response.ok ? response.cookieName : "", - source: "cookies API", - matchedCookieNames: response && response.matchedCookieNames ? response.matchedCookieNames : [], - lookupDetails: response && response.lookupDetails ? response.lookupDetails : [], - error: response && response.error ? response.error : "" - }); - }); - }); - } - - function refreshXsrfCookie(reason) { - const runtimeApi = typeof browser !== "undefined" ? browser : chrome; - - if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { - return Promise.resolve({ - ok: false, - refreshed: false, - cookieName: "", - durationMs: 0, - reason, - error: "Runtime messaging API unavailable." - }); - } - - return new Promise((resolve) => { - runtimeApi.runtime.sendMessage({ - type: "opportunitiesExtension.refreshXsrfCookie" - }, (response) => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - resolve({ - ok: false, - refreshed: false, - cookieName: "", - durationMs: 0, - reason, - error: lastError.message - }); - return; - } - - resolve({ - ...(response || { - ok: false, - refreshed: false, - cookieName: "", - durationMs: 0, - error: "Empty refresh response." - }), - reason - }); - }); - }); - } - - function updateAuthBadge(status) { - const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`); - - if (!badge) { - return; - } - - const badgeStatus = status === AUTH_STATUS.authenticated || status === AUTH_STATUS.unauthenticated - ? status - : AUTH_STATUS.pending; - - badge.setAttribute("data-auth-status", badgeStatus); - badge.className = `opportunities-extension-auth-badge opportunities-extension-auth-badge-${badgeStatus}`; - badge.textContent = badgeStatus === AUTH_STATUS.authenticated - ? "Authenticated" - : badgeStatus === AUTH_STATUS.unauthenticated - ? "Unauthenticated" - : "Authenticating"; - } - - function closeOpportunitiesModal() { - const modal = document.getElementById(MODAL_ID); - - if (modal) { - closePreferencesModal(); - closeAccountPlansModal(); - modal.hidden = true; - } - - debugSecretsVisible = false; - document.documentElement.classList.remove("opportunities-extension-scroll-lock"); - } - - function getSavedTheme() { - try { - return localStorage.getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light"; - } catch (error) { - return "light"; - } - } - - function applyTheme(modal, button, theme) { - const isDarkTheme = theme === "dark"; - - modal.setAttribute("data-theme", isDarkTheme ? "dark" : "light"); - button.textContent = ""; - button.setAttribute("aria-label", isDarkTheme ? "Ativar modo claro" : "Ativar modo escuro"); - button.title = isDarkTheme ? "Light mode" : "Dark mode"; - button.append(createThemeIcon(isDarkTheme ? "sun" : "moon")); - - try { - localStorage.setItem(THEME_STORAGE_KEY, isDarkTheme ? "dark" : "light"); - } catch (error) { - // Continue with the selected theme when browser storage is unavailable. - } - } - - function createThemeIcon(iconName) { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - - svg.setAttribute("viewBox", "0 0 24 24"); - svg.setAttribute("aria-hidden", "true"); - svg.setAttribute("focusable", "false"); - - if (iconName === "sun") { - path.setAttribute("d", "M12 3V1m0 22v-2m9-9h2M1 12h2m15.36 6.36 1.42 1.42M4.22 4.22l1.42 1.42m12.72 0 1.42-1.42M4.22 19.78l1.42-1.42M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z"); - } else { - path.setAttribute("d", "M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5a8.5 8.5 0 1 0 10.7 10.7Z"); - } - - path.setAttribute("fill", "none"); - path.setAttribute("stroke", "currentColor"); - path.setAttribute("stroke-linecap", "round"); - path.setAttribute("stroke-linejoin", "round"); - path.setAttribute("stroke-width", "1.8"); - svg.append(path); - return svg; - } - - function createSettingsIcon() { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - const upperControl = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - const lowerControl = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - - svg.setAttribute("viewBox", "0 0 24 24"); - svg.setAttribute("class", "opportunities-extension-settings-icon"); - svg.setAttribute("aria-hidden", "true"); - svg.setAttribute("focusable", "false"); - appendPath(svg, "", "M4 7h16M4 17h16"); - - [upperControl, lowerControl].forEach((control, index) => { - control.setAttribute("cx", index === 0 ? "9" : "15"); - control.setAttribute("cy", index === 0 ? "7" : "17"); - control.setAttribute("r", "2.5"); - control.setAttribute("fill", "currentColor"); - control.setAttribute("stroke", "currentColor"); - control.setAttribute("stroke-width", "1.8"); - }); - - svg.querySelector("path").setAttribute("fill", "none"); - svg.querySelector("path").setAttribute("stroke", "currentColor"); - svg.querySelector("path").setAttribute("stroke-linecap", "round"); - svg.querySelector("path").setAttribute("stroke-width", "1.8"); - svg.append(upperControl, lowerControl); - return svg; - } - - function createCloseIcon() { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - - svg.setAttribute("viewBox", "0 0 24 24"); - svg.setAttribute("aria-hidden", "true"); - svg.setAttribute("focusable", "false"); - path.setAttribute("d", "m6 6 12 12M18 6 6 18"); - path.setAttribute("fill", "none"); - path.setAttribute("stroke", "currentColor"); - path.setAttribute("stroke-linecap", "round"); - path.setAttribute("stroke-width", "1.8"); - svg.append(path); - return svg; - } - - function getSavedOpportunityTypeValues() { - try { - const rawPreferences = localStorage.getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY); - - if (!rawPreferences) { - return DEFAULT_OPPORTUNITY_TYPE_VALUES; - } - - const preferences = JSON.parse(rawPreferences); - const values = Array.isArray(preferences.opportunityTypeValues) ? preferences.opportunityTypeValues : null; - - if (!values) { - return DEFAULT_OPPORTUNITY_TYPE_VALUES; - } - - const allowedValues = new Set(OPPORTUNITY_TYPE_OPTIONS.map((option) => option.value)); - return values.filter((value) => allowedValues.has(value)); - } catch (error) { - return DEFAULT_OPPORTUNITY_TYPE_VALUES; - } - } - - function getSavedOpptyProductsCacheHours() { - try { - const rawPreferences = localStorage.getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY); - - if (!rawPreferences) { - return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; - } - - const preferences = JSON.parse(rawPreferences); - const cacheHours = Number(preferences.opptyProductsCacheHours); - return OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === cacheHours) - ? cacheHours - : DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; - } catch (error) { - return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; - } - } - - function openPreferencesModal() { - const overlay = document.getElementById(MODAL_ID); - - if (!overlay) { - return; - } - - const existingModal = document.getElementById(PREFERENCES_MODAL_ID); - - if (existingModal) { - const savedValues = new Set(getSavedOpportunityTypeValues()); - existingModal.querySelectorAll("input[name='opportunityTypeView']").forEach((checkbox) => { - checkbox.checked = savedValues.has(checkbox.value); - }); - const cacheSelect = existingModal.querySelector("select[name='opptyProductsCacheHours']"); - - if (cacheSelect) { - cacheSelect.value = String(getSavedOpptyProductsCacheHours()); - } - - existingModal.hidden = false; - existingModal.querySelector("input")?.focus(); - return; - } - - const preferencesModal = document.createElement("section"); - preferencesModal.id = PREFERENCES_MODAL_ID; - preferencesModal.className = "opportunities-extension-preferences-modal"; - preferencesModal.setAttribute("role", "dialog"); - preferencesModal.setAttribute("aria-modal", "true"); - preferencesModal.setAttribute("aria-labelledby", "opportunities-extension-preferences-title"); - - const dialog = document.createElement("div"); - dialog.className = "opportunities-extension-preferences-dialog"; - - const header = document.createElement("header"); - header.className = "opportunities-extension-preferences-header"; - - const title = document.createElement("h2"); - title.id = "opportunities-extension-preferences-title"; - title.textContent = "Preferences"; - - const closeButton = document.createElement("button"); - closeButton.type = "button"; - closeButton.className = "opportunities-extension-icon-button"; - closeButton.setAttribute("aria-label", "Fechar preferências"); - closeButton.title = "Close"; - closeButton.append(createCloseIcon()); - closeButton.addEventListener("click", closePreferencesModal); - header.append(title, closeButton); - - const content = document.createElement("div"); - content.className = "opportunities-extension-preferences-content"; - - const fieldset = document.createElement("fieldset"); - fieldset.className = "opportunities-extension-preferences-fieldset"; - - const legend = document.createElement("legend"); - legend.textContent = "Opportunity Type View"; - fieldset.append(legend); - - const selectedValues = new Set(getSavedOpportunityTypeValues()); - const options = document.createElement("div"); - options.className = "opportunities-extension-preferences-options"; - - OPPORTUNITY_TYPE_OPTIONS.forEach((option) => { - const label = document.createElement("label"); - label.className = "opportunities-extension-preferences-option"; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.name = "opportunityTypeView"; - checkbox.value = option.value; - checkbox.checked = selectedValues.has(option.value); - - const text = document.createElement("span"); - text.textContent = option.label; - label.append(checkbox, text); - options.append(label); - }); - - fieldset.append(options); - - const cacheField = document.createElement("label"); - cacheField.className = "opportunities-extension-preferences-cache-field"; - - const cacheLabel = document.createElement("span"); - cacheLabel.textContent = "Product cache duration"; - - const cacheSelect = document.createElement("select"); - cacheSelect.name = "opptyProductsCacheHours"; - cacheSelect.setAttribute("aria-label", "Product cache duration"); - OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => { - const optionElement = document.createElement("option"); - optionElement.value = String(option.value); - optionElement.textContent = option.label; - optionElement.selected = option.value === getSavedOpptyProductsCacheHours(); - cacheSelect.append(optionElement); - }); - cacheField.append(cacheLabel, cacheSelect); - content.append(fieldset, cacheField); - - const footer = document.createElement("footer"); - footer.className = "opportunities-extension-preferences-footer"; - - const saveButton = document.createElement("button"); - saveButton.type = "button"; - saveButton.className = "opportunities-extension-button opportunities-extension-button-primary"; - saveButton.textContent = "Save"; - saveButton.addEventListener("click", () => { - const selectedValues = Array.from(preferencesModal.querySelectorAll("input[name='opportunityTypeView']:checked"), (checkbox) => checkbox.value); - const selectedCacheHours = Number(preferencesModal.querySelector("select[name='opptyProductsCacheHours']")?.value); - selectedOpportunityTypeValues = new Set(selectedValues); - opptyProductsCacheHours = OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === selectedCacheHours) - ? selectedCacheHours - : DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; - - try { - localStorage.setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({ - opportunityTypeValues: selectedValues, - opptyProductsCacheHours - })); - } catch (error) { - // Continue with the saved selection for the active modal when storage is unavailable. - } - - closePreferencesModal(); - const periodSelect = overlay.querySelector("select[name='opportunityPeriod']"); - requestOpportunitiesForPeriod(periodSelect?.value || PERIOD_OPTIONS[0]); - }); - - footer.append(saveButton); - dialog.append(header, content, footer); - preferencesModal.append(dialog); - overlay.append(preferencesModal); - preferencesModal.querySelector("input")?.focus(); - } - - function closePreferencesModal() { - const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID); - - if (preferencesModal) { - preferencesModal.hidden = true; - } - } - - function ensureEscapeKeyHandler() { - if (window[ESCAPE_LISTENER_KEY]) { - return; - } - - window[ESCAPE_LISTENER_KEY] = true; - document.addEventListener("keydown", (event) => { - const modal = document.getElementById(MODAL_ID); - - if (!modal || modal.hidden) { - return; - } - - if (event.ctrlKey && event.key.toLowerCase() === "d") { - event.preventDefault(); - event.stopPropagation(); - toggleDebugPanel(); - return; - } - - if (event.key === "Escape") { - const accountPlansModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); - - if (accountPlansModal && !accountPlansModal.hidden) { - closeAccountPlansModal(); - event.preventDefault(); - return; - } - - const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID); - - if (preferencesModal && !preferencesModal.hidden) { - closePreferencesModal(); - event.preventDefault(); - return; - } - - if (closeCustomerFilterPanel()) { - event.preventDefault(); - return; - } - - if (closeOwnerFilterPanel()) { - event.preventDefault(); - return; - } - - closeOpportunitiesModal(); - } - }); - - document.addEventListener("pointerdown", (event) => { - const modal = document.getElementById(MODAL_ID); - const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); - const ownerPanel = document.getElementById(OWNER_FILTER_PANEL_ID); - - if (!modal || modal.hidden) { - return; - } - - if (panel && !panel.hidden) { - const customerFilter = panel.closest(".opportunities-extension-customer-filter"); - - if (customerFilter && !customerFilter.contains(event.target)) { - closeCustomerFilterPanel(false); - } - } - - if (ownerPanel && !ownerPanel.hidden) { - const ownerFilter = ownerPanel.closest(".opportunities-extension-customer-filter"); - - if (ownerFilter && !ownerFilter.contains(event.target)) { - closeOwnerFilterPanel(false); - } - } - }); - } - - function ensureExtensionStyles() { - if (document.getElementById(STYLE_ID)) { - return; - } - - const style = document.createElement("style"); - style.id = STYLE_ID; - style.textContent = ` - .opportunities-extension-scroll-lock { - overflow: hidden !important; - } - - .opportunities-extension-modal, - .opportunities-extension-modal * { - box-sizing: border-box; - font-family: "Oracle Sans", Arial, Helvetica, sans-serif; - } - - .opportunities-extension-modal { - position: fixed; - inset: 0; - z-index: 2147483647; - background: #f5f4f2; - color: #000000; - } - - .opportunities-extension-modal[hidden] { - display: none !important; - } - - .opportunities-extension-shell { - height: 100vh; - min-height: 0; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - background: #f5f4f2; - } - - .opportunities-extension-header { - position: relative; - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 24px; - padding: 28px 40px 22px; - border-bottom: 1px solid #dedbd7; - background: - linear-gradient(90deg, #8f624a 0 11%, #c74634 11% 23%, #6f5a7f 23% 36%, #00758f 36% 51%, #d4b06a 51% 63%, transparent 63% 100%) top left / 100% 6px no-repeat, - #ffffff; - } - - .opportunities-extension-header::before { - position: absolute; - left: 40px; - bottom: -1px; - width: 64px; - height: 3px; - background: #00758f; - content: ""; - } - - .opportunities-extension-header h1 { - margin: 0; - color: #000000; - font-size: 24px; - font-weight: 700; - line-height: 1.2; - letter-spacing: 0; - } - - .opportunities-extension-header p { - margin: 0; - color: #5f5a55; - font-size: 14px; - line-height: 1.4; - } - - .opportunities-extension-subtitle-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - margin-top: 6px; - } - - .opportunities-extension-auth-badge { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 2px 8px; - border-radius: 999px; - color: #ffffff; - font-size: 12px; - font-weight: 700; - line-height: 1.2; - } - - .opportunities-extension-auth-badge-authenticated { - background: #3f6f17; - } - - .opportunities-extension-auth-badge-unauthenticated { - background: #c5331f; - } - - .opportunities-extension-auth-badge-pending { - background: #6f5a7f; - } - - .opportunities-extension-products-progress { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 2px 8px; - border-radius: 999px; - background: #e8f4f6; - color: #006d7a; - font-size: 12px; - font-weight: 700; - line-height: 1.2; - } - - .opportunities-extension-products-progress[hidden] { - display: none !important; - } - - .opportunities-extension-products-cache-timestamp { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 2px 8px; - border-radius: 999px; - background: #f0eeeb; - color: #5f5a55; - font-size: 11px; - font-weight: 600; - line-height: 1.2; - } - - .opportunities-extension-products-cache-timestamp[hidden] { - display: none !important; - } - - .opportunities-extension-products-progress[data-state="running"]::before { - width: 12px; - height: 12px; - margin-right: 6px; - border: 2px solid currentColor; - border-right-color: transparent; - border-radius: 50%; - content: ""; - animation: opportunities-extension-spin .8s linear infinite; - } - - .opportunities-extension-products-progress[data-state="complete"] { - background: #e2f2e5; - color: #356d19; - } - - .opportunities-extension-products-progress[data-state="complete-with-errors"], - .opportunities-extension-products-progress[data-state="error"] { - background: #f9e3e1; - color: #a52b1c; - } - - @keyframes opportunities-extension-spin { - to { transform: rotate(360deg); } - } - - .opportunities-extension-icon-button { - width: 36px; - height: 36px; - flex: 0 0 36px; - border: 1px solid transparent; - border-radius: 4px; - background: transparent; - color: #312d2a; - font-size: 18px; - font-weight: 600; - line-height: 1; - cursor: pointer; - } - - .opportunities-extension-header-actions { - display: inline-flex; - align-items: center; - gap: 6px; - } - - .opportunities-extension-icon-button svg { - width: 18px; - height: 18px; - vertical-align: middle; - } - - .opportunities-extension-icon-button .opportunities-extension-settings-icon { - width: 20px; - height: 20px; - } - - .opportunities-extension-icon-button:hover, - .opportunities-extension-icon-button:focus { - border-color: #b8b2ad; - background: #f5f4f2; - outline: none; - } - - .opportunities-extension-body { - display: grid; - min-height: 0; - grid-template-rows: auto auto minmax(0, 1fr); - gap: 16px; - padding: 22px 40px 36px; - background: #f5f4f2; - overflow: hidden; - } - - .opportunities-extension-form { - display: flex; - align-items: end; - flex-wrap: wrap; - gap: 24px; - min-height: 82px; - max-width: none; - margin: 0; - padding: 14px 16px; - border: 1px solid #dedbd7; - border-radius: 4px; - background: #ffffff; - } - - .opportunities-extension-field { - display: grid; - max-width: max-content; - min-width: 270px; - width: max-content; - gap: 6px; - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - } - - .opportunities-extension-stage-dashboard { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 12px; - } - - .opportunities-extension-stage-dashboard-card { - display: grid; - gap: 6px; - min-width: 0; - padding: 12px 16px; - border: 1px solid #dedbd7; - border-top: 3px solid #5f5a55; - border-radius: 4px; - background: #ffffff; - } - - .opportunities-extension-stage-dashboard-card span { - color: #5f5a55; - font-size: 11px; - font-weight: 700; - line-height: 1.2; - } - - .opportunities-extension-stage-dashboard-value { - overflow: hidden; - color: #312d2a; - font-size: 22px; - font-weight: 700; - line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; - } - - .opportunities-extension-stage-dashboard-card[data-stage="SQL"] { - border-top-color: #008aa6; - } - - .opportunities-extension-total-dashboard-card { - border-top-color: #312d2a; - } - - .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { - border-top-color: #6f6863; - } - - .opportunities-extension-stage-dashboard-card[data-stage="UPSIDE"] { - border-top-color: #c87b00; - } - - .opportunities-extension-stage-dashboard-card[data-stage="FORECAST"] { - border-top-color: #755b8b; - } - - .opportunities-extension-stage-dashboard-card[data-stage="WON"] { - border-top-color: #4b7f22; - } - - .opportunities-extension-stage-filter { - display: grid; - grid-template-rows: auto 44px; - gap: 6px; - min-width: 0; - } - - .opportunities-extension-stage-filter-label { - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - } - - .opportunities-extension-stage-filter-controls { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - } - - .opportunities-extension-stage-filter-button { - min-height: 36px; - padding: 6px 12px; - border: 1px solid currentColor; - border-radius: 18px; - background: #ffffff; - color: #5f5a55; - font-size: 12px; - font-weight: 700; - letter-spacing: 0; - line-height: 1.2; - cursor: pointer; - } - - .opportunities-extension-stage-filter-button[data-stage="SQL"] { - color: #006b84; - } - - .opportunities-extension-stage-filter-button[data-stage="PIPELINE"] { - color: #5f5a55; - } - - .opportunities-extension-stage-filter-button[data-stage="UPSIDE"] { - color: #945400; - } - - .opportunities-extension-stage-filter-button[data-stage="FORECAST"] { - color: #624e74; - } - - .opportunities-extension-stage-filter-button[data-stage="WON"] { - color: #3f6f17; - } - - .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="SQL"] { - border-color: #006b84; - background: #006b84; - color: #ffffff; - } - - .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="PIPELINE"] { - border-color: #5f5a55; - background: #5f5a55; - color: #ffffff; - } - - .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="UPSIDE"] { - border-color: #945400; - background: #945400; - color: #ffffff; - } - - .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="FORECAST"] { - border-color: #624e74; - background: #624e74; - color: #ffffff; - } - - .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="WON"] { - border-color: #3f6f17; - background: #3f6f17; - color: #ffffff; - } - - .opportunities-extension-stage-filter-button:focus-visible { - outline: 2px solid #00758f; - outline-offset: 2px; - } - - .opportunities-extension-status-filter { - display: grid; - grid-template-rows: auto 44px; - gap: 6px; - min-width: 0; - } - - .opportunities-extension-status-filter-label { - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - } - - .opportunities-extension-status-filter-controls { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - } - - .opportunities-extension-status-filter-button { - min-height: 36px; - padding: 6px 12px; - border: 1px solid currentColor; - border-radius: 18px; - background: #ffffff; - font-size: 12px; - font-weight: 700; - letter-spacing: 0; - line-height: 1.2; - cursor: pointer; - } - - .opportunities-extension-status-filter-button[data-status="OPEN"] { - color: #197a3d; - } - - .opportunities-extension-status-filter-button[data-status="CLOSED"] { - color: #4b5563; - } - - .opportunities-extension-status-filter-button[data-status="LOST"] { - color: #b13b34; - } - - .opportunities-extension-status-filter-button[data-status="WON"] { - color: #1f5f99; - } - - .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="OPEN"] { - border-color: #197a3d; - background: #197a3d; - color: #ffffff; - } - - .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="CLOSED"] { - border-color: #4b5563; - background: #4b5563; - color: #ffffff; - } - - .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="LOST"] { - border-color: #b13b34; - background: #b13b34; - color: #ffffff; - } - - .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="WON"] { - border-color: #1f5f99; - background: #1f5f99; - color: #ffffff; - } - - .opportunities-extension-status-filter-button:focus-visible { - outline: 2px solid #00758f; - outline-offset: 2px; - } - - .opportunities-extension-customer-filter { - position: relative; - display: grid; - grid-template-rows: auto 44px; - gap: 6px; - min-width: 220px; - } - - .opportunities-extension-customer-filter-label { - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - } - - .opportunities-extension-customer-filter-trigger { - position: relative; - min-width: 220px; - min-height: 44px; - padding: 10px 38px 10px 12px; - border: 1px solid #b8b2ad; - border-radius: 3px; - background: #ffffff; - color: #312d2a; - font-size: 14px; - line-height: 1.3; - text-align: left; - cursor: pointer; - } - - .opportunities-extension-customer-filter-trigger::after { - position: absolute; - top: 50%; - right: 16px; - width: 0; - height: 0; - border-top: 6px solid #312d2a; - border-right: 5px solid transparent; - border-left: 5px solid transparent; - content: ""; - pointer-events: none; - transform: translateY(-50%); - } - - .opportunities-extension-customer-filter-trigger:disabled { - cursor: not-allowed; - background: #f5f4f2; - color: #7d7772; - } - - .opportunities-extension-customer-filter-trigger:focus-visible { - border-color: #312d2a; - box-shadow: 0 0 0 1px #312d2a; - outline: none; - } - - .opportunities-extension-customer-filter-panel { - position: absolute; - top: calc(100% + 8px); - left: 0; - z-index: 4; - display: grid; - width: min(360px, calc(100vw - 80px)); - gap: 10px; - padding: 12px; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - box-shadow: 0 4px 12px rgba(0, 0, 0, .18); - } - - .opportunities-extension-customer-filter-panel[hidden] { - display: none !important; - } - - .opportunities-extension-customer-filter-search { - width: 100%; - min-height: 40px; - padding: 8px 10px; - border: 1px solid #b8b2ad; - border-radius: 3px; - background: #ffffff; - color: #312d2a; - font-size: 14px; - } - - .opportunities-extension-customer-filter-search:focus { - border-color: #312d2a; - box-shadow: 0 0 0 1px #312d2a; - outline: none; - } - - .opportunities-extension-customer-filter-select-all, - .opportunities-extension-customer-filter-option { - display: flex; - align-items: center; - gap: 8px; - color: #312d2a; - font-size: 13px; - line-height: 1.35; - } - - .opportunities-extension-customer-filter-select-all { - min-height: 32px; - padding-bottom: 8px; - border-bottom: 1px solid #dedbd7; - font-weight: 700; - } - - .opportunities-extension-customer-filter-panel input[type="checkbox"] { - width: 16px; - height: 16px; - flex: 0 0 16px; - accent-color: #00758f; - } - - .opportunities-extension-customer-filter-list { - display: block; - max-height: 250px; - overflow: auto; - } - - .opportunities-extension-customer-filter-option { - display: grid; - grid-template-columns: 16px minmax(0, 1fr); - align-items: start; - min-height: 0; - height: auto !important; - padding: 8px 4px; - cursor: pointer; - } - - .opportunities-extension-customer-filter-option input[type="checkbox"] { - margin-top: 1px; - } - - .opportunities-extension-customer-filter-option span { - display: block; - min-width: 0; - line-height: 18px; - overflow-wrap: anywhere; - white-space: normal; - } - - .opportunities-extension-customer-filter-option:hover { - background: #f5f4f2; - } - - .opportunities-extension-customer-filter-empty { - margin: 4px 0; - color: #5f5a55; - font-size: 13px; - } - - .opportunities-extension-table-search-field { - display: grid; - grid-template-rows: auto 44px; - gap: 6px; - width: 260px; - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - } - - .opportunities-extension-table-search-field input { - width: 100%; - min-height: 44px; - padding: 10px 12px; - border: 1px solid #b8b2ad; - border-radius: 3px; - background: #ffffff; - color: #312d2a; - font-size: 14px; - line-height: 1.3; - } - - .opportunities-extension-table-search-field input:focus { - border-color: #312d2a; - box-shadow: 0 0 0 1px #312d2a; - outline: none; - } - - .opportunities-extension-select-wrap { - position: relative; - display: block; - width: 270px; - max-width: 100%; - } - - .opportunities-extension-select-wrap::after { - position: absolute; - top: 50%; - right: 16px; - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-top: 6px solid #000000; - content: ""; - pointer-events: none; - transform: translateY(-50%); - } - - .opportunities-extension-field select { - width: 100%; - min-width: 0; - max-width: 100%; - min-height: 44px; - box-sizing: border-box; - appearance: none; - border: 1px solid #b8b2ad; - border-radius: 3px; - background: #ffffff; - color: #000000; - font-size: 14px; - line-height: 1.3; - padding: 11px 44px 11px 12px; - } - - .opportunities-extension-field select:focus { - border-color: #312d2a; - box-shadow: 0 0 0 1px #312d2a; - outline: none; - } - - .opportunities-extension-table-surface { - min-height: 0; - height: 100%; - border: 1px solid #dedbd7; - border-radius: 4px; - background: #ffffff; - overflow-x: auto; - overflow-y: auto; - } - - .opportunities-extension-table { - width: 100%; - min-width: 0; - border-collapse: collapse; - table-layout: fixed; - color: #312d2a; - font-size: 13px; - line-height: 1.35; - } - - .opportunities-extension-table thead { - background: #faf9f8; - } - - .opportunities-extension-table th { - position: sticky; - top: 0; - z-index: 1; - height: 44px; - border-bottom: 1px solid #b8b2ad; - background: #faf9f8; - color: #312d2a; - font-size: 12px; - font-weight: 700; - text-align: left; - white-space: nowrap; - } - - .opportunities-extension-table td { - min-height: 48px; - padding: 12px 16px; - border-bottom: 1px solid #ebe8e5; - vertical-align: middle; - overflow-wrap: anywhere; - } - - .opportunities-extension-table th:nth-child(1) { - width: 16%; - } - - .opportunities-extension-table th:nth-child(2) { - width: 8%; - } - - .opportunities-extension-table th:nth-child(3) { - width: 18%; - } - - .opportunities-extension-table th:nth-child(4) { - width: 11%; - } - - .opportunities-extension-table th:nth-child(5) { - width: 8%; - } - - .opportunities-extension-table th:nth-child(6) { - width: 8%; - } - - .opportunities-extension-table th:nth-child(7) { - width: 7%; - } - - .opportunities-extension-table th:nth-child(8) { - width: 6%; - } - - .opportunities-extension-table th:nth-child(9) { - width: 7%; - } - - .opportunities-extension-table th:nth-child(10) { - width: 7%; - } - - .opportunities-extension-table th:nth-child(11) { - width: 4%; - } - - .opportunities-extension-table td:nth-child(2), - .opportunities-extension-table td:nth-child(5), - .opportunities-extension-table td:nth-child(6), - .opportunities-extension-table td:nth-child(8) { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .opportunities-extension-table td:nth-child(9) { - padding-right: 6px; - padding-left: 6px; - white-space: nowrap; - overflow: visible; - } - - .opportunities-extension-table td:nth-child(7) { - padding-right: 6px; - padding-left: 6px; - white-space: nowrap; - overflow: visible; - } - - .opportunities-extension-table td:nth-child(10) { - padding-right: 6px; - padding-left: 6px; - font-size: 12px; - white-space: nowrap; - } - - .opportunities-extension-table td:nth-child(11) { - padding-right: 6px; - padding-left: 6px; - text-align: center; - white-space: nowrap; - } - - .opportunities-extension-table tbody tr:hover { - background: #f7f6f4; - } - - .opportunities-extension-table tbody tr:last-child td { - border-bottom: 0; - } - - .opportunities-extension-table a { - color: #006b84; - text-decoration: underline; - text-decoration-thickness: 1px; - text-underline-offset: 2px; - } - - .opportunities-extension-table a:hover { - color: #004f63; - } - - .opportunities-extension-opty-number-content { - display: inline-flex; - align-items: center; - gap: 6px; - max-width: 100%; - } - - .opportunities-extension-name-content { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - } - - .opportunities-extension-name-content > a { - min-width: 0; - } - - .opportunities-extension-actions-content { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 4px; - white-space: nowrap; - } - - .opportunities-extension-products-count { - display: inline-grid; - width: auto; - min-width: 20px; - height: 20px; - place-items: center; - flex: 0 0 auto; - box-sizing: border-box; - padding: 0 3px; - border: 1px solid #7ca7b2; - border-radius: 999px; - background: #e8f4f6; - color: #006d7a; - font-size: 10px; - font-weight: 700; - line-height: 1; - text-align: center; - vertical-align: middle; - cursor: pointer; - } - - .opportunities-extension-products-count:hover, - .opportunities-extension-products-count:focus-visible, - .opportunities-extension-products-count[aria-expanded="true"] { - border-color: #00758f; - background: #00758f; - color: #ffffff; - outline: none; - } - - .opportunities-extension-products-count-error { - border-color: #d8887e; - background: #f9e3e1; - color: #a52b1c; - } - - .opportunities-extension-products-detail-row:hover { - background: transparent !important; - } - - .opportunities-extension-products-detail-cell { - padding: 0 !important; - } - - .opportunities-extension-products-detail { - padding: 14px 20px 18px; - border-bottom: 1px solid #c9c5c1; - background: #f0eeeb; - } - - .opportunities-extension-products-detail-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 10px; - 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; - } - - .opportunities-extension-products-detail-heading span { - color: #5f5a55; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - } - - .opportunities-extension-products-table { - width: 100%; - table-layout: fixed; - border-collapse: collapse; - border: 1px solid #dedbd7; - background: #ffffff; - font-size: 12px; - } - - .opportunities-extension-products-table th, - .opportunities-extension-products-table td { - position: static; - height: auto; - padding: 8px 10px; - border-bottom: 1px solid #ebe8e5; - background: transparent; - overflow-wrap: anywhere; - vertical-align: middle; - white-space: normal; - } - - .opportunities-extension-products-table th { - background: #faf9f8; - font-size: 11px; - } - - .opportunities-extension-products-table th:nth-child(1) { width: 16%; } - .opportunities-extension-products-table th:nth-child(2) { width: 16%; } - .opportunities-extension-products-table th:nth-child(3) { width: 8%; } - .opportunities-extension-products-table th:nth-child(4) { width: 10%; } - .opportunities-extension-products-table th:nth-child(5) { width: 8%; } - .opportunities-extension-products-table th:nth-child(6) { width: 8%; } - .opportunities-extension-products-table th:nth-child(7) { width: 7%; } - .opportunities-extension-products-table th:nth-child(8) { width: 10%; } - .opportunities-extension-products-table th:nth-child(9) { width: 12%; } - .opportunities-extension-products-table th:nth-child(10) { width: 5%; } - - .opportunities-extension-products-empty { - margin: 0; - padding: 10px 0; - color: #5f5a55; - font-size: 13px; - } - - .opportunities-extension-copy-button { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - width: 24px; - height: 24px; - padding: 3px; - border: 0; - border-radius: 3px; - background: transparent; - color: #00758f; - cursor: pointer; - } - - .opportunities-extension-copy-button svg { - width: 16px; - height: 16px; - fill: currentColor; - } - - .opportunities-extension-copy-button:hover, - .opportunities-extension-copy-button:focus-visible { - background: #e8f4f6; - color: #004f63; - outline: none; - } - - .opportunities-extension-action-button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 5px; - border: 1px solid transparent; - border-radius: 4px; - background: transparent; - color: #00758f; - cursor: pointer; - } - - .opportunities-extension-action-button svg { - width: 21px; - height: 21px; - } - - .opportunities-extension-action-button:hover, - .opportunities-extension-action-button:focus-visible { - border-color: #7ca7b2; - background: #e8f4f6; - color: #004f63; - outline: none; - } - - .opportunities-extension-action-button:disabled { - cursor: wait; - opacity: .72; - } - - .opportunities-extension-action-button-loading svg { - animation: opportunities-extension-pulse 1s ease-in-out infinite; - } - - @keyframes opportunities-extension-pulse { - 50% { opacity: .35; } - } - - .opportunities-extension-copy-toast { - position: fixed; - right: 28px; - bottom: 28px; - z-index: 3; - max-width: min(360px, calc(100vw - 56px)); - padding: 12px 16px; - border-radius: 4px; - background: #2f6f18; - box-shadow: 0 4px 12px rgba(0, 0, 0, .18); - color: #ffffff; - font-size: 13px; - font-weight: 700; - } - - .opportunities-extension-copy-toast-error { - background: #a52b1c; - } - - .opportunities-extension-copy-toast[hidden] { - display: none !important; - } - - .opportunities-extension-stage-badge { - display: inline-flex; - align-items: center; - min-height: 24px; - padding: 3px 8px; - border-radius: 12px; - background: #ebe8e5; - color: #312d2a; - font-size: 11px; - font-weight: 700; - line-height: 1.2; - white-space: nowrap; - } - - .opportunities-extension-stage-badge[data-stage="SQL"] { - background: #d9f0f5; - color: #006b84; - } - - .opportunities-extension-stage-badge[data-stage="PIPELINE"] { - background: #ebe8e5; - color: #5f5a55; - } - - .opportunities-extension-stage-badge[data-stage="UPSIDE"] { - background: #fff0d8; - color: #945400; - } - - .opportunities-extension-stage-badge[data-stage="FORECAST"] { - background: #eee8f5; - color: #624e74; - } - - .opportunities-extension-stage-badge[data-stage="WON"] { - background: #e5f1d9; - color: #3f6f17; - } - - .opportunities-extension-status-badge { - display: inline-flex; - align-items: center; - min-height: 24px; - padding: 3px 8px; - border-radius: 12px; - background: #ebe8e5; - color: #312d2a; - font-size: 11px; - font-weight: 700; - line-height: 1.2; - white-space: nowrap; - } - - .opportunities-extension-status-badge[data-status="OPEN"] { - background: #e2f2e5; - color: #197a3d; - } - - .opportunities-extension-status-badge[data-status="CLOSED"] { - background: #e5e7eb; - color: #4b5563; - } - - .opportunities-extension-status-badge[data-status="LOST"] { - background: #f9e3e1; - color: #b13b34; - } - - .opportunities-extension-status-badge[data-status="WON"] { - background: #e3edf8; - color: #1f5f99; - } - - .opportunities-extension-sort-button { - position: relative; - display: inline-flex; - align-items: center; - width: 100%; - min-height: 44px; - padding: 8px 42px 8px 16px; - border: 0; - background: transparent; - color: inherit; - font: inherit; - font-weight: inherit; - letter-spacing: 0; - line-height: 1.2; - text-align: left; - white-space: normal; - cursor: pointer; - } - - .opportunities-extension-table-header-label { - display: inline-flex; - align-items: center; - min-height: 44px; - padding: 8px 6px; - } - - .opportunities-extension-sort-button::after { - position: absolute; - right: 15px; - color: #5f5a55; - content: "↕"; - font-size: 16px; - font-weight: 400; - } - - .opportunities-extension-sort-button[data-sort-direction="ascending"]::after { - color: #00758f; - content: "↑"; - } - - .opportunities-extension-sort-button[data-sort-direction="descending"]::after { - color: #00758f; - content: "↓"; - } - - .opportunities-extension-sort-button:hover { - background: #f0eeeb; - } - - .opportunities-extension-sort-button:focus-visible { - outline: 2px solid #00758f; - outline-offset: -2px; - } - - .opportunities-extension-table-status { - height: 152px; - color: #5f5a55; - font-size: 14px; - text-align: center; - } - - .opportunities-extension-visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; - } - - .opportunities-extension-preferences-modal { - position: absolute; - inset: 0; - z-index: 3; - display: grid; - place-items: center; - padding: 24px; - background: rgba(0, 0, 0, .42); - } - - .opportunities-extension-preferences-modal[hidden] { - display: none !important; - } - - .opportunities-extension-account-plans-modal { - position: absolute; - inset: 0; - z-index: 4; - display: grid; - place-items: center; - padding: 12px; - background: rgba(0, 0, 0, .42); - } - - .opportunities-extension-account-plans-modal[hidden] { - display: none !important; - } - - .opportunities-extension-account-plans-dialog { - display: grid; - width: min(1180px, 100%); - height: min(760px, calc(100vh - 24px)); - min-width: 0; - grid-template-rows: auto minmax(0, 1fr); - grid-template-columns: minmax(0, 1fr); - overflow: hidden; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - box-shadow: 0 8px 24px rgba(0, 0, 0, .24); - color: #312d2a; - } - - .opportunities-extension-account-plans-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - padding: 14px 20px; - border-bottom: 1px solid #dedbd7; - } - - .opportunities-extension-account-plans-header h2 { - margin: 0; - font-size: 20px; - line-height: 1.2; - } - - .opportunities-extension-account-plans-header p { - margin: 5px 0 0; - color: #5f5a55; - font-size: 13px; - line-height: 1.35; - } - - .opportunities-extension-account-plans-content { - display: grid; - min-width: 0; - grid-template-rows: auto minmax(0, 1fr); - grid-template-columns: minmax(0, 1fr); - min-height: 0; - overflow: hidden; - } - - .opportunities-extension-account-plans-content > .opportunities-extension-account-plans-skeleton { - grid-row: 1 / -1; - } - - .opportunities-extension-account-plans-tabs-navigation { - position: relative; - min-width: 0; - border-bottom: 1px solid #dedbd7; - } - - .opportunities-extension-account-plans-tabs-navigation .opportunities-extension-account-plans-tab-navigation-button[hidden] { - display: none; - } - - .opportunities-extension-account-plans-tabs { - display: flex; - min-width: 0; - gap: 4px; - overflow-x: auto; - padding: 8px 20px 0; - scrollbar-width: none; - } - - .opportunities-extension-account-plans-tabs::-webkit-scrollbar { - display: none; - } - - .opportunities-extension-account-plans-tab-navigation-button { - display: grid; - position: absolute; - z-index: 1; - top: 50%; - width: 32px; - height: 32px; - place-items: center; - padding: 0; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - color: #312d2a; - box-shadow: 0 2px 6px rgba(0, 0, 0, .16); - transform: translateY(-50%); - cursor: pointer; - } - - .opportunities-extension-account-plans-tab-navigation-button:first-child { - left: 8px; - } - - .opportunities-extension-account-plans-tab-navigation-button:last-child { - right: 8px; - } - - .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) { - border-color: #00758f; - background: #f0eeeb; - color: #005e73; - } - - .opportunities-extension-account-plans-tab-navigation-button:disabled { - border-color: #dedbd7; - color: #b8b2ad; - cursor: default; - } - - .opportunities-extension-account-plans-tab-navigation-button:focus-visible { - outline: 2px solid #00758f; - outline-offset: 2px; - } - - .opportunities-extension-account-plans-tab-navigation-button svg { - width: 18px; - height: 18px; - } - - .opportunities-extension-account-plans-panels { - min-width: 0; - min-height: 0; - overflow: hidden; - } - - .opportunities-extension-account-plan-tab { - min-height: 38px; - flex: 0 0 auto; - padding: 7px 12px; - border: 0; - border-bottom: 3px solid transparent; - background: transparent; - color: #5f5a55; - font-size: 13px; - font-weight: 600; - cursor: pointer; - } - - .opportunities-extension-account-plan-tab:hover { - background: #f0eeeb; - color: #312d2a; - } - - .opportunities-extension-account-plan-tab[aria-selected="true"] { - border-bottom-color: #00758f; - color: #006d7a; - } - - .opportunities-extension-account-plan-tab:focus-visible { - outline: 2px solid #00758f; - outline-offset: -2px; - } - - .opportunities-extension-account-plan-panel { - display: grid; - height: 100%; - min-height: 0; - grid-template-rows: auto minmax(0, 1fr); - box-sizing: border-box; - padding: 16px 20px; - } - - .opportunities-extension-account-plan-panel[hidden] { - display: none !important; - } - - .opportunities-extension-account-plan-details { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0 24px; - margin: 0; - } - - .opportunities-extension-account-plan-details > div { - display: grid; - grid-template-columns: minmax(104px, 38%) minmax(0, 1fr); - gap: 10px; - min-height: 42px; - align-items: center; - padding: 7px 0; - border-bottom: 1px solid #ebe8e5; - } - - .opportunities-extension-account-plan-details dt { - color: #5f5a55; - font-size: 12px; - font-weight: 700; - } - - .opportunities-extension-account-plan-details dd { - min-width: 0; - margin: 0; - color: #312d2a; - font-size: 13px; - overflow-wrap: anywhere; - } - - .opportunities-extension-consumption-trend { - display: grid; - min-height: 0; - grid-template-rows: auto minmax(0, 1fr); - margin-top: 14px; - padding-top: 12px; - border-top: 1px solid #dedbd7; - } - - .opportunities-extension-consumption-trend-header { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 24px; - } - - .opportunities-extension-consumption-periods { - display: flex; - align-items: center; - gap: 4px; - } - - .opportunities-extension-consumption-periods button { - min-height: 38px; - padding: 7px 14px; - border: 1px solid transparent; - border-radius: 4px; - background: transparent; - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.2; - cursor: pointer; - } - - .opportunities-extension-consumption-periods button:hover { - background: #f0eeeb; - } - - .opportunities-extension-consumption-periods button[aria-pressed="true"] { - border-color: #00758f; - background: #e7f6f8; - color: #005e73; - } - - .opportunities-extension-consumption-periods button:focus-visible, - .opportunities-extension-consumption-navigation-button:focus-visible { - outline: 2px solid #00758f; - outline-offset: 2px; - } - - .opportunities-extension-consumption-chart { - display: grid; - min-height: 0; - grid-template-rows: auto minmax(0, 1fr); - margin-top: 4px; - } - - .opportunities-extension-consumption-navigation { - display: grid; - grid-template-columns: 36px minmax(130px, auto) 36px; - align-items: center; - justify-content: center; - gap: 8px; - min-height: 40px; - margin-bottom: 2px; - } - - .opportunities-extension-consumption-navigation strong { - color: #312d2a; - font-size: 13px; - text-align: center; - } - - .opportunities-extension-consumption-navigation-button { - display: grid; - width: 36px; - height: 36px; - place-items: center; - padding: 0; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - color: #312d2a; - cursor: pointer; - } - - .opportunities-extension-consumption-navigation-button:hover:not(:disabled) { - border-color: #00758f; - background: #f0eeeb; - color: #005e73; - } - - .opportunities-extension-consumption-navigation-button:disabled { - border-color: #dedbd7; - color: #b8b2ad; - cursor: default; - } - - .opportunities-extension-consumption-navigation-button svg { - width: 18px; - height: 18px; - } - - .opportunities-extension-consumption-figure { - position: relative; - min-width: 0; - min-height: 0; - margin: 0; - scrollbar-width: none; - } - - .opportunities-extension-consumption-figure::-webkit-scrollbar { - display: none; - } - - .opportunities-extension-consumption-visual { - display: grid; - height: 100%; - min-height: 0; - grid-template-columns: minmax(0, 1fr) 176px; - align-items: center; - gap: 16px; - } - - .opportunities-extension-consumption-svg { - display: block; - width: 100%; - height: 300px; - min-height: 0; - overflow: visible; - } - - .opportunities-extension-consumption-svg .consumption-grid-line { - stroke: #dedbd7; - stroke-width: 1; - shape-rendering: crispEdges; - } - - .opportunities-extension-consumption-svg .consumption-axis-label, - .opportunities-extension-consumption-svg .consumption-x-label { - fill: #5f5a55; - font-family: Arial, sans-serif; - font-size: 12px; - } - - .opportunities-extension-consumption-svg .consumption-value-label { - fill: #312d2a; - font-family: Arial, sans-serif; - font-size: 12px; - font-weight: 700; - } - - .opportunities-extension-consumption-svg .consumption-bar-funded { - fill: #b77888; - } - - .opportunities-extension-consumption-svg .consumption-bar-overage { - fill: #de7907; - } - - .opportunities-extension-consumption-svg .consumption-average-line { - stroke: #de7907; - stroke-width: 1.5; - stroke-dasharray: 6 6; - } - - .opportunities-extension-consumption-svg .consumption-bar-group { - cursor: default; - outline: none; - } - - .opportunities-extension-consumption-svg .consumption-bar-group rect { - transition: filter .16s ease, opacity .16s ease, stroke .16s ease; - } - - .opportunities-extension-consumption-svg .consumption-bar-group:hover rect, - .opportunities-extension-consumption-svg .consumption-bar-group:focus rect { - filter: brightness(1.06); - stroke: #312d2a; - stroke-width: 1.5; - } - - .opportunities-extension-consumption-svg .consumption-animated-label { - opacity: 0; - animation: opportunities-extension-consumption-label-in .24s ease-out forwards; - } - - .opportunities-extension-consumption-svg .consumption-animated-average { - opacity: 0; - animation: opportunities-extension-consumption-average-in .35s .32s ease-out forwards; - } - - @keyframes opportunities-extension-consumption-label-in { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: translateY(0); } - } - - @keyframes opportunities-extension-consumption-average-in { - from { opacity: 0; } - to { opacity: 1; } - } - - .opportunities-extension-consumption-legend { - display: grid; - align-content: center; - gap: 4px; - } - - .opportunities-extension-consumption-legend button { - display: grid; - min-width: 0; - grid-template-columns: 16px minmax(0, 1fr); - align-items: center; - gap: 8px; - padding: 6px 4px; - border: 0; - border-radius: 4px; - background: transparent; - color: #312d2a; - font-size: 12px; - line-height: 1.25; - text-align: left; - cursor: pointer; - } - - .opportunities-extension-consumption-legend button:hover { - background: #f0eeeb; - } - - .opportunities-extension-consumption-legend button:focus-visible { - outline: 2px solid #00758f; - outline-offset: 1px; - } - - .opportunities-extension-consumption-legend button[aria-pressed="false"] { - color: #77726d; - opacity: .55; - } - - .opportunities-extension-consumption-legend-swatch { - display: block; - width: 12px; - height: 12px; - justify-self: center; - } - - .opportunities-extension-consumption-legend-funded { - background: #b77888; - } - - .opportunities-extension-consumption-legend-overage { - background: #de7907; - } - - .opportunities-extension-consumption-legend-average { - height: 0; - border-top: 2px dashed #de7907; - } - - .opportunities-extension-consumption-tooltip { - position: absolute; - z-index: 2; - display: grid; - min-width: 190px; - max-width: 260px; - gap: 4px; - padding: 10px 12px; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - box-shadow: 0 4px 12px rgba(0, 0, 0, .2); - color: #312d2a; - font-size: 12px; - line-height: 1.3; - pointer-events: none; - } - - .opportunities-extension-consumption-tooltip[hidden] { - display: none !important; - } - - .opportunities-extension-consumption-tooltip strong { - margin-bottom: 2px; - font-size: 13px; - } - - .opportunities-extension-consumption-chart-skeleton { - height: 100%; - min-height: 0; - padding: 34px 62px 24px; - box-sizing: border-box; - } - - .opportunities-extension-consumption-chart-skeleton-plot { - display: flex; - height: 100%; - min-height: 220px; - align-items: flex-end; - justify-content: space-around; - gap: 12px; - padding: 0 14px; - border-bottom: 1px solid #dedbd7; - border-left: 1px solid #dedbd7; - } - - .opportunities-extension-consumption-chart-skeleton-plot span { - width: min(38px, 6%); - border-radius: 2px 2px 0 0; - background: #dedbd7; - animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; - } - - .opportunities-extension-consumption-chart-empty, - .opportunities-extension-consumption-chart-error { - display: grid; - min-height: 220px; - place-items: center; - margin: 0; - color: #5f5a55; - font-size: 14px; - text-align: center; - } - - .opportunities-extension-consumption-chart-error { - color: #9f2d20; - } - - .opportunities-extension-account-plan-status-indicator { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 50%; - background: #9b9691; - box-shadow: 0 0 0 3px #efedeb; - vertical-align: middle; - } - - .opportunities-extension-account-plan-status-indicator[data-active="true"] { - background: #3f6f17; - box-shadow: 0 0 0 3px #e2f2e5; - } - - .opportunities-extension-account-plan-tab .opportunities-extension-account-plan-status-indicator { - margin-right: 8px; - } - - .opportunities-extension-account-plans-skeleton-tabs { - display: flex; - min-height: 46px; - align-items: center; - padding: 0 20px; - border-bottom: 1px solid #dedbd7; - } - - .opportunities-extension-account-plans-skeleton { - display: grid; - height: 100%; - min-height: 0; - grid-template-rows: auto minmax(0, 1fr); - overflow: hidden; - } - - .opportunities-extension-account-plans-skeleton-panel { - padding: 16px 20px; - } - - .opportunities-extension-account-plans-skeleton-tab { - display: inline-flex; - align-items: center; - gap: 9px; - min-height: 38px; - padding: 0 12px; - border-bottom: 3px solid #00758f; - } - - .opportunities-extension-account-plans-skeleton-status { - display: block; - width: 10px; - height: 10px; - flex: 0 0 auto; - border-radius: 50%; - background: #dedbd7; - box-shadow: 0 0 0 3px #efedeb; - animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; - } - - .opportunities-extension-account-plans-skeleton-details { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0 24px; - padding: 0; - } - - .opportunities-extension-account-plans-skeleton-details > div { - display: grid; - grid-template-columns: minmax(104px, 38%) minmax(0, 1fr); - align-items: center; - gap: 10px; - min-height: 42px; - padding: 7px 0; - border-bottom: 1px solid #ebe8e5; - } - - .opportunities-extension-account-plans-skeleton-bar { - display: block; - height: 12px; - border-radius: 3px; - background: #dedbd7; - animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; - } - - .opportunities-extension-account-plans-skeleton-tabs .opportunities-extension-account-plans-skeleton-bar { - height: 14px; - } - - .opportunities-extension-account-plans-skeleton-label { - width: 74%; - height: 10px; - } - - .opportunities-extension-account-plans-skeleton-trend { - display: grid; - min-height: 0; - grid-template-rows: auto auto minmax(0, 1fr); - margin: 14px 0 0; - padding-top: 12px; - border-top: 1px solid #dedbd7; - } - - .opportunities-extension-account-plans-skeleton-trend-header { - display: flex; - justify-content: flex-end; - min-height: 38px; - } - - .opportunities-extension-account-plans-skeleton-periods { - display: flex; - align-items: center; - gap: 16px; - } - - .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar { - height: 12px; - } - - .opportunities-extension-account-plans-skeleton-period-active { - height: 38px !important; - border: 1px solid #8cc8d3; - background: #e7f6f8; - } - - .opportunities-extension-account-plans-skeleton-navigation { - display: grid; - grid-template-columns: 32px 124px 32px; - align-items: center; - justify-content: center; - gap: 8px; - min-height: 54px; - } - - .opportunities-extension-account-plans-skeleton-navigation .opportunities-extension-account-plans-skeleton-bar { - justify-self: center; - } - - .opportunities-extension-account-plans-skeleton-chart { - display: grid; - min-height: 0; - grid-template-columns: minmax(0, 1fr) 150px; - align-items: center; - gap: 28px; - padding: 12px 14px 0 62px; - } - - .opportunities-extension-account-plans-skeleton-plot { - display: flex; - position: relative; - height: 300px; - min-height: 0; - box-sizing: border-box; - align-items: flex-end; - justify-content: space-around; - gap: 12px; - padding: 0 14px 64px; - background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #ebe8e5 58px, #ebe8e5 59px) left top / 100% 236px no-repeat; - } - - .opportunities-extension-account-plans-skeleton-plot::before, - .opportunities-extension-account-plans-skeleton-plot::after { - position: absolute; - display: block; - content: ""; - background: #dedbd7; - } - - .opportunities-extension-account-plans-skeleton-plot::before { - top: 0; - bottom: 64px; - left: 0; - width: 1px; - } - - .opportunities-extension-account-plans-skeleton-plot::after { - right: 0; - bottom: 64px; - left: 0; - height: 1px; - } - - .opportunities-extension-account-plans-skeleton-plot .opportunities-extension-account-plans-skeleton-bar { - width: min(38px, 7%); - flex: 0 1 38px; - border-radius: 2px 2px 0 0; - } - - .opportunities-extension-account-plans-skeleton-legend { - display: grid; - align-content: center; - gap: 18px; - min-height: 0; - } - - .opportunities-extension-account-plans-skeleton-legend .opportunities-extension-account-plans-skeleton-bar { - height: 11px; - } - - @keyframes opportunities-extension-skeleton-pulse { - from { opacity: .48; } - to { opacity: 1; } - } - - @media (prefers-reduced-motion: reduce) { - .opportunities-extension-account-plans-skeleton-bar, - .opportunities-extension-consumption-chart-skeleton-plot span, - .opportunities-extension-consumption-svg .consumption-animated-label, - .opportunities-extension-consumption-svg .consumption-animated-average { - animation: none; - opacity: 1; - } - } - - .opportunities-extension-account-plans-empty, - .opportunities-extension-account-plans-error { - margin: 0; - padding: 28px 20px; - color: #5f5a55; - font-size: 14px; - } - - .opportunities-extension-account-plans-error { - color: #9f2d20; - } - - .opportunities-extension-preferences-dialog { - display: grid; - width: min(680px, 100%); - max-height: min(720px, calc(100vh - 48px)); - grid-template-rows: auto minmax(0, 1fr) auto; - overflow: hidden; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - box-shadow: 0 8px 24px rgba(0, 0, 0, .24); - color: #312d2a; - } - - .opportunities-extension-preferences-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 18px 20px; - border-bottom: 1px solid #dedbd7; - } - - .opportunities-extension-preferences-header h2 { - margin: 0; - font-size: 20px; - font-weight: 700; - line-height: 1.2; - } - - .opportunities-extension-preferences-content { - min-height: 0; - overflow: auto; - padding: 20px; - } - - .opportunities-extension-preferences-fieldset { - min-width: 0; - margin: 0; - padding: 0; - border: 0; - } - - .opportunities-extension-preferences-fieldset legend { - margin-bottom: 12px; - padding: 0; - color: #312d2a; - font-size: 15px; - font-weight: 700; - line-height: 1.3; - } - - .opportunities-extension-preferences-options { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 4px 20px; - } - - .opportunities-extension-preferences-option { - display: flex; - align-items: flex-start; - gap: 10px; - min-width: 0; - padding: 9px 8px; - border-radius: 4px; - color: #312d2a; - font-size: 14px; - line-height: 1.35; - cursor: pointer; - } - - .opportunities-extension-preferences-option:hover { - background: #f0eeeb; - } - - .opportunities-extension-preferences-option input { - width: 18px; - height: 18px; - flex: 0 0 18px; - margin: 0; - accent-color: #00758f; - cursor: pointer; - } - - .opportunities-extension-preferences-cache-field { - display: grid; - width: min(280px, 100%); - gap: 6px; - margin-top: 24px; - color: #312d2a; - font-size: 14px; - font-weight: 700; - } - - .opportunities-extension-preferences-cache-field select { - width: 100%; - height: 40px; - padding: 0 36px 0 12px; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - color: #312d2a; - font-size: 14px; - font-weight: 400; - } - - .opportunities-extension-preferences-cache-field select:focus { - border-color: #00758f; - outline: 2px solid #bde7ee; - outline-offset: 0; - } - - .opportunities-extension-preferences-footer { - display: flex; - justify-content: flex-end; - padding: 14px 20px; - border-top: 1px solid #dedbd7; - } - - .opportunities-extension-button { - min-width: 88px; - min-height: 36px; - padding: 7px 16px; - border: 1px solid transparent; - border-radius: 4px; - font-size: 14px; - font-weight: 600; - line-height: 1.2; - cursor: pointer; - } - - .opportunities-extension-button-primary { - border-color: #00758f; - background: #00758f; - color: #ffffff; - } - - .opportunities-extension-button-primary:hover, - .opportunities-extension-button-primary:focus-visible { - border-color: #005e73; - background: #005e73; - outline: none; - } - - .opportunities-extension-debug-panel { - position: fixed; - right: 24px; - bottom: 24px; - z-index: 1; - width: min(640px, calc(100vw - 48px)); - max-height: min(560px, calc(100vh - 120px)); - overflow: auto; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - box-shadow: 0 2px 8px rgba(0, 0, 0, .16); - padding: 16px; - color: #000000; - } - - .opportunities-extension-debug-panel[hidden] { - display: none !important; - } - - .opportunities-extension-debug-panel h2 { - margin: 0; - font-size: 18px; - font-weight: 700; - line-height: 1.25; - } - - .opportunities-extension-debug-panel p { - margin: 4px 0 0; - color: #5f5a55; - font-size: 13px; - } - - .opportunities-extension-debug-panel-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; - } - - .opportunities-extension-debug-visibility-button { - width: 34px; - height: 34px; - flex: 0 0 34px; - color: #312d2a; - } - - .opportunities-extension-debug-visibility-button svg { - width: 20px; - height: 20px; - } - - .opportunities-extension-debug-list { - display: grid; - gap: 10px; - } - - .opportunities-extension-debug-item { - border: 1px solid #dedbd7; - border-left-width: 4px; - border-radius: 4px; - background: #faf9f8; - } - - .opportunities-extension-debug-item[open] { - background: #ffffff; - } - - .opportunities-extension-debug-item-success { - border-left-color: #3f6f17; - } - - .opportunities-extension-debug-item-success .opportunities-extension-debug-status { - background: #e2f2e5; - color: #356d19; - } - - .opportunities-extension-debug-item-failed { - border-left-color: #c5331f; - } - - .opportunities-extension-debug-item-failed .opportunities-extension-debug-status { - background: #f9e3e1; - color: #a52b1c; - } - - .opportunities-extension-debug-item-pending { - border-left-color: #6f5a7f; - } - - .opportunities-extension-debug-item-pending .opportunities-extension-debug-status { - background: #eee8f2; - color: #6f5a7f; - } - - .opportunities-extension-debug-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-height: 42px; - padding: 9px 12px 9px 10px; - cursor: pointer; - list-style: none; - } - - .opportunities-extension-debug-heading::-webkit-details-marker { - display: none; - } - - .opportunities-extension-debug-heading::before { - content: "⌄"; - width: 14px; - color: #5f5a55; - font-size: 16px; - line-height: 1; - transform: rotate(-90deg); - transition: transform .15s ease; - } - - .opportunities-extension-debug-item[open] .opportunities-extension-debug-heading::before { - transform: rotate(0deg); - } - - .opportunities-extension-debug-summary-meta { - display: inline-flex; - align-items: center; - gap: 8px; - margin-left: auto; - } - - .opportunities-extension-debug-http-status, - .opportunities-extension-debug-status { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 0 8px; - border-radius: 999px; - font-size: 11px; - font-weight: 700; - line-height: 1; - text-transform: uppercase; - } - - .opportunities-extension-debug-http-status { - background: #e8f4f6; - color: #006d7a; - } - - .opportunities-extension-debug-status { - background: #ebe8e5; - color: #5f5a55; - } - - .opportunities-extension-debug-heading strong { - font-size: 13px; - } - - .opportunities-extension-debug-item pre { - margin: 0; - padding: 8px 12px 12px; - color: #312d2a; - font-family: Consolas, "Courier New", monospace; - font-size: 12px; - line-height: 1.45; - white-space: pre-wrap; - word-break: break-word; - } - - .opportunities-extension-debug-empty { - padding: 14px; - border: 1px dashed #b8b2ad; - border-radius: 4px; - color: #5f5a55; - font-size: 13px; - } - - .opportunities-extension-modal[data-theme="dark"] { - background: #1f1f1f; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-shell, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-body { - background: #1f1f1f; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-header { - border-bottom-color: #4e4a46; - background: - linear-gradient(90deg, #a8795d 0 11%, #dd5a47 11% 23%, #876e9b 23% 36%, #00a6be 36% 51%, #dfbd76 51% 63%, transparent 63% 100%) top left / 100% 6px no-repeat, - #292827; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-header h1, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-value, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-field, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-label, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-label, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-label, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-search-field { - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-header p, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header p, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card span, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-empty, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail-heading span, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-empty, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-empty, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details dt, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel p, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-empty { - color: #c9c5c1; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-error { - color: #ffb4a8; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-error { - color: #ffb4a8; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-bar { - background: #5b5753; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-status { - background: #5b5753; - box-shadow: 0 0 0 3px #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-period-active { - border-color: #3f7580; - background: #23474e; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot span { - background: #5b5753; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator { - background: #8f8a85; - box-shadow: 0 0 0 3px #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator[data-active="true"] { - background: #7fb05b; - box-shadow: 0 0 0 3px #314527; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-form, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-surface, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-panel, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-dialog, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-dialog, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] { - border-color: #4e4a46; - background: #292827; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-trigger, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-search, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-search-field input, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-field select, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-cache-field select, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button { - border-color: #6a6560; - background: #333130; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-trigger::after { - border-top-color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-select-wrap::after { - border-top-color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-trigger:disabled { - background: #292827; - color: #918b86; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-search::placeholder, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-search-field input::placeholder { - color: #b5b0ab; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header h2, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header h2, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-fieldset legend, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-cache-field, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail-heading, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details dd, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-heading, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 { - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation strong { - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-empty { - color: #c9c5c1; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table th { - border-bottom-color: #4e4a46; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-footer, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs-navigation, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details > div, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-tabs, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-details > div { - border-color: #4e4a46; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-trend, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-trend, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot { - border-color: #4e4a46; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot { - background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #4e4a46 58px, #4e4a46 59px) left top / 100% 236px no-repeat; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot::before, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot::after { - background: #4e4a46; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab { - color: #c9c5c1; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab:hover { - background: #3b3937; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab[aria-selected="true"] { - border-bottom-color: #43c4d5; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button { - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button:hover { - background: #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button[aria-pressed="true"] { - border-color: #43c4d5; - background: #173e43; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button { - border-color: #6a6560; - background: #333130; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:hover:not(:disabled), - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) { - border-color: #43c4d5; - background: #3b3937; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:disabled, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button:disabled { - border-color: #4e4a46; - color: #746f6a; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-grid-line { - stroke: #4e4a46; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-axis-label, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-x-label { - fill: #c9c5c1; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-value-label { - fill: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-funded { - fill: #ca8798; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-overage { - fill: #f09a32; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-average-line { - stroke: #f09a32; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-group:hover rect, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-group:focus rect { - stroke: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button { - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button:hover { - background: #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button[aria-pressed="false"] { - color: #a8a39e; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-funded { - background: #ca8798; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-overage { - background: #f09a32; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-average { - border-top-color: #f09a32; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-tooltip { - border-color: #6a6560; - background: #333130; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option:hover { - background: #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option input { - accent-color: #43c4d5; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary { - border-color: #00a6be; - background: #008aa6; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary:focus-visible { - border-color: #43c4d5; - background: #006d7a; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table tbody tr:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sort-button:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-icon-button:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-icon-button:focus { - background: #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table thead, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table th, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table th { - background: #333130; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table td { - border-bottom-color: #3f3c39; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail { - border-bottom-color: #4e4a46; - background: #252423; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table th, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table td { - border-bottom-color: #3f3c39; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count { - border-color: #2f9bae; - background: #1d4e55; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count:focus-visible, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count[aria-expanded="true"] { - border-color: #43c4d5; - background: #008aa6; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-progress[data-state="running"] { - background: #1d4e55; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-progress[data-state="complete"] { - background: #244f2e; - color: #a8dfb7; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-progress[data-state="complete-with-errors"], - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-progress[data-state="error"], - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count-error { - border-color: #b13b34; - background: #5f2926; - color: #ffb4ad; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-cache-timestamp { - background: #3b3937; - color: #c9c5c1; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table a, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button { - color: #43c4d5; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table a:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:focus-visible, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button:focus-visible { - background: #1d4e55; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="SQL"] { - color: #43c4d5; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="PIPELINE"] { - color: #d1cbc6; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="UPSIDE"] { - color: #ffbf62; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="FORECAST"] { - color: #d2b7e9; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="WON"] { - color: #9bd46a; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="SQL"] { - border-top-color: #43c4d5; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-total-dashboard-card { - border-top-color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { - border-top-color: #c0b9b3; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="UPSIDE"] { - border-top-color: #ffb84d; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="FORECAST"] { - border-top-color: #cba7e7; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="WON"] { - border-top-color: #92cf5b; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="SQL"] { - border-color: #008aa6; - background: #008aa6; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="PIPELINE"] { - border-color: #6f6863; - background: #6f6863; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="UPSIDE"] { - border-color: #b96e00; - background: #b96e00; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="FORECAST"] { - border-color: #755b8b; - background: #755b8b; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="WON"] { - border-color: #4b7f22; - background: #4b7f22; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[data-status="OPEN"] { - color: #69c985; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[data-status="CLOSED"] { - color: #c3cad3; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[data-status="LOST"] { - color: #ff9d94; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[data-status="WON"] { - color: #83bfff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="OPEN"] { - border-color: #197a3d; - background: #197a3d; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="CLOSED"] { - border-color: #4b5563; - background: #4b5563; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="LOST"] { - border-color: #b13b34; - background: #b13b34; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="WON"] { - border-color: #1f5f99; - background: #1f5f99; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item { - border-color: #4e4a46; - background: #252423; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-icon-button { - color: #f6f4f2; - } - - @media (min-width: 1201px) and (max-width: 1450px) { - .opportunities-extension-table th:nth-child(1) { - width: 16%; - } - - .opportunities-extension-table th:nth-child(3) { - width: 18%; - } - - .opportunities-extension-table th:nth-child(4) { - width: 12%; - } - - .opportunities-extension-table th:nth-child(6) { - width: 7%; - } - - .opportunities-extension-table th:nth-child(7) { - width: 8%; - } - - .opportunities-extension-table th:nth-child(10) { - width: 10%; - } - } - - @media (max-width: 1200px) { - .opportunities-extension-table { - min-width: 1180px; - } - - .opportunities-extension-table th:nth-child(1) { - width: 15%; - } - - .opportunities-extension-table th:nth-child(3) { - width: 17%; - } - - .opportunities-extension-table th:nth-child(4) { - width: 13%; - } - - .opportunities-extension-table th:nth-child(6) { - width: 6%; - } - - .opportunities-extension-table th:nth-child(7) { - width: 9%; - } - - .opportunities-extension-table th:nth-child(10) { - width: 11%; - } - } - - @media (max-width: 700px) { - .opportunities-extension-header { - padding: 24px 20px 18px; - } - - .opportunities-extension-header::before { - left: 20px; - } - - .opportunities-extension-header h1 { - font-size: 22px; - } - - .opportunities-extension-header p { - font-size: 14px; - } - - .opportunities-extension-body { - padding: 16px 20px 24px; - } - - .opportunities-extension-form { - align-items: stretch; - flex-wrap: wrap; - gap: 14px; - min-height: auto; - padding: 12px; - } - - .opportunities-extension-field { - max-width: none; - width: 100%; - } - - .opportunities-extension-customer-filter { - width: 100%; - } - - .opportunities-extension-customer-filter-trigger { - width: 100%; - } - - .opportunities-extension-customer-filter-panel { - width: min(360px, calc(100vw - 64px)); - } - - .opportunities-extension-table-search-field { - width: 100%; - } - - .opportunities-extension-stage-dashboard { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .opportunities-extension-stage-dashboard-value { - font-size: 18px; - } - - .opportunities-extension-preferences-modal { - padding: 12px; - } - - .opportunities-extension-account-plans-modal { - padding: 12px; - } - - .opportunities-extension-preferences-dialog { - max-height: calc(100vh - 24px); - } - - .opportunities-extension-account-plans-dialog { - height: calc(100vh - 24px); - } - - .opportunities-extension-account-plan-panel { - padding: 12px 14px; - } - - .opportunities-extension-account-plan-details { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 14px; - } - - .opportunities-extension-account-plan-details > div { - grid-template-columns: 1fr; - align-content: center; - gap: 3px; - min-height: 54px; - padding: 5px 0; - } - - .opportunities-extension-account-plans-skeleton-details { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 14px; - padding: 0; - } - - .opportunities-extension-account-plans-skeleton-details > div { - grid-template-columns: 1fr; - align-content: center; - gap: 5px; - min-height: 54px; - padding: 5px 0; - } - - .opportunities-extension-account-plans-skeleton-trend { - margin: 14px 0 0; - } - - .opportunities-extension-account-plans-skeleton-periods { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - align-items: center; - gap: 6px; - } - - .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar { - width: 100% !important; - } - - .opportunities-extension-account-plans-skeleton-chart { - grid-template-columns: minmax(0, 1fr); - gap: 12px; - padding: 12px 8px 0 34px; - } - - .opportunities-extension-account-plans-skeleton-legend { - display: flex; - min-height: 24px; - align-items: center; - justify-content: center; - gap: 12px; - } - - .opportunities-extension-consumption-trend-header { - align-items: stretch; - } - - .opportunities-extension-consumption-periods { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 3px; - } - - .opportunities-extension-consumption-periods button { - min-width: 0; - padding: 7px 4px; - font-size: 12px; - } - - .opportunities-extension-consumption-chart { - min-height: 0; - } - - .opportunities-extension-consumption-svg { - width: 760px; - height: 245px; - } - - .opportunities-extension-consumption-visual { - width: 920px; - grid-template-columns: 760px 144px; - gap: 12px; - } - - .opportunities-extension-consumption-figure { - overflow-x: auto; - overscroll-behavior-inline: contain; - } - - .opportunities-extension-consumption-chart-skeleton { - min-height: 0; - padding: 24px 18px 18px; - } - - .opportunities-extension-consumption-chart-skeleton-plot { - min-height: 180px; - gap: 4px; - padding: 0 6px; - } - - .opportunities-extension-preferences-options { - grid-template-columns: 1fr; - } - - .opportunities-extension-stage-filter-controls { - gap: 6px; - } - - .opportunities-extension-status-filter-controls { - gap: 6px; - } - - .opportunities-extension-table-surface { - min-width: 0; - min-height: calc(100vh - 230px); - } - - .opportunities-extension-debug-panel { - right: 12px; - bottom: 12px; - width: calc(100vw - 24px); - max-height: calc(100vh - 96px); - } - } - `; - - document.head.append(style); - } - - function isAllowedPage() { - if (window.location.hostname !== "eeho.fa.us2.oraclecloud.com") { - return false; - } - - return ALLOWED_PATHS.some((path) => { - return window.location.pathname === path || window.location.pathname.startsWith(`${path}/`); - }); - } - - function findInsertionPoint(salesGroup) { - const addIconInsideGroup = salesGroup.querySelector(ADD_ICON_SELECTOR); - - if (addIconInsideGroup) { - return addIconInsideGroup; - } - - const container = salesGroup.closest(".flat-grid, .flat-grid-container, [id*='yourapps'], [id*='groupNode']") || salesGroup.parentElement || document.body; - const addIconCells = Array.from(container.querySelectorAll(ADD_ICON_SELECTOR)); - - return addIconCells.find((cell) => { - return Boolean(salesGroup.compareDocumentPosition(cell) & Node.DOCUMENT_POSITION_FOLLOWING); - }) || null; - } - - function insertTile() { - if (document.getElementById(TILE_ID)) { - return true; - } - - const salesGroup = document.querySelector(SALES_GROUP_SELECTOR); - const addIconCell = salesGroup ? findInsertionPoint(salesGroup) : null; - - if (!salesGroup || !addIconCell) { - return false; - } - - addIconCell.before(createOpportunitiesTile()); - return true; - } - - if (insertTile()) { - return; - } - - const observer = new MutationObserver(() => { - insertTile(); + return { toggleDebugPanel, renderDebugPanel, createDebugVisibilityIcon, appendDebugBlock, formatDebugTime }; +} + +// Source: src/content/main.js +/** Preserve the existing document_idle initialization and observer behavior. */ +function startContentApplication({ environment, page, styles, products }) { + if (!page.isAllowedPage()) return; + styles.ensureExtensionStyles(); + products.ensureProductsBackgroundMessageHandler(); + if (page.insertTile()) return; + const observer = new environment.MutationObserver(() => page.insertTile()); + observer.observe(environment.document.documentElement, { childList: true, subtree: true }); +} + +// Source: src/content/application.js +function createContentApplication(overrides = {}) { + const environment = { + document: globalThis.document, + window: globalThis.window, + navigator: globalThis.navigator, + get localStorage() { return globalThis.localStorage; }, + fetch: globalThis.fetch, + browser: globalThis.browser, + chrome: globalThis.chrome, + MutationObserver: globalThis.MutationObserver, + Node: globalThis.Node, + requestAnimationFrame: globalThis.requestAnimationFrame, + performance: globalThis.performance, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + matchMedia: globalThis.matchMedia, + ...overrides + }; + const messageTypes = createMessageTypes(); + const contracts = createContentContracts(); + const authSessionState = createAuthSessionState(contracts); + const authSalesCommonState = createAuthSalesCommonState(); + const opportunitiesQueryState = createOpportunitiesQueryState(); + const opportunitiesTableState = createOpportunitiesTableState(); + const opportunitiesFilterState = createOpportunitiesFilterState(contracts); + const preferencesState = createPreferencesState(contracts); + const productsContentState = createProductsContentState(); + const aiSummaryState = createAiSummaryState(); + const consumptionPlansState = createConsumptionPlansState(); + const consumptionTrendState = createConsumptionTrendState(); + const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts); + const debugState = createDebugState(); + const columns = createOpportunitiesColumns({ + formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), + formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args), + formatWinProbability: (...args) => opportunitiesFormatters.formatWinProbability(...args), + getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args), + getOwnerName: (...args) => opportunitiesFormatters.getOwnerName(...args) }); - - observer.observe(document.documentElement, { - childList: true, - subtree: true + const contentFusionPage = createContentFusionPage({ + ADD_ICON_SELECTOR: contracts.ADD_ICON_SELECTOR, + ALLOWED_PATHS: contracts.ALLOWED_PATHS, + SALES_GROUP_SELECTOR: contracts.SALES_GROUP_SELECTOR, + TILE_ID: contracts.TILE_ID, + TILE_LABEL_ID: contracts.TILE_LABEL_ID, + appendPath: (...args) => svgPaths.appendPath(...args), + openOpportunitiesModal: (...args) => contentModal.openOpportunitiesModal(...args), + environment }); + const contentModal = createContentModal({ + ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID, + AUTH_STATUS: contracts.AUTH_STATUS, + CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID, + CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID, + CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID, + CUSTOMER_FILTER_SEARCH_ID: contracts.CUSTOMER_FILTER_SEARCH_ID, + CUSTOMER_FILTER_SELECT_ALL_ID: contracts.CUSTOMER_FILTER_SELECT_ALL_ID, + DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID, + ESCAPE_LISTENER_KEY: contracts.ESCAPE_LISTENER_KEY, + MODAL_ID: contracts.MODAL_ID, + OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID, + OWNER_FILTER_BUTTON_ID: contracts.OWNER_FILTER_BUTTON_ID, + OWNER_FILTER_LIST_ID: contracts.OWNER_FILTER_LIST_ID, + OWNER_FILTER_PANEL_ID: contracts.OWNER_FILTER_PANEL_ID, + OWNER_FILTER_SEARCH_ID: contracts.OWNER_FILTER_SEARCH_ID, + OWNER_FILTER_SELECT_ALL_ID: contracts.OWNER_FILTER_SELECT_ALL_ID, + PERIOD_OPTIONS: contracts.PERIOD_OPTIONS, + PREFERENCES_MODAL_ID: contracts.PREFERENCES_MODAL_ID, + PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID, + PRODUCTS_PROGRESS_ID: contracts.PRODUCTS_PROGRESS_ID, + STAGE_DASHBOARD_ID: contracts.STAGE_DASHBOARD_ID, + STAGE_FILTERS_ID: contracts.STAGE_FILTERS_ID, + STATUS_FILTERS_ID: contracts.STATUS_FILTERS_ID, + TABLE_SEARCH_ID: contracts.TABLE_SEARCH_ID, + applyTheme: (...args) => contentTheme.applyTheme(...args), + authSessionState, + closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args), + closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args), + closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args), + closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args), + createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args), + debugState, + ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args), + getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args), + getOwnerOptions: (...args) => opportunitiesFormatters.getOwnerOptions(...args), + getSavedOpportunityTypeValues: (...args) => preferencesStorage.getSavedOpportunityTypeValues(...args), + getSavedOpptyProductsCacheHours: (...args) => preferencesStorage.getSavedOpptyProductsCacheHours(...args), + getSavedTheme: (...args) => contentTheme.getSavedTheme(...args), + openPreferencesModal: (...args) => preferencesView.openPreferencesModal(...args), + opportunitiesFilterState, + opportunitiesQueryState, + preferencesState, + renderCustomerFilter: (...args) => opportunitiesFilters.renderCustomerFilter(...args), + renderCustomerFilterList: (...args) => opportunitiesFilters.renderCustomerFilterList(...args), + renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), + renderOwnerFilter: (...args) => opportunitiesFilters.renderOwnerFilter(...args), + renderOwnerFilterList: (...args) => opportunitiesFilters.renderOwnerFilterList(...args), + renderStageFilterButtons: (...args) => opportunitiesFilters.renderStageFilterButtons(...args), + renderStatusFilterButtons: (...args) => opportunitiesFilters.renderStatusFilterButtons(...args), + requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args), + requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args), + resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args), + resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args), + restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args), + saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args), + toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args), + toggleDebugPanel: (...args) => debugContentView.toggleDebugPanel(...args), + toggleOwnerFilterPanel: (...args) => opportunitiesFilters.toggleOwnerFilterPanel(...args), + environment + }); + const contentTheme = createContentTheme({ + THEME_STORAGE_KEY: contracts.THEME_STORAGE_KEY, + createThemeIcon: (...args) => contentIcons.createThemeIcon(...args), + environment + }); + const contentIcons = createContentIcons({ + appendPath: (...args) => svgPaths.appendPath(...args), + environment + }); + const svgPaths = createSvgPaths({ environment }); + const contentStyles = createContentStyles({ + STYLE_ID: contracts.STYLE_ID, + redwoodStyles, + environment + }); + const authContentSession = createAuthContentSession({ + messageTypes, + AUTH_STATUS: contracts.AUTH_STATUS, + appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args), + authSessionState, + requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args), + sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), + updateAuthBadge: (...args) => contentModal.updateAuthBadge(...args) + }); + const authSalesCommon = createAuthSalesCommon({ + TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL, + authSalesCommonState, + requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args) + }); + const authTokenRelay = createAuthTokenRelay({ + TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL, + createDebugBody: (...args) => debugContentLog.createDebugBody(...args), + getXsrfToken: (...args) => authXsrfContent.getXsrfToken(...args), + logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args), + logRequestStart: (...args) => debugContentLog.logRequestStart(...args), + logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args), + maskToken: (...args) => debugContentLog.maskToken(...args), + readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args), + refreshXsrfCookie: (...args) => platformContentMessages.refreshXsrfCookie(...args), + renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args), + environment + }); + const authXsrfContent = createAuthXsrfContent({ + getXsrfTokenFromExtensionCookies: (...args) => platformContentMessages.getXsrfTokenFromExtensionCookies(...args), + environment + }); + const platformContentMessages = createPlatformContentMessages({ + messageTypes, + environment + }); + const opportunitiesController = createOpportunitiesController({ + STAGE_OPTIONS: contracts.STAGE_OPTIONS, + STATUS_OPTIONS: contracts.STATUS_OPTIONS, + authSessionState, + consumptionPlansState, + consumptionTrendState, + getFiscalPeriodRange: (...args) => opportunitiesFiscalPeriods.getFiscalPeriodRange(...args), + opportunitiesDashboardState, + opportunitiesFilterState, + opportunitiesQueryState, + opportunitiesTableState, + preferencesState, + productsContentState, + renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args), + requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args), + requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args), + requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args), + setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args) + }); + const fusionOpportunities = createFusionOpportunities({ + OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT, + OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL, + isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), + logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args), + logRequestStart: (...args) => debugContentLog.logRequestStart(...args), + logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args), + maskToken: (...args) => debugContentLog.maskToken(...args), + getOpportunityTypeValues: () => preferencesState.selectedOpportunityTypeValues, + readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args), + environment + }); + const opportunitiesFiscalPeriods = createOpportunitiesFiscalPeriods(); + const opportunitiesTable = createOpportunitiesTable({ + ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL, + OPPORTUNITIES_COLUMNS: columns, + OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID, + OPPORTUNITY_DETAIL_URL: contracts.OPPORTUNITY_DETAIL_URL, + createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args), + createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args), + createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args), + createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args), + createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args), + createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args), + createStatusBadge: (...args) => opportunitiesLinks.createStatusBadge(...args), + displayOpportunityValue: (...args) => opportunitiesFormatters.displayOpportunityValue(...args), + getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args), + getSortedOpportunities: (...args) => opportunitiesFilters.getSortedOpportunities(...args), + opportunitiesTableState, + productsContentState, + renderCustomerFilter: (...args) => opportunitiesFilters.renderCustomerFilter(...args), + renderOwnerFilter: (...args) => opportunitiesFilters.renderOwnerFilter(...args), + renderStageDashboard: (...args) => opportunitiesDashboard.renderStageDashboard(...args), + resetCustomerFilter: (...args) => opportunitiesFilters.resetCustomerFilter(...args), + resetOwnerFilter: (...args) => opportunitiesFilters.resetOwnerFilter(...args), + environment + }); + const opportunitiesDashboard = createOpportunitiesDashboard({ + STAGE_DASHBOARD_ID: contracts.STAGE_DASHBOARD_ID, + STAGE_OPTIONS: contracts.STAGE_OPTIONS, + getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args), + opportunitiesDashboardState, + environment + }); + const opportunitiesFilters = createOpportunitiesFilters({ + CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID, + CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID, + CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID, + CUSTOMER_FILTER_SEARCH_ID: contracts.CUSTOMER_FILTER_SEARCH_ID, + CUSTOMER_FILTER_SELECT_ALL_ID: contracts.CUSTOMER_FILTER_SELECT_ALL_ID, + OPPORTUNITIES_COLUMNS: columns, + OWNER_FILTER_BUTTON_ID: contracts.OWNER_FILTER_BUTTON_ID, + OWNER_FILTER_LIST_ID: contracts.OWNER_FILTER_LIST_ID, + OWNER_FILTER_PANEL_ID: contracts.OWNER_FILTER_PANEL_ID, + OWNER_FILTER_SEARCH_ID: contracts.OWNER_FILTER_SEARCH_ID, + OWNER_FILTER_SELECT_ALL_ID: contracts.OWNER_FILTER_SELECT_ALL_ID, + STAGE_FILTERS_ID: contracts.STAGE_FILTERS_ID, + STAGE_OPTIONS: contracts.STAGE_OPTIONS, + STATUS_FILTERS_ID: contracts.STATUS_FILTERS_ID, + STATUS_OPTIONS: contracts.STATUS_OPTIONS, + getCustomerFilterSummary: (...args) => opportunitiesFormatters.getCustomerFilterSummary(...args), + getCustomerKey: (...args) => opportunitiesFormatters.getCustomerKey(...args), + getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args), + getOwnerKey: (...args) => opportunitiesFormatters.getOwnerKey(...args), + getOwnerOptions: (...args) => opportunitiesFormatters.getOwnerOptions(...args), + opportunitiesFilterState, + opportunitiesTableState, + renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), + saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args), + stringifySearchValue: (...args) => opportunitiesFormatters.stringifySearchValue(...args), + environment + }); + const opportunitiesFormatters = createOpportunitiesFormatters({ + opportunitiesTableState + }); + const opportunitiesLinks = createOpportunitiesLinks({ + MODAL_ID: contracts.MODAL_ID, + environment + }); + const preferencesStorage = createPreferencesStorage({ + DEFAULT_OPPORTUNITY_TYPE_VALUES: contracts.DEFAULT_OPPORTUNITY_TYPE_VALUES, + DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS: contracts.DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, + FILTER_PREFERENCES_STORAGE_KEY: contracts.FILTER_PREFERENCES_STORAGE_KEY, + MODAL_ID: contracts.MODAL_ID, + OPPORTUNITY_TYPE_OPTIONS: contracts.OPPORTUNITY_TYPE_OPTIONS, + OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY: contracts.OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, + OPPTY_PRODUCTS_CACHE_OPTIONS: contracts.OPPTY_PRODUCTS_CACHE_OPTIONS, + PERIOD_OPTIONS: contracts.PERIOD_OPTIONS, + STAGE_OPTIONS: contracts.STAGE_OPTIONS, + STATUS_OPTIONS: contracts.STATUS_OPTIONS, + opportunitiesFilterState, + environment + }); + const preferencesView = createPreferencesView({ + DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS: contracts.DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, + MODAL_ID: contracts.MODAL_ID, + OPPORTUNITY_TYPE_OPTIONS: contracts.OPPORTUNITY_TYPE_OPTIONS, + OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY: contracts.OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, + OPPTY_PRODUCTS_CACHE_OPTIONS: contracts.OPPTY_PRODUCTS_CACHE_OPTIONS, + PERIOD_OPTIONS: contracts.PERIOD_OPTIONS, + PREFERENCES_MODAL_ID: contracts.PREFERENCES_MODAL_ID, + createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), + getSavedOpportunityTypeValues: (...args) => preferencesStorage.getSavedOpportunityTypeValues(...args), + getSavedOpptyProductsCacheHours: (...args) => preferencesStorage.getSavedOpptyProductsCacheHours(...args), + preferencesState, + requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args), + environment + }); + const productsContentJobs = createProductsContentJobs({ + messageTypes, + PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID, + PRODUCTS_MESSAGE_LISTENER_KEY: contracts.PRODUCTS_MESSAGE_LISTENER_KEY, + PRODUCTS_PROGRESS_ID: contracts.PRODUCTS_PROGRESS_ID, + preferencesState, + productsContentState, + renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), + sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), + environment + }); + const productsDetails = createProductsDetails({ + OPPORTUNITIES_COLUMNS: columns, + createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args), + formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), + productsContentState, + renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), + requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args), + environment + }); + const aiSummaryContent = createAiSummaryContent({ + aiSummaryState, + invalidateSalesCommonToken: (...args) => authSalesCommon.invalidateSalesCommonToken(...args), + logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args), + logRequestStart: (...args) => debugContentLog.logRequestStart(...args), + logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args), + readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args), + requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...args), + environment + }); + const consumptionPlansAction = createConsumptionPlansAction({ + messageTypes, + appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args), + consumptionPlansState, + getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args), + openAccountPlansModal: (...args) => consumptionPlansView.openAccountPlansModal(...args), + renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), + requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args), + sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), + showCopyConfirmation: (...args) => opportunitiesLinks.showCopyConfirmation(...args), + environment + }); + const consumptionPlansView = createConsumptionPlansView({ + ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID, + DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD, + MODAL_ID: contracts.MODAL_ID, + createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), + createConsumptionTrendSection: (...args) => consumptionTrendController.createConsumptionTrendSection(...args), + createSvgElement: (...args) => consumptionChart.createSvgElement(...args), + formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), + getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args), + loadConsumptionTrendForPanel: (...args) => consumptionTrendController.loadConsumptionTrendForPanel(...args), + environment + }); + const consumptionTrendController = createConsumptionTrendController({ + messageTypes, + CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS, + DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD, + appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args), + consumptionTrendState, + renderConsumptionTrendChart: (...args) => consumptionChart.renderConsumptionTrendChart(...args), + requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args), + sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), + environment + }); + const consumptionChart = createConsumptionChart({ + CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS, + CONSUMPTION_TREND_SERIES: contracts.CONSUMPTION_TREND_SERIES, + SVG_NAMESPACE: contracts.SVG_NAMESPACE, + calculateAverageConsumption: (...args) => consumptionModel.calculateAverageConsumption(...args), + consumptionTrendState, + createConsumptionTrendKey: (...args) => consumptionTrendController.createConsumptionTrendKey(...args), + createNiceConsumptionMaximum: (...args) => consumptionModel.createNiceConsumptionMaximum(...args), + formatCompactConsumptionNumber: (...args) => consumptionModel.formatCompactConsumptionNumber(...args), + formatConsumptionCurrency: (...args) => consumptionModel.formatConsumptionCurrency(...args), + formatConsumptionPointLabel: (...args) => consumptionModel.formatConsumptionPointLabel(...args), + formatConsumptionRange: (...args) => consumptionModel.formatConsumptionRange(...args), + normalizeConsumptionTrendItems: (...args) => consumptionModel.normalizeConsumptionTrendItems(...args), + environment + }); + const consumptionModel = createConsumptionModel({ + + }); + const debugContentLog = createDebugContentLog({ + DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT, + debugState, + renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args) + }); + const debugContentView = createDebugContentView({ + DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID, + MODAL_ID: contracts.MODAL_ID, + debugState, + formatDebugBody: (...args) => debugContentLog.formatDebugBody(...args), + formatHeaderBlock: (...args) => debugContentLog.formatHeaderBlock(...args), + formatMetadataBlock: (...args) => debugContentLog.formatMetadataBlock(...args), + environment + }); + function start() { + startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs }); + } + return { + start, contracts, + authSessionState, + authSalesCommonState, + opportunitiesQueryState, + opportunitiesTableState, + opportunitiesFilterState, + preferencesState, + productsContentState, + aiSummaryState, + consumptionPlansState, + consumptionTrendState, + opportunitiesDashboardState, + debugState, + contentFusionPage, + contentModal, + contentTheme, + contentIcons, + contentStyles, + authContentSession, + authSalesCommon, + authTokenRelay, + authXsrfContent, + platformContentMessages, + opportunitiesController, + fusionOpportunities, + opportunitiesFiscalPeriods, + opportunitiesTable, + opportunitiesDashboard, + opportunitiesFilters, + opportunitiesFormatters, + opportunitiesLinks, + preferencesStorage, + preferencesView, + productsContentJobs, + productsDetails, + aiSummaryContent, + consumptionPlansAction, + consumptionPlansView, + consumptionTrendController, + consumptionChart, + consumptionModel, + debugContentLog, + debugContentView + }; +} + +createContentApplication().start(); })(); diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..6f33a42 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,198 @@ +# Architecture and Compatibility Contracts + +This document describes the integrated modular implementation. Verification and +preexisting risks are tracked in [refactoring.md](refactoring.md). + +## Sources, Outputs, and Runtime Boundaries + +- Editable sources are factory modules under `src/`; the former monoliths were removed. +- `scripts/sources.cjs` explicitly lists modules for each context. `compose.cjs` + validates syntax, serializes CSS, wraps the modules in an IIFE and calls the + corresponding application factory/start method. `build.mjs` writes two packages. +- `dist/chromium` serves Chrome and Edge; `dist/firefox` serves Firefox. Do not edit outputs. +- Both manifests use V3. Chromium has a classic background service worker; Firefox + uses `background.scripts`. Neither entry point is declared as a module. +- Content scripts run at `document_idle`, in all matching frames on the two + configured Fusion welcome pages. Their code also checks the hostname/path. +- There are no extension UI pages, injected page-context scripts, custom CSP, + declared execution world, or web-accessible resources. +- Permissions remain `cookies` and `storage`, with host access limited to the + configured Fusion host and `spa.oracle.com`. + +Content owns the DOM and per-frame state. Background owns privileged cookies, +temporary tabs, extension storage and SPA requests. These contexts communicate +using serializable runtime messages, not shared variables. Page localStorage is +different from extension storage and is not a safe place for credentials. + +## Current Ownership and Side Effects + +| Context | Responsibilities | State and effects | +| --- | --- | --- | +| Content | Fusion tile and main/preferences/consumption modals | DOM nodes, injected CSS, keyboard/pointer/resize listeners, MutationObserver | +| Content | Opportunity query and presentation | Filter sets, sorting, search, request versions, column formatters, dashboard animation | +| Content | Products UI | Current request ID, product map, expanded rows, progress, last update | +| Content | Consumption UI | Plan state, per-plan/period trend cache, chart navigation and selected series | +| Content | AI summary | Per-opportunity in-flight state and summary; fresh generation on expansion | +| Content | Authentication/debug | General and scoped token promises, SPA authorizations, request logs, visibility flag | +| Background | Message dispatcher | Synchronously registered listener, asynchronous replies using `return true` | +| Background | Products | Four workers, per-opportunity in-flight map, lazy cache read and serialized writes | +| Background | Cookie/session recovery | Inactive tabs, polling/retry delays, cleanup in `finally` | +| Background | SPA operations | HTTP/JSON parsing, account/plan extraction and debug envelopes | + +Closing the main modal hides it rather than destroying it. Many requests can +continue while it is hidden. Version and request-ID checks suppress stale UI +updates; they do not abort network traffic. No AbortController is currently used. +Some listeners have window guards; there is no general disposal lifecycle. + +## Authentication and API Routing + +Never substitute one token family for another or assume an opaque token is a JWT. +Production tokens are held in memory, not deliberately persisted by the extension. + +| Credential | Acquisition | Consumers | Lifetime/recovery | +| --- | --- | --- | --- | +| XSRF | Background cookie lookup, then content `document.cookie` fallback | `x-xsrf-token` for both tokenrelays | Cookie recovery tab on missing/invalid response; existing retry rules | +| Fusion general | GET `/fscmRestApi/tokenrelay` | Opportunity POST and product GET Bearer | Content promise/cache; no expiry-based refresh equivalent to Sales Common | +| Sales Common | GET `/fscmRestApi/tokenrelay?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F` | AI summary Bearer | In-memory `expires_in`, shared in-flight acquisition; at most one summary retry after 401/403 | +| ACCP-TS | SPA consumer service `uiaasHeader.value` | Account summary authorization | Retrieved with the consumer service response; expiry recorded, not automatically acted on | +| OCICONS-TS | SPA consumer service `uiaasHeader.value` | Account plans and consumption trend authorization | Same service response, distinct credential from ACCP-TS | + +Fusion calls use `https://eeho.fa.us2.oraclecloud.com`; SPA calls use +`https://spa.oracle.com/oalcrm/web/api/`. Preserve `credentials: include` and +operation-specific headers rather than unifying all calls under a default token. + +| Request | Method/path | Important contract | +| --- | --- | --- | +| requestOpportunities | POST `/crmRestApi/searchResources/11.13.18.05/custom-actions/queries` | RecordSet OR plus EffectiveDate AND, original fields, Preference transient, pages of 15 | +| requestOpptyProducts | GET `/crmRestApi/rest/rv:be91c002-2e5d-4ed2-a37e-e0c837bf141f/en/11.13.18.05:9/opportunities/{number}/child/ChildRevenue` | Encoded identifier, original query/fields/order, count/offset pagination, maximum 250 pages | +| requestGenerateAiSummary | POST `/api/sales-common/gen-ai/v1/summarizations/generate` | `{path,promptCode,object,puid}`; prompt `cx.sales.generate_opportunity_summary`; only summaryText displayed | +| requestConsumerTokenService | GET `g2m-consumer-application/consumerTokenService` | No-store; parse JSON; HTML 200 recovery via inactive SPA UI tab; maximum five HTTP attempts | +| requestAccountSummary | POST `provider-proxy/g2m-account/service/accountsummary` | ACCP-TS; `[{key:"regId",value:partyNumber}]` | +| requestAccountPlans | POST `provider-proxy/g2m-consumption/service/v1/account/accountPlans/{id}` | OCICONS-TS; original hierarchy/products/language/planStatus payload | +| requestConsumptionService{Period} | GET `provider-proxy/g2m-consumption/service/v1/plan/trend/{subPlanNum}?quarter=All&timePeriod={period}` | G2m-Authorization; quarterly/monthly/weekly/daily; weekly default | + +SPA parsing removes BOM and the existing anti-JSON-hijacking prefixes before +JSON.parse. Content uses response.json directly. Do not silently unify these behaviors. +POST retry policies require operation-specific review; do not add generic retries. + +## Message Contracts + +All names below have the `opportunitiesExtension.` prefix. Unknown messages return +false without a response. Supported background messages return true immediately +and later call sendResponse. `platform/background-api.js` centralizes the existing +callback/promise differences. `platform/content-messages.js` retains the original +cookie callback path as well as general runtime messaging; no browser behavior +was silently changed during extraction. + +| Message | Input | Result | +| --- | --- | --- | +| getXsrfToken | type | ok, cookieName, token, matchedCookieNames, lookupDetails; error on failure | +| refreshXsrfCookie | type; sender tab context | ok, refreshed, cookieName, durationMs, error | +| requestOpptyProducts | requestId, accessToken, optyNumbers, cacheMaxAgeMs | ok, requestId, completed, total, failed, cacheHits; error envelope on dispatcher failure | +| opptyProductsProgress | Background to sender tab/frame | requestId, state, counters; per-item optyNumber/items/fromCache/cachedAt or error | +| requestConsumerTokenService | type; sender tab context | ok, authorizations.accountSummary/accountPlans, debugRequests | +| requestAccountPlans | partyNumber, authorizations | ok, accountPlanId, plans, debugRequests | +| requestConsumptionTrend | subPlanNum, period, authorization | ok, subPlanNum, period, items, debugRequests | + +SPA failures return `{ok:false,error,debugRequests}`. Progress states include +`running`, `complete` and `complete-with-errors`; content additionally tracks idle +and error states. A new content request ID rejects progress from an older job. +Do not expose a caller-provided arbitrary request URL through the background. + +## Storage Contracts + +| Location/key | Value | +| --- | --- | +| Page localStorage: `opportunities-extension-filter-preferences` | JSON object: period, stages, statuses, customerKeys, ownerKeys, customerSearch, ownerSearch, tableSearch; unset customer/owner selections may be null | +| Page localStorage: `opportunities-extension-theme` | `light` or `dark` | +| Page localStorage: `opportunities-extension-opportunity-type-preferences` | JSON object: opportunityTypeValues, opptyProductsCacheHours | +| Extension storage.local: `opportunitiesExtension.opptyProductsCache.v1` | Object indexed by OptyNumber, each entry `{fetchedAt,items}` | + +Product TTL defaults to 24 hours and is capped at 30 days in background. Storage +failures are tolerated for the active view. Recreating background reloads stored +products but does not restore in-flight jobs. Cache TTL does not physically delete +expired entries. Account/container isolation limitations are tracked separately. + +## UI Contracts + +Preserve labels/IDs/classes, column order, URLs, clipboard behavior, sort/filter +semantics, formatting, theme colors, keyboard shortcuts and row expansion. +WON includes WON_PENDING without rewriting StatusCode. Owner uses first/last name +with the existing fallbacks. Changing reference resets customers/owners to all; +closing/reopening preserves search. Fiscal years begin in June; RENEWALS currently +has no date range and therefore does not load opportunities. + +Preserve SVG chart data normalization, animation, tooltips and series selection. +AI text is inserted with textContent. Debug masking and DOM isolation have known +limitations; see the separate security backlog rather than assuming full redaction. + +## Module Ownership and Dependencies + +`content/main.js` and `background/main.js` only initialize. Each `application.js` +is a composition root: constructs feature-owned state and factories, wires explicit +function dependencies, then exposes `start`. The returned components are available +to isolated tests, not published on the page/global object in production. + +Factories do not import or instantiate peers. Features receive service/auth ports; +services/auth receive transport/platform ports; pure models receive data. The +composition roots supply callback notifications for UI updates and debug events, +preserving existing interactions without module-import cycles. Shared messages +are the only source included in both contexts, with distinct instances/state. +Some stateful UI collaborators intentionally share a feature-owned state object; +these objects are created per application, never stored in a global registry. + +| Original responsibility | Integrated module(s) | Reason for boundary | +| --- | --- | --- | +| content initialization/tile | `content/main.js`, `fusion-page.js` | Page lifecycle and route guard | +| modal/theme/CSS | `content/modal.js`, `theme.js`, `styles.js`, `styles/redwood.css` | DOM shell separate from operations and cascade | +| fiscal dates/query payload | `features/opportunities/fiscal-periods.js`, `services/fusion/opportunities.js` | Pure date rules vs HTTP operation | +| table/search/filters/formatting/dashboard | `features/opportunities/` | Related presentation responsibilities, explicit state | +| saved settings | `features/preferences/storage.js`, `view.js` | Serialization/fallbacks vs dialog | +| token families | `auth/content-session.js`, `sales-common.js`, `token-relay.js`, `spa-credentials.js` | Different credentials and lifetime policies | +| cookie lookup/recovery, SPA session | `auth/xsrf-*.js`, `consumer-session.js` | Privileged recovery separate from UI | +| product queue/cache | `features/products/background-jobs.js`, `cache.js`, `services/fusion/products.js` | Scheduling, persistence and pagination | +| product progress/details | `features/products/content-jobs.js`, `details.js` | Message consumer vs row rendering | +| AI summary | `features/ai-summary/content.js`, `state.js` | Per-opportunity lifecycle, safe text and auth port | +| consumption | `features/consumption/`, `services/spa/` | Plans, chart model/view/controller vs HTTP operations | +| debug | `features/debug/content-log.js`, `content-view.js` | Retention/formatting vs DOM presentation | +| background dispatcher | `background/messages.js`, `shared/messages.js` | Stable serializable message contracts | +| browser APIs | `platform/background-api.js`, `content-messages.js` | Only APIs actually used, not a universal wrapper | + +The background composition deliberately has no DOM dependency. Listener registration +is synchronous, before any storage read or request. Worker restart reconstructs +volatile state and lazily reads the existing product cache; tokens are not persisted. +Content effects (DOM, fetch, storage, animation) enter through the environment at +the composition root. localStorage access stays lazy to retain blocked-storage +fallbacks. Tests may inject effects or individual callback ports without changing +source strings. Time-sensitive modules are tested with isolated clocks. + +## Adding a Feature + +1. Define its message/API/storage contract and synthetic expected behavior first. +2. Add a factory under the owning feature, or a service under the correct host. + Accept narrow function dependencies; do not call another factory inside it. +3. Add its source path to the appropriate array in `scripts/sources.cjs`. Source + inclusion and duplicate factory checks run automatically. +4. Construct it in that context's `application.js`, supplying auth, platform and + render callbacks explicitly. Extend `background/messages.js` only if crossing + contexts is necessary; do not expose arbitrary destinations. +5. Add a `tests/*.test.cjs` suite using `tests/support/application.cjs` for composed + integration or `vm.Script` for an individual pure factory. Use only synthetic + data and fail unexpected network requests. +6. Run tests, build and `--check`; inspect the local fixture and load both packages + in the target browsers. Record any browser not available for verification. + +As a concrete example, adding a new consumption period should first define its +request/data/authorization contract and synthetic tests, then extend the period +allowlist and normalization/presentation through the existing trend message. It +must not add a URL-proxy message or reuse the general Fusion token. These edits +belong in the consumption feature and SPA service, not the bootstrap. + +## Source vs Generated Code + +Distribution `content.js` and `background.js` are generated bundles and may remain +large. No copy/export/execute JavaScript generator feature was found in the source. +AI summaries are text, not executable JavaScript. No empty generators subsystem +was added. Build serialization uses JSON.stringify for CSS, retains quotes, +backslashes, Unicode and line breaks, and is syntax-checked by vm.Script. Visual +fixtures now load composed scripts normally; they no longer eval rewritten sources. diff --git a/docs/browser-smoke.md b/docs/browser-smoke.md new file mode 100644 index 0000000..15e1c4a --- /dev/null +++ b/docs/browser-smoke.md @@ -0,0 +1,63 @@ +# Native Browser Validation + +This checklist complements the synthetic tests; it is not evidence of a native +pass until executed and recorded separately for each browser. + +## Preconditions + +- Use an authorized test profile/account. Do not delete cookies, sign out, change + account security settings or inspect credential values to force failure cases. +- Connect Chrome/Edge through Settings > Computer use for assisted browser tests. + Firefox needs a separate supported/manual test session. +- Run `node scripts/build.mjs --check` and `node scripts/test.cjs` first. +- Load `dist/chromium` using Load unpacked in Chrome/Edge, or load + `dist/firefox/manifest.json` as a temporary Firefox add-on. Verify the source + directory and version 1.0.1; avoid testing a previously installed stale copy. +- Keep debug secrets hidden. Record only status codes, request names, durations + and sanitized errors. Do not export real tokens, response bodies or account data. +- AI generation and tests requiring a deliberately expired session must use an + explicitly authorized test environment. Do not trigger live writes solely to + complete this checklist. + +## Checklist + +| Check | Expected result | +| --- | --- | +| Package load | No manifest/background/content syntax errors; no additional permissions | +| Both Fusion welcome routes | Exactly one tile before the add-icon item; no tile on unrelated pages | +| Modal open/close/reopen | Same search value and matching results; Esc closes the correct dialog | +| Filters and sorting | Correct customer/owner/stage/status/text combinations; WON includes WON_PENDING | +| Reference change | Customer and owner reset to all; stale responses do not overwrite new results | +| Preferences | Save/reopen retains selected record sets and cache duration; restore original settings afterward | +| Products | Progress completes; badges/counts and expanded rows agree; warm cache avoids redundant product fetches | +| AI in authorized test environment | Summary appears without replacing products; concurrent expansions share scoped-token acquisition; reopen uses valid token | +| Consumption | Dialog opens promptly, weekly chart by default; plan/period changes and series filters work | +| Themes and layout | Light/dark controls work; check long names, WON_PENDING and full update timestamps | +| Debug | Expected operation names/statuses; sensitive values remain hidden | +| Background restart | Close its inspector, allow suspension, then trigger a message; listener responds and stored product cache is reloaded | +| Multiple tabs | Progress remains scoped to requesting frame/job; no duplicate UI/listeners from normal navigation | +| Firefox containers | Test only in authorized containers; record current cookie/cache isolation limitations without silently changing behavior | +| Natural session expiry | When encountered, record recovery outcome and temporary-tab cleanup without revealing cookies or forcing sign-out | + +Background interruption does not currently resume in-flight jobs. Do not mistake +that known limitation for a new regression or claim a mocked restart proves native +worker suspension. Any differences must be compared with the pre-refactor behavior. + +## Execution Record + +2026-09-17 preflight: + +| Target | Discovery | Native test result | +| --- | --- | --- | +| Chrome | Executable version 153.0.8010.37; automation not connected | Blocked pending connection | +| Edge | Executable version 153.0.4234.32; automation not connected | Blocked pending connection | +| Firefox | No executable found at the checked standard Program Files path; installation not confirmed | Not run | + +The running-process diagnostic was denied by the environment, so browser running +state was not established. Connected targets were Brave and the in-app browser; +neither was substituted for this native matrix. The user chose to connect +Chrome/Edge. All five local suites and the output consistency check passed again. +No real Fusion/SPA calls, runtime changes, release or commit were made in preflight. + +For each completed run, append date, browser/version, package path, checklist +outcomes, sanitized errors and remaining gaps. Never mark unexecuted checks passed. diff --git a/docs/refactoring.md b/docs/refactoring.md new file mode 100644 index 0000000..aec80f5 --- /dev/null +++ b/docs/refactoring.md @@ -0,0 +1,184 @@ +# Incremental Refactoring Record + +## Stage 1: Characterization Baseline + +Stage 1 initially added tests and documentation only, before any runtime extraction. +The following records that historical baseline. The completed integration and +remaining verification gaps are recorded under "Completed Extraction Stages" below. + +### Coverage Added + +`tests/characterization.test.cjs` contains 22 test cases using Node's built-in +test runner, assert and vm. `tests/support/characterization.cjs` provides isolated +fixtures: no real browser cookies, account identifiers, tokens or network access. +Unexpected fetches reject rather than falling through to global fetch. + +- Exact current manifests and source/output equality, with classic-script syntax validation. +- Disallowed-origin initialization and reinjection when the tile already exists. +- Fiscal boundaries, leap years, rolling quarters and unsupported RENEWALS. +- Full opportunity query payload, pagination offsets/counts and stale request versions. +- Filter storage round-trip, WON_PENDING, search, numeric sort, reference reset and malformed preferences. +- Missing-cookie tokenrelay recovery and existing 400/401/403 retry limit. +- Chrome callback and Firefox promise mocks for background dispatch, storage, cookies and tabs. +- Product pagination, default/custom TTL, concurrency limit, in-flight sharing and partial errors. +- Persisted product reads after recreating the background context; storage failures and stalled pages. +- XSRF discovery, recovery timeout, successful recovery and temporary-tab cleanup. +- SPA credential selection, account payloads, encoded identifiers and debug envelopes. +- Invalid messages, network errors, HTTP 401 and HTTP 200 invalid JSON in consumption. + +Existing suites remain intact: consumption normalization/requests, consumer-session +recovery, AI summary rendering/state, scoped token reuse/expiry and bounded retry. +Tests for the current security defects are deliberately not encoded as acceptable +behavior. They belong to separately approved fixes. + +### Former Temporary Test Seam + +The original content IIFE had no exports, so baseline tests initially exposed +functions before initialization with a guarded source marker. That temporary +mechanism is now removed. Tests use the actual application factories through +`tests/support/application.cjs`, without replacing source strings. Background +integration tests still execute the production listener and asynchronous replies. + +### Running Checks + +```sh +npm test +npm run check +``` + +Without npm, use the Node commands in package.json individually. The new suite is: + +```sh +node tests/characterization.test.cjs +``` + +At baseline, `build.mjs --check` did not compare output contents. It now checks +both generated manifests and both scripts byte-for-byte against composition. +Characterization tests retain the exact manifest expectations. + +Stage-1 verification on Node.js v24.19.0: + +| Command | Result | +| --- | --- | +| `node --check src/content.js` and `node --check src/background.js` | Passed | +| `node --check tests/support/characterization.cjs` | Passed | +| `node tests/consumptionTrend.test.cjs` | Passed | +| `node tests/consumerTokenSessionRetry.test.cjs` | Passed | +| `node tests/aiSummary.test.cjs` | Passed | +| `node tests/characterization.test.cjs` | 22 passed, 0 failed | +| `node scripts/build.mjs --check` | Passed; distribution equivalence also checked by the suite | +| `git diff --check` | Passed | + +The individual commands from the npm scripts were executed directly. No build +regeneration was needed because runtime sources and the build were unchanged; +`git diff -- src dist scripts/build.mjs` was empty. + +### Verification Limits + +- API mocks are not proof of Chrome/Edge/Firefox compatibility or real authentication. +- Recreating a VM tests cache rehydration, not actual service-worker suspension or job recovery. +- DOM mocks do not prove layout, focus, accessibility or SPA lifecycle correctness. +- The original visual fixture used eval. The completed extraction removed it and + serves the composed factory through a normal local script instead. +- No installed-browser smoke test or live Fusion request was performed in stage 1. +- No JavaScript export/generator feature was located. Do not create a generators + directory until its actual source and standalone-output contracts are identified. + +## Separate Security and Reliability Backlog + +These are preexisting observations, not authorized functional changes in this refactor. + +| Issue | Evidence/impact | Separate change to evaluate | +| --- | --- | --- | +| Debug redaction after truncation | Invalid/truncated JSON is returned unmasked by formatDebugBody; reproduced with a synthetic token | Redact before truncation, fail closed on malformed bodies, test all response shapes | +| Debug secrets and shared DOM | Raw headers/bodies are retained for the reveal control; revealed text is in page DOM | Review retention, safe diagnostic schema and whether revealing credentials remains acceptable | +| Cookie store selection | Lookup scans all stores instead of binding to sender account/container | Scope selection to the requesting context and test multi-account behavior | +| Product cache isolation | Cache and in-flight keys use OptyNumber only | Design identity partitioning and explicit cache migration/retention policy | +| Privileged message validation | No explicit sender identity/origin check; supplied header names are accepted | Validate senders/messages and allowlist headers without introducing an arbitrary proxy | +| Session tab retry | Opening failures retry indefinitely; interruption can strand work | Decide cancellation/limits and worker lifecycle recovery explicitly | +| DOM lifecycle | Observer can remain active; guards do not cover every reinjection/resize path | Define ownership/disposal and characterize navigation before changing behavior | +| Token expiry | General/SPA tokens have policies different from Sales Common | Review independently; do not unify policies as part of extraction | + +Never include real secrets in repro cases, snapshots, documentation or test output. +Do not add persistent token storage to compensate for worker termination. + +## Completed Extraction Stages + +All modules listed below are integrated through `scripts/sources.cjs` and the +two composition roots. Former `src/content.js` and `src/background.js` were removed; +their names remain only for the generated distribution scripts. No version change, +commit, publication, runtime dependency, permission expansion or CSP change occurred. + +| Stage/responsibility | Files/boundaries | Preserved contracts and evidence | Remaining risk/next safe step | +| --- | --- | --- | --- | +| Build and pure logic | `scripts/compose.cjs`, `sources.cjs`; fiscal-periods, formatters, consumption model | Classic IIFEs, exact manifests, dates, payload/normalization tests; syntax checked per source and composed bundle | Native browser package loading still needs a smoke test | +| Platform and dispatch | `platform/`, `background/main.js`, `messages.js`, `shared/messages.js` | Sync listener registration, callback/promise mocks, async responses, sender-frame progress and unknown-message behavior | Sender validation is a separate security change | +| Auth and HTTP operations | `auth/`, `services/` | Credential selection, scoped-token reuse/expiry, 401/403 retry, missing-cookie and HTML-session recovery, request/debug envelopes | Real SSO and worker suspension not simulated completely | +| Products | `features/products/` | Four-worker queue, pagination, TTL, cache restore, in-flight sharing, partial errors and expansion behavior | Existing identity/cache-retention policy unchanged | +| Content features | opportunities, preferences, AI-summary, consumption and debug factories/state | Filter/search storage round-trip, sorting, stale requests, AI safe text, plan/period controls, preserved labels and DOM | Existing DOM lifecycle and narrow-screen limitations remain | +| Bootstrap, CSS and documentation | `content/main.js`, `background/main.js`, application roots, styles; README/docs | CSS byte-identical to original (hash test); factories connected, no orphan sources or peer-factory dependencies; old marker/eval fixture removed | Native browser matrix below remains pending | + +The automated suites were run after the initial extraction and again after the +platform/auth refinements, cleanup and final composition. Intermediate verification +found and corrected test-harness expectations for source inventory and fixture +callback messaging; no production auth, cookie or session policy was changed. + +### Final Automated Verification + +Executed with Node.js 24.19.0, directly through the package-script equivalents: + +| Command | Result | +| --- | --- | +| `node scripts/build.mjs` | Both packages regenerated; all source/composed JavaScript syntax valid | +| `node scripts/test.cjs` | All five suite files passed: AI, architecture (6 cases), characterization (22 cases), session recovery, consumption | +| `node scripts/build.mjs --check` | Generated scripts/manifests match source composition | +| `git diff --check` | No whitespace errors | + +There is no configured linter. Syntax validation uses Node's JavaScript parser +through vm.Script, not eval or new Function in the extension. No generators feature +was located; standalone JavaScript generation therefore has no feature-specific +snapshot to assert. The architectural suite verifies deterministic composition, +source coverage, dependency injection, independent per-frame state and lazy +localStorage failure handling. Feature suites use synthetic credentials only. + +### UI Smoke Verification + +The loopback fixture was exercised in the Codex in-app browser, using the same +composed content factories with injected synthetic runtime/fetch/storage adapters. +It is not an installed extension and does not validate privileged browser APIs. + +- Paginated synthetic opportunities, product progress/counts and dashboard rendered. +- Search text/filter remained after closing and reopening the modal. +- Light/dark toggle and product expansion retained the existing presentation. +- AI text, including literal HTML delimiters, appeared as text above the product table. +- Preferences retained checkbox/cache-duration selections after Save and reopen. +- Cloud Consumption opened with plan tabs; weekly default, daily switch, alternate + plan and series visibility toggles worked. Debug listed scoped relay and AI calls. +- Screenshots inspected at 1920x1080, 1280x720 and 390x844. Desktop columns rendered + with complete Last Update Date and WON_PENDING. Existing compact-table scrolling + and clipping at 1280 remain unchanged; mobile controls stack. This is not a full + mobile workflow/accessibility audit and no layout redesign was included. +- No console errors were reported during the exercised flows. + +### Outstanding Native Browser Matrix + +| Target | Package/runtime | Status | +| --- | --- | --- | +| Chrome | `dist/chromium`, MV3 service worker | Not run as an installed extension | +| Edge | `dist/chromium`, MV3 service worker | Not run as an installed extension | +| Firefox | `dist/firefox`, MV3 background scripts | Not run as an installed extension | + +The available automation connection exposed the in-app browser and a Brave +profile, not Chrome, Edge or Firefox. No live Fusion requests, user-session access +or extension installation were used to substitute for the missing native tests. +Actual suspension/reactivation, container routing and SSO remain unverified. + +The next safe validation is to load the generated packages in an authorized test +profile in each target browser, then exercise tile insertion, modal reopen, +filters/preferences, products, AI, consumption, debug and background restart. +Resolve the separately listed security issues only in explicitly approved changes. + +See [the native browser checklist and execution record](browser-smoke.md) for the +2026-09-17 follow-up. Local tests and output checks passed again; Chrome/Edge were +installed but not connected to automation. Native verification remains pending +while the user connects those browsers. diff --git a/package.json b/package.json index 32310ee..9fbe67e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "build": "node scripts/build.mjs", - "check": "node --check src/content.js && node --check src/background.js && node tests/consumptionTrend.test.cjs && node tests/consumerTokenSessionRetry.test.cjs && node tests/aiSummary.test.cjs && node scripts/build.mjs --check", - "test": "node tests/consumptionTrend.test.cjs && node tests/consumerTokenSessionRetry.test.cjs && node tests/aiSummary.test.cjs" + "check": "node scripts/build.mjs --check && node scripts/test.cjs", + "test": "node scripts/test.cjs", + "preview": "node scripts/preview.mjs" } } diff --git a/scripts/build.mjs b/scripts/build.mjs index 85e12e1..ee2493d 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -1,12 +1,12 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import composition from "./compose.cjs"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const srcDir = path.join(rootDir, "src"); const distDir = path.join(rootDir, "dist"); -const contentScript = await readFile(path.join(srcDir, "content.js"), "utf8"); -const backgroundScript = await readFile(path.join(srcDir, "background.js"), "utf8"); +const contentScript = composition.composeScript("content"); +const backgroundScript = composition.composeScript("background"); const checkOnly = process.argv.includes("--check"); const baseManifest = { @@ -70,6 +70,11 @@ for (const target of targets) { JSON.parse(manifestJson); if (checkOnly) { + const expected = { "manifest.json": manifestJson, "content.js": contentScript, "background.js": backgroundScript }; + for (const [file, contents] of Object.entries(expected)) { + const actual = await readFile(path.join(distDir, target.name, file), "utf8"); + if (actual !== contents) throw new Error(`Stale output: dist/${target.name}/${file}. Run npm run build.`); + } continue; } diff --git a/scripts/compose.cjs b/scripts/compose.cjs new file mode 100644 index 0000000..233a2f2 --- /dev/null +++ b/scripts/compose.cjs @@ -0,0 +1,29 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); +const sources = require("./sources.cjs"); + +const root = path.resolve(__dirname, ".."); + +/** Compose classic scripts at build time; never load or evaluate code at runtime. */ +function composeScript(target, { bootstrap = true } = {}) { + const files = sources[target]; + if (!files) throw new Error(`Unknown script target: ${target}`); + if (new Set(files).size !== files.length) throw new Error(`Duplicate source in ${target}`); + const parts = files.map(file => { + const source = fs.readFileSync(path.join(root, file), "utf8"); + new vm.Script(source, { filename: file }); + return `// Source: ${file}\n${source}`; + }); + if (target === "content") { + const css = fs.readFileSync(path.join(root, "src/content/styles/redwood.css"), "utf8"); + parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`); + } + const factory = target === "content" ? "createContentApplication" : "createBackgroundApplication"; + const ending = bootstrap ? `${factory}().start();` : `return ${factory};`; + const output = `(function () {\n "use strict";\n${parts.join("\n")}\n${ending}\n})();\n`; + new vm.Script(output, { filename: `${target}.js` }); + return output; +} + +module.exports = { composeScript, sources }; diff --git a/scripts/preview.mjs b/scripts/preview.mjs new file mode 100644 index 0000000..165ef79 --- /dev/null +++ b/scripts/preview.mjs @@ -0,0 +1,38 @@ +import http from "node:http"; +import { readFile } from "node:fs/promises"; +import composition from "./compose.cjs"; + +// Development-only routes. Never expose the workspace or forward API requests. +const routes = new Map([ + ["/", ["../tests/fixtures/extension.html", "text/html"]], + ["/extension.js", ["../tests/fixtures/extension.js", "text/javascript"]], + ["/consumption-trend.html", ["../tests/fixtures/consumption-trend.html", "text/html"]] +]); +const server = http.createServer(async (request, response) => { + const pathname = new URL(request.url, "http://127.0.0.1").pathname; + response.setHeader("Cache-Control", "no-store"); + response.setHeader("X-Content-Type-Options", "nosniff"); + if (request.method !== "GET") { + response.writeHead(405).end(); + return; + } + try { + if (pathname === "/fixture-runtime.js") { + response.setHeader("Content-Type", "text/javascript"); + response.end(`window.createFixtureApplication = ${composition.composeScript("content", { bootstrap: false })}`); + return; + } + const route = routes.get(pathname); + if (!route) { + response.writeHead(404).end(); + return; + } + response.setHeader("Content-Type", `${route[1]}; charset=utf-8`); + response.end(await readFile(new URL(route[0], import.meta.url))); + } catch { + response.writeHead(500).end("Fixture could not be loaded."); + } +}); +server.listen(Number(process.env.PORT) || 0, "127.0.0.1", () => { + console.log(`Synthetic extension preview: http://127.0.0.1:${server.address().port}/`); +}); diff --git a/scripts/sources.cjs b/scripts/sources.cjs new file mode 100644 index 0000000..d172b2f --- /dev/null +++ b/scripts/sources.cjs @@ -0,0 +1,72 @@ +module.exports = { + "content": [ + "src/shared/messages.js", + "src/content/contracts.js", + "src/auth/session-state.js", + "src/auth/sales-common-state.js", + "src/features/opportunities/query-state.js", + "src/features/opportunities/table-state.js", + "src/features/opportunities/filter-state.js", + "src/features/preferences/state.js", + "src/features/products/content-state.js", + "src/features/ai-summary/state.js", + "src/features/consumption/plans-state.js", + "src/features/consumption/trend-state.js", + "src/features/opportunities/dashboard-state.js", + "src/features/debug/state.js", + "src/features/opportunities/columns.js", + "src/content/fusion-page.js", + "src/content/modal.js", + "src/content/theme.js", + "src/content/icons.js", + "src/content/svg.js", + "src/content/styles.js", + "src/auth/content-session.js", + "src/auth/sales-common.js", + "src/auth/token-relay.js", + "src/auth/xsrf-content.js", + "src/platform/content-messages.js", + "src/features/opportunities/controller.js", + "src/services/fusion/opportunities.js", + "src/features/opportunities/fiscal-periods.js", + "src/features/opportunities/table.js", + "src/features/opportunities/dashboard.js", + "src/features/opportunities/filters.js", + "src/features/opportunities/formatters.js", + "src/features/opportunities/links.js", + "src/features/preferences/storage.js", + "src/features/preferences/view.js", + "src/features/products/content-jobs.js", + "src/features/products/details.js", + "src/features/ai-summary/content.js", + "src/features/consumption/plans-action.js", + "src/features/consumption/plans-view.js", + "src/features/consumption/trend-controller.js", + "src/features/consumption/chart.js", + "src/features/consumption/model.js", + "src/features/debug/content-log.js", + "src/features/debug/content-view.js", + "src/content/main.js", + "src/content/application.js" + ], + "background": [ + "src/shared/messages.js", + "src/background/contracts.js", + "src/features/products/cache-state.js", + "src/auth/consumer-session.js", + "src/auth/spa-credentials.js", + "src/services/spa/accounts.js", + "src/services/spa/consumption.js", + "src/services/spa/response-shapes.js", + "src/services/http/background-json.js", + "src/features/products/background-jobs.js", + "src/features/products/cache.js", + "src/services/fusion/products.js", + "src/auth/xsrf-recovery.js", + "src/auth/xsrf-cookies.js", + "src/platform/background-api.js", + "src/background/main.js", + "src/background/messages.js", + "src/background/application.js" + ] +}; diff --git a/scripts/test.cjs b/scripts/test.cjs new file mode 100644 index 0000000..90d9aa6 --- /dev/null +++ b/scripts/test.cjs @@ -0,0 +1,13 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const root = path.resolve(__dirname, ".."); +const files = fs.readdirSync(path.join(root, "tests")).filter(name => name.endsWith(".test.cjs")).sort(); +let failed = false; +for (const file of files) { + const result = spawnSync(process.execPath, [path.join(root, "tests", file)], { cwd: root, stdio: "inherit" }); + if (result.error) console.error(result.error.message); + if (result.status !== 0) failed = true; +} +process.exitCode = failed ? 1 : 0; diff --git a/src/auth/consumer-session.js b/src/auth/consumer-session.js new file mode 100644 index 0000000..b9df814 --- /dev/null +++ b/src/auth/consumer-session.js @@ -0,0 +1,222 @@ +function createAuthConsumerSession({ + CUSTOMER_TOKEN_MAX_ATTEMPTS, + CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS, + CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS, + CUSTOMER_TOKEN_SERVICE_URL, + CUSTOMER_TOKEN_SESSION_URL, + extractTokenServiceAuthorization, + fetchJson, + supportsFirefoxCookieStoreTabs, + tabsCreate, + tabsRemove, + wait +}) { + async function requestConsumerTokenService(sender) { + const debugRequests = []; + + try { + const tokenResponse = await fetchConsumerTokenServiceUntilJson(sender, debugRequests); + const accountSummaryAuthorization = extractTokenServiceAuthorization( + tokenResponse, + "ACCP-TS", + "account-ti-authorization" + ); + const accountPlansAuthorization = extractTokenServiceAuthorization( + tokenResponse, + "OCICONS-TS", + "g2m-authorization" + ); + const tokenRequestDebugEntry = debugRequests[debugRequests.length - 1]; + + if (tokenRequestDebugEntry) { + tokenRequestDebugEntry.metadata = { + ...tokenRequestDebugEntry.metadata, + accountSummaryTokenService: "ACCP-TS", + accountSummaryAuthorizationHeader: accountSummaryAuthorization.name || "(missing)", + accountSummaryAuthorizationValue: accountSummaryAuthorization.value + ? `(present, ${accountSummaryAuthorization.value.length} characters)` + : "(missing)", + accountSummaryAuthorizationExpires: accountSummaryAuthorization.expires || "(not provided)", + accountPlansTokenService: "OCICONS-TS", + accountPlansAuthorizationHeader: accountPlansAuthorization.name || "(missing)", + accountPlansAuthorizationValue: accountPlansAuthorization.value + ? `(present, ${accountPlansAuthorization.value.length} characters)` + : "(missing)", + accountPlansAuthorizationExpires: accountPlansAuthorization.expires || "(not provided)" + }; + } + + if (!accountSummaryAuthorization.value) { + throw new Error("ACCP-TS uiaasHeader.value was not found in requestConsumerTokenService."); + } + + if (!accountPlansAuthorization.value) { + throw new Error("OCICONS-TS uiaasHeader.value was not found in requestConsumerTokenService."); + } + + return { + ok: true, + authorizations: { + accountSummary: accountSummaryAuthorization, + accountPlans: accountPlansAuthorization + }, + debugRequests + }; + } catch (error) { + error.debugRequests = debugRequests; + throw error; + } + } + + async function fetchConsumerTokenServiceUntilJson(sender, debugRequests) { + let attempt = 0; + let sessionTab = null; + let receivedValidJson = false; + + try { + while (attempt < CUSTOMER_TOKEN_MAX_ATTEMPTS) { + attempt += 1; + + try { + const tokenResponse = await fetchJson(CUSTOMER_TOKEN_SERVICE_URL, { + method: "GET", + credentials: "include", + cache: "no-store", + headers: { + Accept: "application/json", + "Cache-Control": "no-cache, no-store, max-age=0", + Pragma: "no-cache" + } + }, "requestConsumerTokenService", debugRequests); + const debugEntry = debugRequests[debugRequests.length - 1]; + + if (debugEntry) { + debugEntry.metadata.attempt = String(attempt); + } + + receivedValidJson = true; + return tokenResponse; + } catch (error) { + const isHtmlFallback = error + && error.code === "INVALID_JSON_RESPONSE" + && error.httpStatus === 200; + const debugEntry = debugRequests[debugRequests.length - 1]; + + if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) { + if (debugEntry) { + debugEntry.metadata.attempt = String(attempt); + if (isHtmlFallback) { + debugEntry.metadata.retryLimitReached = "true"; + debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`; + error.message = debugEntry.error; + } + } + + throw error; + } + + if (!sessionTab) { + try { + sessionTab = await openConsumerTokenSessionTabWithRetry(sender, debugEntry); + if (debugEntry) { + debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL; + debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab"; + } + } catch (sessionError) { + if (debugEntry) { + debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL; + debugEntry.metadata.sessionBootstrapState = `tab-open-failed: ${sessionError.message || "unknown error"}`; + } + } + } else if (debugEntry) { + debugEntry.metadata.sessionBootstrapState = "waiting-for-valid-session"; + } + + const retryDelayMs = getConsumerTokenRetryDelay(attempt); + if (debugEntry) { + debugEntry.metadata.attempt = String(attempt); + debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON"; + debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs); + } + + await wait(retryDelayMs); + } + } + + throw new Error("requestConsumerTokenService retry loop ended unexpectedly."); + } finally { + if (sessionTab && Number.isInteger(sessionTab.id)) { + const debugEntry = debugRequests[debugRequests.length - 1]; + + try { + await tabsRemove(sessionTab.id); + if (debugEntry) { + debugEntry.metadata.sessionBootstrapState = receivedValidJson + ? "closed-after-valid-json" + : "closed-after-retry-finished"; + } + } catch (error) { + if (debugEntry) { + debugEntry.metadata.sessionBootstrapState = `tab-close-failed: ${error.message || "unknown error"}`; + } + } + } + } + } + + function getConsumerTokenRetryDelay(attempt) { + return Math.min( + CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS * (2 ** Math.max(attempt - 1, 0)), + CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS + ); + } + + async function openConsumerTokenSessionTabWithRetry(sender, debugEntry) { + let attempt = 0; + + // Opening failures must not consume the token service's five attempts. + while (true) { + attempt += 1; + if (debugEntry) { + debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL; + debugEntry.metadata.sessionTabOpenAttempt = String(attempt); + } + + try { + const tab = await openConsumerTokenSessionTab(sender); + if (debugEntry) { + debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab"; + delete debugEntry.metadata.sessionTabNextRetryDelayMs; + } + return tab; + } catch (error) { + const retryDelayMs = getConsumerTokenRetryDelay(Math.min(attempt, 5)); + if (debugEntry) { + debugEntry.metadata.sessionBootstrapState = "retrying-tab-open"; + debugEntry.metadata.sessionTabLastOpenError = error.message || "unknown error"; + debugEntry.metadata.sessionTabNextRetryDelayMs = String(retryDelayMs); + } + await wait(retryDelayMs); + } + } + } + + function openConsumerTokenSessionTab(sender) { + const details = { + url: CUSTOMER_TOKEN_SESSION_URL, + active: false + }; + + if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) { + details.windowId = sender.tab.windowId; + } + + if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) { + details.cookieStoreId = sender.tab.cookieStoreId; + } + + return tabsCreate(details); + } + + return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab }; +} diff --git a/src/auth/content-session.js b/src/auth/content-session.js new file mode 100644 index 0000000..e4f0111 --- /dev/null +++ b/src/auth/content-session.js @@ -0,0 +1,57 @@ +function createAuthContentSession({ + messageTypes, + AUTH_STATUS, + appendBackgroundRequestLog, + authSessionState, + requestTokenRelay, + sendRuntimeMessage, + updateAuthBadge +}) { + function requestTokenRelayOnce() { + if (!authSessionState.tokenRelayRequest) { + authSessionState.authStatus = AUTH_STATUS.pending; + updateAuthBadge(authSessionState.authStatus); + authSessionState.tokenRelayRequest = requestTokenRelay() + .then((token) => { + authSessionState.accessToken = token; + authSessionState.authStatus = AUTH_STATUS.authenticated; + requestConsumerTokenServiceOnce().catch(() => {}); + return token; + }) + .catch((error) => { + authSessionState.accessToken = ""; + authSessionState.authStatus = AUTH_STATUS.unauthenticated; + throw error; + }) + .finally(() => { + updateAuthBadge(authSessionState.authStatus); + }); + } else { + updateAuthBadge(authSessionState.authStatus); + } + + return authSessionState.tokenRelayRequest; + } + + function requestConsumerTokenServiceOnce() { + if (!authSessionState.consumerTokenServiceRequest) { + authSessionState.consumerTokenServiceRequest = sendRuntimeMessage({ + type: messageTypes.requestConsumerTokenService + }).then((response) => { + appendBackgroundRequestLog(response && response.debugRequests); + + if (!response || !response.ok) { + throw new Error(response && response.error + ? response.error + : "Unable to load consumer token service."); + } + + return response.authorizations; + }); + } + + return authSessionState.consumerTokenServiceRequest; + } + + return { requestTokenRelayOnce, requestConsumerTokenServiceOnce }; +} diff --git a/src/auth/sales-common-state.js b/src/auth/sales-common-state.js new file mode 100644 index 0000000..c6551e5 --- /dev/null +++ b/src/auth/sales-common-state.js @@ -0,0 +1,7 @@ +/** @returns {{salesCommonToken: null | {access_token: string, expiresAt: number}, salesCommonTokenRequest: null | Promise}} */ +function createAuthSalesCommonState() { + return { + salesCommonToken: null, + salesCommonTokenRequest: null + }; +} diff --git a/src/auth/sales-common.js b/src/auth/sales-common.js new file mode 100644 index 0000000..721f15d --- /dev/null +++ b/src/auth/sales-common.js @@ -0,0 +1,33 @@ +function createAuthSalesCommon({ + TOKEN_RELAY_URL, + authSalesCommonState, + requestTokenRelay +}) { + function requestTokenRelaySalesCommon() { + if (authSalesCommonState.salesCommonToken && Date.now() < authSalesCommonState.salesCommonToken.expiresAt) { + return Promise.resolve(authSalesCommonState.salesCommonToken.access_token); + } + if (authSalesCommonState.salesCommonTokenRequest) { + return authSalesCommonState.salesCommonTokenRequest; + } + authSalesCommonState.salesCommonTokenRequest = requestTokenRelay({ + name: "requestTokenRelaySalesCommon", + url: `${TOKEN_RELAY_URL}?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F`, + includeExpiry: true + }).then((token) => { + authSalesCommonState.salesCommonToken = token; + return token.access_token; + }).finally(() => { + authSalesCommonState.salesCommonTokenRequest = null; + }); + return authSalesCommonState.salesCommonTokenRequest; + } + + function invalidateSalesCommonToken(rejectedToken) { + if (authSalesCommonState.salesCommonToken && authSalesCommonState.salesCommonToken.access_token === rejectedToken) { + authSalesCommonState.salesCommonToken = null; + } + } + + return { requestTokenRelaySalesCommon, invalidateSalesCommonToken }; +} diff --git a/src/auth/session-state.js b/src/auth/session-state.js new file mode 100644 index 0000000..ec4c862 --- /dev/null +++ b/src/auth/session-state.js @@ -0,0 +1,8 @@ +function createAuthSessionState({ AUTH_STATUS } = {}) { + return { + tokenRelayRequest: null, + consumerTokenServiceRequest: null, + accessToken: "", + authStatus: AUTH_STATUS.idle + }; +} diff --git a/src/auth/spa-credentials.js b/src/auth/spa-credentials.js new file mode 100644 index 0000000..32dec52 --- /dev/null +++ b/src/auth/spa-credentials.js @@ -0,0 +1,40 @@ +function createAuthSpaCredentials({ + findNestedObject +}) { + function normalizeMessageAuthorization(authorization, fallbackHeaderName) { + const headerName = authorization && typeof authorization.name === "string" + ? authorization.name.trim() + : ""; + const headerValue = authorization && typeof authorization.value === "string" + ? authorization.value.trim() + : ""; + + return { + name: headerName || fallbackHeaderName, + value: headerValue + }; + } + + function extractTokenServiceAuthorization(response, tokenServiceName, fallbackHeaderName) { + const tokenService = Array.isArray(response) + ? response.find((entry) => entry && entry.tokenService === tokenServiceName) + : findNestedObject(response, (entry) => entry.tokenService === tokenServiceName); + const authorizationHeader = tokenService && tokenService.uiaasHeader; + const headerName = authorizationHeader && typeof authorizationHeader.label === "string" + ? authorizationHeader.label.trim() + : ""; + const headerValue = authorizationHeader && typeof authorizationHeader.value === "string" + ? authorizationHeader.value.trim() + : ""; + + return { + name: headerName || fallbackHeaderName, + value: headerValue, + expires: authorizationHeader && authorizationHeader.expires + ? String(authorizationHeader.expires) + : "" + }; + } + + return { normalizeMessageAuthorization, extractTokenServiceAuthorization }; +} diff --git a/src/auth/token-relay.js b/src/auth/token-relay.js new file mode 100644 index 0000000..09e02c6 --- /dev/null +++ b/src/auth/token-relay.js @@ -0,0 +1,153 @@ +function createAuthTokenRelay({ + TOKEN_RELAY_URL, + createDebugBody, + getXsrfToken, + logRequestFailure, + logRequestStart, + logRequestSuccess, + maskToken, + readResponseBodyForDebug, + refreshXsrfCookie, + renderDebugPanel, + environment +}) { + const { fetch } = environment; + async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) { + let refreshResult = null; + let refreshAttempted = false; + + while (true) { + const xsrfTokenSource = await getXsrfToken(); + + if (!xsrfTokenSource.token) { + if (refreshAttempted) { + const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url); + logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh."); + throw new Error("XSRF token cookie not found after automatic refresh."); + } + + refreshResult = await refreshXsrfCookie("missing-cookie"); + refreshAttempted = true; + continue; + } + + const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url); + + try { + const response = await fetch(url, { + method: "GET", + credentials: "include", + headers: { + "x-xsrf-token": xsrfTokenSource.token + } + }); + + const responseBody = await readResponseBodyForDebug(response); + + if (!response.ok) { + logRequestSuccess(requestEntry, response, responseBody); + + if (!refreshAttempted && isInvalidXsrfStatus(response.status)) { + refreshResult = await refreshXsrfCookie(`tokenrelay-${response.status}`); + refreshAttempted = true; + requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult); + renderDebugPanel(); + continue; + } + + throw new Error(`Token relay failed with status ${response.status}.`); + } + + let responseData; + + try { + responseData = await response.json(); + } catch (error) { + logRequestSuccess(requestEntry, response, responseBody); + + if (!refreshAttempted) { + refreshResult = await refreshXsrfCookie("tokenrelay-invalid-response"); + refreshAttempted = true; + requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult); + renderDebugPanel(); + continue; + } + + throw error; + } + + if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) { + logRequestSuccess(requestEntry, response, responseBody); + + if (!refreshAttempted) { + refreshResult = await refreshXsrfCookie("tokenrelay-missing-access-token"); + refreshAttempted = true; + requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult); + renderDebugPanel(); + continue; + } + + throw new Error("Token relay response does not contain access_token."); + } + + logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({ + ...responseData, + access_token: maskToken(responseData.access_token) + }, null, 2))); + + if (includeExpiry) { + const lifetime = Number(responseData.expires_in); + return { + access_token: responseData.access_token, + expiresAt: Number.isFinite(lifetime) && lifetime > 0 + ? requestEntry.startedAt.getTime() + lifetime * 1000 : 0 + }; + } + return responseData.access_token; + } catch (error) { + logRequestFailure(requestEntry, error.message || "Request failed."); + throw error; + } + } + } + + function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name = "requestTokenRelay", url = TOKEN_RELAY_URL) { + return logRequestStart({ + name, + method: "GET", + url, + headers: { + "x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)" + }, + metadata: { + xsrfCookieName: xsrfTokenSource.cookieName || "(missing)", + xsrfTokenSource: xsrfTokenSource.source, + xsrfMatchedCookies: xsrfTokenSource.matchedCookieNames && xsrfTokenSource.matchedCookieNames.length + ? xsrfTokenSource.matchedCookieNames.join(", ") + : "(none)", + xsrfLookupDetails: xsrfTokenSource.lookupDetails && xsrfTokenSource.lookupDetails.length + ? xsrfTokenSource.lookupDetails.join(" | ") + : "(none)", + xsrfTokenError: xsrfTokenSource.error || "", + xsrfRefresh: formatXsrfRefreshResult(refreshResult) + } + }); + } + + function isInvalidXsrfStatus(status) { + return status === 400 || status === 401 || status === 403; + } + + function formatXsrfRefreshResult(result) { + if (!result) { + return "not-requested"; + } + + const outcome = result.refreshed ? "refreshed" : "not-refreshed"; + const cookieName = result.cookieName || "(none)"; + const duration = Number.isFinite(result.durationMs) ? `${result.durationMs}ms` : "unknown"; + return `${result.reason}; ${outcome}; cookie=${cookieName}; duration=${duration}; ${result.error || ""}`.trim(); + } + + return { requestTokenRelay, createTokenRelayRequestEntry, isInvalidXsrfStatus, formatXsrfRefreshResult }; +} diff --git a/src/auth/xsrf-content.js b/src/auth/xsrf-content.js new file mode 100644 index 0000000..73e9070 --- /dev/null +++ b/src/auth/xsrf-content.js @@ -0,0 +1,42 @@ +function createAuthXsrfContent({ + getXsrfTokenFromExtensionCookies, + environment +}) { + const { document } = environment; + async function getXsrfToken() { + const extensionCookie = await getXsrfTokenFromExtensionCookies(); + + if (extensionCookie.token) { + return extensionCookie; + } + + const xsrfCookie = document.cookie + .split(";") + .map((cookie) => cookie.trim()) + .find((cookie) => cookie.startsWith("XSRF-TOKEN-")); + + if (!xsrfCookie) { + return { + token: "", + cookieName: "", + source: extensionCookie.error ? "document.cookie fallback after cookies API error" : "document.cookie fallback", + matchedCookieNames: extensionCookie.matchedCookieNames || [], + lookupDetails: extensionCookie.lookupDetails || [], + error: extensionCookie.error || "" + }; + } + + const cookieName = xsrfCookie.slice(0, xsrfCookie.indexOf("=")); + const tokenValue = xsrfCookie.slice(xsrfCookie.indexOf("=") + 1); + return { + token: decodeURIComponent(tokenValue), + cookieName, + source: "document.cookie", + matchedCookieNames: extensionCookie.matchedCookieNames || [], + lookupDetails: extensionCookie.lookupDetails || [], + error: "" + }; + } + + return { getXsrfToken }; +} diff --git a/src/auth/xsrf-cookies.js b/src/auth/xsrf-cookies.js new file mode 100644 index 0000000..8cb1b1e --- /dev/null +++ b/src/auth/xsrf-cookies.js @@ -0,0 +1,116 @@ +function createAuthXsrfCookies({ + ORACLE_COOKIE_URLS, + ORACLE_DOMAIN, + XSRF_COOKIE_NAME, + XSRF_COOKIE_PREFIX, + cookiesGet, + cookiesGetAll, + cookiesGetAllCookieStores, + removeUndefinedValues +}) { + async function getXsrfTokenCookie() { + const lookupDetails = []; + const allCookies = []; + const stores = await getCookieStores(lookupDetails); + + for (const store of stores) { + const exactCookie = await getExactCookieFromUrls(store.id, lookupDetails); + + if (exactCookie) { + allCookies.push(exactCookie); + } + } + + for (const store of stores) { + await collectCookies({ + name: XSRF_COOKIE_NAME, + storeId: store.id + }, allCookies, lookupDetails); + } + + for (const store of stores) { + await collectCookies({ + domain: ORACLE_DOMAIN, + storeId: store.id + }, allCookies, lookupDetails); + + await collectCookies({ + domain: `.${ORACLE_DOMAIN}`, + storeId: store.id + }, allCookies, lookupDetails); + } + + for (const store of stores) { + for (const url of ORACLE_COOKIE_URLS) { + await collectCookies({ + url, + storeId: store.id + }, allCookies, lookupDetails); + } + } + + const uniqueCookies = dedupeCookies(allCookies); + const xsrfCookies = uniqueCookies.filter((cookie) => cookie.name.startsWith(XSRF_COOKIE_PREFIX)); + const exactCookie = xsrfCookies.find((cookie) => cookie.name === XSRF_COOKIE_NAME); + const hostCookie = xsrfCookies.find((cookie) => cookie.domain === ORACLE_DOMAIN || cookie.domain === `.${ORACLE_DOMAIN}`); + + return { + cookie: exactCookie || hostCookie || xsrfCookies[0] || null, + matchedCookieNames: xsrfCookies.map((cookie) => `${cookie.name} (${cookie.domain}${cookie.path})`), + lookupDetails + }; + } + + async function getExactCookieFromUrls(storeId, lookupDetails) { + for (const url of ORACLE_COOKIE_URLS) { + const cookie = await cookiesGet({ + url, + name: XSRF_COOKIE_NAME, + storeId + }); + + lookupDetails.push(`${JSON.stringify({ url, name: XSRF_COOKIE_NAME, storeId })} => ${cookie ? "found" : "not found"}`); + + if (cookie) { + return cookie; + } + } + + return null; + } + + async function getCookieStores(lookupDetails) { + try { + const stores = await cookiesGetAllCookieStores(); + lookupDetails.push(`getAllCookieStores => ${stores.length} store(s)`); + return stores.length ? stores : [{ id: undefined }]; + } catch (error) { + lookupDetails.push(`getAllCookieStores => unavailable (${error.message || "unknown error"})`); + return [{ id: undefined }]; + } + } + + async function collectCookies(details, target, lookupDetails) { + const cleanDetails = removeUndefinedValues(details); + const cookies = await cookiesGetAll(cleanDetails); + target.push(...cookies); + lookupDetails.push(`${JSON.stringify(cleanDetails)} => ${cookies.length} cookie(s)`); + } + + function dedupeCookies(cookies) { + const seen = new Set(); + + return cookies.filter((cookie) => { + const key = `${cookie.name}|${cookie.domain}|${cookie.path}|${cookie.storeId || ""}`; + + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); + } + + return { getXsrfTokenCookie, getExactCookieFromUrls, getCookieStores, collectCookies, dedupeCookies }; +} diff --git a/src/auth/xsrf-recovery.js b/src/auth/xsrf-recovery.js new file mode 100644 index 0000000..9cb23d1 --- /dev/null +++ b/src/auth/xsrf-recovery.js @@ -0,0 +1,84 @@ +function createAuthXsrfRecovery({ + COOKIE_REFRESH_POLL_MS, + COOKIE_REFRESH_TIMEOUT_MS, + OPPORTUNITIES_LIST_URL, + getXsrfTokenCookie, + supportsFirefoxCookieStoreTabs, + tabsCreate, + tabsRemove, + wait +}) { + async function refreshXsrfCookie(sender) { + const startedAt = Date.now(); + const previous = (await getXsrfTokenCookie()).cookie; + const createProperties = { + url: OPPORTUNITIES_LIST_URL, + active: false + }; + + if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) { + createProperties.windowId = sender.tab.windowId; + } + + if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) { + createProperties.cookieStoreId = sender.tab.cookieStoreId; + } + + let refreshTab; + + try { + refreshTab = await tabsCreate(createProperties); + const current = await waitForXsrfCookieChange(previous); + const refreshed = hasCookieChanged(previous, current); + + return { + ok: Boolean(current), + refreshed, + cookieName: current ? current.name : "", + durationMs: Date.now() - startedAt, + error: refreshed ? "" : "XSRF cookie was not created or changed before timeout." + }; + } finally { + if (refreshTab && Number.isInteger(refreshTab.id)) { + try { + await tabsRemove(refreshTab.id); + } catch (error) { + // The temporary tab may already have been closed. + } + } + } + } + + async function waitForXsrfCookieChange(previousCookie) { + const deadline = Date.now() + COOKIE_REFRESH_TIMEOUT_MS; + let currentCookie = null; + + while (Date.now() < deadline) { + currentCookie = (await getXsrfTokenCookie()).cookie; + + if (hasCookieChanged(previousCookie, currentCookie)) { + return currentCookie; + } + + await wait(COOKIE_REFRESH_POLL_MS); + } + + return currentCookie; + } + + function hasCookieChanged(previousCookie, currentCookie) { + if (!currentCookie || !currentCookie.value) { + return false; + } + + if (!previousCookie) { + return true; + } + + return previousCookie.name !== currentCookie.name + || previousCookie.value !== currentCookie.value + || previousCookie.expirationDate !== currentCookie.expirationDate; + } + + return { refreshXsrfCookie, waitForXsrfCookieChange, hasCookieChanged }; +} diff --git a/src/background.js b/src/background.js deleted file mode 100644 index 35a162f..0000000 --- a/src/background.js +++ /dev/null @@ -1,1328 +0,0 @@ -(function () { - "use strict"; - - const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com"; - const ORACLE_COOKIE_URLS = [ - "https://eeho.fa.us2.oraclecloud.com/", - "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome", - "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome", - "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay" - ]; - const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F"; - const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-"; - const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list"; - const COOKIE_REFRESH_TIMEOUT_MS = 15000; - const COOKIE_REFRESH_POLL_MS = 250; - const OPPTY_PRODUCTS_CACHE_KEY = "opportunitiesExtension.opptyProductsCache.v1"; - const OPPTY_PRODUCTS_PAGE_LIMIT = 15; - const OPPTY_PRODUCTS_MAX_PAGES = 250; - const OPPTY_PRODUCTS_CONCURRENCY = 4; - const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService"; - const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html"; - const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5; - const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750; - const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000; - const ACCOUNT_SUMMARY_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-account/service/accountsummary"; - const ACCOUNT_PLANS_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/account/accountPlans"; - const CONSUMPTION_TREND_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/trend"; - const CONSUMPTION_TREND_REQUESTS = { - quarterly: "requestConsumptionServiceQuarterly", - monthly: "requestConsumptionServiceMonthly", - weekly: "requestConsumptionServiceWeekly", - daily: "requestConsumptionServiceDaily" - }; - const ACCOUNT_PLANS_PAYLOAD = { - hierarchy: "single", - products: "all", - language: "english", - planStatus: [ - "active", - "awaiting_provisioning", - "awaiting_provisioning_error", - "draft", - "expired", - "final_billing", - "onhold", - "provisioned", - "signed", - "superseded", - "superseded_pending_final_billing", - "suspended", - "terminated", - "terminated_pending_final_billing" - ] - }; - const DEBUG_BODY_LIMIT = 100000; - const OPPTY_PRODUCTS_FIELDS = [ - "ProductType", "Description", "InventoryItemId", "ProdGroupName", "ProdGroupId", "OwnerLockAsgnFlag", - "Quantity", "RecurTypeCode", "RevnAmountCurcyCode", "UnitPrice", "RevnAmount", "PriceTypeCode", - "EffectiveDate", "Name1", "NonRecurringRevenue", "OpportunityOwnerPartyName", "OpportunityOwnerResourcePartyId", - "OptyId", "OptyNumber", "PartyName2", "PrTerritoryVersionId", "PrTerritoryVersionIdForManual", - "RecurEndDate", "RecurFrequencyCode", "RecurNumberPeriods", "RecurRevenue", "ResourcePartyId", "RevnId", - "RevnNumber", "SplitPercent", "SalesCreditTypeCode", "SplitTypeCode", "TerrOwnerPartyName", "UpsideAmount", - "UsageRevenue", "StatusCode", "BUOrgId", "TypeCode", "WinProb", "ForecastType_c", "ARRLocalCurrency_c", - "CPQLastSyncDate_c", "CPQOperationType_c", "ProdGLID_c", "ProposalNumber_c", "QuoteNumber_c", - "ServicesPeriod_c", "CPQIntLastUpdatedBy_c", "CPQUpdatedAmount_c", "CPQUpdatedQuantity_c", - "CPQUpdatedRevnType_c", "CPQUpdatedServicePeriod_c", "CPQUpdatedStatus_c", "OrderNumber_c", "PrevAmount_c", - "PrevQuantity_c", "PrevRevenueType_c", "PrevServicePeriod_c", "PrevStatus_c", "SubscriptionID_c", - "WorkloadName_c", "ConsumptionStartDate_c", "RampMonths_c" - ]; - const runtimeApi = typeof browser !== "undefined" ? browser : chrome; - let productsCachePromise = null; - let productsCacheWritePromise = Promise.resolve(); - const inFlightProducts = new Map(); - - runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (!message || !message.type) { - return false; - } - - if (message.type === "opportunitiesExtension.getXsrfToken") { - getXsrfTokenCookie() - .then((result) => { - sendResponse({ - ok: true, - cookieName: result.cookie ? result.cookie.name : "", - token: result.cookie ? result.cookie.value : "", - matchedCookieNames: result.matchedCookieNames, - lookupDetails: result.lookupDetails - }); - }) - .catch((error) => { - sendResponse({ - ok: false, - cookieName: "", - token: "", - matchedCookieNames: [], - lookupDetails: [], - error: error.message || "Unable to read cookies." - }); - }); - - return true; - } - - if (message.type === "opportunitiesExtension.refreshXsrfCookie") { - refreshXsrfCookie(sender) - .then(sendResponse) - .catch((error) => { - sendResponse({ - ok: false, - refreshed: false, - cookieName: "", - durationMs: 0, - error: error.message || "Unable to refresh XSRF cookie." - }); - }); - - return true; - } - - if (message.type === "opportunitiesExtension.requestOpptyProducts") { - requestOpptyProducts(message, sender) - .then(sendResponse) - .catch((error) => { - sendResponse({ - ok: false, - requestId: message.requestId || "", - error: error.message || "Unable to load opportunity products." - }); - }); - - return true; - } - - if (message.type === "opportunitiesExtension.requestConsumerTokenService") { - requestConsumerTokenService(sender) - .then(sendResponse) - .catch((error) => { - sendResponse({ - ok: false, - error: error.message || "Unable to load consumer token service.", - debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] - }); - }); - - return true; - } - - if (message.type === "opportunitiesExtension.requestAccountPlans") { - requestAccountPlans(message) - .then(sendResponse) - .catch((error) => { - sendResponse({ - ok: false, - error: error.message || "Unable to load account plans.", - debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] - }); - }); - - return true; - } - - if (message.type === "opportunitiesExtension.requestConsumptionTrend") { - requestConsumptionTrend(message) - .then(sendResponse) - .catch((error) => { - sendResponse({ - ok: false, - error: error.message || "Unable to load consumption trend.", - debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] - }); - }); - - return true; - } - - return false; - }); - - async function requestConsumerTokenService(sender) { - const debugRequests = []; - - try { - const tokenResponse = await fetchConsumerTokenServiceUntilJson(sender, debugRequests); - const accountSummaryAuthorization = extractTokenServiceAuthorization( - tokenResponse, - "ACCP-TS", - "account-ti-authorization" - ); - const accountPlansAuthorization = extractTokenServiceAuthorization( - tokenResponse, - "OCICONS-TS", - "g2m-authorization" - ); - const tokenRequestDebugEntry = debugRequests[debugRequests.length - 1]; - - if (tokenRequestDebugEntry) { - tokenRequestDebugEntry.metadata = { - ...tokenRequestDebugEntry.metadata, - accountSummaryTokenService: "ACCP-TS", - accountSummaryAuthorizationHeader: accountSummaryAuthorization.name || "(missing)", - accountSummaryAuthorizationValue: accountSummaryAuthorization.value - ? `(present, ${accountSummaryAuthorization.value.length} characters)` - : "(missing)", - accountSummaryAuthorizationExpires: accountSummaryAuthorization.expires || "(not provided)", - accountPlansTokenService: "OCICONS-TS", - accountPlansAuthorizationHeader: accountPlansAuthorization.name || "(missing)", - accountPlansAuthorizationValue: accountPlansAuthorization.value - ? `(present, ${accountPlansAuthorization.value.length} characters)` - : "(missing)", - accountPlansAuthorizationExpires: accountPlansAuthorization.expires || "(not provided)" - }; - } - - if (!accountSummaryAuthorization.value) { - throw new Error("ACCP-TS uiaasHeader.value was not found in requestConsumerTokenService."); - } - - if (!accountPlansAuthorization.value) { - throw new Error("OCICONS-TS uiaasHeader.value was not found in requestConsumerTokenService."); - } - - return { - ok: true, - authorizations: { - accountSummary: accountSummaryAuthorization, - accountPlans: accountPlansAuthorization - }, - debugRequests - }; - } catch (error) { - error.debugRequests = debugRequests; - throw error; - } - } - - async function fetchConsumerTokenServiceUntilJson(sender, debugRequests) { - let attempt = 0; - let sessionTab = null; - let receivedValidJson = false; - - try { - while (attempt < CUSTOMER_TOKEN_MAX_ATTEMPTS) { - attempt += 1; - - try { - const tokenResponse = await fetchJson(CUSTOMER_TOKEN_SERVICE_URL, { - method: "GET", - credentials: "include", - cache: "no-store", - headers: { - Accept: "application/json", - "Cache-Control": "no-cache, no-store, max-age=0", - Pragma: "no-cache" - } - }, "requestConsumerTokenService", debugRequests); - const debugEntry = debugRequests[debugRequests.length - 1]; - - if (debugEntry) { - debugEntry.metadata.attempt = String(attempt); - } - - receivedValidJson = true; - return tokenResponse; - } catch (error) { - const isHtmlFallback = error - && error.code === "INVALID_JSON_RESPONSE" - && error.httpStatus === 200; - const debugEntry = debugRequests[debugRequests.length - 1]; - - if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) { - if (debugEntry) { - debugEntry.metadata.attempt = String(attempt); - if (isHtmlFallback) { - debugEntry.metadata.retryLimitReached = "true"; - debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`; - error.message = debugEntry.error; - } - } - - throw error; - } - - if (!sessionTab) { - try { - sessionTab = await openConsumerTokenSessionTabWithRetry(sender, debugEntry); - if (debugEntry) { - debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL; - debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab"; - } - } catch (sessionError) { - if (debugEntry) { - debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL; - debugEntry.metadata.sessionBootstrapState = `tab-open-failed: ${sessionError.message || "unknown error"}`; - } - } - } else if (debugEntry) { - debugEntry.metadata.sessionBootstrapState = "waiting-for-valid-session"; - } - - const retryDelayMs = getConsumerTokenRetryDelay(attempt); - if (debugEntry) { - debugEntry.metadata.attempt = String(attempt); - debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON"; - debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs); - } - - await wait(retryDelayMs); - } - } - - throw new Error("requestConsumerTokenService retry loop ended unexpectedly."); - } finally { - if (sessionTab && Number.isInteger(sessionTab.id)) { - const debugEntry = debugRequests[debugRequests.length - 1]; - - try { - await tabsRemove(sessionTab.id); - if (debugEntry) { - debugEntry.metadata.sessionBootstrapState = receivedValidJson - ? "closed-after-valid-json" - : "closed-after-retry-finished"; - } - } catch (error) { - if (debugEntry) { - debugEntry.metadata.sessionBootstrapState = `tab-close-failed: ${error.message || "unknown error"}`; - } - } - } - } - } - - function getConsumerTokenRetryDelay(attempt) { - return Math.min( - CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS * (2 ** Math.max(attempt - 1, 0)), - CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS - ); - } - - async function openConsumerTokenSessionTabWithRetry(sender, debugEntry) { - let attempt = 0; - - // Opening failures must not consume the token service's five attempts. - while (true) { - attempt += 1; - if (debugEntry) { - debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL; - debugEntry.metadata.sessionTabOpenAttempt = String(attempt); - } - - try { - const tab = await openConsumerTokenSessionTab(sender); - if (debugEntry) { - debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab"; - delete debugEntry.metadata.sessionTabNextRetryDelayMs; - } - return tab; - } catch (error) { - const retryDelayMs = getConsumerTokenRetryDelay(Math.min(attempt, 5)); - if (debugEntry) { - debugEntry.metadata.sessionBootstrapState = "retrying-tab-open"; - debugEntry.metadata.sessionTabLastOpenError = error.message || "unknown error"; - debugEntry.metadata.sessionTabNextRetryDelayMs = String(retryDelayMs); - } - await wait(retryDelayMs); - } - } - } - - function openConsumerTokenSessionTab(sender) { - const details = { - url: CUSTOMER_TOKEN_SESSION_URL, - active: false - }; - - if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) { - details.windowId = sender.tab.windowId; - } - - if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) { - details.cookieStoreId = sender.tab.cookieStoreId; - } - - return tabsCreate(details); - } - - async function requestAccountPlans(message) { - const partyNumber = typeof message.partyNumber === "string" ? message.partyNumber.trim() : ""; - const accountSummaryAuthorization = normalizeMessageAuthorization( - message.authorizations && message.authorizations.accountSummary, - "account-ti-authorization" - ); - const accountPlansAuthorization = normalizeMessageAuthorization( - message.authorizations && message.authorizations.accountPlans, - "g2m-authorization" - ); - const debugRequests = []; - - if (!partyNumber) { - throw new Error("Customer Party Number is required."); - } - - if (!accountSummaryAuthorization.value) { - throw new Error("ACCP-TS authorization is unavailable. requestConsumerTokenService must complete first."); - } - - if (!accountPlansAuthorization.value) { - throw new Error("OCICONS-TS authorization is unavailable. requestConsumerTokenService must complete first."); - } - - try { - const accountSummary = await fetchJson(ACCOUNT_SUMMARY_URL, { - method: "POST", - credentials: "include", - headers: { - Accept: "application/json", - [accountSummaryAuthorization.name]: accountSummaryAuthorization.value, - "Content-Type": "application/json" - }, - body: JSON.stringify([{ key: "regId", value: partyNumber }]) - }, "requestAccountSummary", debugRequests); - const accountPlanId = findAccountPlanId(accountSummary); - - if (!accountPlanId) { - throw new Error("Account plan id was not found in requestAccountSummary."); - } - - const accountPlansResponse = await fetchJson(`${ACCOUNT_PLANS_URL}/${encodeURIComponent(String(accountPlanId))}`, { - method: "POST", - credentials: "include", - headers: { - Accept: "application/json", - [accountPlansAuthorization.name]: accountPlansAuthorization.value, - "Content-Type": "application/json" - }, - body: JSON.stringify(ACCOUNT_PLANS_PAYLOAD) - }, "requestAccountPlans", debugRequests); - - return { - ok: true, - accountPlanId: String(accountPlanId), - plans: extractAccountPlans(accountPlansResponse), - debugRequests - }; - } catch (error) { - error.debugRequests = debugRequests; - throw error; - } - } - - async function requestConsumptionTrend(message) { - const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : ""; - const period = typeof message.period === "string" ? message.period.toLowerCase() : ""; - const requestName = CONSUMPTION_TREND_REQUESTS[period]; - const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization"); - const debugRequests = []; - - if (!subPlanNum) { - throw new Error("Sub plan number is required for the consumption trend."); - } - - if (!requestName) { - throw new Error(`Unsupported consumption trend period: ${period || "(missing)"}.`); - } - - if (!authorization.value) { - throw new Error("OCICONS-TS authorization is unavailable for the consumption trend."); - } - - const url = `${CONSUMPTION_TREND_URL}/${encodeURIComponent(subPlanNum)}?quarter=All&timePeriod=${period}`; - - try { - const response = await fetchJson(url, { - method: "GET", - credentials: "include", - cache: "no-store", - headers: { - Accept: "application/json", - "G2m-Authorization": authorization.value - } - }, requestName, debugRequests); - const items = Array.isArray(response) - ? response - : Array.isArray(response && response.items) - ? response.items - : []; - - return { - ok: true, - subPlanNum, - period, - items, - debugRequests - }; - } catch (error) { - error.debugRequests = debugRequests; - throw error; - } - } - - function normalizeMessageAuthorization(authorization, fallbackHeaderName) { - const headerName = authorization && typeof authorization.name === "string" - ? authorization.name.trim() - : ""; - const headerValue = authorization && typeof authorization.value === "string" - ? authorization.value.trim() - : ""; - - return { - name: headerName || fallbackHeaderName, - value: headerValue - }; - } - - function extractTokenServiceAuthorization(response, tokenServiceName, fallbackHeaderName) { - const tokenService = Array.isArray(response) - ? response.find((entry) => entry && entry.tokenService === tokenServiceName) - : findNestedObject(response, (entry) => entry.tokenService === tokenServiceName); - const authorizationHeader = tokenService && tokenService.uiaasHeader; - const headerName = authorizationHeader && typeof authorizationHeader.label === "string" - ? authorizationHeader.label.trim() - : ""; - const headerValue = authorizationHeader && typeof authorizationHeader.value === "string" - ? authorizationHeader.value.trim() - : ""; - - return { - name: headerName || fallbackHeaderName, - value: headerValue, - expires: authorizationHeader && authorizationHeader.expires - ? String(authorizationHeader.expires) - : "" - }; - } - - async function fetchJson(url, options, requestName, debugRequests = null) { - const startedAt = new Date(); - const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt); - let response; - - try { - response = await fetch(url, options); - } catch (error) { - completeBackgroundDebugEntry(debugEntry, null, "", error.message || "Network request failed."); - appendBackgroundDebugEntry(debugRequests, debugEntry); - throw error; - } - - const responsePayload = await readResponsePayload(response); - const responseBody = createDebugBody(responsePayload.text); - completeBackgroundDebugEntry(debugEntry, response, responseBody, ""); - debugEntry.metadata.responseContentType = response.headers.get("content-type") || "(not provided)"; - debugEntry.metadata.responseLength = String(responsePayload.text.length); - appendBackgroundDebugEntry(debugRequests, debugEntry); - - if (!response.ok) { - throw new Error(`${requestName} failed with status ${response.status}.`); - } - - if (responsePayload.error) { - throw new Error(`${requestName} response body could not be read.`); - } - - try { - return parseJsonResponse(responsePayload.text, requestName); - } catch (error) { - error.httpStatus = response.status; - debugEntry.status = "failed"; - debugEntry.error = error.message; - throw error; - } - } - - function createBackgroundDebugEntry(requestName, url, options, startedAt) { - return { - name: requestName, - method: options.method || "GET", - url, - status: "pending", - startedAt: startedAt.toISOString(), - completedAt: null, - durationMs: null, - httpStatus: null, - statusText: "", - requestHeaders: normalizeDebugHeaders(options.headers), - requestBody: formatDebugRequestBody(options.body), - metadata: { - executionContext: "background" - }, - responseHeaders: {}, - responseBody: "", - responseBodyTruncated: false, - error: "" - }; - } - - function completeBackgroundDebugEntry(entry, response, responseBody, errorMessage) { - const completedAt = new Date(); - entry.completedAt = completedAt.toISOString(); - entry.durationMs = completedAt.getTime() - new Date(entry.startedAt).getTime(); - entry.error = errorMessage; - - if (!response) { - entry.status = "failed"; - return; - } - - entry.status = response.ok ? "success" : "failed"; - entry.httpStatus = response.status; - entry.statusText = response.statusText || ""; - entry.responseHeaders = headersToObject(response.headers); - entry.responseBody = responseBody.value; - entry.responseBodyTruncated = responseBody.truncated; - } - - function appendBackgroundDebugEntry(debugRequests, entry) { - if (Array.isArray(debugRequests)) { - debugRequests.push(entry); - } - } - - async function readResponsePayload(response) { - try { - return { - text: await response.text(), - error: "" - }; - } catch (error) { - return { - text: `Unable to read response body: ${error.message || "unknown error"}`, - error: error.message || "unknown error" - }; - } - } - - function parseJsonResponse(text, requestName) { - const normalizedText = String(text) - .replace(/^\uFEFF/, "") - .trimStart() - .replace(/^\)\]\}',?\s*/, "") - .replace(/^while\s*\(1\);\s*/, ""); - - try { - return JSON.parse(normalizedText); - } catch (error) { - const invalidJsonError = new Error(`${requestName} returned an invalid JSON response.`); - invalidJsonError.code = "INVALID_JSON_RESPONSE"; - throw invalidJsonError; - } - } - - function createDebugBody(text) { - if (text.length > DEBUG_BODY_LIMIT) { - return { - value: `${text.slice(0, DEBUG_BODY_LIMIT)}\n... truncated ${text.length - DEBUG_BODY_LIMIT} characters`, - truncated: true - }; - } - - return { - value: text || "(empty response body)", - truncated: false - }; - } - - function normalizeDebugHeaders(headers) { - const normalized = {}; - - Object.entries(headers || {}).forEach(([key, value]) => { - normalized[key] = String(value); - }); - return normalized; - } - - function headersToObject(headers) { - const values = {}; - - if (headers && typeof headers.forEach === "function") { - headers.forEach((value, key) => { - values[key] = value; - }); - } - return values; - } - - function formatDebugRequestBody(body) { - if (!body) { - return ""; - } - - try { - return JSON.stringify(JSON.parse(body), null, 2); - } catch (error) { - return String(body); - } - } - - function findNestedObject(value, predicate, visited = new Set()) { - if (!value || typeof value !== "object" || visited.has(value)) { - return null; - } - - visited.add(value); - - if (!Array.isArray(value) && predicate(value)) { - return value; - } - - for (const child of Object.values(value)) { - const match = findNestedObject(child, predicate, visited); - - if (match) { - return match; - } - } - - return null; - } - - function findAccountPlanId(response) { - const account = findNestedObject(response, (value) => ( - Object.prototype.hasOwnProperty.call(value, "id") - && value.id !== null - && value.id !== undefined - && value.id !== "" - )); - return account ? account.id : ""; - } - - function extractAccountPlans(response) { - const plans = []; - const visited = new Set(); - - const visit = (value) => { - if (!value || typeof value !== "object" || visited.has(value)) { - return; - } - - visited.add(value); - - if (!Array.isArray(value) && ( - Object.prototype.hasOwnProperty.call(value, "planType") - || Object.prototype.hasOwnProperty.call(value, "subPlanNum") - )) { - plans.push(value); - return; - } - - Object.values(value).forEach(visit); - }; - - visit(response); - return plans; - } - - async function requestOpptyProducts(message, sender) { - const requestId = typeof message.requestId === "string" ? message.requestId : ""; - const accessToken = typeof message.accessToken === "string" ? message.accessToken : ""; - const cacheMaxAgeMs = normalizeCacheMaxAge(message.cacheMaxAgeMs); - const optyNumbers = Array.from(new Set( - (Array.isArray(message.optyNumbers) ? message.optyNumbers : []) - .filter(Boolean) - .map(String) - )); - - if (!requestId || !accessToken) { - throw new Error("A request id and access token are required for requestOpptyProducts."); - } - - let nextIndex = 0; - let completed = 0; - let failed = 0; - let cacheHits = 0; - - const runWorker = async () => { - while (nextIndex < optyNumbers.length) { - const index = nextIndex; - nextIndex += 1; - const optyNumber = optyNumbers[index]; - - try { - const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs); - completed += 1; - - if (result.fromCache) { - cacheHits += 1; - } - - await sendProductsProgress(sender, { - type: "opportunitiesExtension.opptyProductsProgress", - requestId, - state: "running", - optyNumber, - items: result.items, - fromCache: result.fromCache, - cachedAt: result.cachedAt, - completed, - total: optyNumbers.length, - failed, - cacheHits - }); - } catch (error) { - completed += 1; - failed += 1; - await sendProductsProgress(sender, { - type: "opportunitiesExtension.opptyProductsProgress", - requestId, - state: "running", - optyNumber, - items: [], - error: error.message || "Unable to load products.", - completed, - total: optyNumbers.length, - failed, - cacheHits - }); - } - } - }; - - const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1)); - await Promise.all(Array.from({ length: workerCount }, runWorker)); - - const state = failed > 0 ? "complete-with-errors" : "complete"; - await sendProductsProgress(sender, { - type: "opportunitiesExtension.opptyProductsProgress", - requestId, - state, - completed, - total: optyNumbers.length, - failed, - cacheHits - }); - - return { - ok: failed === 0, - requestId, - completed, - total: optyNumbers.length, - failed, - cacheHits - }; - } - - async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) { - const cache = await getProductsCache(); - const cachedEntry = cache[optyNumber]; - - if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) { - return { items: cachedEntry.items, fromCache: true, cachedAt: Number(cachedEntry.fetchedAt) }; - } - - if (!inFlightProducts.has(optyNumber)) { - const request = fetchAllOpptyProducts(optyNumber, accessToken) - .then(async (items) => { - const fetchedAt = Date.now(); - cache[optyNumber] = { - fetchedAt, - items - }; - await saveProductsCache(cache).catch(() => {}); - return { items, fetchedAt }; - }) - .finally(() => { - inFlightProducts.delete(optyNumber); - }); - inFlightProducts.set(optyNumber, request); - } - - const fetchedResult = await inFlightProducts.get(optyNumber); - return { - items: fetchedResult.items, - cachedAt: fetchedResult.fetchedAt, - fromCache: false - }; - } - - async function fetchAllOpptyProducts(optyNumber, accessToken) { - let offset = 0; - let page = 0; - const items = []; - - while (page < OPPTY_PRODUCTS_MAX_PAGES) { - const response = await fetch(createOpptyProductsUrl(optyNumber, offset), { - method: "GET", - credentials: "include", - headers: { - Accept: "application/json", - Authorization: `Bearer ${accessToken}` - } - }); - - if (!response.ok) { - throw new Error(`requestOpptyProducts failed with status ${response.status}.`); - } - - const responseData = await response.json(); - const pageItems = Array.isArray(responseData.items) ? responseData.items : []; - items.push(...pageItems); - page += 1; - - if (!responseData.hasMore) { - return items; - } - - const responseOffset = Number(responseData.offset); - const responseCount = Number(responseData.count); - const receivedCount = Number.isFinite(responseCount) ? responseCount : pageItems.length; - const currentOffset = Number.isFinite(responseOffset) ? responseOffset : offset; - - if (receivedCount <= 0) { - throw new Error("requestOpptyProducts returned hasMore without additional results."); - } - - offset = currentOffset + receivedCount; - } - - throw new Error("requestOpptyProducts exceeded the pagination safety limit."); - } - - function createOpptyProductsUrl(optyNumber, offset) { - const url = new URL(`https://${ORACLE_DOMAIN}/crmRestApi/rest/rv:be91c002-2e5d-4ed2-a37e-e0c837bf141f/en/11.13.18.05:9/opportunities/${encodeURIComponent(optyNumber)}/child/ChildRevenue`); - url.searchParams.set("onlyData", "true"); - url.searchParams.set("q", "(SplitTypeCode!='DETAILCHILDSPLIT') AND (RecurTypeCode!='CHILDRECUR')"); - url.searchParams.set("totalResults", "false"); - url.searchParams.set("fields", OPPTY_PRODUCTS_FIELDS.join(",")); - url.searchParams.set("orderBy", "CreationDate:desc"); - url.searchParams.set("limit", String(OPPTY_PRODUCTS_PAGE_LIMIT)); - url.searchParams.set("offset", String(offset)); - return url.toString(); - } - - function normalizeCacheMaxAge(value) { - const maxAge = Number(value); - const defaultMaxAge = 24 * 60 * 60 * 1000; - const maximumMaxAge = 30 * 24 * 60 * 60 * 1000; - return Number.isFinite(maxAge) && maxAge > 0 - ? Math.min(maxAge, maximumMaxAge) - : defaultMaxAge; - } - - async function getProductsCache() { - if (!productsCachePromise) { - productsCachePromise = storageLocalGet(OPPTY_PRODUCTS_CACHE_KEY) - .then((result) => result && typeof result[OPPTY_PRODUCTS_CACHE_KEY] === "object" - ? result[OPPTY_PRODUCTS_CACHE_KEY] - : {}) - .catch(() => ({})); - } - - return productsCachePromise; - } - - function saveProductsCache(cache) { - productsCacheWritePromise = productsCacheWritePromise - .catch(() => {}) - .then(() => storageLocalSet({ [OPPTY_PRODUCTS_CACHE_KEY]: cache })); - return productsCacheWritePromise; - } - - function sendProductsProgress(sender, message) { - if (!sender || !sender.tab || !Number.isInteger(sender.tab.id)) { - return Promise.resolve(); - } - - const options = Number.isInteger(sender.frameId) ? { frameId: sender.frameId } : undefined; - return tabsSendMessage(sender.tab.id, message, options).catch(() => {}); - } - - async function refreshXsrfCookie(sender) { - const startedAt = Date.now(); - const previous = (await getXsrfTokenCookie()).cookie; - const createProperties = { - url: OPPORTUNITIES_LIST_URL, - active: false - }; - - if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) { - createProperties.windowId = sender.tab.windowId; - } - - if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) { - createProperties.cookieStoreId = sender.tab.cookieStoreId; - } - - let refreshTab; - - try { - refreshTab = await tabsCreate(createProperties); - const current = await waitForXsrfCookieChange(previous); - const refreshed = hasCookieChanged(previous, current); - - return { - ok: Boolean(current), - refreshed, - cookieName: current ? current.name : "", - durationMs: Date.now() - startedAt, - error: refreshed ? "" : "XSRF cookie was not created or changed before timeout." - }; - } finally { - if (refreshTab && Number.isInteger(refreshTab.id)) { - try { - await tabsRemove(refreshTab.id); - } catch (error) { - // The temporary tab may already have been closed. - } - } - } - } - - async function waitForXsrfCookieChange(previousCookie) { - const deadline = Date.now() + COOKIE_REFRESH_TIMEOUT_MS; - let currentCookie = null; - - while (Date.now() < deadline) { - currentCookie = (await getXsrfTokenCookie()).cookie; - - if (hasCookieChanged(previousCookie, currentCookie)) { - return currentCookie; - } - - await wait(COOKIE_REFRESH_POLL_MS); - } - - return currentCookie; - } - - function hasCookieChanged(previousCookie, currentCookie) { - if (!currentCookie || !currentCookie.value) { - return false; - } - - if (!previousCookie) { - return true; - } - - return previousCookie.name !== currentCookie.name - || previousCookie.value !== currentCookie.value - || previousCookie.expirationDate !== currentCookie.expirationDate; - } - - function supportsFirefoxCookieStoreTabs() { - return Boolean( - runtimeApi.runtime - && typeof runtimeApi.runtime.getBrowserInfo === "function" - ); - } - - function tabsCreate(details) { - if (runtimeApi.tabs.create.length <= 1) { - return runtimeApi.tabs.create(details); - } - - return new Promise((resolve, reject) => { - runtimeApi.tabs.create(details, (tab) => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - reject(new Error(lastError.message)); - return; - } - - resolve(tab); - }); - }); - } - - function tabsRemove(tabId) { - if (runtimeApi.tabs.remove.length <= 1) { - return runtimeApi.tabs.remove(tabId); - } - - return new Promise((resolve, reject) => { - runtimeApi.tabs.remove(tabId, () => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - reject(new Error(lastError.message)); - return; - } - - resolve(); - }); - }); - } - - function tabsSendMessage(tabId, message, options) { - if (typeof browser !== "undefined") { - return options - ? runtimeApi.tabs.sendMessage(tabId, message, options) - : runtimeApi.tabs.sendMessage(tabId, message); - } - - return new Promise((resolve, reject) => { - const callback = (response) => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - reject(new Error(lastError.message)); - return; - } - - resolve(response); - }; - - if (options) { - runtimeApi.tabs.sendMessage(tabId, message, options, callback); - } else { - runtimeApi.tabs.sendMessage(tabId, message, callback); - } - }); - } - - function storageLocalGet(key) { - if (typeof browser !== "undefined") { - return runtimeApi.storage.local.get(key); - } - - return new Promise((resolve, reject) => { - runtimeApi.storage.local.get(key, (result) => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - reject(new Error(lastError.message)); - return; - } - - resolve(result); - }); - }); - } - - function storageLocalSet(value) { - if (typeof browser !== "undefined") { - return runtimeApi.storage.local.set(value); - } - - return new Promise((resolve, reject) => { - runtimeApi.storage.local.set(value, () => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - reject(new Error(lastError.message)); - return; - } - - resolve(); - }); - }); - } - - function wait(durationMs) { - return new Promise((resolve) => setTimeout(resolve, durationMs)); - } - - async function getXsrfTokenCookie() { - const lookupDetails = []; - const allCookies = []; - const stores = await getCookieStores(lookupDetails); - - for (const store of stores) { - const exactCookie = await getExactCookieFromUrls(store.id, lookupDetails); - - if (exactCookie) { - allCookies.push(exactCookie); - } - } - - for (const store of stores) { - await collectCookies({ - name: XSRF_COOKIE_NAME, - storeId: store.id - }, allCookies, lookupDetails); - } - - for (const store of stores) { - await collectCookies({ - domain: ORACLE_DOMAIN, - storeId: store.id - }, allCookies, lookupDetails); - - await collectCookies({ - domain: `.${ORACLE_DOMAIN}`, - storeId: store.id - }, allCookies, lookupDetails); - } - - for (const store of stores) { - for (const url of ORACLE_COOKIE_URLS) { - await collectCookies({ - url, - storeId: store.id - }, allCookies, lookupDetails); - } - } - - const uniqueCookies = dedupeCookies(allCookies); - const xsrfCookies = uniqueCookies.filter((cookie) => cookie.name.startsWith(XSRF_COOKIE_PREFIX)); - const exactCookie = xsrfCookies.find((cookie) => cookie.name === XSRF_COOKIE_NAME); - const hostCookie = xsrfCookies.find((cookie) => cookie.domain === ORACLE_DOMAIN || cookie.domain === `.${ORACLE_DOMAIN}`); - - return { - cookie: exactCookie || hostCookie || xsrfCookies[0] || null, - matchedCookieNames: xsrfCookies.map((cookie) => `${cookie.name} (${cookie.domain}${cookie.path})`), - lookupDetails - }; - } - - async function getExactCookieFromUrls(storeId, lookupDetails) { - for (const url of ORACLE_COOKIE_URLS) { - const cookie = await cookiesGet({ - url, - name: XSRF_COOKIE_NAME, - storeId - }); - - lookupDetails.push(`${JSON.stringify({ url, name: XSRF_COOKIE_NAME, storeId })} => ${cookie ? "found" : "not found"}`); - - if (cookie) { - return cookie; - } - } - - return null; - } - - async function getCookieStores(lookupDetails) { - try { - const stores = await cookiesGetAllCookieStores(); - lookupDetails.push(`getAllCookieStores => ${stores.length} store(s)`); - return stores.length ? stores : [{ id: undefined }]; - } catch (error) { - lookupDetails.push(`getAllCookieStores => unavailable (${error.message || "unknown error"})`); - return [{ id: undefined }]; - } - } - - async function collectCookies(details, target, lookupDetails) { - const cleanDetails = removeUndefinedValues(details); - const cookies = await cookiesGetAll(cleanDetails); - target.push(...cookies); - lookupDetails.push(`${JSON.stringify(cleanDetails)} => ${cookies.length} cookie(s)`); - } - - function dedupeCookies(cookies) { - const seen = new Set(); - - return cookies.filter((cookie) => { - const key = `${cookie.name}|${cookie.domain}|${cookie.path}|${cookie.storeId || ""}`; - - if (seen.has(key)) { - return false; - } - - seen.add(key); - return true; - }); - } - - function cookiesGetAll(details) { - if (runtimeApi.cookies.getAll.length <= 1) { - return runtimeApi.cookies.getAll(details); - } - - return new Promise((resolve, reject) => { - runtimeApi.cookies.getAll(details, (cookies) => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - reject(new Error(lastError.message)); - return; - } - - resolve(cookies); - }); - }); - } - - function cookiesGet(details) { - const cleanDetails = removeUndefinedValues(details); - - if (runtimeApi.cookies.get.length <= 1) { - return runtimeApi.cookies.get(cleanDetails); - } - - return new Promise((resolve, reject) => { - runtimeApi.cookies.get(cleanDetails, (cookie) => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - reject(new Error(lastError.message)); - return; - } - - resolve(cookie); - }); - }); - } - - function cookiesGetAllCookieStores() { - if (!runtimeApi.cookies.getAllCookieStores) { - return Promise.resolve([{ id: undefined }]); - } - - if (runtimeApi.cookies.getAllCookieStores.length === 0) { - return runtimeApi.cookies.getAllCookieStores(); - } - - return new Promise((resolve, reject) => { - runtimeApi.cookies.getAllCookieStores((stores) => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - reject(new Error(lastError.message)); - return; - } - - resolve(stores); - }); - }); - } - - function removeUndefinedValues(details) { - return Object.fromEntries( - Object.entries(details).filter((entry) => entry[1] !== undefined) - ); - } -})(); diff --git a/src/background/application.js b/src/background/application.js new file mode 100644 index 0000000..e9e4f6e --- /dev/null +++ b/src/background/application.js @@ -0,0 +1,119 @@ +function createBackgroundApplication(overrides = {}) { + const environment = { + fetch: globalThis.fetch, + browser: globalThis.browser, + chrome: globalThis.chrome, + setTimeout: globalThis.setTimeout, + ...overrides + }; + const messageTypes = createMessageTypes(); + const contracts = createBackgroundContracts(); + const productsCacheState = createProductsCacheState(); + const authConsumerSession = createAuthConsumerSession({ + CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS, + CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS: contracts.CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS, + CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS: contracts.CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS, + CUSTOMER_TOKEN_SERVICE_URL: contracts.CUSTOMER_TOKEN_SERVICE_URL, + CUSTOMER_TOKEN_SESSION_URL: contracts.CUSTOMER_TOKEN_SESSION_URL, + extractTokenServiceAuthorization: (...args) => authSpaCredentials.extractTokenServiceAuthorization(...args), + fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), + supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args), + tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args), + tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args), + wait: (...args) => platformBackgroundApi.wait(...args) + }); + const authSpaCredentials = createAuthSpaCredentials({ + findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args) + }); + const spaAccounts = createSpaAccounts({ + ACCOUNT_PLANS_PAYLOAD: contracts.ACCOUNT_PLANS_PAYLOAD, + ACCOUNT_PLANS_URL: contracts.ACCOUNT_PLANS_URL, + ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL, + extractAccountPlans: (...args) => spaResponseShapes.extractAccountPlans(...args), + fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), + findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args), + normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args) + }); + const spaConsumption = createSpaConsumption({ + CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS, + CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL, + fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), + normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args) + }); + const spaResponseShapes = createSpaResponseShapes(); + const httpBackgroundJson = createHttpBackgroundJson({ + DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT, + environment + }); + const productsBackgroundJobs = createProductsBackgroundJobs({ + messageTypes, + OPPTY_PRODUCTS_CONCURRENCY: contracts.OPPTY_PRODUCTS_CONCURRENCY, + getOpptyProducts: (...args) => productsCache.getOpptyProducts(...args), + normalizeCacheMaxAge: (...args) => productsCache.normalizeCacheMaxAge(...args), + tabsSendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args) + }); + const productsCache = createProductsCache({ + OPPTY_PRODUCTS_CACHE_KEY: contracts.OPPTY_PRODUCTS_CACHE_KEY, + fetchAllOpptyProducts: (...args) => fusionProducts.fetchAllOpptyProducts(...args), + productsCacheState, + storageLocalGet: (...args) => platformBackgroundApi.storageLocalGet(...args), + storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args) + }); + const fusionProducts = createFusionProducts({ + OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS, + OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES, + OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT, + ORACLE_DOMAIN: contracts.ORACLE_DOMAIN, + environment + }); + const authXsrfRecovery = createAuthXsrfRecovery({ + COOKIE_REFRESH_POLL_MS: contracts.COOKIE_REFRESH_POLL_MS, + COOKIE_REFRESH_TIMEOUT_MS: contracts.COOKIE_REFRESH_TIMEOUT_MS, + OPPORTUNITIES_LIST_URL: contracts.OPPORTUNITIES_LIST_URL, + getXsrfTokenCookie: (...args) => authXsrfCookies.getXsrfTokenCookie(...args), + supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args), + tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args), + tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args), + wait: (...args) => platformBackgroundApi.wait(...args) + }); + const authXsrfCookies = createAuthXsrfCookies({ + ORACLE_COOKIE_URLS: contracts.ORACLE_COOKIE_URLS, + ORACLE_DOMAIN: contracts.ORACLE_DOMAIN, + XSRF_COOKIE_NAME: contracts.XSRF_COOKIE_NAME, + XSRF_COOKIE_PREFIX: contracts.XSRF_COOKIE_PREFIX, + cookiesGet: (...args) => platformBackgroundApi.cookiesGet(...args), + cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args), + cookiesGetAllCookieStores: (...args) => platformBackgroundApi.cookiesGetAllCookieStores(...args), + removeUndefinedValues: (...args) => platformBackgroundApi.removeUndefinedValues(...args) + }); + const platformBackgroundApi = createPlatformBackgroundApi({ + runtimeApi: environment.browser || environment.chrome, + environment + }); + const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args); + const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args); + const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args); + const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args); + const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args); + const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args); + const runtimeApi = environment.browser || environment.chrome; + function start() { + startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi }); + } + return { + start, contracts, + productsCacheState, + authConsumerSession, + authSpaCredentials, + spaAccounts, + spaConsumption, + spaResponseShapes, + httpBackgroundJson, + productsBackgroundJobs, + productsCache, + fusionProducts, + authXsrfRecovery, + authXsrfCookies, + platformBackgroundApi + }; +} diff --git a/src/background/contracts.js b/src/background/contracts.js new file mode 100644 index 0000000..426917c --- /dev/null +++ b/src/background/contracts.js @@ -0,0 +1,69 @@ +function createBackgroundContracts() { + const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com"; + const ORACLE_COOKIE_URLS = [ + "https://eeho.fa.us2.oraclecloud.com/", + "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome", + "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome", + "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay" + ]; + const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F"; + const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-"; + const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list"; + const COOKIE_REFRESH_TIMEOUT_MS = 15000; + const COOKIE_REFRESH_POLL_MS = 250; + const OPPTY_PRODUCTS_CACHE_KEY = "opportunitiesExtension.opptyProductsCache.v1"; + const OPPTY_PRODUCTS_PAGE_LIMIT = 15; + const OPPTY_PRODUCTS_MAX_PAGES = 250; + const OPPTY_PRODUCTS_CONCURRENCY = 4; + const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService"; + const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html"; + const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5; + const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750; + const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000; + const ACCOUNT_SUMMARY_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-account/service/accountsummary"; + const ACCOUNT_PLANS_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/account/accountPlans"; + const CONSUMPTION_TREND_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/trend"; + const CONSUMPTION_TREND_REQUESTS = { + quarterly: "requestConsumptionServiceQuarterly", + monthly: "requestConsumptionServiceMonthly", + weekly: "requestConsumptionServiceWeekly", + daily: "requestConsumptionServiceDaily" + }; + const ACCOUNT_PLANS_PAYLOAD = { + hierarchy: "single", + products: "all", + language: "english", + planStatus: [ + "active", + "awaiting_provisioning", + "awaiting_provisioning_error", + "draft", + "expired", + "final_billing", + "onhold", + "provisioned", + "signed", + "superseded", + "superseded_pending_final_billing", + "suspended", + "terminated", + "terminated_pending_final_billing" + ] + }; + const DEBUG_BODY_LIMIT = 100000; + const OPPTY_PRODUCTS_FIELDS = [ + "ProductType", "Description", "InventoryItemId", "ProdGroupName", "ProdGroupId", "OwnerLockAsgnFlag", + "Quantity", "RecurTypeCode", "RevnAmountCurcyCode", "UnitPrice", "RevnAmount", "PriceTypeCode", + "EffectiveDate", "Name1", "NonRecurringRevenue", "OpportunityOwnerPartyName", "OpportunityOwnerResourcePartyId", + "OptyId", "OptyNumber", "PartyName2", "PrTerritoryVersionId", "PrTerritoryVersionIdForManual", + "RecurEndDate", "RecurFrequencyCode", "RecurNumberPeriods", "RecurRevenue", "ResourcePartyId", "RevnId", + "RevnNumber", "SplitPercent", "SalesCreditTypeCode", "SplitTypeCode", "TerrOwnerPartyName", "UpsideAmount", + "UsageRevenue", "StatusCode", "BUOrgId", "TypeCode", "WinProb", "ForecastType_c", "ARRLocalCurrency_c", + "CPQLastSyncDate_c", "CPQOperationType_c", "ProdGLID_c", "ProposalNumber_c", "QuoteNumber_c", + "ServicesPeriod_c", "CPQIntLastUpdatedBy_c", "CPQUpdatedAmount_c", "CPQUpdatedQuantity_c", + "CPQUpdatedRevnType_c", "CPQUpdatedServicePeriod_c", "CPQUpdatedStatus_c", "OrderNumber_c", "PrevAmount_c", + "PrevQuantity_c", "PrevRevenueType_c", "PrevServicePeriod_c", "PrevStatus_c", "SubscriptionID_c", + "WorkloadName_c", "ConsumptionStartDate_c", "RampMonths_c" + ]; + return { ORACLE_DOMAIN, ORACLE_COOKIE_URLS, XSRF_COOKIE_NAME, XSRF_COOKIE_PREFIX, OPPORTUNITIES_LIST_URL, COOKIE_REFRESH_TIMEOUT_MS, COOKIE_REFRESH_POLL_MS, OPPTY_PRODUCTS_CACHE_KEY, OPPTY_PRODUCTS_PAGE_LIMIT, OPPTY_PRODUCTS_MAX_PAGES, OPPTY_PRODUCTS_CONCURRENCY, CUSTOMER_TOKEN_SERVICE_URL, CUSTOMER_TOKEN_SESSION_URL, CUSTOMER_TOKEN_MAX_ATTEMPTS, CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS, CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS, ACCOUNT_SUMMARY_URL, ACCOUNT_PLANS_URL, CONSUMPTION_TREND_URL, CONSUMPTION_TREND_REQUESTS, ACCOUNT_PLANS_PAYLOAD, DEBUG_BODY_LIMIT, OPPTY_PRODUCTS_FIELDS }; +} diff --git a/src/background/main.js b/src/background/main.js new file mode 100644 index 0000000..0bcaff3 --- /dev/null +++ b/src/background/main.js @@ -0,0 +1,3 @@ +function startBackgroundApplication(dependencies) { + registerBackgroundMessages(dependencies); +} diff --git a/src/background/messages.js b/src/background/messages.js new file mode 100644 index 0000000..297b5ee --- /dev/null +++ b/src/background/messages.js @@ -0,0 +1,116 @@ +/** Register synchronously; replies retain the WebExtension sendResponse contract. */ +function registerBackgroundMessages({ + messageTypes, + getXsrfTokenCookie, + refreshXsrfCookie, + requestAccountPlans, + requestConsumerTokenService, + requestConsumptionTrend, + requestOpptyProducts, + runtimeApi +}) { +runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!message || !message.type) { + return false; + } + + if (message.type === messageTypes.getXsrfToken) { + getXsrfTokenCookie() + .then((result) => { + sendResponse({ + ok: true, + cookieName: result.cookie ? result.cookie.name : "", + token: result.cookie ? result.cookie.value : "", + matchedCookieNames: result.matchedCookieNames, + lookupDetails: result.lookupDetails + }); + }) + .catch((error) => { + sendResponse({ + ok: false, + cookieName: "", + token: "", + matchedCookieNames: [], + lookupDetails: [], + error: error.message || "Unable to read cookies." + }); + }); + + return true; + } + + if (message.type === messageTypes.refreshXsrfCookie) { + refreshXsrfCookie(sender) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + error: error.message || "Unable to refresh XSRF cookie." + }); + }); + + return true; + } + + if (message.type === messageTypes.requestOpptyProducts) { + requestOpptyProducts(message, sender) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + requestId: message.requestId || "", + error: error.message || "Unable to load opportunity products." + }); + }); + + return true; + } + + if (message.type === messageTypes.requestConsumerTokenService) { + requestConsumerTokenService(sender) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + error: error.message || "Unable to load consumer token service.", + debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] + }); + }); + + return true; + } + + if (message.type === messageTypes.requestAccountPlans) { + requestAccountPlans(message) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + error: error.message || "Unable to load account plans.", + debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] + }); + }); + + return true; + } + + if (message.type === messageTypes.requestConsumptionTrend) { + requestConsumptionTrend(message) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + error: error.message || "Unable to load consumption trend.", + debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : [] + }); + }); + + return true; + } + + return false; + }); +} diff --git a/src/content.js b/src/content.js deleted file mode 100644 index 5543b77..0000000 --- a/src/content.js +++ /dev/null @@ -1,7636 +0,0 @@ -(function () { - "use strict"; - - const TILE_ID = "c_5c719a88b5624b1eaf81defe2c2ex4x5"; - const TILE_LABEL_ID = `${TILE_ID}_0`; - const SALES_GROUP_SELECTOR = "#yourapps_groupNode_sales"; - const ADD_ICON_SELECTOR = ".flat-grid-cell.flat-grid-cell-addicon"; - const MODAL_ID = "opportunities-extension-modal"; - const STYLE_ID = "opportunities-extension-redwood-styles"; - const ESCAPE_LISTENER_KEY = "opportunitiesExtensionEscapeListener"; - const DEBUG_PANEL_ID = "opportunities-extension-debug-panel"; - const OPPORTUNITIES_TABLE_ID = "opportunities-extension-table"; - const STAGE_FILTERS_ID = "opportunities-extension-stage-filters"; - const STATUS_FILTERS_ID = "opportunities-extension-status-filters"; - const CUSTOMER_FILTER_BUTTON_ID = "opportunities-extension-customer-filter-button"; - const CUSTOMER_FILTER_PANEL_ID = "opportunities-extension-customer-filter-panel"; - const CUSTOMER_FILTER_SEARCH_ID = "opportunities-extension-customer-filter-search"; - const CUSTOMER_FILTER_SELECT_ALL_ID = "opportunities-extension-customer-filter-select-all"; - const CUSTOMER_FILTER_LIST_ID = "opportunities-extension-customer-filter-list"; - const OWNER_FILTER_BUTTON_ID = "opportunities-extension-owner-filter-button"; - const OWNER_FILTER_PANEL_ID = "opportunities-extension-owner-filter-panel"; - const OWNER_FILTER_SEARCH_ID = "opportunities-extension-owner-filter-search"; - const OWNER_FILTER_SELECT_ALL_ID = "opportunities-extension-owner-filter-select-all"; - const OWNER_FILTER_LIST_ID = "opportunities-extension-owner-filter-list"; - const TABLE_SEARCH_ID = "opportunities-extension-table-search"; - const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard"; - const PRODUCTS_PROGRESS_ID = "opportunities-extension-products-progress"; - const PRODUCTS_CACHE_TIMESTAMP_ID = "opportunities-extension-products-cache-timestamp"; - const FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences"; - const THEME_STORAGE_KEY = "opportunities-extension-theme"; - const OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY = "opportunities-extension-opportunity-type-preferences"; - const PREFERENCES_MODAL_ID = "opportunities-extension-preferences-modal"; - const ACCOUNT_PLANS_MODAL_ID = "opportunities-extension-account-plans-modal"; - const PRODUCTS_MESSAGE_LISTENER_KEY = "opportunitiesExtensionProductsMessageListener"; - const OPPORTUNITY_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid="; - const ACCOUNT_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/accounts/accounts-detail?id="; - const DEBUG_BODY_LIMIT = 12000; - const OPPORTUNITIES_PAGE_LIMIT = 15; - const DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS = 24; - const TOKEN_RELAY_URL = "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay"; - const OPPORTUNITIES_QUERY_URL = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/11.13.18.05/custom-actions/queries"; - const AUTH_STATUS = { - idle: "idle", - pending: "pending", - authenticated: "authenticated", - unauthenticated: "unauthenticated" - }; - const CONSUMPTION_TREND_PERIODS = [ - { value: "quarterly", label: "Quarterly", windowSize: 8 }, - { value: "monthly", label: "Monthly", windowSize: 13 }, - { value: "weekly", label: "Weekly", windowSize: 13 }, - { value: "daily", label: "Daily", windowSize: 30 } - ]; - const CONSUMPTION_TREND_SERIES = [ - { value: "funded", label: "Funded Allocation" }, - { value: "overage", label: "Overage" }, - { value: "average", label: "Average Consumption" } - ]; - const DEFAULT_CONSUMPTION_TREND_PERIOD = "weekly"; - const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; - const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"]; - const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"]; - const OPPORTUNITY_TYPE_OPTIONS = [ - { label: "All records I can see", value: "ORA_ALLOPTIES" }, - { label: "I am credit receiver", value: "ORA_CREDITRECEIVER_ISME" }, - { label: "I am on the team", value: "ORA_MYSALESTEAMOPTIES" }, - { label: "I or my subordinate is a credit receiver", value: "ORA_CREDITRECEIVER_MYORG" }, - { label: "I own", value: "ORA_MYOPTIES" }, - { label: "My subordinates are on the team", value: "ORA_MYSUBORDSSALESTEAMOPTIES" }, - { label: "My subordinates own", value: "ORA_MYSUBORDINATESOPTIES" }, - { label: "My territory", value: "ORA_MYASSGTERROPTIES" }, - { label: "My territory hierarchy", value: "ORA_MYASSGDESCTERROPTIES" }, - { label: "My territory membership", value: "ORA_MYASSGMEMBTERROPTIES" }, - { label: "My territory membership hierarchy", value: "ORA_MYASSGMEMBDESCTERROPTIES" } - ]; - const DEFAULT_OPPORTUNITY_TYPE_VALUES = [ - "ORA_MYSALESTEAMOPTIES", - "ORA_MYASSGTERROPTIES", - "ORA_CREDITRECEIVER_ISME" - ]; - const OPPTY_PRODUCTS_CACHE_OPTIONS = [ - { label: "1 hour", value: 1 }, - { label: "6 hours", value: 6 }, - { label: "12 hours", value: 12 }, - { label: "24 hours (default)", value: 24 }, - { label: "48 hours", value: 48 }, - { label: "7 days", value: 168 } - ]; - let tokenRelayRequest = null; - let consumerTokenServiceRequest = null; - let accessToken = ""; - let salesCommonToken = null; - let salesCommonTokenRequest = null; - let periodRequestVersion = 0; - let authStatus = AUTH_STATUS.idle; - let requestLog = []; - let debugSecretsVisible = false; - let selectedStages = new Set(STAGE_OPTIONS); - let selectedStatuses = new Set(STATUS_OPTIONS); - let selectedCustomerKeys = new Set(); - let customerSearch = ""; - let selectedOwnerKeys = new Set(); - let ownerSearch = ""; - let tableSearch = ""; - let selectedOpportunityTypeValues = new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES); - let opptyProductsCacheHours = DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; - let currentProductsRequestId = ""; - let productsRenderQueued = false; - let opptyProductsByNumber = new Map(); - let expandedProductOptyNumbers = new Set(); - const aiSummariesByOptyNumber = new Map(); - let productsProgress = { - state: "idle", - completed: 0, - total: 0, - failed: 0, - cacheHits: 0 - }; - let productsLastCachedAt = 0; - let accountPlansRequestState = new Map(); - let consumptionTrendCache = new Map(); - let consumptionTrendWindowOffsets = new Map(); - let consumptionTrendSeriesFilters = new Map(); - let hasSavedCustomerFilter = false; - let hasSavedOwnerFilter = false; - let customerFilterInitialized = false; - let ownerFilterInitialized = false; - let stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); - let opportunitiesTableState = { - items: [], - status: "idle", - message: "", - sortKey: "", - sortDirection: "ascending" - }; - const PERIOD_OPTIONS = [ - "Current Quarter", - "Next Quarter", - "Previous Quarter", - "Current Fiscal Year", - "4 Rolling Quarters (CQ + 3)", - "RENEWALS (Current + Past Due)" - ]; - const OPPORTUNITIES_COLUMNS = [ - { key: "name", label: "Name", value: (item) => item.Name, display: (item) => item.Name }, - { key: "optyNumber", label: "Opty Number", value: (item) => item.OptyNumber, display: (item) => item.OptyNumber }, - { key: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]) }, - { key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName }, - { key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue }, - { key: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) }, - { key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode }, - { key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability }, - { key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c }, - { key: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }, - { key: "actions", label: "Actions", value: () => "", display: () => "", sortable: false } - ]; - const ALLOWED_PATHS = [ - "/hcmUI/faces/FuseWelcome", - "/fscmUI/faces/FuseWelcome" - ]; - - if (!isAllowedPage()) { - return; - } - - ensureExtensionStyles(); - ensureProductsBackgroundMessageHandler(); - - function createOpportunitiesTile() { - const wrapper = document.createElement("div"); - wrapper.className = "flat-grid-cell"; - - const item = document.createElement("div"); - item.id = TILE_ID; - item.className = "app-nav-item opportunities-extension-tile"; - item.setAttribute("filmstrip", "Opportunities Extension"); - item.setAttribute("page", "undefined"); - item.setAttribute("index", "0"); - item.setAttribute("type", "subcluster"); - item.setAttribute("title", "Opportunities Extension"); - item.setAttribute("group", "groupNode_tools"); - item.setAttribute("destinationurl", "https://gxpap.oracle.com/ords/pgxpap/f?p=138"); - item.setAttribute("targetframe", "_blank"); - item.setAttribute("isdesturlexist", "true"); - item.setAttribute("role", "presentation"); - - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.setAttribute("viewBox", "0 0 48 48"); - svg.setAttribute("style", "fill:currentColor"); - svg.setAttribute("class", "svg-nav suiicon svg-bkgd09"); - svg.setAttribute("data-icon", "navi_reportsearch"); - svg.setAttribute("role", "presentation"); - svg.setAttribute("focusable", "false"); - - appendPath(svg, "svg-shortcut", "M28 42.5l-3 2.7v-1.7c-.4 0-1.4 0-2.5.6-1.3 1-1.5 1.6-1.5 1.6s-.4-1.2.8-2.7c1.2-1.6 2.6-1.7 3.2-1.6v-1.6l3 2.7z"); - appendPath(svg, "svg-cluster", "M28.5 41.3c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2zm-4 0c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2c-.7 0-1.2-.5-1.2-1.2s.5-1.2 1.2-1.2zm-4 0c.7 0 1.2.5 1.2 1.2s-.5 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2z"); - appendPath(svg, "svg-icon15", "M16 31l-1.6-1-3.4 6.5s0 1 .5 1.4c.5.2 1.4-.4 1.4-.4l3-6.7z"); - appendPath(svg, "svg-icon03", "M36 10H12c-.8 0-2 1.2-2 2v20c0 .4.2.8.5 1l2-3.6c-1-1.4-1.6-3-1.6-5 0-4.2 3.3-7.6 7.4-7.6H20V16h2v1.7c.7.4 1.3 1 1.8 1.5.6.5 1 1 1.3 1.8h4v7h-4c-.3.7-.8 1.4-1.4 2H35v2H20.3l-2 .2H18L17 34h19c.8 0 2-1.2 2-2V12c0-.8-1.2-2-2-2zm-23 4v-2h22v2H13zm22 14h-5V17h5v11z"); - appendPath(svg, "svg-icon12", "M18.5 19c-3 0-5.5 2.5-5.5 5.5s2.5 5.5 5.5 5.5 5.5-2.5 5.5-5.5-2.5-5.5-5.5-5.5zm0 9c-2 0-3.5-1.6-3.5-3.5 0-2 1.6-3.5 3.5-3.5s3.5 1.6 3.5 3.5c0 2-1.6 3.5-3.5 3.5z"); - appendPath(svg, "svg-outline", "M35 34.56H13a2.7 2.7 0 0 1-3-3V14a2.76 2.76 0 0 1 3-3h22a2.74 2.74 0 0 1 3 3v17.56a2.68 2.68 0 0 1-3 3zM16.98 22.32a4.72 4.72 0 1 0 4.73 4.73 4.72 4.72 0 0 0-4.73-4.73zM24 25h3.47v4.72H24V25zm5.78-4.66h4.69v9.38h-4.69v-9.38zM13.5 14.5h20.9v2.4H13.5v-2.4zm6.9 17.59l-.01-1.94zm-2.04-12.75l-5.35-.02zm2.13 12.67H35.7zm-.09-8.05L20.39 19z"); - appendPath(svg, "svg-outline", "M16.98 31.72a4.68 4.68 0 1 0-4.75-4.67 4.74 4.74 0 0 0 4.75 4.67zm-1.44-.5l-3.6 6.83zM8 40"); - - const link = document.createElement("a"); - link.id = TILE_LABEL_ID; - link.className = "app-nav-label flat-grid-nav-label"; - link.href = "#"; - link.textContent = "Opportunities Extension"; - - item.append(svg, link); - wrapper.append(item); - wrapper.addEventListener("click", handleTileClick); - - return wrapper; - } - - function appendPath(svg, className, d) { - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute("class", className); - path.setAttribute("d", d); - svg.append(path); - } - - function handleTileClick(event) { - event.preventDefault(); - event.stopPropagation(); - openOpportunitiesModal(); - } - - function openOpportunitiesModal() { - ensureExtensionStyles(); - debugSecretsVisible = false; - - const existingModal = document.getElementById(MODAL_ID); - - if (existingModal) { - existingModal.hidden = false; - existingModal.querySelector("select").focus(); - document.documentElement.classList.add("opportunities-extension-scroll-lock"); - ensureEscapeKeyHandler(); - requestTokenRelayOnce().catch(() => {}); - return; - } - - const overlay = document.createElement("div"); - requestLog = []; - tokenRelayRequest = null; - consumerTokenServiceRequest = null; - accessToken = ""; - periodRequestVersion = 0; - authStatus = AUTH_STATUS.idle; - resetOpportunitiesTable(); - selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues()); - opptyProductsCacheHours = getSavedOpptyProductsCacheHours(); - overlay.id = MODAL_ID; - overlay.className = "opportunities-extension-modal"; - overlay.setAttribute("data-theme", getSavedTheme()); - overlay.setAttribute("role", "dialog"); - overlay.setAttribute("aria-modal", "true"); - overlay.setAttribute("aria-labelledby", "opportunities-extension-title"); - overlay.setAttribute("aria-describedby", "opportunities-extension-subtitle"); - - const shell = document.createElement("section"); - shell.className = "opportunities-extension-shell"; - - const header = document.createElement("header"); - header.className = "opportunities-extension-header"; - - const title = document.createElement("h1"); - title.id = "opportunities-extension-title"; - title.textContent = "Opportunities Extension"; - - const subtitle = document.createElement("p"); - subtitle.id = "opportunities-extension-subtitle"; - subtitle.textContent = "Lista de oportunidades do HCM Opportunities List"; - - const authBadge = document.createElement("span"); - authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending"; - authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending); - authBadge.textContent = "Authenticating"; - - const subtitleRow = document.createElement("div"); - subtitleRow.className = "opportunities-extension-subtitle-row"; - - const productsProgressBadge = document.createElement("span"); - productsProgressBadge.id = PRODUCTS_PROGRESS_ID; - productsProgressBadge.className = "opportunities-extension-products-progress"; - productsProgressBadge.setAttribute("role", "status"); - productsProgressBadge.hidden = true; - - const productsCacheTimestamp = document.createElement("span"); - productsCacheTimestamp.id = PRODUCTS_CACHE_TIMESTAMP_ID; - productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp"; - productsCacheTimestamp.setAttribute("role", "status"); - productsCacheTimestamp.hidden = true; - subtitleRow.append(subtitle, authBadge, productsProgressBadge, productsCacheTimestamp); - - const titleBlock = document.createElement("div"); - titleBlock.append(title, subtitleRow); - - const closeButton = document.createElement("button"); - closeButton.type = "button"; - closeButton.className = "opportunities-extension-icon-button"; - closeButton.setAttribute("aria-label", "Fechar"); - closeButton.textContent = "X"; - closeButton.addEventListener("click", closeOpportunitiesModal); - - const themeButton = document.createElement("button"); - themeButton.type = "button"; - themeButton.className = "opportunities-extension-icon-button opportunities-extension-theme-toggle"; - themeButton.addEventListener("click", () => { - const nextTheme = overlay.getAttribute("data-theme") === "dark" ? "light" : "dark"; - applyTheme(overlay, themeButton, nextTheme); - }); - applyTheme(overlay, themeButton, overlay.getAttribute("data-theme")); - - const preferencesButton = document.createElement("button"); - preferencesButton.type = "button"; - preferencesButton.className = "opportunities-extension-icon-button"; - preferencesButton.setAttribute("aria-label", "Abrir preferências"); - preferencesButton.title = "Preferences"; - preferencesButton.append(createSettingsIcon()); - preferencesButton.addEventListener("click", openPreferencesModal); - - const headerActions = document.createElement("div"); - headerActions.className = "opportunities-extension-header-actions"; - headerActions.append(themeButton, preferencesButton, closeButton); - - header.append(titleBlock, headerActions); - - const body = document.createElement("main"); - body.className = "opportunities-extension-body"; - - const form = document.createElement("form"); - form.className = "opportunities-extension-form"; - - const field = document.createElement("label"); - field.className = "opportunities-extension-field"; - - const labelText = document.createElement("span"); - labelText.textContent = "Reference"; - - const selectWrap = document.createElement("span"); - selectWrap.className = "opportunities-extension-select-wrap"; - - const select = document.createElement("select"); - select.name = "opportunityPeriod"; - - PERIOD_OPTIONS.forEach((optionLabel) => { - const option = document.createElement("option"); - option.value = optionLabel; - option.textContent = optionLabel; - select.append(option); - }); - - select.addEventListener("change", () => { - resetCustomerAndOwnerFiltersForPeriod(); - saveFilterPreferences(select.value); - requestOpportunitiesForPeriod(select.value); - }); - - selectWrap.append(select); - field.append(labelText, selectWrap); - restoreFilterPreferences(select); - - const stageFilter = document.createElement("section"); - stageFilter.className = "opportunities-extension-stage-filter"; - - const stageFilterLabel = document.createElement("span"); - stageFilterLabel.className = "opportunities-extension-stage-filter-label"; - stageFilterLabel.textContent = "Stage"; - - const stageFilters = document.createElement("div"); - stageFilters.id = STAGE_FILTERS_ID; - stageFilters.className = "opportunities-extension-stage-filter-controls"; - stageFilters.setAttribute("role", "group"); - stageFilters.setAttribute("aria-label", "Filter by stage"); - - stageFilter.append(stageFilterLabel, stageFilters); - - const customerFilter = document.createElement("section"); - customerFilter.className = "opportunities-extension-customer-filter"; - - const customerFilterLabel = document.createElement("span"); - customerFilterLabel.className = "opportunities-extension-customer-filter-label"; - customerFilterLabel.textContent = "Customer"; - - const customerFilterButton = document.createElement("button"); - customerFilterButton.id = CUSTOMER_FILTER_BUTTON_ID; - customerFilterButton.type = "button"; - customerFilterButton.className = "opportunities-extension-customer-filter-trigger"; - customerFilterButton.setAttribute("aria-expanded", "false"); - customerFilterButton.setAttribute("aria-controls", CUSTOMER_FILTER_PANEL_ID); - customerFilterButton.addEventListener("click", toggleCustomerFilterPanel); - - const customerFilterPanel = document.createElement("section"); - customerFilterPanel.id = CUSTOMER_FILTER_PANEL_ID; - customerFilterPanel.className = "opportunities-extension-customer-filter-panel"; - customerFilterPanel.hidden = true; - - const customerSearchInput = document.createElement("input"); - customerSearchInput.id = CUSTOMER_FILTER_SEARCH_ID; - customerSearchInput.className = "opportunities-extension-customer-filter-search"; - customerSearchInput.type = "search"; - customerSearchInput.placeholder = "Search customers"; - customerSearchInput.setAttribute("aria-label", "Search customers"); - customerSearchInput.addEventListener("input", () => { - customerSearch = customerSearchInput.value; - saveFilterPreferences(); - renderCustomerFilterList(); - }); - - const selectAllLabel = document.createElement("label"); - selectAllLabel.className = "opportunities-extension-customer-filter-select-all"; - - const selectAllCheckbox = document.createElement("input"); - selectAllCheckbox.id = CUSTOMER_FILTER_SELECT_ALL_ID; - selectAllCheckbox.type = "checkbox"; - selectAllCheckbox.addEventListener("change", () => { - const customerOptions = getCustomerOptions(); - selectedCustomerKeys = selectAllCheckbox.checked - ? new Set(customerOptions.map((customer) => customer.key)) - : new Set(); - hasSavedCustomerFilter = true; - saveFilterPreferences(); - renderCustomerFilter(); - renderOpportunitiesTable(); - }); - - const selectAllText = document.createElement("span"); - selectAllText.textContent = "Select all"; - selectAllLabel.append(selectAllCheckbox, selectAllText); - - const customerFilterList = document.createElement("div"); - customerFilterList.id = CUSTOMER_FILTER_LIST_ID; - customerFilterList.className = "opportunities-extension-customer-filter-list"; - - customerFilterPanel.append(customerSearchInput, selectAllLabel, customerFilterList); - customerFilter.append(customerFilterLabel, customerFilterButton, customerFilterPanel); - - const ownerFilter = document.createElement("section"); - ownerFilter.className = "opportunities-extension-customer-filter"; - - const ownerFilterLabel = document.createElement("span"); - ownerFilterLabel.className = "opportunities-extension-customer-filter-label"; - ownerFilterLabel.textContent = "Owner"; - - const ownerFilterButton = document.createElement("button"); - ownerFilterButton.id = OWNER_FILTER_BUTTON_ID; - ownerFilterButton.type = "button"; - ownerFilterButton.className = "opportunities-extension-customer-filter-trigger"; - ownerFilterButton.setAttribute("aria-expanded", "false"); - ownerFilterButton.setAttribute("aria-controls", OWNER_FILTER_PANEL_ID); - ownerFilterButton.addEventListener("click", toggleOwnerFilterPanel); - - const ownerFilterPanel = document.createElement("section"); - ownerFilterPanel.id = OWNER_FILTER_PANEL_ID; - ownerFilterPanel.className = "opportunities-extension-customer-filter-panel"; - ownerFilterPanel.hidden = true; - - const ownerSearchInput = document.createElement("input"); - ownerSearchInput.id = OWNER_FILTER_SEARCH_ID; - ownerSearchInput.className = "opportunities-extension-customer-filter-search"; - ownerSearchInput.type = "search"; - ownerSearchInput.placeholder = "Search owners"; - ownerSearchInput.setAttribute("aria-label", "Search owners"); - ownerSearchInput.addEventListener("input", () => { - ownerSearch = ownerSearchInput.value; - saveFilterPreferences(); - renderOwnerFilterList(); - }); - - const ownerSelectAllLabel = document.createElement("label"); - ownerSelectAllLabel.className = "opportunities-extension-customer-filter-select-all"; - - const ownerSelectAllCheckbox = document.createElement("input"); - ownerSelectAllCheckbox.id = OWNER_FILTER_SELECT_ALL_ID; - ownerSelectAllCheckbox.type = "checkbox"; - ownerSelectAllCheckbox.addEventListener("change", () => { - const ownerOptions = getOwnerOptions(); - selectedOwnerKeys = ownerSelectAllCheckbox.checked - ? new Set(ownerOptions.map((owner) => owner.key)) - : new Set(); - hasSavedOwnerFilter = true; - saveFilterPreferences(); - renderOwnerFilter(); - renderOpportunitiesTable(); - }); - - const ownerSelectAllText = document.createElement("span"); - ownerSelectAllText.textContent = "Select all"; - ownerSelectAllLabel.append(ownerSelectAllCheckbox, ownerSelectAllText); - - const ownerFilterList = document.createElement("div"); - ownerFilterList.id = OWNER_FILTER_LIST_ID; - ownerFilterList.className = "opportunities-extension-customer-filter-list"; - - ownerFilterPanel.append(ownerSearchInput, ownerSelectAllLabel, ownerFilterList); - ownerFilter.append(ownerFilterLabel, ownerFilterButton, ownerFilterPanel); - - const tableSearchField = document.createElement("label"); - tableSearchField.className = "opportunities-extension-table-search-field"; - - const tableSearchLabel = document.createElement("span"); - tableSearchLabel.textContent = "Search"; - - const tableSearchInput = document.createElement("input"); - tableSearchInput.id = TABLE_SEARCH_ID; - tableSearchInput.type = "search"; - tableSearchInput.placeholder = "Search opportunities"; - tableSearchInput.setAttribute("aria-label", "Search opportunities"); - tableSearchInput.value = tableSearch; - tableSearchInput.addEventListener("input", () => { - tableSearch = tableSearchInput.value; - saveFilterPreferences(); - renderOpportunitiesTable(); - }); - - tableSearchField.append(tableSearchLabel, tableSearchInput); - - const statusFilter = document.createElement("section"); - statusFilter.className = "opportunities-extension-status-filter"; - - const statusFilterLabel = document.createElement("span"); - statusFilterLabel.className = "opportunities-extension-status-filter-label"; - statusFilterLabel.textContent = "Status"; - - const statusFilters = document.createElement("div"); - statusFilters.id = STATUS_FILTERS_ID; - statusFilters.className = "opportunities-extension-status-filter-controls"; - statusFilters.setAttribute("role", "group"); - statusFilters.setAttribute("aria-label", "Filter by status"); - - statusFilter.append(statusFilterLabel, statusFilters); - form.addEventListener("submit", (event) => event.preventDefault()); - form.append(field, customerFilter, ownerFilter, tableSearchField, statusFilter, stageFilter); - body.append(form); - - const stageDashboard = document.createElement("section"); - stageDashboard.id = STAGE_DASHBOARD_ID; - stageDashboard.className = "opportunities-extension-stage-dashboard"; - stageDashboard.setAttribute("aria-label", "Opportunity amount by stage"); - body.append(stageDashboard); - - const tableSurface = document.createElement("section"); - tableSurface.className = "opportunities-extension-table-surface"; - - const opportunitiesTable = document.createElement("table"); - opportunitiesTable.id = OPPORTUNITIES_TABLE_ID; - opportunitiesTable.className = "opportunities-extension-table"; - opportunitiesTable.setAttribute("aria-label", "Opportunities"); - tableSurface.append(opportunitiesTable); - - body.append(tableSurface); - - const debugPanel = document.createElement("aside"); - debugPanel.id = DEBUG_PANEL_ID; - debugPanel.className = "opportunities-extension-debug-panel"; - debugPanel.hidden = true; - body.append(debugPanel); - - shell.append(header, body); - overlay.append(shell); - document.body.append(overlay); - document.documentElement.classList.add("opportunities-extension-scroll-lock"); - ensureEscapeKeyHandler(); - renderStageFilterButtons(); - renderStatusFilterButtons(); - renderCustomerFilter(); - renderOwnerFilter(); - renderOpportunitiesTable(); - requestOpportunitiesForPeriod(select.value); - select.focus(); - } - - function requestTokenRelayOnce() { - if (!tokenRelayRequest) { - authStatus = AUTH_STATUS.pending; - updateAuthBadge(authStatus); - tokenRelayRequest = requestTokenRelay() - .then((token) => { - accessToken = token; - authStatus = AUTH_STATUS.authenticated; - requestConsumerTokenServiceOnce().catch(() => {}); - return token; - }) - .catch((error) => { - accessToken = ""; - authStatus = AUTH_STATUS.unauthenticated; - throw error; - }) - .finally(() => { - updateAuthBadge(authStatus); - }); - } else { - updateAuthBadge(authStatus); - } - - return tokenRelayRequest; - } - - function requestConsumerTokenServiceOnce() { - if (!consumerTokenServiceRequest) { - consumerTokenServiceRequest = sendRuntimeMessage({ - type: "opportunitiesExtension.requestConsumerTokenService" - }).then((response) => { - appendBackgroundRequestLog(response && response.debugRequests); - - if (!response || !response.ok) { - throw new Error(response && response.error - ? response.error - : "Unable to load consumer token service."); - } - - return response.authorizations; - }); - } - - return consumerTokenServiceRequest; - } - - function requestTokenRelaySalesCommon() { - if (salesCommonToken && Date.now() < salesCommonToken.expiresAt) { - return Promise.resolve(salesCommonToken.access_token); - } - if (salesCommonTokenRequest) { - return salesCommonTokenRequest; - } - salesCommonTokenRequest = requestTokenRelay({ - name: "requestTokenRelaySalesCommon", - url: `${TOKEN_RELAY_URL}?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F`, - includeExpiry: true - }).then((token) => { - salesCommonToken = token; - return token.access_token; - }).finally(() => { - salesCommonTokenRequest = null; - }); - return salesCommonTokenRequest; - } - - async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) { - let refreshResult = null; - let refreshAttempted = false; - - while (true) { - const xsrfTokenSource = await getXsrfToken(); - - if (!xsrfTokenSource.token) { - if (refreshAttempted) { - const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url); - logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh."); - throw new Error("XSRF token cookie not found after automatic refresh."); - } - - refreshResult = await refreshXsrfCookie("missing-cookie"); - refreshAttempted = true; - continue; - } - - const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url); - - try { - const response = await fetch(url, { - method: "GET", - credentials: "include", - headers: { - "x-xsrf-token": xsrfTokenSource.token - } - }); - - const responseBody = await readResponseBodyForDebug(response); - - if (!response.ok) { - logRequestSuccess(requestEntry, response, responseBody); - - if (!refreshAttempted && isInvalidXsrfStatus(response.status)) { - refreshResult = await refreshXsrfCookie(`tokenrelay-${response.status}`); - refreshAttempted = true; - requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult); - renderDebugPanel(); - continue; - } - - throw new Error(`Token relay failed with status ${response.status}.`); - } - - let responseData; - - try { - responseData = await response.json(); - } catch (error) { - logRequestSuccess(requestEntry, response, responseBody); - - if (!refreshAttempted) { - refreshResult = await refreshXsrfCookie("tokenrelay-invalid-response"); - refreshAttempted = true; - requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult); - renderDebugPanel(); - continue; - } - - throw error; - } - - if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) { - logRequestSuccess(requestEntry, response, responseBody); - - if (!refreshAttempted) { - refreshResult = await refreshXsrfCookie("tokenrelay-missing-access-token"); - refreshAttempted = true; - requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult); - renderDebugPanel(); - continue; - } - - throw new Error("Token relay response does not contain access_token."); - } - - logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({ - ...responseData, - access_token: maskToken(responseData.access_token) - }, null, 2))); - - if (includeExpiry) { - const lifetime = Number(responseData.expires_in); - return { - access_token: responseData.access_token, - expiresAt: Number.isFinite(lifetime) && lifetime > 0 - ? requestEntry.startedAt.getTime() + lifetime * 1000 : 0 - }; - } - return responseData.access_token; - } catch (error) { - logRequestFailure(requestEntry, error.message || "Request failed."); - throw error; - } - } - } - - function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name = "requestTokenRelay", url = TOKEN_RELAY_URL) { - return logRequestStart({ - name, - method: "GET", - url, - headers: { - "x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)" - }, - metadata: { - xsrfCookieName: xsrfTokenSource.cookieName || "(missing)", - xsrfTokenSource: xsrfTokenSource.source, - xsrfMatchedCookies: xsrfTokenSource.matchedCookieNames && xsrfTokenSource.matchedCookieNames.length - ? xsrfTokenSource.matchedCookieNames.join(", ") - : "(none)", - xsrfLookupDetails: xsrfTokenSource.lookupDetails && xsrfTokenSource.lookupDetails.length - ? xsrfTokenSource.lookupDetails.join(" | ") - : "(none)", - xsrfTokenError: xsrfTokenSource.error || "", - xsrfRefresh: formatXsrfRefreshResult(refreshResult) - } - }); - } - - function isInvalidXsrfStatus(status) { - return status === 400 || status === 401 || status === 403; - } - - function formatXsrfRefreshResult(result) { - if (!result) { - return "not-requested"; - } - - const outcome = result.refreshed ? "refreshed" : "not-refreshed"; - const cookieName = result.cookieName || "(none)"; - const duration = Number.isFinite(result.durationMs) ? `${result.durationMs}ms` : "unknown"; - return `${result.reason}; ${outcome}; cookie=${cookieName}; duration=${duration}; ${result.error || ""}`.trim(); - } - - async function requestOpportunitiesForPeriod(period) { - const dateRange = getFiscalPeriodRange(period, new Date()); - const requestVersion = ++periodRequestVersion; - - if (selectedOpportunityTypeValues.size === 0) { - setOpportunitiesTableState({ - items: [], - status: "empty", - message: "Select at least one Opportunity Type View in Preferences." - }); - return; - } - - if (!dateRange) { - setOpportunitiesTableState({ - items: [], - status: "empty", - message: "No opportunities to display for this period." - }); - return; - } - - try { - const token = accessToken || await requestTokenRelayOnce(); - - if (requestVersion !== periodRequestVersion) { - return; - } - - await requestOpportunities(token, period, dateRange, requestVersion); - } catch (error) { - // Authentication and request errors are recorded by their respective request handlers. - } - } - - async function requestOpportunities(token, period, dateRange, requestVersion) { - if (isCurrentPeriodRequest(requestVersion)) { - currentProductsRequestId = ""; - opptyProductsByNumber = new Map(); - expandedProductOptyNumbers = new Set(); - productsProgress = { - state: "idle", - completed: 0, - total: 0, - failed: 0, - cacheHits: 0 - }; - productsLastCachedAt = 0; - accountPlansRequestState = new Map(); - consumptionTrendCache = new Map(); - consumptionTrendWindowOffsets = new Map(); - consumptionTrendSeriesFilters = new Map(); - renderProductsProgress(); - setOpportunitiesTableState({ - items: [], - status: "loading", - message: "Loading opportunities..." - }); - } - - try { - const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1); - - if (allItems && isCurrentPeriodRequest(requestVersion)) { - setOpportunitiesTableState({ - items: allItems, - status: "ready", - message: "" - }); - requestOpptyProductsInBackground(allItems, token); - } - } catch (error) { - if (isCurrentPeriodRequest(requestVersion)) { - setOpportunitiesTableState({ - items: [], - status: "error", - message: "Unable to load opportunities." - }); - } - - throw error; - } - } - - async function requestOpportunitiesPage(token, period, dateRange, offset, accumulatedItems, requestVersion, page) { - if (!isCurrentPeriodRequest(requestVersion)) { - return null; - } - - const payload = createOpportunitiesQueryPayload(dateRange, offset); - const requestBody = JSON.stringify(payload); - const requestEntry = logRequestStart({ - name: "requestOpportunities", - method: "POST", - url: OPPORTUNITIES_QUERY_URL, - headers: { - Accept: "application/json", - Authorization: `Bearer ${maskToken(token)}`, - "Content-Type": "application/json", - Origin: "https://eeho.fa.us2.oraclecloud.com", - Preference: "transient" - }, - body: JSON.stringify(payload, null, 2), - metadata: { - period, - page, - offset, - startCloseDate: dateRange.startCloseDate, - endCloseDate: dateRange.endCloseDate - } - }); - - try { - const response = await fetch(OPPORTUNITIES_QUERY_URL, { - method: "POST", - credentials: "include", - headers: { - Accept: "application/json", - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - Origin: "https://eeho.fa.us2.oraclecloud.com", - Preference: "transient" - }, - body: requestBody - }); - - const responseBody = await readResponseBodyForDebug(response); - logRequestSuccess(requestEntry, response, responseBody); - - if (!response.ok) { - throw new Error(`Opportunities query failed with status ${response.status}.`); - } - - const responseData = await response.json(); - const pageItems = Array.isArray(responseData.items) ? responseData.items : []; - const allItems = accumulatedItems.concat(pageItems); - - if (!responseData.hasMore) { - return allItems; - } - - const responseOffset = Number(responseData.offset); - const responseCount = Number(responseData.count); - const receivedCount = Number.isFinite(responseCount) ? responseCount : pageItems.length; - const currentOffset = Number.isFinite(responseOffset) ? responseOffset : offset; - - if (receivedCount <= 0) { - throw new Error("Opportunities query returned hasMore without additional results."); - } - - return requestOpportunitiesPage( - token, - period, - dateRange, - currentOffset + receivedCount, - allItems, - requestVersion, - page + 1 - ); - } catch (error) { - logRequestFailure(requestEntry, error.message || "Request failed."); - throw error; - } - } - - function requestOpptyProductsInBackground(opportunities, token) { - const optyNumbers = Array.from(new Set(opportunities - .map((item) => item && item.OptyNumber) - .filter(Boolean) - .map(String))); - - const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - currentProductsRequestId = requestId; - opptyProductsByNumber = new Map(); - expandedProductOptyNumbers = new Set(); - productsProgress = { - state: optyNumbers.length ? "running" : "complete", - completed: 0, - total: optyNumbers.length, - failed: 0, - cacheHits: 0 - }; - renderProductsProgress(); - - if (optyNumbers.length === 0) { - return; - } - - sendRuntimeMessage({ - type: "opportunitiesExtension.requestOpptyProducts", - requestId, - accessToken: token, - optyNumbers, - cacheMaxAgeMs: opptyProductsCacheHours * 60 * 60 * 1000 - }).then((response) => { - if (response && response.ok === false && response.requestId === currentProductsRequestId && productsProgress.state === "running") { - productsProgress.state = "error"; - productsProgress.failed = productsProgress.total; - renderProductsProgress(); - } - }).catch(() => { - if (requestId === currentProductsRequestId && productsProgress.state === "running") { - productsProgress.state = "error"; - productsProgress.failed = productsProgress.total; - renderProductsProgress(); - } - }); - } - - function ensureProductsBackgroundMessageHandler() { - if (window[PRODUCTS_MESSAGE_LISTENER_KEY]) { - return; - } - - const runtimeApi = typeof browser !== "undefined" ? browser : chrome; - - if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.onMessage) { - return; - } - - window[PRODUCTS_MESSAGE_LISTENER_KEY] = true; - runtimeApi.runtime.onMessage.addListener((message) => { - if (!message || message.type !== "opportunitiesExtension.opptyProductsProgress" || message.requestId !== currentProductsRequestId) { - return false; - } - - if (message.optyNumber) { - opptyProductsByNumber.set(String(message.optyNumber), { - items: Array.isArray(message.items) ? message.items : [], - fromCache: Boolean(message.fromCache), - cachedAt: Number(message.cachedAt) || 0, - error: message.error || "" - }); - } - - if (Number(message.cachedAt) > productsLastCachedAt) { - productsLastCachedAt = Number(message.cachedAt); - } - - productsProgress = { - state: message.state || productsProgress.state, - completed: Number(message.completed) || 0, - total: Number(message.total) || productsProgress.total, - failed: Number(message.failed) || 0, - cacheHits: Number(message.cacheHits) || 0 - }; - renderProductsProgress(); - queueProductsTableRender(); - return false; - }); - } - - function sendRuntimeMessage(message) { - const runtimeApi = typeof browser !== "undefined" ? browser : chrome; - - if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { - return Promise.reject(new Error("Runtime messaging API unavailable.")); - } - - if (typeof browser !== "undefined") { - return runtimeApi.runtime.sendMessage(message); - } - - return new Promise((resolve, reject) => { - runtimeApi.runtime.sendMessage(message, (response) => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - reject(new Error(lastError.message)); - return; - } - - resolve(response); - }); - }); - } - - function renderProductsProgress() { - const badge = document.getElementById(PRODUCTS_PROGRESS_ID); - - if (!badge) { - return; - } - - badge.hidden = productsProgress.state === "idle"; - badge.setAttribute("data-state", productsProgress.state); - - if (productsProgress.state === "running") { - badge.textContent = `Products ${productsProgress.completed}/${productsProgress.total}`; - badge.title = `${productsProgress.cacheHits} loaded from cache`; - } else if (productsProgress.state === "complete") { - badge.textContent = `Products ready ${productsProgress.total}`; - badge.title = `${productsProgress.cacheHits} loaded from cache`; - } else if (productsProgress.state === "complete-with-errors") { - badge.textContent = `Products ${productsProgress.total - productsProgress.failed}/${productsProgress.total}`; - badge.title = `${productsProgress.failed} product requests failed`; - } else if (productsProgress.state === "error") { - badge.textContent = "Products unavailable"; - badge.title = "Unable to load opportunity products"; - } else { - badge.textContent = "Products ready 0"; - } - - const timestamp = document.getElementById(PRODUCTS_CACHE_TIMESTAMP_ID); - - if (timestamp) { - timestamp.hidden = !productsLastCachedAt; - timestamp.textContent = productsLastCachedAt - ? `Last update: ${formatProductsCacheTimestamp(productsLastCachedAt)}` - : ""; - timestamp.title = productsLastCachedAt - ? `Latest product update: ${formatProductsCacheTimestamp(productsLastCachedAt)}` - : ""; - } - } - - function formatProductsCacheTimestamp(timestamp) { - try { - return new Intl.DateTimeFormat("en-GB", { - dateStyle: "short", - timeStyle: "short" - }).format(new Date(timestamp)); - } catch (error) { - return new Date(timestamp).toLocaleString(); - } - } - - function queueProductsTableRender() { - if (productsRenderQueued) { - return; - } - - productsRenderQueued = true; - window.requestAnimationFrame(() => { - productsRenderQueued = false; - renderOpportunitiesTable(); - }); - } - - function resetOpportunitiesTable() { - selectedStages = new Set(STAGE_OPTIONS); - selectedStatuses = new Set(STATUS_OPTIONS); - selectedCustomerKeys = new Set(); - customerSearch = ""; - selectedOwnerKeys = new Set(); - ownerSearch = ""; - tableSearch = ""; - hasSavedCustomerFilter = false; - hasSavedOwnerFilter = false; - customerFilterInitialized = false; - ownerFilterInitialized = false; - currentProductsRequestId = ""; - opptyProductsByNumber = new Map(); - expandedProductOptyNumbers = new Set(); - productsProgress = { - state: "idle", - completed: 0, - total: 0, - failed: 0, - cacheHits: 0 - }; - productsLastCachedAt = 0; - accountPlansRequestState = new Map(); - consumptionTrendCache = new Map(); - consumptionTrendWindowOffsets = new Map(); - consumptionTrendSeriesFilters = new Map(); - stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); - opportunitiesTableState = { - items: [], - status: "idle", - message: "", - sortKey: "", - sortDirection: "ascending" - }; - } - - function resetCustomerAndOwnerFiltersForPeriod() { - selectedCustomerKeys = new Set(); - selectedOwnerKeys = new Set(); - customerSearch = ""; - ownerSearch = ""; - hasSavedCustomerFilter = false; - hasSavedOwnerFilter = false; - customerFilterInitialized = false; - ownerFilterInitialized = false; - } - - function restoreFilterPreferences(select) { - try { - const rawPreferences = localStorage.getItem(FILTER_PREFERENCES_STORAGE_KEY); - - if (!rawPreferences) { - return; - } - - const preferences = JSON.parse(rawPreferences); - const savedStages = Array.isArray(preferences.stages) - ? preferences.stages.filter((stage) => STAGE_OPTIONS.includes(stage)) - : null; - const savedStatuses = Array.isArray(preferences.statuses) - ? preferences.statuses.filter((status) => STATUS_OPTIONS.includes(status)) - : null; - - if (savedStages) { - selectedStages = new Set(savedStages); - } - - if (savedStatuses) { - selectedStatuses = new Set(savedStatuses); - } - - if (Array.isArray(preferences.customerKeys)) { - selectedCustomerKeys = new Set(preferences.customerKeys.map(String)); - hasSavedCustomerFilter = true; - customerFilterInitialized = true; - } - - if (Array.isArray(preferences.ownerKeys)) { - selectedOwnerKeys = new Set(preferences.ownerKeys.map(String)); - hasSavedOwnerFilter = true; - ownerFilterInitialized = true; - } - - customerSearch = typeof preferences.customerSearch === "string" ? preferences.customerSearch : ""; - ownerSearch = typeof preferences.ownerSearch === "string" ? preferences.ownerSearch : ""; - tableSearch = typeof preferences.tableSearch === "string" ? preferences.tableSearch : ""; - - if (PERIOD_OPTIONS.includes(preferences.period)) { - select.value = preferences.period; - } - } catch (error) { - // Ignore malformed or unavailable browser storage and use default filters. - } - } - - function saveFilterPreferences(period) { - try { - const currentPeriod = period || document.querySelector(`#${MODAL_ID} select`)?.value || PERIOD_OPTIONS[0]; - localStorage.setItem(FILTER_PREFERENCES_STORAGE_KEY, JSON.stringify({ - period: currentPeriod, - stages: Array.from(selectedStages), - statuses: Array.from(selectedStatuses), - customerKeys: hasSavedCustomerFilter ? Array.from(selectedCustomerKeys) : null, - ownerKeys: hasSavedOwnerFilter ? Array.from(selectedOwnerKeys) : null, - customerSearch, - ownerSearch, - tableSearch - })); - } catch (error) { - // Ignore unavailable browser storage; filters still work for the current modal. - } - } - - function setOpportunitiesTableState(nextState) { - if (nextState.status === "ready" && Array.isArray(nextState.items)) { - resetCustomerFilter(nextState.items); - resetOwnerFilter(nextState.items); - } - - opportunitiesTableState = { - ...opportunitiesTableState, - ...nextState, - sortKey: nextState.items ? "" : opportunitiesTableState.sortKey, - sortDirection: nextState.items ? "ascending" : opportunitiesTableState.sortDirection - }; - renderOpportunitiesTable(); - renderCustomerFilter(); - renderOwnerFilter(); - } - - function renderOpportunitiesTable() { - const table = document.getElementById(OPPORTUNITIES_TABLE_ID); - - if (!table) { - return; - } - - table.textContent = ""; - table.setAttribute("aria-busy", opportunitiesTableState.status === "loading" ? "true" : "false"); - - const caption = document.createElement("caption"); - caption.className = "opportunities-extension-visually-hidden"; - caption.textContent = "Opportunities"; - - const tableHead = document.createElement("thead"); - const headerRow = document.createElement("tr"); - - OPPORTUNITIES_COLUMNS.forEach((column) => { - const header = document.createElement("th"); - const isSorted = opportunitiesTableState.sortKey === column.key; - header.scope = "col"; - - if (column.sortable === false) { - const label = document.createElement("span"); - label.className = "opportunities-extension-table-header-label"; - label.textContent = column.label; - header.append(label); - } else { - header.setAttribute("aria-sort", isSorted ? opportunitiesTableState.sortDirection : "none"); - - const sortButton = document.createElement("button"); - sortButton.type = "button"; - sortButton.className = "opportunities-extension-sort-button"; - sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.sortDirection : "none"); - sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.sortDirection}` : ""}`); - sortButton.textContent = column.label; - sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key)); - header.append(sortButton); - } - - headerRow.append(header); - }); - - tableHead.append(headerRow); - - const tableBody = document.createElement("tbody"); - const items = getSortedOpportunities(); - renderStageDashboard(items); - - if (opportunitiesTableState.status === "loading" || opportunitiesTableState.status === "error" || opportunitiesTableState.status === "empty" || (opportunitiesTableState.status === "ready" && items.length === 0)) { - const row = document.createElement("tr"); - const cell = document.createElement("td"); - cell.className = "opportunities-extension-table-status"; - cell.colSpan = OPPORTUNITIES_COLUMNS.length; - cell.textContent = opportunitiesTableState.message || "No opportunities found."; - row.append(cell); - tableBody.append(row); - } else { - items.forEach((item) => { - const row = document.createElement("tr"); - - OPPORTUNITIES_COLUMNS.forEach((column) => { - const cell = document.createElement("td"); - - if (column.key === "stage") { - cell.append(createStageBadge(column.display(item))); - } else if (column.key === "status") { - cell.append(createStatusBadge(column.display(item))); - } else if (column.key === "actions") { - const actionsContent = document.createElement("span"); - actionsContent.className = "opportunities-extension-actions-content"; - const productsResult = opptyProductsByNumber.get(String(item.OptyNumber)); - - if (productsResult) { - actionsContent.append(createProductsCountButton(item.OptyNumber, productsResult)); - } - - actionsContent.append(createCloudConsumptionButton(item)); - cell.append(actionsContent); - } else if (column.key === "name" || column.key === "optyNumber") { - const opportunityLink = createDetailLink( - column.display(item), - item.OptyNumber ? `${OPPORTUNITY_DETAIL_URL}${encodeURIComponent(item.OptyNumber)}` : "" - ); - - if (column.key === "name" && item.OptyNumber) { - const nameCellContent = document.createElement("span"); - nameCellContent.className = "opportunities-extension-name-content"; - nameCellContent.append(opportunityLink); - cell.append(nameCellContent); - } else if (column.key === "optyNumber" && item.OptyNumber) { - const opportunityCellContent = document.createElement("span"); - opportunityCellContent.className = "opportunities-extension-opty-number-content"; - opportunityCellContent.append(opportunityLink, createCopyOpportunityButton(item.OptyNumber)); - cell.append(opportunityCellContent); - } else { - cell.append(opportunityLink); - } - } else if (column.key === "customer") { - cell.append(createDetailLink( - column.display(item), - getNestedValue(item, ["CustomerAccount", "PartyId"]) - ? `${ACCOUNT_DETAIL_URL}${encodeURIComponent(getNestedValue(item, ["CustomerAccount", "PartyId"]))}` - : "" - )); - } else { - cell.textContent = displayOpportunityValue(column, item); - } - - row.append(cell); - }); - - tableBody.append(row); - - if (item.OptyNumber && expandedProductOptyNumbers.has(String(item.OptyNumber))) { - tableBody.append(createProductsDetailRow(item)); - } - }); - } - - table.append(caption, tableHead, tableBody); - } - - function renderStageDashboard(items) { - const dashboard = document.getElementById(STAGE_DASHBOARD_ID); - - if (!dashboard) { - return; - } - - dashboard.textContent = ""; - - const totalCard = document.createElement("section"); - const totalLabel = document.createElement("span"); - const totalValue = document.createElement("strong"); - - totalCard.className = "opportunities-extension-stage-dashboard-card opportunities-extension-total-dashboard-card"; - totalCard.setAttribute("data-stage", "TOTAL"); - totalLabel.textContent = "Total Opportunities"; - totalValue.className = "opportunities-extension-stage-dashboard-value"; - totalValue.setAttribute("aria-label", "Total opportunities"); - animateStageDashboardValue( - totalValue, - "TOTAL", - items.length, - (amount) => Math.round(amount).toLocaleString() - ); - totalCard.append(totalLabel, totalValue); - dashboard.append(totalCard); - - STAGE_OPTIONS.forEach((stage) => { - const stageItems = items.filter((item) => item.ForecastGroup_c === stage); - const amount = stageItems.reduce((total, item) => { - const value = Number(getNestedValue(item, ["PrimaryRevenue", "RevnAmount"])); - return Number.isFinite(value) ? total + value : total; - }, 0); - const card = document.createElement("section"); - const label = document.createElement("span"); - const value = document.createElement("strong"); - - card.className = "opportunities-extension-stage-dashboard-card"; - card.setAttribute("data-stage", stage); - label.textContent = stage; - value.className = "opportunities-extension-stage-dashboard-value"; - value.setAttribute("aria-label", `${stage} total amount`); - - animateStageDashboardValue( - value, - stage, - amount, - createStageAmountFormatter(stageItems) - ); - - card.append(label, value); - dashboard.append(card); - }); - } - - function createStageAmountFormatter(items) { - const currencies = new Set(items - .map((item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"])) - .filter(Boolean)); - - if (currencies.size === 1) { - const [currency] = currencies; - - try { - const formatter = new Intl.NumberFormat(undefined, { - style: "currency", - currency, - maximumFractionDigits: 0 - }); - return (amount) => formatter.format(amount); - } catch (error) { - // Fall through to a neutral numeric total when the currency code is invalid. - } - } - - return (amount) => amount.toLocaleString(undefined, { - maximumFractionDigits: 0 - }); - } - - function animateStageDashboardValue(element, stage, target, format) { - const current = stageDashboardAmounts.get(stage) || 0; - stageDashboardAmounts.set(stage, target); - - if (current === target) { - element.textContent = format(target); - return; - } - - const startTime = performance.now(); - const duration = 480; - - function tick(now) { - const progress = Math.min((now - startTime) / duration, 1); - const easedProgress = 1 - Math.pow(1 - progress, 3); - const value = current + ((target - current) * easedProgress); - - element.textContent = format(value); - - if (progress < 1) { - requestAnimationFrame(tick); - } else { - element.textContent = format(target); - } - } - - requestAnimationFrame(tick); - } - - function sortOpportunitiesBy(key) { - const isSameColumn = opportunitiesTableState.sortKey === key; - opportunitiesTableState.sortKey = key; - opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.sortDirection === "ascending" - ? "descending" - : "ascending"; - renderOpportunitiesTable(); - } - - function renderStageFilterButtons() { - const controls = document.getElementById(STAGE_FILTERS_ID); - - if (!controls) { - return; - } - - controls.textContent = ""; - - STAGE_OPTIONS.forEach((stage) => { - const button = document.createElement("button"); - const isPressed = selectedStages.has(stage); - - button.type = "button"; - button.className = "opportunities-extension-stage-filter-button"; - button.setAttribute("data-stage", stage); - button.setAttribute("aria-pressed", isPressed ? "true" : "false"); - button.textContent = stage; - button.addEventListener("click", () => toggleStageFilter(stage)); - controls.append(button); - }); - } - - function toggleStageFilter(stage) { - if (selectedStages.has(stage)) { - selectedStages.delete(stage); - } else { - selectedStages.add(stage); - } - - saveFilterPreferences(); - renderStageFilterButtons(); - renderOpportunitiesTable(); - } - - function renderStatusFilterButtons() { - const controls = document.getElementById(STATUS_FILTERS_ID); - - if (!controls) { - return; - } - - controls.textContent = ""; - - STATUS_OPTIONS.forEach((status) => { - const button = document.createElement("button"); - const isPressed = selectedStatuses.has(status); - - button.type = "button"; - button.className = "opportunities-extension-status-filter-button"; - button.setAttribute("data-status", status); - button.setAttribute("aria-pressed", isPressed ? "true" : "false"); - button.textContent = status; - button.addEventListener("click", () => toggleStatusFilter(status)); - controls.append(button); - }); - } - - function toggleStatusFilter(status) { - if (selectedStatuses.has(status)) { - selectedStatuses.delete(status); - } else { - selectedStatuses.add(status); - } - - saveFilterPreferences(); - renderStatusFilterButtons(); - renderOpportunitiesTable(); - } - - function toggleCustomerFilterPanel() { - const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); - const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID); - - if (!panel || !button || button.disabled) { - return; - } - - panel.hidden = !panel.hidden; - button.setAttribute("aria-expanded", panel.hidden ? "false" : "true"); - - if (!panel.hidden) { - const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID); - searchInput.focus(); - } - } - - function closeCustomerFilterPanel(restoreFocus = true) { - const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); - const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID); - - if (!panel || panel.hidden) { - return false; - } - - panel.hidden = true; - button.setAttribute("aria-expanded", "false"); - - if (restoreFocus) { - button.focus(); - } - - return true; - } - - function toggleOwnerFilterPanel() { - const panel = document.getElementById(OWNER_FILTER_PANEL_ID); - const button = document.getElementById(OWNER_FILTER_BUTTON_ID); - - if (!panel || !button || button.disabled) { - return; - } - - panel.hidden = !panel.hidden; - button.setAttribute("aria-expanded", panel.hidden ? "false" : "true"); - - if (!panel.hidden) { - const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID); - searchInput.focus(); - } - } - - function closeOwnerFilterPanel(restoreFocus = true) { - const panel = document.getElementById(OWNER_FILTER_PANEL_ID); - const button = document.getElementById(OWNER_FILTER_BUTTON_ID); - - if (!panel || panel.hidden) { - return false; - } - - panel.hidden = true; - button.setAttribute("aria-expanded", "false"); - - if (restoreFocus) { - button.focus(); - } - - return true; - } - - function resetCustomerFilter(items) { - const customerOptions = getCustomerOptions(items); - if (!customerFilterInitialized) { - selectedCustomerKeys = new Set(customerOptions.map((customer) => customer.key)); - customerFilterInitialized = true; - } - } - - function resetOwnerFilter(items) { - const ownerOptions = getOwnerOptions(items); - if (!ownerFilterInitialized) { - selectedOwnerKeys = new Set(ownerOptions.map((owner) => owner.key)); - ownerFilterInitialized = true; - } - } - - function renderCustomerFilter() { - const trigger = document.getElementById(CUSTOMER_FILTER_BUTTON_ID); - const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID); - const selectAllCheckbox = document.getElementById(CUSTOMER_FILTER_SELECT_ALL_ID); - const customerOptions = getCustomerOptions(); - - if (!trigger || !searchInput || !selectAllCheckbox) { - return; - } - - const selectedCount = customerOptions.filter((customer) => selectedCustomerKeys.has(customer.key)).length; - trigger.disabled = customerOptions.length === 0; - trigger.textContent = getCustomerFilterSummary(customerOptions.length, selectedCount); - searchInput.value = customerSearch; - selectAllCheckbox.disabled = customerOptions.length === 0; - selectAllCheckbox.checked = customerOptions.length > 0 && selectedCount === customerOptions.length; - selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < customerOptions.length; - renderCustomerFilterList(); - } - - function renderCustomerFilterList() { - const list = document.getElementById(CUSTOMER_FILTER_LIST_ID); - - if (!list) { - return; - } - - const normalizedSearch = customerSearch.trim().toLocaleLowerCase(); - const customerOptions = getCustomerOptions().filter((customer) => { - return customer.label.toLocaleLowerCase().includes(normalizedSearch); - }); - - list.textContent = ""; - - if (customerOptions.length === 0) { - const empty = document.createElement("p"); - empty.className = "opportunities-extension-customer-filter-empty"; - empty.textContent = customerSearch ? "No matching customers." : "No customers available."; - list.append(empty); - return; - } - - customerOptions.forEach((customer) => { - const option = document.createElement("label"); - option.className = "opportunities-extension-customer-filter-option"; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.checked = selectedCustomerKeys.has(customer.key); - checkbox.addEventListener("change", () => { - if (checkbox.checked) { - selectedCustomerKeys.add(customer.key); - } else { - selectedCustomerKeys.delete(customer.key); - } - - hasSavedCustomerFilter = true; - saveFilterPreferences(); - renderCustomerFilter(); - renderOpportunitiesTable(); - }); - - const label = document.createElement("span"); - label.textContent = customer.label; - option.append(checkbox, label); - list.append(option); - }); - } - - function getCustomerOptions(items) { - const optionsByKey = new Map(); - - (items || opportunitiesTableState.items).forEach((item) => { - const key = getCustomerKey(item); - const label = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]); - - if (key && label && !optionsByKey.has(key)) { - optionsByKey.set(key, { key, label: String(label) }); - } - }); - - return Array.from(optionsByKey.values()).sort((first, second) => { - return first.label.localeCompare(second.label, undefined, { - sensitivity: "base" - }); - }); - } - - function getCustomerKey(item) { - const partyId = getNestedValue(item, ["CustomerAccount", "PartyId"]); - const partyName = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]); - - if (partyId !== null && partyId !== undefined && partyId !== "") { - return `party:${partyId}`; - } - - return partyName ? `name:${partyName}` : ""; - } - - function getOwnerName(item) { - const owner = item.Owner; - - if (typeof owner === "string" || typeof owner === "number") { - return owner; - } - - if (owner && typeof owner === "object") { - const firstName = owner.PersonFirstName; - const lastName = owner.PersonLastName; - - if (firstName || lastName) { - return [firstName, lastName].filter(Boolean).join(" "); - } - - return owner.PartyName - || owner.PartyUniqueName - || owner.Name - || owner.DisplayName - || owner.ResourceName - || owner.OwnerName - || ""; - } - - return ""; - } - - function getCustomerFilterSummary(total, selected) { - if (total === 0) { - return "No customers"; - } - - if (selected === total) { - return "All customers"; - } - - if (selected === 0) { - return "No customers"; - } - - return `${selected} customer${selected === 1 ? "" : "s"}`; - } - - function renderOwnerFilter() { - const trigger = document.getElementById(OWNER_FILTER_BUTTON_ID); - const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID); - const selectAllCheckbox = document.getElementById(OWNER_FILTER_SELECT_ALL_ID); - const ownerOptions = getOwnerOptions(); - - if (!trigger || !searchInput || !selectAllCheckbox) { - return; - } - - const selectedCount = ownerOptions.filter((owner) => selectedOwnerKeys.has(owner.key)).length; - trigger.disabled = ownerOptions.length === 0; - trigger.textContent = getCustomerFilterSummary(ownerOptions.length, selectedCount) - .replace("customer", "owner"); - searchInput.value = ownerSearch; - selectAllCheckbox.disabled = ownerOptions.length === 0; - selectAllCheckbox.checked = ownerOptions.length > 0 && selectedCount === ownerOptions.length; - selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < ownerOptions.length; - renderOwnerFilterList(); - } - - function renderOwnerFilterList() { - const list = document.getElementById(OWNER_FILTER_LIST_ID); - - if (!list) { - return; - } - - const normalizedSearch = ownerSearch.trim().toLocaleLowerCase(); - const ownerOptions = getOwnerOptions().filter((owner) => { - return owner.label.toLocaleLowerCase().includes(normalizedSearch); - }); - - list.textContent = ""; - - if (ownerOptions.length === 0) { - const empty = document.createElement("p"); - empty.className = "opportunities-extension-customer-filter-empty"; - empty.textContent = ownerSearch ? "No matching owners." : "No owners available."; - list.append(empty); - return; - } - - ownerOptions.forEach((owner) => { - const option = document.createElement("label"); - option.className = "opportunities-extension-customer-filter-option"; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.checked = selectedOwnerKeys.has(owner.key); - checkbox.addEventListener("change", () => { - if (checkbox.checked) { - selectedOwnerKeys.add(owner.key); - } else { - selectedOwnerKeys.delete(owner.key); - } - - hasSavedOwnerFilter = true; - saveFilterPreferences(); - renderOwnerFilter(); - renderOpportunitiesTable(); - }); - - const label = document.createElement("span"); - label.textContent = owner.label; - option.append(checkbox, label); - list.append(option); - }); - } - - function getOwnerOptions(items) { - const optionsByKey = new Map(); - - (items || opportunitiesTableState.items).forEach((item) => { - const key = getOwnerKey(item); - const label = getOwnerName(item); - - if (key && label && !optionsByKey.has(key)) { - optionsByKey.set(key, { key, label: String(label) }); - } - }); - - return Array.from(optionsByKey.values()).sort((first, second) => { - return first.label.localeCompare(second.label, undefined, { - sensitivity: "base" - }); - }); - } - - function getOwnerKey(item) { - const owner = item.Owner; - const partyId = owner && typeof owner === "object" ? owner.PartyId : ""; - const name = getOwnerName(item); - - if (partyId !== null && partyId !== undefined && partyId !== "") { - return `party:${partyId}`; - } - - return name ? `name:${name}` : ""; - } - - function getSortedOpportunities() { - const normalizedSearch = tableSearch.trim().toLocaleLowerCase(); - const items = opportunitiesTableState.items.filter((item) => { - return selectedStages.has(item.ForecastGroup_c) - && matchesSelectedStatus(item.StatusCode) - && selectedCustomerKeys.has(getCustomerKey(item)) - && selectedOwnerKeys.has(getOwnerKey(item)) - && matchesTableSearch(item, normalizedSearch); - }); - const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.sortKey); - - if (!column) { - return items; - } - - const direction = opportunitiesTableState.sortDirection === "ascending" ? 1 : -1; - - return items.sort((first, second) => { - const firstValue = column.value(first); - const secondValue = column.value(second); - const firstIsEmpty = firstValue === null || firstValue === undefined || firstValue === ""; - const secondIsEmpty = secondValue === null || secondValue === undefined || secondValue === ""; - - if (firstIsEmpty || secondIsEmpty) { - if (firstIsEmpty && secondIsEmpty) { - return 0; - } - - return firstIsEmpty ? 1 : -1; - } - - const firstNumber = Number(firstValue); - const secondNumber = Number(secondValue); - - if (Number.isFinite(firstNumber) && Number.isFinite(secondNumber)) { - return (firstNumber - secondNumber) * direction; - } - - return String(firstValue).localeCompare(String(secondValue), undefined, { - numeric: true, - sensitivity: "base" - }) * direction; - }); - } - - function matchesTableSearch(item, normalizedSearch) { - if (!normalizedSearch) { - return true; - } - - return OPPORTUNITIES_COLUMNS.some((column) => { - const rawValue = stringifySearchValue(column.value(item)); - const displayValue = stringifySearchValue(column.display(item)); - return `${rawValue} ${displayValue}`.toLocaleLowerCase().includes(normalizedSearch); - }); - } - - function matchesSelectedStatus(statusCode) { - if (statusCode === "WON_PENDING") { - return selectedStatuses.has("WON"); - } - - return selectedStatuses.has(statusCode); - } - - function stringifySearchValue(value) { - if (value === null || value === undefined) { - return ""; - } - - if (typeof value === "object") { - return JSON.stringify(value); - } - - return String(value); - } - - function displayOpportunityValue(column, item) { - const value = column.display(item); - return value === null || value === undefined || value === "" ? "-" : String(value); - } - - function createDetailLink(label, href) { - if (label === null || label === undefined || label === "") { - const placeholder = document.createElement("span"); - placeholder.textContent = "-"; - return placeholder; - } - - if (!href) { - const text = document.createElement("span"); - text.textContent = String(label); - return text; - } - - const link = document.createElement("a"); - link.href = href; - link.target = "_blank"; - link.rel = "noopener noreferrer"; - link.textContent = String(label); - return link; - } - - function createCopyOpportunityButton(optyNumber) { - const button = document.createElement("button"); - const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - - button.type = "button"; - button.className = "opportunities-extension-copy-button"; - button.setAttribute("aria-label", `Copy opportunity number ${optyNumber}`); - button.title = "Copy opportunity number"; - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - copyOpportunityNumber(String(optyNumber)); - }); - - icon.setAttribute("viewBox", "0 0 24 24"); - icon.setAttribute("aria-hidden", "true"); - path.setAttribute("d", "M8 8V5.5A2.5 2.5 0 0 1 10.5 3h8A2.5 2.5 0 0 1 21 5.5v8a2.5 2.5 0 0 1-2.5 2.5H16v2.5a2.5 2.5 0 0 1-2.5 2.5h-8A2.5 2.5 0 0 1 3 18.5v-8A2.5 2.5 0 0 1 5.5 8H8zm2 0h3.5A2.5 2.5 0 0 1 16 10.5v3.5h2.5a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5V8zm3.5 2h-8a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5z"); - icon.append(path); - button.append(icon); - return button; - } - - function createCloudConsumptionButton(opportunity) { - const button = document.createElement("button"); - const optyNumber = String(opportunity.OptyNumber || ""); - const partyNumber = getNestedValue(opportunity, ["CustomerAccount", "PartyNumber"]); - const requestState = accountPlansRequestState.get(optyNumber); - const isLoading = requestState && requestState.status === "loading"; - - button.type = "button"; - button.className = "opportunities-extension-action-button"; - button.setAttribute("aria-label", `View cloud consumption for ${optyNumber || "opportunity"}`); - button.title = isLoading ? "Loading customer consumption" : "View customer consumption"; - button.disabled = Boolean(isLoading); - button.classList.toggle("opportunities-extension-action-button-loading", Boolean(isLoading)); - button.append(createCloudConsumptionIcon()); - button.addEventListener("click", () => { - if (!partyNumber) { - showCopyConfirmation("Customer Party Number is unavailable.", true); - return; - } - - accountPlansRequestState.set(optyNumber, { status: "loading" }); - openAccountPlansModal(opportunity, [], "loading"); - renderOpportunitiesTable(); - requestConsumerTokenServiceOnce() - .then((authorizations) => sendRuntimeMessage({ - type: "opportunitiesExtension.requestAccountPlans", - partyNumber: String(partyNumber), - authorizations - })) - .then((response) => { - appendBackgroundRequestLog(response && response.debugRequests); - - if (!response || !response.ok) { - throw new Error(response && response.error ? response.error : "Unable to load cloud consumption."); - } - - accountPlansRequestState.set(optyNumber, { - status: "ready", - plans: Array.isArray(response.plans) ? response.plans : [] - }); - renderOpportunitiesTable(); - openAccountPlansModal(opportunity, response.plans || [], "ready"); - }).catch((error) => { - const errorMessage = error.message || "Unable to load cloud consumption."; - accountPlansRequestState.set(optyNumber, { - status: "error", - error: errorMessage - }); - renderOpportunitiesTable(); - openAccountPlansModal(opportunity, [], "error", errorMessage); - showCopyConfirmation(errorMessage, true); - }); - }); - return button; - } - - function createCloudConsumptionIcon() { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - const cloud = document.createElementNS("http://www.w3.org/2000/svg", "path"); - const usage = document.createElementNS("http://www.w3.org/2000/svg", "path"); - - svg.setAttribute("viewBox", "0 0 24 24"); - svg.setAttribute("aria-hidden", "true"); - svg.setAttribute("focusable", "false"); - cloud.setAttribute("d", "M7.2 18.5h10.1a4.2 4.2 0 0 0 .5-8.37A6.25 6.25 0 0 0 5.9 8.75a4.9 4.9 0 0 0 1.3 9.75Z"); - usage.setAttribute("d", "M9 15v-2m3 2v-4m3 4V9"); - - [cloud, usage].forEach((path) => { - path.setAttribute("fill", "none"); - path.setAttribute("stroke", "currentColor"); - path.setAttribute("stroke-linecap", "round"); - path.setAttribute("stroke-linejoin", "round"); - path.setAttribute("stroke-width", "1.7"); - }); - svg.append(cloud, usage); - return svg; - } - - function openAccountPlansModal(opportunity, plans, state = "ready", errorMessage = "") { - const overlay = document.getElementById(MODAL_ID); - const optyNumber = String(opportunity.OptyNumber || ""); - const existingModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); - - if (!overlay) { - return; - } - - if (state !== "loading" && ( - !existingModal - || existingModal.hidden - || existingModal.dataset.optyNumber !== optyNumber - )) { - return; - } - - existingModal?.remove(); - - const modal = document.createElement("section"); - modal.id = ACCOUNT_PLANS_MODAL_ID; - modal.className = "opportunities-extension-account-plans-modal"; - modal.dataset.optyNumber = optyNumber; - modal.setAttribute("role", "dialog"); - modal.setAttribute("aria-modal", "true"); - modal.setAttribute("aria-labelledby", "opportunities-extension-account-plans-title"); - modal.setAttribute("aria-busy", String(state === "loading")); - - const dialog = document.createElement("div"); - dialog.className = "opportunities-extension-account-plans-dialog"; - - const header = document.createElement("header"); - header.className = "opportunities-extension-account-plans-header"; - - const heading = document.createElement("div"); - const title = document.createElement("h2"); - title.id = "opportunities-extension-account-plans-title"; - title.textContent = "Cloud Consumption"; - - const subtitle = document.createElement("p"); - subtitle.textContent = getNestedValue(opportunity, ["CustomerAccount", "PartyUniqueName"]) || opportunity.Name || opportunity.OptyNumber || "Opportunity"; - heading.append(title, subtitle); - - const closeButton = document.createElement("button"); - closeButton.type = "button"; - closeButton.className = "opportunities-extension-icon-button"; - closeButton.setAttribute("aria-label", "Close cloud consumption"); - closeButton.title = "Close"; - closeButton.append(createCloseIcon()); - closeButton.addEventListener("click", closeAccountPlansModal); - header.append(heading, closeButton); - - const content = document.createElement("div"); - content.className = "opportunities-extension-account-plans-content"; - - if (state === "loading") { - content.append(createAccountPlansSkeleton()); - } else if (state === "error") { - const error = document.createElement("p"); - error.className = "opportunities-extension-account-plans-error"; - error.setAttribute("role", "alert"); - error.textContent = errorMessage || "Unable to load cloud consumption."; - content.append(error); - } else if (!Array.isArray(plans) || plans.length === 0) { - const empty = document.createElement("p"); - empty.className = "opportunities-extension-account-plans-empty"; - empty.textContent = "No account plans found for this customer."; - content.append(empty); - } else { - const tabList = document.createElement("div"); - tabList.className = "opportunities-extension-account-plans-tabs"; - tabList.setAttribute("role", "tablist"); - tabList.setAttribute("aria-label", "Account plans"); - - const tabsNavigation = createAccountPlansTabsNavigation(tabList); - - const panels = document.createElement("div"); - panels.className = "opportunities-extension-account-plans-panels"; - - plans.forEach((plan, index) => { - const tabId = `opportunities-extension-account-plan-tab-${index}`; - const panelId = `opportunities-extension-account-plan-panel-${index}`; - const tab = document.createElement("button"); - tab.id = tabId; - tab.type = "button"; - tab.className = "opportunities-extension-account-plan-tab"; - tab.setAttribute("role", "tab"); - tab.setAttribute("aria-controls", panelId); - tab.setAttribute("aria-selected", String(index === 0)); - tab.tabIndex = index === 0 ? 0 : -1; - const planLabel = [plan.planType, plan.subPlanNum] - .filter((value) => value !== null && value !== undefined && value !== "") - .join(" ") || `Plan ${index + 1}`; - const headerStatus = plan.hdrStatusCode === null || plan.hdrStatusCode === undefined || plan.hdrStatusCode === "" - ? "Unknown" - : String(plan.hdrStatusCode); - tab.setAttribute("aria-label", `${planLabel}. Header Status: ${headerStatus}`); - tab.append(createAccountPlanStatusIndicator(headerStatus), document.createTextNode(planLabel)); - - const panel = document.createElement("section"); - panel.id = panelId; - panel.className = "opportunities-extension-account-plan-panel"; - panel.setAttribute("role", "tabpanel"); - panel.setAttribute("aria-labelledby", tabId); - panel.hidden = index !== 0; - panel.append(createAccountPlanDetails(plan), createConsumptionTrendSection(plan)); - - tab.addEventListener("click", () => { - tabList.querySelectorAll("[role='tab']").forEach((candidate) => { - const isSelected = candidate === tab; - candidate.setAttribute("aria-selected", String(isSelected)); - candidate.tabIndex = isSelected ? 0 : -1; - }); - panels.querySelectorAll("[role='tabpanel']").forEach((candidate) => { - candidate.hidden = candidate !== panel; - }); - const trendSection = panel.querySelector(".opportunities-extension-consumption-trend"); - loadConsumptionTrendForPanel( - panel, - plan, - trendSection ? trendSection.dataset.period : DEFAULT_CONSUMPTION_TREND_PERIOD - ); - }); - tabList.append(tab); - panels.append(panel); - }); - - content.append(tabsNavigation, panels); - } - - dialog.append(header, content); - modal.append(dialog); - overlay.append(modal); - modal.querySelector("[role='tab'], .opportunities-extension-icon-button")?.focus(); - - if (state === "ready" && Array.isArray(plans) && plans.length > 0) { - const firstPanel = modal.querySelector("[role='tabpanel']"); - - if (firstPanel) { - loadConsumptionTrendForPanel(firstPanel, plans[0], DEFAULT_CONSUMPTION_TREND_PERIOD); - } - } - } - - function createAccountPlansTabsNavigation(tabList) { - const navigation = document.createElement("div"); - navigation.className = "opportunities-extension-account-plans-tabs-navigation"; - - const previousButton = createAccountPlansTabNavigationButton("previous"); - const nextButton = createAccountPlansTabNavigationButton("next"); - const updateNavigation = () => { - const maximumScroll = Math.max(0, tabList.scrollWidth - tabList.clientWidth); - const tabs = Array.from(tabList.querySelectorAll("[role='tab']")); - const viewportStart = tabList.scrollLeft; - const viewportEnd = viewportStart + tabList.clientWidth; - const hasContentBefore = tabList.scrollLeft > 1 - || tabs.some((tab) => tab.offsetLeft < viewportStart - 1); - const hasContentAfter = tabList.scrollLeft < maximumScroll - 1 - || tabs.some((tab) => (tab.offsetLeft + tab.offsetWidth) > viewportEnd + 1); - previousButton.hidden = !hasContentBefore; - nextButton.hidden = !hasContentAfter; - }; - const scrollTabs = (direction) => { - tabList.scrollBy({ - left: direction * Math.max(160, Math.floor(tabList.clientWidth * .72)), - behavior: "smooth" - }); - }; - - previousButton.addEventListener("click", () => scrollTabs(-1)); - nextButton.addEventListener("click", () => scrollTabs(1)); - tabList.addEventListener("scroll", updateNavigation, { passive: true }); - const handleResize = () => { - if (!navigation.isConnected) { - window.removeEventListener("resize", handleResize); - return; - } - updateNavigation(); - }; - window.addEventListener("resize", handleResize, { passive: true }); - requestAnimationFrame(() => requestAnimationFrame(updateNavigation)); - setTimeout(updateNavigation, 0); - - navigation.append(previousButton, tabList, nextButton); - return navigation; - } - - function createAccountPlansTabNavigationButton(direction) { - const isPrevious = direction === "previous"; - const button = document.createElement("button"); - button.type = "button"; - button.className = "opportunities-extension-account-plans-tab-navigation-button"; - button.setAttribute("aria-label", isPrevious ? "Show previous account plans" : "Show next account plans"); - button.title = isPrevious ? "Previous plans" : "Next plans"; - - const icon = createSvgElement("svg", { - viewBox: "0 0 24 24", - "aria-hidden": "true", - focusable: "false" - }); - icon.append(createSvgElement("path", { - d: isPrevious ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7", - fill: "none", - stroke: "currentColor", - "stroke-linecap": "round", - "stroke-linejoin": "round", - "stroke-width": "2" - })); - button.append(icon); - return button; - } - - function createAccountPlansSkeleton() { - const skeleton = document.createElement("div"); - skeleton.className = "opportunities-extension-account-plans-skeleton"; - skeleton.setAttribute("role", "status"); - - const announcement = document.createElement("span"); - announcement.className = "opportunities-extension-visually-hidden"; - announcement.textContent = "Loading cloud consumption"; - - const tabs = document.createElement("div"); - tabs.className = "opportunities-extension-account-plans-skeleton-tabs"; - const tab = document.createElement("span"); - tab.className = "opportunities-extension-account-plans-skeleton-tab"; - const status = document.createElement("span"); - status.className = "opportunities-extension-account-plans-skeleton-status"; - const tabLabel = document.createElement("span"); - tabLabel.className = "opportunities-extension-account-plans-skeleton-bar"; - tabLabel.style.width = "104px"; - tab.append(status, tabLabel); - tabs.append(tab); - - const details = document.createElement("div"); - details.className = "opportunities-extension-account-plans-skeleton-details"; - [72, 88, 64, 78, 58, 68].forEach((width) => { - const row = document.createElement("div"); - const label = document.createElement("span"); - const value = document.createElement("span"); - label.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-account-plans-skeleton-label"; - value.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-account-plans-skeleton-value"; - value.style.width = `${width}%`; - row.append(label, value); - details.append(row); - }); - - const trend = document.createElement("div"); - trend.className = "opportunities-extension-account-plans-skeleton-trend"; - - const trendHeader = document.createElement("div"); - trendHeader.className = "opportunities-extension-account-plans-skeleton-trend-header"; - const periods = document.createElement("div"); - periods.className = "opportunities-extension-account-plans-skeleton-periods"; - [58, 52, 56, 38].forEach((width, index) => { - const period = document.createElement("span"); - period.className = "opportunities-extension-account-plans-skeleton-bar"; - period.style.width = `${width}px`; - if (index === 2) period.classList.add("opportunities-extension-account-plans-skeleton-period-active"); - periods.append(period); - }); - trendHeader.append(periods); - - const navigation = document.createElement("div"); - navigation.className = "opportunities-extension-account-plans-skeleton-navigation"; - [32, 124, 32].forEach((width) => { - const item = document.createElement("span"); - item.className = "opportunities-extension-account-plans-skeleton-bar"; - item.style.width = `${width}px`; - navigation.append(item); - }); - - const chart = document.createElement("div"); - chart.className = "opportunities-extension-account-plans-skeleton-chart"; - const plot = document.createElement("div"); - plot.className = "opportunities-extension-account-plans-skeleton-plot"; - [34, 42, 38, 48, 44, 58, 64, 55, 76, 68, 86, 62].forEach((height) => { - const bar = document.createElement("span"); - bar.className = "opportunities-extension-account-plans-skeleton-bar"; - bar.style.height = `${height}%`; - plot.append(bar); - }); - const legend = document.createElement("div"); - legend.className = "opportunities-extension-account-plans-skeleton-legend"; - [112, 68, 132].forEach((width) => { - const item = document.createElement("span"); - item.className = "opportunities-extension-account-plans-skeleton-bar"; - item.style.width = `${width}px`; - legend.append(item); - }); - chart.append(plot, legend); - trend.append(trendHeader, navigation, chart); - - const panel = document.createElement("div"); - panel.className = "opportunities-extension-account-plan-panel opportunities-extension-account-plans-skeleton-panel"; - panel.append(details, trend); - - skeleton.append(announcement, tabs, panel); - return skeleton; - } - - function createAccountPlanStatusIndicator(statusValue) { - const indicator = document.createElement("span"); - const isActive = String(statusValue).toUpperCase() === "ACTIVE"; - - indicator.className = "opportunities-extension-account-plan-status-indicator"; - indicator.dataset.active = String(isActive); - indicator.setAttribute("aria-hidden", "true"); - indicator.title = `Header Status: ${statusValue}`; - return indicator; - } - - function createAccountPlanDetails(plan) { - const details = document.createElement("dl"); - details.className = "opportunities-extension-account-plan-details"; - const fields = [ - { label: "Booking Customer", value: plan.bookingCustomer }, - { label: "Sold To Party Name", value: plan.soldToPartyName }, - { label: "Contract ID", value: plan.contractId }, - { label: "Header Status", value: plan.hdrStatusCode }, - { label: "Start Date", value: formatOracleResponseDate(plan.startDate) }, - { label: "End Date", value: formatOracleResponseDate(plan.endDate) } - ]; - - fields.forEach((field) => { - const group = document.createElement("div"); - const term = document.createElement("dt"); - const description = document.createElement("dd"); - term.textContent = field.label; - description.textContent = field.value === null || field.value === undefined || field.value === "" ? "-" : String(field.value); - group.append(term, description); - details.append(group); - }); - return details; - } - - function createConsumptionTrendSection(plan) { - const section = document.createElement("section"); - section.className = "opportunities-extension-consumption-trend"; - section.dataset.subPlanNum = String(plan.subPlanNum || ""); - section.dataset.period = DEFAULT_CONSUMPTION_TREND_PERIOD; - - const header = document.createElement("header"); - header.className = "opportunities-extension-consumption-trend-header"; - - const periodControls = document.createElement("div"); - periodControls.className = "opportunities-extension-consumption-periods"; - periodControls.setAttribute("role", "group"); - periodControls.setAttribute("aria-label", "Consumption period"); - - CONSUMPTION_TREND_PERIODS.forEach((periodOption) => { - const button = document.createElement("button"); - button.type = "button"; - button.dataset.period = periodOption.value; - button.setAttribute("aria-pressed", String(periodOption.value === DEFAULT_CONSUMPTION_TREND_PERIOD)); - button.textContent = periodOption.label; - button.addEventListener("click", () => { - section.dataset.period = periodOption.value; - consumptionTrendWindowOffsets.set( - createConsumptionTrendKey(section.dataset.subPlanNum, periodOption.value), - 0 - ); - loadConsumptionTrendForPanel(section.closest("[role='tabpanel']"), plan, periodOption.value); - }); - periodControls.append(button); - }); - - const chart = document.createElement("div"); - chart.className = "opportunities-extension-consumption-chart"; - chart.setAttribute("aria-live", "polite"); - header.append(periodControls); - section.append(header, chart); - return section; - } - - function loadConsumptionTrendForPanel(panel, plan, period) { - if (!panel) { - return; - } - - const section = panel.querySelector(".opportunities-extension-consumption-trend"); - - if (!section) { - return; - } - - const subPlanNum = String(plan.subPlanNum || "").trim(); - const selectedPeriod = CONSUMPTION_TREND_PERIODS.some((item) => item.value === period) - ? period - : DEFAULT_CONSUMPTION_TREND_PERIOD; - const key = createConsumptionTrendKey(subPlanNum, selectedPeriod); - const chart = section.querySelector(".opportunities-extension-consumption-chart"); - section.dataset.period = selectedPeriod; - chart.dataset.requestKey = key; - updateConsumptionPeriodButtons(section, selectedPeriod); - - if (!subPlanNum) { - renderConsumptionTrendError(chart, "Sub plan number is unavailable."); - return; - } - - const cached = consumptionTrendCache.get(key); - - renderConsumptionTrendSkeleton(chart); - - if (cached && cached.status === "loading") { - cached.promise - .then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key)) - .catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key)); - return; - } - - const request = requestConsumerTokenServiceOnce() - .then((authorizations) => sendRuntimeMessage({ - type: "opportunitiesExtension.requestConsumptionTrend", - subPlanNum, - period: selectedPeriod, - authorization: authorizations && authorizations.accountPlans - })) - .then((response) => { - appendBackgroundRequestLog(response && response.debugRequests); - - if (!response || !response.ok) { - throw new Error(response && response.error ? response.error : "Unable to load consumption trend."); - } - - return Array.isArray(response.items) ? response.items : []; - }) - .finally(() => { - if (consumptionTrendCache.get(key)?.promise === request) { - consumptionTrendCache.delete(key); - } - }); - - consumptionTrendCache.set(key, { status: "loading", promise: request }); - request - .then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key)) - .catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key)); - } - - function createConsumptionTrendKey(subPlanNum, period) { - return `${subPlanNum}:${period}`; - } - - function updateConsumptionPeriodButtons(section, period) { - section.querySelectorAll(".opportunities-extension-consumption-periods button").forEach((button) => { - button.setAttribute("aria-pressed", String(button.dataset.period === period)); - }); - } - - function renderConsumptionTrendIfCurrent(section, chart, items, period, subPlanNum, key) { - if (chart.dataset.requestKey === key) { - renderConsumptionTrendChart(section, chart, items, period, subPlanNum); - } - } - - function renderConsumptionTrendErrorIfCurrent(chart, errorMessage, key) { - if (chart.dataset.requestKey === key) { - renderConsumptionTrendError(chart, errorMessage || "Unable to load consumption trend."); - } - } - - function renderConsumptionTrendSkeleton(chart) { - chart.textContent = ""; - chart.setAttribute("aria-busy", "true"); - const skeleton = document.createElement("div"); - skeleton.className = "opportunities-extension-consumption-chart-skeleton"; - const announcement = document.createElement("span"); - announcement.className = "opportunities-extension-visually-hidden"; - announcement.textContent = "Loading consumption trend"; - const plot = document.createElement("div"); - plot.className = "opportunities-extension-consumption-chart-skeleton-plot"; - - for (let index = 0; index < 13; index += 1) { - const bar = document.createElement("span"); - bar.style.height = `${28 + ((index * 17) % 58)}%`; - plot.append(bar); - } - - skeleton.append(announcement, plot); - chart.append(skeleton); - } - - function renderConsumptionTrendError(chart, errorMessage) { - chart.textContent = ""; - chart.setAttribute("aria-busy", "false"); - const error = document.createElement("p"); - error.className = "opportunities-extension-consumption-chart-error"; - error.setAttribute("role", "alert"); - error.textContent = errorMessage; - chart.append(error); - } - - function renderConsumptionTrendChart(section, chart, items, period, subPlanNum) { - chart.textContent = ""; - chart.setAttribute("aria-busy", "false"); - const points = normalizeConsumptionTrendItems(items); - - if (points.length === 0) { - const empty = document.createElement("p"); - empty.className = "opportunities-extension-consumption-chart-empty"; - empty.textContent = "No consumption data found for this period."; - chart.append(empty); - return; - } - - const option = CONSUMPTION_TREND_PERIODS.find((item) => item.value === period); - const windowSize = option ? option.windowSize : points.length; - const offsetKey = createConsumptionTrendKey(subPlanNum, period); - const maxOffset = Math.max(0, Math.ceil(points.length / windowSize) - 1); - const offset = Math.min(consumptionTrendWindowOffsets.get(offsetKey) || 0, maxOffset); - const end = Math.max(0, points.length - (offset * windowSize)); - const start = Math.max(0, end - windowSize); - const visiblePoints = points.slice(start, end); - consumptionTrendWindowOffsets.set(offsetKey, offset); - - const navigation = document.createElement("div"); - navigation.className = "opportunities-extension-consumption-navigation"; - const olderButton = createConsumptionNavigationButton("older", start === 0); - const newerButton = createConsumptionNavigationButton("newer", end === points.length); - const range = document.createElement("strong"); - range.textContent = formatConsumptionRange(visiblePoints, period); - olderButton.addEventListener("click", () => { - consumptionTrendWindowOffsets.set(offsetKey, Math.min(maxOffset, offset + 1)); - renderConsumptionTrendChart(section, chart, items, period, subPlanNum); - }); - newerButton.addEventListener("click", () => { - consumptionTrendWindowOffsets.set(offsetKey, Math.max(0, offset - 1)); - renderConsumptionTrendChart(section, chart, items, period, subPlanNum); - }); - navigation.append(olderButton, range, newerButton); - - const figure = document.createElement("figure"); - figure.className = "opportunities-extension-consumption-figure"; - const visual = document.createElement("div"); - visual.className = "opportunities-extension-consumption-visual"; - const tooltip = document.createElement("div"); - tooltip.className = "opportunities-extension-consumption-tooltip"; - tooltip.hidden = true; - tooltip.setAttribute("role", "tooltip"); - const activeSeries = getConsumptionTrendSeriesFilter(subPlanNum); - const svg = createConsumptionSvg( - visiblePoints, - period, - end === points.length, - activeSeries, - tooltip - ); - const legend = createConsumptionLegend( - section, - chart, - items, - period, - subPlanNum, - activeSeries - ); - visual.append(svg, legend); - figure.append(visual, tooltip); - chart.append(navigation, figure); - } - - function getConsumptionTrendSeriesFilter(subPlanNum) { - if (!consumptionTrendSeriesFilters.has(subPlanNum)) { - consumptionTrendSeriesFilters.set( - subPlanNum, - new Set(CONSUMPTION_TREND_SERIES.map((series) => series.value)) - ); - } - - return consumptionTrendSeriesFilters.get(subPlanNum); - } - - function createConsumptionLegend(section, chart, items, period, subPlanNum, activeSeries) { - const legend = document.createElement("div"); - legend.className = "opportunities-extension-consumption-legend"; - legend.setAttribute("role", "group"); - legend.setAttribute("aria-label", "Chart series filters"); - - CONSUMPTION_TREND_SERIES.forEach((series) => { - const button = document.createElement("button"); - const swatch = document.createElement("span"); - const label = document.createElement("span"); - button.type = "button"; - button.dataset.series = series.value; - button.setAttribute("aria-pressed", String(activeSeries.has(series.value))); - button.setAttribute("aria-label", `${activeSeries.has(series.value) ? "Hide" : "Show"} ${series.label}`); - swatch.className = `opportunities-extension-consumption-legend-swatch opportunities-extension-consumption-legend-${series.value}`; - swatch.setAttribute("aria-hidden", "true"); - label.textContent = series.label; - button.append(swatch, label); - button.addEventListener("click", () => { - if (activeSeries.has(series.value)) { - activeSeries.delete(series.value); - } else { - activeSeries.add(series.value); - } - - renderConsumptionTrendChart(section, chart, items, period, subPlanNum); - chart.querySelector(`[data-series="${series.value}"]`)?.focus(); - }); - legend.append(button); - }); - - return legend; - } - - function createConsumptionNavigationButton(direction, disabled) { - const button = document.createElement("button"); - button.type = "button"; - button.className = "opportunities-extension-consumption-navigation-button"; - button.disabled = disabled; - button.setAttribute("aria-label", direction === "older" ? "Show older consumption data" : "Show newer consumption data"); - button.title = direction === "older" ? "Older" : "Newer"; - const icon = createSvgElement("svg", { - viewBox: "0 0 24 24", - "aria-hidden": "true", - focusable: "false" - }); - icon.append(createSvgElement("path", { - d: direction === "older" ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7", - fill: "none", - stroke: "currentColor", - "stroke-linecap": "round", - "stroke-linejoin": "round", - "stroke-width": "2" - })); - button.append(icon); - return button; - } - - function normalizeConsumptionTrendItems(items) { - const points = new Map(); - - (Array.isArray(items) ? items : []).forEach((item) => { - const dateKey = String(item.fyDate || (Array.isArray(item.fiscalTime) ? item.fiscalTime.join("|") : "")); - - if (!dateKey) { - return; - } - - if (!points.has(dateKey)) { - points.set(dateKey, { - dateKey, - date: parseConsumptionDate(item.fyDate), - fiscalTime: Array.isArray(item.fiscalTime) ? item.fiscalTime.slice() : [], - fiscalQtr: item.fiscalQtr || "", - funded: 0, - overage: 0, - arr: 0, - currencyCode: item.currencyCode || "USD" - }); - } - - const point = points.get(dateKey); - const amount = toFiniteNumber(item.usedAmount); - const category = String(item.usageCategory || "Funded Allocation").toUpperCase(); - - if (category === "OVERAGE") { - point.overage += amount; - } else { - point.funded += amount; - } - - point.arr = Math.max(point.arr, toFiniteNumber(item.arrCd)); - }); - - return Array.from(points.values()) - .map((point) => ({ ...point, total: point.funded + point.overage })) - .sort((left, right) => left.dateKey.localeCompare(right.dateKey)); - } - - function createConsumptionSvg(points, period, hasPartialLatestPoint, activeSeries, tooltip) { - const width = 1000; - const height = 320; - const plot = { left: 62, top: 24, right: 980, bottom: 258 }; - const plotWidth = plot.right - plot.left; - const plotHeight = plot.bottom - plot.top; - const average = calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries); - const maximum = createNiceConsumptionMaximum(Math.max(average, ...points.map((point) => point.total))); - const svg = createSvgElement("svg", { - viewBox: `0 0 ${width} ${height}`, - role: "img", - "aria-label": `${period} cloud consumption chart` - }); - svg.classList.add("opportunities-extension-consumption-svg"); - - for (let tick = 0; tick <= 5; tick += 1) { - const value = (maximum / 5) * tick; - const y = plot.bottom - ((value / maximum) * plotHeight); - svg.append( - createSvgElement("line", { x1: plot.left, y1: y, x2: plot.right, y2: y, class: "consumption-grid-line" }), - createSvgText(plot.left - 10, y + 4, formatCompactConsumptionNumber(value), "consumption-axis-label", "end") - ); - } - - const step = plotWidth / Math.max(points.length, 1); - const barWidth = Math.min(44, Math.max(10, step * 0.58)); - - points.forEach((point, index) => { - const x = plot.left + (step * index) + ((step - barWidth) / 2); - const fundedAmount = activeSeries.has("funded") ? point.funded : 0; - const overageAmount = activeSeries.has("overage") ? point.overage : 0; - const visibleTotal = fundedAmount + overageAmount; - const fundedHeight = (fundedAmount / maximum) * plotHeight; - const overageHeight = (overageAmount / maximum) * plotHeight; - const group = createSvgElement("g", { - class: "consumption-bar-group", - tabindex: "0", - role: "img", - "aria-label": createConsumptionPointAriaLabel(point, period) - }); - - ["pointerenter", "pointermove", "focus"].forEach((eventName) => { - group.addEventListener(eventName, (event) => { - showConsumptionTooltip(tooltip, point, period, event); - }); - }); - ["pointerleave", "blur"].forEach((eventName) => { - group.addEventListener(eventName, () => { - tooltip.hidden = true; - }); - }); - - if (fundedHeight > 0) { - const fundedBar = createSvgElement("rect", { - x, - y: plot.bottom - fundedHeight, - width: barWidth, - height: fundedHeight, - class: "consumption-bar-funded" - }); - appendConsumptionBarAnimation( - fundedBar, - plot.bottom, - plot.bottom - fundedHeight, - fundedHeight, - index * 34 - ); - group.append(fundedBar); - } - - if (overageHeight > 0) { - const overageTop = plot.bottom - fundedHeight - overageHeight; - const overageBar = createSvgElement("rect", { - x, - y: overageTop, - width: barWidth, - height: overageHeight, - class: "consumption-bar-overage" - }); - appendConsumptionBarAnimation( - overageBar, - plot.bottom - fundedHeight, - overageTop, - overageHeight, - (index * 34) + 90 - ); - group.append(overageBar); - } - - const top = plot.bottom - fundedHeight - overageHeight; - - if (visibleTotal > 0) { - const valueLabel = createSvgText( - x + (barWidth / 2), - Math.max(plot.top + 11, top - 7), - formatCompactConsumptionNumber(visibleTotal), - "consumption-value-label consumption-animated-label", - "middle" - ); - valueLabel.style.animationDelay = `${(index * 34) + 360}ms`; - group.append(valueLabel); - } - - const label = createSvgText( - x + (barWidth / 2), - plot.bottom + 18, - formatConsumptionPointLabel(point, period), - "consumption-x-label", - period === "monthly" || period === "quarterly" ? "middle" : "end" - ); - - if (period === "weekly" || period === "daily") { - label.setAttribute("transform", `rotate(-65 ${x + (barWidth / 2)} ${plot.bottom + 18})`); - } - - group.append(label); - svg.append(group); - }); - - if (activeSeries.has("average")) { - const averageY = plot.bottom - ((average / maximum) * plotHeight); - const averageLine = createSvgElement("line", { - x1: plot.left, - y1: averageY, - x2: plot.right, - y2: averageY, - class: "consumption-average-line consumption-animated-average" - }); - svg.append(averageLine); - } - - return svg; - } - - function getConsumptionPointTotal(point, activeSeries) { - const selectedSeries = activeSeries && typeof activeSeries.has === "function" - ? activeSeries - : new Set(["funded", "overage"]); - return (selectedSeries.has("funded") ? point.funded : 0) - + (selectedSeries.has("overage") ? point.overage : 0); - } - - function calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries) { - const completePoints = hasPartialLatestPoint && points.length > 1 ? points.slice(0, -1) : points; - return completePoints.reduce( - (sum, point) => sum + getConsumptionPointTotal(point, activeSeries), - 0 - ) / Math.max(completePoints.length, 1); - } - - function appendConsumptionBarAnimation(element, fromY, toY, height, delayMs) { - if (prefersReducedMotion()) { - return; - } - - element.append( - createSvgElement("animate", { - attributeName: "y", - from: fromY, - to: toY, - dur: "0.52s", - begin: `${delayMs}ms`, - fill: "freeze", - calcMode: "spline", - keyTimes: "0;1", - keySplines: "0.2 0.8 0.2 1" - }), - createSvgElement("animate", { - attributeName: "height", - from: "0", - to: height, - dur: "0.52s", - begin: `${delayMs}ms`, - fill: "freeze", - calcMode: "spline", - keyTimes: "0;1", - keySplines: "0.2 0.8 0.2 1" - }) - ); - } - - function prefersReducedMotion() { - return typeof window.matchMedia === "function" - && window.matchMedia("(prefers-reduced-motion: reduce)").matches; - } - - function createConsumptionPointAriaLabel(point, period) { - return `${formatConsumptionPointLabel(point, period)}. ` - + `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}. ` - + `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}. ` - + `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}.`; - } - - function showConsumptionTooltip(tooltip, point, period, event) { - const figure = tooltip.parentElement; - - if (!figure) { - return; - } - - tooltip.textContent = ""; - const title = document.createElement("strong"); - title.textContent = formatConsumptionPointLabel(point, period); - const details = document.createElement("span"); - details.textContent = `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}`; - const overage = document.createElement("span"); - overage.textContent = `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}`; - const total = document.createElement("span"); - total.textContent = `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}`; - tooltip.append(title, details, overage, total); - tooltip.hidden = false; - - const figureBounds = figure.getBoundingClientRect(); - const targetBounds = event.currentTarget.getBoundingClientRect(); - const pointerX = Number.isFinite(event.clientX) && event.clientX > 0 - ? event.clientX - : targetBounds.left + (targetBounds.width / 2); - const pointerY = Number.isFinite(event.clientY) && event.clientY > 0 - ? event.clientY - : targetBounds.top; - const left = Math.min( - Math.max(8, pointerX - figureBounds.left + 12), - Math.max(8, figureBounds.width - tooltip.offsetWidth - 8) - ); - const top = Math.max(8, pointerY - figureBounds.top - tooltip.offsetHeight - 10); - tooltip.style.left = `${left}px`; - tooltip.style.top = `${top}px`; - } - - function createSvgElement(name, attributes = {}) { - const element = document.createElementNS(SVG_NAMESPACE, name); - - Object.entries(attributes).forEach(([key, value]) => { - element.setAttribute(key, String(value)); - }); - return element; - } - - function createSvgText(x, y, value, className, anchor) { - const text = createSvgElement("text", { x, y, class: className, "text-anchor": anchor }); - text.textContent = value; - return text; - } - - function createNiceConsumptionMaximum(value) { - if (!Number.isFinite(value) || value <= 0) { - return 1; - } - - const magnitude = 10 ** Math.floor(Math.log10(value)); - const normalized = value / magnitude; - const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10; - return step * magnitude; - } - - function formatConsumptionPointLabel(point, period) { - if (period === "quarterly") { - return point.fiscalTime[0] || point.fiscalQtr || point.dateKey; - } - - if (period === "monthly") { - return point.fiscalTime[1] || formatConsumptionDate(point.date, { month: "short" }); - } - - return point.fiscalTime[0] || formatConsumptionDate(point.date, { - day: "2-digit", - month: "short", - year: "2-digit" - }); - } - - function formatConsumptionRange(points, period) { - if (points.length === 0) { - return "No data"; - } - - if (period === "quarterly") { - return `${formatConsumptionPointLabel(points[0], period)} - ${formatConsumptionPointLabel(points[points.length - 1], period)}`; - } - - if (period === "monthly") { - const options = { month: "short", year: "numeric" }; - return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`; - } - - const options = { day: "2-digit", month: "short" }; - return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`; - } - - function parseConsumptionDate(value) { - if (!value) { - return null; - } - - const parts = String(value).split("-").map(Number); - return parts.length === 3 && parts.every(Number.isFinite) - ? new Date(parts[0], parts[1] - 1, parts[2]) - : null; - } - - function formatConsumptionDate(date, options) { - if (!(date instanceof Date) || Number.isNaN(date.getTime())) { - return "-"; - } - - return new Intl.DateTimeFormat("en-US", options).format(date).replace(",", ""); - } - - function formatCompactConsumptionNumber(value) { - const amount = toFiniteNumber(value); - - if (Math.abs(amount) >= 1000000) { - return `${(amount / 1000000).toFixed(amount >= 10000000 ? 0 : 1).replace(".0", "")}M`; - } - - if (Math.abs(amount) >= 1000) { - return `${Math.round(amount / 1000)}K`; - } - - return String(Math.round(amount)); - } - - function formatConsumptionCurrency(value, currencyCode) { - try { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: currencyCode || "USD", - maximumFractionDigits: 0 - }).format(toFiniteNumber(value)); - } catch (error) { - return `${currencyCode || "USD"} ${Math.round(toFiniteNumber(value)).toLocaleString("en-US")}`; - } - } - - function toFiniteNumber(value) { - const number = Number(value); - return Number.isFinite(number) ? number : 0; - } - - function closeAccountPlansModal() { - const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); - - if (modal) { - modal.hidden = true; - } - } - - function createProductsCountButton(optyNumber, productsResult) { - const normalizedOptyNumber = String(optyNumber); - const button = document.createElement("button"); - const count = Array.isArray(productsResult.items) ? productsResult.items.length : 0; - const isExpanded = expandedProductOptyNumbers.has(normalizedOptyNumber); - - button.type = "button"; - button.className = "opportunities-extension-products-count"; - button.textContent = String(count); - button.setAttribute("aria-expanded", String(isExpanded)); - button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`); - button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`; - button.classList.toggle("opportunities-extension-products-count-error", Boolean(productsResult.error)); - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - - if (isExpanded) { - expandedProductOptyNumbers.delete(normalizedOptyNumber); - } else { - expandedProductOptyNumbers.add(normalizedOptyNumber); - requestGenerateAiSummary(normalizedOptyNumber); - } - - renderOpportunitiesTable(); - }); - return button; - } - - function requestGenerateAiSummary(optyNumber) { - const key = String(optyNumber); - const existing = aiSummariesByOptyNumber.get(key); - if (existing && existing.status === "loading") { - return existing.promise; - } - - const state = { status: "loading", summaryText: "", error: "", promise: null }; - 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. - if (salesCommonToken && salesCommonToken.access_token === token) { - salesCommonToken = null; - } - 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 = 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; - } - - function createProductsDetailRow(opportunity) { - const row = document.createElement("tr"); - const cell = document.createElement("td"); - const optyNumber = String(opportunity.OptyNumber); - const productsResult = opptyProductsByNumber.get(optyNumber); - const products = productsResult && Array.isArray(productsResult.items) ? productsResult.items : []; - - row.className = "opportunities-extension-products-detail-row"; - cell.colSpan = OPPORTUNITIES_COLUMNS.length; - cell.className = "opportunities-extension-products-detail-cell"; - - const panel = document.createElement("section"); - panel.className = "opportunities-extension-products-detail"; - - const heading = document.createElement("div"); - heading.className = "opportunities-extension-products-detail-heading"; - - const title = document.createElement("strong"); - title.textContent = `Products for ${optyNumber}`; - - const summary = document.createElement("span"); - summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated"; - heading.append(title, summary); - panel.append(heading); - panel.append(createAiSummaryAlert(optyNumber)); - - if (productsResult && productsResult.error) { - const error = document.createElement("p"); - error.className = "opportunities-extension-products-empty"; - error.textContent = productsResult.error; - panel.append(error); - } else if (products.length === 0) { - const empty = document.createElement("p"); - empty.className = "opportunities-extension-products-empty"; - empty.textContent = "No products found for this opportunity."; - panel.append(empty); - } else { - const table = document.createElement("table"); - table.className = "opportunities-extension-products-table"; - table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`); - - const columns = [ - { label: "Product Group", value: (item) => item.ProdGroupName || "-" }, - { label: "Workload", value: (item) => item.WorkloadName_c || "-" }, - { label: "Currency", value: (item) => item.RevnAmountCurcyCode || "-" }, - { label: "Amount", value: (item) => formatProductAmount(item.RevnAmount, item.RevnAmountCurcyCode) }, - { label: "Type", value: (item) => item.TypeCode || "-" }, - { label: "Status", value: (item) => item.StatusCode || "-" }, - { label: "Win Probability", value: (item) => formatProductWinProbability(item.WinProb) }, - { label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" }, - { label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" }, - { label: "Ramp Months", value: (item) => formatProductNumber(item.RampMonths_c) } - ]; - - const head = document.createElement("thead"); - const headerRow = document.createElement("tr"); - columns.forEach((column) => { - const header = document.createElement("th"); - header.scope = "col"; - header.textContent = column.label; - headerRow.append(header); - }); - head.append(headerRow); - - const body = document.createElement("tbody"); - products.forEach((product) => { - const productRow = document.createElement("tr"); - columns.forEach((column) => { - const productCell = document.createElement("td"); - productCell.textContent = String(column.value(product)); - productRow.append(productCell); - }); - body.append(productRow); - }); - - table.append(head, body); - panel.append(table); - } - - cell.append(panel); - row.append(cell); - return row; - } - - function formatProductNumber(value) { - const number = Number(value); - return Number.isFinite(number) ? new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(number) : "-"; - } - - function formatProductAmount(value, currency) { - const amount = Number(value); - - if (!Number.isFinite(amount)) { - return "-"; - } - - try { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: currency || "USD" - }).format(amount); - } catch (error) { - return `${currency || ""} ${formatProductNumber(amount)}`.trim(); - } - } - - function formatProductWinProbability(value) { - const number = Number(value); - return Number.isFinite(number) ? `${number}%` : "-"; - } - - async function copyOpportunityNumber(optyNumber) { - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(optyNumber); - } else { - const input = document.createElement("textarea"); - input.value = optyNumber; - input.setAttribute("readonly", "true"); - input.style.position = "fixed"; - input.style.opacity = "0"; - document.body.append(input); - input.select(); - document.execCommand("copy"); - input.remove(); - } - - showCopyConfirmation(`Opty Number ${optyNumber} copied.`); - } catch (error) { - showCopyConfirmation("Unable to copy opportunity number.", true); - } - } - - function showCopyConfirmation(message, isError = false) { - const modal = document.getElementById(MODAL_ID); - - if (!modal) { - return; - } - - let toast = modal.querySelector(".opportunities-extension-copy-toast"); - - if (!toast) { - toast = document.createElement("div"); - toast.className = "opportunities-extension-copy-toast"; - toast.setAttribute("role", "status"); - modal.append(toast); - } - - toast.classList.toggle("opportunities-extension-copy-toast-error", isError); - toast.textContent = message; - toast.hidden = false; - window.clearTimeout(toast.copyTimeout); - toast.copyTimeout = window.setTimeout(() => { - toast.hidden = true; - }, 2400); - } - - function createStageBadge(stage) { - const badge = document.createElement("span"); - const normalizedStage = typeof stage === "string" ? stage.toUpperCase() : ""; - - badge.className = "opportunities-extension-stage-badge"; - badge.setAttribute("data-stage", normalizedStage); - badge.textContent = normalizedStage || "-"; - return badge; - } - - function createStatusBadge(status) { - const badge = document.createElement("span"); - const normalizedStatus = typeof status === "string" ? status.toUpperCase() : ""; - - badge.className = "opportunities-extension-status-badge"; - badge.setAttribute("data-status", normalizedStatus); - badge.textContent = normalizedStatus || "-"; - return badge; - } - - function getNestedValue(value, path) { - return path.reduce((result, key) => result && result[key], value); - } - - function formatWinProbability(item) { - const value = getNestedValue(item, ["PrimaryRevenue", "WinProb"]); - const number = Number(value); - return Number.isFinite(number) ? `${number}%` : value; - } - - function formatRevenue(item) { - const amount = getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]); - const currency = getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]); - const number = Number(amount); - - if (!Number.isFinite(number)) { - return amount; - } - - if (!currency) { - return number.toLocaleString(); - } - - try { - return new Intl.NumberFormat(undefined, { - style: "currency", - currency - }).format(number); - } catch (error) { - return `${currency} ${number.toLocaleString()}`; - } - } - - function formatOracleResponseDate(value, includeTime) { - if (typeof value !== "string") { - return value; - } - - const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/); - - if (!match) { - return value; - } - - const date = `${match[3]}/${match[2]}/${match[1]}`; - return includeTime && match[4] ? `${date} ${match[4]}:${match[5]}` : date; - } - - function isCurrentPeriodRequest(requestVersion) { - return requestVersion === periodRequestVersion; - } - - function createOpportunitiesQueryPayload(dateRange, offset) { - return { - aggregationResults: false, - applications: ["ORACLE-ISS-APP"], - onlyData: true, - entity: "Opportunity", - q: { - op: "$and", - criteria: [ - { - op: "$or", - criteria: Array.from(selectedOpportunityTypeValues, (value) => ({ - op: "$eq", - attribute: "RecordSet", - value - })) - }, - { - op: "$wi", - attribute: "EffectiveDate", - value1: dateRange.startCloseDate, - value2: dateRange.endCloseDate, - dynamicDate: false - } - ] - }, - keywords: null, - keywordsFields: [ - "Name", - "OptyNumber", - "PrimaryRevenue.WinProb", - "CustomerAccount", - "PrimaryRevenue.RevnAmount", - "EffectiveDate", - "StatusCode", - "DealRisk_c", - "Owner", - "ForecastGroup_c", - "LastUpdateDate", - "PrimaryRevenue.RevnAmountCurcyCode", - "OptyId" - ], - fields: [ - "Name", - "OptyNumber", - "PrimaryRevenue.WinProb", - "CustomerAccount", - "PrimaryRevenue.RevnAmount", - "EffectiveDate", - "StatusCode", - "DealRisk_c", - "Owner", - "ForecastGroup_c", - "LastUpdateDate", - "PrimaryRevenue.RevnAmountCurcyCode", - "OptyId", - "CustomerAccount.PartyUniqueName", - "CustomerAccount.PartyId", - "CustomerAccount.PartyNumber" - ], - sort: [], - language: "en", - skipInValidFields: true, - skipHiddenFromUIFields: true, - copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162", - limit: OPPORTUNITIES_PAGE_LIMIT, - offset - }; - } - - function getFiscalPeriodRange(period, currentDate) { - const currentYear = currentDate.getFullYear(); - const currentMonth = currentDate.getMonth(); - const fiscalStartYear = currentMonth >= 5 ? currentYear : currentYear - 1; - const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3); - - if (period === "Current Fiscal Year") { - const isBeforeFiscalYearStart = currentMonth < 5; - const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear; - const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1; - return createDateRange(startYear, 5, endYear, 4); - } - - let quarterOffset; - let numberOfQuarters = 1; - - if (period === "Current Quarter") { - quarterOffset = currentQuarterIndex; - } else if (period === "Next Quarter") { - quarterOffset = currentQuarterIndex + 1; - } else if (period === "Previous Quarter") { - quarterOffset = currentQuarterIndex - 1; - } else if (period === "4 Rolling Quarters (CQ + 3)") { - quarterOffset = currentQuarterIndex; - numberOfQuarters = 4; - } else { - return null; - } - - const start = new Date(fiscalStartYear, 5 + (quarterOffset * 3), 1); - const end = new Date(start.getFullYear(), start.getMonth() + (numberOfQuarters * 3), 0); - - return { - startCloseDate: formatOracleDate(start), - endCloseDate: formatOracleDate(end) - }; - } - - function createDateRange(startYear, startMonth, endYear, endMonth) { - return { - startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)), - endCloseDate: formatOracleDate(new Date(endYear, endMonth + 1, 0)) - }; - } - - function formatOracleDate(date) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}T00:00:00.000Z`; - } - - function logRequestStart(details) { - const entry = { - id: requestLog.length + 1, - name: details.name, - method: details.method, - url: details.url, - headers: details.headers, - status: "pending", - startedAt: new Date(), - completedAt: null, - durationMs: null, - httpStatus: null, - statusText: "", - requestHeaders: details.headers, - requestBody: details.body || "", - metadata: details.metadata || {}, - responseHeaders: {}, - responseBody: "", - responseBodyTruncated: false, - error: "" - }; - - requestLog.push(entry); - renderDebugPanel(); - return entry; - } - - function logRequestSuccess(entry, response, responseBody) { - entry.status = response.ok ? "success" : "failed"; - entry.completedAt = new Date(); - entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime(); - entry.httpStatus = response.status; - entry.statusText = response.statusText || ""; - entry.responseHeaders = headersToObject(response.headers); - entry.responseBody = responseBody.value; - entry.responseBodyTruncated = responseBody.truncated; - renderDebugPanel(); - } - - function logRequestFailure(entry, errorMessage) { - entry.status = "failed"; - entry.completedAt = new Date(); - entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime(); - entry.error = errorMessage; - renderDebugPanel(); - } - - function appendBackgroundRequestLog(entries) { - if (!Array.isArray(entries) || entries.length === 0) { - return; - } - - entries.forEach((entry) => { - requestLog.push({ - id: requestLog.length + 1, - name: entry.name || "backgroundRequest", - method: entry.method || "GET", - url: entry.url || "", - headers: entry.requestHeaders || {}, - status: entry.status || "failed", - startedAt: new Date(entry.startedAt), - completedAt: entry.completedAt ? new Date(entry.completedAt) : null, - durationMs: entry.durationMs, - httpStatus: entry.httpStatus, - statusText: entry.statusText || "", - requestHeaders: entry.requestHeaders || {}, - requestBody: entry.requestBody || "", - metadata: entry.metadata || { executionContext: "background" }, - responseHeaders: entry.responseHeaders || {}, - responseBody: entry.responseBody || "", - responseBodyTruncated: Boolean(entry.responseBodyTruncated), - error: entry.error || "" - }); - }); - renderDebugPanel(); - } - - async function readResponseBodyForDebug(response) { - try { - const text = await response.clone().text(); - return createDebugBody(text); - } catch (error) { - return { - value: `Unable to read response body: ${error.message || "unknown error"}`, - truncated: false - }; - } - } - - function createDebugBody(text) { - if (text.length > DEBUG_BODY_LIMIT) { - return { - value: `${text.slice(0, DEBUG_BODY_LIMIT)}\n... truncated ${text.length - DEBUG_BODY_LIMIT} characters`, - truncated: true - }; - } - - return { - value: text || "(empty response body)", - truncated: false - }; - } - - function headersToObject(headers) { - const headerMap = {}; - - headers.forEach((value, key) => { - headerMap[key] = value; - }); - - return headerMap; - } - - function formatHeaderBlock(headers) { - const entries = Object.entries(headers); - - if (entries.length === 0) { - return "(none)"; - } - - return entries - .map(([key, value]) => `${key}: ${debugSecretsVisible || !isSensitiveDebugKey(key) - ? value - : formatHiddenDebugValue(value)}`) - .join("\n"); - } - - function formatDebugBody(body) { - const text = body || "(none)"; - - if (debugSecretsVisible) { - return text; - } - - try { - return JSON.stringify(maskSensitiveDebugValue(JSON.parse(text)), null, 2); - } catch (error) { - return text; - } - } - - function maskSensitiveDebugValue(value, parentKey = "") { - if (Array.isArray(value)) { - return value.map((item) => maskSensitiveDebugValue(item, parentKey)); - } - - if (!value || typeof value !== "object") { - return value; - } - - return Object.fromEntries(Object.entries(value).map(([key, child]) => { - const isSensitive = isSensitiveDebugKey(key) - || (key === "value" && /uiaasHeader|uiassHeader/i.test(parentKey)); - return [key, isSensitive - ? formatHiddenDebugValue(child) - : maskSensitiveDebugValue(child, key)]; - })); - } - - function isSensitiveDebugKey(key) { - return /authorization|cookie|password|secret|(^|[_-])token($|[_-])|token$/i.test(key); - } - - function formatHiddenDebugValue(value) { - if (value === null || value === undefined || value === "") { - return "(missing)"; - } - - return `(present, ${String(value).length} characters)`; - } - - function formatMetadataBlock(metadata) { - const entries = Object.entries(metadata); - - if (entries.length === 0) { - return "(none)"; - } - - return entries - .map(([key, value]) => `${key}: ${value || "(none)"}`) - .join("\n"); - } - - function maskToken(token) { - if (token.length <= 10) { - return "(present)"; - } - - return `${token.slice(0, 4)}...${token.slice(-4)}`; - } - - function toggleDebugPanel() { - const modal = document.getElementById(MODAL_ID); - - if (!modal || modal.hidden) { - return; - } - - const panel = document.getElementById(DEBUG_PANEL_ID); - - if (!panel) { - return; - } - - panel.hidden = !panel.hidden; - renderDebugPanel(); - } - - function renderDebugPanel() { - const panel = document.getElementById(DEBUG_PANEL_ID); - - if (!panel || panel.hidden) { - return; - } - - panel.textContent = ""; - - const panelHeader = document.createElement("header"); - panelHeader.className = "opportunities-extension-debug-panel-header"; - - const heading = document.createElement("div"); - const title = document.createElement("h2"); - title.textContent = "Debug Requests"; - - const summary = document.createElement("p"); - summary.textContent = `${requestLog.length} request${requestLog.length === 1 ? "" : "s"} since modal opened.`; - heading.append(title, summary); - - const visibilityButton = document.createElement("button"); - visibilityButton.type = "button"; - visibilityButton.className = "opportunities-extension-icon-button opportunities-extension-debug-visibility-button"; - visibilityButton.setAttribute("aria-pressed", String(debugSecretsVisible)); - visibilityButton.setAttribute("aria-label", debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"); - visibilityButton.title = debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"; - visibilityButton.append(createDebugVisibilityIcon(debugSecretsVisible)); - visibilityButton.addEventListener("click", () => { - debugSecretsVisible = !debugSecretsVisible; - renderDebugPanel(); - }); - panelHeader.append(heading, visibilityButton); - - const list = document.createElement("div"); - list.className = "opportunities-extension-debug-list"; - - if (requestLog.length === 0) { - const empty = document.createElement("div"); - empty.className = "opportunities-extension-debug-empty"; - empty.textContent = "No requests recorded yet."; - list.append(empty); - } - - requestLog.forEach((entry) => { - const item = document.createElement("details"); - item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`; - - const heading = document.createElement("summary"); - heading.className = "opportunities-extension-debug-heading"; - - const name = document.createElement("strong"); - name.textContent = `${entry.id}. ${entry.name}`; - - const httpStatus = document.createElement("span"); - httpStatus.className = "opportunities-extension-debug-http-status"; - httpStatus.textContent = entry.httpStatus ? String(entry.httpStatus) : "pending"; - - const status = document.createElement("span"); - status.className = "opportunities-extension-debug-status"; - status.textContent = entry.status; - - const summaryMeta = document.createElement("span"); - summaryMeta.className = "opportunities-extension-debug-summary-meta"; - summaryMeta.append(httpStatus, status); - - heading.append(name, summaryMeta); - - const lines = [ - `${entry.method} ${entry.url}`, - `Started: ${formatDebugTime(entry.startedAt)}`, - entry.completedAt ? `Completed: ${formatDebugTime(entry.completedAt)} (${entry.durationMs}ms)` : "Completed: pending", - entry.httpStatus ? `HTTP: ${entry.httpStatus} ${entry.statusText}`.trim() : "", - entry.error ? `Error: ${entry.error}` : "", - `Response body truncated: ${entry.responseBodyTruncated ? "yes" : "no"}` - ].filter(Boolean); - - const details = document.createElement("div"); - details.className = "opportunities-extension-debug-details"; - appendDebugBlock(details, "Request", lines.join("\n")); - appendDebugBlock(details, "Request Metadata", formatMetadataBlock(entry.metadata)); - appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders)); - appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody)); - appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders)); - appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody)); - - item.append(heading, details); - list.append(item); - }); - - panel.append(panelHeader, list); - } - - function createDebugVisibilityIcon(secretsVisible) { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - const eye = document.createElementNS("http://www.w3.org/2000/svg", "path"); - const pupil = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - - svg.setAttribute("viewBox", "0 0 24 24"); - svg.setAttribute("aria-hidden", "true"); - svg.setAttribute("focusable", "false"); - eye.setAttribute("d", "M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12Z"); - pupil.setAttribute("cx", "12"); - pupil.setAttribute("cy", "12"); - pupil.setAttribute("r", "2.75"); - [eye, pupil].forEach((shape) => { - shape.setAttribute("fill", "none"); - shape.setAttribute("stroke", "currentColor"); - shape.setAttribute("stroke-linecap", "round"); - shape.setAttribute("stroke-linejoin", "round"); - shape.setAttribute("stroke-width", "1.7"); - }); - svg.append(eye, pupil); - - if (secretsVisible) { - const slash = document.createElementNS("http://www.w3.org/2000/svg", "path"); - slash.setAttribute("d", "M4 4l16 16"); - slash.setAttribute("fill", "none"); - slash.setAttribute("stroke", "currentColor"); - slash.setAttribute("stroke-linecap", "round"); - slash.setAttribute("stroke-width", "1.9"); - svg.append(slash); - } - - return svg; - } - - function appendDebugBlock(container, label, value) { - const block = document.createElement("section"); - const heading = document.createElement("h3"); - const content = document.createElement("pre"); - - heading.textContent = label; - content.textContent = value; - block.append(heading, content); - container.append(block); - } - - function formatDebugTime(date) { - return date.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - second: "2-digit" - }); - } - - async function getXsrfToken() { - const extensionCookie = await getXsrfTokenFromExtensionCookies(); - - if (extensionCookie.token) { - return extensionCookie; - } - - const xsrfCookie = document.cookie - .split(";") - .map((cookie) => cookie.trim()) - .find((cookie) => cookie.startsWith("XSRF-TOKEN-")); - - if (!xsrfCookie) { - return { - token: "", - cookieName: "", - source: extensionCookie.error ? "document.cookie fallback after cookies API error" : "document.cookie fallback", - matchedCookieNames: extensionCookie.matchedCookieNames || [], - lookupDetails: extensionCookie.lookupDetails || [], - error: extensionCookie.error || "" - }; - } - - const cookieName = xsrfCookie.slice(0, xsrfCookie.indexOf("=")); - const tokenValue = xsrfCookie.slice(xsrfCookie.indexOf("=") + 1); - return { - token: decodeURIComponent(tokenValue), - cookieName, - source: "document.cookie", - matchedCookieNames: extensionCookie.matchedCookieNames || [], - lookupDetails: extensionCookie.lookupDetails || [], - error: "" - }; - } - - function getXsrfTokenFromExtensionCookies() { - const runtimeApi = typeof browser !== "undefined" ? browser : chrome; - - if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { - return Promise.resolve({ - token: "", - cookieName: "", - source: "unavailable cookies API", - error: "Runtime messaging API unavailable." - }); - } - - return new Promise((resolve) => { - runtimeApi.runtime.sendMessage({ - type: "opportunitiesExtension.getXsrfToken" - }, (response) => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - resolve({ - token: "", - cookieName: "", - source: "cookies API", - matchedCookieNames: [], - lookupDetails: [], - error: lastError.message - }); - return; - } - - resolve({ - token: response && response.ok ? response.token : "", - cookieName: response && response.ok ? response.cookieName : "", - source: "cookies API", - matchedCookieNames: response && response.matchedCookieNames ? response.matchedCookieNames : [], - lookupDetails: response && response.lookupDetails ? response.lookupDetails : [], - error: response && response.error ? response.error : "" - }); - }); - }); - } - - function refreshXsrfCookie(reason) { - const runtimeApi = typeof browser !== "undefined" ? browser : chrome; - - if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { - return Promise.resolve({ - ok: false, - refreshed: false, - cookieName: "", - durationMs: 0, - reason, - error: "Runtime messaging API unavailable." - }); - } - - return new Promise((resolve) => { - runtimeApi.runtime.sendMessage({ - type: "opportunitiesExtension.refreshXsrfCookie" - }, (response) => { - const lastError = runtimeApi.runtime.lastError; - - if (lastError) { - resolve({ - ok: false, - refreshed: false, - cookieName: "", - durationMs: 0, - reason, - error: lastError.message - }); - return; - } - - resolve({ - ...(response || { - ok: false, - refreshed: false, - cookieName: "", - durationMs: 0, - error: "Empty refresh response." - }), - reason - }); - }); - }); - } - - function updateAuthBadge(status) { - const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`); - - if (!badge) { - return; - } - - const badgeStatus = status === AUTH_STATUS.authenticated || status === AUTH_STATUS.unauthenticated - ? status - : AUTH_STATUS.pending; - - badge.setAttribute("data-auth-status", badgeStatus); - badge.className = `opportunities-extension-auth-badge opportunities-extension-auth-badge-${badgeStatus}`; - badge.textContent = badgeStatus === AUTH_STATUS.authenticated - ? "Authenticated" - : badgeStatus === AUTH_STATUS.unauthenticated - ? "Unauthenticated" - : "Authenticating"; - } - - function closeOpportunitiesModal() { - const modal = document.getElementById(MODAL_ID); - - if (modal) { - closePreferencesModal(); - closeAccountPlansModal(); - modal.hidden = true; - } - - debugSecretsVisible = false; - document.documentElement.classList.remove("opportunities-extension-scroll-lock"); - } - - function getSavedTheme() { - try { - return localStorage.getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light"; - } catch (error) { - return "light"; - } - } - - function applyTheme(modal, button, theme) { - const isDarkTheme = theme === "dark"; - - modal.setAttribute("data-theme", isDarkTheme ? "dark" : "light"); - button.textContent = ""; - button.setAttribute("aria-label", isDarkTheme ? "Ativar modo claro" : "Ativar modo escuro"); - button.title = isDarkTheme ? "Light mode" : "Dark mode"; - button.append(createThemeIcon(isDarkTheme ? "sun" : "moon")); - - try { - localStorage.setItem(THEME_STORAGE_KEY, isDarkTheme ? "dark" : "light"); - } catch (error) { - // Continue with the selected theme when browser storage is unavailable. - } - } - - function createThemeIcon(iconName) { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - - svg.setAttribute("viewBox", "0 0 24 24"); - svg.setAttribute("aria-hidden", "true"); - svg.setAttribute("focusable", "false"); - - if (iconName === "sun") { - path.setAttribute("d", "M12 3V1m0 22v-2m9-9h2M1 12h2m15.36 6.36 1.42 1.42M4.22 4.22l1.42 1.42m12.72 0 1.42-1.42M4.22 19.78l1.42-1.42M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z"); - } else { - path.setAttribute("d", "M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5a8.5 8.5 0 1 0 10.7 10.7Z"); - } - - path.setAttribute("fill", "none"); - path.setAttribute("stroke", "currentColor"); - path.setAttribute("stroke-linecap", "round"); - path.setAttribute("stroke-linejoin", "round"); - path.setAttribute("stroke-width", "1.8"); - svg.append(path); - return svg; - } - - function createSettingsIcon() { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - const upperControl = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - const lowerControl = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - - svg.setAttribute("viewBox", "0 0 24 24"); - svg.setAttribute("class", "opportunities-extension-settings-icon"); - svg.setAttribute("aria-hidden", "true"); - svg.setAttribute("focusable", "false"); - appendPath(svg, "", "M4 7h16M4 17h16"); - - [upperControl, lowerControl].forEach((control, index) => { - control.setAttribute("cx", index === 0 ? "9" : "15"); - control.setAttribute("cy", index === 0 ? "7" : "17"); - control.setAttribute("r", "2.5"); - control.setAttribute("fill", "currentColor"); - control.setAttribute("stroke", "currentColor"); - control.setAttribute("stroke-width", "1.8"); - }); - - svg.querySelector("path").setAttribute("fill", "none"); - svg.querySelector("path").setAttribute("stroke", "currentColor"); - svg.querySelector("path").setAttribute("stroke-linecap", "round"); - svg.querySelector("path").setAttribute("stroke-width", "1.8"); - svg.append(upperControl, lowerControl); - return svg; - } - - function createCloseIcon() { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - - svg.setAttribute("viewBox", "0 0 24 24"); - svg.setAttribute("aria-hidden", "true"); - svg.setAttribute("focusable", "false"); - path.setAttribute("d", "m6 6 12 12M18 6 6 18"); - path.setAttribute("fill", "none"); - path.setAttribute("stroke", "currentColor"); - path.setAttribute("stroke-linecap", "round"); - path.setAttribute("stroke-width", "1.8"); - svg.append(path); - return svg; - } - - function getSavedOpportunityTypeValues() { - try { - const rawPreferences = localStorage.getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY); - - if (!rawPreferences) { - return DEFAULT_OPPORTUNITY_TYPE_VALUES; - } - - const preferences = JSON.parse(rawPreferences); - const values = Array.isArray(preferences.opportunityTypeValues) ? preferences.opportunityTypeValues : null; - - if (!values) { - return DEFAULT_OPPORTUNITY_TYPE_VALUES; - } - - const allowedValues = new Set(OPPORTUNITY_TYPE_OPTIONS.map((option) => option.value)); - return values.filter((value) => allowedValues.has(value)); - } catch (error) { - return DEFAULT_OPPORTUNITY_TYPE_VALUES; - } - } - - function getSavedOpptyProductsCacheHours() { - try { - const rawPreferences = localStorage.getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY); - - if (!rawPreferences) { - return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; - } - - const preferences = JSON.parse(rawPreferences); - const cacheHours = Number(preferences.opptyProductsCacheHours); - return OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === cacheHours) - ? cacheHours - : DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; - } catch (error) { - return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; - } - } - - function openPreferencesModal() { - const overlay = document.getElementById(MODAL_ID); - - if (!overlay) { - return; - } - - const existingModal = document.getElementById(PREFERENCES_MODAL_ID); - - if (existingModal) { - const savedValues = new Set(getSavedOpportunityTypeValues()); - existingModal.querySelectorAll("input[name='opportunityTypeView']").forEach((checkbox) => { - checkbox.checked = savedValues.has(checkbox.value); - }); - const cacheSelect = existingModal.querySelector("select[name='opptyProductsCacheHours']"); - - if (cacheSelect) { - cacheSelect.value = String(getSavedOpptyProductsCacheHours()); - } - - existingModal.hidden = false; - existingModal.querySelector("input")?.focus(); - return; - } - - const preferencesModal = document.createElement("section"); - preferencesModal.id = PREFERENCES_MODAL_ID; - preferencesModal.className = "opportunities-extension-preferences-modal"; - preferencesModal.setAttribute("role", "dialog"); - preferencesModal.setAttribute("aria-modal", "true"); - preferencesModal.setAttribute("aria-labelledby", "opportunities-extension-preferences-title"); - - const dialog = document.createElement("div"); - dialog.className = "opportunities-extension-preferences-dialog"; - - const header = document.createElement("header"); - header.className = "opportunities-extension-preferences-header"; - - const title = document.createElement("h2"); - title.id = "opportunities-extension-preferences-title"; - title.textContent = "Preferences"; - - const closeButton = document.createElement("button"); - closeButton.type = "button"; - closeButton.className = "opportunities-extension-icon-button"; - closeButton.setAttribute("aria-label", "Fechar preferências"); - closeButton.title = "Close"; - closeButton.append(createCloseIcon()); - closeButton.addEventListener("click", closePreferencesModal); - header.append(title, closeButton); - - const content = document.createElement("div"); - content.className = "opportunities-extension-preferences-content"; - - const fieldset = document.createElement("fieldset"); - fieldset.className = "opportunities-extension-preferences-fieldset"; - - const legend = document.createElement("legend"); - legend.textContent = "Opportunity Type View"; - fieldset.append(legend); - - const selectedValues = new Set(getSavedOpportunityTypeValues()); - const options = document.createElement("div"); - options.className = "opportunities-extension-preferences-options"; - - OPPORTUNITY_TYPE_OPTIONS.forEach((option) => { - const label = document.createElement("label"); - label.className = "opportunities-extension-preferences-option"; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.name = "opportunityTypeView"; - checkbox.value = option.value; - checkbox.checked = selectedValues.has(option.value); - - const text = document.createElement("span"); - text.textContent = option.label; - label.append(checkbox, text); - options.append(label); - }); - - fieldset.append(options); - - const cacheField = document.createElement("label"); - cacheField.className = "opportunities-extension-preferences-cache-field"; - - const cacheLabel = document.createElement("span"); - cacheLabel.textContent = "Product cache duration"; - - const cacheSelect = document.createElement("select"); - cacheSelect.name = "opptyProductsCacheHours"; - cacheSelect.setAttribute("aria-label", "Product cache duration"); - OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => { - const optionElement = document.createElement("option"); - optionElement.value = String(option.value); - optionElement.textContent = option.label; - optionElement.selected = option.value === getSavedOpptyProductsCacheHours(); - cacheSelect.append(optionElement); - }); - cacheField.append(cacheLabel, cacheSelect); - content.append(fieldset, cacheField); - - const footer = document.createElement("footer"); - footer.className = "opportunities-extension-preferences-footer"; - - const saveButton = document.createElement("button"); - saveButton.type = "button"; - saveButton.className = "opportunities-extension-button opportunities-extension-button-primary"; - saveButton.textContent = "Save"; - saveButton.addEventListener("click", () => { - const selectedValues = Array.from(preferencesModal.querySelectorAll("input[name='opportunityTypeView']:checked"), (checkbox) => checkbox.value); - const selectedCacheHours = Number(preferencesModal.querySelector("select[name='opptyProductsCacheHours']")?.value); - selectedOpportunityTypeValues = new Set(selectedValues); - opptyProductsCacheHours = OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === selectedCacheHours) - ? selectedCacheHours - : DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; - - try { - localStorage.setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({ - opportunityTypeValues: selectedValues, - opptyProductsCacheHours - })); - } catch (error) { - // Continue with the saved selection for the active modal when storage is unavailable. - } - - closePreferencesModal(); - const periodSelect = overlay.querySelector("select[name='opportunityPeriod']"); - requestOpportunitiesForPeriod(periodSelect?.value || PERIOD_OPTIONS[0]); - }); - - footer.append(saveButton); - dialog.append(header, content, footer); - preferencesModal.append(dialog); - overlay.append(preferencesModal); - preferencesModal.querySelector("input")?.focus(); - } - - function closePreferencesModal() { - const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID); - - if (preferencesModal) { - preferencesModal.hidden = true; - } - } - - function ensureEscapeKeyHandler() { - if (window[ESCAPE_LISTENER_KEY]) { - return; - } - - window[ESCAPE_LISTENER_KEY] = true; - document.addEventListener("keydown", (event) => { - const modal = document.getElementById(MODAL_ID); - - if (!modal || modal.hidden) { - return; - } - - if (event.ctrlKey && event.key.toLowerCase() === "d") { - event.preventDefault(); - event.stopPropagation(); - toggleDebugPanel(); - return; - } - - if (event.key === "Escape") { - const accountPlansModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); - - if (accountPlansModal && !accountPlansModal.hidden) { - closeAccountPlansModal(); - event.preventDefault(); - return; - } - - const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID); - - if (preferencesModal && !preferencesModal.hidden) { - closePreferencesModal(); - event.preventDefault(); - return; - } - - if (closeCustomerFilterPanel()) { - event.preventDefault(); - return; - } - - if (closeOwnerFilterPanel()) { - event.preventDefault(); - return; - } - - closeOpportunitiesModal(); - } - }); - - document.addEventListener("pointerdown", (event) => { - const modal = document.getElementById(MODAL_ID); - const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); - const ownerPanel = document.getElementById(OWNER_FILTER_PANEL_ID); - - if (!modal || modal.hidden) { - return; - } - - if (panel && !panel.hidden) { - const customerFilter = panel.closest(".opportunities-extension-customer-filter"); - - if (customerFilter && !customerFilter.contains(event.target)) { - closeCustomerFilterPanel(false); - } - } - - if (ownerPanel && !ownerPanel.hidden) { - const ownerFilter = ownerPanel.closest(".opportunities-extension-customer-filter"); - - if (ownerFilter && !ownerFilter.contains(event.target)) { - closeOwnerFilterPanel(false); - } - } - }); - } - - function ensureExtensionStyles() { - if (document.getElementById(STYLE_ID)) { - return; - } - - const style = document.createElement("style"); - style.id = STYLE_ID; - style.textContent = ` - .opportunities-extension-scroll-lock { - overflow: hidden !important; - } - - .opportunities-extension-modal, - .opportunities-extension-modal * { - box-sizing: border-box; - font-family: "Oracle Sans", Arial, Helvetica, sans-serif; - } - - .opportunities-extension-modal { - position: fixed; - inset: 0; - z-index: 2147483647; - background: #f5f4f2; - color: #000000; - } - - .opportunities-extension-modal[hidden] { - display: none !important; - } - - .opportunities-extension-shell { - height: 100vh; - min-height: 0; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - background: #f5f4f2; - } - - .opportunities-extension-header { - position: relative; - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 24px; - padding: 28px 40px 22px; - border-bottom: 1px solid #dedbd7; - background: - linear-gradient(90deg, #8f624a 0 11%, #c74634 11% 23%, #6f5a7f 23% 36%, #00758f 36% 51%, #d4b06a 51% 63%, transparent 63% 100%) top left / 100% 6px no-repeat, - #ffffff; - } - - .opportunities-extension-header::before { - position: absolute; - left: 40px; - bottom: -1px; - width: 64px; - height: 3px; - background: #00758f; - content: ""; - } - - .opportunities-extension-header h1 { - margin: 0; - color: #000000; - font-size: 24px; - font-weight: 700; - line-height: 1.2; - letter-spacing: 0; - } - - .opportunities-extension-header p { - margin: 0; - color: #5f5a55; - font-size: 14px; - line-height: 1.4; - } - - .opportunities-extension-subtitle-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - margin-top: 6px; - } - - .opportunities-extension-auth-badge { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 2px 8px; - border-radius: 999px; - color: #ffffff; - font-size: 12px; - font-weight: 700; - line-height: 1.2; - } - - .opportunities-extension-auth-badge-authenticated { - background: #3f6f17; - } - - .opportunities-extension-auth-badge-unauthenticated { - background: #c5331f; - } - - .opportunities-extension-auth-badge-pending { - background: #6f5a7f; - } - - .opportunities-extension-products-progress { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 2px 8px; - border-radius: 999px; - background: #e8f4f6; - color: #006d7a; - font-size: 12px; - font-weight: 700; - line-height: 1.2; - } - - .opportunities-extension-products-progress[hidden] { - display: none !important; - } - - .opportunities-extension-products-cache-timestamp { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 2px 8px; - border-radius: 999px; - background: #f0eeeb; - color: #5f5a55; - font-size: 11px; - font-weight: 600; - line-height: 1.2; - } - - .opportunities-extension-products-cache-timestamp[hidden] { - display: none !important; - } - - .opportunities-extension-products-progress[data-state="running"]::before { - width: 12px; - height: 12px; - margin-right: 6px; - border: 2px solid currentColor; - border-right-color: transparent; - border-radius: 50%; - content: ""; - animation: opportunities-extension-spin .8s linear infinite; - } - - .opportunities-extension-products-progress[data-state="complete"] { - background: #e2f2e5; - color: #356d19; - } - - .opportunities-extension-products-progress[data-state="complete-with-errors"], - .opportunities-extension-products-progress[data-state="error"] { - background: #f9e3e1; - color: #a52b1c; - } - - @keyframes opportunities-extension-spin { - to { transform: rotate(360deg); } - } - - .opportunities-extension-icon-button { - width: 36px; - height: 36px; - flex: 0 0 36px; - border: 1px solid transparent; - border-radius: 4px; - background: transparent; - color: #312d2a; - font-size: 18px; - font-weight: 600; - line-height: 1; - cursor: pointer; - } - - .opportunities-extension-header-actions { - display: inline-flex; - align-items: center; - gap: 6px; - } - - .opportunities-extension-icon-button svg { - width: 18px; - height: 18px; - vertical-align: middle; - } - - .opportunities-extension-icon-button .opportunities-extension-settings-icon { - width: 20px; - height: 20px; - } - - .opportunities-extension-icon-button:hover, - .opportunities-extension-icon-button:focus { - border-color: #b8b2ad; - background: #f5f4f2; - outline: none; - } - - .opportunities-extension-body { - display: grid; - min-height: 0; - grid-template-rows: auto auto minmax(0, 1fr); - gap: 16px; - padding: 22px 40px 36px; - background: #f5f4f2; - overflow: hidden; - } - - .opportunities-extension-form { - display: flex; - align-items: end; - flex-wrap: wrap; - gap: 24px; - min-height: 82px; - max-width: none; - margin: 0; - padding: 14px 16px; - border: 1px solid #dedbd7; - border-radius: 4px; - background: #ffffff; - } - - .opportunities-extension-field { - display: grid; - max-width: max-content; - min-width: 270px; - width: max-content; - gap: 6px; - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - } - - .opportunities-extension-stage-dashboard { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 12px; - } - - .opportunities-extension-stage-dashboard-card { - display: grid; - gap: 6px; - min-width: 0; - padding: 12px 16px; - border: 1px solid #dedbd7; - border-top: 3px solid #5f5a55; - border-radius: 4px; - background: #ffffff; - } - - .opportunities-extension-stage-dashboard-card span { - color: #5f5a55; - font-size: 11px; - font-weight: 700; - line-height: 1.2; - } - - .opportunities-extension-stage-dashboard-value { - overflow: hidden; - color: #312d2a; - font-size: 22px; - font-weight: 700; - line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; - } - - .opportunities-extension-stage-dashboard-card[data-stage="SQL"] { - border-top-color: #008aa6; - } - - .opportunities-extension-total-dashboard-card { - border-top-color: #312d2a; - } - - .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { - border-top-color: #6f6863; - } - - .opportunities-extension-stage-dashboard-card[data-stage="UPSIDE"] { - border-top-color: #c87b00; - } - - .opportunities-extension-stage-dashboard-card[data-stage="FORECAST"] { - border-top-color: #755b8b; - } - - .opportunities-extension-stage-dashboard-card[data-stage="WON"] { - border-top-color: #4b7f22; - } - - .opportunities-extension-stage-filter { - display: grid; - grid-template-rows: auto 44px; - gap: 6px; - min-width: 0; - } - - .opportunities-extension-stage-filter-label { - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - } - - .opportunities-extension-stage-filter-controls { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - } - - .opportunities-extension-stage-filter-button { - min-height: 36px; - padding: 6px 12px; - border: 1px solid currentColor; - border-radius: 18px; - background: #ffffff; - color: #5f5a55; - font-size: 12px; - font-weight: 700; - letter-spacing: 0; - line-height: 1.2; - cursor: pointer; - } - - .opportunities-extension-stage-filter-button[data-stage="SQL"] { - color: #006b84; - } - - .opportunities-extension-stage-filter-button[data-stage="PIPELINE"] { - color: #5f5a55; - } - - .opportunities-extension-stage-filter-button[data-stage="UPSIDE"] { - color: #945400; - } - - .opportunities-extension-stage-filter-button[data-stage="FORECAST"] { - color: #624e74; - } - - .opportunities-extension-stage-filter-button[data-stage="WON"] { - color: #3f6f17; - } - - .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="SQL"] { - border-color: #006b84; - background: #006b84; - color: #ffffff; - } - - .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="PIPELINE"] { - border-color: #5f5a55; - background: #5f5a55; - color: #ffffff; - } - - .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="UPSIDE"] { - border-color: #945400; - background: #945400; - color: #ffffff; - } - - .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="FORECAST"] { - border-color: #624e74; - background: #624e74; - color: #ffffff; - } - - .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="WON"] { - border-color: #3f6f17; - background: #3f6f17; - color: #ffffff; - } - - .opportunities-extension-stage-filter-button:focus-visible { - outline: 2px solid #00758f; - outline-offset: 2px; - } - - .opportunities-extension-status-filter { - display: grid; - grid-template-rows: auto 44px; - gap: 6px; - min-width: 0; - } - - .opportunities-extension-status-filter-label { - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - } - - .opportunities-extension-status-filter-controls { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - } - - .opportunities-extension-status-filter-button { - min-height: 36px; - padding: 6px 12px; - border: 1px solid currentColor; - border-radius: 18px; - background: #ffffff; - font-size: 12px; - font-weight: 700; - letter-spacing: 0; - line-height: 1.2; - cursor: pointer; - } - - .opportunities-extension-status-filter-button[data-status="OPEN"] { - color: #197a3d; - } - - .opportunities-extension-status-filter-button[data-status="CLOSED"] { - color: #4b5563; - } - - .opportunities-extension-status-filter-button[data-status="LOST"] { - color: #b13b34; - } - - .opportunities-extension-status-filter-button[data-status="WON"] { - color: #1f5f99; - } - - .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="OPEN"] { - border-color: #197a3d; - background: #197a3d; - color: #ffffff; - } - - .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="CLOSED"] { - border-color: #4b5563; - background: #4b5563; - color: #ffffff; - } - - .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="LOST"] { - border-color: #b13b34; - background: #b13b34; - color: #ffffff; - } - - .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="WON"] { - border-color: #1f5f99; - background: #1f5f99; - color: #ffffff; - } - - .opportunities-extension-status-filter-button:focus-visible { - outline: 2px solid #00758f; - outline-offset: 2px; - } - - .opportunities-extension-customer-filter { - position: relative; - display: grid; - grid-template-rows: auto 44px; - gap: 6px; - min-width: 220px; - } - - .opportunities-extension-customer-filter-label { - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - } - - .opportunities-extension-customer-filter-trigger { - position: relative; - min-width: 220px; - min-height: 44px; - padding: 10px 38px 10px 12px; - border: 1px solid #b8b2ad; - border-radius: 3px; - background: #ffffff; - color: #312d2a; - font-size: 14px; - line-height: 1.3; - text-align: left; - cursor: pointer; - } - - .opportunities-extension-customer-filter-trigger::after { - position: absolute; - top: 50%; - right: 16px; - width: 0; - height: 0; - border-top: 6px solid #312d2a; - border-right: 5px solid transparent; - border-left: 5px solid transparent; - content: ""; - pointer-events: none; - transform: translateY(-50%); - } - - .opportunities-extension-customer-filter-trigger:disabled { - cursor: not-allowed; - background: #f5f4f2; - color: #7d7772; - } - - .opportunities-extension-customer-filter-trigger:focus-visible { - border-color: #312d2a; - box-shadow: 0 0 0 1px #312d2a; - outline: none; - } - - .opportunities-extension-customer-filter-panel { - position: absolute; - top: calc(100% + 8px); - left: 0; - z-index: 4; - display: grid; - width: min(360px, calc(100vw - 80px)); - gap: 10px; - padding: 12px; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - box-shadow: 0 4px 12px rgba(0, 0, 0, .18); - } - - .opportunities-extension-customer-filter-panel[hidden] { - display: none !important; - } - - .opportunities-extension-customer-filter-search { - width: 100%; - min-height: 40px; - padding: 8px 10px; - border: 1px solid #b8b2ad; - border-radius: 3px; - background: #ffffff; - color: #312d2a; - font-size: 14px; - } - - .opportunities-extension-customer-filter-search:focus { - border-color: #312d2a; - box-shadow: 0 0 0 1px #312d2a; - outline: none; - } - - .opportunities-extension-customer-filter-select-all, - .opportunities-extension-customer-filter-option { - display: flex; - align-items: center; - gap: 8px; - color: #312d2a; - font-size: 13px; - line-height: 1.35; - } - - .opportunities-extension-customer-filter-select-all { - min-height: 32px; - padding-bottom: 8px; - border-bottom: 1px solid #dedbd7; - font-weight: 700; - } - - .opportunities-extension-customer-filter-panel input[type="checkbox"] { - width: 16px; - height: 16px; - flex: 0 0 16px; - accent-color: #00758f; - } - - .opportunities-extension-customer-filter-list { - display: block; - max-height: 250px; - overflow: auto; - } - - .opportunities-extension-customer-filter-option { - display: grid; - grid-template-columns: 16px minmax(0, 1fr); - align-items: start; - min-height: 0; - height: auto !important; - padding: 8px 4px; - cursor: pointer; - } - - .opportunities-extension-customer-filter-option input[type="checkbox"] { - margin-top: 1px; - } - - .opportunities-extension-customer-filter-option span { - display: block; - min-width: 0; - line-height: 18px; - overflow-wrap: anywhere; - white-space: normal; - } - - .opportunities-extension-customer-filter-option:hover { - background: #f5f4f2; - } - - .opportunities-extension-customer-filter-empty { - margin: 4px 0; - color: #5f5a55; - font-size: 13px; - } - - .opportunities-extension-table-search-field { - display: grid; - grid-template-rows: auto 44px; - gap: 6px; - width: 260px; - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.3; - } - - .opportunities-extension-table-search-field input { - width: 100%; - min-height: 44px; - padding: 10px 12px; - border: 1px solid #b8b2ad; - border-radius: 3px; - background: #ffffff; - color: #312d2a; - font-size: 14px; - line-height: 1.3; - } - - .opportunities-extension-table-search-field input:focus { - border-color: #312d2a; - box-shadow: 0 0 0 1px #312d2a; - outline: none; - } - - .opportunities-extension-select-wrap { - position: relative; - display: block; - width: 270px; - max-width: 100%; - } - - .opportunities-extension-select-wrap::after { - position: absolute; - top: 50%; - right: 16px; - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-top: 6px solid #000000; - content: ""; - pointer-events: none; - transform: translateY(-50%); - } - - .opportunities-extension-field select { - width: 100%; - min-width: 0; - max-width: 100%; - min-height: 44px; - box-sizing: border-box; - appearance: none; - border: 1px solid #b8b2ad; - border-radius: 3px; - background: #ffffff; - color: #000000; - font-size: 14px; - line-height: 1.3; - padding: 11px 44px 11px 12px; - } - - .opportunities-extension-field select:focus { - border-color: #312d2a; - box-shadow: 0 0 0 1px #312d2a; - outline: none; - } - - .opportunities-extension-table-surface { - min-height: 0; - height: 100%; - border: 1px solid #dedbd7; - border-radius: 4px; - background: #ffffff; - overflow-x: auto; - overflow-y: auto; - } - - .opportunities-extension-table { - width: 100%; - min-width: 0; - border-collapse: collapse; - table-layout: fixed; - color: #312d2a; - font-size: 13px; - line-height: 1.35; - } - - .opportunities-extension-table thead { - background: #faf9f8; - } - - .opportunities-extension-table th { - position: sticky; - top: 0; - z-index: 1; - height: 44px; - border-bottom: 1px solid #b8b2ad; - background: #faf9f8; - color: #312d2a; - font-size: 12px; - font-weight: 700; - text-align: left; - white-space: nowrap; - } - - .opportunities-extension-table td { - min-height: 48px; - padding: 12px 16px; - border-bottom: 1px solid #ebe8e5; - vertical-align: middle; - overflow-wrap: anywhere; - } - - .opportunities-extension-table th:nth-child(1) { - width: 16%; - } - - .opportunities-extension-table th:nth-child(2) { - width: 8%; - } - - .opportunities-extension-table th:nth-child(3) { - width: 18%; - } - - .opportunities-extension-table th:nth-child(4) { - width: 11%; - } - - .opportunities-extension-table th:nth-child(5) { - width: 8%; - } - - .opportunities-extension-table th:nth-child(6) { - width: 8%; - } - - .opportunities-extension-table th:nth-child(7) { - width: 7%; - } - - .opportunities-extension-table th:nth-child(8) { - width: 6%; - } - - .opportunities-extension-table th:nth-child(9) { - width: 7%; - } - - .opportunities-extension-table th:nth-child(10) { - width: 7%; - } - - .opportunities-extension-table th:nth-child(11) { - width: 4%; - } - - .opportunities-extension-table td:nth-child(2), - .opportunities-extension-table td:nth-child(5), - .opportunities-extension-table td:nth-child(6), - .opportunities-extension-table td:nth-child(8) { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .opportunities-extension-table td:nth-child(9) { - padding-right: 6px; - padding-left: 6px; - white-space: nowrap; - overflow: visible; - } - - .opportunities-extension-table td:nth-child(7) { - padding-right: 6px; - padding-left: 6px; - white-space: nowrap; - overflow: visible; - } - - .opportunities-extension-table td:nth-child(10) { - padding-right: 6px; - padding-left: 6px; - font-size: 12px; - white-space: nowrap; - } - - .opportunities-extension-table td:nth-child(11) { - padding-right: 6px; - padding-left: 6px; - text-align: center; - white-space: nowrap; - } - - .opportunities-extension-table tbody tr:hover { - background: #f7f6f4; - } - - .opportunities-extension-table tbody tr:last-child td { - border-bottom: 0; - } - - .opportunities-extension-table a { - color: #006b84; - text-decoration: underline; - text-decoration-thickness: 1px; - text-underline-offset: 2px; - } - - .opportunities-extension-table a:hover { - color: #004f63; - } - - .opportunities-extension-opty-number-content { - display: inline-flex; - align-items: center; - gap: 6px; - max-width: 100%; - } - - .opportunities-extension-name-content { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - } - - .opportunities-extension-name-content > a { - min-width: 0; - } - - .opportunities-extension-actions-content { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 4px; - white-space: nowrap; - } - - .opportunities-extension-products-count { - display: inline-grid; - width: auto; - min-width: 20px; - height: 20px; - place-items: center; - flex: 0 0 auto; - box-sizing: border-box; - padding: 0 3px; - border: 1px solid #7ca7b2; - border-radius: 999px; - background: #e8f4f6; - color: #006d7a; - font-size: 10px; - font-weight: 700; - line-height: 1; - text-align: center; - vertical-align: middle; - cursor: pointer; - } - - .opportunities-extension-products-count:hover, - .opportunities-extension-products-count:focus-visible, - .opportunities-extension-products-count[aria-expanded="true"] { - border-color: #00758f; - background: #00758f; - color: #ffffff; - outline: none; - } - - .opportunities-extension-products-count-error { - border-color: #d8887e; - background: #f9e3e1; - color: #a52b1c; - } - - .opportunities-extension-products-detail-row:hover { - background: transparent !important; - } - - .opportunities-extension-products-detail-cell { - padding: 0 !important; - } - - .opportunities-extension-products-detail { - padding: 14px 20px 18px; - border-bottom: 1px solid #c9c5c1; - background: #f0eeeb; - } - - .opportunities-extension-products-detail-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 10px; - 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; - } - - .opportunities-extension-products-detail-heading span { - color: #5f5a55; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - } - - .opportunities-extension-products-table { - width: 100%; - table-layout: fixed; - border-collapse: collapse; - border: 1px solid #dedbd7; - background: #ffffff; - font-size: 12px; - } - - .opportunities-extension-products-table th, - .opportunities-extension-products-table td { - position: static; - height: auto; - padding: 8px 10px; - border-bottom: 1px solid #ebe8e5; - background: transparent; - overflow-wrap: anywhere; - vertical-align: middle; - white-space: normal; - } - - .opportunities-extension-products-table th { - background: #faf9f8; - font-size: 11px; - } - - .opportunities-extension-products-table th:nth-child(1) { width: 16%; } - .opportunities-extension-products-table th:nth-child(2) { width: 16%; } - .opportunities-extension-products-table th:nth-child(3) { width: 8%; } - .opportunities-extension-products-table th:nth-child(4) { width: 10%; } - .opportunities-extension-products-table th:nth-child(5) { width: 8%; } - .opportunities-extension-products-table th:nth-child(6) { width: 8%; } - .opportunities-extension-products-table th:nth-child(7) { width: 7%; } - .opportunities-extension-products-table th:nth-child(8) { width: 10%; } - .opportunities-extension-products-table th:nth-child(9) { width: 12%; } - .opportunities-extension-products-table th:nth-child(10) { width: 5%; } - - .opportunities-extension-products-empty { - margin: 0; - padding: 10px 0; - color: #5f5a55; - font-size: 13px; - } - - .opportunities-extension-copy-button { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - width: 24px; - height: 24px; - padding: 3px; - border: 0; - border-radius: 3px; - background: transparent; - color: #00758f; - cursor: pointer; - } - - .opportunities-extension-copy-button svg { - width: 16px; - height: 16px; - fill: currentColor; - } - - .opportunities-extension-copy-button:hover, - .opportunities-extension-copy-button:focus-visible { - background: #e8f4f6; - color: #004f63; - outline: none; - } - - .opportunities-extension-action-button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 5px; - border: 1px solid transparent; - border-radius: 4px; - background: transparent; - color: #00758f; - cursor: pointer; - } - - .opportunities-extension-action-button svg { - width: 21px; - height: 21px; - } - - .opportunities-extension-action-button:hover, - .opportunities-extension-action-button:focus-visible { - border-color: #7ca7b2; - background: #e8f4f6; - color: #004f63; - outline: none; - } - - .opportunities-extension-action-button:disabled { - cursor: wait; - opacity: .72; - } - - .opportunities-extension-action-button-loading svg { - animation: opportunities-extension-pulse 1s ease-in-out infinite; - } - - @keyframes opportunities-extension-pulse { - 50% { opacity: .35; } - } - - .opportunities-extension-copy-toast { - position: fixed; - right: 28px; - bottom: 28px; - z-index: 3; - max-width: min(360px, calc(100vw - 56px)); - padding: 12px 16px; - border-radius: 4px; - background: #2f6f18; - box-shadow: 0 4px 12px rgba(0, 0, 0, .18); - color: #ffffff; - font-size: 13px; - font-weight: 700; - } - - .opportunities-extension-copy-toast-error { - background: #a52b1c; - } - - .opportunities-extension-copy-toast[hidden] { - display: none !important; - } - - .opportunities-extension-stage-badge { - display: inline-flex; - align-items: center; - min-height: 24px; - padding: 3px 8px; - border-radius: 12px; - background: #ebe8e5; - color: #312d2a; - font-size: 11px; - font-weight: 700; - line-height: 1.2; - white-space: nowrap; - } - - .opportunities-extension-stage-badge[data-stage="SQL"] { - background: #d9f0f5; - color: #006b84; - } - - .opportunities-extension-stage-badge[data-stage="PIPELINE"] { - background: #ebe8e5; - color: #5f5a55; - } - - .opportunities-extension-stage-badge[data-stage="UPSIDE"] { - background: #fff0d8; - color: #945400; - } - - .opportunities-extension-stage-badge[data-stage="FORECAST"] { - background: #eee8f5; - color: #624e74; - } - - .opportunities-extension-stage-badge[data-stage="WON"] { - background: #e5f1d9; - color: #3f6f17; - } - - .opportunities-extension-status-badge { - display: inline-flex; - align-items: center; - min-height: 24px; - padding: 3px 8px; - border-radius: 12px; - background: #ebe8e5; - color: #312d2a; - font-size: 11px; - font-weight: 700; - line-height: 1.2; - white-space: nowrap; - } - - .opportunities-extension-status-badge[data-status="OPEN"] { - background: #e2f2e5; - color: #197a3d; - } - - .opportunities-extension-status-badge[data-status="CLOSED"] { - background: #e5e7eb; - color: #4b5563; - } - - .opportunities-extension-status-badge[data-status="LOST"] { - background: #f9e3e1; - color: #b13b34; - } - - .opportunities-extension-status-badge[data-status="WON"] { - background: #e3edf8; - color: #1f5f99; - } - - .opportunities-extension-sort-button { - position: relative; - display: inline-flex; - align-items: center; - width: 100%; - min-height: 44px; - padding: 8px 42px 8px 16px; - border: 0; - background: transparent; - color: inherit; - font: inherit; - font-weight: inherit; - letter-spacing: 0; - line-height: 1.2; - text-align: left; - white-space: normal; - cursor: pointer; - } - - .opportunities-extension-table-header-label { - display: inline-flex; - align-items: center; - min-height: 44px; - padding: 8px 6px; - } - - .opportunities-extension-sort-button::after { - position: absolute; - right: 15px; - color: #5f5a55; - content: "↕"; - font-size: 16px; - font-weight: 400; - } - - .opportunities-extension-sort-button[data-sort-direction="ascending"]::after { - color: #00758f; - content: "↑"; - } - - .opportunities-extension-sort-button[data-sort-direction="descending"]::after { - color: #00758f; - content: "↓"; - } - - .opportunities-extension-sort-button:hover { - background: #f0eeeb; - } - - .opportunities-extension-sort-button:focus-visible { - outline: 2px solid #00758f; - outline-offset: -2px; - } - - .opportunities-extension-table-status { - height: 152px; - color: #5f5a55; - font-size: 14px; - text-align: center; - } - - .opportunities-extension-visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; - } - - .opportunities-extension-preferences-modal { - position: absolute; - inset: 0; - z-index: 3; - display: grid; - place-items: center; - padding: 24px; - background: rgba(0, 0, 0, .42); - } - - .opportunities-extension-preferences-modal[hidden] { - display: none !important; - } - - .opportunities-extension-account-plans-modal { - position: absolute; - inset: 0; - z-index: 4; - display: grid; - place-items: center; - padding: 12px; - background: rgba(0, 0, 0, .42); - } - - .opportunities-extension-account-plans-modal[hidden] { - display: none !important; - } - - .opportunities-extension-account-plans-dialog { - display: grid; - width: min(1180px, 100%); - height: min(760px, calc(100vh - 24px)); - min-width: 0; - grid-template-rows: auto minmax(0, 1fr); - grid-template-columns: minmax(0, 1fr); - overflow: hidden; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - box-shadow: 0 8px 24px rgba(0, 0, 0, .24); - color: #312d2a; - } - - .opportunities-extension-account-plans-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - padding: 14px 20px; - border-bottom: 1px solid #dedbd7; - } - - .opportunities-extension-account-plans-header h2 { - margin: 0; - font-size: 20px; - line-height: 1.2; - } - - .opportunities-extension-account-plans-header p { - margin: 5px 0 0; - color: #5f5a55; - font-size: 13px; - line-height: 1.35; - } - - .opportunities-extension-account-plans-content { - display: grid; - min-width: 0; - grid-template-rows: auto minmax(0, 1fr); - grid-template-columns: minmax(0, 1fr); - min-height: 0; - overflow: hidden; - } - - .opportunities-extension-account-plans-content > .opportunities-extension-account-plans-skeleton { - grid-row: 1 / -1; - } - - .opportunities-extension-account-plans-tabs-navigation { - position: relative; - min-width: 0; - border-bottom: 1px solid #dedbd7; - } - - .opportunities-extension-account-plans-tabs-navigation .opportunities-extension-account-plans-tab-navigation-button[hidden] { - display: none; - } - - .opportunities-extension-account-plans-tabs { - display: flex; - min-width: 0; - gap: 4px; - overflow-x: auto; - padding: 8px 20px 0; - scrollbar-width: none; - } - - .opportunities-extension-account-plans-tabs::-webkit-scrollbar { - display: none; - } - - .opportunities-extension-account-plans-tab-navigation-button { - display: grid; - position: absolute; - z-index: 1; - top: 50%; - width: 32px; - height: 32px; - place-items: center; - padding: 0; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - color: #312d2a; - box-shadow: 0 2px 6px rgba(0, 0, 0, .16); - transform: translateY(-50%); - cursor: pointer; - } - - .opportunities-extension-account-plans-tab-navigation-button:first-child { - left: 8px; - } - - .opportunities-extension-account-plans-tab-navigation-button:last-child { - right: 8px; - } - - .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) { - border-color: #00758f; - background: #f0eeeb; - color: #005e73; - } - - .opportunities-extension-account-plans-tab-navigation-button:disabled { - border-color: #dedbd7; - color: #b8b2ad; - cursor: default; - } - - .opportunities-extension-account-plans-tab-navigation-button:focus-visible { - outline: 2px solid #00758f; - outline-offset: 2px; - } - - .opportunities-extension-account-plans-tab-navigation-button svg { - width: 18px; - height: 18px; - } - - .opportunities-extension-account-plans-panels { - min-width: 0; - min-height: 0; - overflow: hidden; - } - - .opportunities-extension-account-plan-tab { - min-height: 38px; - flex: 0 0 auto; - padding: 7px 12px; - border: 0; - border-bottom: 3px solid transparent; - background: transparent; - color: #5f5a55; - font-size: 13px; - font-weight: 600; - cursor: pointer; - } - - .opportunities-extension-account-plan-tab:hover { - background: #f0eeeb; - color: #312d2a; - } - - .opportunities-extension-account-plan-tab[aria-selected="true"] { - border-bottom-color: #00758f; - color: #006d7a; - } - - .opportunities-extension-account-plan-tab:focus-visible { - outline: 2px solid #00758f; - outline-offset: -2px; - } - - .opportunities-extension-account-plan-panel { - display: grid; - height: 100%; - min-height: 0; - grid-template-rows: auto minmax(0, 1fr); - box-sizing: border-box; - padding: 16px 20px; - } - - .opportunities-extension-account-plan-panel[hidden] { - display: none !important; - } - - .opportunities-extension-account-plan-details { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0 24px; - margin: 0; - } - - .opportunities-extension-account-plan-details > div { - display: grid; - grid-template-columns: minmax(104px, 38%) minmax(0, 1fr); - gap: 10px; - min-height: 42px; - align-items: center; - padding: 7px 0; - border-bottom: 1px solid #ebe8e5; - } - - .opportunities-extension-account-plan-details dt { - color: #5f5a55; - font-size: 12px; - font-weight: 700; - } - - .opportunities-extension-account-plan-details dd { - min-width: 0; - margin: 0; - color: #312d2a; - font-size: 13px; - overflow-wrap: anywhere; - } - - .opportunities-extension-consumption-trend { - display: grid; - min-height: 0; - grid-template-rows: auto minmax(0, 1fr); - margin-top: 14px; - padding-top: 12px; - border-top: 1px solid #dedbd7; - } - - .opportunities-extension-consumption-trend-header { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 24px; - } - - .opportunities-extension-consumption-periods { - display: flex; - align-items: center; - gap: 4px; - } - - .opportunities-extension-consumption-periods button { - min-height: 38px; - padding: 7px 14px; - border: 1px solid transparent; - border-radius: 4px; - background: transparent; - color: #312d2a; - font-size: 13px; - font-weight: 600; - line-height: 1.2; - cursor: pointer; - } - - .opportunities-extension-consumption-periods button:hover { - background: #f0eeeb; - } - - .opportunities-extension-consumption-periods button[aria-pressed="true"] { - border-color: #00758f; - background: #e7f6f8; - color: #005e73; - } - - .opportunities-extension-consumption-periods button:focus-visible, - .opportunities-extension-consumption-navigation-button:focus-visible { - outline: 2px solid #00758f; - outline-offset: 2px; - } - - .opportunities-extension-consumption-chart { - display: grid; - min-height: 0; - grid-template-rows: auto minmax(0, 1fr); - margin-top: 4px; - } - - .opportunities-extension-consumption-navigation { - display: grid; - grid-template-columns: 36px minmax(130px, auto) 36px; - align-items: center; - justify-content: center; - gap: 8px; - min-height: 40px; - margin-bottom: 2px; - } - - .opportunities-extension-consumption-navigation strong { - color: #312d2a; - font-size: 13px; - text-align: center; - } - - .opportunities-extension-consumption-navigation-button { - display: grid; - width: 36px; - height: 36px; - place-items: center; - padding: 0; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - color: #312d2a; - cursor: pointer; - } - - .opportunities-extension-consumption-navigation-button:hover:not(:disabled) { - border-color: #00758f; - background: #f0eeeb; - color: #005e73; - } - - .opportunities-extension-consumption-navigation-button:disabled { - border-color: #dedbd7; - color: #b8b2ad; - cursor: default; - } - - .opportunities-extension-consumption-navigation-button svg { - width: 18px; - height: 18px; - } - - .opportunities-extension-consumption-figure { - position: relative; - min-width: 0; - min-height: 0; - margin: 0; - scrollbar-width: none; - } - - .opportunities-extension-consumption-figure::-webkit-scrollbar { - display: none; - } - - .opportunities-extension-consumption-visual { - display: grid; - height: 100%; - min-height: 0; - grid-template-columns: minmax(0, 1fr) 176px; - align-items: center; - gap: 16px; - } - - .opportunities-extension-consumption-svg { - display: block; - width: 100%; - height: 300px; - min-height: 0; - overflow: visible; - } - - .opportunities-extension-consumption-svg .consumption-grid-line { - stroke: #dedbd7; - stroke-width: 1; - shape-rendering: crispEdges; - } - - .opportunities-extension-consumption-svg .consumption-axis-label, - .opportunities-extension-consumption-svg .consumption-x-label { - fill: #5f5a55; - font-family: Arial, sans-serif; - font-size: 12px; - } - - .opportunities-extension-consumption-svg .consumption-value-label { - fill: #312d2a; - font-family: Arial, sans-serif; - font-size: 12px; - font-weight: 700; - } - - .opportunities-extension-consumption-svg .consumption-bar-funded { - fill: #b77888; - } - - .opportunities-extension-consumption-svg .consumption-bar-overage { - fill: #de7907; - } - - .opportunities-extension-consumption-svg .consumption-average-line { - stroke: #de7907; - stroke-width: 1.5; - stroke-dasharray: 6 6; - } - - .opportunities-extension-consumption-svg .consumption-bar-group { - cursor: default; - outline: none; - } - - .opportunities-extension-consumption-svg .consumption-bar-group rect { - transition: filter .16s ease, opacity .16s ease, stroke .16s ease; - } - - .opportunities-extension-consumption-svg .consumption-bar-group:hover rect, - .opportunities-extension-consumption-svg .consumption-bar-group:focus rect { - filter: brightness(1.06); - stroke: #312d2a; - stroke-width: 1.5; - } - - .opportunities-extension-consumption-svg .consumption-animated-label { - opacity: 0; - animation: opportunities-extension-consumption-label-in .24s ease-out forwards; - } - - .opportunities-extension-consumption-svg .consumption-animated-average { - opacity: 0; - animation: opportunities-extension-consumption-average-in .35s .32s ease-out forwards; - } - - @keyframes opportunities-extension-consumption-label-in { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: translateY(0); } - } - - @keyframes opportunities-extension-consumption-average-in { - from { opacity: 0; } - to { opacity: 1; } - } - - .opportunities-extension-consumption-legend { - display: grid; - align-content: center; - gap: 4px; - } - - .opportunities-extension-consumption-legend button { - display: grid; - min-width: 0; - grid-template-columns: 16px minmax(0, 1fr); - align-items: center; - gap: 8px; - padding: 6px 4px; - border: 0; - border-radius: 4px; - background: transparent; - color: #312d2a; - font-size: 12px; - line-height: 1.25; - text-align: left; - cursor: pointer; - } - - .opportunities-extension-consumption-legend button:hover { - background: #f0eeeb; - } - - .opportunities-extension-consumption-legend button:focus-visible { - outline: 2px solid #00758f; - outline-offset: 1px; - } - - .opportunities-extension-consumption-legend button[aria-pressed="false"] { - color: #77726d; - opacity: .55; - } - - .opportunities-extension-consumption-legend-swatch { - display: block; - width: 12px; - height: 12px; - justify-self: center; - } - - .opportunities-extension-consumption-legend-funded { - background: #b77888; - } - - .opportunities-extension-consumption-legend-overage { - background: #de7907; - } - - .opportunities-extension-consumption-legend-average { - height: 0; - border-top: 2px dashed #de7907; - } - - .opportunities-extension-consumption-tooltip { - position: absolute; - z-index: 2; - display: grid; - min-width: 190px; - max-width: 260px; - gap: 4px; - padding: 10px 12px; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - box-shadow: 0 4px 12px rgba(0, 0, 0, .2); - color: #312d2a; - font-size: 12px; - line-height: 1.3; - pointer-events: none; - } - - .opportunities-extension-consumption-tooltip[hidden] { - display: none !important; - } - - .opportunities-extension-consumption-tooltip strong { - margin-bottom: 2px; - font-size: 13px; - } - - .opportunities-extension-consumption-chart-skeleton { - height: 100%; - min-height: 0; - padding: 34px 62px 24px; - box-sizing: border-box; - } - - .opportunities-extension-consumption-chart-skeleton-plot { - display: flex; - height: 100%; - min-height: 220px; - align-items: flex-end; - justify-content: space-around; - gap: 12px; - padding: 0 14px; - border-bottom: 1px solid #dedbd7; - border-left: 1px solid #dedbd7; - } - - .opportunities-extension-consumption-chart-skeleton-plot span { - width: min(38px, 6%); - border-radius: 2px 2px 0 0; - background: #dedbd7; - animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; - } - - .opportunities-extension-consumption-chart-empty, - .opportunities-extension-consumption-chart-error { - display: grid; - min-height: 220px; - place-items: center; - margin: 0; - color: #5f5a55; - font-size: 14px; - text-align: center; - } - - .opportunities-extension-consumption-chart-error { - color: #9f2d20; - } - - .opportunities-extension-account-plan-status-indicator { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 50%; - background: #9b9691; - box-shadow: 0 0 0 3px #efedeb; - vertical-align: middle; - } - - .opportunities-extension-account-plan-status-indicator[data-active="true"] { - background: #3f6f17; - box-shadow: 0 0 0 3px #e2f2e5; - } - - .opportunities-extension-account-plan-tab .opportunities-extension-account-plan-status-indicator { - margin-right: 8px; - } - - .opportunities-extension-account-plans-skeleton-tabs { - display: flex; - min-height: 46px; - align-items: center; - padding: 0 20px; - border-bottom: 1px solid #dedbd7; - } - - .opportunities-extension-account-plans-skeleton { - display: grid; - height: 100%; - min-height: 0; - grid-template-rows: auto minmax(0, 1fr); - overflow: hidden; - } - - .opportunities-extension-account-plans-skeleton-panel { - padding: 16px 20px; - } - - .opportunities-extension-account-plans-skeleton-tab { - display: inline-flex; - align-items: center; - gap: 9px; - min-height: 38px; - padding: 0 12px; - border-bottom: 3px solid #00758f; - } - - .opportunities-extension-account-plans-skeleton-status { - display: block; - width: 10px; - height: 10px; - flex: 0 0 auto; - border-radius: 50%; - background: #dedbd7; - box-shadow: 0 0 0 3px #efedeb; - animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; - } - - .opportunities-extension-account-plans-skeleton-details { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0 24px; - padding: 0; - } - - .opportunities-extension-account-plans-skeleton-details > div { - display: grid; - grid-template-columns: minmax(104px, 38%) minmax(0, 1fr); - align-items: center; - gap: 10px; - min-height: 42px; - padding: 7px 0; - border-bottom: 1px solid #ebe8e5; - } - - .opportunities-extension-account-plans-skeleton-bar { - display: block; - height: 12px; - border-radius: 3px; - background: #dedbd7; - animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; - } - - .opportunities-extension-account-plans-skeleton-tabs .opportunities-extension-account-plans-skeleton-bar { - height: 14px; - } - - .opportunities-extension-account-plans-skeleton-label { - width: 74%; - height: 10px; - } - - .opportunities-extension-account-plans-skeleton-trend { - display: grid; - min-height: 0; - grid-template-rows: auto auto minmax(0, 1fr); - margin: 14px 0 0; - padding-top: 12px; - border-top: 1px solid #dedbd7; - } - - .opportunities-extension-account-plans-skeleton-trend-header { - display: flex; - justify-content: flex-end; - min-height: 38px; - } - - .opportunities-extension-account-plans-skeleton-periods { - display: flex; - align-items: center; - gap: 16px; - } - - .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar { - height: 12px; - } - - .opportunities-extension-account-plans-skeleton-period-active { - height: 38px !important; - border: 1px solid #8cc8d3; - background: #e7f6f8; - } - - .opportunities-extension-account-plans-skeleton-navigation { - display: grid; - grid-template-columns: 32px 124px 32px; - align-items: center; - justify-content: center; - gap: 8px; - min-height: 54px; - } - - .opportunities-extension-account-plans-skeleton-navigation .opportunities-extension-account-plans-skeleton-bar { - justify-self: center; - } - - .opportunities-extension-account-plans-skeleton-chart { - display: grid; - min-height: 0; - grid-template-columns: minmax(0, 1fr) 150px; - align-items: center; - gap: 28px; - padding: 12px 14px 0 62px; - } - - .opportunities-extension-account-plans-skeleton-plot { - display: flex; - position: relative; - height: 300px; - min-height: 0; - box-sizing: border-box; - align-items: flex-end; - justify-content: space-around; - gap: 12px; - padding: 0 14px 64px; - background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #ebe8e5 58px, #ebe8e5 59px) left top / 100% 236px no-repeat; - } - - .opportunities-extension-account-plans-skeleton-plot::before, - .opportunities-extension-account-plans-skeleton-plot::after { - position: absolute; - display: block; - content: ""; - background: #dedbd7; - } - - .opportunities-extension-account-plans-skeleton-plot::before { - top: 0; - bottom: 64px; - left: 0; - width: 1px; - } - - .opportunities-extension-account-plans-skeleton-plot::after { - right: 0; - bottom: 64px; - left: 0; - height: 1px; - } - - .opportunities-extension-account-plans-skeleton-plot .opportunities-extension-account-plans-skeleton-bar { - width: min(38px, 7%); - flex: 0 1 38px; - border-radius: 2px 2px 0 0; - } - - .opportunities-extension-account-plans-skeleton-legend { - display: grid; - align-content: center; - gap: 18px; - min-height: 0; - } - - .opportunities-extension-account-plans-skeleton-legend .opportunities-extension-account-plans-skeleton-bar { - height: 11px; - } - - @keyframes opportunities-extension-skeleton-pulse { - from { opacity: .48; } - to { opacity: 1; } - } - - @media (prefers-reduced-motion: reduce) { - .opportunities-extension-account-plans-skeleton-bar, - .opportunities-extension-consumption-chart-skeleton-plot span, - .opportunities-extension-consumption-svg .consumption-animated-label, - .opportunities-extension-consumption-svg .consumption-animated-average { - animation: none; - opacity: 1; - } - } - - .opportunities-extension-account-plans-empty, - .opportunities-extension-account-plans-error { - margin: 0; - padding: 28px 20px; - color: #5f5a55; - font-size: 14px; - } - - .opportunities-extension-account-plans-error { - color: #9f2d20; - } - - .opportunities-extension-preferences-dialog { - display: grid; - width: min(680px, 100%); - max-height: min(720px, calc(100vh - 48px)); - grid-template-rows: auto minmax(0, 1fr) auto; - overflow: hidden; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - box-shadow: 0 8px 24px rgba(0, 0, 0, .24); - color: #312d2a; - } - - .opportunities-extension-preferences-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 18px 20px; - border-bottom: 1px solid #dedbd7; - } - - .opportunities-extension-preferences-header h2 { - margin: 0; - font-size: 20px; - font-weight: 700; - line-height: 1.2; - } - - .opportunities-extension-preferences-content { - min-height: 0; - overflow: auto; - padding: 20px; - } - - .opportunities-extension-preferences-fieldset { - min-width: 0; - margin: 0; - padding: 0; - border: 0; - } - - .opportunities-extension-preferences-fieldset legend { - margin-bottom: 12px; - padding: 0; - color: #312d2a; - font-size: 15px; - font-weight: 700; - line-height: 1.3; - } - - .opportunities-extension-preferences-options { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 4px 20px; - } - - .opportunities-extension-preferences-option { - display: flex; - align-items: flex-start; - gap: 10px; - min-width: 0; - padding: 9px 8px; - border-radius: 4px; - color: #312d2a; - font-size: 14px; - line-height: 1.35; - cursor: pointer; - } - - .opportunities-extension-preferences-option:hover { - background: #f0eeeb; - } - - .opportunities-extension-preferences-option input { - width: 18px; - height: 18px; - flex: 0 0 18px; - margin: 0; - accent-color: #00758f; - cursor: pointer; - } - - .opportunities-extension-preferences-cache-field { - display: grid; - width: min(280px, 100%); - gap: 6px; - margin-top: 24px; - color: #312d2a; - font-size: 14px; - font-weight: 700; - } - - .opportunities-extension-preferences-cache-field select { - width: 100%; - height: 40px; - padding: 0 36px 0 12px; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - color: #312d2a; - font-size: 14px; - font-weight: 400; - } - - .opportunities-extension-preferences-cache-field select:focus { - border-color: #00758f; - outline: 2px solid #bde7ee; - outline-offset: 0; - } - - .opportunities-extension-preferences-footer { - display: flex; - justify-content: flex-end; - padding: 14px 20px; - border-top: 1px solid #dedbd7; - } - - .opportunities-extension-button { - min-width: 88px; - min-height: 36px; - padding: 7px 16px; - border: 1px solid transparent; - border-radius: 4px; - font-size: 14px; - font-weight: 600; - line-height: 1.2; - cursor: pointer; - } - - .opportunities-extension-button-primary { - border-color: #00758f; - background: #00758f; - color: #ffffff; - } - - .opportunities-extension-button-primary:hover, - .opportunities-extension-button-primary:focus-visible { - border-color: #005e73; - background: #005e73; - outline: none; - } - - .opportunities-extension-debug-panel { - position: fixed; - right: 24px; - bottom: 24px; - z-index: 1; - width: min(640px, calc(100vw - 48px)); - max-height: min(560px, calc(100vh - 120px)); - overflow: auto; - border: 1px solid #8f8a85; - border-radius: 4px; - background: #ffffff; - box-shadow: 0 2px 8px rgba(0, 0, 0, .16); - padding: 16px; - color: #000000; - } - - .opportunities-extension-debug-panel[hidden] { - display: none !important; - } - - .opportunities-extension-debug-panel h2 { - margin: 0; - font-size: 18px; - font-weight: 700; - line-height: 1.25; - } - - .opportunities-extension-debug-panel p { - margin: 4px 0 0; - color: #5f5a55; - font-size: 13px; - } - - .opportunities-extension-debug-panel-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; - } - - .opportunities-extension-debug-visibility-button { - width: 34px; - height: 34px; - flex: 0 0 34px; - color: #312d2a; - } - - .opportunities-extension-debug-visibility-button svg { - width: 20px; - height: 20px; - } - - .opportunities-extension-debug-list { - display: grid; - gap: 10px; - } - - .opportunities-extension-debug-item { - border: 1px solid #dedbd7; - border-left-width: 4px; - border-radius: 4px; - background: #faf9f8; - } - - .opportunities-extension-debug-item[open] { - background: #ffffff; - } - - .opportunities-extension-debug-item-success { - border-left-color: #3f6f17; - } - - .opportunities-extension-debug-item-success .opportunities-extension-debug-status { - background: #e2f2e5; - color: #356d19; - } - - .opportunities-extension-debug-item-failed { - border-left-color: #c5331f; - } - - .opportunities-extension-debug-item-failed .opportunities-extension-debug-status { - background: #f9e3e1; - color: #a52b1c; - } - - .opportunities-extension-debug-item-pending { - border-left-color: #6f5a7f; - } - - .opportunities-extension-debug-item-pending .opportunities-extension-debug-status { - background: #eee8f2; - color: #6f5a7f; - } - - .opportunities-extension-debug-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-height: 42px; - padding: 9px 12px 9px 10px; - cursor: pointer; - list-style: none; - } - - .opportunities-extension-debug-heading::-webkit-details-marker { - display: none; - } - - .opportunities-extension-debug-heading::before { - content: "⌄"; - width: 14px; - color: #5f5a55; - font-size: 16px; - line-height: 1; - transform: rotate(-90deg); - transition: transform .15s ease; - } - - .opportunities-extension-debug-item[open] .opportunities-extension-debug-heading::before { - transform: rotate(0deg); - } - - .opportunities-extension-debug-summary-meta { - display: inline-flex; - align-items: center; - gap: 8px; - margin-left: auto; - } - - .opportunities-extension-debug-http-status, - .opportunities-extension-debug-status { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 0 8px; - border-radius: 999px; - font-size: 11px; - font-weight: 700; - line-height: 1; - text-transform: uppercase; - } - - .opportunities-extension-debug-http-status { - background: #e8f4f6; - color: #006d7a; - } - - .opportunities-extension-debug-status { - background: #ebe8e5; - color: #5f5a55; - } - - .opportunities-extension-debug-heading strong { - font-size: 13px; - } - - .opportunities-extension-debug-item pre { - margin: 0; - padding: 8px 12px 12px; - color: #312d2a; - font-family: Consolas, "Courier New", monospace; - font-size: 12px; - line-height: 1.45; - white-space: pre-wrap; - word-break: break-word; - } - - .opportunities-extension-debug-empty { - padding: 14px; - border: 1px dashed #b8b2ad; - border-radius: 4px; - color: #5f5a55; - font-size: 13px; - } - - .opportunities-extension-modal[data-theme="dark"] { - background: #1f1f1f; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-shell, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-body { - background: #1f1f1f; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-header { - border-bottom-color: #4e4a46; - background: - linear-gradient(90deg, #a8795d 0 11%, #dd5a47 11% 23%, #876e9b 23% 36%, #00a6be 36% 51%, #dfbd76 51% 63%, transparent 63% 100%) top left / 100% 6px no-repeat, - #292827; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-header h1, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-value, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-field, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-label, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-label, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-label, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-search-field { - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-header p, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header p, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card span, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-empty, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail-heading span, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-empty, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-empty, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details dt, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel p, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-empty { - color: #c9c5c1; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-error { - color: #ffb4a8; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-error { - color: #ffb4a8; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-bar { - background: #5b5753; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-status { - background: #5b5753; - box-shadow: 0 0 0 3px #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-period-active { - border-color: #3f7580; - background: #23474e; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot span { - background: #5b5753; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator { - background: #8f8a85; - box-shadow: 0 0 0 3px #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator[data-active="true"] { - background: #7fb05b; - box-shadow: 0 0 0 3px #314527; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-form, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-surface, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-panel, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-dialog, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-dialog, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] { - border-color: #4e4a46; - background: #292827; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-trigger, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-search, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-search-field input, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-field select, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-cache-field select, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button { - border-color: #6a6560; - background: #333130; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-trigger::after { - border-top-color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-select-wrap::after { - border-top-color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-trigger:disabled { - background: #292827; - color: #918b86; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-search::placeholder, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-search-field input::placeholder { - color: #b5b0ab; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header h2, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header h2, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-fieldset legend, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-cache-field, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail-heading, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details dd, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-heading, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 { - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation strong { - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-empty { - color: #c9c5c1; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table th { - border-bottom-color: #4e4a46; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-footer, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs-navigation, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details > div, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-tabs, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-details > div { - border-color: #4e4a46; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-trend, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-trend, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot { - border-color: #4e4a46; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot { - background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #4e4a46 58px, #4e4a46 59px) left top / 100% 236px no-repeat; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot::before, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot::after { - background: #4e4a46; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab { - color: #c9c5c1; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab:hover { - background: #3b3937; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab[aria-selected="true"] { - border-bottom-color: #43c4d5; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button { - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button:hover { - background: #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button[aria-pressed="true"] { - border-color: #43c4d5; - background: #173e43; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button { - border-color: #6a6560; - background: #333130; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:hover:not(:disabled), - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) { - border-color: #43c4d5; - background: #3b3937; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:disabled, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button:disabled { - border-color: #4e4a46; - color: #746f6a; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-grid-line { - stroke: #4e4a46; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-axis-label, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-x-label { - fill: #c9c5c1; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-value-label { - fill: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-funded { - fill: #ca8798; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-overage { - fill: #f09a32; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-average-line { - stroke: #f09a32; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-group:hover rect, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-group:focus rect { - stroke: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button { - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button:hover { - background: #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button[aria-pressed="false"] { - color: #a8a39e; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-funded { - background: #ca8798; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-overage { - background: #f09a32; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-average { - border-top-color: #f09a32; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-tooltip { - border-color: #6a6560; - background: #333130; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option:hover { - background: #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option input { - accent-color: #43c4d5; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary { - border-color: #00a6be; - background: #008aa6; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary:focus-visible { - border-color: #43c4d5; - background: #006d7a; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table tbody tr:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sort-button:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-icon-button:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-icon-button:focus { - background: #3b3937; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table thead, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table th, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table th { - background: #333130; - color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table td { - border-bottom-color: #3f3c39; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail { - border-bottom-color: #4e4a46; - background: #252423; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table th, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table td { - border-bottom-color: #3f3c39; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count { - border-color: #2f9bae; - background: #1d4e55; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count:focus-visible, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count[aria-expanded="true"] { - border-color: #43c4d5; - background: #008aa6; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-progress[data-state="running"] { - background: #1d4e55; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-progress[data-state="complete"] { - background: #244f2e; - color: #a8dfb7; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-progress[data-state="complete-with-errors"], - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-progress[data-state="error"], - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count-error { - border-color: #b13b34; - background: #5f2926; - color: #ffb4ad; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-cache-timestamp { - background: #3b3937; - color: #c9c5c1; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table a, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button { - color: #43c4d5; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table a:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:focus-visible, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button:focus-visible { - background: #1d4e55; - color: #91e4ed; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="SQL"] { - color: #43c4d5; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="PIPELINE"] { - color: #d1cbc6; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="UPSIDE"] { - color: #ffbf62; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="FORECAST"] { - color: #d2b7e9; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="WON"] { - color: #9bd46a; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="SQL"] { - border-top-color: #43c4d5; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-total-dashboard-card { - border-top-color: #f6f4f2; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { - border-top-color: #c0b9b3; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="UPSIDE"] { - border-top-color: #ffb84d; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="FORECAST"] { - border-top-color: #cba7e7; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="WON"] { - border-top-color: #92cf5b; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="SQL"] { - border-color: #008aa6; - background: #008aa6; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="PIPELINE"] { - border-color: #6f6863; - background: #6f6863; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="UPSIDE"] { - border-color: #b96e00; - background: #b96e00; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="FORECAST"] { - border-color: #755b8b; - background: #755b8b; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="WON"] { - border-color: #4b7f22; - background: #4b7f22; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[data-status="OPEN"] { - color: #69c985; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[data-status="CLOSED"] { - color: #c3cad3; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[data-status="LOST"] { - color: #ff9d94; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[data-status="WON"] { - color: #83bfff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="OPEN"] { - border-color: #197a3d; - background: #197a3d; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="CLOSED"] { - border-color: #4b5563; - background: #4b5563; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="LOST"] { - border-color: #b13b34; - background: #b13b34; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="WON"] { - border-color: #1f5f99; - background: #1f5f99; - color: #ffffff; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item { - border-color: #4e4a46; - background: #252423; - } - - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-icon-button { - color: #f6f4f2; - } - - @media (min-width: 1201px) and (max-width: 1450px) { - .opportunities-extension-table th:nth-child(1) { - width: 16%; - } - - .opportunities-extension-table th:nth-child(3) { - width: 18%; - } - - .opportunities-extension-table th:nth-child(4) { - width: 12%; - } - - .opportunities-extension-table th:nth-child(6) { - width: 7%; - } - - .opportunities-extension-table th:nth-child(7) { - width: 8%; - } - - .opportunities-extension-table th:nth-child(10) { - width: 10%; - } - } - - @media (max-width: 1200px) { - .opportunities-extension-table { - min-width: 1180px; - } - - .opportunities-extension-table th:nth-child(1) { - width: 15%; - } - - .opportunities-extension-table th:nth-child(3) { - width: 17%; - } - - .opportunities-extension-table th:nth-child(4) { - width: 13%; - } - - .opportunities-extension-table th:nth-child(6) { - width: 6%; - } - - .opportunities-extension-table th:nth-child(7) { - width: 9%; - } - - .opportunities-extension-table th:nth-child(10) { - width: 11%; - } - } - - @media (max-width: 700px) { - .opportunities-extension-header { - padding: 24px 20px 18px; - } - - .opportunities-extension-header::before { - left: 20px; - } - - .opportunities-extension-header h1 { - font-size: 22px; - } - - .opportunities-extension-header p { - font-size: 14px; - } - - .opportunities-extension-body { - padding: 16px 20px 24px; - } - - .opportunities-extension-form { - align-items: stretch; - flex-wrap: wrap; - gap: 14px; - min-height: auto; - padding: 12px; - } - - .opportunities-extension-field { - max-width: none; - width: 100%; - } - - .opportunities-extension-customer-filter { - width: 100%; - } - - .opportunities-extension-customer-filter-trigger { - width: 100%; - } - - .opportunities-extension-customer-filter-panel { - width: min(360px, calc(100vw - 64px)); - } - - .opportunities-extension-table-search-field { - width: 100%; - } - - .opportunities-extension-stage-dashboard { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .opportunities-extension-stage-dashboard-value { - font-size: 18px; - } - - .opportunities-extension-preferences-modal { - padding: 12px; - } - - .opportunities-extension-account-plans-modal { - padding: 12px; - } - - .opportunities-extension-preferences-dialog { - max-height: calc(100vh - 24px); - } - - .opportunities-extension-account-plans-dialog { - height: calc(100vh - 24px); - } - - .opportunities-extension-account-plan-panel { - padding: 12px 14px; - } - - .opportunities-extension-account-plan-details { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 14px; - } - - .opportunities-extension-account-plan-details > div { - grid-template-columns: 1fr; - align-content: center; - gap: 3px; - min-height: 54px; - padding: 5px 0; - } - - .opportunities-extension-account-plans-skeleton-details { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 14px; - padding: 0; - } - - .opportunities-extension-account-plans-skeleton-details > div { - grid-template-columns: 1fr; - align-content: center; - gap: 5px; - min-height: 54px; - padding: 5px 0; - } - - .opportunities-extension-account-plans-skeleton-trend { - margin: 14px 0 0; - } - - .opportunities-extension-account-plans-skeleton-periods { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - align-items: center; - gap: 6px; - } - - .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar { - width: 100% !important; - } - - .opportunities-extension-account-plans-skeleton-chart { - grid-template-columns: minmax(0, 1fr); - gap: 12px; - padding: 12px 8px 0 34px; - } - - .opportunities-extension-account-plans-skeleton-legend { - display: flex; - min-height: 24px; - align-items: center; - justify-content: center; - gap: 12px; - } - - .opportunities-extension-consumption-trend-header { - align-items: stretch; - } - - .opportunities-extension-consumption-periods { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 3px; - } - - .opportunities-extension-consumption-periods button { - min-width: 0; - padding: 7px 4px; - font-size: 12px; - } - - .opportunities-extension-consumption-chart { - min-height: 0; - } - - .opportunities-extension-consumption-svg { - width: 760px; - height: 245px; - } - - .opportunities-extension-consumption-visual { - width: 920px; - grid-template-columns: 760px 144px; - gap: 12px; - } - - .opportunities-extension-consumption-figure { - overflow-x: auto; - overscroll-behavior-inline: contain; - } - - .opportunities-extension-consumption-chart-skeleton { - min-height: 0; - padding: 24px 18px 18px; - } - - .opportunities-extension-consumption-chart-skeleton-plot { - min-height: 180px; - gap: 4px; - padding: 0 6px; - } - - .opportunities-extension-preferences-options { - grid-template-columns: 1fr; - } - - .opportunities-extension-stage-filter-controls { - gap: 6px; - } - - .opportunities-extension-status-filter-controls { - gap: 6px; - } - - .opportunities-extension-table-surface { - min-width: 0; - min-height: calc(100vh - 230px); - } - - .opportunities-extension-debug-panel { - right: 12px; - bottom: 12px; - width: calc(100vw - 24px); - max-height: calc(100vh - 96px); - } - } - `; - - document.head.append(style); - } - - function isAllowedPage() { - if (window.location.hostname !== "eeho.fa.us2.oraclecloud.com") { - return false; - } - - return ALLOWED_PATHS.some((path) => { - return window.location.pathname === path || window.location.pathname.startsWith(`${path}/`); - }); - } - - function findInsertionPoint(salesGroup) { - const addIconInsideGroup = salesGroup.querySelector(ADD_ICON_SELECTOR); - - if (addIconInsideGroup) { - return addIconInsideGroup; - } - - const container = salesGroup.closest(".flat-grid, .flat-grid-container, [id*='yourapps'], [id*='groupNode']") || salesGroup.parentElement || document.body; - const addIconCells = Array.from(container.querySelectorAll(ADD_ICON_SELECTOR)); - - return addIconCells.find((cell) => { - return Boolean(salesGroup.compareDocumentPosition(cell) & Node.DOCUMENT_POSITION_FOLLOWING); - }) || null; - } - - function insertTile() { - if (document.getElementById(TILE_ID)) { - return true; - } - - const salesGroup = document.querySelector(SALES_GROUP_SELECTOR); - const addIconCell = salesGroup ? findInsertionPoint(salesGroup) : null; - - if (!salesGroup || !addIconCell) { - return false; - } - - addIconCell.before(createOpportunitiesTile()); - return true; - } - - if (insertTile()) { - return; - } - - const observer = new MutationObserver(() => { - insertTile(); - }); - - observer.observe(document.documentElement, { - childList: true, - subtree: true - }); -})(); diff --git a/src/content/application.js b/src/content/application.js new file mode 100644 index 0000000..681c435 --- /dev/null +++ b/src/content/application.js @@ -0,0 +1,434 @@ +function createContentApplication(overrides = {}) { + const environment = { + document: globalThis.document, + window: globalThis.window, + navigator: globalThis.navigator, + get localStorage() { return globalThis.localStorage; }, + fetch: globalThis.fetch, + browser: globalThis.browser, + chrome: globalThis.chrome, + MutationObserver: globalThis.MutationObserver, + Node: globalThis.Node, + requestAnimationFrame: globalThis.requestAnimationFrame, + performance: globalThis.performance, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + matchMedia: globalThis.matchMedia, + ...overrides + }; + const messageTypes = createMessageTypes(); + const contracts = createContentContracts(); + const authSessionState = createAuthSessionState(contracts); + const authSalesCommonState = createAuthSalesCommonState(); + const opportunitiesQueryState = createOpportunitiesQueryState(); + const opportunitiesTableState = createOpportunitiesTableState(); + const opportunitiesFilterState = createOpportunitiesFilterState(contracts); + const preferencesState = createPreferencesState(contracts); + const productsContentState = createProductsContentState(); + const aiSummaryState = createAiSummaryState(); + const consumptionPlansState = createConsumptionPlansState(); + const consumptionTrendState = createConsumptionTrendState(); + const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts); + const debugState = createDebugState(); + const columns = createOpportunitiesColumns({ + formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), + formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args), + formatWinProbability: (...args) => opportunitiesFormatters.formatWinProbability(...args), + getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args), + getOwnerName: (...args) => opportunitiesFormatters.getOwnerName(...args) + }); + const contentFusionPage = createContentFusionPage({ + ADD_ICON_SELECTOR: contracts.ADD_ICON_SELECTOR, + ALLOWED_PATHS: contracts.ALLOWED_PATHS, + SALES_GROUP_SELECTOR: contracts.SALES_GROUP_SELECTOR, + TILE_ID: contracts.TILE_ID, + TILE_LABEL_ID: contracts.TILE_LABEL_ID, + appendPath: (...args) => svgPaths.appendPath(...args), + openOpportunitiesModal: (...args) => contentModal.openOpportunitiesModal(...args), + environment + }); + const contentModal = createContentModal({ + ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID, + AUTH_STATUS: contracts.AUTH_STATUS, + CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID, + CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID, + CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID, + CUSTOMER_FILTER_SEARCH_ID: contracts.CUSTOMER_FILTER_SEARCH_ID, + CUSTOMER_FILTER_SELECT_ALL_ID: contracts.CUSTOMER_FILTER_SELECT_ALL_ID, + DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID, + ESCAPE_LISTENER_KEY: contracts.ESCAPE_LISTENER_KEY, + MODAL_ID: contracts.MODAL_ID, + OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID, + OWNER_FILTER_BUTTON_ID: contracts.OWNER_FILTER_BUTTON_ID, + OWNER_FILTER_LIST_ID: contracts.OWNER_FILTER_LIST_ID, + OWNER_FILTER_PANEL_ID: contracts.OWNER_FILTER_PANEL_ID, + OWNER_FILTER_SEARCH_ID: contracts.OWNER_FILTER_SEARCH_ID, + OWNER_FILTER_SELECT_ALL_ID: contracts.OWNER_FILTER_SELECT_ALL_ID, + PERIOD_OPTIONS: contracts.PERIOD_OPTIONS, + PREFERENCES_MODAL_ID: contracts.PREFERENCES_MODAL_ID, + PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID, + PRODUCTS_PROGRESS_ID: contracts.PRODUCTS_PROGRESS_ID, + STAGE_DASHBOARD_ID: contracts.STAGE_DASHBOARD_ID, + STAGE_FILTERS_ID: contracts.STAGE_FILTERS_ID, + STATUS_FILTERS_ID: contracts.STATUS_FILTERS_ID, + TABLE_SEARCH_ID: contracts.TABLE_SEARCH_ID, + applyTheme: (...args) => contentTheme.applyTheme(...args), + authSessionState, + closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args), + closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args), + closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args), + closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args), + createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args), + debugState, + ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args), + getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args), + getOwnerOptions: (...args) => opportunitiesFormatters.getOwnerOptions(...args), + getSavedOpportunityTypeValues: (...args) => preferencesStorage.getSavedOpportunityTypeValues(...args), + getSavedOpptyProductsCacheHours: (...args) => preferencesStorage.getSavedOpptyProductsCacheHours(...args), + getSavedTheme: (...args) => contentTheme.getSavedTheme(...args), + openPreferencesModal: (...args) => preferencesView.openPreferencesModal(...args), + opportunitiesFilterState, + opportunitiesQueryState, + preferencesState, + renderCustomerFilter: (...args) => opportunitiesFilters.renderCustomerFilter(...args), + renderCustomerFilterList: (...args) => opportunitiesFilters.renderCustomerFilterList(...args), + renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), + renderOwnerFilter: (...args) => opportunitiesFilters.renderOwnerFilter(...args), + renderOwnerFilterList: (...args) => opportunitiesFilters.renderOwnerFilterList(...args), + renderStageFilterButtons: (...args) => opportunitiesFilters.renderStageFilterButtons(...args), + renderStatusFilterButtons: (...args) => opportunitiesFilters.renderStatusFilterButtons(...args), + requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args), + requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args), + resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args), + resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args), + restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args), + saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args), + toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args), + toggleDebugPanel: (...args) => debugContentView.toggleDebugPanel(...args), + toggleOwnerFilterPanel: (...args) => opportunitiesFilters.toggleOwnerFilterPanel(...args), + environment + }); + const contentTheme = createContentTheme({ + THEME_STORAGE_KEY: contracts.THEME_STORAGE_KEY, + createThemeIcon: (...args) => contentIcons.createThemeIcon(...args), + environment + }); + const contentIcons = createContentIcons({ + appendPath: (...args) => svgPaths.appendPath(...args), + environment + }); + const svgPaths = createSvgPaths({ environment }); + const contentStyles = createContentStyles({ + STYLE_ID: contracts.STYLE_ID, + redwoodStyles, + environment + }); + const authContentSession = createAuthContentSession({ + messageTypes, + AUTH_STATUS: contracts.AUTH_STATUS, + appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args), + authSessionState, + requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args), + sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), + updateAuthBadge: (...args) => contentModal.updateAuthBadge(...args) + }); + const authSalesCommon = createAuthSalesCommon({ + TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL, + authSalesCommonState, + requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args) + }); + const authTokenRelay = createAuthTokenRelay({ + TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL, + createDebugBody: (...args) => debugContentLog.createDebugBody(...args), + getXsrfToken: (...args) => authXsrfContent.getXsrfToken(...args), + logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args), + logRequestStart: (...args) => debugContentLog.logRequestStart(...args), + logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args), + maskToken: (...args) => debugContentLog.maskToken(...args), + readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args), + refreshXsrfCookie: (...args) => platformContentMessages.refreshXsrfCookie(...args), + renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args), + environment + }); + const authXsrfContent = createAuthXsrfContent({ + getXsrfTokenFromExtensionCookies: (...args) => platformContentMessages.getXsrfTokenFromExtensionCookies(...args), + environment + }); + const platformContentMessages = createPlatformContentMessages({ + messageTypes, + environment + }); + const opportunitiesController = createOpportunitiesController({ + STAGE_OPTIONS: contracts.STAGE_OPTIONS, + STATUS_OPTIONS: contracts.STATUS_OPTIONS, + authSessionState, + consumptionPlansState, + consumptionTrendState, + getFiscalPeriodRange: (...args) => opportunitiesFiscalPeriods.getFiscalPeriodRange(...args), + opportunitiesDashboardState, + opportunitiesFilterState, + opportunitiesQueryState, + opportunitiesTableState, + preferencesState, + productsContentState, + renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args), + requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args), + requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args), + requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args), + setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args) + }); + const fusionOpportunities = createFusionOpportunities({ + OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT, + OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL, + isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), + logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args), + logRequestStart: (...args) => debugContentLog.logRequestStart(...args), + logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args), + maskToken: (...args) => debugContentLog.maskToken(...args), + getOpportunityTypeValues: () => preferencesState.selectedOpportunityTypeValues, + readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args), + environment + }); + const opportunitiesFiscalPeriods = createOpportunitiesFiscalPeriods(); + const opportunitiesTable = createOpportunitiesTable({ + ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL, + OPPORTUNITIES_COLUMNS: columns, + OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID, + OPPORTUNITY_DETAIL_URL: contracts.OPPORTUNITY_DETAIL_URL, + createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args), + createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args), + createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args), + createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args), + createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args), + createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args), + createStatusBadge: (...args) => opportunitiesLinks.createStatusBadge(...args), + displayOpportunityValue: (...args) => opportunitiesFormatters.displayOpportunityValue(...args), + getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args), + getSortedOpportunities: (...args) => opportunitiesFilters.getSortedOpportunities(...args), + opportunitiesTableState, + productsContentState, + renderCustomerFilter: (...args) => opportunitiesFilters.renderCustomerFilter(...args), + renderOwnerFilter: (...args) => opportunitiesFilters.renderOwnerFilter(...args), + renderStageDashboard: (...args) => opportunitiesDashboard.renderStageDashboard(...args), + resetCustomerFilter: (...args) => opportunitiesFilters.resetCustomerFilter(...args), + resetOwnerFilter: (...args) => opportunitiesFilters.resetOwnerFilter(...args), + environment + }); + const opportunitiesDashboard = createOpportunitiesDashboard({ + STAGE_DASHBOARD_ID: contracts.STAGE_DASHBOARD_ID, + STAGE_OPTIONS: contracts.STAGE_OPTIONS, + getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args), + opportunitiesDashboardState, + environment + }); + const opportunitiesFilters = createOpportunitiesFilters({ + CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID, + CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID, + CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID, + CUSTOMER_FILTER_SEARCH_ID: contracts.CUSTOMER_FILTER_SEARCH_ID, + CUSTOMER_FILTER_SELECT_ALL_ID: contracts.CUSTOMER_FILTER_SELECT_ALL_ID, + OPPORTUNITIES_COLUMNS: columns, + OWNER_FILTER_BUTTON_ID: contracts.OWNER_FILTER_BUTTON_ID, + OWNER_FILTER_LIST_ID: contracts.OWNER_FILTER_LIST_ID, + OWNER_FILTER_PANEL_ID: contracts.OWNER_FILTER_PANEL_ID, + OWNER_FILTER_SEARCH_ID: contracts.OWNER_FILTER_SEARCH_ID, + OWNER_FILTER_SELECT_ALL_ID: contracts.OWNER_FILTER_SELECT_ALL_ID, + STAGE_FILTERS_ID: contracts.STAGE_FILTERS_ID, + STAGE_OPTIONS: contracts.STAGE_OPTIONS, + STATUS_FILTERS_ID: contracts.STATUS_FILTERS_ID, + STATUS_OPTIONS: contracts.STATUS_OPTIONS, + getCustomerFilterSummary: (...args) => opportunitiesFormatters.getCustomerFilterSummary(...args), + getCustomerKey: (...args) => opportunitiesFormatters.getCustomerKey(...args), + getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args), + getOwnerKey: (...args) => opportunitiesFormatters.getOwnerKey(...args), + getOwnerOptions: (...args) => opportunitiesFormatters.getOwnerOptions(...args), + opportunitiesFilterState, + opportunitiesTableState, + renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), + saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args), + stringifySearchValue: (...args) => opportunitiesFormatters.stringifySearchValue(...args), + environment + }); + const opportunitiesFormatters = createOpportunitiesFormatters({ + opportunitiesTableState + }); + const opportunitiesLinks = createOpportunitiesLinks({ + MODAL_ID: contracts.MODAL_ID, + environment + }); + const preferencesStorage = createPreferencesStorage({ + DEFAULT_OPPORTUNITY_TYPE_VALUES: contracts.DEFAULT_OPPORTUNITY_TYPE_VALUES, + DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS: contracts.DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, + FILTER_PREFERENCES_STORAGE_KEY: contracts.FILTER_PREFERENCES_STORAGE_KEY, + MODAL_ID: contracts.MODAL_ID, + OPPORTUNITY_TYPE_OPTIONS: contracts.OPPORTUNITY_TYPE_OPTIONS, + OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY: contracts.OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, + OPPTY_PRODUCTS_CACHE_OPTIONS: contracts.OPPTY_PRODUCTS_CACHE_OPTIONS, + PERIOD_OPTIONS: contracts.PERIOD_OPTIONS, + STAGE_OPTIONS: contracts.STAGE_OPTIONS, + STATUS_OPTIONS: contracts.STATUS_OPTIONS, + opportunitiesFilterState, + environment + }); + const preferencesView = createPreferencesView({ + DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS: contracts.DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, + MODAL_ID: contracts.MODAL_ID, + OPPORTUNITY_TYPE_OPTIONS: contracts.OPPORTUNITY_TYPE_OPTIONS, + OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY: contracts.OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, + OPPTY_PRODUCTS_CACHE_OPTIONS: contracts.OPPTY_PRODUCTS_CACHE_OPTIONS, + PERIOD_OPTIONS: contracts.PERIOD_OPTIONS, + PREFERENCES_MODAL_ID: contracts.PREFERENCES_MODAL_ID, + createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), + getSavedOpportunityTypeValues: (...args) => preferencesStorage.getSavedOpportunityTypeValues(...args), + getSavedOpptyProductsCacheHours: (...args) => preferencesStorage.getSavedOpptyProductsCacheHours(...args), + preferencesState, + requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args), + environment + }); + const productsContentJobs = createProductsContentJobs({ + messageTypes, + PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID, + PRODUCTS_MESSAGE_LISTENER_KEY: contracts.PRODUCTS_MESSAGE_LISTENER_KEY, + PRODUCTS_PROGRESS_ID: contracts.PRODUCTS_PROGRESS_ID, + preferencesState, + productsContentState, + renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), + sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), + environment + }); + const productsDetails = createProductsDetails({ + OPPORTUNITIES_COLUMNS: columns, + createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args), + formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), + productsContentState, + renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), + requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args), + environment + }); + const aiSummaryContent = createAiSummaryContent({ + aiSummaryState, + invalidateSalesCommonToken: (...args) => authSalesCommon.invalidateSalesCommonToken(...args), + logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args), + logRequestStart: (...args) => debugContentLog.logRequestStart(...args), + logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args), + readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args), + requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...args), + environment + }); + const consumptionPlansAction = createConsumptionPlansAction({ + messageTypes, + appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args), + consumptionPlansState, + getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args), + openAccountPlansModal: (...args) => consumptionPlansView.openAccountPlansModal(...args), + renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args), + requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args), + sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), + showCopyConfirmation: (...args) => opportunitiesLinks.showCopyConfirmation(...args), + environment + }); + const consumptionPlansView = createConsumptionPlansView({ + ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID, + DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD, + MODAL_ID: contracts.MODAL_ID, + createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), + createConsumptionTrendSection: (...args) => consumptionTrendController.createConsumptionTrendSection(...args), + createSvgElement: (...args) => consumptionChart.createSvgElement(...args), + formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), + getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args), + loadConsumptionTrendForPanel: (...args) => consumptionTrendController.loadConsumptionTrendForPanel(...args), + environment + }); + const consumptionTrendController = createConsumptionTrendController({ + messageTypes, + CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS, + DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD, + appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args), + consumptionTrendState, + renderConsumptionTrendChart: (...args) => consumptionChart.renderConsumptionTrendChart(...args), + requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args), + sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args), + environment + }); + const consumptionChart = createConsumptionChart({ + CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS, + CONSUMPTION_TREND_SERIES: contracts.CONSUMPTION_TREND_SERIES, + SVG_NAMESPACE: contracts.SVG_NAMESPACE, + calculateAverageConsumption: (...args) => consumptionModel.calculateAverageConsumption(...args), + consumptionTrendState, + createConsumptionTrendKey: (...args) => consumptionTrendController.createConsumptionTrendKey(...args), + createNiceConsumptionMaximum: (...args) => consumptionModel.createNiceConsumptionMaximum(...args), + formatCompactConsumptionNumber: (...args) => consumptionModel.formatCompactConsumptionNumber(...args), + formatConsumptionCurrency: (...args) => consumptionModel.formatConsumptionCurrency(...args), + formatConsumptionPointLabel: (...args) => consumptionModel.formatConsumptionPointLabel(...args), + formatConsumptionRange: (...args) => consumptionModel.formatConsumptionRange(...args), + normalizeConsumptionTrendItems: (...args) => consumptionModel.normalizeConsumptionTrendItems(...args), + environment + }); + const consumptionModel = createConsumptionModel({ + + }); + const debugContentLog = createDebugContentLog({ + DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT, + debugState, + renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args) + }); + const debugContentView = createDebugContentView({ + DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID, + MODAL_ID: contracts.MODAL_ID, + debugState, + formatDebugBody: (...args) => debugContentLog.formatDebugBody(...args), + formatHeaderBlock: (...args) => debugContentLog.formatHeaderBlock(...args), + formatMetadataBlock: (...args) => debugContentLog.formatMetadataBlock(...args), + environment + }); + function start() { + startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs }); + } + return { + start, contracts, + authSessionState, + authSalesCommonState, + opportunitiesQueryState, + opportunitiesTableState, + opportunitiesFilterState, + preferencesState, + productsContentState, + aiSummaryState, + consumptionPlansState, + consumptionTrendState, + opportunitiesDashboardState, + debugState, + contentFusionPage, + contentModal, + contentTheme, + contentIcons, + contentStyles, + authContentSession, + authSalesCommon, + authTokenRelay, + authXsrfContent, + platformContentMessages, + opportunitiesController, + fusionOpportunities, + opportunitiesFiscalPeriods, + opportunitiesTable, + opportunitiesDashboard, + opportunitiesFilters, + opportunitiesFormatters, + opportunitiesLinks, + preferencesStorage, + preferencesView, + productsContentJobs, + productsDetails, + aiSummaryContent, + consumptionPlansAction, + consumptionPlansView, + consumptionTrendController, + consumptionChart, + consumptionModel, + debugContentLog, + debugContentView + }; +} diff --git a/src/content/contracts.js b/src/content/contracts.js new file mode 100644 index 0000000..b908f85 --- /dev/null +++ b/src/content/contracts.js @@ -0,0 +1,100 @@ +function createContentContracts() { + const TILE_ID = "c_5c719a88b5624b1eaf81defe2c2ex4x5"; + const TILE_LABEL_ID = `${TILE_ID}_0`; + const SALES_GROUP_SELECTOR = "#yourapps_groupNode_sales"; + const ADD_ICON_SELECTOR = ".flat-grid-cell.flat-grid-cell-addicon"; + const MODAL_ID = "opportunities-extension-modal"; + const STYLE_ID = "opportunities-extension-redwood-styles"; + const ESCAPE_LISTENER_KEY = "opportunitiesExtensionEscapeListener"; + const DEBUG_PANEL_ID = "opportunities-extension-debug-panel"; + const OPPORTUNITIES_TABLE_ID = "opportunities-extension-table"; + const STAGE_FILTERS_ID = "opportunities-extension-stage-filters"; + const STATUS_FILTERS_ID = "opportunities-extension-status-filters"; + const CUSTOMER_FILTER_BUTTON_ID = "opportunities-extension-customer-filter-button"; + const CUSTOMER_FILTER_PANEL_ID = "opportunities-extension-customer-filter-panel"; + const CUSTOMER_FILTER_SEARCH_ID = "opportunities-extension-customer-filter-search"; + const CUSTOMER_FILTER_SELECT_ALL_ID = "opportunities-extension-customer-filter-select-all"; + const CUSTOMER_FILTER_LIST_ID = "opportunities-extension-customer-filter-list"; + const OWNER_FILTER_BUTTON_ID = "opportunities-extension-owner-filter-button"; + const OWNER_FILTER_PANEL_ID = "opportunities-extension-owner-filter-panel"; + const OWNER_FILTER_SEARCH_ID = "opportunities-extension-owner-filter-search"; + const OWNER_FILTER_SELECT_ALL_ID = "opportunities-extension-owner-filter-select-all"; + const OWNER_FILTER_LIST_ID = "opportunities-extension-owner-filter-list"; + const TABLE_SEARCH_ID = "opportunities-extension-table-search"; + const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard"; + const PRODUCTS_PROGRESS_ID = "opportunities-extension-products-progress"; + const PRODUCTS_CACHE_TIMESTAMP_ID = "opportunities-extension-products-cache-timestamp"; + const FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences"; + const THEME_STORAGE_KEY = "opportunities-extension-theme"; + const OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY = "opportunities-extension-opportunity-type-preferences"; + const PREFERENCES_MODAL_ID = "opportunities-extension-preferences-modal"; + const ACCOUNT_PLANS_MODAL_ID = "opportunities-extension-account-plans-modal"; + const PRODUCTS_MESSAGE_LISTENER_KEY = "opportunitiesExtensionProductsMessageListener"; + const OPPORTUNITY_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid="; + const ACCOUNT_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/accounts/accounts-detail?id="; + const DEBUG_BODY_LIMIT = 12000; + const OPPORTUNITIES_PAGE_LIMIT = 15; + const DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS = 24; + const TOKEN_RELAY_URL = "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay"; + const OPPORTUNITIES_QUERY_URL = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/11.13.18.05/custom-actions/queries"; + const AUTH_STATUS = { + idle: "idle", + pending: "pending", + authenticated: "authenticated", + unauthenticated: "unauthenticated" + }; + const CONSUMPTION_TREND_PERIODS = [ + { value: "quarterly", label: "Quarterly", windowSize: 8 }, + { value: "monthly", label: "Monthly", windowSize: 13 }, + { value: "weekly", label: "Weekly", windowSize: 13 }, + { value: "daily", label: "Daily", windowSize: 30 } + ]; + const CONSUMPTION_TREND_SERIES = [ + { value: "funded", label: "Funded Allocation" }, + { value: "overage", label: "Overage" }, + { value: "average", label: "Average Consumption" } + ]; + const DEFAULT_CONSUMPTION_TREND_PERIOD = "weekly"; + const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; + const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"]; + const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"]; + const OPPORTUNITY_TYPE_OPTIONS = [ + { label: "All records I can see", value: "ORA_ALLOPTIES" }, + { label: "I am credit receiver", value: "ORA_CREDITRECEIVER_ISME" }, + { label: "I am on the team", value: "ORA_MYSALESTEAMOPTIES" }, + { label: "I or my subordinate is a credit receiver", value: "ORA_CREDITRECEIVER_MYORG" }, + { label: "I own", value: "ORA_MYOPTIES" }, + { label: "My subordinates are on the team", value: "ORA_MYSUBORDSSALESTEAMOPTIES" }, + { label: "My subordinates own", value: "ORA_MYSUBORDINATESOPTIES" }, + { label: "My territory", value: "ORA_MYASSGTERROPTIES" }, + { label: "My territory hierarchy", value: "ORA_MYASSGDESCTERROPTIES" }, + { label: "My territory membership", value: "ORA_MYASSGMEMBTERROPTIES" }, + { label: "My territory membership hierarchy", value: "ORA_MYASSGMEMBDESCTERROPTIES" } + ]; + const DEFAULT_OPPORTUNITY_TYPE_VALUES = [ + "ORA_MYSALESTEAMOPTIES", + "ORA_MYASSGTERROPTIES", + "ORA_CREDITRECEIVER_ISME" + ]; + const OPPTY_PRODUCTS_CACHE_OPTIONS = [ + { label: "1 hour", value: 1 }, + { label: "6 hours", value: 6 }, + { label: "12 hours", value: 12 }, + { label: "24 hours (default)", value: 24 }, + { label: "48 hours", value: 48 }, + { label: "7 days", value: 168 } + ]; + const PERIOD_OPTIONS = [ + "Current Quarter", + "Next Quarter", + "Previous Quarter", + "Current Fiscal Year", + "4 Rolling Quarters (CQ + 3)", + "RENEWALS (Current + Past Due)" + ]; + const ALLOWED_PATHS = [ + "/hcmUI/faces/FuseWelcome", + "/fscmUI/faces/FuseWelcome" + ]; + return { TILE_ID, TILE_LABEL_ID, SALES_GROUP_SELECTOR, ADD_ICON_SELECTOR, MODAL_ID, STYLE_ID, ESCAPE_LISTENER_KEY, DEBUG_PANEL_ID, OPPORTUNITIES_TABLE_ID, STAGE_FILTERS_ID, STATUS_FILTERS_ID, CUSTOMER_FILTER_BUTTON_ID, CUSTOMER_FILTER_PANEL_ID, CUSTOMER_FILTER_SEARCH_ID, CUSTOMER_FILTER_SELECT_ALL_ID, CUSTOMER_FILTER_LIST_ID, OWNER_FILTER_BUTTON_ID, OWNER_FILTER_PANEL_ID, OWNER_FILTER_SEARCH_ID, OWNER_FILTER_SELECT_ALL_ID, OWNER_FILTER_LIST_ID, TABLE_SEARCH_ID, STAGE_DASHBOARD_ID, PRODUCTS_PROGRESS_ID, PRODUCTS_CACHE_TIMESTAMP_ID, FILTER_PREFERENCES_STORAGE_KEY, THEME_STORAGE_KEY, OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, PREFERENCES_MODAL_ID, ACCOUNT_PLANS_MODAL_ID, PRODUCTS_MESSAGE_LISTENER_KEY, OPPORTUNITY_DETAIL_URL, ACCOUNT_DETAIL_URL, DEBUG_BODY_LIMIT, OPPORTUNITIES_PAGE_LIMIT, DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, TOKEN_RELAY_URL, OPPORTUNITIES_QUERY_URL, AUTH_STATUS, CONSUMPTION_TREND_PERIODS, CONSUMPTION_TREND_SERIES, DEFAULT_CONSUMPTION_TREND_PERIOD, SVG_NAMESPACE, STAGE_OPTIONS, STATUS_OPTIONS, OPPORTUNITY_TYPE_OPTIONS, DEFAULT_OPPORTUNITY_TYPE_VALUES, OPPTY_PRODUCTS_CACHE_OPTIONS, PERIOD_OPTIONS, ALLOWED_PATHS }; +} diff --git a/src/content/fusion-page.js b/src/content/fusion-page.js new file mode 100644 index 0000000..4bed649 --- /dev/null +++ b/src/content/fusion-page.js @@ -0,0 +1,107 @@ +function createContentFusionPage({ + ADD_ICON_SELECTOR, + ALLOWED_PATHS, + SALES_GROUP_SELECTOR, + TILE_ID, + TILE_LABEL_ID, + appendPath, + openOpportunitiesModal, + environment +}) { + const { Node, document, window } = environment; + function createOpportunitiesTile() { + const wrapper = document.createElement("div"); + wrapper.className = "flat-grid-cell"; + + const item = document.createElement("div"); + item.id = TILE_ID; + item.className = "app-nav-item opportunities-extension-tile"; + item.setAttribute("filmstrip", "Opportunities Extension"); + item.setAttribute("page", "undefined"); + item.setAttribute("index", "0"); + item.setAttribute("type", "subcluster"); + item.setAttribute("title", "Opportunities Extension"); + item.setAttribute("group", "groupNode_tools"); + item.setAttribute("destinationurl", "https://gxpap.oracle.com/ords/pgxpap/f?p=138"); + item.setAttribute("targetframe", "_blank"); + item.setAttribute("isdesturlexist", "true"); + item.setAttribute("role", "presentation"); + + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 48 48"); + svg.setAttribute("style", "fill:currentColor"); + svg.setAttribute("class", "svg-nav suiicon svg-bkgd09"); + svg.setAttribute("data-icon", "navi_reportsearch"); + svg.setAttribute("role", "presentation"); + svg.setAttribute("focusable", "false"); + + appendPath(svg, "svg-shortcut", "M28 42.5l-3 2.7v-1.7c-.4 0-1.4 0-2.5.6-1.3 1-1.5 1.6-1.5 1.6s-.4-1.2.8-2.7c1.2-1.6 2.6-1.7 3.2-1.6v-1.6l3 2.7z"); + appendPath(svg, "svg-cluster", "M28.5 41.3c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2zm-4 0c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2c-.7 0-1.2-.5-1.2-1.2s.5-1.2 1.2-1.2zm-4 0c.7 0 1.2.5 1.2 1.2s-.5 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2z"); + appendPath(svg, "svg-icon15", "M16 31l-1.6-1-3.4 6.5s0 1 .5 1.4c.5.2 1.4-.4 1.4-.4l3-6.7z"); + appendPath(svg, "svg-icon03", "M36 10H12c-.8 0-2 1.2-2 2v20c0 .4.2.8.5 1l2-3.6c-1-1.4-1.6-3-1.6-5 0-4.2 3.3-7.6 7.4-7.6H20V16h2v1.7c.7.4 1.3 1 1.8 1.5.6.5 1 1 1.3 1.8h4v7h-4c-.3.7-.8 1.4-1.4 2H35v2H20.3l-2 .2H18L17 34h19c.8 0 2-1.2 2-2V12c0-.8-1.2-2-2-2zm-23 4v-2h22v2H13zm22 14h-5V17h5v11z"); + appendPath(svg, "svg-icon12", "M18.5 19c-3 0-5.5 2.5-5.5 5.5s2.5 5.5 5.5 5.5 5.5-2.5 5.5-5.5-2.5-5.5-5.5-5.5zm0 9c-2 0-3.5-1.6-3.5-3.5 0-2 1.6-3.5 3.5-3.5s3.5 1.6 3.5 3.5c0 2-1.6 3.5-3.5 3.5z"); + appendPath(svg, "svg-outline", "M35 34.56H13a2.7 2.7 0 0 1-3-3V14a2.76 2.76 0 0 1 3-3h22a2.74 2.74 0 0 1 3 3v17.56a2.68 2.68 0 0 1-3 3zM16.98 22.32a4.72 4.72 0 1 0 4.73 4.73 4.72 4.72 0 0 0-4.73-4.73zM24 25h3.47v4.72H24V25zm5.78-4.66h4.69v9.38h-4.69v-9.38zM13.5 14.5h20.9v2.4H13.5v-2.4zm6.9 17.59l-.01-1.94zm-2.04-12.75l-5.35-.02zm2.13 12.67H35.7zm-.09-8.05L20.39 19z"); + appendPath(svg, "svg-outline", "M16.98 31.72a4.68 4.68 0 1 0-4.75-4.67 4.74 4.74 0 0 0 4.75 4.67zm-1.44-.5l-3.6 6.83zM8 40"); + + const link = document.createElement("a"); + link.id = TILE_LABEL_ID; + link.className = "app-nav-label flat-grid-nav-label"; + link.href = "#"; + link.textContent = "Opportunities Extension"; + + item.append(svg, link); + wrapper.append(item); + wrapper.addEventListener("click", handleTileClick); + + return wrapper; + } + + function handleTileClick(event) { + event.preventDefault(); + event.stopPropagation(); + openOpportunitiesModal(); + } + + function isAllowedPage() { + if (window.location.hostname !== "eeho.fa.us2.oraclecloud.com") { + return false; + } + + return ALLOWED_PATHS.some((path) => { + return window.location.pathname === path || window.location.pathname.startsWith(`${path}/`); + }); + } + + function findInsertionPoint(salesGroup) { + const addIconInsideGroup = salesGroup.querySelector(ADD_ICON_SELECTOR); + + if (addIconInsideGroup) { + return addIconInsideGroup; + } + + const container = salesGroup.closest(".flat-grid, .flat-grid-container, [id*='yourapps'], [id*='groupNode']") || salesGroup.parentElement || document.body; + const addIconCells = Array.from(container.querySelectorAll(ADD_ICON_SELECTOR)); + + return addIconCells.find((cell) => { + return Boolean(salesGroup.compareDocumentPosition(cell) & Node.DOCUMENT_POSITION_FOLLOWING); + }) || null; + } + + function insertTile() { + if (document.getElementById(TILE_ID)) { + return true; + } + + const salesGroup = document.querySelector(SALES_GROUP_SELECTOR); + const addIconCell = salesGroup ? findInsertionPoint(salesGroup) : null; + + if (!salesGroup || !addIconCell) { + return false; + } + + addIconCell.before(createOpportunitiesTile()); + return true; + } + + return { createOpportunitiesTile, handleTileClick, isAllowedPage, findInsertionPoint, insertTile }; +} diff --git a/src/content/icons.js b/src/content/icons.js new file mode 100644 index 0000000..35ef1be --- /dev/null +++ b/src/content/icons.js @@ -0,0 +1,74 @@ +function createContentIcons({ + appendPath, + environment +}) { + const { document } = environment; + function createThemeIcon(iconName) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + + if (iconName === "sun") { + path.setAttribute("d", "M12 3V1m0 22v-2m9-9h2M1 12h2m15.36 6.36 1.42 1.42M4.22 4.22l1.42 1.42m12.72 0 1.42-1.42M4.22 19.78l1.42-1.42M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z"); + } else { + path.setAttribute("d", "M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5a8.5 8.5 0 1 0 10.7 10.7Z"); + } + + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + path.setAttribute("stroke-width", "1.8"); + svg.append(path); + return svg; + } + + function createSettingsIcon() { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const upperControl = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + const lowerControl = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("class", "opportunities-extension-settings-icon"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + appendPath(svg, "", "M4 7h16M4 17h16"); + + [upperControl, lowerControl].forEach((control, index) => { + control.setAttribute("cx", index === 0 ? "9" : "15"); + control.setAttribute("cy", index === 0 ? "7" : "17"); + control.setAttribute("r", "2.5"); + control.setAttribute("fill", "currentColor"); + control.setAttribute("stroke", "currentColor"); + control.setAttribute("stroke-width", "1.8"); + }); + + svg.querySelector("path").setAttribute("fill", "none"); + svg.querySelector("path").setAttribute("stroke", "currentColor"); + svg.querySelector("path").setAttribute("stroke-linecap", "round"); + svg.querySelector("path").setAttribute("stroke-width", "1.8"); + svg.append(upperControl, lowerControl); + return svg; + } + + function createCloseIcon() { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + path.setAttribute("d", "m6 6 12 12M18 6 6 18"); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-width", "1.8"); + svg.append(path); + return svg; + } + + return { createThemeIcon, createSettingsIcon, createCloseIcon }; +} diff --git a/src/content/main.js b/src/content/main.js new file mode 100644 index 0000000..e8914b4 --- /dev/null +++ b/src/content/main.js @@ -0,0 +1,9 @@ +/** Preserve the existing document_idle initialization and observer behavior. */ +function startContentApplication({ environment, page, styles, products }) { + if (!page.isAllowedPage()) return; + styles.ensureExtensionStyles(); + products.ensureProductsBackgroundMessageHandler(); + if (page.insertTile()) return; + const observer = new environment.MutationObserver(() => page.insertTile()); + observer.observe(environment.document.documentElement, { childList: true, subtree: true }); +} diff --git a/src/content/modal.js b/src/content/modal.js new file mode 100644 index 0000000..1724c9d --- /dev/null +++ b/src/content/modal.js @@ -0,0 +1,521 @@ +function createContentModal({ + ACCOUNT_PLANS_MODAL_ID, + AUTH_STATUS, + CUSTOMER_FILTER_BUTTON_ID, + CUSTOMER_FILTER_LIST_ID, + CUSTOMER_FILTER_PANEL_ID, + CUSTOMER_FILTER_SEARCH_ID, + CUSTOMER_FILTER_SELECT_ALL_ID, + DEBUG_PANEL_ID, + ESCAPE_LISTENER_KEY, + MODAL_ID, + OPPORTUNITIES_TABLE_ID, + OWNER_FILTER_BUTTON_ID, + OWNER_FILTER_LIST_ID, + OWNER_FILTER_PANEL_ID, + OWNER_FILTER_SEARCH_ID, + OWNER_FILTER_SELECT_ALL_ID, + PERIOD_OPTIONS, + PREFERENCES_MODAL_ID, + PRODUCTS_CACHE_TIMESTAMP_ID, + PRODUCTS_PROGRESS_ID, + STAGE_DASHBOARD_ID, + STAGE_FILTERS_ID, + STATUS_FILTERS_ID, + TABLE_SEARCH_ID, + applyTheme, + authSessionState, + closeAccountPlansModal, + closeCustomerFilterPanel, + closeOwnerFilterPanel, + closePreferencesModal, + createSettingsIcon, + debugState, + ensureExtensionStyles, + getCustomerOptions, + getOwnerOptions, + getSavedOpportunityTypeValues, + getSavedOpptyProductsCacheHours, + getSavedTheme, + openPreferencesModal, + opportunitiesFilterState, + opportunitiesQueryState, + preferencesState, + renderCustomerFilter, + renderCustomerFilterList, + renderOpportunitiesTable, + renderOwnerFilter, + renderOwnerFilterList, + renderStageFilterButtons, + renderStatusFilterButtons, + requestOpportunitiesForPeriod, + requestTokenRelayOnce, + resetCustomerAndOwnerFiltersForPeriod, + resetOpportunitiesTable, + restoreFilterPreferences, + saveFilterPreferences, + toggleCustomerFilterPanel, + toggleDebugPanel, + toggleOwnerFilterPanel, + environment +}) { + const { document, window } = environment; + function openOpportunitiesModal() { + ensureExtensionStyles(); + debugState.debugSecretsVisible = false; + + const existingModal = document.getElementById(MODAL_ID); + + if (existingModal) { + existingModal.hidden = false; + existingModal.querySelector("select").focus(); + document.documentElement.classList.add("opportunities-extension-scroll-lock"); + ensureEscapeKeyHandler(); + requestTokenRelayOnce().catch(() => {}); + return; + } + + const overlay = document.createElement("div"); + debugState.requestLog = []; + authSessionState.tokenRelayRequest = null; + authSessionState.consumerTokenServiceRequest = null; + authSessionState.accessToken = ""; + opportunitiesQueryState.periodRequestVersion = 0; + authSessionState.authStatus = AUTH_STATUS.idle; + resetOpportunitiesTable(); + preferencesState.selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues()); + preferencesState.opptyProductsCacheHours = getSavedOpptyProductsCacheHours(); + overlay.id = MODAL_ID; + overlay.className = "opportunities-extension-modal"; + overlay.setAttribute("data-theme", getSavedTheme()); + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + overlay.setAttribute("aria-labelledby", "opportunities-extension-title"); + overlay.setAttribute("aria-describedby", "opportunities-extension-subtitle"); + + const shell = document.createElement("section"); + shell.className = "opportunities-extension-shell"; + + const header = document.createElement("header"); + header.className = "opportunities-extension-header"; + + const title = document.createElement("h1"); + title.id = "opportunities-extension-title"; + title.textContent = "Opportunities Extension"; + + const subtitle = document.createElement("p"); + subtitle.id = "opportunities-extension-subtitle"; + subtitle.textContent = "Lista de oportunidades do HCM Opportunities List"; + + const authBadge = document.createElement("span"); + authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending"; + authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending); + authBadge.textContent = "Authenticating"; + + const subtitleRow = document.createElement("div"); + subtitleRow.className = "opportunities-extension-subtitle-row"; + + const productsProgressBadge = document.createElement("span"); + productsProgressBadge.id = PRODUCTS_PROGRESS_ID; + productsProgressBadge.className = "opportunities-extension-products-progress"; + productsProgressBadge.setAttribute("role", "status"); + productsProgressBadge.hidden = true; + + const productsCacheTimestamp = document.createElement("span"); + productsCacheTimestamp.id = PRODUCTS_CACHE_TIMESTAMP_ID; + productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp"; + productsCacheTimestamp.setAttribute("role", "status"); + productsCacheTimestamp.hidden = true; + subtitleRow.append(subtitle, authBadge, productsProgressBadge, productsCacheTimestamp); + + const titleBlock = document.createElement("div"); + titleBlock.append(title, subtitleRow); + + const closeButton = document.createElement("button"); + closeButton.type = "button"; + closeButton.className = "opportunities-extension-icon-button"; + closeButton.setAttribute("aria-label", "Fechar"); + closeButton.textContent = "X"; + closeButton.addEventListener("click", closeOpportunitiesModal); + + const themeButton = document.createElement("button"); + themeButton.type = "button"; + themeButton.className = "opportunities-extension-icon-button opportunities-extension-theme-toggle"; + themeButton.addEventListener("click", () => { + const nextTheme = overlay.getAttribute("data-theme") === "dark" ? "light" : "dark"; + applyTheme(overlay, themeButton, nextTheme); + }); + applyTheme(overlay, themeButton, overlay.getAttribute("data-theme")); + + const preferencesButton = document.createElement("button"); + preferencesButton.type = "button"; + preferencesButton.className = "opportunities-extension-icon-button"; + preferencesButton.setAttribute("aria-label", "Abrir preferências"); + preferencesButton.title = "Preferences"; + preferencesButton.append(createSettingsIcon()); + preferencesButton.addEventListener("click", openPreferencesModal); + + const headerActions = document.createElement("div"); + headerActions.className = "opportunities-extension-header-actions"; + headerActions.append(themeButton, preferencesButton, closeButton); + + header.append(titleBlock, headerActions); + + const body = document.createElement("main"); + body.className = "opportunities-extension-body"; + + const form = document.createElement("form"); + form.className = "opportunities-extension-form"; + + const field = document.createElement("label"); + field.className = "opportunities-extension-field"; + + const labelText = document.createElement("span"); + labelText.textContent = "Reference"; + + const selectWrap = document.createElement("span"); + selectWrap.className = "opportunities-extension-select-wrap"; + + const select = document.createElement("select"); + select.name = "opportunityPeriod"; + + PERIOD_OPTIONS.forEach((optionLabel) => { + const option = document.createElement("option"); + option.value = optionLabel; + option.textContent = optionLabel; + select.append(option); + }); + + select.addEventListener("change", () => { + resetCustomerAndOwnerFiltersForPeriod(); + saveFilterPreferences(select.value); + requestOpportunitiesForPeriod(select.value); + }); + + selectWrap.append(select); + field.append(labelText, selectWrap); + restoreFilterPreferences(select); + + const stageFilter = document.createElement("section"); + stageFilter.className = "opportunities-extension-stage-filter"; + + const stageFilterLabel = document.createElement("span"); + stageFilterLabel.className = "opportunities-extension-stage-filter-label"; + stageFilterLabel.textContent = "Stage"; + + const stageFilters = document.createElement("div"); + stageFilters.id = STAGE_FILTERS_ID; + stageFilters.className = "opportunities-extension-stage-filter-controls"; + stageFilters.setAttribute("role", "group"); + stageFilters.setAttribute("aria-label", "Filter by stage"); + + stageFilter.append(stageFilterLabel, stageFilters); + + const customerFilter = document.createElement("section"); + customerFilter.className = "opportunities-extension-customer-filter"; + + const customerFilterLabel = document.createElement("span"); + customerFilterLabel.className = "opportunities-extension-customer-filter-label"; + customerFilterLabel.textContent = "Customer"; + + const customerFilterButton = document.createElement("button"); + customerFilterButton.id = CUSTOMER_FILTER_BUTTON_ID; + customerFilterButton.type = "button"; + customerFilterButton.className = "opportunities-extension-customer-filter-trigger"; + customerFilterButton.setAttribute("aria-expanded", "false"); + customerFilterButton.setAttribute("aria-controls", CUSTOMER_FILTER_PANEL_ID); + customerFilterButton.addEventListener("click", toggleCustomerFilterPanel); + + const customerFilterPanel = document.createElement("section"); + customerFilterPanel.id = CUSTOMER_FILTER_PANEL_ID; + customerFilterPanel.className = "opportunities-extension-customer-filter-panel"; + customerFilterPanel.hidden = true; + + const customerSearchInput = document.createElement("input"); + customerSearchInput.id = CUSTOMER_FILTER_SEARCH_ID; + customerSearchInput.className = "opportunities-extension-customer-filter-search"; + customerSearchInput.type = "search"; + customerSearchInput.placeholder = "Search customers"; + customerSearchInput.setAttribute("aria-label", "Search customers"); + customerSearchInput.addEventListener("input", () => { + opportunitiesFilterState.customerSearch = customerSearchInput.value; + saveFilterPreferences(); + renderCustomerFilterList(); + }); + + const selectAllLabel = document.createElement("label"); + selectAllLabel.className = "opportunities-extension-customer-filter-select-all"; + + const selectAllCheckbox = document.createElement("input"); + selectAllCheckbox.id = CUSTOMER_FILTER_SELECT_ALL_ID; + selectAllCheckbox.type = "checkbox"; + selectAllCheckbox.addEventListener("change", () => { + const customerOptions = getCustomerOptions(); + opportunitiesFilterState.selectedCustomerKeys = selectAllCheckbox.checked + ? new Set(customerOptions.map((customer) => customer.key)) + : new Set(); + opportunitiesFilterState.hasSavedCustomerFilter = true; + saveFilterPreferences(); + renderCustomerFilter(); + renderOpportunitiesTable(); + }); + + const selectAllText = document.createElement("span"); + selectAllText.textContent = "Select all"; + selectAllLabel.append(selectAllCheckbox, selectAllText); + + const customerFilterList = document.createElement("div"); + customerFilterList.id = CUSTOMER_FILTER_LIST_ID; + customerFilterList.className = "opportunities-extension-customer-filter-list"; + + customerFilterPanel.append(customerSearchInput, selectAllLabel, customerFilterList); + customerFilter.append(customerFilterLabel, customerFilterButton, customerFilterPanel); + + const ownerFilter = document.createElement("section"); + ownerFilter.className = "opportunities-extension-customer-filter"; + + const ownerFilterLabel = document.createElement("span"); + ownerFilterLabel.className = "opportunities-extension-customer-filter-label"; + ownerFilterLabel.textContent = "Owner"; + + const ownerFilterButton = document.createElement("button"); + ownerFilterButton.id = OWNER_FILTER_BUTTON_ID; + ownerFilterButton.type = "button"; + ownerFilterButton.className = "opportunities-extension-customer-filter-trigger"; + ownerFilterButton.setAttribute("aria-expanded", "false"); + ownerFilterButton.setAttribute("aria-controls", OWNER_FILTER_PANEL_ID); + ownerFilterButton.addEventListener("click", toggleOwnerFilterPanel); + + const ownerFilterPanel = document.createElement("section"); + ownerFilterPanel.id = OWNER_FILTER_PANEL_ID; + ownerFilterPanel.className = "opportunities-extension-customer-filter-panel"; + ownerFilterPanel.hidden = true; + + const ownerSearchInput = document.createElement("input"); + ownerSearchInput.id = OWNER_FILTER_SEARCH_ID; + ownerSearchInput.className = "opportunities-extension-customer-filter-search"; + ownerSearchInput.type = "search"; + ownerSearchInput.placeholder = "Search owners"; + ownerSearchInput.setAttribute("aria-label", "Search owners"); + ownerSearchInput.addEventListener("input", () => { + opportunitiesFilterState.ownerSearch = ownerSearchInput.value; + saveFilterPreferences(); + renderOwnerFilterList(); + }); + + const ownerSelectAllLabel = document.createElement("label"); + ownerSelectAllLabel.className = "opportunities-extension-customer-filter-select-all"; + + const ownerSelectAllCheckbox = document.createElement("input"); + ownerSelectAllCheckbox.id = OWNER_FILTER_SELECT_ALL_ID; + ownerSelectAllCheckbox.type = "checkbox"; + ownerSelectAllCheckbox.addEventListener("change", () => { + const ownerOptions = getOwnerOptions(); + opportunitiesFilterState.selectedOwnerKeys = ownerSelectAllCheckbox.checked + ? new Set(ownerOptions.map((owner) => owner.key)) + : new Set(); + opportunitiesFilterState.hasSavedOwnerFilter = true; + saveFilterPreferences(); + renderOwnerFilter(); + renderOpportunitiesTable(); + }); + + const ownerSelectAllText = document.createElement("span"); + ownerSelectAllText.textContent = "Select all"; + ownerSelectAllLabel.append(ownerSelectAllCheckbox, ownerSelectAllText); + + const ownerFilterList = document.createElement("div"); + ownerFilterList.id = OWNER_FILTER_LIST_ID; + ownerFilterList.className = "opportunities-extension-customer-filter-list"; + + ownerFilterPanel.append(ownerSearchInput, ownerSelectAllLabel, ownerFilterList); + ownerFilter.append(ownerFilterLabel, ownerFilterButton, ownerFilterPanel); + + const tableSearchField = document.createElement("label"); + tableSearchField.className = "opportunities-extension-table-search-field"; + + const tableSearchLabel = document.createElement("span"); + tableSearchLabel.textContent = "Search"; + + const tableSearchInput = document.createElement("input"); + tableSearchInput.id = TABLE_SEARCH_ID; + tableSearchInput.type = "search"; + tableSearchInput.placeholder = "Search opportunities"; + tableSearchInput.setAttribute("aria-label", "Search opportunities"); + tableSearchInput.value = opportunitiesFilterState.tableSearch; + tableSearchInput.addEventListener("input", () => { + opportunitiesFilterState.tableSearch = tableSearchInput.value; + saveFilterPreferences(); + renderOpportunitiesTable(); + }); + + tableSearchField.append(tableSearchLabel, tableSearchInput); + + const statusFilter = document.createElement("section"); + statusFilter.className = "opportunities-extension-status-filter"; + + const statusFilterLabel = document.createElement("span"); + statusFilterLabel.className = "opportunities-extension-status-filter-label"; + statusFilterLabel.textContent = "Status"; + + const statusFilters = document.createElement("div"); + statusFilters.id = STATUS_FILTERS_ID; + statusFilters.className = "opportunities-extension-status-filter-controls"; + statusFilters.setAttribute("role", "group"); + statusFilters.setAttribute("aria-label", "Filter by status"); + + statusFilter.append(statusFilterLabel, statusFilters); + form.addEventListener("submit", (event) => event.preventDefault()); + form.append(field, customerFilter, ownerFilter, tableSearchField, statusFilter, stageFilter); + body.append(form); + + const stageDashboard = document.createElement("section"); + stageDashboard.id = STAGE_DASHBOARD_ID; + stageDashboard.className = "opportunities-extension-stage-dashboard"; + stageDashboard.setAttribute("aria-label", "Opportunity amount by stage"); + body.append(stageDashboard); + + const tableSurface = document.createElement("section"); + tableSurface.className = "opportunities-extension-table-surface"; + + const opportunitiesTable = document.createElement("table"); + opportunitiesTable.id = OPPORTUNITIES_TABLE_ID; + opportunitiesTable.className = "opportunities-extension-table"; + opportunitiesTable.setAttribute("aria-label", "Opportunities"); + tableSurface.append(opportunitiesTable); + + body.append(tableSurface); + + const debugPanel = document.createElement("aside"); + debugPanel.id = DEBUG_PANEL_ID; + debugPanel.className = "opportunities-extension-debug-panel"; + debugPanel.hidden = true; + body.append(debugPanel); + + shell.append(header, body); + overlay.append(shell); + document.body.append(overlay); + document.documentElement.classList.add("opportunities-extension-scroll-lock"); + ensureEscapeKeyHandler(); + renderStageFilterButtons(); + renderStatusFilterButtons(); + renderCustomerFilter(); + renderOwnerFilter(); + renderOpportunitiesTable(); + requestOpportunitiesForPeriod(select.value); + select.focus(); + } + + function updateAuthBadge(status) { + const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`); + + if (!badge) { + return; + } + + const badgeStatus = status === AUTH_STATUS.authenticated || status === AUTH_STATUS.unauthenticated + ? status + : AUTH_STATUS.pending; + + badge.setAttribute("data-auth-status", badgeStatus); + badge.className = `opportunities-extension-auth-badge opportunities-extension-auth-badge-${badgeStatus}`; + badge.textContent = badgeStatus === AUTH_STATUS.authenticated + ? "Authenticated" + : badgeStatus === AUTH_STATUS.unauthenticated + ? "Unauthenticated" + : "Authenticating"; + } + + function closeOpportunitiesModal() { + const modal = document.getElementById(MODAL_ID); + + if (modal) { + closePreferencesModal(); + closeAccountPlansModal(); + modal.hidden = true; + } + + debugState.debugSecretsVisible = false; + document.documentElement.classList.remove("opportunities-extension-scroll-lock"); + } + + function ensureEscapeKeyHandler() { + if (window[ESCAPE_LISTENER_KEY]) { + return; + } + + window[ESCAPE_LISTENER_KEY] = true; + document.addEventListener("keydown", (event) => { + const modal = document.getElementById(MODAL_ID); + + if (!modal || modal.hidden) { + return; + } + + if (event.ctrlKey && event.key.toLowerCase() === "d") { + event.preventDefault(); + event.stopPropagation(); + toggleDebugPanel(); + return; + } + + if (event.key === "Escape") { + const accountPlansModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); + + if (accountPlansModal && !accountPlansModal.hidden) { + closeAccountPlansModal(); + event.preventDefault(); + return; + } + + const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID); + + if (preferencesModal && !preferencesModal.hidden) { + closePreferencesModal(); + event.preventDefault(); + return; + } + + if (closeCustomerFilterPanel()) { + event.preventDefault(); + return; + } + + if (closeOwnerFilterPanel()) { + event.preventDefault(); + return; + } + + closeOpportunitiesModal(); + } + }); + + document.addEventListener("pointerdown", (event) => { + const modal = document.getElementById(MODAL_ID); + const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); + const ownerPanel = document.getElementById(OWNER_FILTER_PANEL_ID); + + if (!modal || modal.hidden) { + return; + } + + if (panel && !panel.hidden) { + const customerFilter = panel.closest(".opportunities-extension-customer-filter"); + + if (customerFilter && !customerFilter.contains(event.target)) { + closeCustomerFilterPanel(false); + } + } + + if (ownerPanel && !ownerPanel.hidden) { + const ownerFilter = ownerPanel.closest(".opportunities-extension-customer-filter"); + + if (ownerFilter && !ownerFilter.contains(event.target)) { + closeOwnerFilterPanel(false); + } + } + }); + } + + return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler }; +} diff --git a/src/content/styles.js b/src/content/styles.js new file mode 100644 index 0000000..88c56af --- /dev/null +++ b/src/content/styles.js @@ -0,0 +1,20 @@ +function createContentStyles({ + STYLE_ID, + redwoodStyles, + environment +}) { + const { document } = environment; + function ensureExtensionStyles() { + if (document.getElementById(STYLE_ID)) { + return; + } + + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = redwoodStyles; + + document.head.append(style); + } + + return { ensureExtensionStyles }; +} diff --git a/src/content/styles/redwood.css b/src/content/styles/redwood.css new file mode 100644 index 0000000..b5b967e --- /dev/null +++ b/src/content/styles/redwood.css @@ -0,0 +1,3126 @@ + + .opportunities-extension-scroll-lock { + overflow: hidden !important; + } + + .opportunities-extension-modal, + .opportunities-extension-modal * { + box-sizing: border-box; + font-family: "Oracle Sans", Arial, Helvetica, sans-serif; + } + + .opportunities-extension-modal { + position: fixed; + inset: 0; + z-index: 2147483647; + background: #f5f4f2; + color: #000000; + } + + .opportunities-extension-modal[hidden] { + display: none !important; + } + + .opportunities-extension-shell { + height: 100vh; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + background: #f5f4f2; + } + + .opportunities-extension-header { + position: relative; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + padding: 28px 40px 22px; + border-bottom: 1px solid #dedbd7; + background: + linear-gradient(90deg, #8f624a 0 11%, #c74634 11% 23%, #6f5a7f 23% 36%, #00758f 36% 51%, #d4b06a 51% 63%, transparent 63% 100%) top left / 100% 6px no-repeat, + #ffffff; + } + + .opportunities-extension-header::before { + position: absolute; + left: 40px; + bottom: -1px; + width: 64px; + height: 3px; + background: #00758f; + content: ""; + } + + .opportunities-extension-header h1 { + margin: 0; + color: #000000; + font-size: 24px; + font-weight: 700; + line-height: 1.2; + letter-spacing: 0; + } + + .opportunities-extension-header p { + margin: 0; + color: #5f5a55; + font-size: 14px; + line-height: 1.4; + } + + .opportunities-extension-subtitle-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 6px; + } + + .opportunities-extension-auth-badge { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 2px 8px; + border-radius: 999px; + color: #ffffff; + font-size: 12px; + font-weight: 700; + line-height: 1.2; + } + + .opportunities-extension-auth-badge-authenticated { + background: #3f6f17; + } + + .opportunities-extension-auth-badge-unauthenticated { + background: #c5331f; + } + + .opportunities-extension-auth-badge-pending { + background: #6f5a7f; + } + + .opportunities-extension-products-progress { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 2px 8px; + border-radius: 999px; + background: #e8f4f6; + color: #006d7a; + font-size: 12px; + font-weight: 700; + line-height: 1.2; + } + + .opportunities-extension-products-progress[hidden] { + display: none !important; + } + + .opportunities-extension-products-cache-timestamp { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 2px 8px; + border-radius: 999px; + background: #f0eeeb; + color: #5f5a55; + font-size: 11px; + font-weight: 600; + line-height: 1.2; + } + + .opportunities-extension-products-cache-timestamp[hidden] { + display: none !important; + } + + .opportunities-extension-products-progress[data-state="running"]::before { + width: 12px; + height: 12px; + margin-right: 6px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + content: ""; + animation: opportunities-extension-spin .8s linear infinite; + } + + .opportunities-extension-products-progress[data-state="complete"] { + background: #e2f2e5; + color: #356d19; + } + + .opportunities-extension-products-progress[data-state="complete-with-errors"], + .opportunities-extension-products-progress[data-state="error"] { + background: #f9e3e1; + color: #a52b1c; + } + + @keyframes opportunities-extension-spin { + to { transform: rotate(360deg); } + } + + .opportunities-extension-icon-button { + width: 36px; + height: 36px; + flex: 0 0 36px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: #312d2a; + font-size: 18px; + font-weight: 600; + line-height: 1; + cursor: pointer; + } + + .opportunities-extension-header-actions { + display: inline-flex; + align-items: center; + gap: 6px; + } + + .opportunities-extension-icon-button svg { + width: 18px; + height: 18px; + vertical-align: middle; + } + + .opportunities-extension-icon-button .opportunities-extension-settings-icon { + width: 20px; + height: 20px; + } + + .opportunities-extension-icon-button:hover, + .opportunities-extension-icon-button:focus { + border-color: #b8b2ad; + background: #f5f4f2; + outline: none; + } + + .opportunities-extension-body { + display: grid; + min-height: 0; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 16px; + padding: 22px 40px 36px; + background: #f5f4f2; + overflow: hidden; + } + + .opportunities-extension-form { + display: flex; + align-items: end; + flex-wrap: wrap; + gap: 24px; + min-height: 82px; + max-width: none; + margin: 0; + padding: 14px 16px; + border: 1px solid #dedbd7; + border-radius: 4px; + background: #ffffff; + } + + .opportunities-extension-field { + display: grid; + max-width: max-content; + min-width: 270px; + width: max-content; + gap: 6px; + color: #312d2a; + font-size: 13px; + font-weight: 600; + line-height: 1.3; + } + + .opportunities-extension-stage-dashboard { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; + } + + .opportunities-extension-stage-dashboard-card { + display: grid; + gap: 6px; + min-width: 0; + padding: 12px 16px; + border: 1px solid #dedbd7; + border-top: 3px solid #5f5a55; + border-radius: 4px; + background: #ffffff; + } + + .opportunities-extension-stage-dashboard-card span { + color: #5f5a55; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + } + + .opportunities-extension-stage-dashboard-value { + overflow: hidden; + color: #312d2a; + font-size: 22px; + font-weight: 700; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; + } + + .opportunities-extension-stage-dashboard-card[data-stage="SQL"] { + border-top-color: #008aa6; + } + + .opportunities-extension-total-dashboard-card { + border-top-color: #312d2a; + } + + .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { + border-top-color: #6f6863; + } + + .opportunities-extension-stage-dashboard-card[data-stage="UPSIDE"] { + border-top-color: #c87b00; + } + + .opportunities-extension-stage-dashboard-card[data-stage="FORECAST"] { + border-top-color: #755b8b; + } + + .opportunities-extension-stage-dashboard-card[data-stage="WON"] { + border-top-color: #4b7f22; + } + + .opportunities-extension-stage-filter { + display: grid; + grid-template-rows: auto 44px; + gap: 6px; + min-width: 0; + } + + .opportunities-extension-stage-filter-label { + color: #312d2a; + font-size: 13px; + font-weight: 600; + line-height: 1.3; + } + + .opportunities-extension-stage-filter-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + } + + .opportunities-extension-stage-filter-button { + min-height: 36px; + padding: 6px 12px; + border: 1px solid currentColor; + border-radius: 18px; + background: #ffffff; + color: #5f5a55; + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + line-height: 1.2; + cursor: pointer; + } + + .opportunities-extension-stage-filter-button[data-stage="SQL"] { + color: #006b84; + } + + .opportunities-extension-stage-filter-button[data-stage="PIPELINE"] { + color: #5f5a55; + } + + .opportunities-extension-stage-filter-button[data-stage="UPSIDE"] { + color: #945400; + } + + .opportunities-extension-stage-filter-button[data-stage="FORECAST"] { + color: #624e74; + } + + .opportunities-extension-stage-filter-button[data-stage="WON"] { + color: #3f6f17; + } + + .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="SQL"] { + border-color: #006b84; + background: #006b84; + color: #ffffff; + } + + .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="PIPELINE"] { + border-color: #5f5a55; + background: #5f5a55; + color: #ffffff; + } + + .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="UPSIDE"] { + border-color: #945400; + background: #945400; + color: #ffffff; + } + + .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="FORECAST"] { + border-color: #624e74; + background: #624e74; + color: #ffffff; + } + + .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="WON"] { + border-color: #3f6f17; + background: #3f6f17; + color: #ffffff; + } + + .opportunities-extension-stage-filter-button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 2px; + } + + .opportunities-extension-status-filter { + display: grid; + grid-template-rows: auto 44px; + gap: 6px; + min-width: 0; + } + + .opportunities-extension-status-filter-label { + color: #312d2a; + font-size: 13px; + font-weight: 600; + line-height: 1.3; + } + + .opportunities-extension-status-filter-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + } + + .opportunities-extension-status-filter-button { + min-height: 36px; + padding: 6px 12px; + border: 1px solid currentColor; + border-radius: 18px; + background: #ffffff; + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + line-height: 1.2; + cursor: pointer; + } + + .opportunities-extension-status-filter-button[data-status="OPEN"] { + color: #197a3d; + } + + .opportunities-extension-status-filter-button[data-status="CLOSED"] { + color: #4b5563; + } + + .opportunities-extension-status-filter-button[data-status="LOST"] { + color: #b13b34; + } + + .opportunities-extension-status-filter-button[data-status="WON"] { + color: #1f5f99; + } + + .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="OPEN"] { + border-color: #197a3d; + background: #197a3d; + color: #ffffff; + } + + .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="CLOSED"] { + border-color: #4b5563; + background: #4b5563; + color: #ffffff; + } + + .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="LOST"] { + border-color: #b13b34; + background: #b13b34; + color: #ffffff; + } + + .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="WON"] { + border-color: #1f5f99; + background: #1f5f99; + color: #ffffff; + } + + .opportunities-extension-status-filter-button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 2px; + } + + .opportunities-extension-customer-filter { + position: relative; + display: grid; + grid-template-rows: auto 44px; + gap: 6px; + min-width: 220px; + } + + .opportunities-extension-customer-filter-label { + color: #312d2a; + font-size: 13px; + font-weight: 600; + line-height: 1.3; + } + + .opportunities-extension-customer-filter-trigger { + position: relative; + min-width: 220px; + min-height: 44px; + padding: 10px 38px 10px 12px; + border: 1px solid #b8b2ad; + border-radius: 3px; + background: #ffffff; + color: #312d2a; + font-size: 14px; + line-height: 1.3; + text-align: left; + cursor: pointer; + } + + .opportunities-extension-customer-filter-trigger::after { + position: absolute; + top: 50%; + right: 16px; + width: 0; + height: 0; + border-top: 6px solid #312d2a; + border-right: 5px solid transparent; + border-left: 5px solid transparent; + content: ""; + pointer-events: none; + transform: translateY(-50%); + } + + .opportunities-extension-customer-filter-trigger:disabled { + cursor: not-allowed; + background: #f5f4f2; + color: #7d7772; + } + + .opportunities-extension-customer-filter-trigger:focus-visible { + border-color: #312d2a; + box-shadow: 0 0 0 1px #312d2a; + outline: none; + } + + .opportunities-extension-customer-filter-panel { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 4; + display: grid; + width: min(360px, calc(100vw - 80px)); + gap: 10px; + padding: 12px; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + box-shadow: 0 4px 12px rgba(0, 0, 0, .18); + } + + .opportunities-extension-customer-filter-panel[hidden] { + display: none !important; + } + + .opportunities-extension-customer-filter-search { + width: 100%; + min-height: 40px; + padding: 8px 10px; + border: 1px solid #b8b2ad; + border-radius: 3px; + background: #ffffff; + color: #312d2a; + font-size: 14px; + } + + .opportunities-extension-customer-filter-search:focus { + border-color: #312d2a; + box-shadow: 0 0 0 1px #312d2a; + outline: none; + } + + .opportunities-extension-customer-filter-select-all, + .opportunities-extension-customer-filter-option { + display: flex; + align-items: center; + gap: 8px; + color: #312d2a; + font-size: 13px; + line-height: 1.35; + } + + .opportunities-extension-customer-filter-select-all { + min-height: 32px; + padding-bottom: 8px; + border-bottom: 1px solid #dedbd7; + font-weight: 700; + } + + .opportunities-extension-customer-filter-panel input[type="checkbox"] { + width: 16px; + height: 16px; + flex: 0 0 16px; + accent-color: #00758f; + } + + .opportunities-extension-customer-filter-list { + display: block; + max-height: 250px; + overflow: auto; + } + + .opportunities-extension-customer-filter-option { + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + align-items: start; + min-height: 0; + height: auto !important; + padding: 8px 4px; + cursor: pointer; + } + + .opportunities-extension-customer-filter-option input[type="checkbox"] { + margin-top: 1px; + } + + .opportunities-extension-customer-filter-option span { + display: block; + min-width: 0; + line-height: 18px; + overflow-wrap: anywhere; + white-space: normal; + } + + .opportunities-extension-customer-filter-option:hover { + background: #f5f4f2; + } + + .opportunities-extension-customer-filter-empty { + margin: 4px 0; + color: #5f5a55; + font-size: 13px; + } + + .opportunities-extension-table-search-field { + display: grid; + grid-template-rows: auto 44px; + gap: 6px; + width: 260px; + color: #312d2a; + font-size: 13px; + font-weight: 600; + line-height: 1.3; + } + + .opportunities-extension-table-search-field input { + width: 100%; + min-height: 44px; + padding: 10px 12px; + border: 1px solid #b8b2ad; + border-radius: 3px; + background: #ffffff; + color: #312d2a; + font-size: 14px; + line-height: 1.3; + } + + .opportunities-extension-table-search-field input:focus { + border-color: #312d2a; + box-shadow: 0 0 0 1px #312d2a; + outline: none; + } + + .opportunities-extension-select-wrap { + position: relative; + display: block; + width: 270px; + max-width: 100%; + } + + .opportunities-extension-select-wrap::after { + position: absolute; + top: 50%; + right: 16px; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid #000000; + content: ""; + pointer-events: none; + transform: translateY(-50%); + } + + .opportunities-extension-field select { + width: 100%; + min-width: 0; + max-width: 100%; + min-height: 44px; + box-sizing: border-box; + appearance: none; + border: 1px solid #b8b2ad; + border-radius: 3px; + background: #ffffff; + color: #000000; + font-size: 14px; + line-height: 1.3; + padding: 11px 44px 11px 12px; + } + + .opportunities-extension-field select:focus { + border-color: #312d2a; + box-shadow: 0 0 0 1px #312d2a; + outline: none; + } + + .opportunities-extension-table-surface { + min-height: 0; + height: 100%; + border: 1px solid #dedbd7; + border-radius: 4px; + background: #ffffff; + overflow-x: auto; + overflow-y: auto; + } + + .opportunities-extension-table { + width: 100%; + min-width: 0; + border-collapse: collapse; + table-layout: fixed; + color: #312d2a; + font-size: 13px; + line-height: 1.35; + } + + .opportunities-extension-table thead { + background: #faf9f8; + } + + .opportunities-extension-table th { + position: sticky; + top: 0; + z-index: 1; + height: 44px; + border-bottom: 1px solid #b8b2ad; + background: #faf9f8; + color: #312d2a; + font-size: 12px; + font-weight: 700; + text-align: left; + white-space: nowrap; + } + + .opportunities-extension-table td { + min-height: 48px; + padding: 12px 16px; + border-bottom: 1px solid #ebe8e5; + vertical-align: middle; + overflow-wrap: anywhere; + } + + .opportunities-extension-table th:nth-child(1) { + width: 16%; + } + + .opportunities-extension-table th:nth-child(2) { + width: 8%; + } + + .opportunities-extension-table th:nth-child(3) { + width: 18%; + } + + .opportunities-extension-table th:nth-child(4) { + width: 11%; + } + + .opportunities-extension-table th:nth-child(5) { + width: 8%; + } + + .opportunities-extension-table th:nth-child(6) { + width: 8%; + } + + .opportunities-extension-table th:nth-child(7) { + width: 7%; + } + + .opportunities-extension-table th:nth-child(8) { + width: 6%; + } + + .opportunities-extension-table th:nth-child(9) { + width: 7%; + } + + .opportunities-extension-table th:nth-child(10) { + width: 7%; + } + + .opportunities-extension-table th:nth-child(11) { + width: 4%; + } + + .opportunities-extension-table td:nth-child(2), + .opportunities-extension-table td:nth-child(5), + .opportunities-extension-table td:nth-child(6), + .opportunities-extension-table td:nth-child(8) { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .opportunities-extension-table td:nth-child(9) { + padding-right: 6px; + padding-left: 6px; + white-space: nowrap; + overflow: visible; + } + + .opportunities-extension-table td:nth-child(7) { + padding-right: 6px; + padding-left: 6px; + white-space: nowrap; + overflow: visible; + } + + .opportunities-extension-table td:nth-child(10) { + padding-right: 6px; + padding-left: 6px; + font-size: 12px; + white-space: nowrap; + } + + .opportunities-extension-table td:nth-child(11) { + padding-right: 6px; + padding-left: 6px; + text-align: center; + white-space: nowrap; + } + + .opportunities-extension-table tbody tr:hover { + background: #f7f6f4; + } + + .opportunities-extension-table tbody tr:last-child td { + border-bottom: 0; + } + + .opportunities-extension-table a { + color: #006b84; + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 2px; + } + + .opportunities-extension-table a:hover { + color: #004f63; + } + + .opportunities-extension-opty-number-content { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + } + + .opportunities-extension-name-content { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + } + + .opportunities-extension-name-content > a { + min-width: 0; + } + + .opportunities-extension-actions-content { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + white-space: nowrap; + } + + .opportunities-extension-products-count { + display: inline-grid; + width: auto; + min-width: 20px; + height: 20px; + place-items: center; + flex: 0 0 auto; + box-sizing: border-box; + padding: 0 3px; + border: 1px solid #7ca7b2; + border-radius: 999px; + background: #e8f4f6; + color: #006d7a; + font-size: 10px; + font-weight: 700; + line-height: 1; + text-align: center; + vertical-align: middle; + cursor: pointer; + } + + .opportunities-extension-products-count:hover, + .opportunities-extension-products-count:focus-visible, + .opportunities-extension-products-count[aria-expanded="true"] { + border-color: #00758f; + background: #00758f; + color: #ffffff; + outline: none; + } + + .opportunities-extension-products-count-error { + border-color: #d8887e; + background: #f9e3e1; + color: #a52b1c; + } + + .opportunities-extension-products-detail-row:hover { + background: transparent !important; + } + + .opportunities-extension-products-detail-cell { + padding: 0 !important; + } + + .opportunities-extension-products-detail { + padding: 14px 20px 18px; + border-bottom: 1px solid #c9c5c1; + background: #f0eeeb; + } + + .opportunities-extension-products-detail-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; + 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; + } + + .opportunities-extension-products-detail-heading span { + color: #5f5a55; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + } + + .opportunities-extension-products-table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + border: 1px solid #dedbd7; + background: #ffffff; + font-size: 12px; + } + + .opportunities-extension-products-table th, + .opportunities-extension-products-table td { + position: static; + height: auto; + padding: 8px 10px; + border-bottom: 1px solid #ebe8e5; + background: transparent; + overflow-wrap: anywhere; + vertical-align: middle; + white-space: normal; + } + + .opportunities-extension-products-table th { + background: #faf9f8; + font-size: 11px; + } + + .opportunities-extension-products-table th:nth-child(1) { width: 16%; } + .opportunities-extension-products-table th:nth-child(2) { width: 16%; } + .opportunities-extension-products-table th:nth-child(3) { width: 8%; } + .opportunities-extension-products-table th:nth-child(4) { width: 10%; } + .opportunities-extension-products-table th:nth-child(5) { width: 8%; } + .opportunities-extension-products-table th:nth-child(6) { width: 8%; } + .opportunities-extension-products-table th:nth-child(7) { width: 7%; } + .opportunities-extension-products-table th:nth-child(8) { width: 10%; } + .opportunities-extension-products-table th:nth-child(9) { width: 12%; } + .opportunities-extension-products-table th:nth-child(10) { width: 5%; } + + .opportunities-extension-products-empty { + margin: 0; + padding: 10px 0; + color: #5f5a55; + font-size: 13px; + } + + .opportunities-extension-copy-button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 24px; + height: 24px; + padding: 3px; + border: 0; + border-radius: 3px; + background: transparent; + color: #00758f; + cursor: pointer; + } + + .opportunities-extension-copy-button svg { + width: 16px; + height: 16px; + fill: currentColor; + } + + .opportunities-extension-copy-button:hover, + .opportunities-extension-copy-button:focus-visible { + background: #e8f4f6; + color: #004f63; + outline: none; + } + + .opportunities-extension-action-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 5px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: #00758f; + cursor: pointer; + } + + .opportunities-extension-action-button svg { + width: 21px; + height: 21px; + } + + .opportunities-extension-action-button:hover, + .opportunities-extension-action-button:focus-visible { + border-color: #7ca7b2; + background: #e8f4f6; + color: #004f63; + outline: none; + } + + .opportunities-extension-action-button:disabled { + cursor: wait; + opacity: .72; + } + + .opportunities-extension-action-button-loading svg { + animation: opportunities-extension-pulse 1s ease-in-out infinite; + } + + @keyframes opportunities-extension-pulse { + 50% { opacity: .35; } + } + + .opportunities-extension-copy-toast { + position: fixed; + right: 28px; + bottom: 28px; + z-index: 3; + max-width: min(360px, calc(100vw - 56px)); + padding: 12px 16px; + border-radius: 4px; + background: #2f6f18; + box-shadow: 0 4px 12px rgba(0, 0, 0, .18); + color: #ffffff; + font-size: 13px; + font-weight: 700; + } + + .opportunities-extension-copy-toast-error { + background: #a52b1c; + } + + .opportunities-extension-copy-toast[hidden] { + display: none !important; + } + + .opportunities-extension-stage-badge { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 8px; + border-radius: 12px; + background: #ebe8e5; + color: #312d2a; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; + } + + .opportunities-extension-stage-badge[data-stage="SQL"] { + background: #d9f0f5; + color: #006b84; + } + + .opportunities-extension-stage-badge[data-stage="PIPELINE"] { + background: #ebe8e5; + color: #5f5a55; + } + + .opportunities-extension-stage-badge[data-stage="UPSIDE"] { + background: #fff0d8; + color: #945400; + } + + .opportunities-extension-stage-badge[data-stage="FORECAST"] { + background: #eee8f5; + color: #624e74; + } + + .opportunities-extension-stage-badge[data-stage="WON"] { + background: #e5f1d9; + color: #3f6f17; + } + + .opportunities-extension-status-badge { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 8px; + border-radius: 12px; + background: #ebe8e5; + color: #312d2a; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; + } + + .opportunities-extension-status-badge[data-status="OPEN"] { + background: #e2f2e5; + color: #197a3d; + } + + .opportunities-extension-status-badge[data-status="CLOSED"] { + background: #e5e7eb; + color: #4b5563; + } + + .opportunities-extension-status-badge[data-status="LOST"] { + background: #f9e3e1; + color: #b13b34; + } + + .opportunities-extension-status-badge[data-status="WON"] { + background: #e3edf8; + color: #1f5f99; + } + + .opportunities-extension-sort-button { + position: relative; + display: inline-flex; + align-items: center; + width: 100%; + min-height: 44px; + padding: 8px 42px 8px 16px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-weight: inherit; + letter-spacing: 0; + line-height: 1.2; + text-align: left; + white-space: normal; + cursor: pointer; + } + + .opportunities-extension-table-header-label { + display: inline-flex; + align-items: center; + min-height: 44px; + padding: 8px 6px; + } + + .opportunities-extension-sort-button::after { + position: absolute; + right: 15px; + color: #5f5a55; + content: "↕"; + font-size: 16px; + font-weight: 400; + } + + .opportunities-extension-sort-button[data-sort-direction="ascending"]::after { + color: #00758f; + content: "↑"; + } + + .opportunities-extension-sort-button[data-sort-direction="descending"]::after { + color: #00758f; + content: "↓"; + } + + .opportunities-extension-sort-button:hover { + background: #f0eeeb; + } + + .opportunities-extension-sort-button:focus-visible { + outline: 2px solid #00758f; + outline-offset: -2px; + } + + .opportunities-extension-table-status { + height: 152px; + color: #5f5a55; + font-size: 14px; + text-align: center; + } + + .opportunities-extension-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .opportunities-extension-preferences-modal { + position: absolute; + inset: 0; + z-index: 3; + display: grid; + place-items: center; + padding: 24px; + background: rgba(0, 0, 0, .42); + } + + .opportunities-extension-preferences-modal[hidden] { + display: none !important; + } + + .opportunities-extension-account-plans-modal { + position: absolute; + inset: 0; + z-index: 4; + display: grid; + place-items: center; + padding: 12px; + background: rgba(0, 0, 0, .42); + } + + .opportunities-extension-account-plans-modal[hidden] { + display: none !important; + } + + .opportunities-extension-account-plans-dialog { + display: grid; + width: min(1180px, 100%); + height: min(760px, calc(100vh - 24px)); + min-width: 0; + grid-template-rows: auto minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); + overflow: hidden; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + box-shadow: 0 8px 24px rgba(0, 0, 0, .24); + color: #312d2a; + } + + .opportunities-extension-account-plans-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 14px 20px; + border-bottom: 1px solid #dedbd7; + } + + .opportunities-extension-account-plans-header h2 { + margin: 0; + font-size: 20px; + line-height: 1.2; + } + + .opportunities-extension-account-plans-header p { + margin: 5px 0 0; + color: #5f5a55; + font-size: 13px; + line-height: 1.35; + } + + .opportunities-extension-account-plans-content { + display: grid; + min-width: 0; + grid-template-rows: auto minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); + min-height: 0; + overflow: hidden; + } + + .opportunities-extension-account-plans-content > .opportunities-extension-account-plans-skeleton { + grid-row: 1 / -1; + } + + .opportunities-extension-account-plans-tabs-navigation { + position: relative; + min-width: 0; + border-bottom: 1px solid #dedbd7; + } + + .opportunities-extension-account-plans-tabs-navigation .opportunities-extension-account-plans-tab-navigation-button[hidden] { + display: none; + } + + .opportunities-extension-account-plans-tabs { + display: flex; + min-width: 0; + gap: 4px; + overflow-x: auto; + padding: 8px 20px 0; + scrollbar-width: none; + } + + .opportunities-extension-account-plans-tabs::-webkit-scrollbar { + display: none; + } + + .opportunities-extension-account-plans-tab-navigation-button { + display: grid; + position: absolute; + z-index: 1; + top: 50%; + width: 32px; + height: 32px; + place-items: center; + padding: 0; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + color: #312d2a; + box-shadow: 0 2px 6px rgba(0, 0, 0, .16); + transform: translateY(-50%); + cursor: pointer; + } + + .opportunities-extension-account-plans-tab-navigation-button:first-child { + left: 8px; + } + + .opportunities-extension-account-plans-tab-navigation-button:last-child { + right: 8px; + } + + .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) { + border-color: #00758f; + background: #f0eeeb; + color: #005e73; + } + + .opportunities-extension-account-plans-tab-navigation-button:disabled { + border-color: #dedbd7; + color: #b8b2ad; + cursor: default; + } + + .opportunities-extension-account-plans-tab-navigation-button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 2px; + } + + .opportunities-extension-account-plans-tab-navigation-button svg { + width: 18px; + height: 18px; + } + + .opportunities-extension-account-plans-panels { + min-width: 0; + min-height: 0; + overflow: hidden; + } + + .opportunities-extension-account-plan-tab { + min-height: 38px; + flex: 0 0 auto; + padding: 7px 12px; + border: 0; + border-bottom: 3px solid transparent; + background: transparent; + color: #5f5a55; + font-size: 13px; + font-weight: 600; + cursor: pointer; + } + + .opportunities-extension-account-plan-tab:hover { + background: #f0eeeb; + color: #312d2a; + } + + .opportunities-extension-account-plan-tab[aria-selected="true"] { + border-bottom-color: #00758f; + color: #006d7a; + } + + .opportunities-extension-account-plan-tab:focus-visible { + outline: 2px solid #00758f; + outline-offset: -2px; + } + + .opportunities-extension-account-plan-panel { + display: grid; + height: 100%; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + box-sizing: border-box; + padding: 16px 20px; + } + + .opportunities-extension-account-plan-panel[hidden] { + display: none !important; + } + + .opportunities-extension-account-plan-details { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0 24px; + margin: 0; + } + + .opportunities-extension-account-plan-details > div { + display: grid; + grid-template-columns: minmax(104px, 38%) minmax(0, 1fr); + gap: 10px; + min-height: 42px; + align-items: center; + padding: 7px 0; + border-bottom: 1px solid #ebe8e5; + } + + .opportunities-extension-account-plan-details dt { + color: #5f5a55; + font-size: 12px; + font-weight: 700; + } + + .opportunities-extension-account-plan-details dd { + min-width: 0; + margin: 0; + color: #312d2a; + font-size: 13px; + overflow-wrap: anywhere; + } + + .opportunities-extension-consumption-trend { + display: grid; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid #dedbd7; + } + + .opportunities-extension-consumption-trend-header { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 24px; + } + + .opportunities-extension-consumption-periods { + display: flex; + align-items: center; + gap: 4px; + } + + .opportunities-extension-consumption-periods button { + min-height: 38px; + padding: 7px 14px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: #312d2a; + font-size: 13px; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + } + + .opportunities-extension-consumption-periods button:hover { + background: #f0eeeb; + } + + .opportunities-extension-consumption-periods button[aria-pressed="true"] { + border-color: #00758f; + background: #e7f6f8; + color: #005e73; + } + + .opportunities-extension-consumption-periods button:focus-visible, + .opportunities-extension-consumption-navigation-button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 2px; + } + + .opportunities-extension-consumption-chart { + display: grid; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + margin-top: 4px; + } + + .opportunities-extension-consumption-navigation { + display: grid; + grid-template-columns: 36px minmax(130px, auto) 36px; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 40px; + margin-bottom: 2px; + } + + .opportunities-extension-consumption-navigation strong { + color: #312d2a; + font-size: 13px; + text-align: center; + } + + .opportunities-extension-consumption-navigation-button { + display: grid; + width: 36px; + height: 36px; + place-items: center; + padding: 0; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + color: #312d2a; + cursor: pointer; + } + + .opportunities-extension-consumption-navigation-button:hover:not(:disabled) { + border-color: #00758f; + background: #f0eeeb; + color: #005e73; + } + + .opportunities-extension-consumption-navigation-button:disabled { + border-color: #dedbd7; + color: #b8b2ad; + cursor: default; + } + + .opportunities-extension-consumption-navigation-button svg { + width: 18px; + height: 18px; + } + + .opportunities-extension-consumption-figure { + position: relative; + min-width: 0; + min-height: 0; + margin: 0; + scrollbar-width: none; + } + + .opportunities-extension-consumption-figure::-webkit-scrollbar { + display: none; + } + + .opportunities-extension-consumption-visual { + display: grid; + height: 100%; + min-height: 0; + grid-template-columns: minmax(0, 1fr) 176px; + align-items: center; + gap: 16px; + } + + .opportunities-extension-consumption-svg { + display: block; + width: 100%; + height: 300px; + min-height: 0; + overflow: visible; + } + + .opportunities-extension-consumption-svg .consumption-grid-line { + stroke: #dedbd7; + stroke-width: 1; + shape-rendering: crispEdges; + } + + .opportunities-extension-consumption-svg .consumption-axis-label, + .opportunities-extension-consumption-svg .consumption-x-label { + fill: #5f5a55; + font-family: Arial, sans-serif; + font-size: 12px; + } + + .opportunities-extension-consumption-svg .consumption-value-label { + fill: #312d2a; + font-family: Arial, sans-serif; + font-size: 12px; + font-weight: 700; + } + + .opportunities-extension-consumption-svg .consumption-bar-funded { + fill: #b77888; + } + + .opportunities-extension-consumption-svg .consumption-bar-overage { + fill: #de7907; + } + + .opportunities-extension-consumption-svg .consumption-average-line { + stroke: #de7907; + stroke-width: 1.5; + stroke-dasharray: 6 6; + } + + .opportunities-extension-consumption-svg .consumption-bar-group { + cursor: default; + outline: none; + } + + .opportunities-extension-consumption-svg .consumption-bar-group rect { + transition: filter .16s ease, opacity .16s ease, stroke .16s ease; + } + + .opportunities-extension-consumption-svg .consumption-bar-group:hover rect, + .opportunities-extension-consumption-svg .consumption-bar-group:focus rect { + filter: brightness(1.06); + stroke: #312d2a; + stroke-width: 1.5; + } + + .opportunities-extension-consumption-svg .consumption-animated-label { + opacity: 0; + animation: opportunities-extension-consumption-label-in .24s ease-out forwards; + } + + .opportunities-extension-consumption-svg .consumption-animated-average { + opacity: 0; + animation: opportunities-extension-consumption-average-in .35s .32s ease-out forwards; + } + + @keyframes opportunities-extension-consumption-label-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } + } + + @keyframes opportunities-extension-consumption-average-in { + from { opacity: 0; } + to { opacity: 1; } + } + + .opportunities-extension-consumption-legend { + display: grid; + align-content: center; + gap: 4px; + } + + .opportunities-extension-consumption-legend button { + display: grid; + min-width: 0; + grid-template-columns: 16px minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 6px 4px; + border: 0; + border-radius: 4px; + background: transparent; + color: #312d2a; + font-size: 12px; + line-height: 1.25; + text-align: left; + cursor: pointer; + } + + .opportunities-extension-consumption-legend button:hover { + background: #f0eeeb; + } + + .opportunities-extension-consumption-legend button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 1px; + } + + .opportunities-extension-consumption-legend button[aria-pressed="false"] { + color: #77726d; + opacity: .55; + } + + .opportunities-extension-consumption-legend-swatch { + display: block; + width: 12px; + height: 12px; + justify-self: center; + } + + .opportunities-extension-consumption-legend-funded { + background: #b77888; + } + + .opportunities-extension-consumption-legend-overage { + background: #de7907; + } + + .opportunities-extension-consumption-legend-average { + height: 0; + border-top: 2px dashed #de7907; + } + + .opportunities-extension-consumption-tooltip { + position: absolute; + z-index: 2; + display: grid; + min-width: 190px; + max-width: 260px; + gap: 4px; + padding: 10px 12px; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + box-shadow: 0 4px 12px rgba(0, 0, 0, .2); + color: #312d2a; + font-size: 12px; + line-height: 1.3; + pointer-events: none; + } + + .opportunities-extension-consumption-tooltip[hidden] { + display: none !important; + } + + .opportunities-extension-consumption-tooltip strong { + margin-bottom: 2px; + font-size: 13px; + } + + .opportunities-extension-consumption-chart-skeleton { + height: 100%; + min-height: 0; + padding: 34px 62px 24px; + box-sizing: border-box; + } + + .opportunities-extension-consumption-chart-skeleton-plot { + display: flex; + height: 100%; + min-height: 220px; + align-items: flex-end; + justify-content: space-around; + gap: 12px; + padding: 0 14px; + border-bottom: 1px solid #dedbd7; + border-left: 1px solid #dedbd7; + } + + .opportunities-extension-consumption-chart-skeleton-plot span { + width: min(38px, 6%); + border-radius: 2px 2px 0 0; + background: #dedbd7; + animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; + } + + .opportunities-extension-consumption-chart-empty, + .opportunities-extension-consumption-chart-error { + display: grid; + min-height: 220px; + place-items: center; + margin: 0; + color: #5f5a55; + font-size: 14px; + text-align: center; + } + + .opportunities-extension-consumption-chart-error { + color: #9f2d20; + } + + .opportunities-extension-account-plan-status-indicator { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: #9b9691; + box-shadow: 0 0 0 3px #efedeb; + vertical-align: middle; + } + + .opportunities-extension-account-plan-status-indicator[data-active="true"] { + background: #3f6f17; + box-shadow: 0 0 0 3px #e2f2e5; + } + + .opportunities-extension-account-plan-tab .opportunities-extension-account-plan-status-indicator { + margin-right: 8px; + } + + .opportunities-extension-account-plans-skeleton-tabs { + display: flex; + min-height: 46px; + align-items: center; + padding: 0 20px; + border-bottom: 1px solid #dedbd7; + } + + .opportunities-extension-account-plans-skeleton { + display: grid; + height: 100%; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + } + + .opportunities-extension-account-plans-skeleton-panel { + padding: 16px 20px; + } + + .opportunities-extension-account-plans-skeleton-tab { + display: inline-flex; + align-items: center; + gap: 9px; + min-height: 38px; + padding: 0 12px; + border-bottom: 3px solid #00758f; + } + + .opportunities-extension-account-plans-skeleton-status { + display: block; + width: 10px; + height: 10px; + flex: 0 0 auto; + border-radius: 50%; + background: #dedbd7; + box-shadow: 0 0 0 3px #efedeb; + animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; + } + + .opportunities-extension-account-plans-skeleton-details { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0 24px; + padding: 0; + } + + .opportunities-extension-account-plans-skeleton-details > div { + display: grid; + grid-template-columns: minmax(104px, 38%) minmax(0, 1fr); + align-items: center; + gap: 10px; + min-height: 42px; + padding: 7px 0; + border-bottom: 1px solid #ebe8e5; + } + + .opportunities-extension-account-plans-skeleton-bar { + display: block; + height: 12px; + border-radius: 3px; + background: #dedbd7; + animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; + } + + .opportunities-extension-account-plans-skeleton-tabs .opportunities-extension-account-plans-skeleton-bar { + height: 14px; + } + + .opportunities-extension-account-plans-skeleton-label { + width: 74%; + height: 10px; + } + + .opportunities-extension-account-plans-skeleton-trend { + display: grid; + min-height: 0; + grid-template-rows: auto auto minmax(0, 1fr); + margin: 14px 0 0; + padding-top: 12px; + border-top: 1px solid #dedbd7; + } + + .opportunities-extension-account-plans-skeleton-trend-header { + display: flex; + justify-content: flex-end; + min-height: 38px; + } + + .opportunities-extension-account-plans-skeleton-periods { + display: flex; + align-items: center; + gap: 16px; + } + + .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar { + height: 12px; + } + + .opportunities-extension-account-plans-skeleton-period-active { + height: 38px !important; + border: 1px solid #8cc8d3; + background: #e7f6f8; + } + + .opportunities-extension-account-plans-skeleton-navigation { + display: grid; + grid-template-columns: 32px 124px 32px; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 54px; + } + + .opportunities-extension-account-plans-skeleton-navigation .opportunities-extension-account-plans-skeleton-bar { + justify-self: center; + } + + .opportunities-extension-account-plans-skeleton-chart { + display: grid; + min-height: 0; + grid-template-columns: minmax(0, 1fr) 150px; + align-items: center; + gap: 28px; + padding: 12px 14px 0 62px; + } + + .opportunities-extension-account-plans-skeleton-plot { + display: flex; + position: relative; + height: 300px; + min-height: 0; + box-sizing: border-box; + align-items: flex-end; + justify-content: space-around; + gap: 12px; + padding: 0 14px 64px; + background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #ebe8e5 58px, #ebe8e5 59px) left top / 100% 236px no-repeat; + } + + .opportunities-extension-account-plans-skeleton-plot::before, + .opportunities-extension-account-plans-skeleton-plot::after { + position: absolute; + display: block; + content: ""; + background: #dedbd7; + } + + .opportunities-extension-account-plans-skeleton-plot::before { + top: 0; + bottom: 64px; + left: 0; + width: 1px; + } + + .opportunities-extension-account-plans-skeleton-plot::after { + right: 0; + bottom: 64px; + left: 0; + height: 1px; + } + + .opportunities-extension-account-plans-skeleton-plot .opportunities-extension-account-plans-skeleton-bar { + width: min(38px, 7%); + flex: 0 1 38px; + border-radius: 2px 2px 0 0; + } + + .opportunities-extension-account-plans-skeleton-legend { + display: grid; + align-content: center; + gap: 18px; + min-height: 0; + } + + .opportunities-extension-account-plans-skeleton-legend .opportunities-extension-account-plans-skeleton-bar { + height: 11px; + } + + @keyframes opportunities-extension-skeleton-pulse { + from { opacity: .48; } + to { opacity: 1; } + } + + @media (prefers-reduced-motion: reduce) { + .opportunities-extension-account-plans-skeleton-bar, + .opportunities-extension-consumption-chart-skeleton-plot span, + .opportunities-extension-consumption-svg .consumption-animated-label, + .opportunities-extension-consumption-svg .consumption-animated-average { + animation: none; + opacity: 1; + } + } + + .opportunities-extension-account-plans-empty, + .opportunities-extension-account-plans-error { + margin: 0; + padding: 28px 20px; + color: #5f5a55; + font-size: 14px; + } + + .opportunities-extension-account-plans-error { + color: #9f2d20; + } + + .opportunities-extension-preferences-dialog { + display: grid; + width: min(680px, 100%); + max-height: min(720px, calc(100vh - 48px)); + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + box-shadow: 0 8px 24px rgba(0, 0, 0, .24); + color: #312d2a; + } + + .opportunities-extension-preferences-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid #dedbd7; + } + + .opportunities-extension-preferences-header h2 { + margin: 0; + font-size: 20px; + font-weight: 700; + line-height: 1.2; + } + + .opportunities-extension-preferences-content { + min-height: 0; + overflow: auto; + padding: 20px; + } + + .opportunities-extension-preferences-fieldset { + min-width: 0; + margin: 0; + padding: 0; + border: 0; + } + + .opportunities-extension-preferences-fieldset legend { + margin-bottom: 12px; + padding: 0; + color: #312d2a; + font-size: 15px; + font-weight: 700; + line-height: 1.3; + } + + .opportunities-extension-preferences-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px 20px; + } + + .opportunities-extension-preferences-option { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; + padding: 9px 8px; + border-radius: 4px; + color: #312d2a; + font-size: 14px; + line-height: 1.35; + cursor: pointer; + } + + .opportunities-extension-preferences-option:hover { + background: #f0eeeb; + } + + .opportunities-extension-preferences-option input { + width: 18px; + height: 18px; + flex: 0 0 18px; + margin: 0; + accent-color: #00758f; + cursor: pointer; + } + + .opportunities-extension-preferences-cache-field { + display: grid; + width: min(280px, 100%); + gap: 6px; + margin-top: 24px; + color: #312d2a; + font-size: 14px; + font-weight: 700; + } + + .opportunities-extension-preferences-cache-field select { + width: 100%; + height: 40px; + padding: 0 36px 0 12px; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + color: #312d2a; + font-size: 14px; + font-weight: 400; + } + + .opportunities-extension-preferences-cache-field select:focus { + border-color: #00758f; + outline: 2px solid #bde7ee; + outline-offset: 0; + } + + .opportunities-extension-preferences-footer { + display: flex; + justify-content: flex-end; + padding: 14px 20px; + border-top: 1px solid #dedbd7; + } + + .opportunities-extension-button { + min-width: 88px; + min-height: 36px; + padding: 7px 16px; + border: 1px solid transparent; + border-radius: 4px; + font-size: 14px; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + } + + .opportunities-extension-button-primary { + border-color: #00758f; + background: #00758f; + color: #ffffff; + } + + .opportunities-extension-button-primary:hover, + .opportunities-extension-button-primary:focus-visible { + border-color: #005e73; + background: #005e73; + outline: none; + } + + .opportunities-extension-debug-panel { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 1; + width: min(640px, calc(100vw - 48px)); + max-height: min(560px, calc(100vh - 120px)); + overflow: auto; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + box-shadow: 0 2px 8px rgba(0, 0, 0, .16); + padding: 16px; + color: #000000; + } + + .opportunities-extension-debug-panel[hidden] { + display: none !important; + } + + .opportunities-extension-debug-panel h2 { + margin: 0; + font-size: 18px; + font-weight: 700; + line-height: 1.25; + } + + .opportunities-extension-debug-panel p { + margin: 4px 0 0; + color: #5f5a55; + font-size: 13px; + } + + .opportunities-extension-debug-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + } + + .opportunities-extension-debug-visibility-button { + width: 34px; + height: 34px; + flex: 0 0 34px; + color: #312d2a; + } + + .opportunities-extension-debug-visibility-button svg { + width: 20px; + height: 20px; + } + + .opportunities-extension-debug-list { + display: grid; + gap: 10px; + } + + .opportunities-extension-debug-item { + border: 1px solid #dedbd7; + border-left-width: 4px; + border-radius: 4px; + background: #faf9f8; + } + + .opportunities-extension-debug-item[open] { + background: #ffffff; + } + + .opportunities-extension-debug-item-success { + border-left-color: #3f6f17; + } + + .opportunities-extension-debug-item-success .opportunities-extension-debug-status { + background: #e2f2e5; + color: #356d19; + } + + .opportunities-extension-debug-item-failed { + border-left-color: #c5331f; + } + + .opportunities-extension-debug-item-failed .opportunities-extension-debug-status { + background: #f9e3e1; + color: #a52b1c; + } + + .opportunities-extension-debug-item-pending { + border-left-color: #6f5a7f; + } + + .opportunities-extension-debug-item-pending .opportunities-extension-debug-status { + background: #eee8f2; + color: #6f5a7f; + } + + .opportunities-extension-debug-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 42px; + padding: 9px 12px 9px 10px; + cursor: pointer; + list-style: none; + } + + .opportunities-extension-debug-heading::-webkit-details-marker { + display: none; + } + + .opportunities-extension-debug-heading::before { + content: "⌄"; + width: 14px; + color: #5f5a55; + font-size: 16px; + line-height: 1; + transform: rotate(-90deg); + transition: transform .15s ease; + } + + .opportunities-extension-debug-item[open] .opportunities-extension-debug-heading::before { + transform: rotate(0deg); + } + + .opportunities-extension-debug-summary-meta { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: auto; + } + + .opportunities-extension-debug-http-status, + .opportunities-extension-debug-status { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + line-height: 1; + text-transform: uppercase; + } + + .opportunities-extension-debug-http-status { + background: #e8f4f6; + color: #006d7a; + } + + .opportunities-extension-debug-status { + background: #ebe8e5; + color: #5f5a55; + } + + .opportunities-extension-debug-heading strong { + font-size: 13px; + } + + .opportunities-extension-debug-item pre { + margin: 0; + padding: 8px 12px 12px; + color: #312d2a; + font-family: Consolas, "Courier New", monospace; + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + } + + .opportunities-extension-debug-empty { + padding: 14px; + border: 1px dashed #b8b2ad; + border-radius: 4px; + color: #5f5a55; + font-size: 13px; + } + + .opportunities-extension-modal[data-theme="dark"] { + background: #1f1f1f; + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-shell, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-body { + background: #1f1f1f; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-header { + border-bottom-color: #4e4a46; + background: + linear-gradient(90deg, #a8795d 0 11%, #dd5a47 11% 23%, #876e9b 23% 36%, #00a6be 36% 51%, #dfbd76 51% 63%, transparent 63% 100%) top left / 100% 6px no-repeat, + #292827; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-header h1, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-value, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-field, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-label, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-label, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-label, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-search-field { + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-header p, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header p, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card span, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-empty, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail-heading span, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-empty, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-empty, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details dt, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel p, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-empty { + color: #c9c5c1; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-error { + color: #ffb4a8; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-error { + color: #ffb4a8; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-bar { + background: #5b5753; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-status { + background: #5b5753; + box-shadow: 0 0 0 3px #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-period-active { + border-color: #3f7580; + background: #23474e; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot span { + background: #5b5753; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator { + background: #8f8a85; + box-shadow: 0 0 0 3px #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator[data-active="true"] { + background: #7fb05b; + box-shadow: 0 0 0 3px #314527; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-form, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-surface, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-panel, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-dialog, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-dialog, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] { + border-color: #4e4a46; + background: #292827; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-trigger, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-search, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-search-field input, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-field select, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-cache-field select, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button { + border-color: #6a6560; + background: #333130; + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-trigger::after { + border-top-color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-select-wrap::after { + border-top-color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-trigger:disabled { + background: #292827; + color: #918b86; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-search::placeholder, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-search-field input::placeholder { + color: #b5b0ab; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header h2, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header h2, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-fieldset legend, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-cache-field, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail-heading, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details dd, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-heading, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 { + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation strong { + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-empty { + color: #c9c5c1; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table th { + border-bottom-color: #4e4a46; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-footer, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs-navigation, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details > div, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-tabs, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-details > div { + border-color: #4e4a46; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-trend, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-chart-skeleton-plot, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-trend, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot { + border-color: #4e4a46; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot { + background: repeating-linear-gradient(to bottom, transparent 0, transparent 58px, #4e4a46 58px, #4e4a46 59px) left top / 100% 236px no-repeat; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot::before, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-plot::after { + background: #4e4a46; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab { + color: #c9c5c1; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab:hover { + background: #3b3937; + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab[aria-selected="true"] { + border-bottom-color: #43c4d5; + color: #91e4ed; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button { + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button:hover { + background: #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-periods button[aria-pressed="true"] { + border-color: #43c4d5; + background: #173e43; + color: #91e4ed; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button { + border-color: #6a6560; + background: #333130; + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:hover:not(:disabled), + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button:hover:not(:disabled) { + border-color: #43c4d5; + background: #3b3937; + color: #91e4ed; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-navigation-button:disabled, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tab-navigation-button:disabled { + border-color: #4e4a46; + color: #746f6a; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-grid-line { + stroke: #4e4a46; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-axis-label, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-x-label { + fill: #c9c5c1; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-value-label { + fill: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-funded { + fill: #ca8798; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-overage { + fill: #f09a32; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-average-line { + stroke: #f09a32; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-group:hover rect, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-svg .consumption-bar-group:focus rect { + stroke: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button { + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button:hover { + background: #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend button[aria-pressed="false"] { + color: #a8a39e; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-funded { + background: #ca8798; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-overage { + background: #f09a32; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-legend-average { + border-top-color: #f09a32; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-consumption-tooltip { + border-color: #6a6560; + background: #333130; + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option:hover { + background: #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option input { + accent-color: #43c4d5; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary { + border-color: #00a6be; + background: #008aa6; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary:hover, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary:focus-visible { + border-color: #43c4d5; + background: #006d7a; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option:hover, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table tbody tr:hover, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sort-button:hover, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-icon-button:hover, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-icon-button:focus { + background: #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table thead, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table th, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table th { + background: #333130; + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table td { + border-bottom-color: #3f3c39; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail { + border-bottom-color: #4e4a46; + background: #252423; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table th, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table td { + border-bottom-color: #3f3c39; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count { + border-color: #2f9bae; + background: #1d4e55; + color: #91e4ed; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count:hover, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count:focus-visible, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count[aria-expanded="true"] { + border-color: #43c4d5; + background: #008aa6; + color: #ffffff; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-progress[data-state="running"] { + background: #1d4e55; + color: #91e4ed; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-progress[data-state="complete"] { + background: #244f2e; + color: #a8dfb7; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-progress[data-state="complete-with-errors"], + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-progress[data-state="error"], + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-count-error { + border-color: #b13b34; + background: #5f2926; + color: #ffb4ad; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-cache-timestamp { + background: #3b3937; + color: #c9c5c1; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table a, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button { + color: #43c4d5; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table a:hover, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:hover, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:focus-visible, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button:hover, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button:focus-visible { + background: #1d4e55; + color: #91e4ed; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="SQL"] { + color: #43c4d5; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="PIPELINE"] { + color: #d1cbc6; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="UPSIDE"] { + color: #ffbf62; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="FORECAST"] { + color: #d2b7e9; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[data-stage="WON"] { + color: #9bd46a; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="SQL"] { + border-top-color: #43c4d5; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-total-dashboard-card { + border-top-color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { + border-top-color: #c0b9b3; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="UPSIDE"] { + border-top-color: #ffb84d; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="FORECAST"] { + border-top-color: #cba7e7; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="WON"] { + border-top-color: #92cf5b; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="SQL"] { + border-color: #008aa6; + background: #008aa6; + color: #ffffff; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="PIPELINE"] { + border-color: #6f6863; + background: #6f6863; + color: #ffffff; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="UPSIDE"] { + border-color: #b96e00; + background: #b96e00; + color: #ffffff; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="FORECAST"] { + border-color: #755b8b; + background: #755b8b; + color: #ffffff; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-filter-button[aria-pressed="true"][data-stage="WON"] { + border-color: #4b7f22; + background: #4b7f22; + color: #ffffff; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[data-status="OPEN"] { + color: #69c985; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[data-status="CLOSED"] { + color: #c3cad3; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[data-status="LOST"] { + color: #ff9d94; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[data-status="WON"] { + color: #83bfff; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="OPEN"] { + border-color: #197a3d; + background: #197a3d; + color: #ffffff; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="CLOSED"] { + border-color: #4b5563; + background: #4b5563; + color: #ffffff; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="LOST"] { + border-color: #b13b34; + background: #b13b34; + color: #ffffff; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="WON"] { + border-color: #1f5f99; + background: #1f5f99; + color: #ffffff; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item { + border-color: #4e4a46; + background: #252423; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-icon-button { + color: #f6f4f2; + } + + @media (min-width: 1201px) and (max-width: 1450px) { + .opportunities-extension-table th:nth-child(1) { + width: 16%; + } + + .opportunities-extension-table th:nth-child(3) { + width: 18%; + } + + .opportunities-extension-table th:nth-child(4) { + width: 12%; + } + + .opportunities-extension-table th:nth-child(6) { + width: 7%; + } + + .opportunities-extension-table th:nth-child(7) { + width: 8%; + } + + .opportunities-extension-table th:nth-child(10) { + width: 10%; + } + } + + @media (max-width: 1200px) { + .opportunities-extension-table { + min-width: 1180px; + } + + .opportunities-extension-table th:nth-child(1) { + width: 15%; + } + + .opportunities-extension-table th:nth-child(3) { + width: 17%; + } + + .opportunities-extension-table th:nth-child(4) { + width: 13%; + } + + .opportunities-extension-table th:nth-child(6) { + width: 6%; + } + + .opportunities-extension-table th:nth-child(7) { + width: 9%; + } + + .opportunities-extension-table th:nth-child(10) { + width: 11%; + } + } + + @media (max-width: 700px) { + .opportunities-extension-header { + padding: 24px 20px 18px; + } + + .opportunities-extension-header::before { + left: 20px; + } + + .opportunities-extension-header h1 { + font-size: 22px; + } + + .opportunities-extension-header p { + font-size: 14px; + } + + .opportunities-extension-body { + padding: 16px 20px 24px; + } + + .opportunities-extension-form { + align-items: stretch; + flex-wrap: wrap; + gap: 14px; + min-height: auto; + padding: 12px; + } + + .opportunities-extension-field { + max-width: none; + width: 100%; + } + + .opportunities-extension-customer-filter { + width: 100%; + } + + .opportunities-extension-customer-filter-trigger { + width: 100%; + } + + .opportunities-extension-customer-filter-panel { + width: min(360px, calc(100vw - 64px)); + } + + .opportunities-extension-table-search-field { + width: 100%; + } + + .opportunities-extension-stage-dashboard { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .opportunities-extension-stage-dashboard-value { + font-size: 18px; + } + + .opportunities-extension-preferences-modal { + padding: 12px; + } + + .opportunities-extension-account-plans-modal { + padding: 12px; + } + + .opportunities-extension-preferences-dialog { + max-height: calc(100vh - 24px); + } + + .opportunities-extension-account-plans-dialog { + height: calc(100vh - 24px); + } + + .opportunities-extension-account-plan-panel { + padding: 12px 14px; + } + + .opportunities-extension-account-plan-details { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 14px; + } + + .opportunities-extension-account-plan-details > div { + grid-template-columns: 1fr; + align-content: center; + gap: 3px; + min-height: 54px; + padding: 5px 0; + } + + .opportunities-extension-account-plans-skeleton-details { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 14px; + padding: 0; + } + + .opportunities-extension-account-plans-skeleton-details > div { + grid-template-columns: 1fr; + align-content: center; + gap: 5px; + min-height: 54px; + padding: 5px 0; + } + + .opportunities-extension-account-plans-skeleton-trend { + margin: 14px 0 0; + } + + .opportunities-extension-account-plans-skeleton-periods { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: center; + gap: 6px; + } + + .opportunities-extension-account-plans-skeleton-periods .opportunities-extension-account-plans-skeleton-bar { + width: 100% !important; + } + + .opportunities-extension-account-plans-skeleton-chart { + grid-template-columns: minmax(0, 1fr); + gap: 12px; + padding: 12px 8px 0 34px; + } + + .opportunities-extension-account-plans-skeleton-legend { + display: flex; + min-height: 24px; + align-items: center; + justify-content: center; + gap: 12px; + } + + .opportunities-extension-consumption-trend-header { + align-items: stretch; + } + + .opportunities-extension-consumption-periods { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 3px; + } + + .opportunities-extension-consumption-periods button { + min-width: 0; + padding: 7px 4px; + font-size: 12px; + } + + .opportunities-extension-consumption-chart { + min-height: 0; + } + + .opportunities-extension-consumption-svg { + width: 760px; + height: 245px; + } + + .opportunities-extension-consumption-visual { + width: 920px; + grid-template-columns: 760px 144px; + gap: 12px; + } + + .opportunities-extension-consumption-figure { + overflow-x: auto; + overscroll-behavior-inline: contain; + } + + .opportunities-extension-consumption-chart-skeleton { + min-height: 0; + padding: 24px 18px 18px; + } + + .opportunities-extension-consumption-chart-skeleton-plot { + min-height: 180px; + gap: 4px; + padding: 0 6px; + } + + .opportunities-extension-preferences-options { + grid-template-columns: 1fr; + } + + .opportunities-extension-stage-filter-controls { + gap: 6px; + } + + .opportunities-extension-status-filter-controls { + gap: 6px; + } + + .opportunities-extension-table-surface { + min-width: 0; + min-height: calc(100vh - 230px); + } + + .opportunities-extension-debug-panel { + right: 12px; + bottom: 12px; + width: calc(100vw - 24px); + max-height: calc(100vh - 96px); + } + } + \ No newline at end of file diff --git a/src/content/svg.js b/src/content/svg.js new file mode 100644 index 0000000..6d3b757 --- /dev/null +++ b/src/content/svg.js @@ -0,0 +1,10 @@ +function createSvgPaths({ environment }) { + const { document } = environment; + function appendPath(svg, className, d) { + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("class", className); + path.setAttribute("d", d); + svg.append(path); + } + return { appendPath }; +} diff --git a/src/content/theme.js b/src/content/theme.js new file mode 100644 index 0000000..c52ff0e --- /dev/null +++ b/src/content/theme.js @@ -0,0 +1,32 @@ +function createContentTheme({ + THEME_STORAGE_KEY, + createThemeIcon, + environment +}) { + const getLocalStorage = () => environment.localStorage; + function getSavedTheme() { + try { + return getLocalStorage().getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light"; + } catch (error) { + return "light"; + } + } + + function applyTheme(modal, button, theme) { + const isDarkTheme = theme === "dark"; + + modal.setAttribute("data-theme", isDarkTheme ? "dark" : "light"); + button.textContent = ""; + button.setAttribute("aria-label", isDarkTheme ? "Ativar modo claro" : "Ativar modo escuro"); + button.title = isDarkTheme ? "Light mode" : "Dark mode"; + button.append(createThemeIcon(isDarkTheme ? "sun" : "moon")); + + try { + getLocalStorage().setItem(THEME_STORAGE_KEY, isDarkTheme ? "dark" : "light"); + } catch (error) { + // Continue with the selected theme when browser storage is unavailable. + } + } + + return { getSavedTheme, applyTheme }; +} diff --git a/src/features/ai-summary/content.js b/src/features/ai-summary/content.js new file mode 100644 index 0000000..ee26033 --- /dev/null +++ b/src/features/ai-summary/content.js @@ -0,0 +1,103 @@ +function createAiSummaryContent({ + aiSummaryState, + invalidateSalesCommonToken, + logRequestFailure, + logRequestStart, + logRequestSuccess, + readResponseBodyForDebug, + requestTokenRelaySalesCommon, + environment +}) { + const { document, fetch } = environment; + function requestGenerateAiSummary(optyNumber) { + const key = String(optyNumber); + const existing = aiSummaryState.aiSummariesByOptyNumber.get(key); + if (existing && existing.status === "loading") { + return existing.promise; + } + + const state = { status: "loading", summaryText: "", error: "", promise: null }; + aiSummaryState.aiSummariesByOptyNumber.set(key, state); + state.promise = (async () => { + let entry; + try { + let token = await requestTokenRelaySalesCommon(); + if (!token) { + throw new Error("Authentication is required to generate the summary."); + } + const path = "/api/sales-common/gen-ai/v1/summarizations/generate"; + const url = `https://eeho.fa.us2.oraclecloud.com${path}`; + const payload = { path, promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: key }; + let response; + for (let attempt = 0; attempt < 2; attempt += 1) { + const headers = { Accept: "application/json", Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; + entry = logRequestStart({ + name: "requestGenerateAiSummary", method: "POST", url, headers, + body: JSON.stringify(payload, null, 2), metadata: { optyNumber: key, attempt: attempt + 1 } + }); + response = await fetch(url, { + method: "POST", credentials: "include", headers, body: JSON.stringify(payload) + }); + logRequestSuccess(entry, response, await readResponseBodyForDebug(response)); + if (response.status !== 401 && response.status !== 403) { + break; + } + // A late failure for an old token must not discard a newly refreshed token. + invalidateSalesCommonToken(token); + if (attempt === 0) { + token = await requestTokenRelaySalesCommon(); + } + } + if (!response.ok) { + throw new Error(`requestGenerateAiSummary failed with status ${response.status}.`); + } + let data; + try { + data = await response.json(); + } catch (error) { + throw new Error("requestGenerateAiSummary returned an invalid JSON response."); + } + if (!data || typeof data.summaryText !== "string" || !data.summaryText.trim()) { + throw new Error("requestGenerateAiSummary returned an empty summary."); + } + state.summaryText = data.summaryText; + state.status = "ready"; + } catch (error) { + state.status = "error"; + state.error = error.message || "Unable to generate the AI summary."; + if (entry) { + logRequestFailure(entry, state.error); + } + } finally { + // Update only visible summaries, without reopening rows or rebuilding the table. + document.querySelectorAll(".opportunities-extension-ai-summary").forEach((alert) => { + if (alert.dataset.optyNumber === key) { + alert.replaceWith(createAiSummaryAlert(key)); + } + }); + } + })(); + return state.promise; + } + + function createAiSummaryAlert(optyNumber) { + const state = aiSummaryState.aiSummariesByOptyNumber.get(optyNumber); + const alert = document.createElement("div"); + alert.className = "opportunities-extension-ai-summary"; + alert.dataset.optyNumber = optyNumber; + alert.setAttribute("role", "status"); + alert.setAttribute("aria-live", "polite"); + alert.setAttribute("aria-busy", String(!state || state.status === "loading")); + const title = document.createElement("strong"); + title.textContent = "Summary generated by AI"; + const description = document.createElement("p"); + description.textContent = !state || state.status === "loading" + ? "Generating summary..." + : state.status === "error" ? state.error : state.summaryText; + alert.classList.toggle("opportunities-extension-ai-summary-error", Boolean(state && state.status === "error")); + alert.append(title, description); + return alert; + } + + return { requestGenerateAiSummary, createAiSummaryAlert }; +} diff --git a/src/features/ai-summary/state.js b/src/features/ai-summary/state.js new file mode 100644 index 0000000..eedec2c --- /dev/null +++ b/src/features/ai-summary/state.js @@ -0,0 +1,5 @@ +function createAiSummaryState() { + return { + aiSummariesByOptyNumber: new Map() + }; +} diff --git a/src/features/consumption/chart.js b/src/features/consumption/chart.js new file mode 100644 index 0000000..7bf91a2 --- /dev/null +++ b/src/features/consumption/chart.js @@ -0,0 +1,383 @@ +function createConsumptionChart({ + CONSUMPTION_TREND_PERIODS, + CONSUMPTION_TREND_SERIES, + SVG_NAMESPACE, + calculateAverageConsumption, + consumptionTrendState, + createConsumptionTrendKey, + createNiceConsumptionMaximum, + formatCompactConsumptionNumber, + formatConsumptionCurrency, + formatConsumptionPointLabel, + formatConsumptionRange, + normalizeConsumptionTrendItems, + environment +}) { + const { document, window } = environment; + function renderConsumptionTrendChart(section, chart, items, period, subPlanNum) { + chart.textContent = ""; + chart.setAttribute("aria-busy", "false"); + const points = normalizeConsumptionTrendItems(items); + + if (points.length === 0) { + const empty = document.createElement("p"); + empty.className = "opportunities-extension-consumption-chart-empty"; + empty.textContent = "No consumption data found for this period."; + chart.append(empty); + return; + } + + const option = CONSUMPTION_TREND_PERIODS.find((item) => item.value === period); + const windowSize = option ? option.windowSize : points.length; + const offsetKey = createConsumptionTrendKey(subPlanNum, period); + const maxOffset = Math.max(0, Math.ceil(points.length / windowSize) - 1); + const offset = Math.min(consumptionTrendState.consumptionTrendWindowOffsets.get(offsetKey) || 0, maxOffset); + const end = Math.max(0, points.length - (offset * windowSize)); + const start = Math.max(0, end - windowSize); + const visiblePoints = points.slice(start, end); + consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, offset); + + const navigation = document.createElement("div"); + navigation.className = "opportunities-extension-consumption-navigation"; + const olderButton = createConsumptionNavigationButton("older", start === 0); + const newerButton = createConsumptionNavigationButton("newer", end === points.length); + const range = document.createElement("strong"); + range.textContent = formatConsumptionRange(visiblePoints, period); + olderButton.addEventListener("click", () => { + consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.min(maxOffset, offset + 1)); + renderConsumptionTrendChart(section, chart, items, period, subPlanNum); + }); + newerButton.addEventListener("click", () => { + consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.max(0, offset - 1)); + renderConsumptionTrendChart(section, chart, items, period, subPlanNum); + }); + navigation.append(olderButton, range, newerButton); + + const figure = document.createElement("figure"); + figure.className = "opportunities-extension-consumption-figure"; + const visual = document.createElement("div"); + visual.className = "opportunities-extension-consumption-visual"; + const tooltip = document.createElement("div"); + tooltip.className = "opportunities-extension-consumption-tooltip"; + tooltip.hidden = true; + tooltip.setAttribute("role", "tooltip"); + const activeSeries = getConsumptionTrendSeriesFilter(subPlanNum); + const svg = createConsumptionSvg( + visiblePoints, + period, + end === points.length, + activeSeries, + tooltip + ); + const legend = createConsumptionLegend( + section, + chart, + items, + period, + subPlanNum, + activeSeries + ); + visual.append(svg, legend); + figure.append(visual, tooltip); + chart.append(navigation, figure); + } + + function getConsumptionTrendSeriesFilter(subPlanNum) { + if (!consumptionTrendState.consumptionTrendSeriesFilters.has(subPlanNum)) { + consumptionTrendState.consumptionTrendSeriesFilters.set( + subPlanNum, + new Set(CONSUMPTION_TREND_SERIES.map((series) => series.value)) + ); + } + + return consumptionTrendState.consumptionTrendSeriesFilters.get(subPlanNum); + } + + function createConsumptionLegend(section, chart, items, period, subPlanNum, activeSeries) { + const legend = document.createElement("div"); + legend.className = "opportunities-extension-consumption-legend"; + legend.setAttribute("role", "group"); + legend.setAttribute("aria-label", "Chart series filters"); + + CONSUMPTION_TREND_SERIES.forEach((series) => { + const button = document.createElement("button"); + const swatch = document.createElement("span"); + const label = document.createElement("span"); + button.type = "button"; + button.dataset.series = series.value; + button.setAttribute("aria-pressed", String(activeSeries.has(series.value))); + button.setAttribute("aria-label", `${activeSeries.has(series.value) ? "Hide" : "Show"} ${series.label}`); + swatch.className = `opportunities-extension-consumption-legend-swatch opportunities-extension-consumption-legend-${series.value}`; + swatch.setAttribute("aria-hidden", "true"); + label.textContent = series.label; + button.append(swatch, label); + button.addEventListener("click", () => { + if (activeSeries.has(series.value)) { + activeSeries.delete(series.value); + } else { + activeSeries.add(series.value); + } + + renderConsumptionTrendChart(section, chart, items, period, subPlanNum); + chart.querySelector(`[data-series="${series.value}"]`)?.focus(); + }); + legend.append(button); + }); + + return legend; + } + + function createConsumptionNavigationButton(direction, disabled) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "opportunities-extension-consumption-navigation-button"; + button.disabled = disabled; + button.setAttribute("aria-label", direction === "older" ? "Show older consumption data" : "Show newer consumption data"); + button.title = direction === "older" ? "Older" : "Newer"; + const icon = createSvgElement("svg", { + viewBox: "0 0 24 24", + "aria-hidden": "true", + focusable: "false" + }); + icon.append(createSvgElement("path", { + d: direction === "older" ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7", + fill: "none", + stroke: "currentColor", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "stroke-width": "2" + })); + button.append(icon); + return button; + } + + function createConsumptionSvg(points, period, hasPartialLatestPoint, activeSeries, tooltip) { + const width = 1000; + const height = 320; + const plot = { left: 62, top: 24, right: 980, bottom: 258 }; + const plotWidth = plot.right - plot.left; + const plotHeight = plot.bottom - plot.top; + const average = calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries); + const maximum = createNiceConsumptionMaximum(Math.max(average, ...points.map((point) => point.total))); + const svg = createSvgElement("svg", { + viewBox: `0 0 ${width} ${height}`, + role: "img", + "aria-label": `${period} cloud consumption chart` + }); + svg.classList.add("opportunities-extension-consumption-svg"); + + for (let tick = 0; tick <= 5; tick += 1) { + const value = (maximum / 5) * tick; + const y = plot.bottom - ((value / maximum) * plotHeight); + svg.append( + createSvgElement("line", { x1: plot.left, y1: y, x2: plot.right, y2: y, class: "consumption-grid-line" }), + createSvgText(plot.left - 10, y + 4, formatCompactConsumptionNumber(value), "consumption-axis-label", "end") + ); + } + + const step = plotWidth / Math.max(points.length, 1); + const barWidth = Math.min(44, Math.max(10, step * 0.58)); + + points.forEach((point, index) => { + const x = plot.left + (step * index) + ((step - barWidth) / 2); + const fundedAmount = activeSeries.has("funded") ? point.funded : 0; + const overageAmount = activeSeries.has("overage") ? point.overage : 0; + const visibleTotal = fundedAmount + overageAmount; + const fundedHeight = (fundedAmount / maximum) * plotHeight; + const overageHeight = (overageAmount / maximum) * plotHeight; + const group = createSvgElement("g", { + class: "consumption-bar-group", + tabindex: "0", + role: "img", + "aria-label": createConsumptionPointAriaLabel(point, period) + }); + + ["pointerenter", "pointermove", "focus"].forEach((eventName) => { + group.addEventListener(eventName, (event) => { + showConsumptionTooltip(tooltip, point, period, event); + }); + }); + ["pointerleave", "blur"].forEach((eventName) => { + group.addEventListener(eventName, () => { + tooltip.hidden = true; + }); + }); + + if (fundedHeight > 0) { + const fundedBar = createSvgElement("rect", { + x, + y: plot.bottom - fundedHeight, + width: barWidth, + height: fundedHeight, + class: "consumption-bar-funded" + }); + appendConsumptionBarAnimation( + fundedBar, + plot.bottom, + plot.bottom - fundedHeight, + fundedHeight, + index * 34 + ); + group.append(fundedBar); + } + + if (overageHeight > 0) { + const overageTop = plot.bottom - fundedHeight - overageHeight; + const overageBar = createSvgElement("rect", { + x, + y: overageTop, + width: barWidth, + height: overageHeight, + class: "consumption-bar-overage" + }); + appendConsumptionBarAnimation( + overageBar, + plot.bottom - fundedHeight, + overageTop, + overageHeight, + (index * 34) + 90 + ); + group.append(overageBar); + } + + const top = plot.bottom - fundedHeight - overageHeight; + + if (visibleTotal > 0) { + const valueLabel = createSvgText( + x + (barWidth / 2), + Math.max(plot.top + 11, top - 7), + formatCompactConsumptionNumber(visibleTotal), + "consumption-value-label consumption-animated-label", + "middle" + ); + valueLabel.style.animationDelay = `${(index * 34) + 360}ms`; + group.append(valueLabel); + } + + const label = createSvgText( + x + (barWidth / 2), + plot.bottom + 18, + formatConsumptionPointLabel(point, period), + "consumption-x-label", + period === "monthly" || period === "quarterly" ? "middle" : "end" + ); + + if (period === "weekly" || period === "daily") { + label.setAttribute("transform", `rotate(-65 ${x + (barWidth / 2)} ${plot.bottom + 18})`); + } + + group.append(label); + svg.append(group); + }); + + if (activeSeries.has("average")) { + const averageY = plot.bottom - ((average / maximum) * plotHeight); + const averageLine = createSvgElement("line", { + x1: plot.left, + y1: averageY, + x2: plot.right, + y2: averageY, + class: "consumption-average-line consumption-animated-average" + }); + svg.append(averageLine); + } + + return svg; + } + + function appendConsumptionBarAnimation(element, fromY, toY, height, delayMs) { + if (prefersReducedMotion()) { + return; + } + + element.append( + createSvgElement("animate", { + attributeName: "y", + from: fromY, + to: toY, + dur: "0.52s", + begin: `${delayMs}ms`, + fill: "freeze", + calcMode: "spline", + keyTimes: "0;1", + keySplines: "0.2 0.8 0.2 1" + }), + createSvgElement("animate", { + attributeName: "height", + from: "0", + to: height, + dur: "0.52s", + begin: `${delayMs}ms`, + fill: "freeze", + calcMode: "spline", + keyTimes: "0;1", + keySplines: "0.2 0.8 0.2 1" + }) + ); + } + + function prefersReducedMotion() { + return typeof window.matchMedia === "function" + && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } + + function createConsumptionPointAriaLabel(point, period) { + return `${formatConsumptionPointLabel(point, period)}. ` + + `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}. ` + + `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}. ` + + `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}.`; + } + + function showConsumptionTooltip(tooltip, point, period, event) { + const figure = tooltip.parentElement; + + if (!figure) { + return; + } + + tooltip.textContent = ""; + const title = document.createElement("strong"); + title.textContent = formatConsumptionPointLabel(point, period); + const details = document.createElement("span"); + details.textContent = `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}`; + const overage = document.createElement("span"); + overage.textContent = `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}`; + const total = document.createElement("span"); + total.textContent = `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}`; + tooltip.append(title, details, overage, total); + tooltip.hidden = false; + + const figureBounds = figure.getBoundingClientRect(); + const targetBounds = event.currentTarget.getBoundingClientRect(); + const pointerX = Number.isFinite(event.clientX) && event.clientX > 0 + ? event.clientX + : targetBounds.left + (targetBounds.width / 2); + const pointerY = Number.isFinite(event.clientY) && event.clientY > 0 + ? event.clientY + : targetBounds.top; + const left = Math.min( + Math.max(8, pointerX - figureBounds.left + 12), + Math.max(8, figureBounds.width - tooltip.offsetWidth - 8) + ); + const top = Math.max(8, pointerY - figureBounds.top - tooltip.offsetHeight - 10); + tooltip.style.left = `${left}px`; + tooltip.style.top = `${top}px`; + } + + function createSvgElement(name, attributes = {}) { + const element = document.createElementNS(SVG_NAMESPACE, name); + + Object.entries(attributes).forEach(([key, value]) => { + element.setAttribute(key, String(value)); + }); + return element; + } + + function createSvgText(x, y, value, className, anchor) { + const text = createSvgElement("text", { x, y, class: className, "text-anchor": anchor }); + text.textContent = value; + return text; + } + + return { renderConsumptionTrendChart, getConsumptionTrendSeriesFilter, createConsumptionLegend, createConsumptionNavigationButton, createConsumptionSvg, appendConsumptionBarAnimation, prefersReducedMotion, createConsumptionPointAriaLabel, showConsumptionTooltip, createSvgElement, createSvgText }; +} diff --git a/src/features/consumption/model.js b/src/features/consumption/model.js new file mode 100644 index 0000000..6ea98e3 --- /dev/null +++ b/src/features/consumption/model.js @@ -0,0 +1,155 @@ +function createConsumptionModel() { + function normalizeConsumptionTrendItems(items) { + const points = new Map(); + + (Array.isArray(items) ? items : []).forEach((item) => { + const dateKey = String(item.fyDate || (Array.isArray(item.fiscalTime) ? item.fiscalTime.join("|") : "")); + + if (!dateKey) { + return; + } + + if (!points.has(dateKey)) { + points.set(dateKey, { + dateKey, + date: parseConsumptionDate(item.fyDate), + fiscalTime: Array.isArray(item.fiscalTime) ? item.fiscalTime.slice() : [], + fiscalQtr: item.fiscalQtr || "", + funded: 0, + overage: 0, + arr: 0, + currencyCode: item.currencyCode || "USD" + }); + } + + const point = points.get(dateKey); + const amount = toFiniteNumber(item.usedAmount); + const category = String(item.usageCategory || "Funded Allocation").toUpperCase(); + + if (category === "OVERAGE") { + point.overage += amount; + } else { + point.funded += amount; + } + + point.arr = Math.max(point.arr, toFiniteNumber(item.arrCd)); + }); + + return Array.from(points.values()) + .map((point) => ({ ...point, total: point.funded + point.overage })) + .sort((left, right) => left.dateKey.localeCompare(right.dateKey)); + } + + function getConsumptionPointTotal(point, activeSeries) { + const selectedSeries = activeSeries && typeof activeSeries.has === "function" + ? activeSeries + : new Set(["funded", "overage"]); + return (selectedSeries.has("funded") ? point.funded : 0) + + (selectedSeries.has("overage") ? point.overage : 0); + } + + function calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries) { + const completePoints = hasPartialLatestPoint && points.length > 1 ? points.slice(0, -1) : points; + return completePoints.reduce( + (sum, point) => sum + getConsumptionPointTotal(point, activeSeries), + 0 + ) / Math.max(completePoints.length, 1); + } + + function createNiceConsumptionMaximum(value) { + if (!Number.isFinite(value) || value <= 0) { + return 1; + } + + const magnitude = 10 ** Math.floor(Math.log10(value)); + const normalized = value / magnitude; + const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10; + return step * magnitude; + } + + function formatConsumptionPointLabel(point, period) { + if (period === "quarterly") { + return point.fiscalTime[0] || point.fiscalQtr || point.dateKey; + } + + if (period === "monthly") { + return point.fiscalTime[1] || formatConsumptionDate(point.date, { month: "short" }); + } + + return point.fiscalTime[0] || formatConsumptionDate(point.date, { + day: "2-digit", + month: "short", + year: "2-digit" + }); + } + + function formatConsumptionRange(points, period) { + if (points.length === 0) { + return "No data"; + } + + if (period === "quarterly") { + return `${formatConsumptionPointLabel(points[0], period)} - ${formatConsumptionPointLabel(points[points.length - 1], period)}`; + } + + if (period === "monthly") { + const options = { month: "short", year: "numeric" }; + return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`; + } + + const options = { day: "2-digit", month: "short" }; + return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`; + } + + function parseConsumptionDate(value) { + if (!value) { + return null; + } + + const parts = String(value).split("-").map(Number); + return parts.length === 3 && parts.every(Number.isFinite) + ? new Date(parts[0], parts[1] - 1, parts[2]) + : null; + } + + function formatConsumptionDate(date, options) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return "-"; + } + + return new Intl.DateTimeFormat("en-US", options).format(date).replace(",", ""); + } + + function formatCompactConsumptionNumber(value) { + const amount = toFiniteNumber(value); + + if (Math.abs(amount) >= 1000000) { + return `${(amount / 1000000).toFixed(amount >= 10000000 ? 0 : 1).replace(".0", "")}M`; + } + + if (Math.abs(amount) >= 1000) { + return `${Math.round(amount / 1000)}K`; + } + + return String(Math.round(amount)); + } + + function formatConsumptionCurrency(value, currencyCode) { + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currencyCode || "USD", + maximumFractionDigits: 0 + }).format(toFiniteNumber(value)); + } catch (error) { + return `${currencyCode || "USD"} ${Math.round(toFiniteNumber(value)).toLocaleString("en-US")}`; + } + } + + function toFiniteNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; + } + + return { normalizeConsumptionTrendItems, getConsumptionPointTotal, calculateAverageConsumption, createNiceConsumptionMaximum, formatConsumptionPointLabel, formatConsumptionRange, parseConsumptionDate, formatConsumptionDate, formatCompactConsumptionNumber, formatConsumptionCurrency, toFiniteNumber }; +} diff --git a/src/features/consumption/plans-action.js b/src/features/consumption/plans-action.js new file mode 100644 index 0000000..76305a2 --- /dev/null +++ b/src/features/consumption/plans-action.js @@ -0,0 +1,93 @@ +function createConsumptionPlansAction({ + messageTypes, + appendBackgroundRequestLog, + consumptionPlansState, + getNestedValue, + openAccountPlansModal, + renderOpportunitiesTable, + requestConsumerTokenServiceOnce, + sendRuntimeMessage, + showCopyConfirmation, + environment +}) { + const { document } = environment; + function createCloudConsumptionButton(opportunity) { + const button = document.createElement("button"); + const optyNumber = String(opportunity.OptyNumber || ""); + const partyNumber = getNestedValue(opportunity, ["CustomerAccount", "PartyNumber"]); + const requestState = consumptionPlansState.accountPlansRequestState.get(optyNumber); + const isLoading = requestState && requestState.status === "loading"; + + button.type = "button"; + button.className = "opportunities-extension-action-button"; + button.setAttribute("aria-label", `View cloud consumption for ${optyNumber || "opportunity"}`); + button.title = isLoading ? "Loading customer consumption" : "View customer consumption"; + button.disabled = Boolean(isLoading); + button.classList.toggle("opportunities-extension-action-button-loading", Boolean(isLoading)); + button.append(createCloudConsumptionIcon()); + button.addEventListener("click", () => { + if (!partyNumber) { + showCopyConfirmation("Customer Party Number is unavailable.", true); + return; + } + + consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "loading" }); + openAccountPlansModal(opportunity, [], "loading"); + renderOpportunitiesTable(); + requestConsumerTokenServiceOnce() + .then((authorizations) => sendRuntimeMessage({ + type: messageTypes.requestAccountPlans, + partyNumber: String(partyNumber), + authorizations + })) + .then((response) => { + appendBackgroundRequestLog(response && response.debugRequests); + + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : "Unable to load cloud consumption."); + } + + consumptionPlansState.accountPlansRequestState.set(optyNumber, { + status: "ready", + plans: Array.isArray(response.plans) ? response.plans : [] + }); + renderOpportunitiesTable(); + openAccountPlansModal(opportunity, response.plans || [], "ready"); + }).catch((error) => { + const errorMessage = error.message || "Unable to load cloud consumption."; + consumptionPlansState.accountPlansRequestState.set(optyNumber, { + status: "error", + error: errorMessage + }); + renderOpportunitiesTable(); + openAccountPlansModal(opportunity, [], "error", errorMessage); + showCopyConfirmation(errorMessage, true); + }); + }); + return button; + } + + function createCloudConsumptionIcon() { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const cloud = document.createElementNS("http://www.w3.org/2000/svg", "path"); + const usage = document.createElementNS("http://www.w3.org/2000/svg", "path"); + + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + cloud.setAttribute("d", "M7.2 18.5h10.1a4.2 4.2 0 0 0 .5-8.37A6.25 6.25 0 0 0 5.9 8.75a4.9 4.9 0 0 0 1.3 9.75Z"); + usage.setAttribute("d", "M9 15v-2m3 2v-4m3 4V9"); + + [cloud, usage].forEach((path) => { + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + path.setAttribute("stroke-width", "1.7"); + }); + svg.append(cloud, usage); + return svg; + } + + return { createCloudConsumptionButton, createCloudConsumptionIcon }; +} diff --git a/src/features/consumption/plans-state.js b/src/features/consumption/plans-state.js new file mode 100644 index 0000000..2ae2304 --- /dev/null +++ b/src/features/consumption/plans-state.js @@ -0,0 +1,5 @@ +function createConsumptionPlansState() { + return { + accountPlansRequestState: new Map() + }; +} diff --git a/src/features/consumption/plans-view.js b/src/features/consumption/plans-view.js new file mode 100644 index 0000000..3b7cce9 --- /dev/null +++ b/src/features/consumption/plans-view.js @@ -0,0 +1,358 @@ +function createConsumptionPlansView({ + ACCOUNT_PLANS_MODAL_ID, + DEFAULT_CONSUMPTION_TREND_PERIOD, + MODAL_ID, + createCloseIcon, + createConsumptionTrendSection, + createSvgElement, + formatOracleResponseDate, + getNestedValue, + loadConsumptionTrendForPanel, + environment +}) { + const { document, requestAnimationFrame, setTimeout, window } = environment; + function openAccountPlansModal(opportunity, plans, state = "ready", errorMessage = "") { + const overlay = document.getElementById(MODAL_ID); + const optyNumber = String(opportunity.OptyNumber || ""); + const existingModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); + + if (!overlay) { + return; + } + + if (state !== "loading" && ( + !existingModal + || existingModal.hidden + || existingModal.dataset.optyNumber !== optyNumber + )) { + return; + } + + existingModal?.remove(); + + const modal = document.createElement("section"); + modal.id = ACCOUNT_PLANS_MODAL_ID; + modal.className = "opportunities-extension-account-plans-modal"; + modal.dataset.optyNumber = optyNumber; + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + modal.setAttribute("aria-labelledby", "opportunities-extension-account-plans-title"); + modal.setAttribute("aria-busy", String(state === "loading")); + + const dialog = document.createElement("div"); + dialog.className = "opportunities-extension-account-plans-dialog"; + + const header = document.createElement("header"); + header.className = "opportunities-extension-account-plans-header"; + + const heading = document.createElement("div"); + const title = document.createElement("h2"); + title.id = "opportunities-extension-account-plans-title"; + title.textContent = "Cloud Consumption"; + + const subtitle = document.createElement("p"); + subtitle.textContent = getNestedValue(opportunity, ["CustomerAccount", "PartyUniqueName"]) || opportunity.Name || opportunity.OptyNumber || "Opportunity"; + heading.append(title, subtitle); + + const closeButton = document.createElement("button"); + closeButton.type = "button"; + closeButton.className = "opportunities-extension-icon-button"; + closeButton.setAttribute("aria-label", "Close cloud consumption"); + closeButton.title = "Close"; + closeButton.append(createCloseIcon()); + closeButton.addEventListener("click", closeAccountPlansModal); + header.append(heading, closeButton); + + const content = document.createElement("div"); + content.className = "opportunities-extension-account-plans-content"; + + if (state === "loading") { + content.append(createAccountPlansSkeleton()); + } else if (state === "error") { + const error = document.createElement("p"); + error.className = "opportunities-extension-account-plans-error"; + error.setAttribute("role", "alert"); + error.textContent = errorMessage || "Unable to load cloud consumption."; + content.append(error); + } else if (!Array.isArray(plans) || plans.length === 0) { + const empty = document.createElement("p"); + empty.className = "opportunities-extension-account-plans-empty"; + empty.textContent = "No account plans found for this customer."; + content.append(empty); + } else { + const tabList = document.createElement("div"); + tabList.className = "opportunities-extension-account-plans-tabs"; + tabList.setAttribute("role", "tablist"); + tabList.setAttribute("aria-label", "Account plans"); + + const tabsNavigation = createAccountPlansTabsNavigation(tabList); + + const panels = document.createElement("div"); + panels.className = "opportunities-extension-account-plans-panels"; + + plans.forEach((plan, index) => { + const tabId = `opportunities-extension-account-plan-tab-${index}`; + const panelId = `opportunities-extension-account-plan-panel-${index}`; + const tab = document.createElement("button"); + tab.id = tabId; + tab.type = "button"; + tab.className = "opportunities-extension-account-plan-tab"; + tab.setAttribute("role", "tab"); + tab.setAttribute("aria-controls", panelId); + tab.setAttribute("aria-selected", String(index === 0)); + tab.tabIndex = index === 0 ? 0 : -1; + const planLabel = [plan.planType, plan.subPlanNum] + .filter((value) => value !== null && value !== undefined && value !== "") + .join(" ") || `Plan ${index + 1}`; + const headerStatus = plan.hdrStatusCode === null || plan.hdrStatusCode === undefined || plan.hdrStatusCode === "" + ? "Unknown" + : String(plan.hdrStatusCode); + tab.setAttribute("aria-label", `${planLabel}. Header Status: ${headerStatus}`); + tab.append(createAccountPlanStatusIndicator(headerStatus), document.createTextNode(planLabel)); + + const panel = document.createElement("section"); + panel.id = panelId; + panel.className = "opportunities-extension-account-plan-panel"; + panel.setAttribute("role", "tabpanel"); + panel.setAttribute("aria-labelledby", tabId); + panel.hidden = index !== 0; + panel.append(createAccountPlanDetails(plan), createConsumptionTrendSection(plan)); + + tab.addEventListener("click", () => { + tabList.querySelectorAll("[role='tab']").forEach((candidate) => { + const isSelected = candidate === tab; + candidate.setAttribute("aria-selected", String(isSelected)); + candidate.tabIndex = isSelected ? 0 : -1; + }); + panels.querySelectorAll("[role='tabpanel']").forEach((candidate) => { + candidate.hidden = candidate !== panel; + }); + const trendSection = panel.querySelector(".opportunities-extension-consumption-trend"); + loadConsumptionTrendForPanel( + panel, + plan, + trendSection ? trendSection.dataset.period : DEFAULT_CONSUMPTION_TREND_PERIOD + ); + }); + tabList.append(tab); + panels.append(panel); + }); + + content.append(tabsNavigation, panels); + } + + dialog.append(header, content); + modal.append(dialog); + overlay.append(modal); + modal.querySelector("[role='tab'], .opportunities-extension-icon-button")?.focus(); + + if (state === "ready" && Array.isArray(plans) && plans.length > 0) { + const firstPanel = modal.querySelector("[role='tabpanel']"); + + if (firstPanel) { + loadConsumptionTrendForPanel(firstPanel, plans[0], DEFAULT_CONSUMPTION_TREND_PERIOD); + } + } + } + + function createAccountPlansTabsNavigation(tabList) { + const navigation = document.createElement("div"); + navigation.className = "opportunities-extension-account-plans-tabs-navigation"; + + const previousButton = createAccountPlansTabNavigationButton("previous"); + const nextButton = createAccountPlansTabNavigationButton("next"); + const updateNavigation = () => { + const maximumScroll = Math.max(0, tabList.scrollWidth - tabList.clientWidth); + const tabs = Array.from(tabList.querySelectorAll("[role='tab']")); + const viewportStart = tabList.scrollLeft; + const viewportEnd = viewportStart + tabList.clientWidth; + const hasContentBefore = tabList.scrollLeft > 1 + || tabs.some((tab) => tab.offsetLeft < viewportStart - 1); + const hasContentAfter = tabList.scrollLeft < maximumScroll - 1 + || tabs.some((tab) => (tab.offsetLeft + tab.offsetWidth) > viewportEnd + 1); + previousButton.hidden = !hasContentBefore; + nextButton.hidden = !hasContentAfter; + }; + const scrollTabs = (direction) => { + tabList.scrollBy({ + left: direction * Math.max(160, Math.floor(tabList.clientWidth * .72)), + behavior: "smooth" + }); + }; + + previousButton.addEventListener("click", () => scrollTabs(-1)); + nextButton.addEventListener("click", () => scrollTabs(1)); + tabList.addEventListener("scroll", updateNavigation, { passive: true }); + const handleResize = () => { + if (!navigation.isConnected) { + window.removeEventListener("resize", handleResize); + return; + } + updateNavigation(); + }; + window.addEventListener("resize", handleResize, { passive: true }); + requestAnimationFrame(() => requestAnimationFrame(updateNavigation)); + setTimeout(updateNavigation, 0); + + navigation.append(previousButton, tabList, nextButton); + return navigation; + } + + function createAccountPlansTabNavigationButton(direction) { + const isPrevious = direction === "previous"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "opportunities-extension-account-plans-tab-navigation-button"; + button.setAttribute("aria-label", isPrevious ? "Show previous account plans" : "Show next account plans"); + button.title = isPrevious ? "Previous plans" : "Next plans"; + + const icon = createSvgElement("svg", { + viewBox: "0 0 24 24", + "aria-hidden": "true", + focusable: "false" + }); + icon.append(createSvgElement("path", { + d: isPrevious ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7", + fill: "none", + stroke: "currentColor", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "stroke-width": "2" + })); + button.append(icon); + return button; + } + + function createAccountPlansSkeleton() { + const skeleton = document.createElement("div"); + skeleton.className = "opportunities-extension-account-plans-skeleton"; + skeleton.setAttribute("role", "status"); + + const announcement = document.createElement("span"); + announcement.className = "opportunities-extension-visually-hidden"; + announcement.textContent = "Loading cloud consumption"; + + const tabs = document.createElement("div"); + tabs.className = "opportunities-extension-account-plans-skeleton-tabs"; + const tab = document.createElement("span"); + tab.className = "opportunities-extension-account-plans-skeleton-tab"; + const status = document.createElement("span"); + status.className = "opportunities-extension-account-plans-skeleton-status"; + const tabLabel = document.createElement("span"); + tabLabel.className = "opportunities-extension-account-plans-skeleton-bar"; + tabLabel.style.width = "104px"; + tab.append(status, tabLabel); + tabs.append(tab); + + const details = document.createElement("div"); + details.className = "opportunities-extension-account-plans-skeleton-details"; + [72, 88, 64, 78, 58, 68].forEach((width) => { + const row = document.createElement("div"); + const label = document.createElement("span"); + const value = document.createElement("span"); + label.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-account-plans-skeleton-label"; + value.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-account-plans-skeleton-value"; + value.style.width = `${width}%`; + row.append(label, value); + details.append(row); + }); + + const trend = document.createElement("div"); + trend.className = "opportunities-extension-account-plans-skeleton-trend"; + + const trendHeader = document.createElement("div"); + trendHeader.className = "opportunities-extension-account-plans-skeleton-trend-header"; + const periods = document.createElement("div"); + periods.className = "opportunities-extension-account-plans-skeleton-periods"; + [58, 52, 56, 38].forEach((width, index) => { + const period = document.createElement("span"); + period.className = "opportunities-extension-account-plans-skeleton-bar"; + period.style.width = `${width}px`; + if (index === 2) period.classList.add("opportunities-extension-account-plans-skeleton-period-active"); + periods.append(period); + }); + trendHeader.append(periods); + + const navigation = document.createElement("div"); + navigation.className = "opportunities-extension-account-plans-skeleton-navigation"; + [32, 124, 32].forEach((width) => { + const item = document.createElement("span"); + item.className = "opportunities-extension-account-plans-skeleton-bar"; + item.style.width = `${width}px`; + navigation.append(item); + }); + + const chart = document.createElement("div"); + chart.className = "opportunities-extension-account-plans-skeleton-chart"; + const plot = document.createElement("div"); + plot.className = "opportunities-extension-account-plans-skeleton-plot"; + [34, 42, 38, 48, 44, 58, 64, 55, 76, 68, 86, 62].forEach((height) => { + const bar = document.createElement("span"); + bar.className = "opportunities-extension-account-plans-skeleton-bar"; + bar.style.height = `${height}%`; + plot.append(bar); + }); + const legend = document.createElement("div"); + legend.className = "opportunities-extension-account-plans-skeleton-legend"; + [112, 68, 132].forEach((width) => { + const item = document.createElement("span"); + item.className = "opportunities-extension-account-plans-skeleton-bar"; + item.style.width = `${width}px`; + legend.append(item); + }); + chart.append(plot, legend); + trend.append(trendHeader, navigation, chart); + + const panel = document.createElement("div"); + panel.className = "opportunities-extension-account-plan-panel opportunities-extension-account-plans-skeleton-panel"; + panel.append(details, trend); + + skeleton.append(announcement, tabs, panel); + return skeleton; + } + + function createAccountPlanStatusIndicator(statusValue) { + const indicator = document.createElement("span"); + const isActive = String(statusValue).toUpperCase() === "ACTIVE"; + + indicator.className = "opportunities-extension-account-plan-status-indicator"; + indicator.dataset.active = String(isActive); + indicator.setAttribute("aria-hidden", "true"); + indicator.title = `Header Status: ${statusValue}`; + return indicator; + } + + function createAccountPlanDetails(plan) { + const details = document.createElement("dl"); + details.className = "opportunities-extension-account-plan-details"; + const fields = [ + { label: "Booking Customer", value: plan.bookingCustomer }, + { label: "Sold To Party Name", value: plan.soldToPartyName }, + { label: "Contract ID", value: plan.contractId }, + { label: "Header Status", value: plan.hdrStatusCode }, + { label: "Start Date", value: formatOracleResponseDate(plan.startDate) }, + { label: "End Date", value: formatOracleResponseDate(plan.endDate) } + ]; + + fields.forEach((field) => { + const group = document.createElement("div"); + const term = document.createElement("dt"); + const description = document.createElement("dd"); + term.textContent = field.label; + description.textContent = field.value === null || field.value === undefined || field.value === "" ? "-" : String(field.value); + group.append(term, description); + details.append(group); + }); + return details; + } + + function closeAccountPlansModal() { + const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID); + + if (modal) { + modal.hidden = true; + } + } + + return { openAccountPlansModal, createAccountPlansTabsNavigation, createAccountPlansTabNavigationButton, createAccountPlansSkeleton, createAccountPlanStatusIndicator, createAccountPlanDetails, closeAccountPlansModal }; +} diff --git a/src/features/consumption/trend-controller.js b/src/features/consumption/trend-controller.js new file mode 100644 index 0000000..8551a98 --- /dev/null +++ b/src/features/consumption/trend-controller.js @@ -0,0 +1,171 @@ +function createConsumptionTrendController({ + messageTypes, + CONSUMPTION_TREND_PERIODS, + DEFAULT_CONSUMPTION_TREND_PERIOD, + appendBackgroundRequestLog, + consumptionTrendState, + renderConsumptionTrendChart, + requestConsumerTokenServiceOnce, + sendRuntimeMessage, + environment +}) { + const { document } = environment; + function createConsumptionTrendSection(plan) { + const section = document.createElement("section"); + section.className = "opportunities-extension-consumption-trend"; + section.dataset.subPlanNum = String(plan.subPlanNum || ""); + section.dataset.period = DEFAULT_CONSUMPTION_TREND_PERIOD; + + const header = document.createElement("header"); + header.className = "opportunities-extension-consumption-trend-header"; + + const periodControls = document.createElement("div"); + periodControls.className = "opportunities-extension-consumption-periods"; + periodControls.setAttribute("role", "group"); + periodControls.setAttribute("aria-label", "Consumption period"); + + CONSUMPTION_TREND_PERIODS.forEach((periodOption) => { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.period = periodOption.value; + button.setAttribute("aria-pressed", String(periodOption.value === DEFAULT_CONSUMPTION_TREND_PERIOD)); + button.textContent = periodOption.label; + button.addEventListener("click", () => { + section.dataset.period = periodOption.value; + consumptionTrendState.consumptionTrendWindowOffsets.set( + createConsumptionTrendKey(section.dataset.subPlanNum, periodOption.value), + 0 + ); + loadConsumptionTrendForPanel(section.closest("[role='tabpanel']"), plan, periodOption.value); + }); + periodControls.append(button); + }); + + const chart = document.createElement("div"); + chart.className = "opportunities-extension-consumption-chart"; + chart.setAttribute("aria-live", "polite"); + header.append(periodControls); + section.append(header, chart); + return section; + } + + function loadConsumptionTrendForPanel(panel, plan, period) { + if (!panel) { + return; + } + + const section = panel.querySelector(".opportunities-extension-consumption-trend"); + + if (!section) { + return; + } + + const subPlanNum = String(plan.subPlanNum || "").trim(); + const selectedPeriod = CONSUMPTION_TREND_PERIODS.some((item) => item.value === period) + ? period + : DEFAULT_CONSUMPTION_TREND_PERIOD; + const key = createConsumptionTrendKey(subPlanNum, selectedPeriod); + const chart = section.querySelector(".opportunities-extension-consumption-chart"); + section.dataset.period = selectedPeriod; + chart.dataset.requestKey = key; + updateConsumptionPeriodButtons(section, selectedPeriod); + + if (!subPlanNum) { + renderConsumptionTrendError(chart, "Sub plan number is unavailable."); + return; + } + + const cached = consumptionTrendState.consumptionTrendCache.get(key); + + renderConsumptionTrendSkeleton(chart); + + if (cached && cached.status === "loading") { + cached.promise + .then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key)) + .catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key)); + return; + } + + const request = requestConsumerTokenServiceOnce() + .then((authorizations) => sendRuntimeMessage({ + type: messageTypes.requestConsumptionTrend, + subPlanNum, + period: selectedPeriod, + authorization: authorizations && authorizations.accountPlans + })) + .then((response) => { + appendBackgroundRequestLog(response && response.debugRequests); + + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : "Unable to load consumption trend."); + } + + return Array.isArray(response.items) ? response.items : []; + }) + .finally(() => { + if (consumptionTrendState.consumptionTrendCache.get(key)?.promise === request) { + consumptionTrendState.consumptionTrendCache.delete(key); + } + }); + + consumptionTrendState.consumptionTrendCache.set(key, { status: "loading", promise: request }); + request + .then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key)) + .catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key)); + } + + function createConsumptionTrendKey(subPlanNum, period) { + return `${subPlanNum}:${period}`; + } + + function updateConsumptionPeriodButtons(section, period) { + section.querySelectorAll(".opportunities-extension-consumption-periods button").forEach((button) => { + button.setAttribute("aria-pressed", String(button.dataset.period === period)); + }); + } + + function renderConsumptionTrendIfCurrent(section, chart, items, period, subPlanNum, key) { + if (chart.dataset.requestKey === key) { + renderConsumptionTrendChart(section, chart, items, period, subPlanNum); + } + } + + function renderConsumptionTrendErrorIfCurrent(chart, errorMessage, key) { + if (chart.dataset.requestKey === key) { + renderConsumptionTrendError(chart, errorMessage || "Unable to load consumption trend."); + } + } + + function renderConsumptionTrendSkeleton(chart) { + chart.textContent = ""; + chart.setAttribute("aria-busy", "true"); + const skeleton = document.createElement("div"); + skeleton.className = "opportunities-extension-consumption-chart-skeleton"; + const announcement = document.createElement("span"); + announcement.className = "opportunities-extension-visually-hidden"; + announcement.textContent = "Loading consumption trend"; + const plot = document.createElement("div"); + plot.className = "opportunities-extension-consumption-chart-skeleton-plot"; + + for (let index = 0; index < 13; index += 1) { + const bar = document.createElement("span"); + bar.style.height = `${28 + ((index * 17) % 58)}%`; + plot.append(bar); + } + + skeleton.append(announcement, plot); + chart.append(skeleton); + } + + function renderConsumptionTrendError(chart, errorMessage) { + chart.textContent = ""; + chart.setAttribute("aria-busy", "false"); + const error = document.createElement("p"); + error.className = "opportunities-extension-consumption-chart-error"; + error.setAttribute("role", "alert"); + error.textContent = errorMessage; + chart.append(error); + } + + return { createConsumptionTrendSection, loadConsumptionTrendForPanel, createConsumptionTrendKey, updateConsumptionPeriodButtons, renderConsumptionTrendIfCurrent, renderConsumptionTrendErrorIfCurrent, renderConsumptionTrendSkeleton, renderConsumptionTrendError }; +} diff --git a/src/features/consumption/trend-state.js b/src/features/consumption/trend-state.js new file mode 100644 index 0000000..05b467c --- /dev/null +++ b/src/features/consumption/trend-state.js @@ -0,0 +1,7 @@ +function createConsumptionTrendState() { + return { + consumptionTrendCache: new Map(), + consumptionTrendWindowOffsets: new Map(), + consumptionTrendSeriesFilters: new Map() + }; +} diff --git a/src/features/debug/content-log.js b/src/features/debug/content-log.js new file mode 100644 index 0000000..de6e75d --- /dev/null +++ b/src/features/debug/content-log.js @@ -0,0 +1,198 @@ +function createDebugContentLog({ + DEBUG_BODY_LIMIT, + debugState, + renderDebugPanel +}) { + function logRequestStart(details) { + const entry = { + id: debugState.requestLog.length + 1, + name: details.name, + method: details.method, + url: details.url, + headers: details.headers, + status: "pending", + startedAt: new Date(), + completedAt: null, + durationMs: null, + httpStatus: null, + statusText: "", + requestHeaders: details.headers, + requestBody: details.body || "", + metadata: details.metadata || {}, + responseHeaders: {}, + responseBody: "", + responseBodyTruncated: false, + error: "" + }; + + debugState.requestLog.push(entry); + renderDebugPanel(); + return entry; + } + + function logRequestSuccess(entry, response, responseBody) { + entry.status = response.ok ? "success" : "failed"; + entry.completedAt = new Date(); + entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime(); + entry.httpStatus = response.status; + entry.statusText = response.statusText || ""; + entry.responseHeaders = headersToObject(response.headers); + entry.responseBody = responseBody.value; + entry.responseBodyTruncated = responseBody.truncated; + renderDebugPanel(); + } + + function logRequestFailure(entry, errorMessage) { + entry.status = "failed"; + entry.completedAt = new Date(); + entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime(); + entry.error = errorMessage; + renderDebugPanel(); + } + + function appendBackgroundRequestLog(entries) { + if (!Array.isArray(entries) || entries.length === 0) { + return; + } + + entries.forEach((entry) => { + debugState.requestLog.push({ + id: debugState.requestLog.length + 1, + name: entry.name || "backgroundRequest", + method: entry.method || "GET", + url: entry.url || "", + headers: entry.requestHeaders || {}, + status: entry.status || "failed", + startedAt: new Date(entry.startedAt), + completedAt: entry.completedAt ? new Date(entry.completedAt) : null, + durationMs: entry.durationMs, + httpStatus: entry.httpStatus, + statusText: entry.statusText || "", + requestHeaders: entry.requestHeaders || {}, + requestBody: entry.requestBody || "", + metadata: entry.metadata || { executionContext: "background" }, + responseHeaders: entry.responseHeaders || {}, + responseBody: entry.responseBody || "", + responseBodyTruncated: Boolean(entry.responseBodyTruncated), + error: entry.error || "" + }); + }); + renderDebugPanel(); + } + + async function readResponseBodyForDebug(response) { + try { + const text = await response.clone().text(); + return createDebugBody(text); + } catch (error) { + return { + value: `Unable to read response body: ${error.message || "unknown error"}`, + truncated: false + }; + } + } + + function createDebugBody(text) { + if (text.length > DEBUG_BODY_LIMIT) { + return { + value: `${text.slice(0, DEBUG_BODY_LIMIT)}\n... truncated ${text.length - DEBUG_BODY_LIMIT} characters`, + truncated: true + }; + } + + return { + value: text || "(empty response body)", + truncated: false + }; + } + + function headersToObject(headers) { + const headerMap = {}; + + headers.forEach((value, key) => { + headerMap[key] = value; + }); + + return headerMap; + } + + function formatHeaderBlock(headers) { + const entries = Object.entries(headers); + + if (entries.length === 0) { + return "(none)"; + } + + return entries + .map(([key, value]) => `${key}: ${debugState.debugSecretsVisible || !isSensitiveDebugKey(key) + ? value + : formatHiddenDebugValue(value)}`) + .join("\n"); + } + + function formatDebugBody(body) { + const text = body || "(none)"; + + if (debugState.debugSecretsVisible) { + return text; + } + + try { + return JSON.stringify(maskSensitiveDebugValue(JSON.parse(text)), null, 2); + } catch (error) { + return text; + } + } + + function maskSensitiveDebugValue(value, parentKey = "") { + if (Array.isArray(value)) { + return value.map((item) => maskSensitiveDebugValue(item, parentKey)); + } + + if (!value || typeof value !== "object") { + return value; + } + + return Object.fromEntries(Object.entries(value).map(([key, child]) => { + const isSensitive = isSensitiveDebugKey(key) + || (key === "value" && /uiaasHeader|uiassHeader/i.test(parentKey)); + return [key, isSensitive + ? formatHiddenDebugValue(child) + : maskSensitiveDebugValue(child, key)]; + })); + } + + function isSensitiveDebugKey(key) { + return /authorization|cookie|password|secret|(^|[_-])token($|[_-])|token$/i.test(key); + } + + function formatHiddenDebugValue(value) { + if (value === null || value === undefined || value === "") { + return "(missing)"; + } + + return `(present, ${String(value).length} characters)`; + } + + function formatMetadataBlock(metadata) { + const entries = Object.entries(metadata); + + if (entries.length === 0) { + return "(none)"; + } + + return entries + .map(([key, value]) => `${key}: ${value || "(none)"}`) + .join("\n"); + } + + function maskToken(token) { + if (token.length <= 10) { + return "(present)"; + } + + return `${token.slice(0, 4)}...${token.slice(-4)}`; + } + + return { logRequestStart, logRequestSuccess, logRequestFailure, appendBackgroundRequestLog, readResponseBodyForDebug, createDebugBody, headersToObject, formatHeaderBlock, formatDebugBody, maskSensitiveDebugValue, isSensitiveDebugKey, formatHiddenDebugValue, formatMetadataBlock, maskToken }; +} diff --git a/src/features/debug/content-view.js b/src/features/debug/content-view.js new file mode 100644 index 0000000..70f8345 --- /dev/null +++ b/src/features/debug/content-view.js @@ -0,0 +1,174 @@ +function createDebugContentView({ + DEBUG_PANEL_ID, + MODAL_ID, + debugState, + formatDebugBody, + formatHeaderBlock, + formatMetadataBlock, + environment +}) { + const { document } = environment; + function toggleDebugPanel() { + const modal = document.getElementById(MODAL_ID); + + if (!modal || modal.hidden) { + return; + } + + const panel = document.getElementById(DEBUG_PANEL_ID); + + if (!panel) { + return; + } + + panel.hidden = !panel.hidden; + renderDebugPanel(); + } + + function renderDebugPanel() { + const panel = document.getElementById(DEBUG_PANEL_ID); + + if (!panel || panel.hidden) { + return; + } + + panel.textContent = ""; + + const panelHeader = document.createElement("header"); + panelHeader.className = "opportunities-extension-debug-panel-header"; + + const heading = document.createElement("div"); + const title = document.createElement("h2"); + title.textContent = "Debug Requests"; + + const summary = document.createElement("p"); + summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`; + heading.append(title, summary); + + const visibilityButton = document.createElement("button"); + visibilityButton.type = "button"; + visibilityButton.className = "opportunities-extension-icon-button opportunities-extension-debug-visibility-button"; + visibilityButton.setAttribute("aria-pressed", String(debugState.debugSecretsVisible)); + visibilityButton.setAttribute("aria-label", debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"); + visibilityButton.title = debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"; + visibilityButton.append(createDebugVisibilityIcon(debugState.debugSecretsVisible)); + visibilityButton.addEventListener("click", () => { + debugState.debugSecretsVisible = !debugState.debugSecretsVisible; + renderDebugPanel(); + }); + panelHeader.append(heading, visibilityButton); + + const list = document.createElement("div"); + list.className = "opportunities-extension-debug-list"; + + if (debugState.requestLog.length === 0) { + const empty = document.createElement("div"); + empty.className = "opportunities-extension-debug-empty"; + empty.textContent = "No requests recorded yet."; + list.append(empty); + } + + debugState.requestLog.forEach((entry) => { + const item = document.createElement("details"); + item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`; + + const heading = document.createElement("summary"); + heading.className = "opportunities-extension-debug-heading"; + + const name = document.createElement("strong"); + name.textContent = `${entry.id}. ${entry.name}`; + + const httpStatus = document.createElement("span"); + httpStatus.className = "opportunities-extension-debug-http-status"; + httpStatus.textContent = entry.httpStatus ? String(entry.httpStatus) : "pending"; + + const status = document.createElement("span"); + status.className = "opportunities-extension-debug-status"; + status.textContent = entry.status; + + const summaryMeta = document.createElement("span"); + summaryMeta.className = "opportunities-extension-debug-summary-meta"; + summaryMeta.append(httpStatus, status); + + heading.append(name, summaryMeta); + + const lines = [ + `${entry.method} ${entry.url}`, + `Started: ${formatDebugTime(entry.startedAt)}`, + entry.completedAt ? `Completed: ${formatDebugTime(entry.completedAt)} (${entry.durationMs}ms)` : "Completed: pending", + entry.httpStatus ? `HTTP: ${entry.httpStatus} ${entry.statusText}`.trim() : "", + entry.error ? `Error: ${entry.error}` : "", + `Response body truncated: ${entry.responseBodyTruncated ? "yes" : "no"}` + ].filter(Boolean); + + const details = document.createElement("div"); + details.className = "opportunities-extension-debug-details"; + appendDebugBlock(details, "Request", lines.join("\n")); + appendDebugBlock(details, "Request Metadata", formatMetadataBlock(entry.metadata)); + appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders)); + appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody)); + appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders)); + appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody)); + + item.append(heading, details); + list.append(item); + }); + + panel.append(panelHeader, list); + } + + function createDebugVisibilityIcon(secretsVisible) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const eye = document.createElementNS("http://www.w3.org/2000/svg", "path"); + const pupil = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + eye.setAttribute("d", "M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12Z"); + pupil.setAttribute("cx", "12"); + pupil.setAttribute("cy", "12"); + pupil.setAttribute("r", "2.75"); + [eye, pupil].forEach((shape) => { + shape.setAttribute("fill", "none"); + shape.setAttribute("stroke", "currentColor"); + shape.setAttribute("stroke-linecap", "round"); + shape.setAttribute("stroke-linejoin", "round"); + shape.setAttribute("stroke-width", "1.7"); + }); + svg.append(eye, pupil); + + if (secretsVisible) { + const slash = document.createElementNS("http://www.w3.org/2000/svg", "path"); + slash.setAttribute("d", "M4 4l16 16"); + slash.setAttribute("fill", "none"); + slash.setAttribute("stroke", "currentColor"); + slash.setAttribute("stroke-linecap", "round"); + slash.setAttribute("stroke-width", "1.9"); + svg.append(slash); + } + + return svg; + } + + function appendDebugBlock(container, label, value) { + const block = document.createElement("section"); + const heading = document.createElement("h3"); + const content = document.createElement("pre"); + + heading.textContent = label; + content.textContent = value; + block.append(heading, content); + container.append(block); + } + + function formatDebugTime(date) { + return date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + } + + return { toggleDebugPanel, renderDebugPanel, createDebugVisibilityIcon, appendDebugBlock, formatDebugTime }; +} diff --git a/src/features/debug/state.js b/src/features/debug/state.js new file mode 100644 index 0000000..50ed41b --- /dev/null +++ b/src/features/debug/state.js @@ -0,0 +1,6 @@ +function createDebugState() { + return { + requestLog: [], + debugSecretsVisible: false + }; +} diff --git a/src/features/opportunities/columns.js b/src/features/opportunities/columns.js new file mode 100644 index 0000000..77ebdd6 --- /dev/null +++ b/src/features/opportunities/columns.js @@ -0,0 +1,15 @@ +function createOpportunitiesColumns({ formatOracleResponseDate, formatRevenue, formatWinProbability, getNestedValue, getOwnerName }) { + return [ + { key: "name", label: "Name", value: (item) => item.Name, display: (item) => item.Name }, + { key: "optyNumber", label: "Opty Number", value: (item) => item.OptyNumber, display: (item) => item.OptyNumber }, + { key: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]) }, + { key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName }, + { key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue }, + { key: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) }, + { key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode }, + { key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability }, + { key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c }, + { key: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }, + { key: "actions", label: "Actions", value: () => "", display: () => "", sortable: false } + ]; +} diff --git a/src/features/opportunities/controller.js b/src/features/opportunities/controller.js new file mode 100644 index 0000000..5e88a9a --- /dev/null +++ b/src/features/opportunities/controller.js @@ -0,0 +1,146 @@ +function createOpportunitiesController({ + STAGE_OPTIONS, + STATUS_OPTIONS, + authSessionState, + consumptionPlansState, + consumptionTrendState, + getFiscalPeriodRange, + opportunitiesDashboardState, + opportunitiesFilterState, + opportunitiesQueryState, + opportunitiesTableState, + preferencesState, + productsContentState, + renderProductsProgress, + requestOpportunitiesPage, + requestOpptyProductsInBackground, + requestTokenRelayOnce, + setOpportunitiesTableState +}) { + async function requestOpportunitiesForPeriod(period) { + const dateRange = getFiscalPeriodRange(period, new Date()); + const requestVersion = ++opportunitiesQueryState.periodRequestVersion; + + if (preferencesState.selectedOpportunityTypeValues.size === 0) { + setOpportunitiesTableState({ + items: [], + status: "empty", + message: "Select at least one Opportunity Type View in Preferences." + }); + return; + } + + if (!dateRange) { + setOpportunitiesTableState({ + items: [], + status: "empty", + message: "No opportunities to display for this period." + }); + return; + } + + try { + const token = authSessionState.accessToken || await requestTokenRelayOnce(); + + if (requestVersion !== opportunitiesQueryState.periodRequestVersion) { + return; + } + + await requestOpportunities(token, period, dateRange, requestVersion); + } catch (error) { + // Authentication and request errors are recorded by their respective request handlers. + } + } + + async function requestOpportunities(token, period, dateRange, requestVersion) { + if (isCurrentPeriodRequest(requestVersion)) { + productsContentState.currentProductsRequestId = ""; + productsContentState.opptyProductsByNumber = new Map(); + productsContentState.expandedProductOptyNumbers = new Set(); + productsContentState.productsProgress = { + state: "idle", + completed: 0, + total: 0, + failed: 0, + cacheHits: 0 + }; + productsContentState.productsLastCachedAt = 0; + consumptionPlansState.accountPlansRequestState = new Map(); + consumptionTrendState.consumptionTrendCache = new Map(); + consumptionTrendState.consumptionTrendWindowOffsets = new Map(); + consumptionTrendState.consumptionTrendSeriesFilters = new Map(); + renderProductsProgress(); + setOpportunitiesTableState({ + items: [], + status: "loading", + message: "Loading opportunities..." + }); + } + + try { + const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1); + + if (allItems && isCurrentPeriodRequest(requestVersion)) { + setOpportunitiesTableState({ + items: allItems, + status: "ready", + message: "" + }); + requestOpptyProductsInBackground(allItems, token); + } + } catch (error) { + if (isCurrentPeriodRequest(requestVersion)) { + setOpportunitiesTableState({ + items: [], + status: "error", + message: "Unable to load opportunities." + }); + } + + throw error; + } + } + + function resetOpportunitiesTable() { + opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS); + opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS); + opportunitiesFilterState.selectedCustomerKeys = new Set(); + opportunitiesFilterState.customerSearch = ""; + opportunitiesFilterState.selectedOwnerKeys = new Set(); + opportunitiesFilterState.ownerSearch = ""; + opportunitiesFilterState.tableSearch = ""; + opportunitiesFilterState.hasSavedCustomerFilter = false; + opportunitiesFilterState.hasSavedOwnerFilter = false; + opportunitiesFilterState.customerFilterInitialized = false; + opportunitiesFilterState.ownerFilterInitialized = false; + productsContentState.currentProductsRequestId = ""; + productsContentState.opptyProductsByNumber = new Map(); + productsContentState.expandedProductOptyNumbers = new Set(); + productsContentState.productsProgress = { + state: "idle", + completed: 0, + total: 0, + failed: 0, + cacheHits: 0 + }; + productsContentState.productsLastCachedAt = 0; + consumptionPlansState.accountPlansRequestState = new Map(); + consumptionTrendState.consumptionTrendCache = new Map(); + consumptionTrendState.consumptionTrendWindowOffsets = new Map(); + consumptionTrendState.consumptionTrendSeriesFilters = new Map(); + opportunitiesDashboardState.stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); + opportunitiesTableState.opportunitiesTableState = { + items: [], + status: "idle", + message: "", + sortKey: "", + sortDirection: "ascending" + }; + } + + function isCurrentPeriodRequest(requestVersion) { + return requestVersion === opportunitiesQueryState.periodRequestVersion; + } + + return { requestOpportunitiesForPeriod, requestOpportunities, resetOpportunitiesTable, isCurrentPeriodRequest }; +} diff --git a/src/features/opportunities/dashboard-state.js b/src/features/opportunities/dashboard-state.js new file mode 100644 index 0000000..69b7fd1 --- /dev/null +++ b/src/features/opportunities/dashboard-state.js @@ -0,0 +1,5 @@ +function createOpportunitiesDashboardState({ STAGE_OPTIONS } = {}) { + return { + stageDashboardAmounts: new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]) + }; +} diff --git a/src/features/opportunities/dashboard.js b/src/features/opportunities/dashboard.js new file mode 100644 index 0000000..322c3ac --- /dev/null +++ b/src/features/opportunities/dashboard.js @@ -0,0 +1,119 @@ +function createOpportunitiesDashboard({ + STAGE_DASHBOARD_ID, + STAGE_OPTIONS, + getNestedValue, + opportunitiesDashboardState, + environment +}) { + const { document, requestAnimationFrame, performance } = environment; + function renderStageDashboard(items) { + const dashboard = document.getElementById(STAGE_DASHBOARD_ID); + + if (!dashboard) { + return; + } + + dashboard.textContent = ""; + + const totalCard = document.createElement("section"); + const totalLabel = document.createElement("span"); + const totalValue = document.createElement("strong"); + + totalCard.className = "opportunities-extension-stage-dashboard-card opportunities-extension-total-dashboard-card"; + totalCard.setAttribute("data-stage", "TOTAL"); + totalLabel.textContent = "Total Opportunities"; + totalValue.className = "opportunities-extension-stage-dashboard-value"; + totalValue.setAttribute("aria-label", "Total opportunities"); + animateStageDashboardValue( + totalValue, + "TOTAL", + items.length, + (amount) => Math.round(amount).toLocaleString() + ); + totalCard.append(totalLabel, totalValue); + dashboard.append(totalCard); + + STAGE_OPTIONS.forEach((stage) => { + const stageItems = items.filter((item) => item.ForecastGroup_c === stage); + const amount = stageItems.reduce((total, item) => { + const value = Number(getNestedValue(item, ["PrimaryRevenue", "RevnAmount"])); + return Number.isFinite(value) ? total + value : total; + }, 0); + const card = document.createElement("section"); + const label = document.createElement("span"); + const value = document.createElement("strong"); + + card.className = "opportunities-extension-stage-dashboard-card"; + card.setAttribute("data-stage", stage); + label.textContent = stage; + value.className = "opportunities-extension-stage-dashboard-value"; + value.setAttribute("aria-label", `${stage} total amount`); + + animateStageDashboardValue( + value, + stage, + amount, + createStageAmountFormatter(stageItems) + ); + + card.append(label, value); + dashboard.append(card); + }); + } + + function createStageAmountFormatter(items) { + const currencies = new Set(items + .map((item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"])) + .filter(Boolean)); + + if (currencies.size === 1) { + const [currency] = currencies; + + try { + const formatter = new Intl.NumberFormat(undefined, { + style: "currency", + currency, + maximumFractionDigits: 0 + }); + return (amount) => formatter.format(amount); + } catch (error) { + // Fall through to a neutral numeric total when the currency code is invalid. + } + } + + return (amount) => amount.toLocaleString(undefined, { + maximumFractionDigits: 0 + }); + } + + function animateStageDashboardValue(element, stage, target, format) { + const current = opportunitiesDashboardState.stageDashboardAmounts.get(stage) || 0; + opportunitiesDashboardState.stageDashboardAmounts.set(stage, target); + + if (current === target) { + element.textContent = format(target); + return; + } + + const startTime = performance.now(); + const duration = 480; + + function tick(now) { + const progress = Math.min((now - startTime) / duration, 1); + const easedProgress = 1 - Math.pow(1 - progress, 3); + const value = current + ((target - current) * easedProgress); + + element.textContent = format(value); + + if (progress < 1) { + requestAnimationFrame(tick); + } else { + element.textContent = format(target); + } + } + + requestAnimationFrame(tick); + } + + return { renderStageDashboard, createStageAmountFormatter, animateStageDashboardValue }; +} diff --git a/src/features/opportunities/filter-state.js b/src/features/opportunities/filter-state.js new file mode 100644 index 0000000..fe05ae5 --- /dev/null +++ b/src/features/opportunities/filter-state.js @@ -0,0 +1,15 @@ +function createOpportunitiesFilterState({ STAGE_OPTIONS, STATUS_OPTIONS } = {}) { + return { + selectedStages: new Set(STAGE_OPTIONS), + selectedStatuses: new Set(STATUS_OPTIONS), + selectedCustomerKeys: new Set(), + customerSearch: "", + selectedOwnerKeys: new Set(), + ownerSearch: "", + tableSearch: "", + hasSavedCustomerFilter: false, + hasSavedOwnerFilter: false, + customerFilterInitialized: false, + ownerFilterInitialized: false + }; +} diff --git a/src/features/opportunities/filters.js b/src/features/opportunities/filters.js new file mode 100644 index 0000000..3eba222 --- /dev/null +++ b/src/features/opportunities/filters.js @@ -0,0 +1,402 @@ +function createOpportunitiesFilters({ + CUSTOMER_FILTER_BUTTON_ID, + CUSTOMER_FILTER_LIST_ID, + CUSTOMER_FILTER_PANEL_ID, + CUSTOMER_FILTER_SEARCH_ID, + CUSTOMER_FILTER_SELECT_ALL_ID, + OPPORTUNITIES_COLUMNS, + OWNER_FILTER_BUTTON_ID, + OWNER_FILTER_LIST_ID, + OWNER_FILTER_PANEL_ID, + OWNER_FILTER_SEARCH_ID, + OWNER_FILTER_SELECT_ALL_ID, + STAGE_FILTERS_ID, + STAGE_OPTIONS, + STATUS_FILTERS_ID, + STATUS_OPTIONS, + getCustomerFilterSummary, + getCustomerKey, + getCustomerOptions, + getOwnerKey, + getOwnerOptions, + opportunitiesFilterState, + opportunitiesTableState, + renderOpportunitiesTable, + saveFilterPreferences, + stringifySearchValue, + environment +}) { + const { document } = environment; + function resetCustomerAndOwnerFiltersForPeriod() { + opportunitiesFilterState.selectedCustomerKeys = new Set(); + opportunitiesFilterState.selectedOwnerKeys = new Set(); + opportunitiesFilterState.customerSearch = ""; + opportunitiesFilterState.ownerSearch = ""; + opportunitiesFilterState.hasSavedCustomerFilter = false; + opportunitiesFilterState.hasSavedOwnerFilter = false; + opportunitiesFilterState.customerFilterInitialized = false; + opportunitiesFilterState.ownerFilterInitialized = false; + } + + function renderStageFilterButtons() { + const controls = document.getElementById(STAGE_FILTERS_ID); + + if (!controls) { + return; + } + + controls.textContent = ""; + + STAGE_OPTIONS.forEach((stage) => { + const button = document.createElement("button"); + const isPressed = opportunitiesFilterState.selectedStages.has(stage); + + button.type = "button"; + button.className = "opportunities-extension-stage-filter-button"; + button.setAttribute("data-stage", stage); + button.setAttribute("aria-pressed", isPressed ? "true" : "false"); + button.textContent = stage; + button.addEventListener("click", () => toggleStageFilter(stage)); + controls.append(button); + }); + } + + function toggleStageFilter(stage) { + if (opportunitiesFilterState.selectedStages.has(stage)) { + opportunitiesFilterState.selectedStages.delete(stage); + } else { + opportunitiesFilterState.selectedStages.add(stage); + } + + saveFilterPreferences(); + renderStageFilterButtons(); + renderOpportunitiesTable(); + } + + function renderStatusFilterButtons() { + const controls = document.getElementById(STATUS_FILTERS_ID); + + if (!controls) { + return; + } + + controls.textContent = ""; + + STATUS_OPTIONS.forEach((status) => { + const button = document.createElement("button"); + const isPressed = opportunitiesFilterState.selectedStatuses.has(status); + + button.type = "button"; + button.className = "opportunities-extension-status-filter-button"; + button.setAttribute("data-status", status); + button.setAttribute("aria-pressed", isPressed ? "true" : "false"); + button.textContent = status; + button.addEventListener("click", () => toggleStatusFilter(status)); + controls.append(button); + }); + } + + function toggleStatusFilter(status) { + if (opportunitiesFilterState.selectedStatuses.has(status)) { + opportunitiesFilterState.selectedStatuses.delete(status); + } else { + opportunitiesFilterState.selectedStatuses.add(status); + } + + saveFilterPreferences(); + renderStatusFilterButtons(); + renderOpportunitiesTable(); + } + + function toggleCustomerFilterPanel() { + const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); + const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID); + + if (!panel || !button || button.disabled) { + return; + } + + panel.hidden = !panel.hidden; + button.setAttribute("aria-expanded", panel.hidden ? "false" : "true"); + + if (!panel.hidden) { + const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID); + searchInput.focus(); + } + } + + function closeCustomerFilterPanel(restoreFocus = true) { + const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); + const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID); + + if (!panel || panel.hidden) { + return false; + } + + panel.hidden = true; + button.setAttribute("aria-expanded", "false"); + + if (restoreFocus) { + button.focus(); + } + + return true; + } + + function toggleOwnerFilterPanel() { + const panel = document.getElementById(OWNER_FILTER_PANEL_ID); + const button = document.getElementById(OWNER_FILTER_BUTTON_ID); + + if (!panel || !button || button.disabled) { + return; + } + + panel.hidden = !panel.hidden; + button.setAttribute("aria-expanded", panel.hidden ? "false" : "true"); + + if (!panel.hidden) { + const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID); + searchInput.focus(); + } + } + + function closeOwnerFilterPanel(restoreFocus = true) { + const panel = document.getElementById(OWNER_FILTER_PANEL_ID); + const button = document.getElementById(OWNER_FILTER_BUTTON_ID); + + if (!panel || panel.hidden) { + return false; + } + + panel.hidden = true; + button.setAttribute("aria-expanded", "false"); + + if (restoreFocus) { + button.focus(); + } + + return true; + } + + function resetCustomerFilter(items) { + const customerOptions = getCustomerOptions(items); + if (!opportunitiesFilterState.customerFilterInitialized) { + opportunitiesFilterState.selectedCustomerKeys = new Set(customerOptions.map((customer) => customer.key)); + opportunitiesFilterState.customerFilterInitialized = true; + } + } + + function resetOwnerFilter(items) { + const ownerOptions = getOwnerOptions(items); + if (!opportunitiesFilterState.ownerFilterInitialized) { + opportunitiesFilterState.selectedOwnerKeys = new Set(ownerOptions.map((owner) => owner.key)); + opportunitiesFilterState.ownerFilterInitialized = true; + } + } + + function renderCustomerFilter() { + const trigger = document.getElementById(CUSTOMER_FILTER_BUTTON_ID); + const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID); + const selectAllCheckbox = document.getElementById(CUSTOMER_FILTER_SELECT_ALL_ID); + const customerOptions = getCustomerOptions(); + + if (!trigger || !searchInput || !selectAllCheckbox) { + return; + } + + const selectedCount = customerOptions.filter((customer) => opportunitiesFilterState.selectedCustomerKeys.has(customer.key)).length; + trigger.disabled = customerOptions.length === 0; + trigger.textContent = getCustomerFilterSummary(customerOptions.length, selectedCount); + searchInput.value = opportunitiesFilterState.customerSearch; + selectAllCheckbox.disabled = customerOptions.length === 0; + selectAllCheckbox.checked = customerOptions.length > 0 && selectedCount === customerOptions.length; + selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < customerOptions.length; + renderCustomerFilterList(); + } + + function renderCustomerFilterList() { + const list = document.getElementById(CUSTOMER_FILTER_LIST_ID); + + if (!list) { + return; + } + + const normalizedSearch = opportunitiesFilterState.customerSearch.trim().toLocaleLowerCase(); + const customerOptions = getCustomerOptions().filter((customer) => { + return customer.label.toLocaleLowerCase().includes(normalizedSearch); + }); + + list.textContent = ""; + + if (customerOptions.length === 0) { + const empty = document.createElement("p"); + empty.className = "opportunities-extension-customer-filter-empty"; + empty.textContent = opportunitiesFilterState.customerSearch ? "No matching customers." : "No customers available."; + list.append(empty); + return; + } + + customerOptions.forEach((customer) => { + const option = document.createElement("label"); + option.className = "opportunities-extension-customer-filter-option"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key); + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + opportunitiesFilterState.selectedCustomerKeys.add(customer.key); + } else { + opportunitiesFilterState.selectedCustomerKeys.delete(customer.key); + } + + opportunitiesFilterState.hasSavedCustomerFilter = true; + saveFilterPreferences(); + renderCustomerFilter(); + renderOpportunitiesTable(); + }); + + const label = document.createElement("span"); + label.textContent = customer.label; + option.append(checkbox, label); + list.append(option); + }); + } + + function renderOwnerFilter() { + const trigger = document.getElementById(OWNER_FILTER_BUTTON_ID); + const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID); + const selectAllCheckbox = document.getElementById(OWNER_FILTER_SELECT_ALL_ID); + const ownerOptions = getOwnerOptions(); + + if (!trigger || !searchInput || !selectAllCheckbox) { + return; + } + + const selectedCount = ownerOptions.filter((owner) => opportunitiesFilterState.selectedOwnerKeys.has(owner.key)).length; + trigger.disabled = ownerOptions.length === 0; + trigger.textContent = getCustomerFilterSummary(ownerOptions.length, selectedCount) + .replace("customer", "owner"); + searchInput.value = opportunitiesFilterState.ownerSearch; + selectAllCheckbox.disabled = ownerOptions.length === 0; + selectAllCheckbox.checked = ownerOptions.length > 0 && selectedCount === ownerOptions.length; + selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < ownerOptions.length; + renderOwnerFilterList(); + } + + function renderOwnerFilterList() { + const list = document.getElementById(OWNER_FILTER_LIST_ID); + + if (!list) { + return; + } + + const normalizedSearch = opportunitiesFilterState.ownerSearch.trim().toLocaleLowerCase(); + const ownerOptions = getOwnerOptions().filter((owner) => { + return owner.label.toLocaleLowerCase().includes(normalizedSearch); + }); + + list.textContent = ""; + + if (ownerOptions.length === 0) { + const empty = document.createElement("p"); + empty.className = "opportunities-extension-customer-filter-empty"; + empty.textContent = opportunitiesFilterState.ownerSearch ? "No matching owners." : "No owners available."; + list.append(empty); + return; + } + + ownerOptions.forEach((owner) => { + const option = document.createElement("label"); + option.className = "opportunities-extension-customer-filter-option"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key); + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + opportunitiesFilterState.selectedOwnerKeys.add(owner.key); + } else { + opportunitiesFilterState.selectedOwnerKeys.delete(owner.key); + } + + opportunitiesFilterState.hasSavedOwnerFilter = true; + saveFilterPreferences(); + renderOwnerFilter(); + renderOpportunitiesTable(); + }); + + const label = document.createElement("span"); + label.textContent = owner.label; + option.append(checkbox, label); + list.append(option); + }); + } + + function getSortedOpportunities() { + const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase(); + const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => { + return opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c) + && matchesSelectedStatus(item.StatusCode) + && opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)) + && opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)) + && matchesTableSearch(item, normalizedSearch); + }); + const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey); + + if (!column) { + return items; + } + + const direction = opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending" ? 1 : -1; + + return items.sort((first, second) => { + const firstValue = column.value(first); + const secondValue = column.value(second); + const firstIsEmpty = firstValue === null || firstValue === undefined || firstValue === ""; + const secondIsEmpty = secondValue === null || secondValue === undefined || secondValue === ""; + + if (firstIsEmpty || secondIsEmpty) { + if (firstIsEmpty && secondIsEmpty) { + return 0; + } + + return firstIsEmpty ? 1 : -1; + } + + const firstNumber = Number(firstValue); + const secondNumber = Number(secondValue); + + if (Number.isFinite(firstNumber) && Number.isFinite(secondNumber)) { + return (firstNumber - secondNumber) * direction; + } + + return String(firstValue).localeCompare(String(secondValue), undefined, { + numeric: true, + sensitivity: "base" + }) * direction; + }); + } + + function matchesTableSearch(item, normalizedSearch) { + if (!normalizedSearch) { + return true; + } + + return OPPORTUNITIES_COLUMNS.some((column) => { + const rawValue = stringifySearchValue(column.value(item)); + const displayValue = stringifySearchValue(column.display(item)); + return `${rawValue} ${displayValue}`.toLocaleLowerCase().includes(normalizedSearch); + }); + } + + function matchesSelectedStatus(statusCode) { + if (statusCode === "WON_PENDING") { + return opportunitiesFilterState.selectedStatuses.has("WON"); + } + + return opportunitiesFilterState.selectedStatuses.has(statusCode); + } + + return { resetCustomerAndOwnerFiltersForPeriod, renderStageFilterButtons, toggleStageFilter, renderStatusFilterButtons, toggleStatusFilter, toggleCustomerFilterPanel, closeCustomerFilterPanel, toggleOwnerFilterPanel, closeOwnerFilterPanel, resetCustomerFilter, resetOwnerFilter, renderCustomerFilter, renderCustomerFilterList, renderOwnerFilter, renderOwnerFilterList, getSortedOpportunities, matchesTableSearch, matchesSelectedStatus }; +} diff --git a/src/features/opportunities/fiscal-periods.js b/src/features/opportunities/fiscal-periods.js new file mode 100644 index 0000000..afbdc66 --- /dev/null +++ b/src/features/opportunities/fiscal-periods.js @@ -0,0 +1,55 @@ +function createOpportunitiesFiscalPeriods() { + function getFiscalPeriodRange(period, currentDate) { + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth(); + const fiscalStartYear = currentMonth >= 5 ? currentYear : currentYear - 1; + const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3); + + if (period === "Current Fiscal Year") { + const isBeforeFiscalYearStart = currentMonth < 5; + const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear; + const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1; + return createDateRange(startYear, 5, endYear, 4); + } + + let quarterOffset; + let numberOfQuarters = 1; + + if (period === "Current Quarter") { + quarterOffset = currentQuarterIndex; + } else if (period === "Next Quarter") { + quarterOffset = currentQuarterIndex + 1; + } else if (period === "Previous Quarter") { + quarterOffset = currentQuarterIndex - 1; + } else if (period === "4 Rolling Quarters (CQ + 3)") { + quarterOffset = currentQuarterIndex; + numberOfQuarters = 4; + } else { + return null; + } + + const start = new Date(fiscalStartYear, 5 + (quarterOffset * 3), 1); + const end = new Date(start.getFullYear(), start.getMonth() + (numberOfQuarters * 3), 0); + + return { + startCloseDate: formatOracleDate(start), + endCloseDate: formatOracleDate(end) + }; + } + + function createDateRange(startYear, startMonth, endYear, endMonth) { + return { + startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)), + endCloseDate: formatOracleDate(new Date(endYear, endMonth + 1, 0)) + }; + } + + function formatOracleDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}T00:00:00.000Z`; + } + + return { getFiscalPeriodRange, createDateRange, formatOracleDate }; +} diff --git a/src/features/opportunities/formatters.js b/src/features/opportunities/formatters.js new file mode 100644 index 0000000..392ec93 --- /dev/null +++ b/src/features/opportunities/formatters.js @@ -0,0 +1,174 @@ +function createOpportunitiesFormatters({ + opportunitiesTableState +}) { + function getCustomerOptions(items) { + const optionsByKey = new Map(); + + (items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => { + const key = getCustomerKey(item); + const label = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]); + + if (key && label && !optionsByKey.has(key)) { + optionsByKey.set(key, { key, label: String(label) }); + } + }); + + return Array.from(optionsByKey.values()).sort((first, second) => { + return first.label.localeCompare(second.label, undefined, { + sensitivity: "base" + }); + }); + } + + function getCustomerKey(item) { + const partyId = getNestedValue(item, ["CustomerAccount", "PartyId"]); + const partyName = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]); + + if (partyId !== null && partyId !== undefined && partyId !== "") { + return `party:${partyId}`; + } + + return partyName ? `name:${partyName}` : ""; + } + + function getOwnerName(item) { + const owner = item.Owner; + + if (typeof owner === "string" || typeof owner === "number") { + return owner; + } + + if (owner && typeof owner === "object") { + const firstName = owner.PersonFirstName; + const lastName = owner.PersonLastName; + + if (firstName || lastName) { + return [firstName, lastName].filter(Boolean).join(" "); + } + + return owner.PartyName + || owner.PartyUniqueName + || owner.Name + || owner.DisplayName + || owner.ResourceName + || owner.OwnerName + || ""; + } + + return ""; + } + + function getCustomerFilterSummary(total, selected) { + if (total === 0) { + return "No customers"; + } + + if (selected === total) { + return "All customers"; + } + + if (selected === 0) { + return "No customers"; + } + + return `${selected} customer${selected === 1 ? "" : "s"}`; + } + + function getOwnerOptions(items) { + const optionsByKey = new Map(); + + (items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => { + const key = getOwnerKey(item); + const label = getOwnerName(item); + + if (key && label && !optionsByKey.has(key)) { + optionsByKey.set(key, { key, label: String(label) }); + } + }); + + return Array.from(optionsByKey.values()).sort((first, second) => { + return first.label.localeCompare(second.label, undefined, { + sensitivity: "base" + }); + }); + } + + function getOwnerKey(item) { + const owner = item.Owner; + const partyId = owner && typeof owner === "object" ? owner.PartyId : ""; + const name = getOwnerName(item); + + if (partyId !== null && partyId !== undefined && partyId !== "") { + return `party:${partyId}`; + } + + return name ? `name:${name}` : ""; + } + + function stringifySearchValue(value) { + if (value === null || value === undefined) { + return ""; + } + + if (typeof value === "object") { + return JSON.stringify(value); + } + + return String(value); + } + + function displayOpportunityValue(column, item) { + const value = column.display(item); + return value === null || value === undefined || value === "" ? "-" : String(value); + } + + function getNestedValue(value, path) { + return path.reduce((result, key) => result && result[key], value); + } + + function formatWinProbability(item) { + const value = getNestedValue(item, ["PrimaryRevenue", "WinProb"]); + const number = Number(value); + return Number.isFinite(number) ? `${number}%` : value; + } + + function formatRevenue(item) { + const amount = getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]); + const currency = getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]); + const number = Number(amount); + + if (!Number.isFinite(number)) { + return amount; + } + + if (!currency) { + return number.toLocaleString(); + } + + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency + }).format(number); + } catch (error) { + return `${currency} ${number.toLocaleString()}`; + } + } + + function formatOracleResponseDate(value, includeTime) { + if (typeof value !== "string") { + return value; + } + + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/); + + if (!match) { + return value; + } + + const date = `${match[3]}/${match[2]}/${match[1]}`; + return includeTime && match[4] ? `${date} ${match[4]}:${match[5]}` : date; + } + + return { getCustomerOptions, getCustomerKey, getOwnerName, getCustomerFilterSummary, getOwnerOptions, getOwnerKey, stringifySearchValue, displayOpportunityValue, getNestedValue, formatWinProbability, formatRevenue, formatOracleResponseDate }; +} diff --git a/src/features/opportunities/links.js b/src/features/opportunities/links.js new file mode 100644 index 0000000..c366a21 --- /dev/null +++ b/src/features/opportunities/links.js @@ -0,0 +1,118 @@ +function createOpportunitiesLinks({ + MODAL_ID, + environment +}) { + const { document, navigator, window } = environment; + function createDetailLink(label, href) { + if (label === null || label === undefined || label === "") { + const placeholder = document.createElement("span"); + placeholder.textContent = "-"; + return placeholder; + } + + if (!href) { + const text = document.createElement("span"); + text.textContent = String(label); + return text; + } + + const link = document.createElement("a"); + link.href = href; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.textContent = String(label); + return link; + } + + function createCopyOpportunityButton(optyNumber) { + const button = document.createElement("button"); + const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + + button.type = "button"; + button.className = "opportunities-extension-copy-button"; + button.setAttribute("aria-label", `Copy opportunity number ${optyNumber}`); + button.title = "Copy opportunity number"; + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + copyOpportunityNumber(String(optyNumber)); + }); + + icon.setAttribute("viewBox", "0 0 24 24"); + icon.setAttribute("aria-hidden", "true"); + path.setAttribute("d", "M8 8V5.5A2.5 2.5 0 0 1 10.5 3h8A2.5 2.5 0 0 1 21 5.5v8a2.5 2.5 0 0 1-2.5 2.5H16v2.5a2.5 2.5 0 0 1-2.5 2.5h-8A2.5 2.5 0 0 1 3 18.5v-8A2.5 2.5 0 0 1 5.5 8H8zm2 0h3.5A2.5 2.5 0 0 1 16 10.5v3.5h2.5a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5V8zm3.5 2h-8a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5z"); + icon.append(path); + button.append(icon); + return button; + } + + async function copyOpportunityNumber(optyNumber) { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(optyNumber); + } else { + const input = document.createElement("textarea"); + input.value = optyNumber; + input.setAttribute("readonly", "true"); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.append(input); + input.select(); + document.execCommand("copy"); + input.remove(); + } + + showCopyConfirmation(`Opty Number ${optyNumber} copied.`); + } catch (error) { + showCopyConfirmation("Unable to copy opportunity number.", true); + } + } + + function showCopyConfirmation(message, isError = false) { + const modal = document.getElementById(MODAL_ID); + + if (!modal) { + return; + } + + let toast = modal.querySelector(".opportunities-extension-copy-toast"); + + if (!toast) { + toast = document.createElement("div"); + toast.className = "opportunities-extension-copy-toast"; + toast.setAttribute("role", "status"); + modal.append(toast); + } + + toast.classList.toggle("opportunities-extension-copy-toast-error", isError); + toast.textContent = message; + toast.hidden = false; + window.clearTimeout(toast.copyTimeout); + toast.copyTimeout = window.setTimeout(() => { + toast.hidden = true; + }, 2400); + } + + function createStageBadge(stage) { + const badge = document.createElement("span"); + const normalizedStage = typeof stage === "string" ? stage.toUpperCase() : ""; + + badge.className = "opportunities-extension-stage-badge"; + badge.setAttribute("data-stage", normalizedStage); + badge.textContent = normalizedStage || "-"; + return badge; + } + + function createStatusBadge(status) { + const badge = document.createElement("span"); + const normalizedStatus = typeof status === "string" ? status.toUpperCase() : ""; + + badge.className = "opportunities-extension-status-badge"; + badge.setAttribute("data-status", normalizedStatus); + badge.textContent = normalizedStatus || "-"; + return badge; + } + + return { createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge }; +} diff --git a/src/features/opportunities/query-state.js b/src/features/opportunities/query-state.js new file mode 100644 index 0000000..4cd3260 --- /dev/null +++ b/src/features/opportunities/query-state.js @@ -0,0 +1,5 @@ +function createOpportunitiesQueryState() { + return { + periodRequestVersion: 0 + }; +} diff --git a/src/features/opportunities/table-state.js b/src/features/opportunities/table-state.js new file mode 100644 index 0000000..3de50a0 --- /dev/null +++ b/src/features/opportunities/table-state.js @@ -0,0 +1,11 @@ +function createOpportunitiesTableState() { + return { + opportunitiesTableState: { + items: [], + status: "idle", + message: "", + sortKey: "", + sortDirection: "ascending" + } + }; +} diff --git a/src/features/opportunities/table.js b/src/features/opportunities/table.js new file mode 100644 index 0000000..be04b1c --- /dev/null +++ b/src/features/opportunities/table.js @@ -0,0 +1,176 @@ +function createOpportunitiesTable({ + ACCOUNT_DETAIL_URL, + OPPORTUNITIES_COLUMNS, + OPPORTUNITIES_TABLE_ID, + OPPORTUNITY_DETAIL_URL, + createCloudConsumptionButton, + createCopyOpportunityButton, + createDetailLink, + createProductsCountButton, + createProductsDetailRow, + createStageBadge, + createStatusBadge, + displayOpportunityValue, + getNestedValue, + getSortedOpportunities, + opportunitiesTableState, + productsContentState, + renderCustomerFilter, + renderOwnerFilter, + renderStageDashboard, + resetCustomerFilter, + resetOwnerFilter, + environment +}) { + const { document } = environment; + function setOpportunitiesTableState(nextState) { + if (nextState.status === "ready" && Array.isArray(nextState.items)) { + resetCustomerFilter(nextState.items); + resetOwnerFilter(nextState.items); + } + + opportunitiesTableState.opportunitiesTableState = { + ...opportunitiesTableState.opportunitiesTableState, + ...nextState, + sortKey: nextState.items ? "" : opportunitiesTableState.opportunitiesTableState.sortKey, + sortDirection: nextState.items ? "ascending" : opportunitiesTableState.opportunitiesTableState.sortDirection + }; + renderOpportunitiesTable(); + renderCustomerFilter(); + renderOwnerFilter(); + } + + function renderOpportunitiesTable() { + const table = document.getElementById(OPPORTUNITIES_TABLE_ID); + + if (!table) { + return; + } + + table.textContent = ""; + table.setAttribute("aria-busy", opportunitiesTableState.opportunitiesTableState.status === "loading" ? "true" : "false"); + + const caption = document.createElement("caption"); + caption.className = "opportunities-extension-visually-hidden"; + caption.textContent = "Opportunities"; + + const tableHead = document.createElement("thead"); + const headerRow = document.createElement("tr"); + + OPPORTUNITIES_COLUMNS.forEach((column) => { + const header = document.createElement("th"); + const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key; + header.scope = "col"; + + if (column.sortable === false) { + const label = document.createElement("span"); + label.className = "opportunities-extension-table-header-label"; + label.textContent = column.label; + header.append(label); + } else { + header.setAttribute("aria-sort", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none"); + + const sortButton = document.createElement("button"); + sortButton.type = "button"; + sortButton.className = "opportunities-extension-sort-button"; + sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none"); + sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`); + sortButton.textContent = column.label; + sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key)); + header.append(sortButton); + } + + headerRow.append(header); + }); + + tableHead.append(headerRow); + + const tableBody = document.createElement("tbody"); + const items = getSortedOpportunities(); + renderStageDashboard(items); + + if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) { + const row = document.createElement("tr"); + const cell = document.createElement("td"); + cell.className = "opportunities-extension-table-status"; + cell.colSpan = OPPORTUNITIES_COLUMNS.length; + cell.textContent = opportunitiesTableState.opportunitiesTableState.message || "No opportunities found."; + row.append(cell); + tableBody.append(row); + } else { + items.forEach((item) => { + const row = document.createElement("tr"); + + OPPORTUNITIES_COLUMNS.forEach((column) => { + const cell = document.createElement("td"); + + if (column.key === "stage") { + cell.append(createStageBadge(column.display(item))); + } else if (column.key === "status") { + cell.append(createStatusBadge(column.display(item))); + } else if (column.key === "actions") { + const actionsContent = document.createElement("span"); + actionsContent.className = "opportunities-extension-actions-content"; + const productsResult = productsContentState.opptyProductsByNumber.get(String(item.OptyNumber)); + + if (productsResult) { + actionsContent.append(createProductsCountButton(item.OptyNumber, productsResult)); + } + + actionsContent.append(createCloudConsumptionButton(item)); + cell.append(actionsContent); + } else if (column.key === "name" || column.key === "optyNumber") { + const opportunityLink = createDetailLink( + column.display(item), + item.OptyNumber ? `${OPPORTUNITY_DETAIL_URL}${encodeURIComponent(item.OptyNumber)}` : "" + ); + + if (column.key === "name" && item.OptyNumber) { + const nameCellContent = document.createElement("span"); + nameCellContent.className = "opportunities-extension-name-content"; + nameCellContent.append(opportunityLink); + cell.append(nameCellContent); + } else if (column.key === "optyNumber" && item.OptyNumber) { + const opportunityCellContent = document.createElement("span"); + opportunityCellContent.className = "opportunities-extension-opty-number-content"; + opportunityCellContent.append(opportunityLink, createCopyOpportunityButton(item.OptyNumber)); + cell.append(opportunityCellContent); + } else { + cell.append(opportunityLink); + } + } else if (column.key === "customer") { + cell.append(createDetailLink( + column.display(item), + getNestedValue(item, ["CustomerAccount", "PartyId"]) + ? `${ACCOUNT_DETAIL_URL}${encodeURIComponent(getNestedValue(item, ["CustomerAccount", "PartyId"]))}` + : "" + )); + } else { + cell.textContent = displayOpportunityValue(column, item); + } + + row.append(cell); + }); + + tableBody.append(row); + + if (item.OptyNumber && productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber))) { + tableBody.append(createProductsDetailRow(item)); + } + }); + } + + table.append(caption, tableHead, tableBody); + } + + function sortOpportunitiesBy(key) { + const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key; + opportunitiesTableState.opportunitiesTableState.sortKey = key; + opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending" + ? "descending" + : "ascending"; + renderOpportunitiesTable(); + } + + return { setOpportunitiesTableState, renderOpportunitiesTable, sortOpportunitiesBy }; +} diff --git a/src/features/preferences/state.js b/src/features/preferences/state.js new file mode 100644 index 0000000..1cf79c7 --- /dev/null +++ b/src/features/preferences/state.js @@ -0,0 +1,6 @@ +function createPreferencesState({ DEFAULT_OPPORTUNITY_TYPE_VALUES, DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS } = {}) { + return { + selectedOpportunityTypeValues: new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES), + opptyProductsCacheHours: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS + }; +} diff --git a/src/features/preferences/storage.js b/src/features/preferences/storage.js new file mode 100644 index 0000000..7b859ee --- /dev/null +++ b/src/features/preferences/storage.js @@ -0,0 +1,124 @@ +function createPreferencesStorage({ + DEFAULT_OPPORTUNITY_TYPE_VALUES, + DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, + FILTER_PREFERENCES_STORAGE_KEY, + MODAL_ID, + OPPORTUNITY_TYPE_OPTIONS, + OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, + OPPTY_PRODUCTS_CACHE_OPTIONS, + PERIOD_OPTIONS, + STAGE_OPTIONS, + STATUS_OPTIONS, + opportunitiesFilterState, + environment +}) { + const { document } = environment; + const getLocalStorage = () => environment.localStorage; + function restoreFilterPreferences(select) { + try { + const rawPreferences = getLocalStorage().getItem(FILTER_PREFERENCES_STORAGE_KEY); + + if (!rawPreferences) { + return; + } + + const preferences = JSON.parse(rawPreferences); + const savedStages = Array.isArray(preferences.stages) + ? preferences.stages.filter((stage) => STAGE_OPTIONS.includes(stage)) + : null; + const savedStatuses = Array.isArray(preferences.statuses) + ? preferences.statuses.filter((status) => STATUS_OPTIONS.includes(status)) + : null; + + if (savedStages) { + opportunitiesFilterState.selectedStages = new Set(savedStages); + } + + if (savedStatuses) { + opportunitiesFilterState.selectedStatuses = new Set(savedStatuses); + } + + if (Array.isArray(preferences.customerKeys)) { + opportunitiesFilterState.selectedCustomerKeys = new Set(preferences.customerKeys.map(String)); + opportunitiesFilterState.hasSavedCustomerFilter = true; + opportunitiesFilterState.customerFilterInitialized = true; + } + + if (Array.isArray(preferences.ownerKeys)) { + opportunitiesFilterState.selectedOwnerKeys = new Set(preferences.ownerKeys.map(String)); + opportunitiesFilterState.hasSavedOwnerFilter = true; + opportunitiesFilterState.ownerFilterInitialized = true; + } + + opportunitiesFilterState.customerSearch = typeof preferences.customerSearch === "string" ? preferences.customerSearch : ""; + opportunitiesFilterState.ownerSearch = typeof preferences.ownerSearch === "string" ? preferences.ownerSearch : ""; + opportunitiesFilterState.tableSearch = typeof preferences.tableSearch === "string" ? preferences.tableSearch : ""; + + if (PERIOD_OPTIONS.includes(preferences.period)) { + select.value = preferences.period; + } + } catch (error) { + // Ignore malformed or unavailable browser storage and use default filters. + } + } + + function saveFilterPreferences(period) { + try { + const currentPeriod = period || document.querySelector(`#${MODAL_ID} select`)?.value || PERIOD_OPTIONS[0]; + getLocalStorage().setItem(FILTER_PREFERENCES_STORAGE_KEY, JSON.stringify({ + period: currentPeriod, + stages: Array.from(opportunitiesFilterState.selectedStages), + statuses: Array.from(opportunitiesFilterState.selectedStatuses), + customerKeys: opportunitiesFilterState.hasSavedCustomerFilter ? Array.from(opportunitiesFilterState.selectedCustomerKeys) : null, + ownerKeys: opportunitiesFilterState.hasSavedOwnerFilter ? Array.from(opportunitiesFilterState.selectedOwnerKeys) : null, + customerSearch: opportunitiesFilterState.customerSearch, + ownerSearch: opportunitiesFilterState.ownerSearch, + tableSearch: opportunitiesFilterState.tableSearch + })); + } catch (error) { + // Ignore unavailable browser storage; filters still work for the current modal. + } + } + + function getSavedOpportunityTypeValues() { + try { + const rawPreferences = getLocalStorage().getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY); + + if (!rawPreferences) { + return DEFAULT_OPPORTUNITY_TYPE_VALUES; + } + + const preferences = JSON.parse(rawPreferences); + const values = Array.isArray(preferences.opportunityTypeValues) ? preferences.opportunityTypeValues : null; + + if (!values) { + return DEFAULT_OPPORTUNITY_TYPE_VALUES; + } + + const allowedValues = new Set(OPPORTUNITY_TYPE_OPTIONS.map((option) => option.value)); + return values.filter((value) => allowedValues.has(value)); + } catch (error) { + return DEFAULT_OPPORTUNITY_TYPE_VALUES; + } + } + + function getSavedOpptyProductsCacheHours() { + try { + const rawPreferences = getLocalStorage().getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY); + + if (!rawPreferences) { + return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; + } + + const preferences = JSON.parse(rawPreferences); + const cacheHours = Number(preferences.opptyProductsCacheHours); + return OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === cacheHours) + ? cacheHours + : DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; + } catch (error) { + return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; + } + } + + return { restoreFilterPreferences, saveFilterPreferences, getSavedOpportunityTypeValues, getSavedOpptyProductsCacheHours }; +} diff --git a/src/features/preferences/view.js b/src/features/preferences/view.js new file mode 100644 index 0000000..9852bbf --- /dev/null +++ b/src/features/preferences/view.js @@ -0,0 +1,165 @@ +function createPreferencesView({ + DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, + MODAL_ID, + OPPORTUNITY_TYPE_OPTIONS, + OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, + OPPTY_PRODUCTS_CACHE_OPTIONS, + PERIOD_OPTIONS, + PREFERENCES_MODAL_ID, + createCloseIcon, + getSavedOpportunityTypeValues, + getSavedOpptyProductsCacheHours, + preferencesState, + requestOpportunitiesForPeriod, + environment +}) { + const { document } = environment; + const getLocalStorage = () => environment.localStorage; + function openPreferencesModal() { + const overlay = document.getElementById(MODAL_ID); + + if (!overlay) { + return; + } + + const existingModal = document.getElementById(PREFERENCES_MODAL_ID); + + if (existingModal) { + const savedValues = new Set(getSavedOpportunityTypeValues()); + existingModal.querySelectorAll("input[name='opportunityTypeView']").forEach((checkbox) => { + checkbox.checked = savedValues.has(checkbox.value); + }); + const cacheSelect = existingModal.querySelector("select[name='opptyProductsCacheHours']"); + + if (cacheSelect) { + cacheSelect.value = String(getSavedOpptyProductsCacheHours()); + } + + existingModal.hidden = false; + existingModal.querySelector("input")?.focus(); + return; + } + + const preferencesModal = document.createElement("section"); + preferencesModal.id = PREFERENCES_MODAL_ID; + preferencesModal.className = "opportunities-extension-preferences-modal"; + preferencesModal.setAttribute("role", "dialog"); + preferencesModal.setAttribute("aria-modal", "true"); + preferencesModal.setAttribute("aria-labelledby", "opportunities-extension-preferences-title"); + + const dialog = document.createElement("div"); + dialog.className = "opportunities-extension-preferences-dialog"; + + const header = document.createElement("header"); + header.className = "opportunities-extension-preferences-header"; + + const title = document.createElement("h2"); + title.id = "opportunities-extension-preferences-title"; + title.textContent = "Preferences"; + + const closeButton = document.createElement("button"); + closeButton.type = "button"; + closeButton.className = "opportunities-extension-icon-button"; + closeButton.setAttribute("aria-label", "Fechar preferências"); + closeButton.title = "Close"; + closeButton.append(createCloseIcon()); + closeButton.addEventListener("click", closePreferencesModal); + header.append(title, closeButton); + + const content = document.createElement("div"); + content.className = "opportunities-extension-preferences-content"; + + const fieldset = document.createElement("fieldset"); + fieldset.className = "opportunities-extension-preferences-fieldset"; + + const legend = document.createElement("legend"); + legend.textContent = "Opportunity Type View"; + fieldset.append(legend); + + const selectedValues = new Set(getSavedOpportunityTypeValues()); + const options = document.createElement("div"); + options.className = "opportunities-extension-preferences-options"; + + OPPORTUNITY_TYPE_OPTIONS.forEach((option) => { + const label = document.createElement("label"); + label.className = "opportunities-extension-preferences-option"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.name = "opportunityTypeView"; + checkbox.value = option.value; + checkbox.checked = selectedValues.has(option.value); + + const text = document.createElement("span"); + text.textContent = option.label; + label.append(checkbox, text); + options.append(label); + }); + + fieldset.append(options); + + const cacheField = document.createElement("label"); + cacheField.className = "opportunities-extension-preferences-cache-field"; + + const cacheLabel = document.createElement("span"); + cacheLabel.textContent = "Product cache duration"; + + const cacheSelect = document.createElement("select"); + cacheSelect.name = "opptyProductsCacheHours"; + cacheSelect.setAttribute("aria-label", "Product cache duration"); + OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => { + const optionElement = document.createElement("option"); + optionElement.value = String(option.value); + optionElement.textContent = option.label; + optionElement.selected = option.value === getSavedOpptyProductsCacheHours(); + cacheSelect.append(optionElement); + }); + cacheField.append(cacheLabel, cacheSelect); + content.append(fieldset, cacheField); + + const footer = document.createElement("footer"); + footer.className = "opportunities-extension-preferences-footer"; + + const saveButton = document.createElement("button"); + saveButton.type = "button"; + saveButton.className = "opportunities-extension-button opportunities-extension-button-primary"; + saveButton.textContent = "Save"; + saveButton.addEventListener("click", () => { + const selectedValues = Array.from(preferencesModal.querySelectorAll("input[name='opportunityTypeView']:checked"), (checkbox) => checkbox.value); + const selectedCacheHours = Number(preferencesModal.querySelector("select[name='opptyProductsCacheHours']")?.value); + preferencesState.selectedOpportunityTypeValues = new Set(selectedValues); + preferencesState.opptyProductsCacheHours = OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === selectedCacheHours) + ? selectedCacheHours + : DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS; + + try { + getLocalStorage().setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({ + opportunityTypeValues: selectedValues, + opptyProductsCacheHours: preferencesState.opptyProductsCacheHours + })); + } catch (error) { + // Continue with the saved selection for the active modal when storage is unavailable. + } + + closePreferencesModal(); + const periodSelect = overlay.querySelector("select[name='opportunityPeriod']"); + requestOpportunitiesForPeriod(periodSelect?.value || PERIOD_OPTIONS[0]); + }); + + footer.append(saveButton); + dialog.append(header, content, footer); + preferencesModal.append(dialog); + overlay.append(preferencesModal); + preferencesModal.querySelector("input")?.focus(); + } + + function closePreferencesModal() { + const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID); + + if (preferencesModal) { + preferencesModal.hidden = true; + } + } + + return { openPreferencesModal, closePreferencesModal }; +} diff --git a/src/features/products/background-jobs.js b/src/features/products/background-jobs.js new file mode 100644 index 0000000..299be6d --- /dev/null +++ b/src/features/products/background-jobs.js @@ -0,0 +1,107 @@ +function createProductsBackgroundJobs({ + messageTypes, + OPPTY_PRODUCTS_CONCURRENCY, + getOpptyProducts, + normalizeCacheMaxAge, + tabsSendMessage +}) { + async function requestOpptyProducts(message, sender) { + const requestId = typeof message.requestId === "string" ? message.requestId : ""; + const accessToken = typeof message.accessToken === "string" ? message.accessToken : ""; + const cacheMaxAgeMs = normalizeCacheMaxAge(message.cacheMaxAgeMs); + const optyNumbers = Array.from(new Set( + (Array.isArray(message.optyNumbers) ? message.optyNumbers : []) + .filter(Boolean) + .map(String) + )); + + if (!requestId || !accessToken) { + throw new Error("A request id and access token are required for requestOpptyProducts."); + } + + let nextIndex = 0; + let completed = 0; + let failed = 0; + let cacheHits = 0; + + const runWorker = async () => { + while (nextIndex < optyNumbers.length) { + const index = nextIndex; + nextIndex += 1; + const optyNumber = optyNumbers[index]; + + try { + const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs); + completed += 1; + + if (result.fromCache) { + cacheHits += 1; + } + + await sendProductsProgress(sender, { + type: messageTypes.opptyProductsProgress, + requestId, + state: "running", + optyNumber, + items: result.items, + fromCache: result.fromCache, + cachedAt: result.cachedAt, + completed, + total: optyNumbers.length, + failed, + cacheHits + }); + } catch (error) { + completed += 1; + failed += 1; + await sendProductsProgress(sender, { + type: messageTypes.opptyProductsProgress, + requestId, + state: "running", + optyNumber, + items: [], + error: error.message || "Unable to load products.", + completed, + total: optyNumbers.length, + failed, + cacheHits + }); + } + } + }; + + const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1)); + await Promise.all(Array.from({ length: workerCount }, runWorker)); + + const state = failed > 0 ? "complete-with-errors" : "complete"; + await sendProductsProgress(sender, { + type: messageTypes.opptyProductsProgress, + requestId, + state, + completed, + total: optyNumbers.length, + failed, + cacheHits + }); + + return { + ok: failed === 0, + requestId, + completed, + total: optyNumbers.length, + failed, + cacheHits + }; + } + + function sendProductsProgress(sender, message) { + if (!sender || !sender.tab || !Number.isInteger(sender.tab.id)) { + return Promise.resolve(); + } + + const options = Number.isInteger(sender.frameId) ? { frameId: sender.frameId } : undefined; + return tabsSendMessage(sender.tab.id, message, options).catch(() => {}); + } + + return { requestOpptyProducts, sendProductsProgress }; +} diff --git a/src/features/products/cache-state.js b/src/features/products/cache-state.js new file mode 100644 index 0000000..9c5cd4d --- /dev/null +++ b/src/features/products/cache-state.js @@ -0,0 +1,7 @@ +function createProductsCacheState() { + return { + productsCachePromise: null, + productsCacheWritePromise: Promise.resolve(), + inFlightProducts: new Map() + }; +} diff --git a/src/features/products/cache.js b/src/features/products/cache.js new file mode 100644 index 0000000..eeac416 --- /dev/null +++ b/src/features/products/cache.js @@ -0,0 +1,70 @@ +function createProductsCache({ + OPPTY_PRODUCTS_CACHE_KEY, + fetchAllOpptyProducts, + productsCacheState, + storageLocalGet, + storageLocalSet +}) { + async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) { + const cache = await getProductsCache(); + const cachedEntry = cache[optyNumber]; + + if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) { + return { items: cachedEntry.items, fromCache: true, cachedAt: Number(cachedEntry.fetchedAt) }; + } + + if (!productsCacheState.inFlightProducts.has(optyNumber)) { + const request = fetchAllOpptyProducts(optyNumber, accessToken) + .then(async (items) => { + const fetchedAt = Date.now(); + cache[optyNumber] = { + fetchedAt, + items + }; + await saveProductsCache(cache).catch(() => {}); + return { items, fetchedAt }; + }) + .finally(() => { + productsCacheState.inFlightProducts.delete(optyNumber); + }); + productsCacheState.inFlightProducts.set(optyNumber, request); + } + + const fetchedResult = await productsCacheState.inFlightProducts.get(optyNumber); + return { + items: fetchedResult.items, + cachedAt: fetchedResult.fetchedAt, + fromCache: false + }; + } + + function normalizeCacheMaxAge(value) { + const maxAge = Number(value); + const defaultMaxAge = 24 * 60 * 60 * 1000; + const maximumMaxAge = 30 * 24 * 60 * 60 * 1000; + return Number.isFinite(maxAge) && maxAge > 0 + ? Math.min(maxAge, maximumMaxAge) + : defaultMaxAge; + } + + async function getProductsCache() { + if (!productsCacheState.productsCachePromise) { + productsCacheState.productsCachePromise = storageLocalGet(OPPTY_PRODUCTS_CACHE_KEY) + .then((result) => result && typeof result[OPPTY_PRODUCTS_CACHE_KEY] === "object" + ? result[OPPTY_PRODUCTS_CACHE_KEY] + : {}) + .catch(() => ({})); + } + + return productsCacheState.productsCachePromise; + } + + function saveProductsCache(cache) { + productsCacheState.productsCacheWritePromise = productsCacheState.productsCacheWritePromise + .catch(() => {}) + .then(() => storageLocalSet({ [OPPTY_PRODUCTS_CACHE_KEY]: cache })); + return productsCacheState.productsCacheWritePromise; + } + + return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache }; +} diff --git a/src/features/products/content-jobs.js b/src/features/products/content-jobs.js new file mode 100644 index 0000000..b153869 --- /dev/null +++ b/src/features/products/content-jobs.js @@ -0,0 +1,163 @@ +function createProductsContentJobs({ + messageTypes, + PRODUCTS_CACHE_TIMESTAMP_ID, + PRODUCTS_MESSAGE_LISTENER_KEY, + PRODUCTS_PROGRESS_ID, + preferencesState, + productsContentState, + renderOpportunitiesTable, + sendRuntimeMessage, + environment +}) { + const { browser, chrome, document, window } = environment; + function requestOpptyProductsInBackground(opportunities, token) { + const optyNumbers = Array.from(new Set(opportunities + .map((item) => item && item.OptyNumber) + .filter(Boolean) + .map(String))); + + const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + productsContentState.currentProductsRequestId = requestId; + productsContentState.opptyProductsByNumber = new Map(); + productsContentState.expandedProductOptyNumbers = new Set(); + productsContentState.productsProgress = { + state: optyNumbers.length ? "running" : "complete", + completed: 0, + total: optyNumbers.length, + failed: 0, + cacheHits: 0 + }; + renderProductsProgress(); + + if (optyNumbers.length === 0) { + return; + } + + sendRuntimeMessage({ + type: messageTypes.requestOpptyProducts, + requestId, + accessToken: token, + optyNumbers, + cacheMaxAgeMs: preferencesState.opptyProductsCacheHours * 60 * 60 * 1000 + }).then((response) => { + if (response && response.ok === false && response.requestId === productsContentState.currentProductsRequestId && productsContentState.productsProgress.state === "running") { + productsContentState.productsProgress.state = "error"; + productsContentState.productsProgress.failed = productsContentState.productsProgress.total; + renderProductsProgress(); + } + }).catch(() => { + if (requestId === productsContentState.currentProductsRequestId && productsContentState.productsProgress.state === "running") { + productsContentState.productsProgress.state = "error"; + productsContentState.productsProgress.failed = productsContentState.productsProgress.total; + renderProductsProgress(); + } + }); + } + + function ensureProductsBackgroundMessageHandler() { + if (window[PRODUCTS_MESSAGE_LISTENER_KEY]) { + return; + } + + const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + + if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.onMessage) { + return; + } + + window[PRODUCTS_MESSAGE_LISTENER_KEY] = true; + runtimeApi.runtime.onMessage.addListener((message) => { + if (!message || message.type !== messageTypes.opptyProductsProgress || message.requestId !== productsContentState.currentProductsRequestId) { + return false; + } + + if (message.optyNumber) { + productsContentState.opptyProductsByNumber.set(String(message.optyNumber), { + items: Array.isArray(message.items) ? message.items : [], + fromCache: Boolean(message.fromCache), + cachedAt: Number(message.cachedAt) || 0, + error: message.error || "" + }); + } + + if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) { + productsContentState.productsLastCachedAt = Number(message.cachedAt); + } + + productsContentState.productsProgress = { + state: message.state || productsContentState.productsProgress.state, + completed: Number(message.completed) || 0, + total: Number(message.total) || productsContentState.productsProgress.total, + failed: Number(message.failed) || 0, + cacheHits: Number(message.cacheHits) || 0 + }; + renderProductsProgress(); + queueProductsTableRender(); + return false; + }); + } + + function renderProductsProgress() { + const badge = document.getElementById(PRODUCTS_PROGRESS_ID); + + if (!badge) { + return; + } + + badge.hidden = productsContentState.productsProgress.state === "idle"; + badge.setAttribute("data-state", productsContentState.productsProgress.state); + + if (productsContentState.productsProgress.state === "running") { + badge.textContent = `Products ${productsContentState.productsProgress.completed}/${productsContentState.productsProgress.total}`; + badge.title = `${productsContentState.productsProgress.cacheHits} loaded from cache`; + } else if (productsContentState.productsProgress.state === "complete") { + badge.textContent = `Products ready ${productsContentState.productsProgress.total}`; + badge.title = `${productsContentState.productsProgress.cacheHits} loaded from cache`; + } else if (productsContentState.productsProgress.state === "complete-with-errors") { + badge.textContent = `Products ${productsContentState.productsProgress.total - productsContentState.productsProgress.failed}/${productsContentState.productsProgress.total}`; + badge.title = `${productsContentState.productsProgress.failed} product requests failed`; + } else if (productsContentState.productsProgress.state === "error") { + badge.textContent = "Products unavailable"; + badge.title = "Unable to load opportunity products"; + } else { + badge.textContent = "Products ready 0"; + } + + const timestamp = document.getElementById(PRODUCTS_CACHE_TIMESTAMP_ID); + + if (timestamp) { + timestamp.hidden = !productsContentState.productsLastCachedAt; + timestamp.textContent = productsContentState.productsLastCachedAt + ? `Last update: ${formatProductsCacheTimestamp(productsContentState.productsLastCachedAt)}` + : ""; + timestamp.title = productsContentState.productsLastCachedAt + ? `Latest product update: ${formatProductsCacheTimestamp(productsContentState.productsLastCachedAt)}` + : ""; + } + } + + function formatProductsCacheTimestamp(timestamp) { + try { + return new Intl.DateTimeFormat("en-GB", { + dateStyle: "short", + timeStyle: "short" + }).format(new Date(timestamp)); + } catch (error) { + return new Date(timestamp).toLocaleString(); + } + } + + function queueProductsTableRender() { + if (productsContentState.productsRenderQueued) { + return; + } + + productsContentState.productsRenderQueued = true; + window.requestAnimationFrame(() => { + productsContentState.productsRenderQueued = false; + renderOpportunitiesTable(); + }); + } + + return { requestOpptyProductsInBackground, ensureProductsBackgroundMessageHandler, renderProductsProgress, formatProductsCacheTimestamp, queueProductsTableRender }; +} diff --git a/src/features/products/content-state.js b/src/features/products/content-state.js new file mode 100644 index 0000000..c37b2bb --- /dev/null +++ b/src/features/products/content-state.js @@ -0,0 +1,16 @@ +function createProductsContentState() { + return { + currentProductsRequestId: "", + productsRenderQueued: false, + opptyProductsByNumber: new Map(), + expandedProductOptyNumbers: new Set(), + productsProgress: { + state: "idle", + completed: 0, + total: 0, + failed: 0, + cacheHits: 0 + }, + productsLastCachedAt: 0 + }; +} diff --git a/src/features/products/details.js b/src/features/products/details.js new file mode 100644 index 0000000..88082ce --- /dev/null +++ b/src/features/products/details.js @@ -0,0 +1,152 @@ +function createProductsDetails({ + OPPORTUNITIES_COLUMNS, + createAiSummaryAlert, + formatOracleResponseDate, + productsContentState, + renderOpportunitiesTable, + requestGenerateAiSummary, + environment +}) { + const { document } = environment; + function createProductsCountButton(optyNumber, productsResult) { + const normalizedOptyNumber = String(optyNumber); + const button = document.createElement("button"); + const count = Array.isArray(productsResult.items) ? productsResult.items.length : 0; + const isExpanded = productsContentState.expandedProductOptyNumbers.has(normalizedOptyNumber); + + button.type = "button"; + button.className = "opportunities-extension-products-count"; + button.textContent = String(count); + button.setAttribute("aria-expanded", String(isExpanded)); + button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`); + button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`; + button.classList.toggle("opportunities-extension-products-count-error", Boolean(productsResult.error)); + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + + if (isExpanded) { + productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber); + } else { + productsContentState.expandedProductOptyNumbers.add(normalizedOptyNumber); + requestGenerateAiSummary(normalizedOptyNumber); + } + + renderOpportunitiesTable(); + }); + return button; + } + + function createProductsDetailRow(opportunity) { + const row = document.createElement("tr"); + const cell = document.createElement("td"); + const optyNumber = String(opportunity.OptyNumber); + const productsResult = productsContentState.opptyProductsByNumber.get(optyNumber); + const products = productsResult && Array.isArray(productsResult.items) ? productsResult.items : []; + + row.className = "opportunities-extension-products-detail-row"; + cell.colSpan = OPPORTUNITIES_COLUMNS.length; + cell.className = "opportunities-extension-products-detail-cell"; + + const panel = document.createElement("section"); + panel.className = "opportunities-extension-products-detail"; + + const heading = document.createElement("div"); + heading.className = "opportunities-extension-products-detail-heading"; + + const title = document.createElement("strong"); + title.textContent = `Products for ${optyNumber}`; + + const summary = document.createElement("span"); + summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated"; + heading.append(title, summary); + panel.append(heading); + panel.append(createAiSummaryAlert(optyNumber)); + + if (productsResult && productsResult.error) { + const error = document.createElement("p"); + error.className = "opportunities-extension-products-empty"; + error.textContent = productsResult.error; + panel.append(error); + } else if (products.length === 0) { + const empty = document.createElement("p"); + empty.className = "opportunities-extension-products-empty"; + empty.textContent = "No products found for this opportunity."; + panel.append(empty); + } else { + const table = document.createElement("table"); + table.className = "opportunities-extension-products-table"; + table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`); + + const columns = [ + { label: "Product Group", value: (item) => item.ProdGroupName || "-" }, + { label: "Workload", value: (item) => item.WorkloadName_c || "-" }, + { label: "Currency", value: (item) => item.RevnAmountCurcyCode || "-" }, + { label: "Amount", value: (item) => formatProductAmount(item.RevnAmount, item.RevnAmountCurcyCode) }, + { label: "Type", value: (item) => item.TypeCode || "-" }, + { label: "Status", value: (item) => item.StatusCode || "-" }, + { label: "Win Probability", value: (item) => formatProductWinProbability(item.WinProb) }, + { label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" }, + { label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" }, + { label: "Ramp Months", value: (item) => formatProductNumber(item.RampMonths_c) } + ]; + + const head = document.createElement("thead"); + const headerRow = document.createElement("tr"); + columns.forEach((column) => { + const header = document.createElement("th"); + header.scope = "col"; + header.textContent = column.label; + headerRow.append(header); + }); + head.append(headerRow); + + const body = document.createElement("tbody"); + products.forEach((product) => { + const productRow = document.createElement("tr"); + columns.forEach((column) => { + const productCell = document.createElement("td"); + productCell.textContent = String(column.value(product)); + productRow.append(productCell); + }); + body.append(productRow); + }); + + table.append(head, body); + panel.append(table); + } + + cell.append(panel); + row.append(cell); + return row; + } + + function formatProductNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(number) : "-"; + } + + function formatProductAmount(value, currency) { + const amount = Number(value); + + if (!Number.isFinite(amount)) { + return "-"; + } + + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency || "USD" + }).format(amount); + } catch (error) { + return `${currency || ""} ${formatProductNumber(amount)}`.trim(); + } + } + + function formatProductWinProbability(value) { + const number = Number(value); + return Number.isFinite(number) ? `${number}%` : "-"; + } + + return { createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability }; +} diff --git a/src/platform/background-api.js b/src/platform/background-api.js new file mode 100644 index 0000000..53eacf0 --- /dev/null +++ b/src/platform/background-api.js @@ -0,0 +1,190 @@ +function createPlatformBackgroundApi({ + runtimeApi, + environment +}) { + const { browser, setTimeout } = environment; + function supportsFirefoxCookieStoreTabs() { + return Boolean( + runtimeApi.runtime + && typeof runtimeApi.runtime.getBrowserInfo === "function" + ); + } + + function tabsCreate(details) { + if (runtimeApi.tabs.create.length <= 1) { + return runtimeApi.tabs.create(details); + } + + return new Promise((resolve, reject) => { + runtimeApi.tabs.create(details, (tab) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(tab); + }); + }); + } + + function tabsRemove(tabId) { + if (runtimeApi.tabs.remove.length <= 1) { + return runtimeApi.tabs.remove(tabId); + } + + return new Promise((resolve, reject) => { + runtimeApi.tabs.remove(tabId, () => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(); + }); + }); + } + + function tabsSendMessage(tabId, message, options) { + if (typeof browser !== "undefined") { + return options + ? runtimeApi.tabs.sendMessage(tabId, message, options) + : runtimeApi.tabs.sendMessage(tabId, message); + } + + return new Promise((resolve, reject) => { + const callback = (response) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(response); + }; + + if (options) { + runtimeApi.tabs.sendMessage(tabId, message, options, callback); + } else { + runtimeApi.tabs.sendMessage(tabId, message, callback); + } + }); + } + + function storageLocalGet(key) { + if (typeof browser !== "undefined") { + return runtimeApi.storage.local.get(key); + } + + return new Promise((resolve, reject) => { + runtimeApi.storage.local.get(key, (result) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(result); + }); + }); + } + + function storageLocalSet(value) { + if (typeof browser !== "undefined") { + return runtimeApi.storage.local.set(value); + } + + return new Promise((resolve, reject) => { + runtimeApi.storage.local.set(value, () => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(); + }); + }); + } + + function wait(durationMs) { + return new Promise((resolve) => setTimeout(resolve, durationMs)); + } + + function cookiesGetAll(details) { + if (runtimeApi.cookies.getAll.length <= 1) { + return runtimeApi.cookies.getAll(details); + } + + return new Promise((resolve, reject) => { + runtimeApi.cookies.getAll(details, (cookies) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(cookies); + }); + }); + } + + function cookiesGet(details) { + const cleanDetails = removeUndefinedValues(details); + + if (runtimeApi.cookies.get.length <= 1) { + return runtimeApi.cookies.get(cleanDetails); + } + + return new Promise((resolve, reject) => { + runtimeApi.cookies.get(cleanDetails, (cookie) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(cookie); + }); + }); + } + + function cookiesGetAllCookieStores() { + if (!runtimeApi.cookies.getAllCookieStores) { + return Promise.resolve([{ id: undefined }]); + } + + if (runtimeApi.cookies.getAllCookieStores.length === 0) { + return runtimeApi.cookies.getAllCookieStores(); + } + + return new Promise((resolve, reject) => { + runtimeApi.cookies.getAllCookieStores((stores) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(stores); + }); + }); + } + + function removeUndefinedValues(details) { + return Object.fromEntries( + Object.entries(details).filter((entry) => entry[1] !== undefined) + ); + } + + return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues }; +} diff --git a/src/platform/content-messages.js b/src/platform/content-messages.js new file mode 100644 index 0000000..8316a96 --- /dev/null +++ b/src/platform/content-messages.js @@ -0,0 +1,120 @@ +function createPlatformContentMessages({ + messageTypes, + environment +}) { + const { browser, chrome } = environment; + function sendRuntimeMessage(message) { + const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + + if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { + return Promise.reject(new Error("Runtime messaging API unavailable.")); + } + + if (typeof browser !== "undefined") { + return runtimeApi.runtime.sendMessage(message); + } + + return new Promise((resolve, reject) => { + runtimeApi.runtime.sendMessage(message, (response) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + reject(new Error(lastError.message)); + return; + } + + resolve(response); + }); + }); + } + + function getXsrfTokenFromExtensionCookies() { + const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + + if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { + return Promise.resolve({ + token: "", + cookieName: "", + source: "unavailable cookies API", + error: "Runtime messaging API unavailable." + }); + } + + return new Promise((resolve) => { + runtimeApi.runtime.sendMessage({ + type: messageTypes.getXsrfToken + }, (response) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + resolve({ + token: "", + cookieName: "", + source: "cookies API", + matchedCookieNames: [], + lookupDetails: [], + error: lastError.message + }); + return; + } + + resolve({ + token: response && response.ok ? response.token : "", + cookieName: response && response.ok ? response.cookieName : "", + source: "cookies API", + matchedCookieNames: response && response.matchedCookieNames ? response.matchedCookieNames : [], + lookupDetails: response && response.lookupDetails ? response.lookupDetails : [], + error: response && response.error ? response.error : "" + }); + }); + }); + } + + function refreshXsrfCookie(reason) { + const runtimeApi = typeof browser !== "undefined" ? browser : chrome; + + if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) { + return Promise.resolve({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + reason, + error: "Runtime messaging API unavailable." + }); + } + + return new Promise((resolve) => { + runtimeApi.runtime.sendMessage({ + type: messageTypes.refreshXsrfCookie + }, (response) => { + const lastError = runtimeApi.runtime.lastError; + + if (lastError) { + resolve({ + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + reason, + error: lastError.message + }); + return; + } + + resolve({ + ...(response || { + ok: false, + refreshed: false, + cookieName: "", + durationMs: 0, + error: "Empty refresh response." + }), + reason + }); + }); + }); + } + + return { sendRuntimeMessage, getXsrfTokenFromExtensionCookies, refreshXsrfCookie }; +} diff --git a/src/services/fusion/opportunities.js b/src/services/fusion/opportunities.js new file mode 100644 index 0000000..3733621 --- /dev/null +++ b/src/services/fusion/opportunities.js @@ -0,0 +1,166 @@ +function createFusionOpportunities({ + OPPORTUNITIES_PAGE_LIMIT, + OPPORTUNITIES_QUERY_URL, + isCurrentPeriodRequest, + logRequestFailure, + logRequestStart, + logRequestSuccess, + maskToken, + getOpportunityTypeValues, + readResponseBodyForDebug, + environment +}) { + const { fetch } = environment; + async function requestOpportunitiesPage(token, period, dateRange, offset, accumulatedItems, requestVersion, page) { + if (!isCurrentPeriodRequest(requestVersion)) { + return null; + } + + const payload = createOpportunitiesQueryPayload(dateRange, offset); + const requestBody = JSON.stringify(payload); + const requestEntry = logRequestStart({ + name: "requestOpportunities", + method: "POST", + url: OPPORTUNITIES_QUERY_URL, + headers: { + Accept: "application/json", + Authorization: `Bearer ${maskToken(token)}`, + "Content-Type": "application/json", + Origin: "https://eeho.fa.us2.oraclecloud.com", + Preference: "transient" + }, + body: JSON.stringify(payload, null, 2), + metadata: { + period, + page, + offset, + startCloseDate: dateRange.startCloseDate, + endCloseDate: dateRange.endCloseDate + } + }); + + try { + const response = await fetch(OPPORTUNITIES_QUERY_URL, { + method: "POST", + credentials: "include", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Origin: "https://eeho.fa.us2.oraclecloud.com", + Preference: "transient" + }, + body: requestBody + }); + + const responseBody = await readResponseBodyForDebug(response); + logRequestSuccess(requestEntry, response, responseBody); + + if (!response.ok) { + throw new Error(`Opportunities query failed with status ${response.status}.`); + } + + const responseData = await response.json(); + const pageItems = Array.isArray(responseData.items) ? responseData.items : []; + const allItems = accumulatedItems.concat(pageItems); + + if (!responseData.hasMore) { + return allItems; + } + + const responseOffset = Number(responseData.offset); + const responseCount = Number(responseData.count); + const receivedCount = Number.isFinite(responseCount) ? responseCount : pageItems.length; + const currentOffset = Number.isFinite(responseOffset) ? responseOffset : offset; + + if (receivedCount <= 0) { + throw new Error("Opportunities query returned hasMore without additional results."); + } + + return requestOpportunitiesPage( + token, + period, + dateRange, + currentOffset + receivedCount, + allItems, + requestVersion, + page + 1 + ); + } catch (error) { + logRequestFailure(requestEntry, error.message || "Request failed."); + throw error; + } + } + + function createOpportunitiesQueryPayload(dateRange, offset) { + return { + aggregationResults: false, + applications: ["ORACLE-ISS-APP"], + onlyData: true, + entity: "Opportunity", + q: { + 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 + } + ] + }, + keywords: null, + keywordsFields: [ + "Name", + "OptyNumber", + "PrimaryRevenue.WinProb", + "CustomerAccount", + "PrimaryRevenue.RevnAmount", + "EffectiveDate", + "StatusCode", + "DealRisk_c", + "Owner", + "ForecastGroup_c", + "LastUpdateDate", + "PrimaryRevenue.RevnAmountCurcyCode", + "OptyId" + ], + fields: [ + "Name", + "OptyNumber", + "PrimaryRevenue.WinProb", + "CustomerAccount", + "PrimaryRevenue.RevnAmount", + "EffectiveDate", + "StatusCode", + "DealRisk_c", + "Owner", + "ForecastGroup_c", + "LastUpdateDate", + "PrimaryRevenue.RevnAmountCurcyCode", + "OptyId", + "CustomerAccount.PartyUniqueName", + "CustomerAccount.PartyId", + "CustomerAccount.PartyNumber" + ], + sort: [], + language: "en", + skipInValidFields: true, + skipHiddenFromUIFields: true, + copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162", + limit: OPPORTUNITIES_PAGE_LIMIT, + offset + }; + } + + return { requestOpportunitiesPage, createOpportunitiesQueryPayload }; +} diff --git a/src/services/fusion/products.js b/src/services/fusion/products.js new file mode 100644 index 0000000..a95d242 --- /dev/null +++ b/src/services/fusion/products.js @@ -0,0 +1,65 @@ +function createFusionProducts({ + OPPTY_PRODUCTS_FIELDS, + OPPTY_PRODUCTS_MAX_PAGES, + OPPTY_PRODUCTS_PAGE_LIMIT, + ORACLE_DOMAIN, + environment +}) { + const { fetch } = environment; + async function fetchAllOpptyProducts(optyNumber, accessToken) { + let offset = 0; + let page = 0; + const items = []; + + while (page < OPPTY_PRODUCTS_MAX_PAGES) { + const response = await fetch(createOpptyProductsUrl(optyNumber, offset), { + method: "GET", + credentials: "include", + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}` + } + }); + + if (!response.ok) { + throw new Error(`requestOpptyProducts failed with status ${response.status}.`); + } + + const responseData = await response.json(); + const pageItems = Array.isArray(responseData.items) ? responseData.items : []; + items.push(...pageItems); + page += 1; + + if (!responseData.hasMore) { + return items; + } + + const responseOffset = Number(responseData.offset); + const responseCount = Number(responseData.count); + const receivedCount = Number.isFinite(responseCount) ? responseCount : pageItems.length; + const currentOffset = Number.isFinite(responseOffset) ? responseOffset : offset; + + if (receivedCount <= 0) { + throw new Error("requestOpptyProducts returned hasMore without additional results."); + } + + offset = currentOffset + receivedCount; + } + + throw new Error("requestOpptyProducts exceeded the pagination safety limit."); + } + + function createOpptyProductsUrl(optyNumber, offset) { + const url = new URL(`https://${ORACLE_DOMAIN}/crmRestApi/rest/rv:be91c002-2e5d-4ed2-a37e-e0c837bf141f/en/11.13.18.05:9/opportunities/${encodeURIComponent(optyNumber)}/child/ChildRevenue`); + url.searchParams.set("onlyData", "true"); + url.searchParams.set("q", "(SplitTypeCode!='DETAILCHILDSPLIT') AND (RecurTypeCode!='CHILDRECUR')"); + url.searchParams.set("totalResults", "false"); + url.searchParams.set("fields", OPPTY_PRODUCTS_FIELDS.join(",")); + url.searchParams.set("orderBy", "CreationDate:desc"); + url.searchParams.set("limit", String(OPPTY_PRODUCTS_PAGE_LIMIT)); + url.searchParams.set("offset", String(offset)); + return url.toString(); + } + + return { fetchAllOpptyProducts, createOpptyProductsUrl }; +} diff --git a/src/services/http/background-json.js b/src/services/http/background-json.js new file mode 100644 index 0000000..7111b2c --- /dev/null +++ b/src/services/http/background-json.js @@ -0,0 +1,169 @@ +function createHttpBackgroundJson({ + DEBUG_BODY_LIMIT, + environment +}) { + const { fetch } = environment; + async function fetchJson(url, options, requestName, debugRequests = null) { + const startedAt = new Date(); + const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt); + let response; + + try { + response = await fetch(url, options); + } catch (error) { + completeBackgroundDebugEntry(debugEntry, null, "", error.message || "Network request failed."); + appendBackgroundDebugEntry(debugRequests, debugEntry); + throw error; + } + + const responsePayload = await readResponsePayload(response); + const responseBody = createDebugBody(responsePayload.text); + completeBackgroundDebugEntry(debugEntry, response, responseBody, ""); + debugEntry.metadata.responseContentType = response.headers.get("content-type") || "(not provided)"; + debugEntry.metadata.responseLength = String(responsePayload.text.length); + appendBackgroundDebugEntry(debugRequests, debugEntry); + + if (!response.ok) { + throw new Error(`${requestName} failed with status ${response.status}.`); + } + + if (responsePayload.error) { + throw new Error(`${requestName} response body could not be read.`); + } + + try { + return parseJsonResponse(responsePayload.text, requestName); + } catch (error) { + error.httpStatus = response.status; + debugEntry.status = "failed"; + debugEntry.error = error.message; + throw error; + } + } + + function createBackgroundDebugEntry(requestName, url, options, startedAt) { + return { + name: requestName, + method: options.method || "GET", + url, + status: "pending", + startedAt: startedAt.toISOString(), + completedAt: null, + durationMs: null, + httpStatus: null, + statusText: "", + requestHeaders: normalizeDebugHeaders(options.headers), + requestBody: formatDebugRequestBody(options.body), + metadata: { + executionContext: "background" + }, + responseHeaders: {}, + responseBody: "", + responseBodyTruncated: false, + error: "" + }; + } + + function completeBackgroundDebugEntry(entry, response, responseBody, errorMessage) { + const completedAt = new Date(); + entry.completedAt = completedAt.toISOString(); + entry.durationMs = completedAt.getTime() - new Date(entry.startedAt).getTime(); + entry.error = errorMessage; + + if (!response) { + entry.status = "failed"; + return; + } + + entry.status = response.ok ? "success" : "failed"; + entry.httpStatus = response.status; + entry.statusText = response.statusText || ""; + entry.responseHeaders = headersToObject(response.headers); + entry.responseBody = responseBody.value; + entry.responseBodyTruncated = responseBody.truncated; + } + + function appendBackgroundDebugEntry(debugRequests, entry) { + if (Array.isArray(debugRequests)) { + debugRequests.push(entry); + } + } + + async function readResponsePayload(response) { + try { + return { + text: await response.text(), + error: "" + }; + } catch (error) { + return { + text: `Unable to read response body: ${error.message || "unknown error"}`, + error: error.message || "unknown error" + }; + } + } + + function parseJsonResponse(text, requestName) { + const normalizedText = String(text) + .replace(/^\uFEFF/, "") + .trimStart() + .replace(/^\)\]\}',?\s*/, "") + .replace(/^while\s*\(1\);\s*/, ""); + + try { + return JSON.parse(normalizedText); + } catch (error) { + const invalidJsonError = new Error(`${requestName} returned an invalid JSON response.`); + invalidJsonError.code = "INVALID_JSON_RESPONSE"; + throw invalidJsonError; + } + } + + function createDebugBody(text) { + if (text.length > DEBUG_BODY_LIMIT) { + return { + value: `${text.slice(0, DEBUG_BODY_LIMIT)}\n... truncated ${text.length - DEBUG_BODY_LIMIT} characters`, + truncated: true + }; + } + + return { + value: text || "(empty response body)", + truncated: false + }; + } + + function normalizeDebugHeaders(headers) { + const normalized = {}; + + Object.entries(headers || {}).forEach(([key, value]) => { + normalized[key] = String(value); + }); + return normalized; + } + + function headersToObject(headers) { + const values = {}; + + if (headers && typeof headers.forEach === "function") { + headers.forEach((value, key) => { + values[key] = value; + }); + } + return values; + } + + function formatDebugRequestBody(body) { + if (!body) { + return ""; + } + + try { + return JSON.stringify(JSON.parse(body), null, 2); + } catch (error) { + return String(body); + } + } + + return { fetchJson, createBackgroundDebugEntry, completeBackgroundDebugEntry, appendBackgroundDebugEntry, readResponsePayload, parseJsonResponse, createDebugBody, normalizeDebugHeaders, headersToObject, formatDebugRequestBody }; +} diff --git a/src/services/spa/accounts.js b/src/services/spa/accounts.js new file mode 100644 index 0000000..f13e6cc --- /dev/null +++ b/src/services/spa/accounts.js @@ -0,0 +1,75 @@ +function createSpaAccounts({ + ACCOUNT_PLANS_PAYLOAD, + ACCOUNT_PLANS_URL, + ACCOUNT_SUMMARY_URL, + extractAccountPlans, + fetchJson, + findAccountPlanId, + normalizeMessageAuthorization +}) { + async function requestAccountPlans(message) { + const partyNumber = typeof message.partyNumber === "string" ? message.partyNumber.trim() : ""; + const accountSummaryAuthorization = normalizeMessageAuthorization( + message.authorizations && message.authorizations.accountSummary, + "account-ti-authorization" + ); + const accountPlansAuthorization = normalizeMessageAuthorization( + message.authorizations && message.authorizations.accountPlans, + "g2m-authorization" + ); + const debugRequests = []; + + if (!partyNumber) { + throw new Error("Customer Party Number is required."); + } + + if (!accountSummaryAuthorization.value) { + throw new Error("ACCP-TS authorization is unavailable. requestConsumerTokenService must complete first."); + } + + if (!accountPlansAuthorization.value) { + throw new Error("OCICONS-TS authorization is unavailable. requestConsumerTokenService must complete first."); + } + + try { + const accountSummary = await fetchJson(ACCOUNT_SUMMARY_URL, { + method: "POST", + credentials: "include", + headers: { + Accept: "application/json", + [accountSummaryAuthorization.name]: accountSummaryAuthorization.value, + "Content-Type": "application/json" + }, + body: JSON.stringify([{ key: "regId", value: partyNumber }]) + }, "requestAccountSummary", debugRequests); + const accountPlanId = findAccountPlanId(accountSummary); + + if (!accountPlanId) { + throw new Error("Account plan id was not found in requestAccountSummary."); + } + + const accountPlansResponse = await fetchJson(`${ACCOUNT_PLANS_URL}/${encodeURIComponent(String(accountPlanId))}`, { + method: "POST", + credentials: "include", + headers: { + Accept: "application/json", + [accountPlansAuthorization.name]: accountPlansAuthorization.value, + "Content-Type": "application/json" + }, + body: JSON.stringify(ACCOUNT_PLANS_PAYLOAD) + }, "requestAccountPlans", debugRequests); + + return { + ok: true, + accountPlanId: String(accountPlanId), + plans: extractAccountPlans(accountPlansResponse), + debugRequests + }; + } catch (error) { + error.debugRequests = debugRequests; + throw error; + } + } + + return { requestAccountPlans }; +} diff --git a/src/services/spa/consumption.js b/src/services/spa/consumption.js new file mode 100644 index 0000000..a5974bf --- /dev/null +++ b/src/services/spa/consumption.js @@ -0,0 +1,58 @@ +function createSpaConsumption({ + CONSUMPTION_TREND_REQUESTS, + CONSUMPTION_TREND_URL, + fetchJson, + normalizeMessageAuthorization +}) { + async function requestConsumptionTrend(message) { + const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : ""; + const period = typeof message.period === "string" ? message.period.toLowerCase() : ""; + const requestName = CONSUMPTION_TREND_REQUESTS[period]; + const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization"); + const debugRequests = []; + + if (!subPlanNum) { + throw new Error("Sub plan number is required for the consumption trend."); + } + + if (!requestName) { + throw new Error(`Unsupported consumption trend period: ${period || "(missing)"}.`); + } + + if (!authorization.value) { + throw new Error("OCICONS-TS authorization is unavailable for the consumption trend."); + } + + const url = `${CONSUMPTION_TREND_URL}/${encodeURIComponent(subPlanNum)}?quarter=All&timePeriod=${period}`; + + try { + const response = await fetchJson(url, { + method: "GET", + credentials: "include", + cache: "no-store", + headers: { + Accept: "application/json", + "G2m-Authorization": authorization.value + } + }, requestName, debugRequests); + const items = Array.isArray(response) + ? response + : Array.isArray(response && response.items) + ? response.items + : []; + + return { + ok: true, + subPlanNum, + period, + items, + debugRequests + }; + } catch (error) { + error.debugRequests = debugRequests; + throw error; + } + } + + return { requestConsumptionTrend }; +} diff --git a/src/services/spa/response-shapes.js b/src/services/spa/response-shapes.js new file mode 100644 index 0000000..e099882 --- /dev/null +++ b/src/services/spa/response-shapes.js @@ -0,0 +1,61 @@ +function createSpaResponseShapes() { + function findNestedObject(value, predicate, visited = new Set()) { + if (!value || typeof value !== "object" || visited.has(value)) { + return null; + } + + visited.add(value); + + if (!Array.isArray(value) && predicate(value)) { + return value; + } + + for (const child of Object.values(value)) { + const match = findNestedObject(child, predicate, visited); + + if (match) { + return match; + } + } + + return null; + } + + function findAccountPlanId(response) { + const account = findNestedObject(response, (value) => ( + Object.prototype.hasOwnProperty.call(value, "id") + && value.id !== null + && value.id !== undefined + && value.id !== "" + )); + return account ? account.id : ""; + } + + function extractAccountPlans(response) { + const plans = []; + const visited = new Set(); + + const visit = (value) => { + if (!value || typeof value !== "object" || visited.has(value)) { + return; + } + + visited.add(value); + + if (!Array.isArray(value) && ( + Object.prototype.hasOwnProperty.call(value, "planType") + || Object.prototype.hasOwnProperty.call(value, "subPlanNum") + )) { + plans.push(value); + return; + } + + Object.values(value).forEach(visit); + }; + + visit(response); + return plans; + } + + return { findNestedObject, findAccountPlanId, extractAccountPlans }; +} diff --git a/src/shared/messages.js b/src/shared/messages.js new file mode 100644 index 0000000..78a7002 --- /dev/null +++ b/src/shared/messages.js @@ -0,0 +1,12 @@ +/** Stable wire names. All payloads remain plain, serializable objects. */ +function createMessageTypes() { + return Object.freeze({ + getXsrfToken: "opportunitiesExtension.getXsrfToken", + refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie", + requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts", + opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress", + requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService", + requestAccountPlans: "opportunitiesExtension.requestAccountPlans", + requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend" + }); +} diff --git a/tests/aiSummary.test.cjs b/tests/aiSummary.test.cjs index cb73109..d7bae4a 100644 --- a/tests/aiSummary.test.cjs +++ b/tests/aiSummary.test.cjs @@ -1,7 +1,5 @@ const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); -const vm = require("node:vm"); +const { loadApplication } = require("./support/application.cjs"); class Element { constructor(tag) { @@ -28,24 +26,6 @@ function setup(mockSalesToken = true) { static now() { return now; } } let visibleAlerts = []; - const source = fs.readFileSync(path.join(__dirname, "../src/content.js"), "utf8"); - const marker = " if (!isAllowedPage()) {"; - assert.ok(source.includes(marker)); - const instrumented = source.replace(marker, ` - accessToken = "test-bearer-secret"; - getXsrfToken = async () => ({ token: "test-xsrf-cookie", cookieName: "XSRF-TOKEN-DYNAMIC", source: "test" }); - refreshXsrfCookie = async () => ({ refreshed: false }); - ${mockSalesToken ? 'requestTokenRelaySalesCommon = async () => "test-sales-common-secret";' : ''} - renderDebugPanel = () => {}; - renderOpportunitiesTable = () => {}; - globalThis.helpers = { - requestGenerateAiSummary, createProductsCountButton, createProductsDetailRow, - createAiSummaryAlert, aiSummariesByOptyNumber, expandedProductOptyNumbers, - opptyProductsByNumber, requestLog, formatHeaderBlock, requestTokenRelay, - getAccessToken: () => accessToken, requestTokenRelaySalesCommon - }; - return; -${marker}`); const context = { document: { createElement: (tag) => new Element(tag), @@ -54,8 +34,21 @@ ${marker}`); fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })), Intl, Date: TestDate, Map, Set }; - vm.runInNewContext(instrumented, context); - return { ...context.helpers, calls, advance: (ms) => { now += ms; }, show: (alerts) => { visibleAlerts = alerts; } }; + const app = loadApplication("content", context); + app.authSessionState.accessToken = "test-bearer-secret"; + app.authXsrfContent.getXsrfToken = async () => ({ token: "test-xsrf-cookie", cookieName: "XSRF-TOKEN-DYNAMIC", source: "test" }); + app.platformContentMessages.refreshXsrfCookie = async () => ({ refreshed: false }); + if (mockSalesToken) app.authSalesCommon.requestTokenRelaySalesCommon = async () => "test-sales-common-secret"; + app.debugContentView.renderDebugPanel = () => {}; + app.opportunitiesTable.renderOpportunitiesTable = () => {}; + return { + ...app.aiSummaryContent, ...app.productsDetails, ...app.aiSummaryState, + ...app.productsContentState, ...app.debugContentLog, ...app.debugState, + requestTokenRelay: app.authTokenRelay.requestTokenRelay, + requestTokenRelaySalesCommon: app.authSalesCommon.requestTokenRelaySalesCommon, + getAccessToken: () => app.authSessionState.accessToken, + calls, advance: (ms) => { now += ms; }, show: (alerts) => { visibleAlerts = alerts; } + }; } function json(data, status = 200) { diff --git a/tests/architecture.test.cjs b/tests/architecture.test.cjs new file mode 100644 index 0000000..fb84dda --- /dev/null +++ b/tests/architecture.test.cjs @@ -0,0 +1,75 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); +const { createHash } = require("node:crypto"); +const { test } = require("node:test"); +const { sources, composeScript } = require("../scripts/compose.cjs"); +const { loadApplication } = require("./support/application.cjs"); + +const root = path.resolve(__dirname, ".."); +const read = file => fs.readFileSync(path.join(root, file), "utf8"); +const json = value => JSON.parse(JSON.stringify(value)); + +test("every source belongs to an explicit runtime composition, with no duplicate factories", () => { + const actual = fs.readdirSync(path.join(root, "src"), { recursive: true }) + .filter(file => file.endsWith(".js")).map(file => `src/${file.replaceAll(path.sep, "/")}`).sort(); + assert.deepEqual([...new Set(Object.values(sources).flat())].sort(), actual); + for (const [target, files] of Object.entries(sources)) { + assert.equal(new Set(files).size, files.length); + const factories = files.flatMap(file => [...read(file).matchAll(/^function (\w+)\(/gm)].map(match => match[1])); + assert.equal(new Set(factories).size, factories.length, target); + assert.equal(composeScript(target), composeScript(target)); + assert.doesNotThrow(() => new vm.Script(composeScript(target))); + for (const file of files) { + const source = read(file); + assert.doesNotMatch(source, /\beval\s*\(|\bnew\s+Function\s*\(|\bimportScripts\s*\(|\brequire\s*\(|^\s*(?:import|export)\s/m, file); + if (file.endsWith("/application.js") || file.endsWith("/main.js")) continue; + const declaration = source.match(/^function (\w+)\(/m)[1]; + for (const factory of factories.filter(name => name !== declaration)) { + assert.doesNotMatch(source, new RegExp(`\\b${factory}\\s*\\(`), `${file} must receive dependencies, not construct ${factory}`); + } + } + } + assert.deepEqual(sources.content.filter(file => sources.background.includes(file)), ["src/shared/messages.js"]); +}); + +test("Redwood cascade is byte-identical to the characterized original", () => { + const hash = createHash("sha256").update(read("src/content/styles/redwood.css")).digest("hex"); + assert.equal(hash, "eaeb7e53757d48a3095f9111c5029d2c923d0dcab92af23454ccd14bdcfc81c4"); +}); + +test("fiscal module can run alone without DOM, browser APIs or network", () => { + const factory = new vm.Script(`${read("src/features/opportunities/fiscal-periods.js")}\ncreateOpportunitiesFiscalPeriods;`).runInNewContext(); + assert.deepEqual(json(factory().getFiscalPeriodRange("Current Fiscal Year", new Date(2027, 0, 15))), { + startCloseDate: "2026-06-01T00:00:00.000Z", endCloseDate: "2027-05-31T00:00:00.000Z" + }); +}); + +test("per-frame state and credentials do not leak between application instances", () => { + const a = loadApplication("content"); + const b = loadApplication("content"); + for (const key of Object.keys(a).filter(key => key.endsWith("State"))) assert.notEqual(a[key], b[key], key); + a.authSessionState.accessToken = "synthetic-general"; + a.authSalesCommonState.salesCommonToken = { access_token: "synthetic-scoped", expiresAt: 123 }; + a.productsContentState.opptyProductsByNumber.set("TEST", { items: [] }); + a.aiSummaryState.aiSummariesByOptyNumber.set("TEST", { summaryText: "Synthetic" }); + assert.equal(b.authSessionState.accessToken, ""); + assert.equal(b.authSalesCommonState.salesCommonToken, null); + assert.equal(b.productsContentState.opptyProductsByNumber.size, 0); + assert.equal(b.aiSummaryState.aiSummariesByOptyNumber.size, 0); +}); + +test("blocked localStorage access remains lazy and handled by preference fallbacks", () => { + const context = {}; + Object.defineProperty(context, "localStorage", { get() { throw new Error("Storage blocked"); } }); + const app = loadApplication("content", context); + assert.doesNotThrow(() => app.preferencesStorage.getSavedOpportunityTypeValues()); + assert.equal(app.preferencesStorage.getSavedOpptyProductsCacheHours(), 24); +}); + +test("visual fixtures load modules without eval or rewriting source markers", () => { + for (const file of ["tests/fixtures/consumption-trend.html", "tests/fixtures/extension.js"]) { + assert.doesNotMatch(read(file), /\beval\s*\(|testableSource|src\/content\.js/); + } +}); diff --git a/tests/characterization.test.cjs b/tests/characterization.test.cjs new file mode 100644 index 0000000..6e68654 --- /dev/null +++ b/tests/characterization.test.cjs @@ -0,0 +1,344 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); +const { root, plain, response, loadContent, loadBackground } = require("./support/characterization.cjs"); +const { composeScript } = require("./support/application.cjs"); + +const PREFIX = "opportunitiesExtension."; +const FILTER_KEY = "opportunities-extension-filter-preferences"; +const TYPE_KEY = "opportunities-extension-opportunity-type-preferences"; +const CACHE_KEY = "opportunitiesExtension.opptyProductsCache.v1"; +const DAY = 86400000; +const range = { startCloseDate: "2026-06-01T00:00:00.000Z", endCloseDate: "2026-08-31T00:00:00.000Z" }; +const drain = () => new Promise(resolve => setImmediate(resolve)); +const productRequest = (requestId, optyNumbers = ["SYNTH-1"]) => ({ + type: PREFIX + "requestOpptyProducts", requestId, optyNumbers, accessToken: "synthetic-products-token" +}); + +test("content bootstrap ignores other origins and preserves the existing tile/listener on reinjection", () => { + const source = composeScript("content"); + const script = new vm.Script(source); + assert.doesNotThrow(() => script.runInNewContext({ + window: { location: { hostname: "unrelated.example", pathname: "/" } }, + document: { getElementById() { assert.fail("DOM touched on unrelated origin"); } } + })); + let listeners = 0; + const context = vm.createContext({ + window: { location: { hostname: "eeho.fa.us2.oraclecloud.com", pathname: "/fscmUI/faces/FuseWelcome" } }, + document: { getElementById: () => ({}) }, + chrome: { runtime: { onMessage: { addListener() { listeners++; } } } }, + MutationObserver: class { constructor() { assert.fail("Observer unnecessary when tile already exists"); } } + }); + script.runInContext(context); + script.runInContext(context); + assert.equal(listeners, 1); +}); + +test("distribution contracts: manifests, execution boundaries and deterministic composition", () => { + const base = { + manifest_version: 3, name: "Opportunities Extension", + description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", + version: "1.0.1", permissions: ["cookies", "storage"], + host_permissions: ["https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*"], + content_scripts: [{ + matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"], + js: ["content.js"], all_frames: true, run_at: "document_idle" + }] + }; + for (const target of ["chromium", "firefox"]) { + const expected = target === "chromium" + ? { ...base, background: { service_worker: "background.js" } } + : { ...base, background: { scripts: ["background.js"] }, browser_specific_settings: { gecko: { id: "opportunities-extension@local.dev" } } }; + assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, "dist", target, "manifest.json"))), expected); + for (const file of ["content.js", "background.js"]) { + const output = fs.readFileSync(path.join(root, "dist", target, file), "utf8"); + assert.equal(output, composeScript(file.replace(".js", ""))); + assert.doesNotThrow(() => new vm.Script(output, { filename: `${target}/${file}` })); + } + } +}); + +test("fiscal dates preserve quarter/year boundaries, leap year and unsupported renewals", () => { + const h = loadContent(); + const cases = [ + ["Current Quarter", [2026, 5, 1], "2026-06-01", "2026-08-31"], + ["Current Quarter", [2026, 7, 31], "2026-06-01", "2026-08-31"], + ["Current Quarter", [2026, 8, 1], "2026-09-01", "2026-11-30"], + ["Current Quarter", [2028, 1, 29], "2027-12-01", "2028-02-29"], + ["Next Quarter", [2026, 10, 30], "2026-12-01", "2027-02-28"], + ["Previous Quarter", [2026, 7, 24], "2026-03-01", "2026-05-31"], + ["Current Fiscal Year", [2026, 4, 31], "2025-06-01", "2026-05-31"], + ["Current Fiscal Year", [2026, 5, 1], "2026-06-01", "2027-05-31"], + ["Current Fiscal Year", [2027, 0, 1], "2026-06-01", "2027-05-31"], + ["4 Rolling Quarters (CQ + 3)", [2026, 8, 16], "2026-09-01", "2027-08-31"] + ]; + for (const [period, date, start, end] of cases) { + assert.deepEqual(plain(h.getFiscalPeriodRange(period, new Date(...date))), { + startCloseDate: start + "T00:00:00.000Z", endCloseDate: end + "T00:00:00.000Z" + }); + } + assert.equal(h.getFiscalPeriodRange("RENEWALS (Current + Past Due)", new Date()), null); +}); + +test("opportunity query preserves fields, record-set OR, date AND, and pagination payload", () => { + const h = loadContent(); + const types = ["ORA_MYSALESTEAMOPTIES", "ORA_MYASSGTERROPTIES", "ORA_CREDITRECEIVER_ISME"]; + h.setTypes(types); + const fields = ["Name", "OptyNumber", "PrimaryRevenue.WinProb", "CustomerAccount", "PrimaryRevenue.RevnAmount", "EffectiveDate", "StatusCode", "DealRisk_c", "Owner", "ForecastGroup_c", "LastUpdateDate", "PrimaryRevenue.RevnAmountCurcyCode", "OptyId"]; + assert.deepEqual(plain(h.createOpportunitiesQueryPayload(range, 15)), { + aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, entity: "Opportunity", + q: { op: "$and", criteria: [ + { op: "$or", criteria: types.map(value => ({ op: "$eq", attribute: "RecordSet", value })) }, + { op: "$wi", attribute: "EffectiveDate", value1: range.startCloseDate, value2: range.endCloseDate, dynamicDate: false } + ] }, keywords: null, keywordsFields: fields, + fields: [...fields, "CustomerAccount.PartyUniqueName", "CustomerAccount.PartyId", "CustomerAccount.PartyNumber"], + sort: [], language: "en", skipInValidFields: true, skipHiddenFromUIFields: true, + copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162", limit: 15, offset: 15 + }); +}); + +test("opportunity pagination uses response offsets/count, ignores stale versions and detects stalled pages", async () => { + const calls = []; + const h = loadContent({ fetch: async (url, options) => { + calls.push({ url, options }); + return response(calls.length === 1 + ? { items: [{ OptyNumber: "S1" }], offset: 0, count: 1, hasMore: true } + : { items: [{ OptyNumber: "S2" }], offset: 1, count: 1, hasMore: false }); + } }); + const items = await h.requestOpportunitiesPage("synthetic-token", "Current Quarter", range, 0, [], 0, 1); + assert.deepEqual(plain(items), [{ OptyNumber: "S1" }, { OptyNumber: "S2" }]); + assert.deepEqual(calls.map(call => JSON.parse(call.options.body).offset), [0, 1]); + assert.equal(calls[0].url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/11.13.18.05/custom-actions/queries"); + assert.deepEqual(plain(calls[0].options.headers), { + Accept: "application/json", Authorization: "Bearer synthetic-token", "Content-Type": "application/json", + Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient" + }); + assert.equal(calls[0].options.method, "POST"); + assert.equal(calls[0].options.credentials, "include"); + h.setVersion(1); + assert.equal(await h.requestOpportunitiesPage("unused", "Current Quarter", range, 0, [], 0, 1), null); + assert.equal(calls.length, 2); + const stalled = loadContent({ fetch: async () => response({ items: [], count: 0, hasMore: true }) }); + await assert.rejects(stalled.requestOpportunitiesPage("synthetic", "Current Quarter", range, 0, [], 0, 1), /without additional results/); +}); + +const opportunity = (number, status, amount) => ({ + Name: `Synthetic cloud ${number}`, OptyNumber: number, StatusCode: status, ForecastGroup_c: "PIPELINE", + CustomerAccount: { PartyId: "customer-1", PartyUniqueName: "Synthetic Customer", PartyNumber: "party-1" }, + Owner: { PartyId: "owner-1", PersonFirstName: "Test", PersonLastName: "Owner", PartyName: "Long Test Owner Name" }, + PrimaryRevenue: { RevnAmount: amount, RevnAmountCurcyCode: "USD", WinProb: 60 }, + EffectiveDate: "2026-08-28", LastUpdateDate: "2026-08-28T13:40:00" +}); + +test("filter storage round-trip, WON_PENDING membership, search, sorting and reference resets", () => { + const preferences = { period: "Next Quarter", stages: ["PIPELINE"], statuses: ["WON"], + customerKeys: ["party:customer-1"], ownerKeys: ["party:owner-1"], + customerSearch: "Synthetic", ownerSearch: "Test", tableSearch: "cloud" }; + const storage = new Map([[FILTER_KEY, JSON.stringify(preferences)]]); + const h = loadContent({ storage }); + const select = { value: "Current Quarter" }; + h.restoreFilterPreferences(select); + assert.equal(select.value, "Next Quarter"); + const items = [opportunity("S1", "WON", "100"), opportunity("S2", "WON_PENDING", "20"), opportunity("S3", "OPEN", "5")]; + h.setTable(items, "revenue"); + assert.deepEqual(plain(h.getSortedOpportunities()).map(item => item.OptyNumber), ["S2", "S1"]); + assert.equal(h.getSortedOpportunities()[0].StatusCode, "WON_PENDING"); + assert.equal(h.getOwnerName(items[0]), "Test Owner"); + assert.equal(h.matchesTableSearch(items[0], "28/08/2026"), true); + assert.equal(h.matchesTableSearch(items[0], "test owner"), true); + h.saveFilterPreferences(select.value); + assert.deepEqual(JSON.parse(storage.get(FILTER_KEY)), preferences); + const reopened = loadContent({ storage }); + reopened.restoreFilterPreferences({ value: "Current Quarter" }); + assert.equal(reopened.filters().tableSearch, "cloud"); + reopened.resetCustomerAndOwnerFiltersForPeriod(); + const nextItems = [...items, { ...opportunity("S4", "WON", "30"), CustomerAccount: { PartyId: "customer-2", PartyUniqueName: "Another Customer" }, Owner: { PartyId: "owner-2", PartyName: "Another Owner" } }]; + reopened.setTable(nextItems); + assert.deepEqual(plain(reopened.filters().customerKeys).sort(), ["party:customer-1", "party:customer-2"]); + assert.deepEqual(plain(reopened.filters().ownerKeys).sort(), ["party:owner-1", "party:owner-2"]); + assert.equal(reopened.filters().tableSearch, "cloud"); + assert.equal(reopened.filters().customerSearch, ""); + assert.equal(reopened.filters().ownerSearch, ""); +}); + +test("malformed preferences fall back, while theme/type/cache keys retain their schema", () => { + const storage = new Map([[FILTER_KEY, "not JSON"], ["opportunities-extension-theme", "dark"], + [TYPE_KEY, JSON.stringify({ opportunityTypeValues: ["ORA_MYOPTIES", "UNKNOWN"], opptyProductsCacheHours: 24 })]]); + const h = loadContent({ storage }); + assert.doesNotThrow(() => h.restoreFilterPreferences({ value: "Current Quarter" })); + assert.equal(h.getSavedTheme(), "dark"); + assert.deepEqual(plain(h.getSavedOpportunityTypeValues()), ["ORA_MYOPTIES"]); + assert.equal(h.getSavedOpptyProductsCacheHours(), 24); + assert.equal(h.filters().tableSearch, ""); +}); + +test("tokenrelay missing-cookie recovery and HTTP failure do not change the request contract", async () => { + let reads = 0; + const calls = []; + const h = loadContent({ xsrf: async () => ({ token: ++reads === 1 ? "" : "synthetic-xsrf", cookieName: "XSRF-TOKEN-SYNTHETIC" }), + fetch: async (url, options) => { calls.push({ url, options }); return response({ access_token: "synthetic-access" }); } }); + assert.equal(await h.requestTokenRelay(), "synthetic-access"); + assert.equal(h.refreshes.length, 1); + assert.equal(calls.length, 1); + assert.equal(calls[0].options.headers["x-xsrf-token"], "synthetic-xsrf"); + assert.equal(calls[0].options.method, "GET"); + for (const status of [400, 401, 403]) { + let attempts = 0; + const rejected = loadContent({ xsrf: async () => ({ token: "synthetic-xsrf" }), + fetch: async () => { attempts++; return response({}, status); } }); + await assert.rejects(rejected.requestTokenRelay(), /failed with status/); + assert.equal(attempts, 2); + assert.equal(rejected.refreshes.length, 1); + } +}); + +for (const family of ["chrome", "firefox"]) { + test(`${family}: products paginate, route progress to sender frame and reuse persisted cache after restart`, async () => { + const calls = []; + const storage = {}; + const h = loadBackground({ family, storage, fetch: async (url, options) => { + calls.push({ url, options }); + const offset = Number(new URL(url).searchParams.get("offset")); + return response({ items: [{ RevnNumber: `SYNTH-${offset}` }], count: 1, offset, hasMore: offset === 0 }); + } }); + const result = await h.dispatch(productRequest("job-1", ["SYNTH-1", "SYNTH-1"])); + assert.deepEqual(result, { ok: true, requestId: "job-1", completed: 1, total: 1, failed: 0, cacheHits: 0 }); + assert.equal(calls.length, 2); + assert.equal(new URL(calls[0].url).pathname, "/crmRestApi/rest/rv:be91c002-2e5d-4ed2-a37e-e0c837bf141f/en/11.13.18.05:9/opportunities/SYNTH-1/child/ChildRevenue"); + assert.equal(new URL(calls[0].url).searchParams.get("q"), "(SplitTypeCode!='DETAILCHILDSPLIT') AND (RecurTypeCode!='CHILDRECUR')"); + assert.equal(new URL(calls[0].url).searchParams.get("limit"), "15"); + assert.equal(new URL(calls[1].url).searchParams.get("offset"), "1"); + assert.equal(calls[0].options.headers.Authorization, "Bearer synthetic-products-token"); + assert.ok(h.progress.every(event => event.tabId === 7 && event.options.frameId === 2)); + assert.equal(h.progress[0].message.type, PREFIX + "opptyProductsProgress"); + assert.equal(h.progress[0].message.items.length, 2); + assert.equal(h.progress.at(-1).message.state, "complete"); + assert.deepEqual(Object.keys(storage[CACHE_KEY]["SYNTH-1"]).sort(), ["fetchedAt", "items"]); + const restarted = loadBackground({ family, storage }); + assert.equal((await restarted.dispatch(productRequest("job-2"))).cacheHits, 1); + h.advance(DAY); + await h.dispatch(productRequest("job-3")); + assert.equal(calls.length, 4, "default TTL expires at 24 hours"); + }); + + test(`${family}: product queue has four workers, shares in-flight results and reports partial failures`, async () => { + const calls = []; + const h = loadBackground({ family, fetch: (url) => new Promise(resolve => calls.push({ url, resolve })) }); + const first = h.dispatch(productRequest("concurrent-1", ["S1", "S2", "S3", "S4", "S5"])); + await drain(); + assert.equal(calls.length, 4); + const duplicate = h.dispatch(productRequest("concurrent-2", ["S1"])); + await drain(); + assert.equal(calls.length, 4); + calls[0].resolve(response({ items: [{ RevnNumber: "R1" }], hasMore: false })); + await drain(); + assert.equal(calls.length, 5); + calls[1].resolve(response({}, 500)); + for (const call of calls.slice(2)) call.resolve(response({ items: [], hasMore: false })); + assert.equal((await duplicate).ok, true); + const result = await first; + assert.equal(result.failed, 1); + assert.equal(result.completed, 5); + assert.equal(h.progress.filter(event => event.message.requestId === "concurrent-1").at(-1).message.state, "complete-with-errors"); + }); + + test(`${family}: custom product TTL and zero-count pagination failures`, async () => { + let calls = 0; + const h = loadBackground({ family, fetch: async () => { calls++; return response({ items: [], hasMore: false }); } }); + const message = { ...productRequest("custom-ttl"), cacheMaxAgeMs: 1000 }; + await h.dispatch(message); + h.advance(999); + assert.equal((await h.dispatch(message)).cacheHits, 1); + assert.equal(calls, 1); + h.advance(1); + assert.equal((await h.dispatch(message)).cacheHits, 0); + assert.equal(calls, 2); + const stalled = loadBackground({ family, fetch: async () => response({ items: [], count: 0, hasMore: true }) }); + assert.equal((await stalled.dispatch(productRequest("stalled"))).failed, 1); + assert.match(stalled.progress[0].message.error, /without additional results/); + }); + + test(`${family}: dynamic XSRF cookie lookup, missing-cookie result and temporary-tab cleanup`, async () => { + const cookie = { name: "XSRF-TOKEN-SYNTHETIC", value: "synthetic-xsrf", domain: "eeho.fa.us2.oraclecloud.com", path: "/", storeId: "test-store" }; + const h = loadBackground({ family, cookies: () => [cookie] }); + const result = await h.dispatch({ type: PREFIX + "getXsrfToken" }); + assert.equal(result.ok, true); + assert.equal(result.cookieName, cookie.name); + assert.equal(result.token, cookie.value); + assert.ok(h.cookieQueries.some(query => query.storeId === "test-store")); + const missing = loadBackground({ family }); + assert.equal((await missing.dispatch({ type: PREFIX + "getXsrfToken" })).token, ""); + const refresh = await missing.dispatch({ type: PREFIX + "refreshXsrfCookie" }); + assert.equal(refresh.refreshed, false); + assert.equal(refresh.durationMs, 15000); + assert.deepEqual(missing.removedTabs, [91]); + assert.equal(missing.tabs[0].active, false); + assert.equal(missing.tabs[0].windowId, 3); + assert.equal(missing.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined); + let refreshHarness; + refreshHarness = loadBackground({ family, cookies: () => refreshHarness.tabs.length ? [cookie] : [] }); + assert.equal((await refreshHarness.dispatch({ type: PREFIX + "refreshXsrfCookie" })).refreshed, true); + assert.deepEqual(refreshHarness.removedTabs, [91]); + }); + + test(`${family}: SPA token selection, account POST contracts and debug envelope`, async () => { + const calls = []; + const h = loadBackground({ family, fetch: async (url, options) => { + calls.push({ url, options }); + if (calls.length === 1) return response([ + { tokenService: "UNRELATED", uiaasHeader: { value: "synthetic-unused" } }, + { tokenService: "ACCP-TS", uiaasHeader: { value: "synthetic-account" } }, + { tokenService: "OCICONS-TS", uiaasHeader: { value: "synthetic-consumption" } } + ]); + if (calls.length === 2) return response({ items: [{ id: "synthetic/id" }] }); + return response({ items: [{ planType: "Synthetic", subPlanNum: "plan-1" }] }); + } }); + const tokens = await h.dispatch({ type: PREFIX + "requestConsumerTokenService" }); + const plans = await h.dispatch({ type: PREFIX + "requestAccountPlans", partyNumber: "synthetic-party", authorizations: tokens.authorizations }); + assert.equal(plans.ok, true); + assert.equal(plans.accountPlanId, "synthetic/id"); + assert.deepEqual(plans.plans, [{ planType: "Synthetic", subPlanNum: "plan-1" }]); + assert.equal(calls[1].url, "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-account/service/accountsummary"); + assert.deepEqual(JSON.parse(calls[1].options.body), [{ key: "regId", value: "synthetic-party" }]); + assert.equal(calls[1].options.headers["account-ti-authorization"], "synthetic-account"); + assert.equal(calls[2].options.headers["g2m-authorization"], "synthetic-consumption"); + assert.equal(calls[2].url, "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/account/accountPlans/synthetic%2Fid"); + assert.deepEqual(JSON.parse(calls[2].options.body), { + hierarchy: "single", products: "all", language: "english", + planStatus: ["active", "awaiting_provisioning", "awaiting_provisioning_error", "draft", "expired", "final_billing", "onhold", "provisioned", "signed", "superseded", "superseded_pending_final_billing", "suspended", "terminated", "terminated_pending_final_billing"] + }); + assert.ok(calls.slice(1).every(call => call.options.method === "POST" && call.options.credentials === "include")); + assert.deepEqual(plans.debugRequests.map(entry => entry.name), ["requestAccountSummary", "requestAccountPlans"]); + assert.ok(plans.debugRequests.every(entry => entry.httpStatus === 200 && entry.status === "success" && entry.durationMs >= 0)); + }); + + test(`${family}: unknown/invalid messages fail without network; storage failure does not discard fetched products`, async () => { + const h = loadBackground({ family }); + assert.equal(h.listener({ type: "unknown" }, {}, () => assert.fail("Unexpected reply")), false); + assert.equal((await h.dispatch({ type: PREFIX + "requestOpptyProducts" })).ok, false); + assert.equal((await h.dispatch({ type: PREFIX + "requestAccountPlans" })).ok, false); + assert.equal((await h.dispatch({ type: PREFIX + "requestConsumptionTrend", subPlanNum: "S1", period: "unsupported" })).ok, false); + const noStorage = loadBackground({ family, storageError: true, fetch: async () => response({ items: [{ RevnNumber: "R1" }], hasMore: false }) }); + assert.equal((await noStorage.dispatch(productRequest("no-storage"))).ok, true); + assert.equal(noStorage.progress[0].message.items.length, 1); + }); + + test(`${family}: consumption network/HTTP/JSON failures retain the response and debug contracts`, async () => { + for (const failure of ["network", "http", "json"]) { + const h = loadBackground({ family, fetch: async () => { + if (failure === "network") throw new Error("Synthetic network error"); + return failure === "http" ? response({}, 401) : new Response("Synthetic fallback"); + } }); + const result = await h.dispatch({ type: PREFIX + "requestConsumptionTrend", subPlanNum: "synthetic-plan", period: "weekly", + authorization: { name: "g2m-authorization", value: "synthetic-token" } }); + assert.equal(result.ok, false); + assert.equal(result.debugRequests.length, 1); + assert.equal(result.debugRequests[0].name, "requestConsumptionServiceWeekly"); + assert.equal(result.debugRequests[0].status, "failed"); + assert.equal(result.debugRequests[0].httpStatus, failure === "network" ? null : failure === "http" ? 401 : 200); + assert.match(result.error, failure === "network" ? /Synthetic network error/ : failure === "http" ? /status 401/ : /invalid JSON/); + } + }); +} diff --git a/tests/consumerTokenSessionRetry.test.cjs b/tests/consumerTokenSessionRetry.test.cjs index 1a92295..289c6b9 100644 --- a/tests/consumerTokenSessionRetry.test.cjs +++ b/tests/consumerTokenSessionRetry.test.cjs @@ -1,9 +1,7 @@ const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); const vm = require("node:vm"); +const { composeScript } = require("./support/application.cjs"); -const root = path.resolve(__dirname, ".."); function createResponse(body, contentType) { return { @@ -24,7 +22,7 @@ function createResponse(body, contentType) { function loadBackground(responses, createdTabs, removedTabs, options = {}) { let listener; - const source = fs.readFileSync(path.join(root, "src", "background.js"), "utf8"); + const source = composeScript("background"); const context = { chrome: { runtime: { diff --git a/tests/consumptionTrend.test.cjs b/tests/consumptionTrend.test.cjs index db1a3fc..5d96c18 100644 --- a/tests/consumptionTrend.test.cjs +++ b/tests/consumptionTrend.test.cjs @@ -1,21 +1,9 @@ const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); const vm = require("node:vm"); +const { loadApplication, composeScript } = require("./support/application.cjs"); -const root = path.resolve(__dirname, ".."); function loadContentHelpers() { - const source = fs.readFileSync(path.join(root, "src", "content.js"), "utf8"); - const marker = "\n if (insertTile()) {"; - const instrumented = source.replace(marker, ` - globalThis.__consumptionTrendTest = { - normalizeConsumptionTrendItems, - formatConsumptionPointLabel, - createNiceConsumptionMaximum, - calculateAverageConsumption - }; -${marker}`); const context = { chrome: { runtime: { onMessage: { addListener() {} } } }, document: { getElementById: () => ({}) }, @@ -36,13 +24,12 @@ ${marker}`); Math }; - vm.runInNewContext(instrumented, context); - return context.__consumptionTrendTest; + return loadApplication("content", context).consumptionModel; } function loadBackground(fetchCalls) { let listener; - const source = fs.readFileSync(path.join(root, "src", "background.js"), "utf8"); + const source = composeScript("background"); const headers = { get(name) { return name.toLowerCase() === "content-type" ? "application/json" : null; diff --git a/tests/fixtures/consumption-trend.html b/tests/fixtures/consumption-trend.html index 69ac380..a371e6f 100644 --- a/tests/fixtures/consumption-trend.html +++ b/tests/fixtures/consumption-trend.html @@ -12,25 +12,21 @@
+ + + + diff --git a/tests/fixtures/extension.js b/tests/fixtures/extension.js new file mode 100644 index 0000000..7198fab --- /dev/null +++ b/tests/fixtures/extension.js @@ -0,0 +1,77 @@ +(() => { + const listeners = []; + const storage = new Map(); + const opportunities = Array.from({ length: 25 }, (_, index) => ({ + OptyNumber: `TEST${index + 1}`, + Name: `Synthetic opportunity ${index + 1} - Database migration`, + CustomerAccount: { PartyUniqueName: "Example public sector customer (Example City, BR)", PartyId: "test-party", PartyNumber: "test-account" }, + Owner: { PersonFirstName: "Example", PersonLastName: "Owner" }, + PrimaryRevenue: { RevnAmountCurcyCode: "USD", RevnAmount: "25000", WinProb: 60 }, + EffectiveDate: "2026-11-15T00:00:00Z", + LastUpdateDate: "2026-09-15T17:00:00Z", + ForecastGroup_c: ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"][index % 5], + StatusCode: ["OPEN", "CLOSED", "LOST", "WON", "WON_PENDING"][index % 5] + })); + const products = [{ ProdGroupName: "Migrate databases to cloud", WorkloadName_c: "Example workload", RevnAmountCurcyCode: "USD", RevnAmount: "25000", TypeCode: "NEWCOMMIT", StatusCode: "OPEN", WinProb: 60, EffectiveDate: "2026-11-15", ConsumptionStartDate_c: "2026-11-15", RampMonths_c: 3 }]; + const trend = Array.from({ length: 30 }, (_, index) => ({ + fyDate: `2026-09-${String((index % 28) + 1).padStart(2, "0")}`, + fiscalTime: [`${String((index % 28) + 1).padStart(2, "0")}-Sep-26`], + fiscalQtr: "FY27-Q2", currencyCode: "USD", arrCd: 0, + usageCategory: index % 4 ? "Funded Allocation" : "OVERAGE", + usedAmount: 1000 + index * 100 + })); + const authorizations = { + accountSummary: { label: "Account-Ti-Authorization", value: "synthetic-accp" }, + accountPlans: { label: "G2m-Authorization", value: "synthetic-ocicons" } + }; + const runtime = { + onMessage: { addListener: listener => listeners.push(listener) }, + async sendMessage(message) { + const operation = message.type.split(".").at(-1); + if (operation === "getXsrfToken") return { ok: true, token: "synthetic-xsrf", cookieName: "XSRF-TOKEN-TEST" }; + if (operation === "requestConsumerTokenService") return { ok: true, authorizations, debugRequests: [] }; + if (operation === "requestOpptyProducts") { + message.optyNumbers.forEach((optyNumber, index) => listeners.forEach(listener => listener({ + type: "opportunitiesExtension.opptyProductsProgress", requestId: message.requestId, + optyNumber, items: products, cachedAt: Date.now(), completed: index + 1, + total: message.optyNumbers.length, state: index + 1 === message.optyNumbers.length ? "complete" : "running" + }))); + return { ok: true, requestId: message.requestId }; + } + if (operation === "requestAccountPlans") return { + ok: true, debugRequests: [], plans: ["ACTIVE", "EXPIRED"].map((hdrStatusCode, index) => ({ + subPlanNum: `PLAN${index + 1}`, planType: "Universal Credits", hdrStatusCode, + bookingCustomer: "Example Customer", soldToPartyName: "Example Sold To", + contractId: "synthetic-contract", startDate: "2026-06-01", endDate: "2027-05-31" + })) + }; + if (operation === "requestConsumptionTrend") return { ok: true, items: trend, debugRequests: [] }; + throw new Error(`Unexpected synthetic message: ${operation}`); + } + }; + const application = window.createFixtureApplication({ + browser: { runtime: { + onMessage: runtime.onMessage, + sendMessage(message, callback) { + const result = runtime.sendMessage(message); + if (callback) result.then(callback); + return result; + } + } }, chrome: undefined, + localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) }, + async fetch(input, options) { + const url = new URL(input); + let body; + if (url.pathname === "/fscmRestApi/tokenrelay") body = { access_token: "synthetic-fusion-token", expires_in: 3600 }; + else if (url.pathname.endsWith("custom-actions/queries")) { + const { offset, limit } = JSON.parse(options.body); + body = { items: opportunities.slice(offset, offset + limit), totalResults: opportunities.length, count: Math.min(limit, opportunities.length - offset), hasMore: offset + limit < opportunities.length, offset, limit }; + } else if (url.pathname.endsWith("summarizations/generate")) body = { summaryText: "Synthetic AI summary.\nText is displayed safely: ." }; + else throw new Error(`Unexpected synthetic fetch: ${url.pathname}`); + return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); + } + }); + application.productsContentJobs.ensureProductsBackgroundMessageHandler(); + document.getElementById("open").addEventListener("click", () => application.contentModal.openOpportunitiesModal()); + application.contentModal.openOpportunitiesModal(); +})(); diff --git a/tests/support/application.cjs b/tests/support/application.cjs new file mode 100644 index 0000000..86e9b0d --- /dev/null +++ b/tests/support/application.cjs @@ -0,0 +1,11 @@ +const vm = require("node:vm"); +const { composeScript } = require("../../scripts/compose.cjs"); + +function loadApplication(target, globals = {}) { + const factory = new vm.Script(composeScript(target, { bootstrap: false }), { + filename: `${target}.test-bundle.js` + }).runInNewContext(globals); + return factory(); +} + +module.exports = { loadApplication, composeScript }; diff --git a/tests/support/characterization.cjs b/tests/support/characterization.cjs new file mode 100644 index 0000000..1d5f701 --- /dev/null +++ b/tests/support/characterization.cjs @@ -0,0 +1,134 @@ +const assert = require("node:assert/strict"); +const path = require("node:path"); +const vm = require("node:vm"); +const { loadApplication, composeScript } = require("./application.cjs"); + +const root = path.resolve(__dirname, "../.."); +const plain = (value) => JSON.parse(JSON.stringify(value)); +const response = (body, status = 200) => new Response(JSON.stringify(body), { + status, headers: { "Content-Type": "application/json" } +}); +const unexpectedFetch = async () => { throw new Error("Unexpected network request in isolated test"); }; + +function clock() { + let now = Date.UTC(2026, 8, 16, 12); + class ClockDate extends Date { + constructor(...args) { super(...(args.length ? args : [now])); } + static now() { return now; } + } + return { Date: ClockDate, advance: (ms) => { now += ms; } }; +} + +function loadContent({ fetch = unexpectedFetch, storage = new Map(), xsrf = null } = {}) { + const refreshes = []; + const context = { + fetch, testXsrf: xsrf, refreshes, + document: { querySelector: () => ({ value: "Current Quarter" }) }, + localStorage: { + getItem: key => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value) + }, + Date, Intl, URL + }; + const app = loadApplication("content", context); + app.debugContentView.renderDebugPanel = () => {}; + if (xsrf) app.authXsrfContent.getXsrfToken = xsrf; + app.platformContentMessages.refreshXsrfCookie = async () => { refreshes.push(true); return { refreshed: true }; }; + return { + ...app.opportunitiesFiscalPeriods, ...app.fusionOpportunities, ...app.authTokenRelay, + ...app.authSalesCommon, ...app.preferencesStorage, ...app.opportunitiesFilters, + ...app.opportunitiesFormatters, ...app.contentTheme, ...app.debugState, + setVersion: value => { app.opportunitiesQueryState.periodRequestVersion = value; }, + setTypes: values => { app.preferencesState.selectedOpportunityTypeValues = new Set(values); }, + setTable: (items, sortKey = "", sortDirection = "ascending") => { + app.opportunitiesTableState.opportunitiesTableState = { items, sortKey, sortDirection }; + app.opportunitiesFilters.resetCustomerFilter(items); + app.opportunitiesFilters.resetOwnerFilter(items); + }, + filters: () => { + const state = app.opportunitiesFilterState; + return { + stages: Array.from(state.selectedStages), statuses: Array.from(state.selectedStatuses), + customerKeys: Array.from(state.selectedCustomerKeys), ownerKeys: Array.from(state.selectedOwnerKeys), + customerSearch: state.customerSearch, ownerSearch: state.ownerSearch, tableSearch: state.tableSearch + }; + }, + storage, refreshes + }; +} + +function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage = {}, cookies = () => [], storageError = false } = {}) { + const time = clock(); + const listeners = []; + const progress = []; + const tabs = []; + const removedTabs = []; + const cookieQueries = []; + const api = { runtime: { onMessage: { addListener: fn => listeners.push(fn) } } }; + const readCookies = details => { cookieQueries.push(plain(details)); return cookies(details); }; + const getStored = key => ({ [key]: storage[key] === undefined ? undefined : plain(storage[key]) }); + const store = value => Object.assign(storage, plain(value)); + const openTab = details => { tabs.push(plain(details)); return { id: 91 }; }; + const send = (tabId, message, options) => progress.push({ tabId, message: plain(message), options: plain(options || {}) }); + if (family === "firefox") { + api.runtime.getBrowserInfo = async () => ({ name: "Firefox" }); + api.storage = { local: { + get: async key => { if (storageError) throw new Error("Storage unavailable"); return getStored(key); }, + set: async value => { if (storageError) throw new Error("Storage unavailable"); store(value); } + } }; + api.tabs = { create: async details => openTab(details), remove: async id => removedTabs.push(id), + sendMessage: async (tabId, message, options) => send(tabId, message, options) }; + api.cookies = { + get: async details => readCookies(details).find(cookie => cookie.name === details.name) || null, + getAll: async details => readCookies(details), + getAllCookieStores: async () => [{ id: "test-store" }] + }; + } else { + const withStorageError = callback => { + api.runtime.lastError = storageError ? { message: "Storage unavailable" } : undefined; + try { callback(); } finally { delete api.runtime.lastError; } + }; + api.storage = { local: { + get(key, callback) { withStorageError(() => callback(getStored(key))); }, + set(value, callback) { withStorageError(() => { if (!storageError) store(value); callback(); }); } + } }; + api.tabs = { + create(details, callback) { callback(openTab(details)); }, + remove(id, callback) { removedTabs.push(id); callback(); }, + sendMessage(tabId, message, options, callback) { + if (typeof options === "function") { callback = options; options = undefined; } + send(tabId, message, options); callback(); + } + }; + api.cookies = { + get(details, callback) { callback(readCookies(details).find(cookie => cookie.name === details.name) || null); }, + getAll(details, callback) { callback(readCookies(details)); }, + getAllCookieStores(callback) { callback([{ id: "test-store" }]); } + }; + } + const context = { + [family === "firefox" ? "browser" : "chrome"]: api, + fetch, URL, Date: time.Date, + setTimeout: (callback, ms) => { time.advance(ms); return setTimeout(callback, 0); } + }; + new vm.Script(composeScript("background"), { filename: "background.js" }) + .runInNewContext(context); + assert.equal(listeners.length, 1, "Background must register one listener synchronously"); + const sender = { id: "synthetic-extension", url: "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome", + tab: { id: 7, windowId: 3, cookieStoreId: "test-store" }, frameId: 2 }; + function dispatch(message) { + return new Promise((resolve, reject) => { + let replies = 0; + const timer = setTimeout(() => reject(new Error("Message did not receive a response")), 2000); + const pending = listeners[0](message, sender, result => { + clearTimeout(timer); + assert.equal(++replies, 1, "Message must receive exactly one response"); + resolve(plain(result)); + }); + if (pending !== true) { clearTimeout(timer); reject(new Error("Expected asynchronous response contract")); } + }); + } + return { dispatch, listener: listeners[0], progress, tabs, removedTabs, cookieQueries, storage, advance: time.advance }; +} + +module.exports = { root, plain, response, loadContent, loadBackground };