Implementation of the opportunity product view

This commit is contained in:
2026-09-07 00:36:56 -03:00
parent 7fef10abd3
commit e4cfb3fa9c
10 changed files with 3082 additions and 18 deletions

View File

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

View File

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

View File

@@ -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/*"