Implementation of the opportunity product view
This commit is contained in:
@@ -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.
|
- 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.
|
- Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators.
|
||||||
- Opportunity type view preferences persisted in the browser.
|
- 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.
|
- Light and dark themes based on Oracle Redwood.
|
||||||
- Separate outputs for Chromium/Edge and Firefox.
|
- Separate outputs for Chromium/Edge and Firefox.
|
||||||
|
|
||||||
|
|||||||
314
dist/chromium/background.js
vendored
314
dist/chromium/background.js
vendored
@@ -13,7 +13,28 @@
|
|||||||
const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list";
|
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_TIMEOUT_MS = 15000;
|
||||||
const COOKIE_REFRESH_POLL_MS = 250;
|
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;
|
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) => {
|
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
if (!message || !message.type) {
|
if (!message || !message.type) {
|
||||||
@@ -61,9 +82,237 @@
|
|||||||
return true;
|
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;
|
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) {
|
async function refreshXsrfCookie(sender) {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const previous = (await getXsrfTokenCookie()).cookie;
|
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) {
|
function wait(durationMs) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, durationMs));
|
return new Promise((resolve) => setTimeout(resolve, durationMs));
|
||||||
}
|
}
|
||||||
|
|||||||
716
dist/chromium/content.js
vendored
716
dist/chromium/content.js
vendored
@@ -24,14 +24,18 @@
|
|||||||
const OWNER_FILTER_LIST_ID = "opportunities-extension-owner-filter-list";
|
const OWNER_FILTER_LIST_ID = "opportunities-extension-owner-filter-list";
|
||||||
const TABLE_SEARCH_ID = "opportunities-extension-table-search";
|
const TABLE_SEARCH_ID = "opportunities-extension-table-search";
|
||||||
const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard";
|
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 FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences";
|
||||||
const THEME_STORAGE_KEY = "opportunities-extension-theme";
|
const THEME_STORAGE_KEY = "opportunities-extension-theme";
|
||||||
const OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY = "opportunities-extension-opportunity-type-preferences";
|
const OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY = "opportunities-extension-opportunity-type-preferences";
|
||||||
const PREFERENCES_MODAL_ID = "opportunities-extension-preferences-modal";
|
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 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 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 DEBUG_BODY_LIMIT = 12000;
|
||||||
const OPPORTUNITIES_PAGE_LIMIT = 15;
|
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 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 OPPORTUNITIES_QUERY_URL = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/11.13.18.05/custom-actions/queries";
|
||||||
const AUTH_STATUS = {
|
const AUTH_STATUS = {
|
||||||
@@ -60,6 +64,14 @@
|
|||||||
"ORA_MYASSGTERROPTIES",
|
"ORA_MYASSGTERROPTIES",
|
||||||
"ORA_CREDITRECEIVER_ISME"
|
"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 tokenRelayRequest = null;
|
||||||
let accessToken = "";
|
let accessToken = "";
|
||||||
let periodRequestVersion = 0;
|
let periodRequestVersion = 0;
|
||||||
@@ -73,6 +85,19 @@
|
|||||||
let ownerSearch = "";
|
let ownerSearch = "";
|
||||||
let tableSearch = "";
|
let tableSearch = "";
|
||||||
let selectedOpportunityTypeValues = new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES);
|
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 hasSavedCustomerFilter = false;
|
||||||
let hasSavedOwnerFilter = false;
|
let hasSavedOwnerFilter = false;
|
||||||
let customerFilterInitialized = false;
|
let customerFilterInitialized = false;
|
||||||
@@ -115,6 +140,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensureExtensionStyles();
|
ensureExtensionStyles();
|
||||||
|
ensureProductsBackgroundMessageHandler();
|
||||||
|
|
||||||
function createOpportunitiesTile() {
|
function createOpportunitiesTile() {
|
||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
@@ -198,6 +224,7 @@
|
|||||||
authStatus = AUTH_STATUS.idle;
|
authStatus = AUTH_STATUS.idle;
|
||||||
resetOpportunitiesTable();
|
resetOpportunitiesTable();
|
||||||
selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues());
|
selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues());
|
||||||
|
opptyProductsCacheHours = getSavedOpptyProductsCacheHours();
|
||||||
overlay.id = MODAL_ID;
|
overlay.id = MODAL_ID;
|
||||||
overlay.className = "opportunities-extension-modal";
|
overlay.className = "opportunities-extension-modal";
|
||||||
overlay.setAttribute("data-theme", getSavedTheme());
|
overlay.setAttribute("data-theme", getSavedTheme());
|
||||||
@@ -227,7 +254,19 @@
|
|||||||
|
|
||||||
const subtitleRow = document.createElement("div");
|
const subtitleRow = document.createElement("div");
|
||||||
subtitleRow.className = "opportunities-extension-subtitle-row";
|
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");
|
const titleBlock = document.createElement("div");
|
||||||
titleBlock.append(title, subtitleRow);
|
titleBlock.append(title, subtitleRow);
|
||||||
@@ -698,6 +737,18 @@
|
|||||||
|
|
||||||
async function requestOpportunities(token, period, dateRange, requestVersion) {
|
async function requestOpportunities(token, period, dateRange, requestVersion) {
|
||||||
if (isCurrentPeriodRequest(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({
|
setOpportunitiesTableState({
|
||||||
items: [],
|
items: [],
|
||||||
status: "loading",
|
status: "loading",
|
||||||
@@ -714,6 +765,7 @@
|
|||||||
status: "ready",
|
status: "ready",
|
||||||
message: ""
|
message: ""
|
||||||
});
|
});
|
||||||
|
requestOpptyProductsInBackground(allItems, token);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isCurrentPeriodRequest(requestVersion)) {
|
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() {
|
function resetOpportunitiesTable() {
|
||||||
selectedStages = new Set(STAGE_OPTIONS);
|
selectedStages = new Set(STAGE_OPTIONS);
|
||||||
selectedStatuses = new Set(STATUS_OPTIONS);
|
selectedStatuses = new Set(STATUS_OPTIONS);
|
||||||
@@ -821,6 +1047,17 @@
|
|||||||
hasSavedOwnerFilter = false;
|
hasSavedOwnerFilter = false;
|
||||||
customerFilterInitialized = false;
|
customerFilterInitialized = false;
|
||||||
ownerFilterInitialized = 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])]);
|
stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]);
|
||||||
opportunitiesTableState = {
|
opportunitiesTableState = {
|
||||||
items: [],
|
items: [],
|
||||||
@@ -991,7 +1228,19 @@
|
|||||||
item.OptyNumber ? `${OPPORTUNITY_DETAIL_URL}${encodeURIComponent(item.OptyNumber)}` : ""
|
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");
|
const opportunityCellContent = document.createElement("span");
|
||||||
opportunityCellContent.className = "opportunities-extension-opty-number-content";
|
opportunityCellContent.className = "opportunities-extension-opty-number-content";
|
||||||
opportunityCellContent.append(opportunityLink, createCopyOpportunityButton(item.OptyNumber));
|
opportunityCellContent.append(opportunityLink, createCopyOpportunityButton(item.OptyNumber));
|
||||||
@@ -1014,6 +1263,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
tableBody.append(row);
|
tableBody.append(row);
|
||||||
|
|
||||||
|
if (item.OptyNumber && expandedProductOptyNumbers.has(String(item.OptyNumber))) {
|
||||||
|
tableBody.append(createProductsDetailRow(item));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1663,6 +1916,144 @@
|
|||||||
return button;
|
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) {
|
async function copyOpportunityNumber(optyNumber) {
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
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() {
|
function openPreferencesModal() {
|
||||||
const overlay = document.getElementById(MODAL_ID);
|
const overlay = document.getElementById(MODAL_ID);
|
||||||
|
|
||||||
@@ -2406,6 +2815,16 @@
|
|||||||
const existingModal = document.getElementById(PREFERENCES_MODAL_ID);
|
const existingModal = document.getElementById(PREFERENCES_MODAL_ID);
|
||||||
|
|
||||||
if (existingModal) {
|
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.hidden = false;
|
||||||
existingModal.querySelector("input")?.focus();
|
existingModal.querySelector("input")?.focus();
|
||||||
return;
|
return;
|
||||||
@@ -2468,7 +2887,25 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
fieldset.append(options);
|
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");
|
const footer = document.createElement("footer");
|
||||||
footer.className = "opportunities-extension-preferences-footer";
|
footer.className = "opportunities-extension-preferences-footer";
|
||||||
@@ -2479,11 +2916,16 @@
|
|||||||
saveButton.textContent = "Save";
|
saveButton.textContent = "Save";
|
||||||
saveButton.addEventListener("click", () => {
|
saveButton.addEventListener("click", () => {
|
||||||
const selectedValues = Array.from(preferencesModal.querySelectorAll("input[name='opportunityTypeView']:checked"), (checkbox) => checkbox.value);
|
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);
|
selectedOpportunityTypeValues = new Set(selectedValues);
|
||||||
|
opptyProductsCacheHours = OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === selectedCacheHours)
|
||||||
|
? selectedCacheHours
|
||||||
|
: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({
|
localStorage.setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({
|
||||||
opportunityTypeValues: selectedValues
|
opportunityTypeValues: selectedValues,
|
||||||
|
opptyProductsCacheHours
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Continue with the saved selection for the active modal when storage is unavailable.
|
// Continue with the saved selection for the active modal when storage is unavailable.
|
||||||
@@ -2688,6 +3130,66 @@
|
|||||||
background: #6f5a7f;
|
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 {
|
.opportunities-extension-icon-button {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -3357,6 +3859,128 @@
|
|||||||
max-width: 100%;
|
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 {
|
.opportunities-extension-copy-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3644,6 +4268,34 @@
|
|||||||
cursor: pointer;
|
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 {
|
.opportunities-extension-preferences-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
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-header p,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card span,
|
.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-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-panel p,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-empty {
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-empty {
|
||||||
color: #c9c5c1;
|
color: #c9c5c1;
|
||||||
@@ -3875,6 +4529,7 @@
|
|||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-surface,
|
.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-customer-filter-panel,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-dialog,
|
.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-panel,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] {
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] {
|
||||||
border-color: #4e4a46;
|
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-customer-filter-search,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-search-field input,
|
.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-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-stage-filter-button,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button {
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button {
|
||||||
border-color: #6a6560;
|
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-header h2,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-fieldset legend,
|
.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-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-heading,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre,
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 {
|
.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 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;
|
background: #333130;
|
||||||
color: #f6f4f2;
|
color: #f6f4f2;
|
||||||
}
|
}
|
||||||
@@ -3968,6 +4627,53 @@
|
|||||||
border-bottom-color: #3f3c39;
|
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-table a,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button {
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button {
|
||||||
color: #43c4d5;
|
color: #43c4d5;
|
||||||
|
|||||||
3
dist/chromium/manifest.json
vendored
3
dist/chromium/manifest.json
vendored
@@ -4,7 +4,8 @@
|
|||||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"cookies"
|
"cookies",
|
||||||
|
"storage"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*"
|
"https://eeho.fa.us2.oraclecloud.com/*"
|
||||||
|
|||||||
314
dist/firefox/background.js
vendored
314
dist/firefox/background.js
vendored
@@ -13,7 +13,28 @@
|
|||||||
const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list";
|
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_TIMEOUT_MS = 15000;
|
||||||
const COOKIE_REFRESH_POLL_MS = 250;
|
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;
|
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) => {
|
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
if (!message || !message.type) {
|
if (!message || !message.type) {
|
||||||
@@ -61,9 +82,237 @@
|
|||||||
return true;
|
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;
|
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) {
|
async function refreshXsrfCookie(sender) {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const previous = (await getXsrfTokenCookie()).cookie;
|
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) {
|
function wait(durationMs) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, durationMs));
|
return new Promise((resolve) => setTimeout(resolve, durationMs));
|
||||||
}
|
}
|
||||||
|
|||||||
716
dist/firefox/content.js
vendored
716
dist/firefox/content.js
vendored
@@ -24,14 +24,18 @@
|
|||||||
const OWNER_FILTER_LIST_ID = "opportunities-extension-owner-filter-list";
|
const OWNER_FILTER_LIST_ID = "opportunities-extension-owner-filter-list";
|
||||||
const TABLE_SEARCH_ID = "opportunities-extension-table-search";
|
const TABLE_SEARCH_ID = "opportunities-extension-table-search";
|
||||||
const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard";
|
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 FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences";
|
||||||
const THEME_STORAGE_KEY = "opportunities-extension-theme";
|
const THEME_STORAGE_KEY = "opportunities-extension-theme";
|
||||||
const OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY = "opportunities-extension-opportunity-type-preferences";
|
const OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY = "opportunities-extension-opportunity-type-preferences";
|
||||||
const PREFERENCES_MODAL_ID = "opportunities-extension-preferences-modal";
|
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 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 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 DEBUG_BODY_LIMIT = 12000;
|
||||||
const OPPORTUNITIES_PAGE_LIMIT = 15;
|
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 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 OPPORTUNITIES_QUERY_URL = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/11.13.18.05/custom-actions/queries";
|
||||||
const AUTH_STATUS = {
|
const AUTH_STATUS = {
|
||||||
@@ -60,6 +64,14 @@
|
|||||||
"ORA_MYASSGTERROPTIES",
|
"ORA_MYASSGTERROPTIES",
|
||||||
"ORA_CREDITRECEIVER_ISME"
|
"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 tokenRelayRequest = null;
|
||||||
let accessToken = "";
|
let accessToken = "";
|
||||||
let periodRequestVersion = 0;
|
let periodRequestVersion = 0;
|
||||||
@@ -73,6 +85,19 @@
|
|||||||
let ownerSearch = "";
|
let ownerSearch = "";
|
||||||
let tableSearch = "";
|
let tableSearch = "";
|
||||||
let selectedOpportunityTypeValues = new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES);
|
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 hasSavedCustomerFilter = false;
|
||||||
let hasSavedOwnerFilter = false;
|
let hasSavedOwnerFilter = false;
|
||||||
let customerFilterInitialized = false;
|
let customerFilterInitialized = false;
|
||||||
@@ -115,6 +140,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensureExtensionStyles();
|
ensureExtensionStyles();
|
||||||
|
ensureProductsBackgroundMessageHandler();
|
||||||
|
|
||||||
function createOpportunitiesTile() {
|
function createOpportunitiesTile() {
|
||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
@@ -198,6 +224,7 @@
|
|||||||
authStatus = AUTH_STATUS.idle;
|
authStatus = AUTH_STATUS.idle;
|
||||||
resetOpportunitiesTable();
|
resetOpportunitiesTable();
|
||||||
selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues());
|
selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues());
|
||||||
|
opptyProductsCacheHours = getSavedOpptyProductsCacheHours();
|
||||||
overlay.id = MODAL_ID;
|
overlay.id = MODAL_ID;
|
||||||
overlay.className = "opportunities-extension-modal";
|
overlay.className = "opportunities-extension-modal";
|
||||||
overlay.setAttribute("data-theme", getSavedTheme());
|
overlay.setAttribute("data-theme", getSavedTheme());
|
||||||
@@ -227,7 +254,19 @@
|
|||||||
|
|
||||||
const subtitleRow = document.createElement("div");
|
const subtitleRow = document.createElement("div");
|
||||||
subtitleRow.className = "opportunities-extension-subtitle-row";
|
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");
|
const titleBlock = document.createElement("div");
|
||||||
titleBlock.append(title, subtitleRow);
|
titleBlock.append(title, subtitleRow);
|
||||||
@@ -698,6 +737,18 @@
|
|||||||
|
|
||||||
async function requestOpportunities(token, period, dateRange, requestVersion) {
|
async function requestOpportunities(token, period, dateRange, requestVersion) {
|
||||||
if (isCurrentPeriodRequest(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({
|
setOpportunitiesTableState({
|
||||||
items: [],
|
items: [],
|
||||||
status: "loading",
|
status: "loading",
|
||||||
@@ -714,6 +765,7 @@
|
|||||||
status: "ready",
|
status: "ready",
|
||||||
message: ""
|
message: ""
|
||||||
});
|
});
|
||||||
|
requestOpptyProductsInBackground(allItems, token);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isCurrentPeriodRequest(requestVersion)) {
|
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() {
|
function resetOpportunitiesTable() {
|
||||||
selectedStages = new Set(STAGE_OPTIONS);
|
selectedStages = new Set(STAGE_OPTIONS);
|
||||||
selectedStatuses = new Set(STATUS_OPTIONS);
|
selectedStatuses = new Set(STATUS_OPTIONS);
|
||||||
@@ -821,6 +1047,17 @@
|
|||||||
hasSavedOwnerFilter = false;
|
hasSavedOwnerFilter = false;
|
||||||
customerFilterInitialized = false;
|
customerFilterInitialized = false;
|
||||||
ownerFilterInitialized = 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])]);
|
stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]);
|
||||||
opportunitiesTableState = {
|
opportunitiesTableState = {
|
||||||
items: [],
|
items: [],
|
||||||
@@ -991,7 +1228,19 @@
|
|||||||
item.OptyNumber ? `${OPPORTUNITY_DETAIL_URL}${encodeURIComponent(item.OptyNumber)}` : ""
|
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");
|
const opportunityCellContent = document.createElement("span");
|
||||||
opportunityCellContent.className = "opportunities-extension-opty-number-content";
|
opportunityCellContent.className = "opportunities-extension-opty-number-content";
|
||||||
opportunityCellContent.append(opportunityLink, createCopyOpportunityButton(item.OptyNumber));
|
opportunityCellContent.append(opportunityLink, createCopyOpportunityButton(item.OptyNumber));
|
||||||
@@ -1014,6 +1263,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
tableBody.append(row);
|
tableBody.append(row);
|
||||||
|
|
||||||
|
if (item.OptyNumber && expandedProductOptyNumbers.has(String(item.OptyNumber))) {
|
||||||
|
tableBody.append(createProductsDetailRow(item));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1663,6 +1916,144 @@
|
|||||||
return button;
|
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) {
|
async function copyOpportunityNumber(optyNumber) {
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
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() {
|
function openPreferencesModal() {
|
||||||
const overlay = document.getElementById(MODAL_ID);
|
const overlay = document.getElementById(MODAL_ID);
|
||||||
|
|
||||||
@@ -2406,6 +2815,16 @@
|
|||||||
const existingModal = document.getElementById(PREFERENCES_MODAL_ID);
|
const existingModal = document.getElementById(PREFERENCES_MODAL_ID);
|
||||||
|
|
||||||
if (existingModal) {
|
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.hidden = false;
|
||||||
existingModal.querySelector("input")?.focus();
|
existingModal.querySelector("input")?.focus();
|
||||||
return;
|
return;
|
||||||
@@ -2468,7 +2887,25 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
fieldset.append(options);
|
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");
|
const footer = document.createElement("footer");
|
||||||
footer.className = "opportunities-extension-preferences-footer";
|
footer.className = "opportunities-extension-preferences-footer";
|
||||||
@@ -2479,11 +2916,16 @@
|
|||||||
saveButton.textContent = "Save";
|
saveButton.textContent = "Save";
|
||||||
saveButton.addEventListener("click", () => {
|
saveButton.addEventListener("click", () => {
|
||||||
const selectedValues = Array.from(preferencesModal.querySelectorAll("input[name='opportunityTypeView']:checked"), (checkbox) => checkbox.value);
|
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);
|
selectedOpportunityTypeValues = new Set(selectedValues);
|
||||||
|
opptyProductsCacheHours = OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === selectedCacheHours)
|
||||||
|
? selectedCacheHours
|
||||||
|
: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({
|
localStorage.setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({
|
||||||
opportunityTypeValues: selectedValues
|
opportunityTypeValues: selectedValues,
|
||||||
|
opptyProductsCacheHours
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Continue with the saved selection for the active modal when storage is unavailable.
|
// Continue with the saved selection for the active modal when storage is unavailable.
|
||||||
@@ -2688,6 +3130,66 @@
|
|||||||
background: #6f5a7f;
|
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 {
|
.opportunities-extension-icon-button {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -3357,6 +3859,128 @@
|
|||||||
max-width: 100%;
|
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 {
|
.opportunities-extension-copy-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3644,6 +4268,34 @@
|
|||||||
cursor: pointer;
|
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 {
|
.opportunities-extension-preferences-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
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-header p,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card span,
|
.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-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-panel p,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-empty {
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-empty {
|
||||||
color: #c9c5c1;
|
color: #c9c5c1;
|
||||||
@@ -3875,6 +4529,7 @@
|
|||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-surface,
|
.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-customer-filter-panel,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-dialog,
|
.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-panel,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] {
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] {
|
||||||
border-color: #4e4a46;
|
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-customer-filter-search,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-search-field input,
|
.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-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-stage-filter-button,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button {
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button {
|
||||||
border-color: #6a6560;
|
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-header h2,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-fieldset legend,
|
.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-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-heading,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre,
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 {
|
.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 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;
|
background: #333130;
|
||||||
color: #f6f4f2;
|
color: #f6f4f2;
|
||||||
}
|
}
|
||||||
@@ -3968,6 +4627,53 @@
|
|||||||
border-bottom-color: #3f3c39;
|
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-table a,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button {
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button {
|
||||||
color: #43c4d5;
|
color: #43c4d5;
|
||||||
|
|||||||
3
dist/firefox/manifest.json
vendored
3
dist/firefox/manifest.json
vendored
@@ -4,7 +4,8 @@
|
|||||||
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"cookies"
|
"cookies",
|
||||||
|
"storage"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*"
|
"https://eeho.fa.us2.oraclecloud.com/*"
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ const baseManifest = {
|
|||||||
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
permissions: [
|
permissions: [
|
||||||
"cookies"
|
"cookies",
|
||||||
|
"storage"
|
||||||
],
|
],
|
||||||
host_permissions: [
|
host_permissions: [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*"
|
"https://eeho.fa.us2.oraclecloud.com/*"
|
||||||
|
|||||||
@@ -13,7 +13,28 @@
|
|||||||
const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list";
|
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_TIMEOUT_MS = 15000;
|
||||||
const COOKIE_REFRESH_POLL_MS = 250;
|
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;
|
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) => {
|
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
if (!message || !message.type) {
|
if (!message || !message.type) {
|
||||||
@@ -61,9 +82,237 @@
|
|||||||
return true;
|
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;
|
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) {
|
async function refreshXsrfCookie(sender) {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const previous = (await getXsrfTokenCookie()).cookie;
|
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) {
|
function wait(durationMs) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, durationMs));
|
return new Promise((resolve) => setTimeout(resolve, durationMs));
|
||||||
}
|
}
|
||||||
|
|||||||
716
src/content.js
716
src/content.js
@@ -24,14 +24,18 @@
|
|||||||
const OWNER_FILTER_LIST_ID = "opportunities-extension-owner-filter-list";
|
const OWNER_FILTER_LIST_ID = "opportunities-extension-owner-filter-list";
|
||||||
const TABLE_SEARCH_ID = "opportunities-extension-table-search";
|
const TABLE_SEARCH_ID = "opportunities-extension-table-search";
|
||||||
const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard";
|
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 FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences";
|
||||||
const THEME_STORAGE_KEY = "opportunities-extension-theme";
|
const THEME_STORAGE_KEY = "opportunities-extension-theme";
|
||||||
const OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY = "opportunities-extension-opportunity-type-preferences";
|
const OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY = "opportunities-extension-opportunity-type-preferences";
|
||||||
const PREFERENCES_MODAL_ID = "opportunities-extension-preferences-modal";
|
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 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 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 DEBUG_BODY_LIMIT = 12000;
|
||||||
const OPPORTUNITIES_PAGE_LIMIT = 15;
|
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 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 OPPORTUNITIES_QUERY_URL = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/11.13.18.05/custom-actions/queries";
|
||||||
const AUTH_STATUS = {
|
const AUTH_STATUS = {
|
||||||
@@ -60,6 +64,14 @@
|
|||||||
"ORA_MYASSGTERROPTIES",
|
"ORA_MYASSGTERROPTIES",
|
||||||
"ORA_CREDITRECEIVER_ISME"
|
"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 tokenRelayRequest = null;
|
||||||
let accessToken = "";
|
let accessToken = "";
|
||||||
let periodRequestVersion = 0;
|
let periodRequestVersion = 0;
|
||||||
@@ -73,6 +85,19 @@
|
|||||||
let ownerSearch = "";
|
let ownerSearch = "";
|
||||||
let tableSearch = "";
|
let tableSearch = "";
|
||||||
let selectedOpportunityTypeValues = new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES);
|
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 hasSavedCustomerFilter = false;
|
||||||
let hasSavedOwnerFilter = false;
|
let hasSavedOwnerFilter = false;
|
||||||
let customerFilterInitialized = false;
|
let customerFilterInitialized = false;
|
||||||
@@ -115,6 +140,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensureExtensionStyles();
|
ensureExtensionStyles();
|
||||||
|
ensureProductsBackgroundMessageHandler();
|
||||||
|
|
||||||
function createOpportunitiesTile() {
|
function createOpportunitiesTile() {
|
||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
@@ -198,6 +224,7 @@
|
|||||||
authStatus = AUTH_STATUS.idle;
|
authStatus = AUTH_STATUS.idle;
|
||||||
resetOpportunitiesTable();
|
resetOpportunitiesTable();
|
||||||
selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues());
|
selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues());
|
||||||
|
opptyProductsCacheHours = getSavedOpptyProductsCacheHours();
|
||||||
overlay.id = MODAL_ID;
|
overlay.id = MODAL_ID;
|
||||||
overlay.className = "opportunities-extension-modal";
|
overlay.className = "opportunities-extension-modal";
|
||||||
overlay.setAttribute("data-theme", getSavedTheme());
|
overlay.setAttribute("data-theme", getSavedTheme());
|
||||||
@@ -227,7 +254,19 @@
|
|||||||
|
|
||||||
const subtitleRow = document.createElement("div");
|
const subtitleRow = document.createElement("div");
|
||||||
subtitleRow.className = "opportunities-extension-subtitle-row";
|
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");
|
const titleBlock = document.createElement("div");
|
||||||
titleBlock.append(title, subtitleRow);
|
titleBlock.append(title, subtitleRow);
|
||||||
@@ -698,6 +737,18 @@
|
|||||||
|
|
||||||
async function requestOpportunities(token, period, dateRange, requestVersion) {
|
async function requestOpportunities(token, period, dateRange, requestVersion) {
|
||||||
if (isCurrentPeriodRequest(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({
|
setOpportunitiesTableState({
|
||||||
items: [],
|
items: [],
|
||||||
status: "loading",
|
status: "loading",
|
||||||
@@ -714,6 +765,7 @@
|
|||||||
status: "ready",
|
status: "ready",
|
||||||
message: ""
|
message: ""
|
||||||
});
|
});
|
||||||
|
requestOpptyProductsInBackground(allItems, token);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isCurrentPeriodRequest(requestVersion)) {
|
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() {
|
function resetOpportunitiesTable() {
|
||||||
selectedStages = new Set(STAGE_OPTIONS);
|
selectedStages = new Set(STAGE_OPTIONS);
|
||||||
selectedStatuses = new Set(STATUS_OPTIONS);
|
selectedStatuses = new Set(STATUS_OPTIONS);
|
||||||
@@ -821,6 +1047,17 @@
|
|||||||
hasSavedOwnerFilter = false;
|
hasSavedOwnerFilter = false;
|
||||||
customerFilterInitialized = false;
|
customerFilterInitialized = false;
|
||||||
ownerFilterInitialized = 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])]);
|
stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]);
|
||||||
opportunitiesTableState = {
|
opportunitiesTableState = {
|
||||||
items: [],
|
items: [],
|
||||||
@@ -991,7 +1228,19 @@
|
|||||||
item.OptyNumber ? `${OPPORTUNITY_DETAIL_URL}${encodeURIComponent(item.OptyNumber)}` : ""
|
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");
|
const opportunityCellContent = document.createElement("span");
|
||||||
opportunityCellContent.className = "opportunities-extension-opty-number-content";
|
opportunityCellContent.className = "opportunities-extension-opty-number-content";
|
||||||
opportunityCellContent.append(opportunityLink, createCopyOpportunityButton(item.OptyNumber));
|
opportunityCellContent.append(opportunityLink, createCopyOpportunityButton(item.OptyNumber));
|
||||||
@@ -1014,6 +1263,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
tableBody.append(row);
|
tableBody.append(row);
|
||||||
|
|
||||||
|
if (item.OptyNumber && expandedProductOptyNumbers.has(String(item.OptyNumber))) {
|
||||||
|
tableBody.append(createProductsDetailRow(item));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1663,6 +1916,144 @@
|
|||||||
return button;
|
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) {
|
async function copyOpportunityNumber(optyNumber) {
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
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() {
|
function openPreferencesModal() {
|
||||||
const overlay = document.getElementById(MODAL_ID);
|
const overlay = document.getElementById(MODAL_ID);
|
||||||
|
|
||||||
@@ -2406,6 +2815,16 @@
|
|||||||
const existingModal = document.getElementById(PREFERENCES_MODAL_ID);
|
const existingModal = document.getElementById(PREFERENCES_MODAL_ID);
|
||||||
|
|
||||||
if (existingModal) {
|
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.hidden = false;
|
||||||
existingModal.querySelector("input")?.focus();
|
existingModal.querySelector("input")?.focus();
|
||||||
return;
|
return;
|
||||||
@@ -2468,7 +2887,25 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
fieldset.append(options);
|
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");
|
const footer = document.createElement("footer");
|
||||||
footer.className = "opportunities-extension-preferences-footer";
|
footer.className = "opportunities-extension-preferences-footer";
|
||||||
@@ -2479,11 +2916,16 @@
|
|||||||
saveButton.textContent = "Save";
|
saveButton.textContent = "Save";
|
||||||
saveButton.addEventListener("click", () => {
|
saveButton.addEventListener("click", () => {
|
||||||
const selectedValues = Array.from(preferencesModal.querySelectorAll("input[name='opportunityTypeView']:checked"), (checkbox) => checkbox.value);
|
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);
|
selectedOpportunityTypeValues = new Set(selectedValues);
|
||||||
|
opptyProductsCacheHours = OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === selectedCacheHours)
|
||||||
|
? selectedCacheHours
|
||||||
|
: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({
|
localStorage.setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({
|
||||||
opportunityTypeValues: selectedValues
|
opportunityTypeValues: selectedValues,
|
||||||
|
opptyProductsCacheHours
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Continue with the saved selection for the active modal when storage is unavailable.
|
// Continue with the saved selection for the active modal when storage is unavailable.
|
||||||
@@ -2688,6 +3130,66 @@
|
|||||||
background: #6f5a7f;
|
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 {
|
.opportunities-extension-icon-button {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -3357,6 +3859,128 @@
|
|||||||
max-width: 100%;
|
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 {
|
.opportunities-extension-copy-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3644,6 +4268,34 @@
|
|||||||
cursor: pointer;
|
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 {
|
.opportunities-extension-preferences-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
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-header p,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card span,
|
.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-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-panel p,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-empty {
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-empty {
|
||||||
color: #c9c5c1;
|
color: #c9c5c1;
|
||||||
@@ -3875,6 +4529,7 @@
|
|||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-surface,
|
.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-customer-filter-panel,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-dialog,
|
.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-panel,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] {
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] {
|
||||||
border-color: #4e4a46;
|
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-customer-filter-search,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-search-field input,
|
.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-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-stage-filter-button,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button {
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-status-filter-button {
|
||||||
border-color: #6a6560;
|
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-header h2,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-fieldset legend,
|
.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-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-heading,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre,
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 {
|
.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 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;
|
background: #333130;
|
||||||
color: #f6f4f2;
|
color: #f6f4f2;
|
||||||
}
|
}
|
||||||
@@ -3968,6 +4627,53 @@
|
|||||||
border-bottom-color: #3f3c39;
|
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-table a,
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button {
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button {
|
||||||
color: #43c4d5;
|
color: #43c4d5;
|
||||||
|
|||||||
Reference in New Issue
Block a user