General source code refactoring

This commit is contained in:
2026-09-17 12:07:01 -03:00
parent 4421b28efe
commit ac817a69d4
92 changed files with 18211 additions and 19394 deletions

View File

@@ -0,0 +1,222 @@
function createAuthConsumerSession({
CUSTOMER_TOKEN_MAX_ATTEMPTS,
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS,
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS,
CUSTOMER_TOKEN_SERVICE_URL,
CUSTOMER_TOKEN_SESSION_URL,
extractTokenServiceAuthorization,
fetchJson,
supportsFirefoxCookieStoreTabs,
tabsCreate,
tabsRemove,
wait
}) {
async function requestConsumerTokenService(sender) {
const debugRequests = [];
try {
const tokenResponse = await fetchConsumerTokenServiceUntilJson(sender, debugRequests);
const accountSummaryAuthorization = extractTokenServiceAuthorization(
tokenResponse,
"ACCP-TS",
"account-ti-authorization"
);
const accountPlansAuthorization = extractTokenServiceAuthorization(
tokenResponse,
"OCICONS-TS",
"g2m-authorization"
);
const tokenRequestDebugEntry = debugRequests[debugRequests.length - 1];
if (tokenRequestDebugEntry) {
tokenRequestDebugEntry.metadata = {
...tokenRequestDebugEntry.metadata,
accountSummaryTokenService: "ACCP-TS",
accountSummaryAuthorizationHeader: accountSummaryAuthorization.name || "(missing)",
accountSummaryAuthorizationValue: accountSummaryAuthorization.value
? `(present, ${accountSummaryAuthorization.value.length} characters)`
: "(missing)",
accountSummaryAuthorizationExpires: accountSummaryAuthorization.expires || "(not provided)",
accountPlansTokenService: "OCICONS-TS",
accountPlansAuthorizationHeader: accountPlansAuthorization.name || "(missing)",
accountPlansAuthorizationValue: accountPlansAuthorization.value
? `(present, ${accountPlansAuthorization.value.length} characters)`
: "(missing)",
accountPlansAuthorizationExpires: accountPlansAuthorization.expires || "(not provided)"
};
}
if (!accountSummaryAuthorization.value) {
throw new Error("ACCP-TS uiaasHeader.value was not found in requestConsumerTokenService.");
}
if (!accountPlansAuthorization.value) {
throw new Error("OCICONS-TS uiaasHeader.value was not found in requestConsumerTokenService.");
}
return {
ok: true,
authorizations: {
accountSummary: accountSummaryAuthorization,
accountPlans: accountPlansAuthorization
},
debugRequests
};
} catch (error) {
error.debugRequests = debugRequests;
throw error;
}
}
async function fetchConsumerTokenServiceUntilJson(sender, debugRequests) {
let attempt = 0;
let sessionTab = null;
let receivedValidJson = false;
try {
while (attempt < CUSTOMER_TOKEN_MAX_ATTEMPTS) {
attempt += 1;
try {
const tokenResponse = await fetchJson(CUSTOMER_TOKEN_SERVICE_URL, {
method: "GET",
credentials: "include",
cache: "no-store",
headers: {
Accept: "application/json",
"Cache-Control": "no-cache, no-store, max-age=0",
Pragma: "no-cache"
}
}, "requestConsumerTokenService", debugRequests);
const debugEntry = debugRequests[debugRequests.length - 1];
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
}
receivedValidJson = true;
return tokenResponse;
} catch (error) {
const isHtmlFallback = error
&& error.code === "INVALID_JSON_RESPONSE"
&& error.httpStatus === 200;
const debugEntry = debugRequests[debugRequests.length - 1];
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
if (isHtmlFallback) {
debugEntry.metadata.retryLimitReached = "true";
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
error.message = debugEntry.error;
}
}
throw error;
}
if (!sessionTab) {
try {
sessionTab = await openConsumerTokenSessionTabWithRetry(sender, debugEntry);
if (debugEntry) {
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab";
}
} catch (sessionError) {
if (debugEntry) {
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
debugEntry.metadata.sessionBootstrapState = `tab-open-failed: ${sessionError.message || "unknown error"}`;
}
}
} else if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = "waiting-for-valid-session";
}
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON";
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
}
await wait(retryDelayMs);
}
}
throw new Error("requestConsumerTokenService retry loop ended unexpectedly.");
} finally {
if (sessionTab && Number.isInteger(sessionTab.id)) {
const debugEntry = debugRequests[debugRequests.length - 1];
try {
await tabsRemove(sessionTab.id);
if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = receivedValidJson
? "closed-after-valid-json"
: "closed-after-retry-finished";
}
} catch (error) {
if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = `tab-close-failed: ${error.message || "unknown error"}`;
}
}
}
}
}
function getConsumerTokenRetryDelay(attempt) {
return Math.min(
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS * (2 ** Math.max(attempt - 1, 0)),
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS
);
}
async function openConsumerTokenSessionTabWithRetry(sender, debugEntry) {
let attempt = 0;
// Opening failures must not consume the token service's five attempts.
while (true) {
attempt += 1;
if (debugEntry) {
debugEntry.metadata.sessionBootstrapUrl = CUSTOMER_TOKEN_SESSION_URL;
debugEntry.metadata.sessionTabOpenAttempt = String(attempt);
}
try {
const tab = await openConsumerTokenSessionTab(sender);
if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = "opened-inactive-tab";
delete debugEntry.metadata.sessionTabNextRetryDelayMs;
}
return tab;
} catch (error) {
const retryDelayMs = getConsumerTokenRetryDelay(Math.min(attempt, 5));
if (debugEntry) {
debugEntry.metadata.sessionBootstrapState = "retrying-tab-open";
debugEntry.metadata.sessionTabLastOpenError = error.message || "unknown error";
debugEntry.metadata.sessionTabNextRetryDelayMs = String(retryDelayMs);
}
await wait(retryDelayMs);
}
}
}
function openConsumerTokenSessionTab(sender) {
const details = {
url: CUSTOMER_TOKEN_SESSION_URL,
active: false
};
if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) {
details.windowId = sender.tab.windowId;
}
if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) {
details.cookieStoreId = sender.tab.cookieStoreId;
}
return tabsCreate(details);
}
return { requestConsumerTokenService, fetchConsumerTokenServiceUntilJson, getConsumerTokenRetryDelay, openConsumerTokenSessionTabWithRetry, openConsumerTokenSessionTab };
}

View File

@@ -0,0 +1,57 @@
function createAuthContentSession({
messageTypes,
AUTH_STATUS,
appendBackgroundRequestLog,
authSessionState,
requestTokenRelay,
sendRuntimeMessage,
updateAuthBadge
}) {
function requestTokenRelayOnce() {
if (!authSessionState.tokenRelayRequest) {
authSessionState.authStatus = AUTH_STATUS.pending;
updateAuthBadge(authSessionState.authStatus);
authSessionState.tokenRelayRequest = requestTokenRelay()
.then((token) => {
authSessionState.accessToken = token;
authSessionState.authStatus = AUTH_STATUS.authenticated;
requestConsumerTokenServiceOnce().catch(() => {});
return token;
})
.catch((error) => {
authSessionState.accessToken = "";
authSessionState.authStatus = AUTH_STATUS.unauthenticated;
throw error;
})
.finally(() => {
updateAuthBadge(authSessionState.authStatus);
});
} else {
updateAuthBadge(authSessionState.authStatus);
}
return authSessionState.tokenRelayRequest;
}
function requestConsumerTokenServiceOnce() {
if (!authSessionState.consumerTokenServiceRequest) {
authSessionState.consumerTokenServiceRequest = sendRuntimeMessage({
type: messageTypes.requestConsumerTokenService
}).then((response) => {
appendBackgroundRequestLog(response && response.debugRequests);
if (!response || !response.ok) {
throw new Error(response && response.error
? response.error
: "Unable to load consumer token service.");
}
return response.authorizations;
});
}
return authSessionState.consumerTokenServiceRequest;
}
return { requestTokenRelayOnce, requestConsumerTokenServiceOnce };
}

View File

@@ -0,0 +1,7 @@
/** @returns {{salesCommonToken: null | {access_token: string, expiresAt: number}, salesCommonTokenRequest: null | Promise<string>}} */
function createAuthSalesCommonState() {
return {
salesCommonToken: null,
salesCommonTokenRequest: null
};
}

33
src/auth/sales-common.js Normal file
View File

@@ -0,0 +1,33 @@
function createAuthSalesCommon({
TOKEN_RELAY_URL,
authSalesCommonState,
requestTokenRelay
}) {
function requestTokenRelaySalesCommon() {
if (authSalesCommonState.salesCommonToken && Date.now() < authSalesCommonState.salesCommonToken.expiresAt) {
return Promise.resolve(authSalesCommonState.salesCommonToken.access_token);
}
if (authSalesCommonState.salesCommonTokenRequest) {
return authSalesCommonState.salesCommonTokenRequest;
}
authSalesCommonState.salesCommonTokenRequest = requestTokenRelay({
name: "requestTokenRelaySalesCommon",
url: `${TOKEN_RELAY_URL}?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F`,
includeExpiry: true
}).then((token) => {
authSalesCommonState.salesCommonToken = token;
return token.access_token;
}).finally(() => {
authSalesCommonState.salesCommonTokenRequest = null;
});
return authSalesCommonState.salesCommonTokenRequest;
}
function invalidateSalesCommonToken(rejectedToken) {
if (authSalesCommonState.salesCommonToken && authSalesCommonState.salesCommonToken.access_token === rejectedToken) {
authSalesCommonState.salesCommonToken = null;
}
}
return { requestTokenRelaySalesCommon, invalidateSalesCommonToken };
}

View File

@@ -0,0 +1,8 @@
function createAuthSessionState({ AUTH_STATUS } = {}) {
return {
tokenRelayRequest: null,
consumerTokenServiceRequest: null,
accessToken: "",
authStatus: AUTH_STATUS.idle
};
}

View File

@@ -0,0 +1,40 @@
function createAuthSpaCredentials({
findNestedObject
}) {
function normalizeMessageAuthorization(authorization, fallbackHeaderName) {
const headerName = authorization && typeof authorization.name === "string"
? authorization.name.trim()
: "";
const headerValue = authorization && typeof authorization.value === "string"
? authorization.value.trim()
: "";
return {
name: headerName || fallbackHeaderName,
value: headerValue
};
}
function extractTokenServiceAuthorization(response, tokenServiceName, fallbackHeaderName) {
const tokenService = Array.isArray(response)
? response.find((entry) => entry && entry.tokenService === tokenServiceName)
: findNestedObject(response, (entry) => entry.tokenService === tokenServiceName);
const authorizationHeader = tokenService && tokenService.uiaasHeader;
const headerName = authorizationHeader && typeof authorizationHeader.label === "string"
? authorizationHeader.label.trim()
: "";
const headerValue = authorizationHeader && typeof authorizationHeader.value === "string"
? authorizationHeader.value.trim()
: "";
return {
name: headerName || fallbackHeaderName,
value: headerValue,
expires: authorizationHeader && authorizationHeader.expires
? String(authorizationHeader.expires)
: ""
};
}
return { normalizeMessageAuthorization, extractTokenServiceAuthorization };
}

153
src/auth/token-relay.js Normal file
View File

@@ -0,0 +1,153 @@
function createAuthTokenRelay({
TOKEN_RELAY_URL,
createDebugBody,
getXsrfToken,
logRequestFailure,
logRequestStart,
logRequestSuccess,
maskToken,
readResponseBodyForDebug,
refreshXsrfCookie,
renderDebugPanel,
environment
}) {
const { fetch } = environment;
async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) {
let refreshResult = null;
let refreshAttempted = false;
while (true) {
const xsrfTokenSource = await getXsrfToken();
if (!xsrfTokenSource.token) {
if (refreshAttempted) {
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh.");
throw new Error("XSRF token cookie not found after automatic refresh.");
}
refreshResult = await refreshXsrfCookie("missing-cookie");
refreshAttempted = true;
continue;
}
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
try {
const response = await fetch(url, {
method: "GET",
credentials: "include",
headers: {
"x-xsrf-token": xsrfTokenSource.token
}
});
const responseBody = await readResponseBodyForDebug(response);
if (!response.ok) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted && isInvalidXsrfStatus(response.status)) {
refreshResult = await refreshXsrfCookie(`tokenrelay-${response.status}`);
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw new Error(`Token relay failed with status ${response.status}.`);
}
let responseData;
try {
responseData = await response.json();
} catch (error) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted) {
refreshResult = await refreshXsrfCookie("tokenrelay-invalid-response");
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw error;
}
if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted) {
refreshResult = await refreshXsrfCookie("tokenrelay-missing-access-token");
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw new Error("Token relay response does not contain access_token.");
}
logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({
...responseData,
access_token: maskToken(responseData.access_token)
}, null, 2)));
if (includeExpiry) {
const lifetime = Number(responseData.expires_in);
return {
access_token: responseData.access_token,
expiresAt: Number.isFinite(lifetime) && lifetime > 0
? requestEntry.startedAt.getTime() + lifetime * 1000 : 0
};
}
return responseData.access_token;
} catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed.");
throw error;
}
}
}
function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name = "requestTokenRelay", url = TOKEN_RELAY_URL) {
return logRequestStart({
name,
method: "GET",
url,
headers: {
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)"
},
metadata: {
xsrfCookieName: xsrfTokenSource.cookieName || "(missing)",
xsrfTokenSource: xsrfTokenSource.source,
xsrfMatchedCookies: xsrfTokenSource.matchedCookieNames && xsrfTokenSource.matchedCookieNames.length
? xsrfTokenSource.matchedCookieNames.join(", ")
: "(none)",
xsrfLookupDetails: xsrfTokenSource.lookupDetails && xsrfTokenSource.lookupDetails.length
? xsrfTokenSource.lookupDetails.join(" | ")
: "(none)",
xsrfTokenError: xsrfTokenSource.error || "",
xsrfRefresh: formatXsrfRefreshResult(refreshResult)
}
});
}
function isInvalidXsrfStatus(status) {
return status === 400 || status === 401 || status === 403;
}
function formatXsrfRefreshResult(result) {
if (!result) {
return "not-requested";
}
const outcome = result.refreshed ? "refreshed" : "not-refreshed";
const cookieName = result.cookieName || "(none)";
const duration = Number.isFinite(result.durationMs) ? `${result.durationMs}ms` : "unknown";
return `${result.reason}; ${outcome}; cookie=${cookieName}; duration=${duration}; ${result.error || ""}`.trim();
}
return { requestTokenRelay, createTokenRelayRequestEntry, isInvalidXsrfStatus, formatXsrfRefreshResult };
}

42
src/auth/xsrf-content.js Normal file
View File

@@ -0,0 +1,42 @@
function createAuthXsrfContent({
getXsrfTokenFromExtensionCookies,
environment
}) {
const { document } = environment;
async function getXsrfToken() {
const extensionCookie = await getXsrfTokenFromExtensionCookies();
if (extensionCookie.token) {
return extensionCookie;
}
const xsrfCookie = document.cookie
.split(";")
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith("XSRF-TOKEN-"));
if (!xsrfCookie) {
return {
token: "",
cookieName: "",
source: extensionCookie.error ? "document.cookie fallback after cookies API error" : "document.cookie fallback",
matchedCookieNames: extensionCookie.matchedCookieNames || [],
lookupDetails: extensionCookie.lookupDetails || [],
error: extensionCookie.error || ""
};
}
const cookieName = xsrfCookie.slice(0, xsrfCookie.indexOf("="));
const tokenValue = xsrfCookie.slice(xsrfCookie.indexOf("=") + 1);
return {
token: decodeURIComponent(tokenValue),
cookieName,
source: "document.cookie",
matchedCookieNames: extensionCookie.matchedCookieNames || [],
lookupDetails: extensionCookie.lookupDetails || [],
error: ""
};
}
return { getXsrfToken };
}

116
src/auth/xsrf-cookies.js Normal file
View File

@@ -0,0 +1,116 @@
function createAuthXsrfCookies({
ORACLE_COOKIE_URLS,
ORACLE_DOMAIN,
XSRF_COOKIE_NAME,
XSRF_COOKIE_PREFIX,
cookiesGet,
cookiesGetAll,
cookiesGetAllCookieStores,
removeUndefinedValues
}) {
async function getXsrfTokenCookie() {
const lookupDetails = [];
const allCookies = [];
const stores = await getCookieStores(lookupDetails);
for (const store of stores) {
const exactCookie = await getExactCookieFromUrls(store.id, lookupDetails);
if (exactCookie) {
allCookies.push(exactCookie);
}
}
for (const store of stores) {
await collectCookies({
name: XSRF_COOKIE_NAME,
storeId: store.id
}, allCookies, lookupDetails);
}
for (const store of stores) {
await collectCookies({
domain: ORACLE_DOMAIN,
storeId: store.id
}, allCookies, lookupDetails);
await collectCookies({
domain: `.${ORACLE_DOMAIN}`,
storeId: store.id
}, allCookies, lookupDetails);
}
for (const store of stores) {
for (const url of ORACLE_COOKIE_URLS) {
await collectCookies({
url,
storeId: store.id
}, allCookies, lookupDetails);
}
}
const uniqueCookies = dedupeCookies(allCookies);
const xsrfCookies = uniqueCookies.filter((cookie) => cookie.name.startsWith(XSRF_COOKIE_PREFIX));
const exactCookie = xsrfCookies.find((cookie) => cookie.name === XSRF_COOKIE_NAME);
const hostCookie = xsrfCookies.find((cookie) => cookie.domain === ORACLE_DOMAIN || cookie.domain === `.${ORACLE_DOMAIN}`);
return {
cookie: exactCookie || hostCookie || xsrfCookies[0] || null,
matchedCookieNames: xsrfCookies.map((cookie) => `${cookie.name} (${cookie.domain}${cookie.path})`),
lookupDetails
};
}
async function getExactCookieFromUrls(storeId, lookupDetails) {
for (const url of ORACLE_COOKIE_URLS) {
const cookie = await cookiesGet({
url,
name: XSRF_COOKIE_NAME,
storeId
});
lookupDetails.push(`${JSON.stringify({ url, name: XSRF_COOKIE_NAME, storeId })} => ${cookie ? "found" : "not found"}`);
if (cookie) {
return cookie;
}
}
return null;
}
async function getCookieStores(lookupDetails) {
try {
const stores = await cookiesGetAllCookieStores();
lookupDetails.push(`getAllCookieStores => ${stores.length} store(s)`);
return stores.length ? stores : [{ id: undefined }];
} catch (error) {
lookupDetails.push(`getAllCookieStores => unavailable (${error.message || "unknown error"})`);
return [{ id: undefined }];
}
}
async function collectCookies(details, target, lookupDetails) {
const cleanDetails = removeUndefinedValues(details);
const cookies = await cookiesGetAll(cleanDetails);
target.push(...cookies);
lookupDetails.push(`${JSON.stringify(cleanDetails)} => ${cookies.length} cookie(s)`);
}
function dedupeCookies(cookies) {
const seen = new Set();
return cookies.filter((cookie) => {
const key = `${cookie.name}|${cookie.domain}|${cookie.path}|${cookie.storeId || ""}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
return { getXsrfTokenCookie, getExactCookieFromUrls, getCookieStores, collectCookies, dedupeCookies };
}

84
src/auth/xsrf-recovery.js Normal file
View File

@@ -0,0 +1,84 @@
function createAuthXsrfRecovery({
COOKIE_REFRESH_POLL_MS,
COOKIE_REFRESH_TIMEOUT_MS,
OPPORTUNITIES_LIST_URL,
getXsrfTokenCookie,
supportsFirefoxCookieStoreTabs,
tabsCreate,
tabsRemove,
wait
}) {
async function refreshXsrfCookie(sender) {
const startedAt = Date.now();
const previous = (await getXsrfTokenCookie()).cookie;
const createProperties = {
url: OPPORTUNITIES_LIST_URL,
active: false
};
if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) {
createProperties.windowId = sender.tab.windowId;
}
if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) {
createProperties.cookieStoreId = sender.tab.cookieStoreId;
}
let refreshTab;
try {
refreshTab = await tabsCreate(createProperties);
const current = await waitForXsrfCookieChange(previous);
const refreshed = hasCookieChanged(previous, current);
return {
ok: Boolean(current),
refreshed,
cookieName: current ? current.name : "",
durationMs: Date.now() - startedAt,
error: refreshed ? "" : "XSRF cookie was not created or changed before timeout."
};
} finally {
if (refreshTab && Number.isInteger(refreshTab.id)) {
try {
await tabsRemove(refreshTab.id);
} catch (error) {
// The temporary tab may already have been closed.
}
}
}
}
async function waitForXsrfCookieChange(previousCookie) {
const deadline = Date.now() + COOKIE_REFRESH_TIMEOUT_MS;
let currentCookie = null;
while (Date.now() < deadline) {
currentCookie = (await getXsrfTokenCookie()).cookie;
if (hasCookieChanged(previousCookie, currentCookie)) {
return currentCookie;
}
await wait(COOKIE_REFRESH_POLL_MS);
}
return currentCookie;
}
function hasCookieChanged(previousCookie, currentCookie) {
if (!currentCookie || !currentCookie.value) {
return false;
}
if (!previousCookie) {
return true;
}
return previousCookie.name !== currentCookie.name
|| previousCookie.value !== currentCookie.value
|| previousCookie.expirationDate !== currentCookie.expirationDate;
}
return { refreshXsrfCookie, waitForXsrfCookieChange, hasCookieChanged };
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
function createBackgroundApplication(overrides = {}) {
const environment = {
fetch: globalThis.fetch,
browser: globalThis.browser,
chrome: globalThis.chrome,
setTimeout: globalThis.setTimeout,
...overrides
};
const messageTypes = createMessageTypes();
const contracts = createBackgroundContracts();
const productsCacheState = createProductsCacheState();
const authConsumerSession = createAuthConsumerSession({
CUSTOMER_TOKEN_MAX_ATTEMPTS: contracts.CUSTOMER_TOKEN_MAX_ATTEMPTS,
CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS: contracts.CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS,
CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS: contracts.CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS,
CUSTOMER_TOKEN_SERVICE_URL: contracts.CUSTOMER_TOKEN_SERVICE_URL,
CUSTOMER_TOKEN_SESSION_URL: contracts.CUSTOMER_TOKEN_SESSION_URL,
extractTokenServiceAuthorization: (...args) => authSpaCredentials.extractTokenServiceAuthorization(...args),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
wait: (...args) => platformBackgroundApi.wait(...args)
});
const authSpaCredentials = createAuthSpaCredentials({
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args)
});
const spaAccounts = createSpaAccounts({
ACCOUNT_PLANS_PAYLOAD: contracts.ACCOUNT_PLANS_PAYLOAD,
ACCOUNT_PLANS_URL: contracts.ACCOUNT_PLANS_URL,
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
extractAccountPlans: (...args) => spaResponseShapes.extractAccountPlans(...args),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaConsumption = createSpaConsumption({
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaResponseShapes = createSpaResponseShapes();
const httpBackgroundJson = createHttpBackgroundJson({
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
environment
});
const productsBackgroundJobs = createProductsBackgroundJobs({
messageTypes,
OPPTY_PRODUCTS_CONCURRENCY: contracts.OPPTY_PRODUCTS_CONCURRENCY,
getOpptyProducts: (...args) => productsCache.getOpptyProducts(...args),
normalizeCacheMaxAge: (...args) => productsCache.normalizeCacheMaxAge(...args),
tabsSendMessage: (...args) => platformBackgroundApi.tabsSendMessage(...args)
});
const productsCache = createProductsCache({
OPPTY_PRODUCTS_CACHE_KEY: contracts.OPPTY_PRODUCTS_CACHE_KEY,
fetchAllOpptyProducts: (...args) => fusionProducts.fetchAllOpptyProducts(...args),
productsCacheState,
storageLocalGet: (...args) => platformBackgroundApi.storageLocalGet(...args),
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
});
const fusionProducts = createFusionProducts({
OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS,
OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT,
ORACLE_DOMAIN: contracts.ORACLE_DOMAIN,
environment
});
const authXsrfRecovery = createAuthXsrfRecovery({
COOKIE_REFRESH_POLL_MS: contracts.COOKIE_REFRESH_POLL_MS,
COOKIE_REFRESH_TIMEOUT_MS: contracts.COOKIE_REFRESH_TIMEOUT_MS,
OPPORTUNITIES_LIST_URL: contracts.OPPORTUNITIES_LIST_URL,
getXsrfTokenCookie: (...args) => authXsrfCookies.getXsrfTokenCookie(...args),
supportsFirefoxCookieStoreTabs: (...args) => platformBackgroundApi.supportsFirefoxCookieStoreTabs(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
wait: (...args) => platformBackgroundApi.wait(...args)
});
const authXsrfCookies = createAuthXsrfCookies({
ORACLE_COOKIE_URLS: contracts.ORACLE_COOKIE_URLS,
ORACLE_DOMAIN: contracts.ORACLE_DOMAIN,
XSRF_COOKIE_NAME: contracts.XSRF_COOKIE_NAME,
XSRF_COOKIE_PREFIX: contracts.XSRF_COOKIE_PREFIX,
cookiesGet: (...args) => platformBackgroundApi.cookiesGet(...args),
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
cookiesGetAllCookieStores: (...args) => platformBackgroundApi.cookiesGetAllCookieStores(...args),
removeUndefinedValues: (...args) => platformBackgroundApi.removeUndefinedValues(...args)
});
const platformBackgroundApi = createPlatformBackgroundApi({
runtimeApi: environment.browser || environment.chrome,
environment
});
const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args);
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
function start() {
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,
productsCacheState,
authConsumerSession,
authSpaCredentials,
spaAccounts,
spaConsumption,
spaResponseShapes,
httpBackgroundJson,
productsBackgroundJobs,
productsCache,
fusionProducts,
authXsrfRecovery,
authXsrfCookies,
platformBackgroundApi
};
}

View File

@@ -0,0 +1,69 @@
function createBackgroundContracts() {
const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com";
const ORACLE_COOKIE_URLS = [
"https://eeho.fa.us2.oraclecloud.com/",
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome",
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome",
"https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay"
];
const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F";
const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-";
const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list";
const COOKIE_REFRESH_TIMEOUT_MS = 15000;
const COOKIE_REFRESH_POLL_MS = 250;
const OPPTY_PRODUCTS_CACHE_KEY = "opportunitiesExtension.opptyProductsCache.v1";
const OPPTY_PRODUCTS_PAGE_LIMIT = 15;
const OPPTY_PRODUCTS_MAX_PAGES = 250;
const OPPTY_PRODUCTS_CONCURRENCY = 4;
const CUSTOMER_TOKEN_SERVICE_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/consumerTokenService";
const CUSTOMER_TOKEN_SESSION_URL = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html";
const CUSTOMER_TOKEN_MAX_ATTEMPTS = 5;
const CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS = 750;
const CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS = 4000;
const ACCOUNT_SUMMARY_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-account/service/accountsummary";
const ACCOUNT_PLANS_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/account/accountPlans";
const CONSUMPTION_TREND_URL = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/trend";
const CONSUMPTION_TREND_REQUESTS = {
quarterly: "requestConsumptionServiceQuarterly",
monthly: "requestConsumptionServiceMonthly",
weekly: "requestConsumptionServiceWeekly",
daily: "requestConsumptionServiceDaily"
};
const ACCOUNT_PLANS_PAYLOAD = {
hierarchy: "single",
products: "all",
language: "english",
planStatus: [
"active",
"awaiting_provisioning",
"awaiting_provisioning_error",
"draft",
"expired",
"final_billing",
"onhold",
"provisioned",
"signed",
"superseded",
"superseded_pending_final_billing",
"suspended",
"terminated",
"terminated_pending_final_billing"
]
};
const DEBUG_BODY_LIMIT = 100000;
const OPPTY_PRODUCTS_FIELDS = [
"ProductType", "Description", "InventoryItemId", "ProdGroupName", "ProdGroupId", "OwnerLockAsgnFlag",
"Quantity", "RecurTypeCode", "RevnAmountCurcyCode", "UnitPrice", "RevnAmount", "PriceTypeCode",
"EffectiveDate", "Name1", "NonRecurringRevenue", "OpportunityOwnerPartyName", "OpportunityOwnerResourcePartyId",
"OptyId", "OptyNumber", "PartyName2", "PrTerritoryVersionId", "PrTerritoryVersionIdForManual",
"RecurEndDate", "RecurFrequencyCode", "RecurNumberPeriods", "RecurRevenue", "ResourcePartyId", "RevnId",
"RevnNumber", "SplitPercent", "SalesCreditTypeCode", "SplitTypeCode", "TerrOwnerPartyName", "UpsideAmount",
"UsageRevenue", "StatusCode", "BUOrgId", "TypeCode", "WinProb", "ForecastType_c", "ARRLocalCurrency_c",
"CPQLastSyncDate_c", "CPQOperationType_c", "ProdGLID_c", "ProposalNumber_c", "QuoteNumber_c",
"ServicesPeriod_c", "CPQIntLastUpdatedBy_c", "CPQUpdatedAmount_c", "CPQUpdatedQuantity_c",
"CPQUpdatedRevnType_c", "CPQUpdatedServicePeriod_c", "CPQUpdatedStatus_c", "OrderNumber_c", "PrevAmount_c",
"PrevQuantity_c", "PrevRevenueType_c", "PrevServicePeriod_c", "PrevStatus_c", "SubscriptionID_c",
"WorkloadName_c", "ConsumptionStartDate_c", "RampMonths_c"
];
return { ORACLE_DOMAIN, ORACLE_COOKIE_URLS, XSRF_COOKIE_NAME, XSRF_COOKIE_PREFIX, OPPORTUNITIES_LIST_URL, COOKIE_REFRESH_TIMEOUT_MS, COOKIE_REFRESH_POLL_MS, OPPTY_PRODUCTS_CACHE_KEY, OPPTY_PRODUCTS_PAGE_LIMIT, OPPTY_PRODUCTS_MAX_PAGES, OPPTY_PRODUCTS_CONCURRENCY, CUSTOMER_TOKEN_SERVICE_URL, CUSTOMER_TOKEN_SESSION_URL, CUSTOMER_TOKEN_MAX_ATTEMPTS, CUSTOMER_TOKEN_RETRY_BASE_DELAY_MS, CUSTOMER_TOKEN_RETRY_MAX_DELAY_MS, ACCOUNT_SUMMARY_URL, ACCOUNT_PLANS_URL, CONSUMPTION_TREND_URL, CONSUMPTION_TREND_REQUESTS, ACCOUNT_PLANS_PAYLOAD, DEBUG_BODY_LIMIT, OPPTY_PRODUCTS_FIELDS };
}

3
src/background/main.js Normal file
View File

@@ -0,0 +1,3 @@
function startBackgroundApplication(dependencies) {
registerBackgroundMessages(dependencies);
}

116
src/background/messages.js Normal file
View File

@@ -0,0 +1,116 @@
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
messageTypes,
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
requestConsumerTokenService,
requestConsumptionTrend,
requestOpptyProducts,
runtimeApi
}) {
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) {
return false;
}
if (message.type === messageTypes.getXsrfToken) {
getXsrfTokenCookie()
.then((result) => {
sendResponse({
ok: true,
cookieName: result.cookie ? result.cookie.name : "",
token: result.cookie ? result.cookie.value : "",
matchedCookieNames: result.matchedCookieNames,
lookupDetails: result.lookupDetails
});
})
.catch((error) => {
sendResponse({
ok: false,
cookieName: "",
token: "",
matchedCookieNames: [],
lookupDetails: [],
error: error.message || "Unable to read cookies."
});
});
return true;
}
if (message.type === messageTypes.refreshXsrfCookie) {
refreshXsrfCookie(sender)
.then(sendResponse)
.catch((error) => {
sendResponse({
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
error: error.message || "Unable to refresh XSRF cookie."
});
});
return true;
}
if (message.type === messageTypes.requestOpptyProducts) {
requestOpptyProducts(message, sender)
.then(sendResponse)
.catch((error) => {
sendResponse({
ok: false,
requestId: message.requestId || "",
error: error.message || "Unable to load opportunity products."
});
});
return true;
}
if (message.type === messageTypes.requestConsumerTokenService) {
requestConsumerTokenService(sender)
.then(sendResponse)
.catch((error) => {
sendResponse({
ok: false,
error: error.message || "Unable to load consumer token service.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
});
});
return true;
}
if (message.type === messageTypes.requestAccountPlans) {
requestAccountPlans(message)
.then(sendResponse)
.catch((error) => {
sendResponse({
ok: false,
error: error.message || "Unable to load account plans.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
});
});
return true;
}
if (message.type === messageTypes.requestConsumptionTrend) {
requestConsumptionTrend(message)
.then(sendResponse)
.catch((error) => {
sendResponse({
ok: false,
error: error.message || "Unable to load consumption trend.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
});
});
return true;
}
return false;
});
}

File diff suppressed because it is too large Load Diff

434
src/content/application.js Normal file
View File

@@ -0,0 +1,434 @@
function createContentApplication(overrides = {}) {
const environment = {
document: globalThis.document,
window: globalThis.window,
navigator: globalThis.navigator,
get localStorage() { return globalThis.localStorage; },
fetch: globalThis.fetch,
browser: globalThis.browser,
chrome: globalThis.chrome,
MutationObserver: globalThis.MutationObserver,
Node: globalThis.Node,
requestAnimationFrame: globalThis.requestAnimationFrame,
performance: globalThis.performance,
cancelAnimationFrame: globalThis.cancelAnimationFrame,
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
matchMedia: globalThis.matchMedia,
...overrides
};
const messageTypes = createMessageTypes();
const contracts = createContentContracts();
const authSessionState = createAuthSessionState(contracts);
const authSalesCommonState = createAuthSalesCommonState();
const opportunitiesQueryState = createOpportunitiesQueryState();
const opportunitiesTableState = createOpportunitiesTableState();
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
const preferencesState = createPreferencesState(contracts);
const productsContentState = createProductsContentState();
const aiSummaryState = createAiSummaryState();
const consumptionPlansState = createConsumptionPlansState();
const consumptionTrendState = createConsumptionTrendState();
const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts);
const debugState = createDebugState();
const columns = createOpportunitiesColumns({
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args),
formatWinProbability: (...args) => opportunitiesFormatters.formatWinProbability(...args),
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
getOwnerName: (...args) => opportunitiesFormatters.getOwnerName(...args)
});
const contentFusionPage = createContentFusionPage({
ADD_ICON_SELECTOR: contracts.ADD_ICON_SELECTOR,
ALLOWED_PATHS: contracts.ALLOWED_PATHS,
SALES_GROUP_SELECTOR: contracts.SALES_GROUP_SELECTOR,
TILE_ID: contracts.TILE_ID,
TILE_LABEL_ID: contracts.TILE_LABEL_ID,
appendPath: (...args) => svgPaths.appendPath(...args),
openOpportunitiesModal: (...args) => contentModal.openOpportunitiesModal(...args),
environment
});
const contentModal = createContentModal({
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
AUTH_STATUS: contracts.AUTH_STATUS,
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
CUSTOMER_FILTER_SEARCH_ID: contracts.CUSTOMER_FILTER_SEARCH_ID,
CUSTOMER_FILTER_SELECT_ALL_ID: contracts.CUSTOMER_FILTER_SELECT_ALL_ID,
DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID,
ESCAPE_LISTENER_KEY: contracts.ESCAPE_LISTENER_KEY,
MODAL_ID: contracts.MODAL_ID,
OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID,
OWNER_FILTER_BUTTON_ID: contracts.OWNER_FILTER_BUTTON_ID,
OWNER_FILTER_LIST_ID: contracts.OWNER_FILTER_LIST_ID,
OWNER_FILTER_PANEL_ID: contracts.OWNER_FILTER_PANEL_ID,
OWNER_FILTER_SEARCH_ID: contracts.OWNER_FILTER_SEARCH_ID,
OWNER_FILTER_SELECT_ALL_ID: contracts.OWNER_FILTER_SELECT_ALL_ID,
PERIOD_OPTIONS: contracts.PERIOD_OPTIONS,
PREFERENCES_MODAL_ID: contracts.PREFERENCES_MODAL_ID,
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
PRODUCTS_PROGRESS_ID: contracts.PRODUCTS_PROGRESS_ID,
STAGE_DASHBOARD_ID: contracts.STAGE_DASHBOARD_ID,
STAGE_FILTERS_ID: contracts.STAGE_FILTERS_ID,
STATUS_FILTERS_ID: contracts.STATUS_FILTERS_ID,
TABLE_SEARCH_ID: contracts.TABLE_SEARCH_ID,
applyTheme: (...args) => contentTheme.applyTheme(...args),
authSessionState,
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
debugState,
ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args),
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
getOwnerOptions: (...args) => opportunitiesFormatters.getOwnerOptions(...args),
getSavedOpportunityTypeValues: (...args) => preferencesStorage.getSavedOpportunityTypeValues(...args),
getSavedOpptyProductsCacheHours: (...args) => preferencesStorage.getSavedOpptyProductsCacheHours(...args),
getSavedTheme: (...args) => contentTheme.getSavedTheme(...args),
openPreferencesModal: (...args) => preferencesView.openPreferencesModal(...args),
opportunitiesFilterState,
opportunitiesQueryState,
preferencesState,
renderCustomerFilter: (...args) => opportunitiesFilters.renderCustomerFilter(...args),
renderCustomerFilterList: (...args) => opportunitiesFilters.renderCustomerFilterList(...args),
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
renderOwnerFilter: (...args) => opportunitiesFilters.renderOwnerFilter(...args),
renderOwnerFilterList: (...args) => opportunitiesFilters.renderOwnerFilterList(...args),
renderStageFilterButtons: (...args) => opportunitiesFilters.renderStageFilterButtons(...args),
renderStatusFilterButtons: (...args) => opportunitiesFilters.renderStatusFilterButtons(...args),
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args),
resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args),
restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args),
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args),
toggleDebugPanel: (...args) => debugContentView.toggleDebugPanel(...args),
toggleOwnerFilterPanel: (...args) => opportunitiesFilters.toggleOwnerFilterPanel(...args),
environment
});
const contentTheme = createContentTheme({
THEME_STORAGE_KEY: contracts.THEME_STORAGE_KEY,
createThemeIcon: (...args) => contentIcons.createThemeIcon(...args),
environment
});
const contentIcons = createContentIcons({
appendPath: (...args) => svgPaths.appendPath(...args),
environment
});
const svgPaths = createSvgPaths({ environment });
const contentStyles = createContentStyles({
STYLE_ID: contracts.STYLE_ID,
redwoodStyles,
environment
});
const authContentSession = createAuthContentSession({
messageTypes,
AUTH_STATUS: contracts.AUTH_STATUS,
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
authSessionState,
requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
updateAuthBadge: (...args) => contentModal.updateAuthBadge(...args)
});
const authSalesCommon = createAuthSalesCommon({
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
authSalesCommonState,
requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args)
});
const authTokenRelay = createAuthTokenRelay({
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
createDebugBody: (...args) => debugContentLog.createDebugBody(...args),
getXsrfToken: (...args) => authXsrfContent.getXsrfToken(...args),
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
maskToken: (...args) => debugContentLog.maskToken(...args),
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
refreshXsrfCookie: (...args) => platformContentMessages.refreshXsrfCookie(...args),
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args),
environment
});
const authXsrfContent = createAuthXsrfContent({
getXsrfTokenFromExtensionCookies: (...args) => platformContentMessages.getXsrfTokenFromExtensionCookies(...args),
environment
});
const platformContentMessages = createPlatformContentMessages({
messageTypes,
environment
});
const opportunitiesController = createOpportunitiesController({
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
authSessionState,
consumptionPlansState,
consumptionTrendState,
getFiscalPeriodRange: (...args) => opportunitiesFiscalPeriods.getFiscalPeriodRange(...args),
opportunitiesDashboardState,
opportunitiesFilterState,
opportunitiesQueryState,
opportunitiesTableState,
preferencesState,
productsContentState,
renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args),
requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args),
requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args),
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
});
const fusionOpportunities = createFusionOpportunities({
OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT,
OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL,
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
maskToken: (...args) => debugContentLog.maskToken(...args),
getOpportunityTypeValues: () => preferencesState.selectedOpportunityTypeValues,
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
environment
});
const opportunitiesFiscalPeriods = createOpportunitiesFiscalPeriods();
const opportunitiesTable = createOpportunitiesTable({
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
OPPORTUNITIES_COLUMNS: columns,
OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID,
OPPORTUNITY_DETAIL_URL: contracts.OPPORTUNITY_DETAIL_URL,
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
createStatusBadge: (...args) => opportunitiesLinks.createStatusBadge(...args),
displayOpportunityValue: (...args) => opportunitiesFormatters.displayOpportunityValue(...args),
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
getSortedOpportunities: (...args) => opportunitiesFilters.getSortedOpportunities(...args),
opportunitiesTableState,
productsContentState,
renderCustomerFilter: (...args) => opportunitiesFilters.renderCustomerFilter(...args),
renderOwnerFilter: (...args) => opportunitiesFilters.renderOwnerFilter(...args),
renderStageDashboard: (...args) => opportunitiesDashboard.renderStageDashboard(...args),
resetCustomerFilter: (...args) => opportunitiesFilters.resetCustomerFilter(...args),
resetOwnerFilter: (...args) => opportunitiesFilters.resetOwnerFilter(...args),
environment
});
const opportunitiesDashboard = createOpportunitiesDashboard({
STAGE_DASHBOARD_ID: contracts.STAGE_DASHBOARD_ID,
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
opportunitiesDashboardState,
environment
});
const opportunitiesFilters = createOpportunitiesFilters({
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
CUSTOMER_FILTER_SEARCH_ID: contracts.CUSTOMER_FILTER_SEARCH_ID,
CUSTOMER_FILTER_SELECT_ALL_ID: contracts.CUSTOMER_FILTER_SELECT_ALL_ID,
OPPORTUNITIES_COLUMNS: columns,
OWNER_FILTER_BUTTON_ID: contracts.OWNER_FILTER_BUTTON_ID,
OWNER_FILTER_LIST_ID: contracts.OWNER_FILTER_LIST_ID,
OWNER_FILTER_PANEL_ID: contracts.OWNER_FILTER_PANEL_ID,
OWNER_FILTER_SEARCH_ID: contracts.OWNER_FILTER_SEARCH_ID,
OWNER_FILTER_SELECT_ALL_ID: contracts.OWNER_FILTER_SELECT_ALL_ID,
STAGE_FILTERS_ID: contracts.STAGE_FILTERS_ID,
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
STATUS_FILTERS_ID: contracts.STATUS_FILTERS_ID,
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
getCustomerFilterSummary: (...args) => opportunitiesFormatters.getCustomerFilterSummary(...args),
getCustomerKey: (...args) => opportunitiesFormatters.getCustomerKey(...args),
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
getOwnerKey: (...args) => opportunitiesFormatters.getOwnerKey(...args),
getOwnerOptions: (...args) => opportunitiesFormatters.getOwnerOptions(...args),
opportunitiesFilterState,
opportunitiesTableState,
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
stringifySearchValue: (...args) => opportunitiesFormatters.stringifySearchValue(...args),
environment
});
const opportunitiesFormatters = createOpportunitiesFormatters({
opportunitiesTableState
});
const opportunitiesLinks = createOpportunitiesLinks({
MODAL_ID: contracts.MODAL_ID,
environment
});
const preferencesStorage = createPreferencesStorage({
DEFAULT_OPPORTUNITY_TYPE_VALUES: contracts.DEFAULT_OPPORTUNITY_TYPE_VALUES,
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS: contracts.DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
FILTER_PREFERENCES_STORAGE_KEY: contracts.FILTER_PREFERENCES_STORAGE_KEY,
MODAL_ID: contracts.MODAL_ID,
OPPORTUNITY_TYPE_OPTIONS: contracts.OPPORTUNITY_TYPE_OPTIONS,
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY: contracts.OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
OPPTY_PRODUCTS_CACHE_OPTIONS: contracts.OPPTY_PRODUCTS_CACHE_OPTIONS,
PERIOD_OPTIONS: contracts.PERIOD_OPTIONS,
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
opportunitiesFilterState,
environment
});
const preferencesView = createPreferencesView({
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS: contracts.DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
MODAL_ID: contracts.MODAL_ID,
OPPORTUNITY_TYPE_OPTIONS: contracts.OPPORTUNITY_TYPE_OPTIONS,
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY: contracts.OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
OPPTY_PRODUCTS_CACHE_OPTIONS: contracts.OPPTY_PRODUCTS_CACHE_OPTIONS,
PERIOD_OPTIONS: contracts.PERIOD_OPTIONS,
PREFERENCES_MODAL_ID: contracts.PREFERENCES_MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
getSavedOpportunityTypeValues: (...args) => preferencesStorage.getSavedOpportunityTypeValues(...args),
getSavedOpptyProductsCacheHours: (...args) => preferencesStorage.getSavedOpptyProductsCacheHours(...args),
preferencesState,
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
environment
});
const productsContentJobs = createProductsContentJobs({
messageTypes,
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
PRODUCTS_MESSAGE_LISTENER_KEY: contracts.PRODUCTS_MESSAGE_LISTENER_KEY,
PRODUCTS_PROGRESS_ID: contracts.PRODUCTS_PROGRESS_ID,
preferencesState,
productsContentState,
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
environment
});
const productsDetails = createProductsDetails({
OPPORTUNITIES_COLUMNS: columns,
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
productsContentState,
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args),
environment
});
const aiSummaryContent = createAiSummaryContent({
aiSummaryState,
invalidateSalesCommonToken: (...args) => authSalesCommon.invalidateSalesCommonToken(...args),
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...args),
environment
});
const consumptionPlansAction = createConsumptionPlansAction({
messageTypes,
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
consumptionPlansState,
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
openAccountPlansModal: (...args) => consumptionPlansView.openAccountPlansModal(...args),
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
showCopyConfirmation: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
environment
});
const consumptionPlansView = createConsumptionPlansView({
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
createConsumptionTrendSection: (...args) => consumptionTrendController.createConsumptionTrendSection(...args),
createSvgElement: (...args) => consumptionChart.createSvgElement(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
loadConsumptionTrendForPanel: (...args) => consumptionTrendController.loadConsumptionTrendForPanel(...args),
environment
});
const consumptionTrendController = createConsumptionTrendController({
messageTypes,
CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS,
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
consumptionTrendState,
renderConsumptionTrendChart: (...args) => consumptionChart.renderConsumptionTrendChart(...args),
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
environment
});
const consumptionChart = createConsumptionChart({
CONSUMPTION_TREND_PERIODS: contracts.CONSUMPTION_TREND_PERIODS,
CONSUMPTION_TREND_SERIES: contracts.CONSUMPTION_TREND_SERIES,
SVG_NAMESPACE: contracts.SVG_NAMESPACE,
calculateAverageConsumption: (...args) => consumptionModel.calculateAverageConsumption(...args),
consumptionTrendState,
createConsumptionTrendKey: (...args) => consumptionTrendController.createConsumptionTrendKey(...args),
createNiceConsumptionMaximum: (...args) => consumptionModel.createNiceConsumptionMaximum(...args),
formatCompactConsumptionNumber: (...args) => consumptionModel.formatCompactConsumptionNumber(...args),
formatConsumptionCurrency: (...args) => consumptionModel.formatConsumptionCurrency(...args),
formatConsumptionPointLabel: (...args) => consumptionModel.formatConsumptionPointLabel(...args),
formatConsumptionRange: (...args) => consumptionModel.formatConsumptionRange(...args),
normalizeConsumptionTrendItems: (...args) => consumptionModel.normalizeConsumptionTrendItems(...args),
environment
});
const consumptionModel = createConsumptionModel({
});
const debugContentLog = createDebugContentLog({
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
debugState,
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args)
});
const debugContentView = createDebugContentView({
DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID,
MODAL_ID: contracts.MODAL_ID,
debugState,
formatDebugBody: (...args) => debugContentLog.formatDebugBody(...args),
formatHeaderBlock: (...args) => debugContentLog.formatHeaderBlock(...args),
formatMetadataBlock: (...args) => debugContentLog.formatMetadataBlock(...args),
environment
});
function start() {
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
}
return {
start, contracts,
authSessionState,
authSalesCommonState,
opportunitiesQueryState,
opportunitiesTableState,
opportunitiesFilterState,
preferencesState,
productsContentState,
aiSummaryState,
consumptionPlansState,
consumptionTrendState,
opportunitiesDashboardState,
debugState,
contentFusionPage,
contentModal,
contentTheme,
contentIcons,
contentStyles,
authContentSession,
authSalesCommon,
authTokenRelay,
authXsrfContent,
platformContentMessages,
opportunitiesController,
fusionOpportunities,
opportunitiesFiscalPeriods,
opportunitiesTable,
opportunitiesDashboard,
opportunitiesFilters,
opportunitiesFormatters,
opportunitiesLinks,
preferencesStorage,
preferencesView,
productsContentJobs,
productsDetails,
aiSummaryContent,
consumptionPlansAction,
consumptionPlansView,
consumptionTrendController,
consumptionChart,
consumptionModel,
debugContentLog,
debugContentView
};
}

100
src/content/contracts.js Normal file
View File

@@ -0,0 +1,100 @@
function createContentContracts() {
const TILE_ID = "c_5c719a88b5624b1eaf81defe2c2ex4x5";
const TILE_LABEL_ID = `${TILE_ID}_0`;
const SALES_GROUP_SELECTOR = "#yourapps_groupNode_sales";
const ADD_ICON_SELECTOR = ".flat-grid-cell.flat-grid-cell-addicon";
const MODAL_ID = "opportunities-extension-modal";
const STYLE_ID = "opportunities-extension-redwood-styles";
const ESCAPE_LISTENER_KEY = "opportunitiesExtensionEscapeListener";
const DEBUG_PANEL_ID = "opportunities-extension-debug-panel";
const OPPORTUNITIES_TABLE_ID = "opportunities-extension-table";
const STAGE_FILTERS_ID = "opportunities-extension-stage-filters";
const STATUS_FILTERS_ID = "opportunities-extension-status-filters";
const CUSTOMER_FILTER_BUTTON_ID = "opportunities-extension-customer-filter-button";
const CUSTOMER_FILTER_PANEL_ID = "opportunities-extension-customer-filter-panel";
const CUSTOMER_FILTER_SEARCH_ID = "opportunities-extension-customer-filter-search";
const CUSTOMER_FILTER_SELECT_ALL_ID = "opportunities-extension-customer-filter-select-all";
const CUSTOMER_FILTER_LIST_ID = "opportunities-extension-customer-filter-list";
const OWNER_FILTER_BUTTON_ID = "opportunities-extension-owner-filter-button";
const OWNER_FILTER_PANEL_ID = "opportunities-extension-owner-filter-panel";
const OWNER_FILTER_SEARCH_ID = "opportunities-extension-owner-filter-search";
const OWNER_FILTER_SELECT_ALL_ID = "opportunities-extension-owner-filter-select-all";
const OWNER_FILTER_LIST_ID = "opportunities-extension-owner-filter-list";
const TABLE_SEARCH_ID = "opportunities-extension-table-search";
const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard";
const PRODUCTS_PROGRESS_ID = "opportunities-extension-products-progress";
const PRODUCTS_CACHE_TIMESTAMP_ID = "opportunities-extension-products-cache-timestamp";
const FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences";
const THEME_STORAGE_KEY = "opportunities-extension-theme";
const OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY = "opportunities-extension-opportunity-type-preferences";
const PREFERENCES_MODAL_ID = "opportunities-extension-preferences-modal";
const ACCOUNT_PLANS_MODAL_ID = "opportunities-extension-account-plans-modal";
const PRODUCTS_MESSAGE_LISTENER_KEY = "opportunitiesExtensionProductsMessageListener";
const OPPORTUNITY_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid=";
const ACCOUNT_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/accounts/accounts-detail?id=";
const DEBUG_BODY_LIMIT = 12000;
const OPPORTUNITIES_PAGE_LIMIT = 15;
const DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS = 24;
const TOKEN_RELAY_URL = "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay";
const OPPORTUNITIES_QUERY_URL = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/11.13.18.05/custom-actions/queries";
const AUTH_STATUS = {
idle: "idle",
pending: "pending",
authenticated: "authenticated",
unauthenticated: "unauthenticated"
};
const CONSUMPTION_TREND_PERIODS = [
{ value: "quarterly", label: "Quarterly", windowSize: 8 },
{ value: "monthly", label: "Monthly", windowSize: 13 },
{ value: "weekly", label: "Weekly", windowSize: 13 },
{ value: "daily", label: "Daily", windowSize: 30 }
];
const CONSUMPTION_TREND_SERIES = [
{ value: "funded", label: "Funded Allocation" },
{ value: "overage", label: "Overage" },
{ value: "average", label: "Average Consumption" }
];
const DEFAULT_CONSUMPTION_TREND_PERIOD = "weekly";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"];
const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"];
const OPPORTUNITY_TYPE_OPTIONS = [
{ label: "All records I can see", value: "ORA_ALLOPTIES" },
{ label: "I am credit receiver", value: "ORA_CREDITRECEIVER_ISME" },
{ label: "I am on the team", value: "ORA_MYSALESTEAMOPTIES" },
{ label: "I or my subordinate is a credit receiver", value: "ORA_CREDITRECEIVER_MYORG" },
{ label: "I own", value: "ORA_MYOPTIES" },
{ label: "My subordinates are on the team", value: "ORA_MYSUBORDSSALESTEAMOPTIES" },
{ label: "My subordinates own", value: "ORA_MYSUBORDINATESOPTIES" },
{ label: "My territory", value: "ORA_MYASSGTERROPTIES" },
{ label: "My territory hierarchy", value: "ORA_MYASSGDESCTERROPTIES" },
{ label: "My territory membership", value: "ORA_MYASSGMEMBTERROPTIES" },
{ label: "My territory membership hierarchy", value: "ORA_MYASSGMEMBDESCTERROPTIES" }
];
const DEFAULT_OPPORTUNITY_TYPE_VALUES = [
"ORA_MYSALESTEAMOPTIES",
"ORA_MYASSGTERROPTIES",
"ORA_CREDITRECEIVER_ISME"
];
const OPPTY_PRODUCTS_CACHE_OPTIONS = [
{ label: "1 hour", value: 1 },
{ label: "6 hours", value: 6 },
{ label: "12 hours", value: 12 },
{ label: "24 hours (default)", value: 24 },
{ label: "48 hours", value: 48 },
{ label: "7 days", value: 168 }
];
const PERIOD_OPTIONS = [
"Current Quarter",
"Next Quarter",
"Previous Quarter",
"Current Fiscal Year",
"4 Rolling Quarters (CQ + 3)",
"RENEWALS (Current + Past Due)"
];
const ALLOWED_PATHS = [
"/hcmUI/faces/FuseWelcome",
"/fscmUI/faces/FuseWelcome"
];
return { TILE_ID, TILE_LABEL_ID, SALES_GROUP_SELECTOR, ADD_ICON_SELECTOR, MODAL_ID, STYLE_ID, ESCAPE_LISTENER_KEY, DEBUG_PANEL_ID, OPPORTUNITIES_TABLE_ID, STAGE_FILTERS_ID, STATUS_FILTERS_ID, CUSTOMER_FILTER_BUTTON_ID, CUSTOMER_FILTER_PANEL_ID, CUSTOMER_FILTER_SEARCH_ID, CUSTOMER_FILTER_SELECT_ALL_ID, CUSTOMER_FILTER_LIST_ID, OWNER_FILTER_BUTTON_ID, OWNER_FILTER_PANEL_ID, OWNER_FILTER_SEARCH_ID, OWNER_FILTER_SELECT_ALL_ID, OWNER_FILTER_LIST_ID, TABLE_SEARCH_ID, STAGE_DASHBOARD_ID, PRODUCTS_PROGRESS_ID, PRODUCTS_CACHE_TIMESTAMP_ID, FILTER_PREFERENCES_STORAGE_KEY, THEME_STORAGE_KEY, OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, PREFERENCES_MODAL_ID, ACCOUNT_PLANS_MODAL_ID, PRODUCTS_MESSAGE_LISTENER_KEY, OPPORTUNITY_DETAIL_URL, ACCOUNT_DETAIL_URL, DEBUG_BODY_LIMIT, OPPORTUNITIES_PAGE_LIMIT, DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS, TOKEN_RELAY_URL, OPPORTUNITIES_QUERY_URL, AUTH_STATUS, CONSUMPTION_TREND_PERIODS, CONSUMPTION_TREND_SERIES, DEFAULT_CONSUMPTION_TREND_PERIOD, SVG_NAMESPACE, STAGE_OPTIONS, STATUS_OPTIONS, OPPORTUNITY_TYPE_OPTIONS, DEFAULT_OPPORTUNITY_TYPE_VALUES, OPPTY_PRODUCTS_CACHE_OPTIONS, PERIOD_OPTIONS, ALLOWED_PATHS };
}

107
src/content/fusion-page.js Normal file
View File

@@ -0,0 +1,107 @@
function createContentFusionPage({
ADD_ICON_SELECTOR,
ALLOWED_PATHS,
SALES_GROUP_SELECTOR,
TILE_ID,
TILE_LABEL_ID,
appendPath,
openOpportunitiesModal,
environment
}) {
const { Node, document, window } = environment;
function createOpportunitiesTile() {
const wrapper = document.createElement("div");
wrapper.className = "flat-grid-cell";
const item = document.createElement("div");
item.id = TILE_ID;
item.className = "app-nav-item opportunities-extension-tile";
item.setAttribute("filmstrip", "Opportunities Extension");
item.setAttribute("page", "undefined");
item.setAttribute("index", "0");
item.setAttribute("type", "subcluster");
item.setAttribute("title", "Opportunities Extension");
item.setAttribute("group", "groupNode_tools");
item.setAttribute("destinationurl", "https://gxpap.oracle.com/ords/pgxpap/f?p=138");
item.setAttribute("targetframe", "_blank");
item.setAttribute("isdesturlexist", "true");
item.setAttribute("role", "presentation");
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 48 48");
svg.setAttribute("style", "fill:currentColor");
svg.setAttribute("class", "svg-nav suiicon svg-bkgd09");
svg.setAttribute("data-icon", "navi_reportsearch");
svg.setAttribute("role", "presentation");
svg.setAttribute("focusable", "false");
appendPath(svg, "svg-shortcut", "M28 42.5l-3 2.7v-1.7c-.4 0-1.4 0-2.5.6-1.3 1-1.5 1.6-1.5 1.6s-.4-1.2.8-2.7c1.2-1.6 2.6-1.7 3.2-1.6v-1.6l3 2.7z");
appendPath(svg, "svg-cluster", "M28.5 41.3c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2zm-4 0c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2c-.7 0-1.2-.5-1.2-1.2s.5-1.2 1.2-1.2zm-4 0c.7 0 1.2.5 1.2 1.2s-.5 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2z");
appendPath(svg, "svg-icon15", "M16 31l-1.6-1-3.4 6.5s0 1 .5 1.4c.5.2 1.4-.4 1.4-.4l3-6.7z");
appendPath(svg, "svg-icon03", "M36 10H12c-.8 0-2 1.2-2 2v20c0 .4.2.8.5 1l2-3.6c-1-1.4-1.6-3-1.6-5 0-4.2 3.3-7.6 7.4-7.6H20V16h2v1.7c.7.4 1.3 1 1.8 1.5.6.5 1 1 1.3 1.8h4v7h-4c-.3.7-.8 1.4-1.4 2H35v2H20.3l-2 .2H18L17 34h19c.8 0 2-1.2 2-2V12c0-.8-1.2-2-2-2zm-23 4v-2h22v2H13zm22 14h-5V17h5v11z");
appendPath(svg, "svg-icon12", "M18.5 19c-3 0-5.5 2.5-5.5 5.5s2.5 5.5 5.5 5.5 5.5-2.5 5.5-5.5-2.5-5.5-5.5-5.5zm0 9c-2 0-3.5-1.6-3.5-3.5 0-2 1.6-3.5 3.5-3.5s3.5 1.6 3.5 3.5c0 2-1.6 3.5-3.5 3.5z");
appendPath(svg, "svg-outline", "M35 34.56H13a2.7 2.7 0 0 1-3-3V14a2.76 2.76 0 0 1 3-3h22a2.74 2.74 0 0 1 3 3v17.56a2.68 2.68 0 0 1-3 3zM16.98 22.32a4.72 4.72 0 1 0 4.73 4.73 4.72 4.72 0 0 0-4.73-4.73zM24 25h3.47v4.72H24V25zm5.78-4.66h4.69v9.38h-4.69v-9.38zM13.5 14.5h20.9v2.4H13.5v-2.4zm6.9 17.59l-.01-1.94zm-2.04-12.75l-5.35-.02zm2.13 12.67H35.7zm-.09-8.05L20.39 19z");
appendPath(svg, "svg-outline", "M16.98 31.72a4.68 4.68 0 1 0-4.75-4.67 4.74 4.74 0 0 0 4.75 4.67zm-1.44-.5l-3.6 6.83zM8 40");
const link = document.createElement("a");
link.id = TILE_LABEL_ID;
link.className = "app-nav-label flat-grid-nav-label";
link.href = "#";
link.textContent = "Opportunities Extension";
item.append(svg, link);
wrapper.append(item);
wrapper.addEventListener("click", handleTileClick);
return wrapper;
}
function handleTileClick(event) {
event.preventDefault();
event.stopPropagation();
openOpportunitiesModal();
}
function isAllowedPage() {
if (window.location.hostname !== "eeho.fa.us2.oraclecloud.com") {
return false;
}
return ALLOWED_PATHS.some((path) => {
return window.location.pathname === path || window.location.pathname.startsWith(`${path}/`);
});
}
function findInsertionPoint(salesGroup) {
const addIconInsideGroup = salesGroup.querySelector(ADD_ICON_SELECTOR);
if (addIconInsideGroup) {
return addIconInsideGroup;
}
const container = salesGroup.closest(".flat-grid, .flat-grid-container, [id*='yourapps'], [id*='groupNode']") || salesGroup.parentElement || document.body;
const addIconCells = Array.from(container.querySelectorAll(ADD_ICON_SELECTOR));
return addIconCells.find((cell) => {
return Boolean(salesGroup.compareDocumentPosition(cell) & Node.DOCUMENT_POSITION_FOLLOWING);
}) || null;
}
function insertTile() {
if (document.getElementById(TILE_ID)) {
return true;
}
const salesGroup = document.querySelector(SALES_GROUP_SELECTOR);
const addIconCell = salesGroup ? findInsertionPoint(salesGroup) : null;
if (!salesGroup || !addIconCell) {
return false;
}
addIconCell.before(createOpportunitiesTile());
return true;
}
return { createOpportunitiesTile, handleTileClick, isAllowedPage, findInsertionPoint, insertTile };
}

74
src/content/icons.js Normal file
View File

@@ -0,0 +1,74 @@
function createContentIcons({
appendPath,
environment
}) {
const { document } = environment;
function createThemeIcon(iconName) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
if (iconName === "sun") {
path.setAttribute("d", "M12 3V1m0 22v-2m9-9h2M1 12h2m15.36 6.36 1.42 1.42M4.22 4.22l1.42 1.42m12.72 0 1.42-1.42M4.22 19.78l1.42-1.42M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z");
} else {
path.setAttribute("d", "M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5a8.5 8.5 0 1 0 10.7 10.7Z");
}
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
path.setAttribute("stroke-width", "1.8");
svg.append(path);
return svg;
}
function createSettingsIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const upperControl = document.createElementNS("http://www.w3.org/2000/svg", "circle");
const lowerControl = document.createElementNS("http://www.w3.org/2000/svg", "circle");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("class", "opportunities-extension-settings-icon");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
appendPath(svg, "", "M4 7h16M4 17h16");
[upperControl, lowerControl].forEach((control, index) => {
control.setAttribute("cx", index === 0 ? "9" : "15");
control.setAttribute("cy", index === 0 ? "7" : "17");
control.setAttribute("r", "2.5");
control.setAttribute("fill", "currentColor");
control.setAttribute("stroke", "currentColor");
control.setAttribute("stroke-width", "1.8");
});
svg.querySelector("path").setAttribute("fill", "none");
svg.querySelector("path").setAttribute("stroke", "currentColor");
svg.querySelector("path").setAttribute("stroke-linecap", "round");
svg.querySelector("path").setAttribute("stroke-width", "1.8");
svg.append(upperControl, lowerControl);
return svg;
}
function createCloseIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
path.setAttribute("d", "m6 6 12 12M18 6 6 18");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-width", "1.8");
svg.append(path);
return svg;
}
return { createThemeIcon, createSettingsIcon, createCloseIcon };
}

9
src/content/main.js Normal file
View File

@@ -0,0 +1,9 @@
/** Preserve the existing document_idle initialization and observer behavior. */
function startContentApplication({ environment, page, styles, products }) {
if (!page.isAllowedPage()) return;
styles.ensureExtensionStyles();
products.ensureProductsBackgroundMessageHandler();
if (page.insertTile()) return;
const observer = new environment.MutationObserver(() => page.insertTile());
observer.observe(environment.document.documentElement, { childList: true, subtree: true });
}

521
src/content/modal.js Normal file
View File

@@ -0,0 +1,521 @@
function createContentModal({
ACCOUNT_PLANS_MODAL_ID,
AUTH_STATUS,
CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID,
CUSTOMER_FILTER_PANEL_ID,
CUSTOMER_FILTER_SEARCH_ID,
CUSTOMER_FILTER_SELECT_ALL_ID,
DEBUG_PANEL_ID,
ESCAPE_LISTENER_KEY,
MODAL_ID,
OPPORTUNITIES_TABLE_ID,
OWNER_FILTER_BUTTON_ID,
OWNER_FILTER_LIST_ID,
OWNER_FILTER_PANEL_ID,
OWNER_FILTER_SEARCH_ID,
OWNER_FILTER_SELECT_ALL_ID,
PERIOD_OPTIONS,
PREFERENCES_MODAL_ID,
PRODUCTS_CACHE_TIMESTAMP_ID,
PRODUCTS_PROGRESS_ID,
STAGE_DASHBOARD_ID,
STAGE_FILTERS_ID,
STATUS_FILTERS_ID,
TABLE_SEARCH_ID,
applyTheme,
authSessionState,
closeAccountPlansModal,
closeCustomerFilterPanel,
closeOwnerFilterPanel,
closePreferencesModal,
createSettingsIcon,
debugState,
ensureExtensionStyles,
getCustomerOptions,
getOwnerOptions,
getSavedOpportunityTypeValues,
getSavedOpptyProductsCacheHours,
getSavedTheme,
openPreferencesModal,
opportunitiesFilterState,
opportunitiesQueryState,
preferencesState,
renderCustomerFilter,
renderCustomerFilterList,
renderOpportunitiesTable,
renderOwnerFilter,
renderOwnerFilterList,
renderStageFilterButtons,
renderStatusFilterButtons,
requestOpportunitiesForPeriod,
requestTokenRelayOnce,
resetCustomerAndOwnerFiltersForPeriod,
resetOpportunitiesTable,
restoreFilterPreferences,
saveFilterPreferences,
toggleCustomerFilterPanel,
toggleDebugPanel,
toggleOwnerFilterPanel,
environment
}) {
const { document, window } = environment;
function openOpportunitiesModal() {
ensureExtensionStyles();
debugState.debugSecretsVisible = false;
const existingModal = document.getElementById(MODAL_ID);
if (existingModal) {
existingModal.hidden = false;
existingModal.querySelector("select").focus();
document.documentElement.classList.add("opportunities-extension-scroll-lock");
ensureEscapeKeyHandler();
requestTokenRelayOnce().catch(() => {});
return;
}
const overlay = document.createElement("div");
debugState.requestLog = [];
authSessionState.tokenRelayRequest = null;
authSessionState.consumerTokenServiceRequest = null;
authSessionState.accessToken = "";
opportunitiesQueryState.periodRequestVersion = 0;
authSessionState.authStatus = AUTH_STATUS.idle;
resetOpportunitiesTable();
preferencesState.selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues());
preferencesState.opptyProductsCacheHours = getSavedOpptyProductsCacheHours();
overlay.id = MODAL_ID;
overlay.className = "opportunities-extension-modal";
overlay.setAttribute("data-theme", getSavedTheme());
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-labelledby", "opportunities-extension-title");
overlay.setAttribute("aria-describedby", "opportunities-extension-subtitle");
const shell = document.createElement("section");
shell.className = "opportunities-extension-shell";
const header = document.createElement("header");
header.className = "opportunities-extension-header";
const title = document.createElement("h1");
title.id = "opportunities-extension-title";
title.textContent = "Opportunities Extension";
const subtitle = document.createElement("p");
subtitle.id = "opportunities-extension-subtitle";
subtitle.textContent = "Lista de oportunidades do HCM Opportunities List";
const authBadge = document.createElement("span");
authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending";
authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending);
authBadge.textContent = "Authenticating";
const subtitleRow = document.createElement("div");
subtitleRow.className = "opportunities-extension-subtitle-row";
const productsProgressBadge = document.createElement("span");
productsProgressBadge.id = PRODUCTS_PROGRESS_ID;
productsProgressBadge.className = "opportunities-extension-products-progress";
productsProgressBadge.setAttribute("role", "status");
productsProgressBadge.hidden = true;
const productsCacheTimestamp = document.createElement("span");
productsCacheTimestamp.id = PRODUCTS_CACHE_TIMESTAMP_ID;
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
productsCacheTimestamp.setAttribute("role", "status");
productsCacheTimestamp.hidden = true;
subtitleRow.append(subtitle, authBadge, productsProgressBadge, productsCacheTimestamp);
const titleBlock = document.createElement("div");
titleBlock.append(title, subtitleRow);
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Fechar");
closeButton.textContent = "X";
closeButton.addEventListener("click", closeOpportunitiesModal);
const themeButton = document.createElement("button");
themeButton.type = "button";
themeButton.className = "opportunities-extension-icon-button opportunities-extension-theme-toggle";
themeButton.addEventListener("click", () => {
const nextTheme = overlay.getAttribute("data-theme") === "dark" ? "light" : "dark";
applyTheme(overlay, themeButton, nextTheme);
});
applyTheme(overlay, themeButton, overlay.getAttribute("data-theme"));
const preferencesButton = document.createElement("button");
preferencesButton.type = "button";
preferencesButton.className = "opportunities-extension-icon-button";
preferencesButton.setAttribute("aria-label", "Abrir preferências");
preferencesButton.title = "Preferences";
preferencesButton.append(createSettingsIcon());
preferencesButton.addEventListener("click", openPreferencesModal);
const headerActions = document.createElement("div");
headerActions.className = "opportunities-extension-header-actions";
headerActions.append(themeButton, preferencesButton, closeButton);
header.append(titleBlock, headerActions);
const body = document.createElement("main");
body.className = "opportunities-extension-body";
const form = document.createElement("form");
form.className = "opportunities-extension-form";
const field = document.createElement("label");
field.className = "opportunities-extension-field";
const labelText = document.createElement("span");
labelText.textContent = "Reference";
const selectWrap = document.createElement("span");
selectWrap.className = "opportunities-extension-select-wrap";
const select = document.createElement("select");
select.name = "opportunityPeriod";
PERIOD_OPTIONS.forEach((optionLabel) => {
const option = document.createElement("option");
option.value = optionLabel;
option.textContent = optionLabel;
select.append(option);
});
select.addEventListener("change", () => {
resetCustomerAndOwnerFiltersForPeriod();
saveFilterPreferences(select.value);
requestOpportunitiesForPeriod(select.value);
});
selectWrap.append(select);
field.append(labelText, selectWrap);
restoreFilterPreferences(select);
const stageFilter = document.createElement("section");
stageFilter.className = "opportunities-extension-stage-filter";
const stageFilterLabel = document.createElement("span");
stageFilterLabel.className = "opportunities-extension-stage-filter-label";
stageFilterLabel.textContent = "Stage";
const stageFilters = document.createElement("div");
stageFilters.id = STAGE_FILTERS_ID;
stageFilters.className = "opportunities-extension-stage-filter-controls";
stageFilters.setAttribute("role", "group");
stageFilters.setAttribute("aria-label", "Filter by stage");
stageFilter.append(stageFilterLabel, stageFilters);
const customerFilter = document.createElement("section");
customerFilter.className = "opportunities-extension-customer-filter";
const customerFilterLabel = document.createElement("span");
customerFilterLabel.className = "opportunities-extension-customer-filter-label";
customerFilterLabel.textContent = "Customer";
const customerFilterButton = document.createElement("button");
customerFilterButton.id = CUSTOMER_FILTER_BUTTON_ID;
customerFilterButton.type = "button";
customerFilterButton.className = "opportunities-extension-customer-filter-trigger";
customerFilterButton.setAttribute("aria-expanded", "false");
customerFilterButton.setAttribute("aria-controls", CUSTOMER_FILTER_PANEL_ID);
customerFilterButton.addEventListener("click", toggleCustomerFilterPanel);
const customerFilterPanel = document.createElement("section");
customerFilterPanel.id = CUSTOMER_FILTER_PANEL_ID;
customerFilterPanel.className = "opportunities-extension-customer-filter-panel";
customerFilterPanel.hidden = true;
const customerSearchInput = document.createElement("input");
customerSearchInput.id = CUSTOMER_FILTER_SEARCH_ID;
customerSearchInput.className = "opportunities-extension-customer-filter-search";
customerSearchInput.type = "search";
customerSearchInput.placeholder = "Search customers";
customerSearchInput.setAttribute("aria-label", "Search customers");
customerSearchInput.addEventListener("input", () => {
opportunitiesFilterState.customerSearch = customerSearchInput.value;
saveFilterPreferences();
renderCustomerFilterList();
});
const selectAllLabel = document.createElement("label");
selectAllLabel.className = "opportunities-extension-customer-filter-select-all";
const selectAllCheckbox = document.createElement("input");
selectAllCheckbox.id = CUSTOMER_FILTER_SELECT_ALL_ID;
selectAllCheckbox.type = "checkbox";
selectAllCheckbox.addEventListener("change", () => {
const customerOptions = getCustomerOptions();
opportunitiesFilterState.selectedCustomerKeys = selectAllCheckbox.checked
? new Set(customerOptions.map((customer) => customer.key))
: new Set();
opportunitiesFilterState.hasSavedCustomerFilter = true;
saveFilterPreferences();
renderCustomerFilter();
renderOpportunitiesTable();
});
const selectAllText = document.createElement("span");
selectAllText.textContent = "Select all";
selectAllLabel.append(selectAllCheckbox, selectAllText);
const customerFilterList = document.createElement("div");
customerFilterList.id = CUSTOMER_FILTER_LIST_ID;
customerFilterList.className = "opportunities-extension-customer-filter-list";
customerFilterPanel.append(customerSearchInput, selectAllLabel, customerFilterList);
customerFilter.append(customerFilterLabel, customerFilterButton, customerFilterPanel);
const ownerFilter = document.createElement("section");
ownerFilter.className = "opportunities-extension-customer-filter";
const ownerFilterLabel = document.createElement("span");
ownerFilterLabel.className = "opportunities-extension-customer-filter-label";
ownerFilterLabel.textContent = "Owner";
const ownerFilterButton = document.createElement("button");
ownerFilterButton.id = OWNER_FILTER_BUTTON_ID;
ownerFilterButton.type = "button";
ownerFilterButton.className = "opportunities-extension-customer-filter-trigger";
ownerFilterButton.setAttribute("aria-expanded", "false");
ownerFilterButton.setAttribute("aria-controls", OWNER_FILTER_PANEL_ID);
ownerFilterButton.addEventListener("click", toggleOwnerFilterPanel);
const ownerFilterPanel = document.createElement("section");
ownerFilterPanel.id = OWNER_FILTER_PANEL_ID;
ownerFilterPanel.className = "opportunities-extension-customer-filter-panel";
ownerFilterPanel.hidden = true;
const ownerSearchInput = document.createElement("input");
ownerSearchInput.id = OWNER_FILTER_SEARCH_ID;
ownerSearchInput.className = "opportunities-extension-customer-filter-search";
ownerSearchInput.type = "search";
ownerSearchInput.placeholder = "Search owners";
ownerSearchInput.setAttribute("aria-label", "Search owners");
ownerSearchInput.addEventListener("input", () => {
opportunitiesFilterState.ownerSearch = ownerSearchInput.value;
saveFilterPreferences();
renderOwnerFilterList();
});
const ownerSelectAllLabel = document.createElement("label");
ownerSelectAllLabel.className = "opportunities-extension-customer-filter-select-all";
const ownerSelectAllCheckbox = document.createElement("input");
ownerSelectAllCheckbox.id = OWNER_FILTER_SELECT_ALL_ID;
ownerSelectAllCheckbox.type = "checkbox";
ownerSelectAllCheckbox.addEventListener("change", () => {
const ownerOptions = getOwnerOptions();
opportunitiesFilterState.selectedOwnerKeys = ownerSelectAllCheckbox.checked
? new Set(ownerOptions.map((owner) => owner.key))
: new Set();
opportunitiesFilterState.hasSavedOwnerFilter = true;
saveFilterPreferences();
renderOwnerFilter();
renderOpportunitiesTable();
});
const ownerSelectAllText = document.createElement("span");
ownerSelectAllText.textContent = "Select all";
ownerSelectAllLabel.append(ownerSelectAllCheckbox, ownerSelectAllText);
const ownerFilterList = document.createElement("div");
ownerFilterList.id = OWNER_FILTER_LIST_ID;
ownerFilterList.className = "opportunities-extension-customer-filter-list";
ownerFilterPanel.append(ownerSearchInput, ownerSelectAllLabel, ownerFilterList);
ownerFilter.append(ownerFilterLabel, ownerFilterButton, ownerFilterPanel);
const tableSearchField = document.createElement("label");
tableSearchField.className = "opportunities-extension-table-search-field";
const tableSearchLabel = document.createElement("span");
tableSearchLabel.textContent = "Search";
const tableSearchInput = document.createElement("input");
tableSearchInput.id = TABLE_SEARCH_ID;
tableSearchInput.type = "search";
tableSearchInput.placeholder = "Search opportunities";
tableSearchInput.setAttribute("aria-label", "Search opportunities");
tableSearchInput.value = opportunitiesFilterState.tableSearch;
tableSearchInput.addEventListener("input", () => {
opportunitiesFilterState.tableSearch = tableSearchInput.value;
saveFilterPreferences();
renderOpportunitiesTable();
});
tableSearchField.append(tableSearchLabel, tableSearchInput);
const statusFilter = document.createElement("section");
statusFilter.className = "opportunities-extension-status-filter";
const statusFilterLabel = document.createElement("span");
statusFilterLabel.className = "opportunities-extension-status-filter-label";
statusFilterLabel.textContent = "Status";
const statusFilters = document.createElement("div");
statusFilters.id = STATUS_FILTERS_ID;
statusFilters.className = "opportunities-extension-status-filter-controls";
statusFilters.setAttribute("role", "group");
statusFilters.setAttribute("aria-label", "Filter by status");
statusFilter.append(statusFilterLabel, statusFilters);
form.addEventListener("submit", (event) => event.preventDefault());
form.append(field, customerFilter, ownerFilter, tableSearchField, statusFilter, stageFilter);
body.append(form);
const stageDashboard = document.createElement("section");
stageDashboard.id = STAGE_DASHBOARD_ID;
stageDashboard.className = "opportunities-extension-stage-dashboard";
stageDashboard.setAttribute("aria-label", "Opportunity amount by stage");
body.append(stageDashboard);
const tableSurface = document.createElement("section");
tableSurface.className = "opportunities-extension-table-surface";
const opportunitiesTable = document.createElement("table");
opportunitiesTable.id = OPPORTUNITIES_TABLE_ID;
opportunitiesTable.className = "opportunities-extension-table";
opportunitiesTable.setAttribute("aria-label", "Opportunities");
tableSurface.append(opportunitiesTable);
body.append(tableSurface);
const debugPanel = document.createElement("aside");
debugPanel.id = DEBUG_PANEL_ID;
debugPanel.className = "opportunities-extension-debug-panel";
debugPanel.hidden = true;
body.append(debugPanel);
shell.append(header, body);
overlay.append(shell);
document.body.append(overlay);
document.documentElement.classList.add("opportunities-extension-scroll-lock");
ensureEscapeKeyHandler();
renderStageFilterButtons();
renderStatusFilterButtons();
renderCustomerFilter();
renderOwnerFilter();
renderOpportunitiesTable();
requestOpportunitiesForPeriod(select.value);
select.focus();
}
function updateAuthBadge(status) {
const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`);
if (!badge) {
return;
}
const badgeStatus = status === AUTH_STATUS.authenticated || status === AUTH_STATUS.unauthenticated
? status
: AUTH_STATUS.pending;
badge.setAttribute("data-auth-status", badgeStatus);
badge.className = `opportunities-extension-auth-badge opportunities-extension-auth-badge-${badgeStatus}`;
badge.textContent = badgeStatus === AUTH_STATUS.authenticated
? "Authenticated"
: badgeStatus === AUTH_STATUS.unauthenticated
? "Unauthenticated"
: "Authenticating";
}
function closeOpportunitiesModal() {
const modal = document.getElementById(MODAL_ID);
if (modal) {
closePreferencesModal();
closeAccountPlansModal();
modal.hidden = true;
}
debugState.debugSecretsVisible = false;
document.documentElement.classList.remove("opportunities-extension-scroll-lock");
}
function ensureEscapeKeyHandler() {
if (window[ESCAPE_LISTENER_KEY]) {
return;
}
window[ESCAPE_LISTENER_KEY] = true;
document.addEventListener("keydown", (event) => {
const modal = document.getElementById(MODAL_ID);
if (!modal || modal.hidden) {
return;
}
if (event.ctrlKey && event.key.toLowerCase() === "d") {
event.preventDefault();
event.stopPropagation();
toggleDebugPanel();
return;
}
if (event.key === "Escape") {
const accountPlansModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
if (accountPlansModal && !accountPlansModal.hidden) {
closeAccountPlansModal();
event.preventDefault();
return;
}
const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID);
if (preferencesModal && !preferencesModal.hidden) {
closePreferencesModal();
event.preventDefault();
return;
}
if (closeCustomerFilterPanel()) {
event.preventDefault();
return;
}
if (closeOwnerFilterPanel()) {
event.preventDefault();
return;
}
closeOpportunitiesModal();
}
});
document.addEventListener("pointerdown", (event) => {
const modal = document.getElementById(MODAL_ID);
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
const ownerPanel = document.getElementById(OWNER_FILTER_PANEL_ID);
if (!modal || modal.hidden) {
return;
}
if (panel && !panel.hidden) {
const customerFilter = panel.closest(".opportunities-extension-customer-filter");
if (customerFilter && !customerFilter.contains(event.target)) {
closeCustomerFilterPanel(false);
}
}
if (ownerPanel && !ownerPanel.hidden) {
const ownerFilter = ownerPanel.closest(".opportunities-extension-customer-filter");
if (ownerFilter && !ownerFilter.contains(event.target)) {
closeOwnerFilterPanel(false);
}
}
});
}
return { openOpportunitiesModal, updateAuthBadge, closeOpportunitiesModal, ensureEscapeKeyHandler };
}

20
src/content/styles.js Normal file
View File

@@ -0,0 +1,20 @@
function createContentStyles({
STYLE_ID,
redwoodStyles,
environment
}) {
const { document } = environment;
function ensureExtensionStyles() {
if (document.getElementById(STYLE_ID)) {
return;
}
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = redwoodStyles;
document.head.append(style);
}
return { ensureExtensionStyles };
}

File diff suppressed because it is too large Load Diff

10
src/content/svg.js Normal file
View File

@@ -0,0 +1,10 @@
function createSvgPaths({ environment }) {
const { document } = environment;
function appendPath(svg, className, d) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("class", className);
path.setAttribute("d", d);
svg.append(path);
}
return { appendPath };
}

32
src/content/theme.js Normal file
View File

@@ -0,0 +1,32 @@
function createContentTheme({
THEME_STORAGE_KEY,
createThemeIcon,
environment
}) {
const getLocalStorage = () => environment.localStorage;
function getSavedTheme() {
try {
return getLocalStorage().getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light";
} catch (error) {
return "light";
}
}
function applyTheme(modal, button, theme) {
const isDarkTheme = theme === "dark";
modal.setAttribute("data-theme", isDarkTheme ? "dark" : "light");
button.textContent = "";
button.setAttribute("aria-label", isDarkTheme ? "Ativar modo claro" : "Ativar modo escuro");
button.title = isDarkTheme ? "Light mode" : "Dark mode";
button.append(createThemeIcon(isDarkTheme ? "sun" : "moon"));
try {
getLocalStorage().setItem(THEME_STORAGE_KEY, isDarkTheme ? "dark" : "light");
} catch (error) {
// Continue with the selected theme when browser storage is unavailable.
}
}
return { getSavedTheme, applyTheme };
}

View File

@@ -0,0 +1,103 @@
function createAiSummaryContent({
aiSummaryState,
invalidateSalesCommonToken,
logRequestFailure,
logRequestStart,
logRequestSuccess,
readResponseBodyForDebug,
requestTokenRelaySalesCommon,
environment
}) {
const { document, fetch } = environment;
function requestGenerateAiSummary(optyNumber) {
const key = String(optyNumber);
const existing = aiSummaryState.aiSummariesByOptyNumber.get(key);
if (existing && existing.status === "loading") {
return existing.promise;
}
const state = { status: "loading", summaryText: "", error: "", promise: null };
aiSummaryState.aiSummariesByOptyNumber.set(key, state);
state.promise = (async () => {
let entry;
try {
let token = await requestTokenRelaySalesCommon();
if (!token) {
throw new Error("Authentication is required to generate the summary.");
}
const path = "/api/sales-common/gen-ai/v1/summarizations/generate";
const url = `https://eeho.fa.us2.oraclecloud.com${path}`;
const payload = { path, promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: key };
let response;
for (let attempt = 0; attempt < 2; attempt += 1) {
const headers = { Accept: "application/json", Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
entry = logRequestStart({
name: "requestGenerateAiSummary", method: "POST", url, headers,
body: JSON.stringify(payload, null, 2), metadata: { optyNumber: key, attempt: attempt + 1 }
});
response = await fetch(url, {
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
});
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
if (response.status !== 401 && response.status !== 403) {
break;
}
// A late failure for an old token must not discard a newly refreshed token.
invalidateSalesCommonToken(token);
if (attempt === 0) {
token = await requestTokenRelaySalesCommon();
}
}
if (!response.ok) {
throw new Error(`requestGenerateAiSummary failed with status ${response.status}.`);
}
let data;
try {
data = await response.json();
} catch (error) {
throw new Error("requestGenerateAiSummary returned an invalid JSON response.");
}
if (!data || typeof data.summaryText !== "string" || !data.summaryText.trim()) {
throw new Error("requestGenerateAiSummary returned an empty summary.");
}
state.summaryText = data.summaryText;
state.status = "ready";
} catch (error) {
state.status = "error";
state.error = error.message || "Unable to generate the AI summary.";
if (entry) {
logRequestFailure(entry, state.error);
}
} finally {
// Update only visible summaries, without reopening rows or rebuilding the table.
document.querySelectorAll(".opportunities-extension-ai-summary").forEach((alert) => {
if (alert.dataset.optyNumber === key) {
alert.replaceWith(createAiSummaryAlert(key));
}
});
}
})();
return state.promise;
}
function createAiSummaryAlert(optyNumber) {
const state = aiSummaryState.aiSummariesByOptyNumber.get(optyNumber);
const alert = document.createElement("div");
alert.className = "opportunities-extension-ai-summary";
alert.dataset.optyNumber = optyNumber;
alert.setAttribute("role", "status");
alert.setAttribute("aria-live", "polite");
alert.setAttribute("aria-busy", String(!state || state.status === "loading"));
const title = document.createElement("strong");
title.textContent = "Summary generated by AI";
const description = document.createElement("p");
description.textContent = !state || state.status === "loading"
? "Generating summary..."
: state.status === "error" ? state.error : state.summaryText;
alert.classList.toggle("opportunities-extension-ai-summary-error", Boolean(state && state.status === "error"));
alert.append(title, description);
return alert;
}
return { requestGenerateAiSummary, createAiSummaryAlert };
}

View File

@@ -0,0 +1,5 @@
function createAiSummaryState() {
return {
aiSummariesByOptyNumber: new Map()
};
}

View File

@@ -0,0 +1,383 @@
function createConsumptionChart({
CONSUMPTION_TREND_PERIODS,
CONSUMPTION_TREND_SERIES,
SVG_NAMESPACE,
calculateAverageConsumption,
consumptionTrendState,
createConsumptionTrendKey,
createNiceConsumptionMaximum,
formatCompactConsumptionNumber,
formatConsumptionCurrency,
formatConsumptionPointLabel,
formatConsumptionRange,
normalizeConsumptionTrendItems,
environment
}) {
const { document, window } = environment;
function renderConsumptionTrendChart(section, chart, items, period, subPlanNum) {
chart.textContent = "";
chart.setAttribute("aria-busy", "false");
const points = normalizeConsumptionTrendItems(items);
if (points.length === 0) {
const empty = document.createElement("p");
empty.className = "opportunities-extension-consumption-chart-empty";
empty.textContent = "No consumption data found for this period.";
chart.append(empty);
return;
}
const option = CONSUMPTION_TREND_PERIODS.find((item) => item.value === period);
const windowSize = option ? option.windowSize : points.length;
const offsetKey = createConsumptionTrendKey(subPlanNum, period);
const maxOffset = Math.max(0, Math.ceil(points.length / windowSize) - 1);
const offset = Math.min(consumptionTrendState.consumptionTrendWindowOffsets.get(offsetKey) || 0, maxOffset);
const end = Math.max(0, points.length - (offset * windowSize));
const start = Math.max(0, end - windowSize);
const visiblePoints = points.slice(start, end);
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, offset);
const navigation = document.createElement("div");
navigation.className = "opportunities-extension-consumption-navigation";
const olderButton = createConsumptionNavigationButton("older", start === 0);
const newerButton = createConsumptionNavigationButton("newer", end === points.length);
const range = document.createElement("strong");
range.textContent = formatConsumptionRange(visiblePoints, period);
olderButton.addEventListener("click", () => {
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.min(maxOffset, offset + 1));
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
});
newerButton.addEventListener("click", () => {
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.max(0, offset - 1));
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
});
navigation.append(olderButton, range, newerButton);
const figure = document.createElement("figure");
figure.className = "opportunities-extension-consumption-figure";
const visual = document.createElement("div");
visual.className = "opportunities-extension-consumption-visual";
const tooltip = document.createElement("div");
tooltip.className = "opportunities-extension-consumption-tooltip";
tooltip.hidden = true;
tooltip.setAttribute("role", "tooltip");
const activeSeries = getConsumptionTrendSeriesFilter(subPlanNum);
const svg = createConsumptionSvg(
visiblePoints,
period,
end === points.length,
activeSeries,
tooltip
);
const legend = createConsumptionLegend(
section,
chart,
items,
period,
subPlanNum,
activeSeries
);
visual.append(svg, legend);
figure.append(visual, tooltip);
chart.append(navigation, figure);
}
function getConsumptionTrendSeriesFilter(subPlanNum) {
if (!consumptionTrendState.consumptionTrendSeriesFilters.has(subPlanNum)) {
consumptionTrendState.consumptionTrendSeriesFilters.set(
subPlanNum,
new Set(CONSUMPTION_TREND_SERIES.map((series) => series.value))
);
}
return consumptionTrendState.consumptionTrendSeriesFilters.get(subPlanNum);
}
function createConsumptionLegend(section, chart, items, period, subPlanNum, activeSeries) {
const legend = document.createElement("div");
legend.className = "opportunities-extension-consumption-legend";
legend.setAttribute("role", "group");
legend.setAttribute("aria-label", "Chart series filters");
CONSUMPTION_TREND_SERIES.forEach((series) => {
const button = document.createElement("button");
const swatch = document.createElement("span");
const label = document.createElement("span");
button.type = "button";
button.dataset.series = series.value;
button.setAttribute("aria-pressed", String(activeSeries.has(series.value)));
button.setAttribute("aria-label", `${activeSeries.has(series.value) ? "Hide" : "Show"} ${series.label}`);
swatch.className = `opportunities-extension-consumption-legend-swatch opportunities-extension-consumption-legend-${series.value}`;
swatch.setAttribute("aria-hidden", "true");
label.textContent = series.label;
button.append(swatch, label);
button.addEventListener("click", () => {
if (activeSeries.has(series.value)) {
activeSeries.delete(series.value);
} else {
activeSeries.add(series.value);
}
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
chart.querySelector(`[data-series="${series.value}"]`)?.focus();
});
legend.append(button);
});
return legend;
}
function createConsumptionNavigationButton(direction, disabled) {
const button = document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-consumption-navigation-button";
button.disabled = disabled;
button.setAttribute("aria-label", direction === "older" ? "Show older consumption data" : "Show newer consumption data");
button.title = direction === "older" ? "Older" : "Newer";
const icon = createSvgElement("svg", {
viewBox: "0 0 24 24",
"aria-hidden": "true",
focusable: "false"
});
icon.append(createSvgElement("path", {
d: direction === "older" ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7",
fill: "none",
stroke: "currentColor",
"stroke-linecap": "round",
"stroke-linejoin": "round",
"stroke-width": "2"
}));
button.append(icon);
return button;
}
function createConsumptionSvg(points, period, hasPartialLatestPoint, activeSeries, tooltip) {
const width = 1000;
const height = 320;
const plot = { left: 62, top: 24, right: 980, bottom: 258 };
const plotWidth = plot.right - plot.left;
const plotHeight = plot.bottom - plot.top;
const average = calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries);
const maximum = createNiceConsumptionMaximum(Math.max(average, ...points.map((point) => point.total)));
const svg = createSvgElement("svg", {
viewBox: `0 0 ${width} ${height}`,
role: "img",
"aria-label": `${period} cloud consumption chart`
});
svg.classList.add("opportunities-extension-consumption-svg");
for (let tick = 0; tick <= 5; tick += 1) {
const value = (maximum / 5) * tick;
const y = plot.bottom - ((value / maximum) * plotHeight);
svg.append(
createSvgElement("line", { x1: plot.left, y1: y, x2: plot.right, y2: y, class: "consumption-grid-line" }),
createSvgText(plot.left - 10, y + 4, formatCompactConsumptionNumber(value), "consumption-axis-label", "end")
);
}
const step = plotWidth / Math.max(points.length, 1);
const barWidth = Math.min(44, Math.max(10, step * 0.58));
points.forEach((point, index) => {
const x = plot.left + (step * index) + ((step - barWidth) / 2);
const fundedAmount = activeSeries.has("funded") ? point.funded : 0;
const overageAmount = activeSeries.has("overage") ? point.overage : 0;
const visibleTotal = fundedAmount + overageAmount;
const fundedHeight = (fundedAmount / maximum) * plotHeight;
const overageHeight = (overageAmount / maximum) * plotHeight;
const group = createSvgElement("g", {
class: "consumption-bar-group",
tabindex: "0",
role: "img",
"aria-label": createConsumptionPointAriaLabel(point, period)
});
["pointerenter", "pointermove", "focus"].forEach((eventName) => {
group.addEventListener(eventName, (event) => {
showConsumptionTooltip(tooltip, point, period, event);
});
});
["pointerleave", "blur"].forEach((eventName) => {
group.addEventListener(eventName, () => {
tooltip.hidden = true;
});
});
if (fundedHeight > 0) {
const fundedBar = createSvgElement("rect", {
x,
y: plot.bottom - fundedHeight,
width: barWidth,
height: fundedHeight,
class: "consumption-bar-funded"
});
appendConsumptionBarAnimation(
fundedBar,
plot.bottom,
plot.bottom - fundedHeight,
fundedHeight,
index * 34
);
group.append(fundedBar);
}
if (overageHeight > 0) {
const overageTop = plot.bottom - fundedHeight - overageHeight;
const overageBar = createSvgElement("rect", {
x,
y: overageTop,
width: barWidth,
height: overageHeight,
class: "consumption-bar-overage"
});
appendConsumptionBarAnimation(
overageBar,
plot.bottom - fundedHeight,
overageTop,
overageHeight,
(index * 34) + 90
);
group.append(overageBar);
}
const top = plot.bottom - fundedHeight - overageHeight;
if (visibleTotal > 0) {
const valueLabel = createSvgText(
x + (barWidth / 2),
Math.max(plot.top + 11, top - 7),
formatCompactConsumptionNumber(visibleTotal),
"consumption-value-label consumption-animated-label",
"middle"
);
valueLabel.style.animationDelay = `${(index * 34) + 360}ms`;
group.append(valueLabel);
}
const label = createSvgText(
x + (barWidth / 2),
plot.bottom + 18,
formatConsumptionPointLabel(point, period),
"consumption-x-label",
period === "monthly" || period === "quarterly" ? "middle" : "end"
);
if (period === "weekly" || period === "daily") {
label.setAttribute("transform", `rotate(-65 ${x + (barWidth / 2)} ${plot.bottom + 18})`);
}
group.append(label);
svg.append(group);
});
if (activeSeries.has("average")) {
const averageY = plot.bottom - ((average / maximum) * plotHeight);
const averageLine = createSvgElement("line", {
x1: plot.left,
y1: averageY,
x2: plot.right,
y2: averageY,
class: "consumption-average-line consumption-animated-average"
});
svg.append(averageLine);
}
return svg;
}
function appendConsumptionBarAnimation(element, fromY, toY, height, delayMs) {
if (prefersReducedMotion()) {
return;
}
element.append(
createSvgElement("animate", {
attributeName: "y",
from: fromY,
to: toY,
dur: "0.52s",
begin: `${delayMs}ms`,
fill: "freeze",
calcMode: "spline",
keyTimes: "0;1",
keySplines: "0.2 0.8 0.2 1"
}),
createSvgElement("animate", {
attributeName: "height",
from: "0",
to: height,
dur: "0.52s",
begin: `${delayMs}ms`,
fill: "freeze",
calcMode: "spline",
keyTimes: "0;1",
keySplines: "0.2 0.8 0.2 1"
})
);
}
function prefersReducedMotion() {
return typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function createConsumptionPointAriaLabel(point, period) {
return `${formatConsumptionPointLabel(point, period)}. `
+ `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}. `
+ `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}. `
+ `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}.`;
}
function showConsumptionTooltip(tooltip, point, period, event) {
const figure = tooltip.parentElement;
if (!figure) {
return;
}
tooltip.textContent = "";
const title = document.createElement("strong");
title.textContent = formatConsumptionPointLabel(point, period);
const details = document.createElement("span");
details.textContent = `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}`;
const overage = document.createElement("span");
overage.textContent = `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}`;
const total = document.createElement("span");
total.textContent = `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}`;
tooltip.append(title, details, overage, total);
tooltip.hidden = false;
const figureBounds = figure.getBoundingClientRect();
const targetBounds = event.currentTarget.getBoundingClientRect();
const pointerX = Number.isFinite(event.clientX) && event.clientX > 0
? event.clientX
: targetBounds.left + (targetBounds.width / 2);
const pointerY = Number.isFinite(event.clientY) && event.clientY > 0
? event.clientY
: targetBounds.top;
const left = Math.min(
Math.max(8, pointerX - figureBounds.left + 12),
Math.max(8, figureBounds.width - tooltip.offsetWidth - 8)
);
const top = Math.max(8, pointerY - figureBounds.top - tooltip.offsetHeight - 10);
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
}
function createSvgElement(name, attributes = {}) {
const element = document.createElementNS(SVG_NAMESPACE, name);
Object.entries(attributes).forEach(([key, value]) => {
element.setAttribute(key, String(value));
});
return element;
}
function createSvgText(x, y, value, className, anchor) {
const text = createSvgElement("text", { x, y, class: className, "text-anchor": anchor });
text.textContent = value;
return text;
}
return { renderConsumptionTrendChart, getConsumptionTrendSeriesFilter, createConsumptionLegend, createConsumptionNavigationButton, createConsumptionSvg, appendConsumptionBarAnimation, prefersReducedMotion, createConsumptionPointAriaLabel, showConsumptionTooltip, createSvgElement, createSvgText };
}

View File

@@ -0,0 +1,155 @@
function createConsumptionModel() {
function normalizeConsumptionTrendItems(items) {
const points = new Map();
(Array.isArray(items) ? items : []).forEach((item) => {
const dateKey = String(item.fyDate || (Array.isArray(item.fiscalTime) ? item.fiscalTime.join("|") : ""));
if (!dateKey) {
return;
}
if (!points.has(dateKey)) {
points.set(dateKey, {
dateKey,
date: parseConsumptionDate(item.fyDate),
fiscalTime: Array.isArray(item.fiscalTime) ? item.fiscalTime.slice() : [],
fiscalQtr: item.fiscalQtr || "",
funded: 0,
overage: 0,
arr: 0,
currencyCode: item.currencyCode || "USD"
});
}
const point = points.get(dateKey);
const amount = toFiniteNumber(item.usedAmount);
const category = String(item.usageCategory || "Funded Allocation").toUpperCase();
if (category === "OVERAGE") {
point.overage += amount;
} else {
point.funded += amount;
}
point.arr = Math.max(point.arr, toFiniteNumber(item.arrCd));
});
return Array.from(points.values())
.map((point) => ({ ...point, total: point.funded + point.overage }))
.sort((left, right) => left.dateKey.localeCompare(right.dateKey));
}
function getConsumptionPointTotal(point, activeSeries) {
const selectedSeries = activeSeries && typeof activeSeries.has === "function"
? activeSeries
: new Set(["funded", "overage"]);
return (selectedSeries.has("funded") ? point.funded : 0)
+ (selectedSeries.has("overage") ? point.overage : 0);
}
function calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries) {
const completePoints = hasPartialLatestPoint && points.length > 1 ? points.slice(0, -1) : points;
return completePoints.reduce(
(sum, point) => sum + getConsumptionPointTotal(point, activeSeries),
0
) / Math.max(completePoints.length, 1);
}
function createNiceConsumptionMaximum(value) {
if (!Number.isFinite(value) || value <= 0) {
return 1;
}
const magnitude = 10 ** Math.floor(Math.log10(value));
const normalized = value / magnitude;
const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return step * magnitude;
}
function formatConsumptionPointLabel(point, period) {
if (period === "quarterly") {
return point.fiscalTime[0] || point.fiscalQtr || point.dateKey;
}
if (period === "monthly") {
return point.fiscalTime[1] || formatConsumptionDate(point.date, { month: "short" });
}
return point.fiscalTime[0] || formatConsumptionDate(point.date, {
day: "2-digit",
month: "short",
year: "2-digit"
});
}
function formatConsumptionRange(points, period) {
if (points.length === 0) {
return "No data";
}
if (period === "quarterly") {
return `${formatConsumptionPointLabel(points[0], period)} - ${formatConsumptionPointLabel(points[points.length - 1], period)}`;
}
if (period === "monthly") {
const options = { month: "short", year: "numeric" };
return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`;
}
const options = { day: "2-digit", month: "short" };
return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`;
}
function parseConsumptionDate(value) {
if (!value) {
return null;
}
const parts = String(value).split("-").map(Number);
return parts.length === 3 && parts.every(Number.isFinite)
? new Date(parts[0], parts[1] - 1, parts[2])
: null;
}
function formatConsumptionDate(date, options) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return "-";
}
return new Intl.DateTimeFormat("en-US", options).format(date).replace(",", "");
}
function formatCompactConsumptionNumber(value) {
const amount = toFiniteNumber(value);
if (Math.abs(amount) >= 1000000) {
return `${(amount / 1000000).toFixed(amount >= 10000000 ? 0 : 1).replace(".0", "")}M`;
}
if (Math.abs(amount) >= 1000) {
return `${Math.round(amount / 1000)}K`;
}
return String(Math.round(amount));
}
function formatConsumptionCurrency(value, currencyCode) {
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currencyCode || "USD",
maximumFractionDigits: 0
}).format(toFiniteNumber(value));
} catch (error) {
return `${currencyCode || "USD"} ${Math.round(toFiniteNumber(value)).toLocaleString("en-US")}`;
}
}
function toFiniteNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
return { normalizeConsumptionTrendItems, getConsumptionPointTotal, calculateAverageConsumption, createNiceConsumptionMaximum, formatConsumptionPointLabel, formatConsumptionRange, parseConsumptionDate, formatConsumptionDate, formatCompactConsumptionNumber, formatConsumptionCurrency, toFiniteNumber };
}

View File

@@ -0,0 +1,93 @@
function createConsumptionPlansAction({
messageTypes,
appendBackgroundRequestLog,
consumptionPlansState,
getNestedValue,
openAccountPlansModal,
renderOpportunitiesTable,
requestConsumerTokenServiceOnce,
sendRuntimeMessage,
showCopyConfirmation,
environment
}) {
const { document } = environment;
function createCloudConsumptionButton(opportunity) {
const button = document.createElement("button");
const optyNumber = String(opportunity.OptyNumber || "");
const partyNumber = getNestedValue(opportunity, ["CustomerAccount", "PartyNumber"]);
const requestState = consumptionPlansState.accountPlansRequestState.get(optyNumber);
const isLoading = requestState && requestState.status === "loading";
button.type = "button";
button.className = "opportunities-extension-action-button";
button.setAttribute("aria-label", `View cloud consumption for ${optyNumber || "opportunity"}`);
button.title = isLoading ? "Loading customer consumption" : "View customer consumption";
button.disabled = Boolean(isLoading);
button.classList.toggle("opportunities-extension-action-button-loading", Boolean(isLoading));
button.append(createCloudConsumptionIcon());
button.addEventListener("click", () => {
if (!partyNumber) {
showCopyConfirmation("Customer Party Number is unavailable.", true);
return;
}
consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "loading" });
openAccountPlansModal(opportunity, [], "loading");
renderOpportunitiesTable();
requestConsumerTokenServiceOnce()
.then((authorizations) => sendRuntimeMessage({
type: messageTypes.requestAccountPlans,
partyNumber: String(partyNumber),
authorizations
}))
.then((response) => {
appendBackgroundRequestLog(response && response.debugRequests);
if (!response || !response.ok) {
throw new Error(response && response.error ? response.error : "Unable to load cloud consumption.");
}
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
status: "ready",
plans: Array.isArray(response.plans) ? response.plans : []
});
renderOpportunitiesTable();
openAccountPlansModal(opportunity, response.plans || [], "ready");
}).catch((error) => {
const errorMessage = error.message || "Unable to load cloud consumption.";
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
status: "error",
error: errorMessage
});
renderOpportunitiesTable();
openAccountPlansModal(opportunity, [], "error", errorMessage);
showCopyConfirmation(errorMessage, true);
});
});
return button;
}
function createCloudConsumptionIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const cloud = document.createElementNS("http://www.w3.org/2000/svg", "path");
const usage = document.createElementNS("http://www.w3.org/2000/svg", "path");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
cloud.setAttribute("d", "M7.2 18.5h10.1a4.2 4.2 0 0 0 .5-8.37A6.25 6.25 0 0 0 5.9 8.75a4.9 4.9 0 0 0 1.3 9.75Z");
usage.setAttribute("d", "M9 15v-2m3 2v-4m3 4V9");
[cloud, usage].forEach((path) => {
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
path.setAttribute("stroke-width", "1.7");
});
svg.append(cloud, usage);
return svg;
}
return { createCloudConsumptionButton, createCloudConsumptionIcon };
}

View File

@@ -0,0 +1,5 @@
function createConsumptionPlansState() {
return {
accountPlansRequestState: new Map()
};
}

View File

@@ -0,0 +1,358 @@
function createConsumptionPlansView({
ACCOUNT_PLANS_MODAL_ID,
DEFAULT_CONSUMPTION_TREND_PERIOD,
MODAL_ID,
createCloseIcon,
createConsumptionTrendSection,
createSvgElement,
formatOracleResponseDate,
getNestedValue,
loadConsumptionTrendForPanel,
environment
}) {
const { document, requestAnimationFrame, setTimeout, window } = environment;
function openAccountPlansModal(opportunity, plans, state = "ready", errorMessage = "") {
const overlay = document.getElementById(MODAL_ID);
const optyNumber = String(opportunity.OptyNumber || "");
const existingModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
if (!overlay) {
return;
}
if (state !== "loading" && (
!existingModal
|| existingModal.hidden
|| existingModal.dataset.optyNumber !== optyNumber
)) {
return;
}
existingModal?.remove();
const modal = document.createElement("section");
modal.id = ACCOUNT_PLANS_MODAL_ID;
modal.className = "opportunities-extension-account-plans-modal";
modal.dataset.optyNumber = optyNumber;
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-account-plans-title");
modal.setAttribute("aria-busy", String(state === "loading"));
const dialog = document.createElement("div");
dialog.className = "opportunities-extension-account-plans-dialog";
const header = document.createElement("header");
header.className = "opportunities-extension-account-plans-header";
const heading = document.createElement("div");
const title = document.createElement("h2");
title.id = "opportunities-extension-account-plans-title";
title.textContent = "Cloud Consumption";
const subtitle = document.createElement("p");
subtitle.textContent = getNestedValue(opportunity, ["CustomerAccount", "PartyUniqueName"]) || opportunity.Name || opportunity.OptyNumber || "Opportunity";
heading.append(title, subtitle);
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Close cloud consumption");
closeButton.title = "Close";
closeButton.append(createCloseIcon());
closeButton.addEventListener("click", closeAccountPlansModal);
header.append(heading, closeButton);
const content = document.createElement("div");
content.className = "opportunities-extension-account-plans-content";
if (state === "loading") {
content.append(createAccountPlansSkeleton());
} else if (state === "error") {
const error = document.createElement("p");
error.className = "opportunities-extension-account-plans-error";
error.setAttribute("role", "alert");
error.textContent = errorMessage || "Unable to load cloud consumption.";
content.append(error);
} else if (!Array.isArray(plans) || plans.length === 0) {
const empty = document.createElement("p");
empty.className = "opportunities-extension-account-plans-empty";
empty.textContent = "No account plans found for this customer.";
content.append(empty);
} else {
const tabList = document.createElement("div");
tabList.className = "opportunities-extension-account-plans-tabs";
tabList.setAttribute("role", "tablist");
tabList.setAttribute("aria-label", "Account plans");
const tabsNavigation = createAccountPlansTabsNavigation(tabList);
const panels = document.createElement("div");
panels.className = "opportunities-extension-account-plans-panels";
plans.forEach((plan, index) => {
const tabId = `opportunities-extension-account-plan-tab-${index}`;
const panelId = `opportunities-extension-account-plan-panel-${index}`;
const tab = document.createElement("button");
tab.id = tabId;
tab.type = "button";
tab.className = "opportunities-extension-account-plan-tab";
tab.setAttribute("role", "tab");
tab.setAttribute("aria-controls", panelId);
tab.setAttribute("aria-selected", String(index === 0));
tab.tabIndex = index === 0 ? 0 : -1;
const planLabel = [plan.planType, plan.subPlanNum]
.filter((value) => value !== null && value !== undefined && value !== "")
.join(" ") || `Plan ${index + 1}`;
const headerStatus = plan.hdrStatusCode === null || plan.hdrStatusCode === undefined || plan.hdrStatusCode === ""
? "Unknown"
: String(plan.hdrStatusCode);
tab.setAttribute("aria-label", `${planLabel}. Header Status: ${headerStatus}`);
tab.append(createAccountPlanStatusIndicator(headerStatus), document.createTextNode(planLabel));
const panel = document.createElement("section");
panel.id = panelId;
panel.className = "opportunities-extension-account-plan-panel";
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", tabId);
panel.hidden = index !== 0;
panel.append(createAccountPlanDetails(plan), createConsumptionTrendSection(plan));
tab.addEventListener("click", () => {
tabList.querySelectorAll("[role='tab']").forEach((candidate) => {
const isSelected = candidate === tab;
candidate.setAttribute("aria-selected", String(isSelected));
candidate.tabIndex = isSelected ? 0 : -1;
});
panels.querySelectorAll("[role='tabpanel']").forEach((candidate) => {
candidate.hidden = candidate !== panel;
});
const trendSection = panel.querySelector(".opportunities-extension-consumption-trend");
loadConsumptionTrendForPanel(
panel,
plan,
trendSection ? trendSection.dataset.period : DEFAULT_CONSUMPTION_TREND_PERIOD
);
});
tabList.append(tab);
panels.append(panel);
});
content.append(tabsNavigation, panels);
}
dialog.append(header, content);
modal.append(dialog);
overlay.append(modal);
modal.querySelector("[role='tab'], .opportunities-extension-icon-button")?.focus();
if (state === "ready" && Array.isArray(plans) && plans.length > 0) {
const firstPanel = modal.querySelector("[role='tabpanel']");
if (firstPanel) {
loadConsumptionTrendForPanel(firstPanel, plans[0], DEFAULT_CONSUMPTION_TREND_PERIOD);
}
}
}
function createAccountPlansTabsNavigation(tabList) {
const navigation = document.createElement("div");
navigation.className = "opportunities-extension-account-plans-tabs-navigation";
const previousButton = createAccountPlansTabNavigationButton("previous");
const nextButton = createAccountPlansTabNavigationButton("next");
const updateNavigation = () => {
const maximumScroll = Math.max(0, tabList.scrollWidth - tabList.clientWidth);
const tabs = Array.from(tabList.querySelectorAll("[role='tab']"));
const viewportStart = tabList.scrollLeft;
const viewportEnd = viewportStart + tabList.clientWidth;
const hasContentBefore = tabList.scrollLeft > 1
|| tabs.some((tab) => tab.offsetLeft < viewportStart - 1);
const hasContentAfter = tabList.scrollLeft < maximumScroll - 1
|| tabs.some((tab) => (tab.offsetLeft + tab.offsetWidth) > viewportEnd + 1);
previousButton.hidden = !hasContentBefore;
nextButton.hidden = !hasContentAfter;
};
const scrollTabs = (direction) => {
tabList.scrollBy({
left: direction * Math.max(160, Math.floor(tabList.clientWidth * .72)),
behavior: "smooth"
});
};
previousButton.addEventListener("click", () => scrollTabs(-1));
nextButton.addEventListener("click", () => scrollTabs(1));
tabList.addEventListener("scroll", updateNavigation, { passive: true });
const handleResize = () => {
if (!navigation.isConnected) {
window.removeEventListener("resize", handleResize);
return;
}
updateNavigation();
};
window.addEventListener("resize", handleResize, { passive: true });
requestAnimationFrame(() => requestAnimationFrame(updateNavigation));
setTimeout(updateNavigation, 0);
navigation.append(previousButton, tabList, nextButton);
return navigation;
}
function createAccountPlansTabNavigationButton(direction) {
const isPrevious = direction === "previous";
const button = document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-account-plans-tab-navigation-button";
button.setAttribute("aria-label", isPrevious ? "Show previous account plans" : "Show next account plans");
button.title = isPrevious ? "Previous plans" : "Next plans";
const icon = createSvgElement("svg", {
viewBox: "0 0 24 24",
"aria-hidden": "true",
focusable: "false"
});
icon.append(createSvgElement("path", {
d: isPrevious ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7",
fill: "none",
stroke: "currentColor",
"stroke-linecap": "round",
"stroke-linejoin": "round",
"stroke-width": "2"
}));
button.append(icon);
return button;
}
function createAccountPlansSkeleton() {
const skeleton = document.createElement("div");
skeleton.className = "opportunities-extension-account-plans-skeleton";
skeleton.setAttribute("role", "status");
const announcement = document.createElement("span");
announcement.className = "opportunities-extension-visually-hidden";
announcement.textContent = "Loading cloud consumption";
const tabs = document.createElement("div");
tabs.className = "opportunities-extension-account-plans-skeleton-tabs";
const tab = document.createElement("span");
tab.className = "opportunities-extension-account-plans-skeleton-tab";
const status = document.createElement("span");
status.className = "opportunities-extension-account-plans-skeleton-status";
const tabLabel = document.createElement("span");
tabLabel.className = "opportunities-extension-account-plans-skeleton-bar";
tabLabel.style.width = "104px";
tab.append(status, tabLabel);
tabs.append(tab);
const details = document.createElement("div");
details.className = "opportunities-extension-account-plans-skeleton-details";
[72, 88, 64, 78, 58, 68].forEach((width) => {
const row = document.createElement("div");
const label = document.createElement("span");
const value = document.createElement("span");
label.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-account-plans-skeleton-label";
value.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-account-plans-skeleton-value";
value.style.width = `${width}%`;
row.append(label, value);
details.append(row);
});
const trend = document.createElement("div");
trend.className = "opportunities-extension-account-plans-skeleton-trend";
const trendHeader = document.createElement("div");
trendHeader.className = "opportunities-extension-account-plans-skeleton-trend-header";
const periods = document.createElement("div");
periods.className = "opportunities-extension-account-plans-skeleton-periods";
[58, 52, 56, 38].forEach((width, index) => {
const period = document.createElement("span");
period.className = "opportunities-extension-account-plans-skeleton-bar";
period.style.width = `${width}px`;
if (index === 2) period.classList.add("opportunities-extension-account-plans-skeleton-period-active");
periods.append(period);
});
trendHeader.append(periods);
const navigation = document.createElement("div");
navigation.className = "opportunities-extension-account-plans-skeleton-navigation";
[32, 124, 32].forEach((width) => {
const item = document.createElement("span");
item.className = "opportunities-extension-account-plans-skeleton-bar";
item.style.width = `${width}px`;
navigation.append(item);
});
const chart = document.createElement("div");
chart.className = "opportunities-extension-account-plans-skeleton-chart";
const plot = document.createElement("div");
plot.className = "opportunities-extension-account-plans-skeleton-plot";
[34, 42, 38, 48, 44, 58, 64, 55, 76, 68, 86, 62].forEach((height) => {
const bar = document.createElement("span");
bar.className = "opportunities-extension-account-plans-skeleton-bar";
bar.style.height = `${height}%`;
plot.append(bar);
});
const legend = document.createElement("div");
legend.className = "opportunities-extension-account-plans-skeleton-legend";
[112, 68, 132].forEach((width) => {
const item = document.createElement("span");
item.className = "opportunities-extension-account-plans-skeleton-bar";
item.style.width = `${width}px`;
legend.append(item);
});
chart.append(plot, legend);
trend.append(trendHeader, navigation, chart);
const panel = document.createElement("div");
panel.className = "opportunities-extension-account-plan-panel opportunities-extension-account-plans-skeleton-panel";
panel.append(details, trend);
skeleton.append(announcement, tabs, panel);
return skeleton;
}
function createAccountPlanStatusIndicator(statusValue) {
const indicator = document.createElement("span");
const isActive = String(statusValue).toUpperCase() === "ACTIVE";
indicator.className = "opportunities-extension-account-plan-status-indicator";
indicator.dataset.active = String(isActive);
indicator.setAttribute("aria-hidden", "true");
indicator.title = `Header Status: ${statusValue}`;
return indicator;
}
function createAccountPlanDetails(plan) {
const details = document.createElement("dl");
details.className = "opportunities-extension-account-plan-details";
const fields = [
{ label: "Booking Customer", value: plan.bookingCustomer },
{ label: "Sold To Party Name", value: plan.soldToPartyName },
{ label: "Contract ID", value: plan.contractId },
{ label: "Header Status", value: plan.hdrStatusCode },
{ label: "Start Date", value: formatOracleResponseDate(plan.startDate) },
{ label: "End Date", value: formatOracleResponseDate(plan.endDate) }
];
fields.forEach((field) => {
const group = document.createElement("div");
const term = document.createElement("dt");
const description = document.createElement("dd");
term.textContent = field.label;
description.textContent = field.value === null || field.value === undefined || field.value === "" ? "-" : String(field.value);
group.append(term, description);
details.append(group);
});
return details;
}
function closeAccountPlansModal() {
const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
if (modal) {
modal.hidden = true;
}
}
return { openAccountPlansModal, createAccountPlansTabsNavigation, createAccountPlansTabNavigationButton, createAccountPlansSkeleton, createAccountPlanStatusIndicator, createAccountPlanDetails, closeAccountPlansModal };
}

View File

@@ -0,0 +1,171 @@
function createConsumptionTrendController({
messageTypes,
CONSUMPTION_TREND_PERIODS,
DEFAULT_CONSUMPTION_TREND_PERIOD,
appendBackgroundRequestLog,
consumptionTrendState,
renderConsumptionTrendChart,
requestConsumerTokenServiceOnce,
sendRuntimeMessage,
environment
}) {
const { document } = environment;
function createConsumptionTrendSection(plan) {
const section = document.createElement("section");
section.className = "opportunities-extension-consumption-trend";
section.dataset.subPlanNum = String(plan.subPlanNum || "");
section.dataset.period = DEFAULT_CONSUMPTION_TREND_PERIOD;
const header = document.createElement("header");
header.className = "opportunities-extension-consumption-trend-header";
const periodControls = document.createElement("div");
periodControls.className = "opportunities-extension-consumption-periods";
periodControls.setAttribute("role", "group");
periodControls.setAttribute("aria-label", "Consumption period");
CONSUMPTION_TREND_PERIODS.forEach((periodOption) => {
const button = document.createElement("button");
button.type = "button";
button.dataset.period = periodOption.value;
button.setAttribute("aria-pressed", String(periodOption.value === DEFAULT_CONSUMPTION_TREND_PERIOD));
button.textContent = periodOption.label;
button.addEventListener("click", () => {
section.dataset.period = periodOption.value;
consumptionTrendState.consumptionTrendWindowOffsets.set(
createConsumptionTrendKey(section.dataset.subPlanNum, periodOption.value),
0
);
loadConsumptionTrendForPanel(section.closest("[role='tabpanel']"), plan, periodOption.value);
});
periodControls.append(button);
});
const chart = document.createElement("div");
chart.className = "opportunities-extension-consumption-chart";
chart.setAttribute("aria-live", "polite");
header.append(periodControls);
section.append(header, chart);
return section;
}
function loadConsumptionTrendForPanel(panel, plan, period) {
if (!panel) {
return;
}
const section = panel.querySelector(".opportunities-extension-consumption-trend");
if (!section) {
return;
}
const subPlanNum = String(plan.subPlanNum || "").trim();
const selectedPeriod = CONSUMPTION_TREND_PERIODS.some((item) => item.value === period)
? period
: DEFAULT_CONSUMPTION_TREND_PERIOD;
const key = createConsumptionTrendKey(subPlanNum, selectedPeriod);
const chart = section.querySelector(".opportunities-extension-consumption-chart");
section.dataset.period = selectedPeriod;
chart.dataset.requestKey = key;
updateConsumptionPeriodButtons(section, selectedPeriod);
if (!subPlanNum) {
renderConsumptionTrendError(chart, "Sub plan number is unavailable.");
return;
}
const cached = consumptionTrendState.consumptionTrendCache.get(key);
renderConsumptionTrendSkeleton(chart);
if (cached && cached.status === "loading") {
cached.promise
.then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key))
.catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key));
return;
}
const request = requestConsumerTokenServiceOnce()
.then((authorizations) => sendRuntimeMessage({
type: messageTypes.requestConsumptionTrend,
subPlanNum,
period: selectedPeriod,
authorization: authorizations && authorizations.accountPlans
}))
.then((response) => {
appendBackgroundRequestLog(response && response.debugRequests);
if (!response || !response.ok) {
throw new Error(response && response.error ? response.error : "Unable to load consumption trend.");
}
return Array.isArray(response.items) ? response.items : [];
})
.finally(() => {
if (consumptionTrendState.consumptionTrendCache.get(key)?.promise === request) {
consumptionTrendState.consumptionTrendCache.delete(key);
}
});
consumptionTrendState.consumptionTrendCache.set(key, { status: "loading", promise: request });
request
.then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key))
.catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key));
}
function createConsumptionTrendKey(subPlanNum, period) {
return `${subPlanNum}:${period}`;
}
function updateConsumptionPeriodButtons(section, period) {
section.querySelectorAll(".opportunities-extension-consumption-periods button").forEach((button) => {
button.setAttribute("aria-pressed", String(button.dataset.period === period));
});
}
function renderConsumptionTrendIfCurrent(section, chart, items, period, subPlanNum, key) {
if (chart.dataset.requestKey === key) {
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
}
}
function renderConsumptionTrendErrorIfCurrent(chart, errorMessage, key) {
if (chart.dataset.requestKey === key) {
renderConsumptionTrendError(chart, errorMessage || "Unable to load consumption trend.");
}
}
function renderConsumptionTrendSkeleton(chart) {
chart.textContent = "";
chart.setAttribute("aria-busy", "true");
const skeleton = document.createElement("div");
skeleton.className = "opportunities-extension-consumption-chart-skeleton";
const announcement = document.createElement("span");
announcement.className = "opportunities-extension-visually-hidden";
announcement.textContent = "Loading consumption trend";
const plot = document.createElement("div");
plot.className = "opportunities-extension-consumption-chart-skeleton-plot";
for (let index = 0; index < 13; index += 1) {
const bar = document.createElement("span");
bar.style.height = `${28 + ((index * 17) % 58)}%`;
plot.append(bar);
}
skeleton.append(announcement, plot);
chart.append(skeleton);
}
function renderConsumptionTrendError(chart, errorMessage) {
chart.textContent = "";
chart.setAttribute("aria-busy", "false");
const error = document.createElement("p");
error.className = "opportunities-extension-consumption-chart-error";
error.setAttribute("role", "alert");
error.textContent = errorMessage;
chart.append(error);
}
return { createConsumptionTrendSection, loadConsumptionTrendForPanel, createConsumptionTrendKey, updateConsumptionPeriodButtons, renderConsumptionTrendIfCurrent, renderConsumptionTrendErrorIfCurrent, renderConsumptionTrendSkeleton, renderConsumptionTrendError };
}

View File

@@ -0,0 +1,7 @@
function createConsumptionTrendState() {
return {
consumptionTrendCache: new Map(),
consumptionTrendWindowOffsets: new Map(),
consumptionTrendSeriesFilters: new Map()
};
}

View File

@@ -0,0 +1,198 @@
function createDebugContentLog({
DEBUG_BODY_LIMIT,
debugState,
renderDebugPanel
}) {
function logRequestStart(details) {
const entry = {
id: debugState.requestLog.length + 1,
name: details.name,
method: details.method,
url: details.url,
headers: details.headers,
status: "pending",
startedAt: new Date(),
completedAt: null,
durationMs: null,
httpStatus: null,
statusText: "",
requestHeaders: details.headers,
requestBody: details.body || "",
metadata: details.metadata || {},
responseHeaders: {},
responseBody: "",
responseBodyTruncated: false,
error: ""
};
debugState.requestLog.push(entry);
renderDebugPanel();
return entry;
}
function logRequestSuccess(entry, response, responseBody) {
entry.status = response.ok ? "success" : "failed";
entry.completedAt = new Date();
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
entry.httpStatus = response.status;
entry.statusText = response.statusText || "";
entry.responseHeaders = headersToObject(response.headers);
entry.responseBody = responseBody.value;
entry.responseBodyTruncated = responseBody.truncated;
renderDebugPanel();
}
function logRequestFailure(entry, errorMessage) {
entry.status = "failed";
entry.completedAt = new Date();
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
entry.error = errorMessage;
renderDebugPanel();
}
function appendBackgroundRequestLog(entries) {
if (!Array.isArray(entries) || entries.length === 0) {
return;
}
entries.forEach((entry) => {
debugState.requestLog.push({
id: debugState.requestLog.length + 1,
name: entry.name || "backgroundRequest",
method: entry.method || "GET",
url: entry.url || "",
headers: entry.requestHeaders || {},
status: entry.status || "failed",
startedAt: new Date(entry.startedAt),
completedAt: entry.completedAt ? new Date(entry.completedAt) : null,
durationMs: entry.durationMs,
httpStatus: entry.httpStatus,
statusText: entry.statusText || "",
requestHeaders: entry.requestHeaders || {},
requestBody: entry.requestBody || "",
metadata: entry.metadata || { executionContext: "background" },
responseHeaders: entry.responseHeaders || {},
responseBody: entry.responseBody || "",
responseBodyTruncated: Boolean(entry.responseBodyTruncated),
error: entry.error || ""
});
});
renderDebugPanel();
}
async function readResponseBodyForDebug(response) {
try {
const text = await response.clone().text();
return createDebugBody(text);
} catch (error) {
return {
value: `Unable to read response body: ${error.message || "unknown error"}`,
truncated: false
};
}
}
function createDebugBody(text) {
if (text.length > DEBUG_BODY_LIMIT) {
return {
value: `${text.slice(0, DEBUG_BODY_LIMIT)}\n... truncated ${text.length - DEBUG_BODY_LIMIT} characters`,
truncated: true
};
}
return {
value: text || "(empty response body)",
truncated: false
};
}
function headersToObject(headers) {
const headerMap = {};
headers.forEach((value, key) => {
headerMap[key] = value;
});
return headerMap;
}
function formatHeaderBlock(headers) {
const entries = Object.entries(headers);
if (entries.length === 0) {
return "(none)";
}
return entries
.map(([key, value]) => `${key}: ${debugState.debugSecretsVisible || !isSensitiveDebugKey(key)
? value
: formatHiddenDebugValue(value)}`)
.join("\n");
}
function formatDebugBody(body) {
const text = body || "(none)";
if (debugState.debugSecretsVisible) {
return text;
}
try {
return JSON.stringify(maskSensitiveDebugValue(JSON.parse(text)), null, 2);
} catch (error) {
return text;
}
}
function maskSensitiveDebugValue(value, parentKey = "") {
if (Array.isArray(value)) {
return value.map((item) => maskSensitiveDebugValue(item, parentKey));
}
if (!value || typeof value !== "object") {
return value;
}
return Object.fromEntries(Object.entries(value).map(([key, child]) => {
const isSensitive = isSensitiveDebugKey(key)
|| (key === "value" && /uiaasHeader|uiassHeader/i.test(parentKey));
return [key, isSensitive
? formatHiddenDebugValue(child)
: maskSensitiveDebugValue(child, key)];
}));
}
function isSensitiveDebugKey(key) {
return /authorization|cookie|password|secret|(^|[_-])token($|[_-])|token$/i.test(key);
}
function formatHiddenDebugValue(value) {
if (value === null || value === undefined || value === "") {
return "(missing)";
}
return `(present, ${String(value).length} characters)`;
}
function formatMetadataBlock(metadata) {
const entries = Object.entries(metadata);
if (entries.length === 0) {
return "(none)";
}
return entries
.map(([key, value]) => `${key}: ${value || "(none)"}`)
.join("\n");
}
function maskToken(token) {
if (token.length <= 10) {
return "(present)";
}
return `${token.slice(0, 4)}...${token.slice(-4)}`;
}
return { logRequestStart, logRequestSuccess, logRequestFailure, appendBackgroundRequestLog, readResponseBodyForDebug, createDebugBody, headersToObject, formatHeaderBlock, formatDebugBody, maskSensitiveDebugValue, isSensitiveDebugKey, formatHiddenDebugValue, formatMetadataBlock, maskToken };
}

View File

@@ -0,0 +1,174 @@
function createDebugContentView({
DEBUG_PANEL_ID,
MODAL_ID,
debugState,
formatDebugBody,
formatHeaderBlock,
formatMetadataBlock,
environment
}) {
const { document } = environment;
function toggleDebugPanel() {
const modal = document.getElementById(MODAL_ID);
if (!modal || modal.hidden) {
return;
}
const panel = document.getElementById(DEBUG_PANEL_ID);
if (!panel) {
return;
}
panel.hidden = !panel.hidden;
renderDebugPanel();
}
function renderDebugPanel() {
const panel = document.getElementById(DEBUG_PANEL_ID);
if (!panel || panel.hidden) {
return;
}
panel.textContent = "";
const panelHeader = document.createElement("header");
panelHeader.className = "opportunities-extension-debug-panel-header";
const heading = document.createElement("div");
const title = document.createElement("h2");
title.textContent = "Debug Requests";
const summary = document.createElement("p");
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`;
heading.append(title, summary);
const visibilityButton = document.createElement("button");
visibilityButton.type = "button";
visibilityButton.className = "opportunities-extension-icon-button opportunities-extension-debug-visibility-button";
visibilityButton.setAttribute("aria-pressed", String(debugState.debugSecretsVisible));
visibilityButton.setAttribute("aria-label", debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values");
visibilityButton.title = debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values";
visibilityButton.append(createDebugVisibilityIcon(debugState.debugSecretsVisible));
visibilityButton.addEventListener("click", () => {
debugState.debugSecretsVisible = !debugState.debugSecretsVisible;
renderDebugPanel();
});
panelHeader.append(heading, visibilityButton);
const list = document.createElement("div");
list.className = "opportunities-extension-debug-list";
if (debugState.requestLog.length === 0) {
const empty = document.createElement("div");
empty.className = "opportunities-extension-debug-empty";
empty.textContent = "No requests recorded yet.";
list.append(empty);
}
debugState.requestLog.forEach((entry) => {
const item = document.createElement("details");
item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`;
const heading = document.createElement("summary");
heading.className = "opportunities-extension-debug-heading";
const name = document.createElement("strong");
name.textContent = `${entry.id}. ${entry.name}`;
const httpStatus = document.createElement("span");
httpStatus.className = "opportunities-extension-debug-http-status";
httpStatus.textContent = entry.httpStatus ? String(entry.httpStatus) : "pending";
const status = document.createElement("span");
status.className = "opportunities-extension-debug-status";
status.textContent = entry.status;
const summaryMeta = document.createElement("span");
summaryMeta.className = "opportunities-extension-debug-summary-meta";
summaryMeta.append(httpStatus, status);
heading.append(name, summaryMeta);
const lines = [
`${entry.method} ${entry.url}`,
`Started: ${formatDebugTime(entry.startedAt)}`,
entry.completedAt ? `Completed: ${formatDebugTime(entry.completedAt)} (${entry.durationMs}ms)` : "Completed: pending",
entry.httpStatus ? `HTTP: ${entry.httpStatus} ${entry.statusText}`.trim() : "",
entry.error ? `Error: ${entry.error}` : "",
`Response body truncated: ${entry.responseBodyTruncated ? "yes" : "no"}`
].filter(Boolean);
const details = document.createElement("div");
details.className = "opportunities-extension-debug-details";
appendDebugBlock(details, "Request", lines.join("\n"));
appendDebugBlock(details, "Request Metadata", formatMetadataBlock(entry.metadata));
appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders));
appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody));
appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders));
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody));
item.append(heading, details);
list.append(item);
});
panel.append(panelHeader, list);
}
function createDebugVisibilityIcon(secretsVisible) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const eye = document.createElementNS("http://www.w3.org/2000/svg", "path");
const pupil = document.createElementNS("http://www.w3.org/2000/svg", "circle");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
eye.setAttribute("d", "M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12Z");
pupil.setAttribute("cx", "12");
pupil.setAttribute("cy", "12");
pupil.setAttribute("r", "2.75");
[eye, pupil].forEach((shape) => {
shape.setAttribute("fill", "none");
shape.setAttribute("stroke", "currentColor");
shape.setAttribute("stroke-linecap", "round");
shape.setAttribute("stroke-linejoin", "round");
shape.setAttribute("stroke-width", "1.7");
});
svg.append(eye, pupil);
if (secretsVisible) {
const slash = document.createElementNS("http://www.w3.org/2000/svg", "path");
slash.setAttribute("d", "M4 4l16 16");
slash.setAttribute("fill", "none");
slash.setAttribute("stroke", "currentColor");
slash.setAttribute("stroke-linecap", "round");
slash.setAttribute("stroke-width", "1.9");
svg.append(slash);
}
return svg;
}
function appendDebugBlock(container, label, value) {
const block = document.createElement("section");
const heading = document.createElement("h3");
const content = document.createElement("pre");
heading.textContent = label;
content.textContent = value;
block.append(heading, content);
container.append(block);
}
function formatDebugTime(date) {
return date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}
return { toggleDebugPanel, renderDebugPanel, createDebugVisibilityIcon, appendDebugBlock, formatDebugTime };
}

View File

@@ -0,0 +1,6 @@
function createDebugState() {
return {
requestLog: [],
debugSecretsVisible: false
};
}

View File

@@ -0,0 +1,15 @@
function createOpportunitiesColumns({ formatOracleResponseDate, formatRevenue, formatWinProbability, getNestedValue, getOwnerName }) {
return [
{ key: "name", label: "Name", value: (item) => item.Name, display: (item) => item.Name },
{ key: "optyNumber", label: "Opty Number", value: (item) => item.OptyNumber, display: (item) => item.OptyNumber },
{ key: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]) },
{ key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName },
{ key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue },
{ key: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) },
{ key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode },
{ key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
{ key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c },
{ key: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) },
{ key: "actions", label: "Actions", value: () => "", display: () => "", sortable: false }
];
}

View File

@@ -0,0 +1,146 @@
function createOpportunitiesController({
STAGE_OPTIONS,
STATUS_OPTIONS,
authSessionState,
consumptionPlansState,
consumptionTrendState,
getFiscalPeriodRange,
opportunitiesDashboardState,
opportunitiesFilterState,
opportunitiesQueryState,
opportunitiesTableState,
preferencesState,
productsContentState,
renderProductsProgress,
requestOpportunitiesPage,
requestOpptyProductsInBackground,
requestTokenRelayOnce,
setOpportunitiesTableState
}) {
async function requestOpportunitiesForPeriod(period) {
const dateRange = getFiscalPeriodRange(period, new Date());
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
if (preferencesState.selectedOpportunityTypeValues.size === 0) {
setOpportunitiesTableState({
items: [],
status: "empty",
message: "Select at least one Opportunity Type View in Preferences."
});
return;
}
if (!dateRange) {
setOpportunitiesTableState({
items: [],
status: "empty",
message: "No opportunities to display for this period."
});
return;
}
try {
const token = authSessionState.accessToken || await requestTokenRelayOnce();
if (requestVersion !== opportunitiesQueryState.periodRequestVersion) {
return;
}
await requestOpportunities(token, period, dateRange, requestVersion);
} catch (error) {
// Authentication and request errors are recorded by their respective request handlers.
}
}
async function requestOpportunities(token, period, dateRange, requestVersion) {
if (isCurrentPeriodRequest(requestVersion)) {
productsContentState.currentProductsRequestId = "";
productsContentState.opptyProductsByNumber = new Map();
productsContentState.expandedProductOptyNumbers = new Set();
productsContentState.productsProgress = {
state: "idle",
completed: 0,
total: 0,
failed: 0,
cacheHits: 0
};
productsContentState.productsLastCachedAt = 0;
consumptionPlansState.accountPlansRequestState = new Map();
consumptionTrendState.consumptionTrendCache = new Map();
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
renderProductsProgress();
setOpportunitiesTableState({
items: [],
status: "loading",
message: "Loading opportunities..."
});
}
try {
const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1);
if (allItems && isCurrentPeriodRequest(requestVersion)) {
setOpportunitiesTableState({
items: allItems,
status: "ready",
message: ""
});
requestOpptyProductsInBackground(allItems, token);
}
} catch (error) {
if (isCurrentPeriodRequest(requestVersion)) {
setOpportunitiesTableState({
items: [],
status: "error",
message: "Unable to load opportunities."
});
}
throw error;
}
}
function resetOpportunitiesTable() {
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);
opportunitiesFilterState.selectedCustomerKeys = new Set();
opportunitiesFilterState.customerSearch = "";
opportunitiesFilterState.selectedOwnerKeys = new Set();
opportunitiesFilterState.ownerSearch = "";
opportunitiesFilterState.tableSearch = "";
opportunitiesFilterState.hasSavedCustomerFilter = false;
opportunitiesFilterState.hasSavedOwnerFilter = false;
opportunitiesFilterState.customerFilterInitialized = false;
opportunitiesFilterState.ownerFilterInitialized = false;
productsContentState.currentProductsRequestId = "";
productsContentState.opptyProductsByNumber = new Map();
productsContentState.expandedProductOptyNumbers = new Set();
productsContentState.productsProgress = {
state: "idle",
completed: 0,
total: 0,
failed: 0,
cacheHits: 0
};
productsContentState.productsLastCachedAt = 0;
consumptionPlansState.accountPlansRequestState = new Map();
consumptionTrendState.consumptionTrendCache = new Map();
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
opportunitiesDashboardState.stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]);
opportunitiesTableState.opportunitiesTableState = {
items: [],
status: "idle",
message: "",
sortKey: "",
sortDirection: "ascending"
};
}
function isCurrentPeriodRequest(requestVersion) {
return requestVersion === opportunitiesQueryState.periodRequestVersion;
}
return { requestOpportunitiesForPeriod, requestOpportunities, resetOpportunitiesTable, isCurrentPeriodRequest };
}

View File

@@ -0,0 +1,5 @@
function createOpportunitiesDashboardState({ STAGE_OPTIONS } = {}) {
return {
stageDashboardAmounts: new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])])
};
}

View File

@@ -0,0 +1,119 @@
function createOpportunitiesDashboard({
STAGE_DASHBOARD_ID,
STAGE_OPTIONS,
getNestedValue,
opportunitiesDashboardState,
environment
}) {
const { document, requestAnimationFrame, performance } = environment;
function renderStageDashboard(items) {
const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
if (!dashboard) {
return;
}
dashboard.textContent = "";
const totalCard = document.createElement("section");
const totalLabel = document.createElement("span");
const totalValue = document.createElement("strong");
totalCard.className = "opportunities-extension-stage-dashboard-card opportunities-extension-total-dashboard-card";
totalCard.setAttribute("data-stage", "TOTAL");
totalLabel.textContent = "Total Opportunities";
totalValue.className = "opportunities-extension-stage-dashboard-value";
totalValue.setAttribute("aria-label", "Total opportunities");
animateStageDashboardValue(
totalValue,
"TOTAL",
items.length,
(amount) => Math.round(amount).toLocaleString()
);
totalCard.append(totalLabel, totalValue);
dashboard.append(totalCard);
STAGE_OPTIONS.forEach((stage) => {
const stageItems = items.filter((item) => item.ForecastGroup_c === stage);
const amount = stageItems.reduce((total, item) => {
const value = Number(getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]));
return Number.isFinite(value) ? total + value : total;
}, 0);
const card = document.createElement("section");
const label = document.createElement("span");
const value = document.createElement("strong");
card.className = "opportunities-extension-stage-dashboard-card";
card.setAttribute("data-stage", stage);
label.textContent = stage;
value.className = "opportunities-extension-stage-dashboard-value";
value.setAttribute("aria-label", `${stage} total amount`);
animateStageDashboardValue(
value,
stage,
amount,
createStageAmountFormatter(stageItems)
);
card.append(label, value);
dashboard.append(card);
});
}
function createStageAmountFormatter(items) {
const currencies = new Set(items
.map((item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]))
.filter(Boolean));
if (currencies.size === 1) {
const [currency] = currencies;
try {
const formatter = new Intl.NumberFormat(undefined, {
style: "currency",
currency,
maximumFractionDigits: 0
});
return (amount) => formatter.format(amount);
} catch (error) {
// Fall through to a neutral numeric total when the currency code is invalid.
}
}
return (amount) => amount.toLocaleString(undefined, {
maximumFractionDigits: 0
});
}
function animateStageDashboardValue(element, stage, target, format) {
const current = opportunitiesDashboardState.stageDashboardAmounts.get(stage) || 0;
opportunitiesDashboardState.stageDashboardAmounts.set(stage, target);
if (current === target) {
element.textContent = format(target);
return;
}
const startTime = performance.now();
const duration = 480;
function tick(now) {
const progress = Math.min((now - startTime) / duration, 1);
const easedProgress = 1 - Math.pow(1 - progress, 3);
const value = current + ((target - current) * easedProgress);
element.textContent = format(value);
if (progress < 1) {
requestAnimationFrame(tick);
} else {
element.textContent = format(target);
}
}
requestAnimationFrame(tick);
}
return { renderStageDashboard, createStageAmountFormatter, animateStageDashboardValue };
}

View File

@@ -0,0 +1,15 @@
function createOpportunitiesFilterState({ STAGE_OPTIONS, STATUS_OPTIONS } = {}) {
return {
selectedStages: new Set(STAGE_OPTIONS),
selectedStatuses: new Set(STATUS_OPTIONS),
selectedCustomerKeys: new Set(),
customerSearch: "",
selectedOwnerKeys: new Set(),
ownerSearch: "",
tableSearch: "",
hasSavedCustomerFilter: false,
hasSavedOwnerFilter: false,
customerFilterInitialized: false,
ownerFilterInitialized: false
};
}

View File

@@ -0,0 +1,402 @@
function createOpportunitiesFilters({
CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID,
CUSTOMER_FILTER_PANEL_ID,
CUSTOMER_FILTER_SEARCH_ID,
CUSTOMER_FILTER_SELECT_ALL_ID,
OPPORTUNITIES_COLUMNS,
OWNER_FILTER_BUTTON_ID,
OWNER_FILTER_LIST_ID,
OWNER_FILTER_PANEL_ID,
OWNER_FILTER_SEARCH_ID,
OWNER_FILTER_SELECT_ALL_ID,
STAGE_FILTERS_ID,
STAGE_OPTIONS,
STATUS_FILTERS_ID,
STATUS_OPTIONS,
getCustomerFilterSummary,
getCustomerKey,
getCustomerOptions,
getOwnerKey,
getOwnerOptions,
opportunitiesFilterState,
opportunitiesTableState,
renderOpportunitiesTable,
saveFilterPreferences,
stringifySearchValue,
environment
}) {
const { document } = environment;
function resetCustomerAndOwnerFiltersForPeriod() {
opportunitiesFilterState.selectedCustomerKeys = new Set();
opportunitiesFilterState.selectedOwnerKeys = new Set();
opportunitiesFilterState.customerSearch = "";
opportunitiesFilterState.ownerSearch = "";
opportunitiesFilterState.hasSavedCustomerFilter = false;
opportunitiesFilterState.hasSavedOwnerFilter = false;
opportunitiesFilterState.customerFilterInitialized = false;
opportunitiesFilterState.ownerFilterInitialized = false;
}
function renderStageFilterButtons() {
const controls = document.getElementById(STAGE_FILTERS_ID);
if (!controls) {
return;
}
controls.textContent = "";
STAGE_OPTIONS.forEach((stage) => {
const button = document.createElement("button");
const isPressed = opportunitiesFilterState.selectedStages.has(stage);
button.type = "button";
button.className = "opportunities-extension-stage-filter-button";
button.setAttribute("data-stage", stage);
button.setAttribute("aria-pressed", isPressed ? "true" : "false");
button.textContent = stage;
button.addEventListener("click", () => toggleStageFilter(stage));
controls.append(button);
});
}
function toggleStageFilter(stage) {
if (opportunitiesFilterState.selectedStages.has(stage)) {
opportunitiesFilterState.selectedStages.delete(stage);
} else {
opportunitiesFilterState.selectedStages.add(stage);
}
saveFilterPreferences();
renderStageFilterButtons();
renderOpportunitiesTable();
}
function renderStatusFilterButtons() {
const controls = document.getElementById(STATUS_FILTERS_ID);
if (!controls) {
return;
}
controls.textContent = "";
STATUS_OPTIONS.forEach((status) => {
const button = document.createElement("button");
const isPressed = opportunitiesFilterState.selectedStatuses.has(status);
button.type = "button";
button.className = "opportunities-extension-status-filter-button";
button.setAttribute("data-status", status);
button.setAttribute("aria-pressed", isPressed ? "true" : "false");
button.textContent = status;
button.addEventListener("click", () => toggleStatusFilter(status));
controls.append(button);
});
}
function toggleStatusFilter(status) {
if (opportunitiesFilterState.selectedStatuses.has(status)) {
opportunitiesFilterState.selectedStatuses.delete(status);
} else {
opportunitiesFilterState.selectedStatuses.add(status);
}
saveFilterPreferences();
renderStatusFilterButtons();
renderOpportunitiesTable();
}
function toggleCustomerFilterPanel() {
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
if (!panel || !button || button.disabled) {
return;
}
panel.hidden = !panel.hidden;
button.setAttribute("aria-expanded", panel.hidden ? "false" : "true");
if (!panel.hidden) {
const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID);
searchInput.focus();
}
}
function closeCustomerFilterPanel(restoreFocus = true) {
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
if (!panel || panel.hidden) {
return false;
}
panel.hidden = true;
button.setAttribute("aria-expanded", "false");
if (restoreFocus) {
button.focus();
}
return true;
}
function toggleOwnerFilterPanel() {
const panel = document.getElementById(OWNER_FILTER_PANEL_ID);
const button = document.getElementById(OWNER_FILTER_BUTTON_ID);
if (!panel || !button || button.disabled) {
return;
}
panel.hidden = !panel.hidden;
button.setAttribute("aria-expanded", panel.hidden ? "false" : "true");
if (!panel.hidden) {
const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID);
searchInput.focus();
}
}
function closeOwnerFilterPanel(restoreFocus = true) {
const panel = document.getElementById(OWNER_FILTER_PANEL_ID);
const button = document.getElementById(OWNER_FILTER_BUTTON_ID);
if (!panel || panel.hidden) {
return false;
}
panel.hidden = true;
button.setAttribute("aria-expanded", "false");
if (restoreFocus) {
button.focus();
}
return true;
}
function resetCustomerFilter(items) {
const customerOptions = getCustomerOptions(items);
if (!opportunitiesFilterState.customerFilterInitialized) {
opportunitiesFilterState.selectedCustomerKeys = new Set(customerOptions.map((customer) => customer.key));
opportunitiesFilterState.customerFilterInitialized = true;
}
}
function resetOwnerFilter(items) {
const ownerOptions = getOwnerOptions(items);
if (!opportunitiesFilterState.ownerFilterInitialized) {
opportunitiesFilterState.selectedOwnerKeys = new Set(ownerOptions.map((owner) => owner.key));
opportunitiesFilterState.ownerFilterInitialized = true;
}
}
function renderCustomerFilter() {
const trigger = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID);
const selectAllCheckbox = document.getElementById(CUSTOMER_FILTER_SELECT_ALL_ID);
const customerOptions = getCustomerOptions();
if (!trigger || !searchInput || !selectAllCheckbox) {
return;
}
const selectedCount = customerOptions.filter((customer) => opportunitiesFilterState.selectedCustomerKeys.has(customer.key)).length;
trigger.disabled = customerOptions.length === 0;
trigger.textContent = getCustomerFilterSummary(customerOptions.length, selectedCount);
searchInput.value = opportunitiesFilterState.customerSearch;
selectAllCheckbox.disabled = customerOptions.length === 0;
selectAllCheckbox.checked = customerOptions.length > 0 && selectedCount === customerOptions.length;
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < customerOptions.length;
renderCustomerFilterList();
}
function renderCustomerFilterList() {
const list = document.getElementById(CUSTOMER_FILTER_LIST_ID);
if (!list) {
return;
}
const normalizedSearch = opportunitiesFilterState.customerSearch.trim().toLocaleLowerCase();
const customerOptions = getCustomerOptions().filter((customer) => {
return customer.label.toLocaleLowerCase().includes(normalizedSearch);
});
list.textContent = "";
if (customerOptions.length === 0) {
const empty = document.createElement("p");
empty.className = "opportunities-extension-customer-filter-empty";
empty.textContent = opportunitiesFilterState.customerSearch ? "No matching customers." : "No customers available.";
list.append(empty);
return;
}
customerOptions.forEach((customer) => {
const option = document.createElement("label");
option.className = "opportunities-extension-customer-filter-option";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key);
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
opportunitiesFilterState.selectedCustomerKeys.add(customer.key);
} else {
opportunitiesFilterState.selectedCustomerKeys.delete(customer.key);
}
opportunitiesFilterState.hasSavedCustomerFilter = true;
saveFilterPreferences();
renderCustomerFilter();
renderOpportunitiesTable();
});
const label = document.createElement("span");
label.textContent = customer.label;
option.append(checkbox, label);
list.append(option);
});
}
function renderOwnerFilter() {
const trigger = document.getElementById(OWNER_FILTER_BUTTON_ID);
const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID);
const selectAllCheckbox = document.getElementById(OWNER_FILTER_SELECT_ALL_ID);
const ownerOptions = getOwnerOptions();
if (!trigger || !searchInput || !selectAllCheckbox) {
return;
}
const selectedCount = ownerOptions.filter((owner) => opportunitiesFilterState.selectedOwnerKeys.has(owner.key)).length;
trigger.disabled = ownerOptions.length === 0;
trigger.textContent = getCustomerFilterSummary(ownerOptions.length, selectedCount)
.replace("customer", "owner");
searchInput.value = opportunitiesFilterState.ownerSearch;
selectAllCheckbox.disabled = ownerOptions.length === 0;
selectAllCheckbox.checked = ownerOptions.length > 0 && selectedCount === ownerOptions.length;
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < ownerOptions.length;
renderOwnerFilterList();
}
function renderOwnerFilterList() {
const list = document.getElementById(OWNER_FILTER_LIST_ID);
if (!list) {
return;
}
const normalizedSearch = opportunitiesFilterState.ownerSearch.trim().toLocaleLowerCase();
const ownerOptions = getOwnerOptions().filter((owner) => {
return owner.label.toLocaleLowerCase().includes(normalizedSearch);
});
list.textContent = "";
if (ownerOptions.length === 0) {
const empty = document.createElement("p");
empty.className = "opportunities-extension-customer-filter-empty";
empty.textContent = opportunitiesFilterState.ownerSearch ? "No matching owners." : "No owners available.";
list.append(empty);
return;
}
ownerOptions.forEach((owner) => {
const option = document.createElement("label");
option.className = "opportunities-extension-customer-filter-option";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key);
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
opportunitiesFilterState.selectedOwnerKeys.add(owner.key);
} else {
opportunitiesFilterState.selectedOwnerKeys.delete(owner.key);
}
opportunitiesFilterState.hasSavedOwnerFilter = true;
saveFilterPreferences();
renderOwnerFilter();
renderOpportunitiesTable();
});
const label = document.createElement("span");
label.textContent = owner.label;
option.append(checkbox, label);
list.append(option);
});
}
function getSortedOpportunities() {
const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase();
const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => {
return opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c)
&& matchesSelectedStatus(item.StatusCode)
&& opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item))
&& opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item))
&& matchesTableSearch(item, normalizedSearch);
});
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);
if (!column) {
return items;
}
const direction = opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending" ? 1 : -1;
return items.sort((first, second) => {
const firstValue = column.value(first);
const secondValue = column.value(second);
const firstIsEmpty = firstValue === null || firstValue === undefined || firstValue === "";
const secondIsEmpty = secondValue === null || secondValue === undefined || secondValue === "";
if (firstIsEmpty || secondIsEmpty) {
if (firstIsEmpty && secondIsEmpty) {
return 0;
}
return firstIsEmpty ? 1 : -1;
}
const firstNumber = Number(firstValue);
const secondNumber = Number(secondValue);
if (Number.isFinite(firstNumber) && Number.isFinite(secondNumber)) {
return (firstNumber - secondNumber) * direction;
}
return String(firstValue).localeCompare(String(secondValue), undefined, {
numeric: true,
sensitivity: "base"
}) * direction;
});
}
function matchesTableSearch(item, normalizedSearch) {
if (!normalizedSearch) {
return true;
}
return OPPORTUNITIES_COLUMNS.some((column) => {
const rawValue = stringifySearchValue(column.value(item));
const displayValue = stringifySearchValue(column.display(item));
return `${rawValue} ${displayValue}`.toLocaleLowerCase().includes(normalizedSearch);
});
}
function matchesSelectedStatus(statusCode) {
if (statusCode === "WON_PENDING") {
return opportunitiesFilterState.selectedStatuses.has("WON");
}
return opportunitiesFilterState.selectedStatuses.has(statusCode);
}
return { resetCustomerAndOwnerFiltersForPeriod, renderStageFilterButtons, toggleStageFilter, renderStatusFilterButtons, toggleStatusFilter, toggleCustomerFilterPanel, closeCustomerFilterPanel, toggleOwnerFilterPanel, closeOwnerFilterPanel, resetCustomerFilter, resetOwnerFilter, renderCustomerFilter, renderCustomerFilterList, renderOwnerFilter, renderOwnerFilterList, getSortedOpportunities, matchesTableSearch, matchesSelectedStatus };
}

View File

@@ -0,0 +1,55 @@
function createOpportunitiesFiscalPeriods() {
function getFiscalPeriodRange(period, currentDate) {
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth();
const fiscalStartYear = currentMonth >= 5 ? currentYear : currentYear - 1;
const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3);
if (period === "Current Fiscal Year") {
const isBeforeFiscalYearStart = currentMonth < 5;
const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear;
const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1;
return createDateRange(startYear, 5, endYear, 4);
}
let quarterOffset;
let numberOfQuarters = 1;
if (period === "Current Quarter") {
quarterOffset = currentQuarterIndex;
} else if (period === "Next Quarter") {
quarterOffset = currentQuarterIndex + 1;
} else if (period === "Previous Quarter") {
quarterOffset = currentQuarterIndex - 1;
} else if (period === "4 Rolling Quarters (CQ + 3)") {
quarterOffset = currentQuarterIndex;
numberOfQuarters = 4;
} else {
return null;
}
const start = new Date(fiscalStartYear, 5 + (quarterOffset * 3), 1);
const end = new Date(start.getFullYear(), start.getMonth() + (numberOfQuarters * 3), 0);
return {
startCloseDate: formatOracleDate(start),
endCloseDate: formatOracleDate(end)
};
}
function createDateRange(startYear, startMonth, endYear, endMonth) {
return {
startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)),
endCloseDate: formatOracleDate(new Date(endYear, endMonth + 1, 0))
};
}
function formatOracleDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}T00:00:00.000Z`;
}
return { getFiscalPeriodRange, createDateRange, formatOracleDate };
}

View File

@@ -0,0 +1,174 @@
function createOpportunitiesFormatters({
opportunitiesTableState
}) {
function getCustomerOptions(items) {
const optionsByKey = new Map();
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
const key = getCustomerKey(item);
const label = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]);
if (key && label && !optionsByKey.has(key)) {
optionsByKey.set(key, { key, label: String(label) });
}
});
return Array.from(optionsByKey.values()).sort((first, second) => {
return first.label.localeCompare(second.label, undefined, {
sensitivity: "base"
});
});
}
function getCustomerKey(item) {
const partyId = getNestedValue(item, ["CustomerAccount", "PartyId"]);
const partyName = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]);
if (partyId !== null && partyId !== undefined && partyId !== "") {
return `party:${partyId}`;
}
return partyName ? `name:${partyName}` : "";
}
function getOwnerName(item) {
const owner = item.Owner;
if (typeof owner === "string" || typeof owner === "number") {
return owner;
}
if (owner && typeof owner === "object") {
const firstName = owner.PersonFirstName;
const lastName = owner.PersonLastName;
if (firstName || lastName) {
return [firstName, lastName].filter(Boolean).join(" ");
}
return owner.PartyName
|| owner.PartyUniqueName
|| owner.Name
|| owner.DisplayName
|| owner.ResourceName
|| owner.OwnerName
|| "";
}
return "";
}
function getCustomerFilterSummary(total, selected) {
if (total === 0) {
return "No customers";
}
if (selected === total) {
return "All customers";
}
if (selected === 0) {
return "No customers";
}
return `${selected} customer${selected === 1 ? "" : "s"}`;
}
function getOwnerOptions(items) {
const optionsByKey = new Map();
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
const key = getOwnerKey(item);
const label = getOwnerName(item);
if (key && label && !optionsByKey.has(key)) {
optionsByKey.set(key, { key, label: String(label) });
}
});
return Array.from(optionsByKey.values()).sort((first, second) => {
return first.label.localeCompare(second.label, undefined, {
sensitivity: "base"
});
});
}
function getOwnerKey(item) {
const owner = item.Owner;
const partyId = owner && typeof owner === "object" ? owner.PartyId : "";
const name = getOwnerName(item);
if (partyId !== null && partyId !== undefined && partyId !== "") {
return `party:${partyId}`;
}
return name ? `name:${name}` : "";
}
function stringifySearchValue(value) {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "object") {
return JSON.stringify(value);
}
return String(value);
}
function displayOpportunityValue(column, item) {
const value = column.display(item);
return value === null || value === undefined || value === "" ? "-" : String(value);
}
function getNestedValue(value, path) {
return path.reduce((result, key) => result && result[key], value);
}
function formatWinProbability(item) {
const value = getNestedValue(item, ["PrimaryRevenue", "WinProb"]);
const number = Number(value);
return Number.isFinite(number) ? `${number}%` : value;
}
function formatRevenue(item) {
const amount = getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]);
const currency = getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]);
const number = Number(amount);
if (!Number.isFinite(number)) {
return amount;
}
if (!currency) {
return number.toLocaleString();
}
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency
}).format(number);
} catch (error) {
return `${currency} ${number.toLocaleString()}`;
}
}
function formatOracleResponseDate(value, includeTime) {
if (typeof value !== "string") {
return value;
}
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/);
if (!match) {
return value;
}
const date = `${match[3]}/${match[2]}/${match[1]}`;
return includeTime && match[4] ? `${date} ${match[4]}:${match[5]}` : date;
}
return { getCustomerOptions, getCustomerKey, getOwnerName, getCustomerFilterSummary, getOwnerOptions, getOwnerKey, stringifySearchValue, displayOpportunityValue, getNestedValue, formatWinProbability, formatRevenue, formatOracleResponseDate };
}

View File

@@ -0,0 +1,118 @@
function createOpportunitiesLinks({
MODAL_ID,
environment
}) {
const { document, navigator, window } = environment;
function createDetailLink(label, href) {
if (label === null || label === undefined || label === "") {
const placeholder = document.createElement("span");
placeholder.textContent = "-";
return placeholder;
}
if (!href) {
const text = document.createElement("span");
text.textContent = String(label);
return text;
}
const link = document.createElement("a");
link.href = href;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = String(label);
return link;
}
function createCopyOpportunityButton(optyNumber) {
const button = document.createElement("button");
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
button.type = "button";
button.className = "opportunities-extension-copy-button";
button.setAttribute("aria-label", `Copy opportunity number ${optyNumber}`);
button.title = "Copy opportunity number";
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
copyOpportunityNumber(String(optyNumber));
});
icon.setAttribute("viewBox", "0 0 24 24");
icon.setAttribute("aria-hidden", "true");
path.setAttribute("d", "M8 8V5.5A2.5 2.5 0 0 1 10.5 3h8A2.5 2.5 0 0 1 21 5.5v8a2.5 2.5 0 0 1-2.5 2.5H16v2.5a2.5 2.5 0 0 1-2.5 2.5h-8A2.5 2.5 0 0 1 3 18.5v-8A2.5 2.5 0 0 1 5.5 8H8zm2 0h3.5A2.5 2.5 0 0 1 16 10.5v3.5h2.5a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5V8zm3.5 2h-8a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5z");
icon.append(path);
button.append(icon);
return button;
}
async function copyOpportunityNumber(optyNumber) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(optyNumber);
} else {
const input = document.createElement("textarea");
input.value = optyNumber;
input.setAttribute("readonly", "true");
input.style.position = "fixed";
input.style.opacity = "0";
document.body.append(input);
input.select();
document.execCommand("copy");
input.remove();
}
showCopyConfirmation(`Opty Number ${optyNumber} copied.`);
} catch (error) {
showCopyConfirmation("Unable to copy opportunity number.", true);
}
}
function showCopyConfirmation(message, isError = false) {
const modal = document.getElementById(MODAL_ID);
if (!modal) {
return;
}
let toast = modal.querySelector(".opportunities-extension-copy-toast");
if (!toast) {
toast = document.createElement("div");
toast.className = "opportunities-extension-copy-toast";
toast.setAttribute("role", "status");
modal.append(toast);
}
toast.classList.toggle("opportunities-extension-copy-toast-error", isError);
toast.textContent = message;
toast.hidden = false;
window.clearTimeout(toast.copyTimeout);
toast.copyTimeout = window.setTimeout(() => {
toast.hidden = true;
}, 2400);
}
function createStageBadge(stage) {
const badge = document.createElement("span");
const normalizedStage = typeof stage === "string" ? stage.toUpperCase() : "";
badge.className = "opportunities-extension-stage-badge";
badge.setAttribute("data-stage", normalizedStage);
badge.textContent = normalizedStage || "-";
return badge;
}
function createStatusBadge(status) {
const badge = document.createElement("span");
const normalizedStatus = typeof status === "string" ? status.toUpperCase() : "";
badge.className = "opportunities-extension-status-badge";
badge.setAttribute("data-status", normalizedStatus);
badge.textContent = normalizedStatus || "-";
return badge;
}
return { createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge };
}

View File

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

View File

@@ -0,0 +1,11 @@
function createOpportunitiesTableState() {
return {
opportunitiesTableState: {
items: [],
status: "idle",
message: "",
sortKey: "",
sortDirection: "ascending"
}
};
}

View File

@@ -0,0 +1,176 @@
function createOpportunitiesTable({
ACCOUNT_DETAIL_URL,
OPPORTUNITIES_COLUMNS,
OPPORTUNITIES_TABLE_ID,
OPPORTUNITY_DETAIL_URL,
createCloudConsumptionButton,
createCopyOpportunityButton,
createDetailLink,
createProductsCountButton,
createProductsDetailRow,
createStageBadge,
createStatusBadge,
displayOpportunityValue,
getNestedValue,
getSortedOpportunities,
opportunitiesTableState,
productsContentState,
renderCustomerFilter,
renderOwnerFilter,
renderStageDashboard,
resetCustomerFilter,
resetOwnerFilter,
environment
}) {
const { document } = environment;
function setOpportunitiesTableState(nextState) {
if (nextState.status === "ready" && Array.isArray(nextState.items)) {
resetCustomerFilter(nextState.items);
resetOwnerFilter(nextState.items);
}
opportunitiesTableState.opportunitiesTableState = {
...opportunitiesTableState.opportunitiesTableState,
...nextState,
sortKey: nextState.items ? "" : opportunitiesTableState.opportunitiesTableState.sortKey,
sortDirection: nextState.items ? "ascending" : opportunitiesTableState.opportunitiesTableState.sortDirection
};
renderOpportunitiesTable();
renderCustomerFilter();
renderOwnerFilter();
}
function renderOpportunitiesTable() {
const table = document.getElementById(OPPORTUNITIES_TABLE_ID);
if (!table) {
return;
}
table.textContent = "";
table.setAttribute("aria-busy", opportunitiesTableState.opportunitiesTableState.status === "loading" ? "true" : "false");
const caption = document.createElement("caption");
caption.className = "opportunities-extension-visually-hidden";
caption.textContent = "Opportunities";
const tableHead = document.createElement("thead");
const headerRow = document.createElement("tr");
OPPORTUNITIES_COLUMNS.forEach((column) => {
const header = document.createElement("th");
const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key;
header.scope = "col";
if (column.sortable === false) {
const label = document.createElement("span");
label.className = "opportunities-extension-table-header-label";
label.textContent = column.label;
header.append(label);
} else {
header.setAttribute("aria-sort", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
const sortButton = document.createElement("button");
sortButton.type = "button";
sortButton.className = "opportunities-extension-sort-button";
sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`);
sortButton.textContent = column.label;
sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key));
header.append(sortButton);
}
headerRow.append(header);
});
tableHead.append(headerRow);
const tableBody = document.createElement("tbody");
const items = getSortedOpportunities();
renderStageDashboard(items);
if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.className = "opportunities-extension-table-status";
cell.colSpan = OPPORTUNITIES_COLUMNS.length;
cell.textContent = opportunitiesTableState.opportunitiesTableState.message || "No opportunities found.";
row.append(cell);
tableBody.append(row);
} else {
items.forEach((item) => {
const row = document.createElement("tr");
OPPORTUNITIES_COLUMNS.forEach((column) => {
const cell = document.createElement("td");
if (column.key === "stage") {
cell.append(createStageBadge(column.display(item)));
} else if (column.key === "status") {
cell.append(createStatusBadge(column.display(item)));
} else if (column.key === "actions") {
const actionsContent = document.createElement("span");
actionsContent.className = "opportunities-extension-actions-content";
const productsResult = productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
if (productsResult) {
actionsContent.append(createProductsCountButton(item.OptyNumber, productsResult));
}
actionsContent.append(createCloudConsumptionButton(item));
cell.append(actionsContent);
} else if (column.key === "name" || column.key === "optyNumber") {
const opportunityLink = createDetailLink(
column.display(item),
item.OptyNumber ? `${OPPORTUNITY_DETAIL_URL}${encodeURIComponent(item.OptyNumber)}` : ""
);
if (column.key === "name" && item.OptyNumber) {
const nameCellContent = document.createElement("span");
nameCellContent.className = "opportunities-extension-name-content";
nameCellContent.append(opportunityLink);
cell.append(nameCellContent);
} else if (column.key === "optyNumber" && item.OptyNumber) {
const opportunityCellContent = document.createElement("span");
opportunityCellContent.className = "opportunities-extension-opty-number-content";
opportunityCellContent.append(opportunityLink, createCopyOpportunityButton(item.OptyNumber));
cell.append(opportunityCellContent);
} else {
cell.append(opportunityLink);
}
} else if (column.key === "customer") {
cell.append(createDetailLink(
column.display(item),
getNestedValue(item, ["CustomerAccount", "PartyId"])
? `${ACCOUNT_DETAIL_URL}${encodeURIComponent(getNestedValue(item, ["CustomerAccount", "PartyId"]))}`
: ""
));
} else {
cell.textContent = displayOpportunityValue(column, item);
}
row.append(cell);
});
tableBody.append(row);
if (item.OptyNumber && productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber))) {
tableBody.append(createProductsDetailRow(item));
}
});
}
table.append(caption, tableHead, tableBody);
}
function sortOpportunitiesBy(key) {
const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key;
opportunitiesTableState.opportunitiesTableState.sortKey = key;
opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending"
? "descending"
: "ascending";
renderOpportunitiesTable();
}
return { setOpportunitiesTableState, renderOpportunitiesTable, sortOpportunitiesBy };
}

View File

@@ -0,0 +1,6 @@
function createPreferencesState({ DEFAULT_OPPORTUNITY_TYPE_VALUES, DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS } = {}) {
return {
selectedOpportunityTypeValues: new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES),
opptyProductsCacheHours: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS
};
}

View File

@@ -0,0 +1,124 @@
function createPreferencesStorage({
DEFAULT_OPPORTUNITY_TYPE_VALUES,
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
FILTER_PREFERENCES_STORAGE_KEY,
MODAL_ID,
OPPORTUNITY_TYPE_OPTIONS,
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
OPPTY_PRODUCTS_CACHE_OPTIONS,
PERIOD_OPTIONS,
STAGE_OPTIONS,
STATUS_OPTIONS,
opportunitiesFilterState,
environment
}) {
const { document } = environment;
const getLocalStorage = () => environment.localStorage;
function restoreFilterPreferences(select) {
try {
const rawPreferences = getLocalStorage().getItem(FILTER_PREFERENCES_STORAGE_KEY);
if (!rawPreferences) {
return;
}
const preferences = JSON.parse(rawPreferences);
const savedStages = Array.isArray(preferences.stages)
? preferences.stages.filter((stage) => STAGE_OPTIONS.includes(stage))
: null;
const savedStatuses = Array.isArray(preferences.statuses)
? preferences.statuses.filter((status) => STATUS_OPTIONS.includes(status))
: null;
if (savedStages) {
opportunitiesFilterState.selectedStages = new Set(savedStages);
}
if (savedStatuses) {
opportunitiesFilterState.selectedStatuses = new Set(savedStatuses);
}
if (Array.isArray(preferences.customerKeys)) {
opportunitiesFilterState.selectedCustomerKeys = new Set(preferences.customerKeys.map(String));
opportunitiesFilterState.hasSavedCustomerFilter = true;
opportunitiesFilterState.customerFilterInitialized = true;
}
if (Array.isArray(preferences.ownerKeys)) {
opportunitiesFilterState.selectedOwnerKeys = new Set(preferences.ownerKeys.map(String));
opportunitiesFilterState.hasSavedOwnerFilter = true;
opportunitiesFilterState.ownerFilterInitialized = true;
}
opportunitiesFilterState.customerSearch = typeof preferences.customerSearch === "string" ? preferences.customerSearch : "";
opportunitiesFilterState.ownerSearch = typeof preferences.ownerSearch === "string" ? preferences.ownerSearch : "";
opportunitiesFilterState.tableSearch = typeof preferences.tableSearch === "string" ? preferences.tableSearch : "";
if (PERIOD_OPTIONS.includes(preferences.period)) {
select.value = preferences.period;
}
} catch (error) {
// Ignore malformed or unavailable browser storage and use default filters.
}
}
function saveFilterPreferences(period) {
try {
const currentPeriod = period || document.querySelector(`#${MODAL_ID} select`)?.value || PERIOD_OPTIONS[0];
getLocalStorage().setItem(FILTER_PREFERENCES_STORAGE_KEY, JSON.stringify({
period: currentPeriod,
stages: Array.from(opportunitiesFilterState.selectedStages),
statuses: Array.from(opportunitiesFilterState.selectedStatuses),
customerKeys: opportunitiesFilterState.hasSavedCustomerFilter ? Array.from(opportunitiesFilterState.selectedCustomerKeys) : null,
ownerKeys: opportunitiesFilterState.hasSavedOwnerFilter ? Array.from(opportunitiesFilterState.selectedOwnerKeys) : null,
customerSearch: opportunitiesFilterState.customerSearch,
ownerSearch: opportunitiesFilterState.ownerSearch,
tableSearch: opportunitiesFilterState.tableSearch
}));
} catch (error) {
// Ignore unavailable browser storage; filters still work for the current modal.
}
}
function getSavedOpportunityTypeValues() {
try {
const rawPreferences = getLocalStorage().getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY);
if (!rawPreferences) {
return DEFAULT_OPPORTUNITY_TYPE_VALUES;
}
const preferences = JSON.parse(rawPreferences);
const values = Array.isArray(preferences.opportunityTypeValues) ? preferences.opportunityTypeValues : null;
if (!values) {
return DEFAULT_OPPORTUNITY_TYPE_VALUES;
}
const allowedValues = new Set(OPPORTUNITY_TYPE_OPTIONS.map((option) => option.value));
return values.filter((value) => allowedValues.has(value));
} catch (error) {
return DEFAULT_OPPORTUNITY_TYPE_VALUES;
}
}
function getSavedOpptyProductsCacheHours() {
try {
const rawPreferences = getLocalStorage().getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY);
if (!rawPreferences) {
return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
}
const preferences = JSON.parse(rawPreferences);
const cacheHours = Number(preferences.opptyProductsCacheHours);
return OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === cacheHours)
? cacheHours
: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
} catch (error) {
return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
}
}
return { restoreFilterPreferences, saveFilterPreferences, getSavedOpportunityTypeValues, getSavedOpptyProductsCacheHours };
}

View File

@@ -0,0 +1,165 @@
function createPreferencesView({
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
MODAL_ID,
OPPORTUNITY_TYPE_OPTIONS,
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
OPPTY_PRODUCTS_CACHE_OPTIONS,
PERIOD_OPTIONS,
PREFERENCES_MODAL_ID,
createCloseIcon,
getSavedOpportunityTypeValues,
getSavedOpptyProductsCacheHours,
preferencesState,
requestOpportunitiesForPeriod,
environment
}) {
const { document } = environment;
const getLocalStorage = () => environment.localStorage;
function openPreferencesModal() {
const overlay = document.getElementById(MODAL_ID);
if (!overlay) {
return;
}
const existingModal = document.getElementById(PREFERENCES_MODAL_ID);
if (existingModal) {
const savedValues = new Set(getSavedOpportunityTypeValues());
existingModal.querySelectorAll("input[name='opportunityTypeView']").forEach((checkbox) => {
checkbox.checked = savedValues.has(checkbox.value);
});
const cacheSelect = existingModal.querySelector("select[name='opptyProductsCacheHours']");
if (cacheSelect) {
cacheSelect.value = String(getSavedOpptyProductsCacheHours());
}
existingModal.hidden = false;
existingModal.querySelector("input")?.focus();
return;
}
const preferencesModal = document.createElement("section");
preferencesModal.id = PREFERENCES_MODAL_ID;
preferencesModal.className = "opportunities-extension-preferences-modal";
preferencesModal.setAttribute("role", "dialog");
preferencesModal.setAttribute("aria-modal", "true");
preferencesModal.setAttribute("aria-labelledby", "opportunities-extension-preferences-title");
const dialog = document.createElement("div");
dialog.className = "opportunities-extension-preferences-dialog";
const header = document.createElement("header");
header.className = "opportunities-extension-preferences-header";
const title = document.createElement("h2");
title.id = "opportunities-extension-preferences-title";
title.textContent = "Preferences";
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Fechar preferências");
closeButton.title = "Close";
closeButton.append(createCloseIcon());
closeButton.addEventListener("click", closePreferencesModal);
header.append(title, closeButton);
const content = document.createElement("div");
content.className = "opportunities-extension-preferences-content";
const fieldset = document.createElement("fieldset");
fieldset.className = "opportunities-extension-preferences-fieldset";
const legend = document.createElement("legend");
legend.textContent = "Opportunity Type View";
fieldset.append(legend);
const selectedValues = new Set(getSavedOpportunityTypeValues());
const options = document.createElement("div");
options.className = "opportunities-extension-preferences-options";
OPPORTUNITY_TYPE_OPTIONS.forEach((option) => {
const label = document.createElement("label");
label.className = "opportunities-extension-preferences-option";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.name = "opportunityTypeView";
checkbox.value = option.value;
checkbox.checked = selectedValues.has(option.value);
const text = document.createElement("span");
text.textContent = option.label;
label.append(checkbox, text);
options.append(label);
});
fieldset.append(options);
const cacheField = document.createElement("label");
cacheField.className = "opportunities-extension-preferences-cache-field";
const cacheLabel = document.createElement("span");
cacheLabel.textContent = "Product cache duration";
const cacheSelect = document.createElement("select");
cacheSelect.name = "opptyProductsCacheHours";
cacheSelect.setAttribute("aria-label", "Product cache duration");
OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => {
const optionElement = document.createElement("option");
optionElement.value = String(option.value);
optionElement.textContent = option.label;
optionElement.selected = option.value === getSavedOpptyProductsCacheHours();
cacheSelect.append(optionElement);
});
cacheField.append(cacheLabel, cacheSelect);
content.append(fieldset, cacheField);
const footer = document.createElement("footer");
footer.className = "opportunities-extension-preferences-footer";
const saveButton = document.createElement("button");
saveButton.type = "button";
saveButton.className = "opportunities-extension-button opportunities-extension-button-primary";
saveButton.textContent = "Save";
saveButton.addEventListener("click", () => {
const selectedValues = Array.from(preferencesModal.querySelectorAll("input[name='opportunityTypeView']:checked"), (checkbox) => checkbox.value);
const selectedCacheHours = Number(preferencesModal.querySelector("select[name='opptyProductsCacheHours']")?.value);
preferencesState.selectedOpportunityTypeValues = new Set(selectedValues);
preferencesState.opptyProductsCacheHours = OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === selectedCacheHours)
? selectedCacheHours
: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
try {
getLocalStorage().setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({
opportunityTypeValues: selectedValues,
opptyProductsCacheHours: preferencesState.opptyProductsCacheHours
}));
} catch (error) {
// Continue with the saved selection for the active modal when storage is unavailable.
}
closePreferencesModal();
const periodSelect = overlay.querySelector("select[name='opportunityPeriod']");
requestOpportunitiesForPeriod(periodSelect?.value || PERIOD_OPTIONS[0]);
});
footer.append(saveButton);
dialog.append(header, content, footer);
preferencesModal.append(dialog);
overlay.append(preferencesModal);
preferencesModal.querySelector("input")?.focus();
}
function closePreferencesModal() {
const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID);
if (preferencesModal) {
preferencesModal.hidden = true;
}
}
return { openPreferencesModal, closePreferencesModal };
}

View File

@@ -0,0 +1,107 @@
function createProductsBackgroundJobs({
messageTypes,
OPPTY_PRODUCTS_CONCURRENCY,
getOpptyProducts,
normalizeCacheMaxAge,
tabsSendMessage
}) {
async function requestOpptyProducts(message, sender) {
const requestId = typeof message.requestId === "string" ? message.requestId : "";
const accessToken = typeof message.accessToken === "string" ? message.accessToken : "";
const cacheMaxAgeMs = normalizeCacheMaxAge(message.cacheMaxAgeMs);
const optyNumbers = Array.from(new Set(
(Array.isArray(message.optyNumbers) ? message.optyNumbers : [])
.filter(Boolean)
.map(String)
));
if (!requestId || !accessToken) {
throw new Error("A request id and access token are required for requestOpptyProducts.");
}
let nextIndex = 0;
let completed = 0;
let failed = 0;
let cacheHits = 0;
const runWorker = async () => {
while (nextIndex < optyNumbers.length) {
const index = nextIndex;
nextIndex += 1;
const optyNumber = optyNumbers[index];
try {
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs);
completed += 1;
if (result.fromCache) {
cacheHits += 1;
}
await sendProductsProgress(sender, {
type: messageTypes.opptyProductsProgress,
requestId,
state: "running",
optyNumber,
items: result.items,
fromCache: result.fromCache,
cachedAt: result.cachedAt,
completed,
total: optyNumbers.length,
failed,
cacheHits
});
} catch (error) {
completed += 1;
failed += 1;
await sendProductsProgress(sender, {
type: messageTypes.opptyProductsProgress,
requestId,
state: "running",
optyNumber,
items: [],
error: error.message || "Unable to load products.",
completed,
total: optyNumbers.length,
failed,
cacheHits
});
}
}
};
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
await Promise.all(Array.from({ length: workerCount }, runWorker));
const state = failed > 0 ? "complete-with-errors" : "complete";
await sendProductsProgress(sender, {
type: messageTypes.opptyProductsProgress,
requestId,
state,
completed,
total: optyNumbers.length,
failed,
cacheHits
});
return {
ok: failed === 0,
requestId,
completed,
total: optyNumbers.length,
failed,
cacheHits
};
}
function sendProductsProgress(sender, message) {
if (!sender || !sender.tab || !Number.isInteger(sender.tab.id)) {
return Promise.resolve();
}
const options = Number.isInteger(sender.frameId) ? { frameId: sender.frameId } : undefined;
return tabsSendMessage(sender.tab.id, message, options).catch(() => {});
}
return { requestOpptyProducts, sendProductsProgress };
}

View File

@@ -0,0 +1,7 @@
function createProductsCacheState() {
return {
productsCachePromise: null,
productsCacheWritePromise: Promise.resolve(),
inFlightProducts: new Map()
};
}

View File

@@ -0,0 +1,70 @@
function createProductsCache({
OPPTY_PRODUCTS_CACHE_KEY,
fetchAllOpptyProducts,
productsCacheState,
storageLocalGet,
storageLocalSet
}) {
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) {
const cache = await getProductsCache();
const cachedEntry = cache[optyNumber];
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
return { items: cachedEntry.items, fromCache: true, cachedAt: Number(cachedEntry.fetchedAt) };
}
if (!productsCacheState.inFlightProducts.has(optyNumber)) {
const request = fetchAllOpptyProducts(optyNumber, accessToken)
.then(async (items) => {
const fetchedAt = Date.now();
cache[optyNumber] = {
fetchedAt,
items
};
await saveProductsCache(cache).catch(() => {});
return { items, fetchedAt };
})
.finally(() => {
productsCacheState.inFlightProducts.delete(optyNumber);
});
productsCacheState.inFlightProducts.set(optyNumber, request);
}
const fetchedResult = await productsCacheState.inFlightProducts.get(optyNumber);
return {
items: fetchedResult.items,
cachedAt: fetchedResult.fetchedAt,
fromCache: false
};
}
function normalizeCacheMaxAge(value) {
const maxAge = Number(value);
const defaultMaxAge = 24 * 60 * 60 * 1000;
const maximumMaxAge = 30 * 24 * 60 * 60 * 1000;
return Number.isFinite(maxAge) && maxAge > 0
? Math.min(maxAge, maximumMaxAge)
: defaultMaxAge;
}
async function getProductsCache() {
if (!productsCacheState.productsCachePromise) {
productsCacheState.productsCachePromise = storageLocalGet(OPPTY_PRODUCTS_CACHE_KEY)
.then((result) => result && typeof result[OPPTY_PRODUCTS_CACHE_KEY] === "object"
? result[OPPTY_PRODUCTS_CACHE_KEY]
: {})
.catch(() => ({}));
}
return productsCacheState.productsCachePromise;
}
function saveProductsCache(cache) {
productsCacheState.productsCacheWritePromise = productsCacheState.productsCacheWritePromise
.catch(() => {})
.then(() => storageLocalSet({ [OPPTY_PRODUCTS_CACHE_KEY]: cache }));
return productsCacheState.productsCacheWritePromise;
}
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
}

View File

@@ -0,0 +1,163 @@
function createProductsContentJobs({
messageTypes,
PRODUCTS_CACHE_TIMESTAMP_ID,
PRODUCTS_MESSAGE_LISTENER_KEY,
PRODUCTS_PROGRESS_ID,
preferencesState,
productsContentState,
renderOpportunitiesTable,
sendRuntimeMessage,
environment
}) {
const { browser, chrome, document, window } = environment;
function requestOpptyProductsInBackground(opportunities, token) {
const optyNumbers = Array.from(new Set(opportunities
.map((item) => item && item.OptyNumber)
.filter(Boolean)
.map(String)));
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
productsContentState.currentProductsRequestId = requestId;
productsContentState.opptyProductsByNumber = new Map();
productsContentState.expandedProductOptyNumbers = new Set();
productsContentState.productsProgress = {
state: optyNumbers.length ? "running" : "complete",
completed: 0,
total: optyNumbers.length,
failed: 0,
cacheHits: 0
};
renderProductsProgress();
if (optyNumbers.length === 0) {
return;
}
sendRuntimeMessage({
type: messageTypes.requestOpptyProducts,
requestId,
accessToken: token,
optyNumbers,
cacheMaxAgeMs: preferencesState.opptyProductsCacheHours * 60 * 60 * 1000
}).then((response) => {
if (response && response.ok === false && response.requestId === productsContentState.currentProductsRequestId && productsContentState.productsProgress.state === "running") {
productsContentState.productsProgress.state = "error";
productsContentState.productsProgress.failed = productsContentState.productsProgress.total;
renderProductsProgress();
}
}).catch(() => {
if (requestId === productsContentState.currentProductsRequestId && productsContentState.productsProgress.state === "running") {
productsContentState.productsProgress.state = "error";
productsContentState.productsProgress.failed = productsContentState.productsProgress.total;
renderProductsProgress();
}
});
}
function ensureProductsBackgroundMessageHandler() {
if (window[PRODUCTS_MESSAGE_LISTENER_KEY]) {
return;
}
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.onMessage) {
return;
}
window[PRODUCTS_MESSAGE_LISTENER_KEY] = true;
runtimeApi.runtime.onMessage.addListener((message) => {
if (!message || message.type !== messageTypes.opptyProductsProgress || message.requestId !== productsContentState.currentProductsRequestId) {
return false;
}
if (message.optyNumber) {
productsContentState.opptyProductsByNumber.set(String(message.optyNumber), {
items: Array.isArray(message.items) ? message.items : [],
fromCache: Boolean(message.fromCache),
cachedAt: Number(message.cachedAt) || 0,
error: message.error || ""
});
}
if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) {
productsContentState.productsLastCachedAt = Number(message.cachedAt);
}
productsContentState.productsProgress = {
state: message.state || productsContentState.productsProgress.state,
completed: Number(message.completed) || 0,
total: Number(message.total) || productsContentState.productsProgress.total,
failed: Number(message.failed) || 0,
cacheHits: Number(message.cacheHits) || 0
};
renderProductsProgress();
queueProductsTableRender();
return false;
});
}
function renderProductsProgress() {
const badge = document.getElementById(PRODUCTS_PROGRESS_ID);
if (!badge) {
return;
}
badge.hidden = productsContentState.productsProgress.state === "idle";
badge.setAttribute("data-state", productsContentState.productsProgress.state);
if (productsContentState.productsProgress.state === "running") {
badge.textContent = `Products ${productsContentState.productsProgress.completed}/${productsContentState.productsProgress.total}`;
badge.title = `${productsContentState.productsProgress.cacheHits} loaded from cache`;
} else if (productsContentState.productsProgress.state === "complete") {
badge.textContent = `Products ready ${productsContentState.productsProgress.total}`;
badge.title = `${productsContentState.productsProgress.cacheHits} loaded from cache`;
} else if (productsContentState.productsProgress.state === "complete-with-errors") {
badge.textContent = `Products ${productsContentState.productsProgress.total - productsContentState.productsProgress.failed}/${productsContentState.productsProgress.total}`;
badge.title = `${productsContentState.productsProgress.failed} product requests failed`;
} else if (productsContentState.productsProgress.state === "error") {
badge.textContent = "Products unavailable";
badge.title = "Unable to load opportunity products";
} else {
badge.textContent = "Products ready 0";
}
const timestamp = document.getElementById(PRODUCTS_CACHE_TIMESTAMP_ID);
if (timestamp) {
timestamp.hidden = !productsContentState.productsLastCachedAt;
timestamp.textContent = productsContentState.productsLastCachedAt
? `Last update: ${formatProductsCacheTimestamp(productsContentState.productsLastCachedAt)}`
: "";
timestamp.title = productsContentState.productsLastCachedAt
? `Latest product update: ${formatProductsCacheTimestamp(productsContentState.productsLastCachedAt)}`
: "";
}
}
function formatProductsCacheTimestamp(timestamp) {
try {
return new Intl.DateTimeFormat("en-GB", {
dateStyle: "short",
timeStyle: "short"
}).format(new Date(timestamp));
} catch (error) {
return new Date(timestamp).toLocaleString();
}
}
function queueProductsTableRender() {
if (productsContentState.productsRenderQueued) {
return;
}
productsContentState.productsRenderQueued = true;
window.requestAnimationFrame(() => {
productsContentState.productsRenderQueued = false;
renderOpportunitiesTable();
});
}
return { requestOpptyProductsInBackground, ensureProductsBackgroundMessageHandler, renderProductsProgress, formatProductsCacheTimestamp, queueProductsTableRender };
}

View File

@@ -0,0 +1,16 @@
function createProductsContentState() {
return {
currentProductsRequestId: "",
productsRenderQueued: false,
opptyProductsByNumber: new Map(),
expandedProductOptyNumbers: new Set(),
productsProgress: {
state: "idle",
completed: 0,
total: 0,
failed: 0,
cacheHits: 0
},
productsLastCachedAt: 0
};
}

View File

@@ -0,0 +1,152 @@
function createProductsDetails({
OPPORTUNITIES_COLUMNS,
createAiSummaryAlert,
formatOracleResponseDate,
productsContentState,
renderOpportunitiesTable,
requestGenerateAiSummary,
environment
}) {
const { document } = environment;
function createProductsCountButton(optyNumber, productsResult) {
const normalizedOptyNumber = String(optyNumber);
const button = document.createElement("button");
const count = Array.isArray(productsResult.items) ? productsResult.items.length : 0;
const isExpanded = productsContentState.expandedProductOptyNumbers.has(normalizedOptyNumber);
button.type = "button";
button.className = "opportunities-extension-products-count";
button.textContent = String(count);
button.setAttribute("aria-expanded", String(isExpanded));
button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`);
button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`;
button.classList.toggle("opportunities-extension-products-count-error", Boolean(productsResult.error));
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (isExpanded) {
productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber);
} else {
productsContentState.expandedProductOptyNumbers.add(normalizedOptyNumber);
requestGenerateAiSummary(normalizedOptyNumber);
}
renderOpportunitiesTable();
});
return button;
}
function createProductsDetailRow(opportunity) {
const row = document.createElement("tr");
const cell = document.createElement("td");
const optyNumber = String(opportunity.OptyNumber);
const productsResult = productsContentState.opptyProductsByNumber.get(optyNumber);
const products = productsResult && Array.isArray(productsResult.items) ? productsResult.items : [];
row.className = "opportunities-extension-products-detail-row";
cell.colSpan = OPPORTUNITIES_COLUMNS.length;
cell.className = "opportunities-extension-products-detail-cell";
const panel = document.createElement("section");
panel.className = "opportunities-extension-products-detail";
const heading = document.createElement("div");
heading.className = "opportunities-extension-products-detail-heading";
const title = document.createElement("strong");
title.textContent = `Products for ${optyNumber}`;
const summary = document.createElement("span");
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
heading.append(title, summary);
panel.append(heading);
panel.append(createAiSummaryAlert(optyNumber));
if (productsResult && productsResult.error) {
const error = document.createElement("p");
error.className = "opportunities-extension-products-empty";
error.textContent = productsResult.error;
panel.append(error);
} else if (products.length === 0) {
const empty = document.createElement("p");
empty.className = "opportunities-extension-products-empty";
empty.textContent = "No products found for this opportunity.";
panel.append(empty);
} else {
const table = document.createElement("table");
table.className = "opportunities-extension-products-table";
table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`);
const columns = [
{ label: "Product Group", value: (item) => item.ProdGroupName || "-" },
{ label: "Workload", value: (item) => item.WorkloadName_c || "-" },
{ label: "Currency", value: (item) => item.RevnAmountCurcyCode || "-" },
{ label: "Amount", value: (item) => formatProductAmount(item.RevnAmount, item.RevnAmountCurcyCode) },
{ label: "Type", value: (item) => item.TypeCode || "-" },
{ label: "Status", value: (item) => item.StatusCode || "-" },
{ label: "Win Probability", value: (item) => formatProductWinProbability(item.WinProb) },
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
{ label: "Ramp Months", value: (item) => formatProductNumber(item.RampMonths_c) }
];
const head = document.createElement("thead");
const headerRow = document.createElement("tr");
columns.forEach((column) => {
const header = document.createElement("th");
header.scope = "col";
header.textContent = column.label;
headerRow.append(header);
});
head.append(headerRow);
const body = document.createElement("tbody");
products.forEach((product) => {
const productRow = document.createElement("tr");
columns.forEach((column) => {
const productCell = document.createElement("td");
productCell.textContent = String(column.value(product));
productRow.append(productCell);
});
body.append(productRow);
});
table.append(head, body);
panel.append(table);
}
cell.append(panel);
row.append(cell);
return row;
}
function formatProductNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(number) : "-";
}
function formatProductAmount(value, currency) {
const amount = Number(value);
if (!Number.isFinite(amount)) {
return "-";
}
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency || "USD"
}).format(amount);
} catch (error) {
return `${currency || ""} ${formatProductNumber(amount)}`.trim();
}
}
function formatProductWinProbability(value) {
const number = Number(value);
return Number.isFinite(number) ? `${number}%` : "-";
}
return { createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
}

View File

@@ -0,0 +1,190 @@
function createPlatformBackgroundApi({
runtimeApi,
environment
}) {
const { browser, setTimeout } = environment;
function supportsFirefoxCookieStoreTabs() {
return Boolean(
runtimeApi.runtime
&& typeof runtimeApi.runtime.getBrowserInfo === "function"
);
}
function tabsCreate(details) {
if (runtimeApi.tabs.create.length <= 1) {
return runtimeApi.tabs.create(details);
}
return new Promise((resolve, reject) => {
runtimeApi.tabs.create(details, (tab) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(tab);
});
});
}
function tabsRemove(tabId) {
if (runtimeApi.tabs.remove.length <= 1) {
return runtimeApi.tabs.remove(tabId);
}
return new Promise((resolve, reject) => {
runtimeApi.tabs.remove(tabId, () => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve();
});
});
}
function tabsSendMessage(tabId, message, options) {
if (typeof browser !== "undefined") {
return options
? runtimeApi.tabs.sendMessage(tabId, message, options)
: runtimeApi.tabs.sendMessage(tabId, message);
}
return new Promise((resolve, reject) => {
const callback = (response) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(response);
};
if (options) {
runtimeApi.tabs.sendMessage(tabId, message, options, callback);
} else {
runtimeApi.tabs.sendMessage(tabId, message, callback);
}
});
}
function storageLocalGet(key) {
if (typeof browser !== "undefined") {
return runtimeApi.storage.local.get(key);
}
return new Promise((resolve, reject) => {
runtimeApi.storage.local.get(key, (result) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(result);
});
});
}
function storageLocalSet(value) {
if (typeof browser !== "undefined") {
return runtimeApi.storage.local.set(value);
}
return new Promise((resolve, reject) => {
runtimeApi.storage.local.set(value, () => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve();
});
});
}
function wait(durationMs) {
return new Promise((resolve) => setTimeout(resolve, durationMs));
}
function cookiesGetAll(details) {
if (runtimeApi.cookies.getAll.length <= 1) {
return runtimeApi.cookies.getAll(details);
}
return new Promise((resolve, reject) => {
runtimeApi.cookies.getAll(details, (cookies) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(cookies);
});
});
}
function cookiesGet(details) {
const cleanDetails = removeUndefinedValues(details);
if (runtimeApi.cookies.get.length <= 1) {
return runtimeApi.cookies.get(cleanDetails);
}
return new Promise((resolve, reject) => {
runtimeApi.cookies.get(cleanDetails, (cookie) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(cookie);
});
});
}
function cookiesGetAllCookieStores() {
if (!runtimeApi.cookies.getAllCookieStores) {
return Promise.resolve([{ id: undefined }]);
}
if (runtimeApi.cookies.getAllCookieStores.length === 0) {
return runtimeApi.cookies.getAllCookieStores();
}
return new Promise((resolve, reject) => {
runtimeApi.cookies.getAllCookieStores((stores) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(stores);
});
});
}
function removeUndefinedValues(details) {
return Object.fromEntries(
Object.entries(details).filter((entry) => entry[1] !== undefined)
);
}
return { supportsFirefoxCookieStoreTabs, tabsCreate, tabsRemove, tabsSendMessage, storageLocalGet, storageLocalSet, wait, cookiesGetAll, cookiesGet, cookiesGetAllCookieStores, removeUndefinedValues };
}

View File

@@ -0,0 +1,120 @@
function createPlatformContentMessages({
messageTypes,
environment
}) {
const { browser, chrome } = environment;
function sendRuntimeMessage(message) {
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {
return Promise.reject(new Error("Runtime messaging API unavailable."));
}
if (typeof browser !== "undefined") {
return runtimeApi.runtime.sendMessage(message);
}
return new Promise((resolve, reject) => {
runtimeApi.runtime.sendMessage(message, (response) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(response);
});
});
}
function getXsrfTokenFromExtensionCookies() {
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {
return Promise.resolve({
token: "",
cookieName: "",
source: "unavailable cookies API",
error: "Runtime messaging API unavailable."
});
}
return new Promise((resolve) => {
runtimeApi.runtime.sendMessage({
type: messageTypes.getXsrfToken
}, (response) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
resolve({
token: "",
cookieName: "",
source: "cookies API",
matchedCookieNames: [],
lookupDetails: [],
error: lastError.message
});
return;
}
resolve({
token: response && response.ok ? response.token : "",
cookieName: response && response.ok ? response.cookieName : "",
source: "cookies API",
matchedCookieNames: response && response.matchedCookieNames ? response.matchedCookieNames : [],
lookupDetails: response && response.lookupDetails ? response.lookupDetails : [],
error: response && response.error ? response.error : ""
});
});
});
}
function refreshXsrfCookie(reason) {
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {
return Promise.resolve({
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
reason,
error: "Runtime messaging API unavailable."
});
}
return new Promise((resolve) => {
runtimeApi.runtime.sendMessage({
type: messageTypes.refreshXsrfCookie
}, (response) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
resolve({
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
reason,
error: lastError.message
});
return;
}
resolve({
...(response || {
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
error: "Empty refresh response."
}),
reason
});
});
});
}
return { sendRuntimeMessage, getXsrfTokenFromExtensionCookies, refreshXsrfCookie };
}

View File

@@ -0,0 +1,166 @@
function createFusionOpportunities({
OPPORTUNITIES_PAGE_LIMIT,
OPPORTUNITIES_QUERY_URL,
isCurrentPeriodRequest,
logRequestFailure,
logRequestStart,
logRequestSuccess,
maskToken,
getOpportunityTypeValues,
readResponseBodyForDebug,
environment
}) {
const { fetch } = environment;
async function requestOpportunitiesPage(token, period, dateRange, offset, accumulatedItems, requestVersion, page) {
if (!isCurrentPeriodRequest(requestVersion)) {
return null;
}
const payload = createOpportunitiesQueryPayload(dateRange, offset);
const requestBody = JSON.stringify(payload);
const requestEntry = logRequestStart({
name: "requestOpportunities",
method: "POST",
url: OPPORTUNITIES_QUERY_URL,
headers: {
Accept: "application/json",
Authorization: `Bearer ${maskToken(token)}`,
"Content-Type": "application/json",
Origin: "https://eeho.fa.us2.oraclecloud.com",
Preference: "transient"
},
body: JSON.stringify(payload, null, 2),
metadata: {
period,
page,
offset,
startCloseDate: dateRange.startCloseDate,
endCloseDate: dateRange.endCloseDate
}
});
try {
const response = await fetch(OPPORTUNITIES_QUERY_URL, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Origin: "https://eeho.fa.us2.oraclecloud.com",
Preference: "transient"
},
body: requestBody
});
const responseBody = await readResponseBodyForDebug(response);
logRequestSuccess(requestEntry, response, responseBody);
if (!response.ok) {
throw new Error(`Opportunities query failed with status ${response.status}.`);
}
const responseData = await response.json();
const pageItems = Array.isArray(responseData.items) ? responseData.items : [];
const allItems = accumulatedItems.concat(pageItems);
if (!responseData.hasMore) {
return allItems;
}
const responseOffset = Number(responseData.offset);
const responseCount = Number(responseData.count);
const receivedCount = Number.isFinite(responseCount) ? responseCount : pageItems.length;
const currentOffset = Number.isFinite(responseOffset) ? responseOffset : offset;
if (receivedCount <= 0) {
throw new Error("Opportunities query returned hasMore without additional results.");
}
return requestOpportunitiesPage(
token,
period,
dateRange,
currentOffset + receivedCount,
allItems,
requestVersion,
page + 1
);
} catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed.");
throw error;
}
}
function createOpportunitiesQueryPayload(dateRange, offset) {
return {
aggregationResults: false,
applications: ["ORACLE-ISS-APP"],
onlyData: true,
entity: "Opportunity",
q: {
op: "$and",
criteria: [
{
op: "$or",
criteria: Array.from(getOpportunityTypeValues(), (value) => ({
op: "$eq",
attribute: "RecordSet",
value
}))
},
{
op: "$wi",
attribute: "EffectiveDate",
value1: dateRange.startCloseDate,
value2: dateRange.endCloseDate,
dynamicDate: false
}
]
},
keywords: null,
keywordsFields: [
"Name",
"OptyNumber",
"PrimaryRevenue.WinProb",
"CustomerAccount",
"PrimaryRevenue.RevnAmount",
"EffectiveDate",
"StatusCode",
"DealRisk_c",
"Owner",
"ForecastGroup_c",
"LastUpdateDate",
"PrimaryRevenue.RevnAmountCurcyCode",
"OptyId"
],
fields: [
"Name",
"OptyNumber",
"PrimaryRevenue.WinProb",
"CustomerAccount",
"PrimaryRevenue.RevnAmount",
"EffectiveDate",
"StatusCode",
"DealRisk_c",
"Owner",
"ForecastGroup_c",
"LastUpdateDate",
"PrimaryRevenue.RevnAmountCurcyCode",
"OptyId",
"CustomerAccount.PartyUniqueName",
"CustomerAccount.PartyId",
"CustomerAccount.PartyNumber"
],
sort: [],
language: "en",
skipInValidFields: true,
skipHiddenFromUIFields: true,
copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162",
limit: OPPORTUNITIES_PAGE_LIMIT,
offset
};
}
return { requestOpportunitiesPage, createOpportunitiesQueryPayload };
}

View File

@@ -0,0 +1,65 @@
function createFusionProducts({
OPPTY_PRODUCTS_FIELDS,
OPPTY_PRODUCTS_MAX_PAGES,
OPPTY_PRODUCTS_PAGE_LIMIT,
ORACLE_DOMAIN,
environment
}) {
const { fetch } = environment;
async function fetchAllOpptyProducts(optyNumber, accessToken) {
let offset = 0;
let page = 0;
const items = [];
while (page < OPPTY_PRODUCTS_MAX_PAGES) {
const response = await fetch(createOpptyProductsUrl(optyNumber, offset), {
method: "GET",
credentials: "include",
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`
}
});
if (!response.ok) {
throw new Error(`requestOpptyProducts failed with status ${response.status}.`);
}
const responseData = await response.json();
const pageItems = Array.isArray(responseData.items) ? responseData.items : [];
items.push(...pageItems);
page += 1;
if (!responseData.hasMore) {
return items;
}
const responseOffset = Number(responseData.offset);
const responseCount = Number(responseData.count);
const receivedCount = Number.isFinite(responseCount) ? responseCount : pageItems.length;
const currentOffset = Number.isFinite(responseOffset) ? responseOffset : offset;
if (receivedCount <= 0) {
throw new Error("requestOpptyProducts returned hasMore without additional results.");
}
offset = currentOffset + receivedCount;
}
throw new Error("requestOpptyProducts exceeded the pagination safety limit.");
}
function createOpptyProductsUrl(optyNumber, offset) {
const url = new URL(`https://${ORACLE_DOMAIN}/crmRestApi/rest/rv:be91c002-2e5d-4ed2-a37e-e0c837bf141f/en/11.13.18.05:9/opportunities/${encodeURIComponent(optyNumber)}/child/ChildRevenue`);
url.searchParams.set("onlyData", "true");
url.searchParams.set("q", "(SplitTypeCode!='DETAILCHILDSPLIT') AND (RecurTypeCode!='CHILDRECUR')");
url.searchParams.set("totalResults", "false");
url.searchParams.set("fields", OPPTY_PRODUCTS_FIELDS.join(","));
url.searchParams.set("orderBy", "CreationDate:desc");
url.searchParams.set("limit", String(OPPTY_PRODUCTS_PAGE_LIMIT));
url.searchParams.set("offset", String(offset));
return url.toString();
}
return { fetchAllOpptyProducts, createOpptyProductsUrl };
}

View File

@@ -0,0 +1,169 @@
function createHttpBackgroundJson({
DEBUG_BODY_LIMIT,
environment
}) {
const { fetch } = environment;
async function fetchJson(url, options, requestName, debugRequests = null) {
const startedAt = new Date();
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
let response;
try {
response = await fetch(url, options);
} catch (error) {
completeBackgroundDebugEntry(debugEntry, null, "", error.message || "Network request failed.");
appendBackgroundDebugEntry(debugRequests, debugEntry);
throw error;
}
const responsePayload = await readResponsePayload(response);
const responseBody = createDebugBody(responsePayload.text);
completeBackgroundDebugEntry(debugEntry, response, responseBody, "");
debugEntry.metadata.responseContentType = response.headers.get("content-type") || "(not provided)";
debugEntry.metadata.responseLength = String(responsePayload.text.length);
appendBackgroundDebugEntry(debugRequests, debugEntry);
if (!response.ok) {
throw new Error(`${requestName} failed with status ${response.status}.`);
}
if (responsePayload.error) {
throw new Error(`${requestName} response body could not be read.`);
}
try {
return parseJsonResponse(responsePayload.text, requestName);
} catch (error) {
error.httpStatus = response.status;
debugEntry.status = "failed";
debugEntry.error = error.message;
throw error;
}
}
function createBackgroundDebugEntry(requestName, url, options, startedAt) {
return {
name: requestName,
method: options.method || "GET",
url,
status: "pending",
startedAt: startedAt.toISOString(),
completedAt: null,
durationMs: null,
httpStatus: null,
statusText: "",
requestHeaders: normalizeDebugHeaders(options.headers),
requestBody: formatDebugRequestBody(options.body),
metadata: {
executionContext: "background"
},
responseHeaders: {},
responseBody: "",
responseBodyTruncated: false,
error: ""
};
}
function completeBackgroundDebugEntry(entry, response, responseBody, errorMessage) {
const completedAt = new Date();
entry.completedAt = completedAt.toISOString();
entry.durationMs = completedAt.getTime() - new Date(entry.startedAt).getTime();
entry.error = errorMessage;
if (!response) {
entry.status = "failed";
return;
}
entry.status = response.ok ? "success" : "failed";
entry.httpStatus = response.status;
entry.statusText = response.statusText || "";
entry.responseHeaders = headersToObject(response.headers);
entry.responseBody = responseBody.value;
entry.responseBodyTruncated = responseBody.truncated;
}
function appendBackgroundDebugEntry(debugRequests, entry) {
if (Array.isArray(debugRequests)) {
debugRequests.push(entry);
}
}
async function readResponsePayload(response) {
try {
return {
text: await response.text(),
error: ""
};
} catch (error) {
return {
text: `Unable to read response body: ${error.message || "unknown error"}`,
error: error.message || "unknown error"
};
}
}
function parseJsonResponse(text, requestName) {
const normalizedText = String(text)
.replace(/^\uFEFF/, "")
.trimStart()
.replace(/^\)\]\}',?\s*/, "")
.replace(/^while\s*\(1\);\s*/, "");
try {
return JSON.parse(normalizedText);
} catch (error) {
const invalidJsonError = new Error(`${requestName} returned an invalid JSON response.`);
invalidJsonError.code = "INVALID_JSON_RESPONSE";
throw invalidJsonError;
}
}
function createDebugBody(text) {
if (text.length > DEBUG_BODY_LIMIT) {
return {
value: `${text.slice(0, DEBUG_BODY_LIMIT)}\n... truncated ${text.length - DEBUG_BODY_LIMIT} characters`,
truncated: true
};
}
return {
value: text || "(empty response body)",
truncated: false
};
}
function normalizeDebugHeaders(headers) {
const normalized = {};
Object.entries(headers || {}).forEach(([key, value]) => {
normalized[key] = String(value);
});
return normalized;
}
function headersToObject(headers) {
const values = {};
if (headers && typeof headers.forEach === "function") {
headers.forEach((value, key) => {
values[key] = value;
});
}
return values;
}
function formatDebugRequestBody(body) {
if (!body) {
return "";
}
try {
return JSON.stringify(JSON.parse(body), null, 2);
} catch (error) {
return String(body);
}
}
return { fetchJson, createBackgroundDebugEntry, completeBackgroundDebugEntry, appendBackgroundDebugEntry, readResponsePayload, parseJsonResponse, createDebugBody, normalizeDebugHeaders, headersToObject, formatDebugRequestBody };
}

View File

@@ -0,0 +1,75 @@
function createSpaAccounts({
ACCOUNT_PLANS_PAYLOAD,
ACCOUNT_PLANS_URL,
ACCOUNT_SUMMARY_URL,
extractAccountPlans,
fetchJson,
findAccountPlanId,
normalizeMessageAuthorization
}) {
async function requestAccountPlans(message) {
const partyNumber = typeof message.partyNumber === "string" ? message.partyNumber.trim() : "";
const accountSummaryAuthorization = normalizeMessageAuthorization(
message.authorizations && message.authorizations.accountSummary,
"account-ti-authorization"
);
const accountPlansAuthorization = normalizeMessageAuthorization(
message.authorizations && message.authorizations.accountPlans,
"g2m-authorization"
);
const debugRequests = [];
if (!partyNumber) {
throw new Error("Customer Party Number is required.");
}
if (!accountSummaryAuthorization.value) {
throw new Error("ACCP-TS authorization is unavailable. requestConsumerTokenService must complete first.");
}
if (!accountPlansAuthorization.value) {
throw new Error("OCICONS-TS authorization is unavailable. requestConsumerTokenService must complete first.");
}
try {
const accountSummary = await fetchJson(ACCOUNT_SUMMARY_URL, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
[accountSummaryAuthorization.name]: accountSummaryAuthorization.value,
"Content-Type": "application/json"
},
body: JSON.stringify([{ key: "regId", value: partyNumber }])
}, "requestAccountSummary", debugRequests);
const accountPlanId = findAccountPlanId(accountSummary);
if (!accountPlanId) {
throw new Error("Account plan id was not found in requestAccountSummary.");
}
const accountPlansResponse = await fetchJson(`${ACCOUNT_PLANS_URL}/${encodeURIComponent(String(accountPlanId))}`, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
[accountPlansAuthorization.name]: accountPlansAuthorization.value,
"Content-Type": "application/json"
},
body: JSON.stringify(ACCOUNT_PLANS_PAYLOAD)
}, "requestAccountPlans", debugRequests);
return {
ok: true,
accountPlanId: String(accountPlanId),
plans: extractAccountPlans(accountPlansResponse),
debugRequests
};
} catch (error) {
error.debugRequests = debugRequests;
throw error;
}
}
return { requestAccountPlans };
}

View File

@@ -0,0 +1,58 @@
function createSpaConsumption({
CONSUMPTION_TREND_REQUESTS,
CONSUMPTION_TREND_URL,
fetchJson,
normalizeMessageAuthorization
}) {
async function requestConsumptionTrend(message) {
const subPlanNum = typeof message.subPlanNum === "string" ? message.subPlanNum.trim() : "";
const period = typeof message.period === "string" ? message.period.toLowerCase() : "";
const requestName = CONSUMPTION_TREND_REQUESTS[period];
const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization");
const debugRequests = [];
if (!subPlanNum) {
throw new Error("Sub plan number is required for the consumption trend.");
}
if (!requestName) {
throw new Error(`Unsupported consumption trend period: ${period || "(missing)"}.`);
}
if (!authorization.value) {
throw new Error("OCICONS-TS authorization is unavailable for the consumption trend.");
}
const url = `${CONSUMPTION_TREND_URL}/${encodeURIComponent(subPlanNum)}?quarter=All&timePeriod=${period}`;
try {
const response = await fetchJson(url, {
method: "GET",
credentials: "include",
cache: "no-store",
headers: {
Accept: "application/json",
"G2m-Authorization": authorization.value
}
}, requestName, debugRequests);
const items = Array.isArray(response)
? response
: Array.isArray(response && response.items)
? response.items
: [];
return {
ok: true,
subPlanNum,
period,
items,
debugRequests
};
} catch (error) {
error.debugRequests = debugRequests;
throw error;
}
}
return { requestConsumptionTrend };
}

View File

@@ -0,0 +1,61 @@
function createSpaResponseShapes() {
function findNestedObject(value, predicate, visited = new Set()) {
if (!value || typeof value !== "object" || visited.has(value)) {
return null;
}
visited.add(value);
if (!Array.isArray(value) && predicate(value)) {
return value;
}
for (const child of Object.values(value)) {
const match = findNestedObject(child, predicate, visited);
if (match) {
return match;
}
}
return null;
}
function findAccountPlanId(response) {
const account = findNestedObject(response, (value) => (
Object.prototype.hasOwnProperty.call(value, "id")
&& value.id !== null
&& value.id !== undefined
&& value.id !== ""
));
return account ? account.id : "";
}
function extractAccountPlans(response) {
const plans = [];
const visited = new Set();
const visit = (value) => {
if (!value || typeof value !== "object" || visited.has(value)) {
return;
}
visited.add(value);
if (!Array.isArray(value) && (
Object.prototype.hasOwnProperty.call(value, "planType")
|| Object.prototype.hasOwnProperty.call(value, "subPlanNum")
)) {
plans.push(value);
return;
}
Object.values(value).forEach(visit);
};
visit(response);
return plans;
}
return { findNestedObject, findAccountPlanId, extractAccountPlans };
}

12
src/shared/messages.js Normal file
View File

@@ -0,0 +1,12 @@
/** Stable wire names. All payloads remain plain, serializable objects. */
function createMessageTypes() {
return Object.freeze({
getXsrfToken: "opportunitiesExtension.getXsrfToken",
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress",
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
});
}