diff --git a/README.md b/README.md index 76ea438..7f3ac4b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ The build recreates `dist/chromium` and `dist/firefox` from the same source. - Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search. - Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators. - Opportunity type view preferences persisted in the browser. +- Background product retrieval with configurable caching, progress tracking, and expandable product details. - Light and dark themes based on Oracle Redwood. - Separate outputs for Chromium/Edge and Firefox. diff --git a/dist/chromium/background.js b/dist/chromium/background.js index c98694e..87093ac 100644 --- a/dist/chromium/background.js +++ b/dist/chromium/background.js @@ -13,7 +13,28 @@ 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 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) { @@ -61,9 +82,237 @@ 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; + } + return false; }); + 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; @@ -181,6 +430,71 @@ }); } + 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)); } diff --git a/dist/chromium/content.js b/dist/chromium/content.js index aa8b46f..e55ed4d 100644 --- a/dist/chromium/content.js +++ b/dist/chromium/content.js @@ -24,14 +24,18 @@ 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 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 = { @@ -60,6 +64,14 @@ "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 accessToken = ""; let periodRequestVersion = 0; @@ -73,6 +85,19 @@ 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(); + let productsProgress = { + state: "idle", + completed: 0, + total: 0, + failed: 0, + cacheHits: 0 + }; + let productsLastCachedAt = 0; let hasSavedCustomerFilter = false; let hasSavedOwnerFilter = false; let customerFilterInitialized = false; @@ -115,6 +140,7 @@ } ensureExtensionStyles(); + ensureProductsBackgroundMessageHandler(); function createOpportunitiesTile() { const wrapper = document.createElement("div"); @@ -198,6 +224,7 @@ 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()); @@ -227,7 +254,19 @@ const subtitleRow = document.createElement("div"); subtitleRow.className = "opportunities-extension-subtitle-row"; - subtitleRow.append(subtitle, authBadge); + + 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); @@ -698,6 +737,18 @@ 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; + renderProductsProgress(); setOpportunitiesTableState({ items: [], status: "loading", @@ -714,6 +765,7 @@ status: "ready", message: "" }); + requestOpptyProductsInBackground(allItems, token); } } catch (error) { if (isCurrentPeriodRequest(requestVersion)) { @@ -809,6 +861,180 @@ } } + 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 cache: ${formatProductsCacheTimestamp(productsLastCachedAt)}` + : ""; + timestamp.title = productsLastCachedAt + ? `Latest product cache: ${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); @@ -821,6 +1047,17 @@ 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; stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); opportunitiesTableState = { items: [], @@ -991,7 +1228,19 @@ item.OptyNumber ? `${OPPORTUNITY_DETAIL_URL}${encodeURIComponent(item.OptyNumber)}` : "" ); - if (column.key === "optyNumber" && item.OptyNumber) { + if (column.key === "name" && item.OptyNumber) { + const nameCellContent = document.createElement("span"); + nameCellContent.className = "opportunities-extension-name-content"; + nameCellContent.append(opportunityLink); + + const productsResult = opptyProductsByNumber.get(String(item.OptyNumber)); + + if (productsResult) { + nameCellContent.append(createProductsCountButton(item.OptyNumber, productsResult)); + } + + 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)); @@ -1014,6 +1263,10 @@ }); tableBody.append(row); + + if (item.OptyNumber && expandedProductOptyNumbers.has(String(item.OptyNumber))) { + tableBody.append(createProductsDetailRow(item)); + } }); } @@ -1663,6 +1916,144 @@ return button; } + 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); + } + + renderOpportunitiesTable(); + }); + return button; + } + + 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); + + 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) { @@ -2396,6 +2787,24 @@ } } + 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); @@ -2406,6 +2815,16 @@ 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; @@ -2468,7 +2887,25 @@ }); fieldset.append(options); - content.append(fieldset); + + 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"; @@ -2479,11 +2916,16 @@ 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 + opportunityTypeValues: selectedValues, + opptyProductsCacheHours })); } catch (error) { // Continue with the saved selection for the active modal when storage is unavailable. @@ -2688,6 +3130,66 @@ 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; @@ -3357,6 +3859,128 @@ 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-products-count { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + min-width: 24px; + min-height: 22px; + padding: 2px 7px; + border: 1px solid #7ca7b2; + border-radius: 999px; + background: #e8f4f6; + color: #006d7a; + font-size: 11px; + font-weight: 700; + line-height: 1; + 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-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; @@ -3644,6 +4268,34 @@ 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; @@ -3865,6 +4517,8 @@ .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-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-debug-panel p, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-empty { color: #c9c5c1; @@ -3875,6 +4529,7 @@ .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-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; @@ -3885,6 +4540,7 @@ .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; @@ -3915,6 +4571,8 @@ .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-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-debug-heading, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 { @@ -3959,7 +4617,8 @@ } .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-table th, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table th { background: #333130; color: #f6f4f2; } @@ -3968,6 +4627,53 @@ 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 { color: #43c4d5; diff --git a/dist/chromium/manifest.json b/dist/chromium/manifest.json index 6ab29d8..2017767 100644 --- a/dist/chromium/manifest.json +++ b/dist/chromium/manifest.json @@ -4,7 +4,8 @@ "description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", "version": "1.0.0", "permissions": [ - "cookies" + "cookies", + "storage" ], "host_permissions": [ "https://eeho.fa.us2.oraclecloud.com/*" diff --git a/dist/firefox/background.js b/dist/firefox/background.js index c98694e..87093ac 100644 --- a/dist/firefox/background.js +++ b/dist/firefox/background.js @@ -13,7 +13,28 @@ 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 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) { @@ -61,9 +82,237 @@ 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; + } + return false; }); + 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; @@ -181,6 +430,71 @@ }); } + 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)); } diff --git a/dist/firefox/content.js b/dist/firefox/content.js index aa8b46f..e55ed4d 100644 --- a/dist/firefox/content.js +++ b/dist/firefox/content.js @@ -24,14 +24,18 @@ 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 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 = { @@ -60,6 +64,14 @@ "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 accessToken = ""; let periodRequestVersion = 0; @@ -73,6 +85,19 @@ 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(); + let productsProgress = { + state: "idle", + completed: 0, + total: 0, + failed: 0, + cacheHits: 0 + }; + let productsLastCachedAt = 0; let hasSavedCustomerFilter = false; let hasSavedOwnerFilter = false; let customerFilterInitialized = false; @@ -115,6 +140,7 @@ } ensureExtensionStyles(); + ensureProductsBackgroundMessageHandler(); function createOpportunitiesTile() { const wrapper = document.createElement("div"); @@ -198,6 +224,7 @@ 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()); @@ -227,7 +254,19 @@ const subtitleRow = document.createElement("div"); subtitleRow.className = "opportunities-extension-subtitle-row"; - subtitleRow.append(subtitle, authBadge); + + 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); @@ -698,6 +737,18 @@ 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; + renderProductsProgress(); setOpportunitiesTableState({ items: [], status: "loading", @@ -714,6 +765,7 @@ status: "ready", message: "" }); + requestOpptyProductsInBackground(allItems, token); } } catch (error) { if (isCurrentPeriodRequest(requestVersion)) { @@ -809,6 +861,180 @@ } } + 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 cache: ${formatProductsCacheTimestamp(productsLastCachedAt)}` + : ""; + timestamp.title = productsLastCachedAt + ? `Latest product cache: ${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); @@ -821,6 +1047,17 @@ 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; stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); opportunitiesTableState = { items: [], @@ -991,7 +1228,19 @@ item.OptyNumber ? `${OPPORTUNITY_DETAIL_URL}${encodeURIComponent(item.OptyNumber)}` : "" ); - if (column.key === "optyNumber" && item.OptyNumber) { + if (column.key === "name" && item.OptyNumber) { + const nameCellContent = document.createElement("span"); + nameCellContent.className = "opportunities-extension-name-content"; + nameCellContent.append(opportunityLink); + + const productsResult = opptyProductsByNumber.get(String(item.OptyNumber)); + + if (productsResult) { + nameCellContent.append(createProductsCountButton(item.OptyNumber, productsResult)); + } + + 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)); @@ -1014,6 +1263,10 @@ }); tableBody.append(row); + + if (item.OptyNumber && expandedProductOptyNumbers.has(String(item.OptyNumber))) { + tableBody.append(createProductsDetailRow(item)); + } }); } @@ -1663,6 +1916,144 @@ return button; } + 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); + } + + renderOpportunitiesTable(); + }); + return button; + } + + 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); + + 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) { @@ -2396,6 +2787,24 @@ } } + 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); @@ -2406,6 +2815,16 @@ 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; @@ -2468,7 +2887,25 @@ }); fieldset.append(options); - content.append(fieldset); + + 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"; @@ -2479,11 +2916,16 @@ 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 + opportunityTypeValues: selectedValues, + opptyProductsCacheHours })); } catch (error) { // Continue with the saved selection for the active modal when storage is unavailable. @@ -2688,6 +3130,66 @@ 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; @@ -3357,6 +3859,128 @@ 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-products-count { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + min-width: 24px; + min-height: 22px; + padding: 2px 7px; + border: 1px solid #7ca7b2; + border-radius: 999px; + background: #e8f4f6; + color: #006d7a; + font-size: 11px; + font-weight: 700; + line-height: 1; + 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-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; @@ -3644,6 +4268,34 @@ 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; @@ -3865,6 +4517,8 @@ .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-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-debug-panel p, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-empty { color: #c9c5c1; @@ -3875,6 +4529,7 @@ .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-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; @@ -3885,6 +4540,7 @@ .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; @@ -3915,6 +4571,8 @@ .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-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-debug-heading, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 { @@ -3959,7 +4617,8 @@ } .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-table th, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table th { background: #333130; color: #f6f4f2; } @@ -3968,6 +4627,53 @@ 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 { color: #43c4d5; diff --git a/dist/firefox/manifest.json b/dist/firefox/manifest.json index 9e02624..77c6e28 100644 --- a/dist/firefox/manifest.json +++ b/dist/firefox/manifest.json @@ -4,7 +4,8 @@ "description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", "version": "1.0.0", "permissions": [ - "cookies" + "cookies", + "storage" ], "host_permissions": [ "https://eeho.fa.us2.oraclecloud.com/*" diff --git a/scripts/build.mjs b/scripts/build.mjs index ff41537..1bb7eed 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -15,7 +15,8 @@ const baseManifest = { description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", version: "1.0.0", permissions: [ - "cookies" + "cookies", + "storage" ], host_permissions: [ "https://eeho.fa.us2.oraclecloud.com/*" diff --git a/src/background.js b/src/background.js index c98694e..87093ac 100644 --- a/src/background.js +++ b/src/background.js @@ -13,7 +13,28 @@ 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 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) { @@ -61,9 +82,237 @@ 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; + } + return false; }); + 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; @@ -181,6 +430,71 @@ }); } + 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)); } diff --git a/src/content.js b/src/content.js index aa8b46f..e55ed4d 100644 --- a/src/content.js +++ b/src/content.js @@ -24,14 +24,18 @@ 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 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 = { @@ -60,6 +64,14 @@ "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 accessToken = ""; let periodRequestVersion = 0; @@ -73,6 +85,19 @@ 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(); + let productsProgress = { + state: "idle", + completed: 0, + total: 0, + failed: 0, + cacheHits: 0 + }; + let productsLastCachedAt = 0; let hasSavedCustomerFilter = false; let hasSavedOwnerFilter = false; let customerFilterInitialized = false; @@ -115,6 +140,7 @@ } ensureExtensionStyles(); + ensureProductsBackgroundMessageHandler(); function createOpportunitiesTile() { const wrapper = document.createElement("div"); @@ -198,6 +224,7 @@ 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()); @@ -227,7 +254,19 @@ const subtitleRow = document.createElement("div"); subtitleRow.className = "opportunities-extension-subtitle-row"; - subtitleRow.append(subtitle, authBadge); + + 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); @@ -698,6 +737,18 @@ 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; + renderProductsProgress(); setOpportunitiesTableState({ items: [], status: "loading", @@ -714,6 +765,7 @@ status: "ready", message: "" }); + requestOpptyProductsInBackground(allItems, token); } } catch (error) { if (isCurrentPeriodRequest(requestVersion)) { @@ -809,6 +861,180 @@ } } + 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 cache: ${formatProductsCacheTimestamp(productsLastCachedAt)}` + : ""; + timestamp.title = productsLastCachedAt + ? `Latest product cache: ${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); @@ -821,6 +1047,17 @@ 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; stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); opportunitiesTableState = { items: [], @@ -991,7 +1228,19 @@ item.OptyNumber ? `${OPPORTUNITY_DETAIL_URL}${encodeURIComponent(item.OptyNumber)}` : "" ); - if (column.key === "optyNumber" && item.OptyNumber) { + if (column.key === "name" && item.OptyNumber) { + const nameCellContent = document.createElement("span"); + nameCellContent.className = "opportunities-extension-name-content"; + nameCellContent.append(opportunityLink); + + const productsResult = opptyProductsByNumber.get(String(item.OptyNumber)); + + if (productsResult) { + nameCellContent.append(createProductsCountButton(item.OptyNumber, productsResult)); + } + + 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)); @@ -1014,6 +1263,10 @@ }); tableBody.append(row); + + if (item.OptyNumber && expandedProductOptyNumbers.has(String(item.OptyNumber))) { + tableBody.append(createProductsDetailRow(item)); + } }); } @@ -1663,6 +1916,144 @@ return button; } + 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); + } + + renderOpportunitiesTable(); + }); + return button; + } + + 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); + + 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) { @@ -2396,6 +2787,24 @@ } } + 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); @@ -2406,6 +2815,16 @@ 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; @@ -2468,7 +2887,25 @@ }); fieldset.append(options); - content.append(fieldset); + + 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"; @@ -2479,11 +2916,16 @@ 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 + opportunityTypeValues: selectedValues, + opptyProductsCacheHours })); } catch (error) { // Continue with the saved selection for the active modal when storage is unavailable. @@ -2688,6 +3130,66 @@ 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; @@ -3357,6 +3859,128 @@ 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-products-count { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + min-width: 24px; + min-height: 22px; + padding: 2px 7px; + border: 1px solid #7ca7b2; + border-radius: 999px; + background: #e8f4f6; + color: #006d7a; + font-size: 11px; + font-weight: 700; + line-height: 1; + 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-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; @@ -3644,6 +4268,34 @@ 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; @@ -3865,6 +4517,8 @@ .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-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-debug-panel p, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-empty { color: #c9c5c1; @@ -3875,6 +4529,7 @@ .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-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; @@ -3885,6 +4540,7 @@ .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; @@ -3915,6 +4571,8 @@ .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-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-debug-heading, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 { @@ -3959,7 +4617,8 @@ } .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-table th, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table th { background: #333130; color: #f6f4f2; } @@ -3968,6 +4627,53 @@ 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 { color: #43c4d5;