Updated the calendar to improve opportunity display and navigation

This commit is contained in:
2026-10-27 03:22:10 -03:00
parent 7606643ff4
commit 1d5b28e95b
51 changed files with 1486 additions and 1237 deletions

View File

@@ -19,6 +19,7 @@ function createAuthContentSession({
return token;
})
.catch((error) => {
authSessionState.tokenRelayRequest = null;
authSessionState.accessToken = "";
authSessionState.principal = "";
authSessionState.authStatus = AUTH_STATUS.unauthenticated;

View File

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

View File

@@ -1,33 +0,0 @@
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

@@ -34,17 +34,27 @@ function createAuthTokenRelay({
}
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
const controller = environment.AbortController ? new environment.AbortController() : null;
let timeoutId;
const timeout = new Promise((_, reject) => {
if (!environment.setTimeout) return;
timeoutId = environment.setTimeout(() => {
reject(new Error("Token relay timed out after 30 seconds. Please try again."));
controller?.abort();
}, 30000);
});
try {
const response = await fetch(url, {
const response = await Promise.race([fetch(url, {
method: "GET",
credentials: "include",
...(controller ? { signal: controller.signal } : {}),
headers: {
"x-xsrf-token": xsrfTokenSource.token
}
});
}), timeout]);
const responseBody = await readResponseBodyForDebug(response);
const responseBody = await Promise.race([readResponseBodyForDebug(response), timeout]);
if (!response.ok) {
logRequestSuccess(requestEntry, response, responseBody);
@@ -63,8 +73,9 @@ function createAuthTokenRelay({
let responseData;
try {
responseData = await response.json();
responseData = await Promise.race([response.json(), timeout]);
} catch (error) {
if (controller?.signal.aborted) throw error;
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted) {
@@ -109,6 +120,8 @@ function createAuthTokenRelay({
} catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed.");
throw error;
} finally {
if (timeoutId !== undefined) environment.clearTimeout?.(timeoutId);
}
}
}

View File

@@ -17,6 +17,7 @@ function createBackgroundApplication(overrides = {}) {
getHttp: () => httpBackgroundJson
});
const spaCurrentUser = createSpaCurrentUser({
sessionStorage: (environment.browser || environment.chrome)?.storage?.session,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
fetchInTab: (tab, logs) => spaPageClient.requestIdentity(tab, logs),
openSessionTab: sender => authConsumerSession.openConsumerTokenSessionTab(sender),

View File

@@ -5,7 +5,7 @@ function createContentApplication(overrides = {}) {
navigator: globalThis.navigator,
crypto: globalThis.crypto,
get localStorage() { return globalThis.localStorage; },
fetch: globalThis.fetch,
fetch: globalThis.fetch && ((...args) => globalThis.fetch.call(undefined, ...args)),
browser: globalThis.browser,
chrome: globalThis.chrome,
MutationObserver: globalThis.MutationObserver,
@@ -13,8 +13,9 @@ function createContentApplication(overrides = {}) {
requestAnimationFrame: globalThis.requestAnimationFrame,
performance: globalThis.performance,
cancelAnimationFrame: globalThis.cancelAnimationFrame,
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
setTimeout: globalThis.setTimeout && ((...args) => globalThis.setTimeout.call(undefined, ...args)),
clearTimeout: globalThis.clearTimeout && ((...args) => globalThis.clearTimeout.call(undefined, ...args)),
AbortController: globalThis.AbortController,
matchMedia: globalThis.matchMedia,
...overrides
};
@@ -28,7 +29,6 @@ function createContentApplication(overrides = {}) {
const contracts = createContentContracts();
const searchableSelect = createSearchableSelect({ environment });
const authSessionState = createAuthSessionState(contracts);
const authSalesCommonState = createAuthSalesCommonState();
const opportunitiesQueryState = createOpportunitiesQueryState();
const tableSorting = createTableSorting({ environment });
const orgChartIcons = createOrgChartIcons({ environment });
@@ -57,11 +57,10 @@ function createContentApplication(overrides = {}) {
const preferencesState = createPreferencesState(contracts);
const activityCache = createActivityCache({ authSessionState, preferencesState, environment });
const productsContentState = createProductsContentState();
const aiSummaryState = createAiSummaryState();
const consumptionPlansState = createConsumptionPlansState();
const consumptionTrendState = createConsumptionTrendState();
const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts);
const debugState = createDebugState();
const debugState = createDebugState(environment);
const columns = createOpportunitiesColumns({
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args),
@@ -227,11 +226,6 @@ function createContentApplication(overrides = {}) {
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({
authSessionState,
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
@@ -279,6 +273,7 @@ function createContentApplication(overrides = {}) {
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
});
const fusionOpportunities = createFusionOpportunities({
resetDebugRequests: () => debugState.reset(),
OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT,
OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL,
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
@@ -370,11 +365,14 @@ function createContentApplication(overrides = {}) {
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
});
const opportunityCalendar = createOpportunityCalendar({
productTypeSelection: opportunitiesFilterState.selectedProductTypes,
onProductTypeChange: () => opportunitiesTable.renderOpportunitiesTable(),
createViewIcon: view => contentIcons.createViewIcon(view),
environment, loadLibrary: () => loadOpportunityCalendarLibrary(),
getState: () => opportunitiesTableState.opportunitiesTableState,
getItems: () => opportunitiesFilters.getSortedOpportunities(),
getVersion: () => opportunitiesQueryState.periodRequestVersion,
getRange: () => opportunitiesQueryState.dateRange,
formatRevenue: item => opportunitiesFormatters.formatRevenue(item),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
getOwnerName: item => opportunitiesFormatters.getOwnerName(item),
@@ -429,6 +427,13 @@ function createContentApplication(overrides = {}) {
environment
});
const opportunitiesFilters = createOpportunitiesFilters({
matchesProductType: item => {
const selected = opportunitiesFilterState.selectedProductTypes;
if (selected.size === 2) return true;
const result = productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
if (!result || result.error) return false;
return selected.has(productsDetails.isBooking(result.items) ? "Booking" : "Workload");
},
matchesActivities: item => activityFilters.matches(item),
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
@@ -496,7 +501,7 @@ function createContentApplication(overrides = {}) {
const refreshAll = createRefreshAll({
createRefreshIcon: () => contentIcons.createRefreshIcon(),
messageTypes, activityCache, opportunitiesQueryState, productsContentState,
consumptionPlansState, consumptionTrendState, aiSummaryState, environment,
consumptionPlansState, consumptionTrendState, environment,
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
reload: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
onFailure: error => opportunitiesTable.setOpportunitiesTableState({ status: "error", message: error.message || "Unable to refresh cached data." }),
@@ -517,7 +522,8 @@ function createContentApplication(overrides = {}) {
opportunitiesTable.setOpportunitiesTableState({ status: "loading", message: "Refreshing data..." });
}
});
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment });
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment,
retrySession: () => srSessionPreparation.retry() });
const srSessionPreparation = createSrSessionPreparation({
async onReady() {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestResourceUser, emailAddress: currentUser.getUserId() });
@@ -611,13 +617,12 @@ function createContentApplication(overrides = {}) {
});
const productsDetails = createProductsDetails({
createProjectionIndicator: (...args) => rampProjectionView.createIndicator(...args),
openProjection: (products, opportunity, trigger) => rampProjectionView.open(products[0], opportunity, trigger, products),
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
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 rampProjection = createRampProjection();
@@ -645,16 +650,6 @@ function createContentApplication(overrides = {}) {
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
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 customerTableSearch = createCustomerTableSearch({ environment });
const installBaseView = createInstallBaseView({
tableSorting,
@@ -810,13 +805,11 @@ function createContentApplication(overrides = {}) {
opportunityWorkloadActions,
fusionCrmServiceRequests, opportunityCrmServiceRequests,
authSessionState,
authSalesCommonState,
opportunitiesQueryState,
opportunitiesTableState,
opportunitiesFilterState,
preferencesState,
productsContentState,
aiSummaryState,
consumptionPlansState,
consumptionTrendState,
opportunitiesDashboardState,
@@ -828,7 +821,6 @@ function createContentApplication(overrides = {}) {
contentStyles,
authContentSession,
currentUser,
authSalesCommon,
authTokenRelay,
authXsrfContent,
platformContentMessages,
@@ -853,7 +845,6 @@ function createContentApplication(overrides = {}) {
rampProjection,
workloadForecastUpdate,
rampProjectionView,
aiSummaryContent,
consumptionPlansAction,
installBaseAction,
installBaseView,

View File

@@ -81,6 +81,8 @@ function createContentModal({
}
function openOpportunitiesModal() {
ensureExtensionStyles();
const oldDebug = document.getElementById(DEBUG_PANEL_ID);
if (oldDebug) { oldDebug.hidden = true; oldDebug.textContent = ""; }
debugState.debugSecretsVisible = false;
const existingModal = document.getElementById(MODAL_ID);
@@ -101,7 +103,6 @@ function createContentModal({
const overlay = document.createElement("div");
overlay.setAttribute("lang", "en");
debugState.requestLog = [];
authSessionState.tokenRelayRequest = null;
authSessionState.consumerTokenServiceRequest = null;
authSessionState.accessToken = "";

View File

@@ -918,51 +918,6 @@
color: #312d2a;
}
.opportunities-extension-ai-summary {
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: 0 0 12px;
padding: 12px 16px;
border: 1px solid #dedbd7;
border-radius: 6px;
background: #ffffff;
color: #312d2a;
font-size: 13px;
line-height: 1.5;
text-align: left;
white-space: normal;
overflow-wrap: anywhere;
}
.opportunities-extension-ai-summary strong {
display: block;
font-size: 13px;
font-weight: 600;
}
.opportunities-extension-ai-summary p {
margin: 4px 0 0;
font: inherit;
white-space: pre-wrap;
}
.opportunities-extension-ai-summary-error {
color: #b42318;
border-color: #d9928b;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary {
background: #292827;
color: #f6f4f2;
border-color: #5f5a55;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary-error {
color: #ffb4ab;
border-color: #a45c55;
}
.opportunities-extension-products-detail-heading strong {
font-size: 13px;
}

View File

@@ -83,6 +83,22 @@
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] { table-layout: fixed; }
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > th[scope="row"] { padding: 10px 12px; text-align: left; background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-border); }
/* Rate Card must fit its drawer; headings may wrap at spaces, never mid-word. */
/* Overview reserves nearly half its width for the two primary descriptions. */
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] { table-layout: fixed; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="name"] { width: 24%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="customer"] { width: 24%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="optyNumber"] { width: 7%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="owner"] { width: 7%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="revenue"] { width: 8%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="closeDate"] { width: 7%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="status"] { width: 5%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="winProbability"] { width: 4%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="stage"] { width: 6%; }
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="lastUpdateDate"] { width: 8%; }
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-inline: 6px; }
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-sort-button { min-width: 0; gap: 4px; }
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] .opportunities-extension-sort-label { overflow: hidden; text-overflow: ellipsis; }
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] {
min-width: 0;
table-layout: fixed;

View File

@@ -1,103 +0,0 @@
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

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

View File

@@ -26,11 +26,13 @@ function createDebugContentLog({
};
debugState.requestLog.push(entry);
debugState.persist(entry);
renderDebugPanel();
return entry;
}
function logRequestSuccess(entry, response, responseBody) {
if (!debugState.requestLog.includes(entry)) return;
entry.status = response.ok ? "success" : "failed";
entry.completedAt = new Date();
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
@@ -39,14 +41,17 @@ function createDebugContentLog({
entry.responseHeaders = headersToObject(response.headers);
entry.responseBody = responseBody.value;
entry.responseBodyTruncated = responseBody.truncated;
debugState.persist(entry);
renderDebugPanel();
}
function logRequestFailure(entry, errorMessage) {
if (!debugState.requestLog.includes(entry)) return;
entry.status = "failed";
entry.completedAt = new Date();
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
entry.error = errorMessage;
debugState.persist(entry);
renderDebugPanel();
}
@@ -81,6 +86,7 @@ function createDebugContentLog({
};
if (existing) Object.assign(existing, normalized);
else debugState.requestLog.push(normalized);
debugState.persist(existing || normalized);
});
renderDebugPanel();
}

View File

@@ -12,12 +12,13 @@ function createDebugContentView({
const categories = [
["all", "All"], ["authentication", "Authentication"],
["opportunities", "Opportunities"], ["contacts", "Contacts"], ["products", "Products"],
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"],
["consumption", "Consumption"], ["installBase", "Installed Base"],
["serviceRequests", "Service Requests"], ["actions", "Actions"], ["other", "Other"]
];
let selectedCategory = "all";
const expandedIds = new Set();
let returnFocus = null;
let generation = -1;
function getRequestCategory(entry) {
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
@@ -25,7 +26,6 @@ function createDebugContentView({
if (["requestQueryAccountContact", "requestPersistContact"].includes(entry.name)) return "contacts";
if (entry.name === "requestOpptyProducts") return "products";
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
if (entry.name === "requestGenerateAiSummary") return "ai";
if (entry.name === "requestInstallBase") return "installBase";
if (["requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestResourceUser", "requestSrCatalog", "requestSrTokenCatalog", "requestPostServiceRequest"].includes(entry.name)) return "serviceRequests";
@@ -54,6 +54,7 @@ function createDebugContentView({
closeDebugPanel();
return;
}
debugState.reload();
returnFocus = document.activeElement;
// Escape nested stacking contexts and inert containers created by child modals.
modal.append(panel);
@@ -67,6 +68,8 @@ function createDebugContentView({
const panel = document.getElementById(DEBUG_PANEL_ID);
if (!panel) return;
panel.hidden = true;
panel.textContent = "";
panel.onkeydown = null;
if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
returnFocus = null;
}
@@ -78,6 +81,7 @@ function createDebugContentView({
return;
}
if (generation !== debugState.generation) { expandedIds.clear(); selectedCategory = "all"; generation = debugState.generation; }
for (const item of panel.querySelectorAll("details[data-request-id]")) {
const id = item.getAttribute("data-request-id");
if (item.open) expandedIds.add(id);
@@ -102,7 +106,8 @@ function createDebugContentView({
title.textContent = "Debug Requests";
const summary = document.createElement("p");
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`;
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} in the current query history.`;
if (debugState.storageError) summary.textContent += ` ${debugState.storageError}`;
const running = debugState.requestLog.filter(entry => entry.status === "pending").length;
if (running) summary.textContent += ` ${running} running.`;
heading.append(title, summary);
@@ -238,6 +243,9 @@ function createDebugContentView({
heading.append(name, summaryMeta);
item.append(heading);
const renderDetails = () => {
if (!item.open || item.children.length > 1) return;
const lines = [
`${entry.method} ${entry.url}`,
`Started: ${formatDebugTime(entry.startedAt)}`,
@@ -256,7 +264,17 @@ function createDebugContentView({
appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders));
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody, entry.name));
item.append(heading, details);
item.append(details);
};
item.addEventListener("toggle", () => {
if (item.open) { expandedIds.add(String(entry.id)); renderDetails(); }
else {
expandedIds.delete(String(entry.id));
item.querySelector(".opportunities-extension-debug-details")?.remove();
}
});
renderDetails();
list.append(item);
});

View File

@@ -1,6 +1,87 @@
function createDebugState() {
return {
function createDebugState(environment = {}) {
let storage;
let tabId;
try {
storage = environment.localStorage;
const session = environment.window?.sessionStorage;
tabId = session?.getItem("opportunities-extension-debug-tab");
if (!tabId) {
tabId = environment.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
session?.setItem("opportunities-extension-debug-tab", tabId);
}
} catch { storage = null; }
const prefix = `opportunities-extension-debug:${tabId || "memory"}:`;
let timer = null;
const pending = new Map();
const state = {
requestLog: [],
debugSecretsVisible: false
debugSecretsVisible: false,
storageError: "",
generation: 0,
persist,
flush,
reload,
reset
};
function persist(entry) {
if (!state.requestLog.includes(entry)) return;
pending.set(entry.id, entry);
try {
if (timer === null && environment.setTimeout) timer = environment.setTimeout(flush, 100);
else if (!environment.setTimeout) flush();
} catch {
// Debug persistence must never interrupt the request being recorded.
timer = null;
flush();
}
}
function flush() {
if (timer !== null) environment.clearTimeout?.(timer);
timer = null;
if (!storage) { pending.clear(); return; }
try {
// Write only changed records, never stringify all response bodies per request.
for (const [id, entry] of pending) storage.setItem(`${prefix}${id}`, JSON.stringify(entry));
storage.setItem(`${prefix}index`, JSON.stringify({ version: 1, count: state.requestLog.length }));
state.storageError = "";
} catch {
state.storageError = "Local storage is unavailable or full. Requests remain available in memory for this session.";
}
pending.clear();
}
function reload() {
flush();
if (!storage || state.storageError) return;
try {
const index = JSON.parse(storage.getItem(`${prefix}index`) || "null");
if (!index || index.version !== 1 || index.count !== state.requestLog.length) return;
const records = state.requestLog.map(entry => {
const saved = JSON.parse(storage.getItem(`${prefix}${entry.id}`) || "null");
if (!saved || saved.id !== entry.id) throw new Error("Incomplete debug record");
return { ...saved, startedAt: new Date(saved.startedAt), completedAt: saved.completedAt ? new Date(saved.completedAt) : null };
});
// Preserve the handles held by requests which are still in flight.
records.forEach((record, i) => Object.assign(state.requestLog[i], record));
} catch { state.storageError = "Saved debug data could not be read. Showing this session's requests from memory."; }
}
function reset() {
if (timer !== null) environment.clearTimeout?.(timer);
timer = null;
pending.clear();
state.requestLog = [];
state.debugSecretsVisible = false;
state.storageError = "";
state.generation++;
try {
if (!storage) return;
const keys = [];
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i);
if (key?.startsWith(prefix)) keys.push(key);
}
keys.forEach(key => storage.removeItem(key));
storage.setItem(`${prefix}index`, JSON.stringify({ version: 1, count: 0 }));
} catch { state.storageError = "Local storage is unavailable. Requests will be kept in memory for this session."; }
}
return state;
}

View File

@@ -1,4 +1,4 @@
function createActivityProgress({ MODAL_ID, environment }) {
function createActivityProgress({ MODAL_ID, environment, retrySession = () => {} }) {
const { document, setTimeout, clearTimeout, matchMedia } = environment;
const entries = new Map();
function update(key, state, completed = 0, total = null) {
@@ -29,6 +29,15 @@ function createActivityProgress({ MODAL_ID, environment }) {
: state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`;
if (key === "session") entry.badge.textContent = state === "complete" ? "SR session ready" : state === "error" ? "SR session unavailable" : "SR session loading";
entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Product workloads checked for Actions in the selected Reference, regardless of display filters";
if (key === "session" && state === "error") {
const retry = document.createElement("button");
retry.type = "button";
retry.className = "opportunities-extension-session-retry";
retry.textContent = "Retry";
retry.setAttribute("aria-label", "Retry Service Request session initialization");
retry.addEventListener("click", () => { retry.disabled = true; retrySession(); });
entry.badge.append(retry);
}
if (state === "complete") entry.timer = setTimeout(() => {
entry.badge.setAttribute("data-fading", "true");
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;

View File

@@ -16,6 +16,22 @@
border: 1px solid var(--fc-border-color); border-radius: 4px; font: 13px/1.4 Arial, sans-serif;
}
.opportunities-calendar-grid { flex: 1 1 0; min-height: 0; min-width: 0; }
.opportunities-calendar-day-cards { position: absolute; inset: 27px 0 0; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; padding-bottom: 4px; }
.opportunities-calendar-day-card { display: block; width: calc(100% - 8px); padding: 0; text-align: left; font: inherit; }
.opportunities-calendar-day-card:focus-visible { outline: 2px solid #00758f; outline-offset: -2px; }
/* A single vertical sequence of months, with a separate scroll area per day. */
.opportunities-extension-calendar .fc .fc-multimonth { overflow-y: auto; overflow-x: hidden; }
.opportunities-extension-calendar .fc .fc-multimonth-month { width: 100%; }
.opportunities-extension-calendar .fc .fc-multimonth-daygrid-table { height: auto !important; }
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-body tbody { height: auto; }
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-body tr { height: var(--calendar-week-height, 190px); }
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-frame { position: relative; height: var(--calendar-week-height, 190px); min-height: var(--calendar-week-height, 190px); }
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-events {
position: absolute; inset: 27px 0 0; overflow-y: auto; overflow-x: hidden;
margin: 0; padding-bottom: 4px; scrollbar-width: thin;
}
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-event-harness { position: relative !important; top: auto !important; left: auto !important; right: auto !important; }
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-bg { pointer-events: none; }
.opportunities-extension-calendar .fc { min-width: 620px; }
.opportunities-extension-calendar .fc .fc-toolbar { gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.opportunities-extension-calendar .fc .fc-toolbar-title { font-size: 18px; line-height: 26px; letter-spacing: 0; }
@@ -103,3 +119,12 @@
.opportunities-calendar-day-count { top: 25px; }
.fc-daygrid-week-number ~ .opportunities-calendar-day-count { left: 6px; }
}
.opportunities-view-bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--oe-table-heading-border); flex-wrap: wrap; }
.opportunities-view-bar .opportunities-extension-view-tabs { border-bottom: 0; }
.opportunities-product-type-filters { display: flex; gap: 8px; margin-left: auto; padding: 4px 0; }
.opportunities-product-type-filter { border: 1px solid #8f8a85; border-radius: 12px; padding: 3px 9px; background: transparent; color: inherit; font: 600 12px/16px Arial, sans-serif; cursor: pointer; }
.opportunities-product-type-filter[aria-pressed="true"][data-type="Booking"] { color: #714016; background: #fff0df; border-color: #c58b50; }
.opportunities-product-type-filter[aria-pressed="true"][data-type="Workload"] { color: #63358a; background: #f0e8f7; border-color: #bba5cf; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-product-type-filter[aria-pressed="true"][data-type="Booking"] { color: #ffdbb5; background: #4b321e; border-color: #c99760; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-product-type-filter[aria-pressed="true"][data-type="Workload"] { color: #ead5fc; background: #43334f; border-color: #9372ac; }
.opportunities-product-type-filter:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }

View File

@@ -1,6 +1,7 @@
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion,
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, getRange,
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon }) {
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon,
productTypeSelection, onProductTypeChange }) {
const { document } = environment;
let elements = null;
let calendar = null;
@@ -10,34 +11,49 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
let details = null;
let lastEvents = "";
let dayCounts = new Map();
let dayEvents = new Map();
const dayBadges = new Map();
let resizeObserver = null;
let layoutFrame = null;
let eventLimit = 2;
function visibleEventLimit(height, weeks, headerHeight) {
return Math.max(0, Math.min(2, Math.floor((height / Math.max(1, weeks) - headerHeight - 26) / 48)));
}
let positionToday = false;
function scheduleLayout() {
for (const [name, icon] of [["today", "today"], ["dayGridMonth", "calendar"], ["dayGridWeek", "week"]]) {
const button = elements?.calendarHost.querySelector?.(`.fc-${name}-button`);
if (button && !button.querySelector("svg")) button.prepend(createViewIcon(icon));
}
const win = environment.window;
if (!win?.requestAnimationFrame || layoutFrame !== null) return;
layoutFrame = win.requestAnimationFrame(() => {
layoutFrame = null;
if (!calendar || selectedView !== "calendar" || !elements.calendarHost.clientHeight) return;
calendar.updateSize();
const frame = elements.calendarHost.querySelector(".fc-daygrid-day-frame");
const events = frame?.querySelector(".fc-daygrid-day-events");
if (!frame || !events) return;
const scroller = frame.closest(".fc-scroller");
const weeks = elements.calendarHost.querySelectorAll(".fc-daygrid-body tr").length;
if (!scroller || !weeks) return;
elements.calendarHost.style.setProperty("--opportunities-calendar-weeks", String(weeks));
const headerHeight = events.getBoundingClientRect().top - frame.getBoundingClientRect().top;
const limit = visibleEventLimit(scroller.clientHeight, weeks, headerHeight);
if (limit !== eventLimit) { eventLimit = limit; calendar.setOption("dayMaxEvents", limit); }
if (calendar && selectedView === "calendar") {
calendar.updateSize();
if (positionToday && !elements.calendarHost.hidden) {
const scroller = elements.calendarHost.querySelector(".fc-multimonth");
if (!scroller?.clientHeight) return;
const today = dateKey(new Date());
const range = getRange();
const cell = range && today >= range.startCloseDate.slice(0, 10) && today < range.endCloseDate.slice(0, 10)
? elements.calendarHost.querySelector(`[data-date="${today}"]:not(.fc-day-other)`)
: null;
if (cell) {
const first = new Date(`${today}T12:00:00`);
first.setDate(first.getDate() - first.getDay() - 7);
const last = new Date(first);
last.setDate(last.getDate() + 20);
const start = dateKey(first), end = dateKey(last);
// A week crossing a month boundary has two rendered rows. Include both.
const rows = [...scroller.querySelectorAll(".fc-daygrid-body tr")].filter(row =>
[...row.querySelectorAll("[data-date]:not(.fc-day-other)")].some(day => {
const date = day.getAttribute("data-date");
return date >= start && date <= end;
}));
const headings = [...new Set(rows.map(row => row.closest(".fc-multimonth-month")?.querySelector(".fc-multimonth-header")))].filter(Boolean);
const headerSpace = headings.reduce((sum, header) => sum + header.getBoundingClientRect().height, 0);
const height = Math.max(48, Math.floor((scroller.clientHeight - headerSpace - 20) / Math.max(1, rows.length)));
elements.calendarHost.style.setProperty("--calendar-week-height", `${height}px`);
const target = rows[0] || cell;
const inset = (headings[0]?.getBoundingClientRect().height || 0) + 8;
scroller.scrollTop += target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - inset;
} else scroller.scrollTop = 0;
positionToday = false;
}
}
});
}
function dateKey(date) {
@@ -64,6 +80,21 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
badge.setAttribute("aria-label", badge.title);
badge.setAttribute("data-empty", String(count === 0));
}
function renderDayCards(container, key) {
container.textContent = "";
for (const event of dayEvents.get(key) || []) {
const item = event.extendedProps.item;
const button = node("button", `opportunities-calendar-day-card fc-daygrid-event ${event.classNames.join(" ")}`);
button.type = "button";
const card = node("div", "opportunities-calendar-event-content");
const summary = `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/A"} | ${formatRevenue(item)}`;
card.append(node("strong", "", event.title), node("span", "", summary));
button.title = `${event.title}\n${summary}`;
button.append(card);
button.addEventListener("click", () => showDetails(item, button));
container.append(button);
}
}
function node(tag, className, text) {
const el = document.createElement(tag);
el.className = className;
@@ -145,29 +176,10 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
content.append(identity, summary, badges);
showDialog(item.Name || item.OptyNumber || "Opportunity", content, item.OptyNumber ? [link, copy] : [], trigger);
}
function showDayOpportunities({ date, allSegs, jsEvent }) {
jsEvent.preventDefault();
const trigger = jsEvent.currentTarget || elements?.tabs.calendar;
const content = node("div", "opportunities-calendar-day-list");
const controls = [];
const events = new Map(allSegs.map(({ event }) => [event.id, event]));
for (const event of events.values()) {
const item = event.extendedProps.item;
const button = node("button", "opportunities-calendar-day-item"); button.type = "button";
const summary = `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/A"} | ${formatRevenue(item)}`;
button.append(node("strong", "", event.title), node("span", "", summary));
button.title = `${event.title}\n${summary}`;
button.addEventListener("click", () => showDetails(item, trigger));
controls.push(button); content.append(button);
}
showDialog(`${formatDate(date.toISOString().slice(0, 10))} | ${events.size} ${events.size === 1 ? "opportunity" : "opportunities"}`, content, controls, trigger);
// A truthy non-string result prevents FullCalendar from opening its measured popover.
return true;
}
function mount(body, tableSurface) {
resizeObserver?.disconnect();
if (layoutFrame !== null) environment.window?.cancelAnimationFrame(layoutFrame);
layoutFrame = null; eventLimit = 2;
layoutFrame = null;
calendar?.destroy(); calendar = null; lastEvents = ""; anchoredVersion = null; selectedView = "overview";
dayBadges.clear(); dayCounts.clear();
closeDetails(false);
@@ -194,7 +206,26 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
});
tabs[key] = tab; tablist.append(tab);
}
workspace.append(tablist, tableSurface, surface); body.append(workspace);
const viewbar = node("div", "opportunities-view-bar");
viewbar.append(tablist);
if (productTypeSelection) {
const filters = node("div", "opportunities-product-type-filters");
filters.setAttribute("role", "group"); filters.setAttribute("aria-label", "Opportunity type");
for (const type of ["Booking", "Workload"]) {
const button = node("button", "opportunities-product-type-filter", type);
button.type = "button";
button.setAttribute("data-type", type);
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
button.addEventListener("click", () => {
if (productTypeSelection.has(type)) productTypeSelection.delete(type); else productTypeSelection.add(type);
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
onProductTypeChange();
});
filters.append(button);
}
viewbar.append(filters);
}
workspace.append(viewbar, tableSurface, surface); body.append(workspace);
elements = { workspace, tableSurface, surface, message, calendarHost, undated, tabs };
if (environment.window?.ResizeObserver) {
resizeObserver = new environment.window.ResizeObserver(scheduleLayout);
@@ -204,6 +235,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
}
function selectView(view) {
if (!elements || !["overview", "calendar"].includes(view)) return;
if (view === "calendar" && selectedView !== "calendar") positionToday = true;
closeDetails(false); closeSrPopover(); selectedView = view;
elements.tableSurface.hidden = view !== "overview"; elements.surface.hidden = view !== "calendar";
for (const [key, tab] of Object.entries(elements.tabs)) {
@@ -217,41 +249,57 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
if (!elements || selectedView !== "calendar") return;
const { surface, message, calendarHost, undated } = elements;
surface.setAttribute("aria-busy", String(state.status === "loading"));
if (["loading", "error", "empty", "idle"].includes(state.status)) {
if (["loading", "error", "idle"].includes(state.status)) {
message.textContent = state.message || (state.status === "loading" ? "Loading opportunities..." : "No opportunities found.");
message.hidden = false; calendarHost.hidden = true; undated.hidden = true; closeDetails(false); return;
}
message.textContent = items.length ? "" : "No opportunities found."; message.hidden = !!items.length;
calendarHost.hidden = false;
const events = eventsFor(items);
const range = getRange();
if (!range) { calendarHost.hidden = true; return; }
const start = range.startCloseDate.slice(0, 10);
const end = range.endCloseDate.slice(0, 10);
const visibleRange = { start, end }; // Fiscal ranges end at the next period's start (exclusive).
const events = eventsFor(items).filter(event => event.start >= start && event.start < end);
dayCounts = new Map();
for (const event of events) dayCounts.set(event.start, (dayCounts.get(event.start) || 0) + 1);
for (const { badge, key } of dayBadges.values()) updateDayBadge(badge, key);
const anchor = initialDate(state.items);
dayEvents = new Map();
for (const event of events) {
dayCounts.set(event.start, (dayCounts.get(event.start) || 0) + 1);
if (!dayEvents.has(event.start)) dayEvents.set(event.start, []);
dayEvents.get(event.start).push(event);
}
for (const { badge, key, cards } of dayBadges.values()) { updateDayBadge(badge, key); renderDayCards(cards, key); }
const anchor = start;
if (!calendar) {
try {
library ||= loadLibrary();
calendar = new library.Calendar(calendarHost, {
initialView: "dayGridMonth", initialDate: anchor, height: "100%", expandRows: true, fixedWeekCount: false,
initialView: "referenceMonths", initialDate: anchor, height: "100%", fixedWeekCount: false,
views: { referenceMonths: { type: "multiMonth", visibleRange, dayMaxEvents: false, dayMaxEventRows: false } },
visibleRange, validRange: visibleRange, showNonCurrentDates: false,
multiMonthMaxColumns: 1, multiMonthMinWidth: 0,
multiMonthTitleFormat: { month: "long", year: "numeric" },
datesSet: scheduleLayout,
locale: "en", firstDay: 0, editable: false, selectable: false, eventInteractive: true,
headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,dayGridWeek" },
buttonText: { today: "Today", month: "Month", week: "Week" },
buttonHints: { prev: "Previous", next: "Next", today: "Today", dayGridMonth: "Month", dayGridWeek: "Week" },
dayMaxEvents: 2, moreLinkText: count => `... +${count}`, displayEventTime: false,
moreLinkHint: count => `View ${count} more ${count === 1 ? "opportunity" : "opportunities"}`,
moreLinkClick: showDayOpportunities,
headerToolbar: false,
dayMaxEvents: false, dayMaxEventRows: false, displayEventTime: false,
weekNumbers: true,
weekNumberContent: ({ date }) => ({ domNodes: [node("span", "opportunities-calendar-fiscal-week", fiscalWeekLabel(date))] }),
dayCellDidMount: ({ el, date }) => {
const badge = node("span", "opportunities-calendar-day-count");
const key = dateKey(date);
updateDayBadge(badge, key);
el.querySelector(".fc-daygrid-day-frame").append(badge);
dayBadges.set(el, { badge, key });
const cards = node("div", "opportunities-calendar-day-cards");
cards.tabIndex = 0;
cards.setAttribute("role", "region");
cards.setAttribute("aria-label", `Opportunities for ${formatDate(key)}`);
renderDayCards(cards, key);
el.querySelector(".fc-daygrid-day-frame").append(badge, cards);
dayBadges.set(el, { badge, key, cards });
},
dayCellWillUnmount: ({ el }) => { dayBadges.get(el)?.badge.remove(); dayBadges.delete(el); },
eventOrder: "title", eventDisplay: "block", events,
dayCellWillUnmount: ({ el }) => { dayBadges.get(el)?.badge.remove(); dayBadges.get(el)?.cards.remove(); dayBadges.delete(el); },
// MultiMonth imposes its own event limit. Render all cards in our scrollable day region.
eventOrder: "title", eventDisplay: "none", events,
eventContent: ({ event }) => {
const item = event.extendedProps.item;
const card = node("div", "opportunities-calendar-event-content");
@@ -274,7 +322,15 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
}
const signature = JSON.stringify(events);
calendar.batchRendering(() => {
if (anchoredVersion !== getVersion()) { calendar.gotoDate(anchor); anchoredVersion = getVersion(); closeDetails(false); }
if (anchoredVersion !== getVersion()) {
positionToday = true;
calendar.setOption("views", { referenceMonths: { type: "multiMonth", visibleRange, dayMaxEvents: false, dayMaxEventRows: false } });
calendar.setOption("visibleRange", visibleRange);
calendar.setOption("validRange", visibleRange);
calendar.gotoDate(anchor); anchoredVersion = getVersion(); closeDetails(false);
const scroller = calendarHost.querySelector?.(".fc-multimonth");
if (scroller) scroller.scrollTop = 0;
}
if (signature !== lastEvents) { calendar.removeAllEvents(); calendar.addEventSource(events); lastEvents = signature; closeDetails(false); }
});
const missing = items.filter(item => !closeDate(item.EffectiveDate));
@@ -288,5 +344,5 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
}
scheduleLayout();
}
return { mount, render, selectView, closeDetails, closeDate, eventsFor, initialDate, fiscalWeekLabel, visibleEventLimit };
return { mount, render, selectView, closeDetails, closeDate, eventsFor, initialDate, fiscalWeekLabel };
}

View File

@@ -7,7 +7,7 @@ function createOpportunitiesColumns({ formatOracleResponseDate, formatRevenue, f
{ 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: "winProbability", label: "Prob.", 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) }
];

View File

@@ -23,6 +23,7 @@ function createOpportunitiesController({
}) {
async function requestOpportunitiesForPeriod(period) {
const dateRange = getFiscalPeriodRange(period, new Date());
opportunitiesQueryState.dateRange = dateRange;
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
resetWorkloadActions();
resetWorkloads();

View File

@@ -1,5 +1,6 @@
function createOpportunitiesFilterState({ STAGE_OPTIONS, STATUS_OPTIONS } = {}) {
return {
selectedProductTypes: new Set(["Booking", "Workload"]),
selectedStages: new Set(STAGE_OPTIONS),
selectedStatuses: new Set(STATUS_OPTIONS),
selectedCustomerKeys: new Set(),

View File

@@ -25,6 +25,7 @@ function createOpportunitiesFilters({
saveFilterPreferences,
stringifySearchValue,
matchesActivities,
matchesProductType = () => true,
environment
}) {
const { document } = environment;
@@ -345,7 +346,8 @@ function createOpportunitiesFilters({
&& (allCustomers || opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)))
&& (allOwners || opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)))
&& matchesTableSearch(item, normalizedSearch)
&& matchesActivities(item);
&& matchesActivities(item)
&& matchesProductType(item);
});
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);

View File

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

View File

@@ -1,6 +1,6 @@
function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, resetRelatedData,
opportunitiesQueryState, productsContentState, consumptionPlansState, consumptionTrendState,
aiSummaryState, reload, onFailure, notify, createRefreshIcon, environment }) {
reload, onFailure, notify, createRefreshIcon, environment }) {
let pending = null;
function refresh(period) {
if (pending) return pending;
@@ -13,7 +13,6 @@ function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, res
consumptionTrendState.consumptionTrendCache = new Map();
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
aiSummaryState.aiSummariesByOptyNumber = new Map();
resetRelatedData();
pending = (async () => {
await activityCache.invalidate();

View File

@@ -1,22 +1,30 @@
function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, sendRequest, reportProgress, appendLog, onReady = async () => {} }) {
let pending = null;
let failed = false;
function ensure() {
if (pending) return pending;
pending = (async () => {
failed = false;
try {
await ensureCurrentUser();
if (!allowsCrmActivities()) return;
reportProgress("running");
const result = await sendRequest();
// Navigation establishes SR cookies independently of the SPA identity.
// Consume user-dependent data only once both preparations have finished.
const [, result] = await Promise.all([ensureCurrentUser(), sendRequest()]);
appendLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Session initialization failed.");
if (!allowsCrmActivities()) { reportProgress("idle"); return; }
reportProgress("complete");
await onReady();
} catch {
failed = true;
reportProgress("error");
}
})();
return pending;
}
return { ensure };
function retry() {
if (failed) pending = null;
return ensure();
}
return { ensure, retry };
}

View File

@@ -625,3 +625,9 @@
}
.opportunities-extension-option-description { display: block; font-size: 12px; line-height: 1.4; margin-top: 4px; white-space: normal; overflow-wrap: anywhere; opacity: .85; }
.opportunities-extension-action-form-dialog.opportunities-extension-internal-sr-dialog { width: min(760px, 100%); }
.opportunities-extension-session-retry {
margin-left: 8px; padding: 2px 6px; border: 1px solid currentColor;
border-radius: 4px; background: transparent; color: inherit; font: inherit; cursor: pointer;
}
.opportunities-extension-session-retry:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-session-retry:disabled { opacity: .6; cursor: wait; }

View File

@@ -171,16 +171,16 @@ function createOpportunitiesTable({
const productsResult = item.OptyNumber && productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
if (productsResult) {
productActions.append(createProductsCountButton(item.OptyNumber, productsResult));
const rampBadge = createProductsRampBadge(productsResult);
const rampBadge = createProductsRampBadge(productsResult, item);
if (rampBadge) productActions.append(rampBadge);
}
const projectionIndicator = createProjectionIndicator(productsResult, item,
productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber)));
if (projectionIndicator) productActions.append(projectionIndicator);
const srBadge = createCrmServiceRequestBadge(item.OptyNumber);
if (srBadge) productActions.append(srBadge);
const actionsBadge = createWorkloadActionsBadge(item.OptyNumber);
if (actionsBadge) productActions.append(actionsBadge);
const projectionIndicator = createProjectionIndicator(productsResult, item,
productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber)));
if (projectionIndicator) productActions.append(projectionIndicator);
nameCellContent.append(opportunityLink, productActions);
cell.append(nameCellContent);
} else if (column.key === "optyNumber" && item.OptyNumber) {

View File

@@ -129,7 +129,7 @@ function createOrgChartView({ environment, tableSorting, model, loadLayout, icon
person = entry.nodes.find(node => node.id === person.id) || person;
const overlay = el("div", "oe-org-confirm"), panel = el("section", "opportunities-extension-action-form-dialog oe-org-dialog oe-contact-opportunities");
panel.setAttribute("role", "dialog"); panel.setAttribute("aria-modal", "true"); panel.setAttribute("aria-labelledby", "oe-contact-opportunities-title");
const header = el("header", "oe-org-editor-header"), title = el("h3", "", "Opportunities — " + person.name); title.id = "oe-contact-opportunities-title";
const header = el("header", "oe-org-editor-header"), title = el("h3", "", "Opportunities | " + person.name); title.id = "oe-contact-opportunities-title";
let closed = false, offset = 0, partyNumber = person.PartyNumber;
const current = () => !closed && active === entry;
function finish() { closed = true; overlay.remove(); entry.confirmation = null; entry.contactCleanup = null; dialog.inert = false; trigger.focus(); }

View File

@@ -1,27 +1,45 @@
function createProductsDetails({
OPPORTUNITIES_COLUMNS,
setBadgeContent,
createAiSummaryAlert,
createProjectionIndicator,
openProjection,
formatOracleResponseDate,
productsContentState,
renderOpportunitiesTable,
requestGenerateAiSummary,
environment
}) {
const { document } = environment;
function createProductsRampBadge(productsResult) {
const values = (Array.isArray(productsResult.items) ? productsResult.items : [])
function isBooking(products) {
return Boolean(products?.length) && products.every(item => !String(item?.WorkloadName_c ?? "").trim() || String(item.WorkloadName_c).trim() === "-");
}
function createProductsRampBadge(productsResult, opportunity) {
const products = Array.isArray(productsResult.items) ? productsResult.items : [];
if (isBooking(products)) {
const badge = document.createElement("span");
badge.className = "opportunities-extension-products-ramp";
badge.setAttribute("data-booking", "true");
badge.textContent = "Booking";
badge.title = "Booking: no product has a Workload assigned.";
badge.setAttribute("aria-label", badge.title);
return badge;
}
const values = products
.map(item => item && item.RampMonths_c)
.map(value => value === null || value === undefined || String(value).trim() === "" ? "N/A" : String(value).trim())
.filter((value, index, items) => value !== "N/A" || items.indexOf(value) === index);
if (!values.length) return null;
const badge = document.createElement("span");
const badge = document.createElement("button");
badge.type = "button";
badge.className = "opportunities-extension-products-ramp";
badge.setAttribute("data-missing", String(values.includes("N/A")));
setBadgeContent(badge, "ramp", values.join(" | "));
badge.title = `Ramp Months: ${values.join(", ")}`;
badge.setAttribute("aria-label", badge.title);
badge.setAttribute("aria-haspopup", "dialog");
badge.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
openProjection(productsResult.items, opportunity, badge);
});
return badge;
}
@@ -29,7 +47,6 @@ function createProductsDetails({
const number = String(optyNumber);
if (productsContentState.expandedProductOptyNumbers.has(number)) return;
productsContentState.expandedProductOptyNumbers.add(number);
requestGenerateAiSummary(number);
renderOpportunitiesTable();
}
@@ -86,7 +103,6 @@ function createProductsDetails({
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");
@@ -181,5 +197,5 @@ function createProductsDetails({
return Number.isFinite(number) ? `${number}%` : "-";
}
return { expandProducts, createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
return { isBooking, expandProducts, createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
}

View File

@@ -19,6 +19,15 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
}
function createIndicator(product, opportunity) {
const result = compare(product);
if (!product?.workload) {
const disabled = node("button", "opportunities-extension-icon-button opportunities-extension-projection-alert");
disabled.type = "button";
disabled.disabled = true;
disabled.title = "No Workload is available for this product.";
disabled.setAttribute("aria-label", disabled.title);
disabled.append(createAlertIcon());
return disabled;
}
if (result.status !== "divergent") {
const text = node("span", "opportunities-extension-projection-status", result.status === "matched" ? "OK" : "-");
text.title = result.status === "matched" ? "Monthly forecast matches the ramp calculation." : result.reason;
@@ -55,10 +64,10 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
});
return indicator;
}
function open(product, opportunity, trigger) {
function open(product, opportunity, trigger, products = []) {
const host = document.getElementById(MODAL_ID);
const result = compare(product);
if (!host || host.hidden || result.status !== "divergent") return;
if (!host || host.hidden || !product) return;
close(false);
const modal = node("section", "opportunities-extension-action-form-modal opportunities-extension-projection-modal");
modal.setAttribute("role", "dialog");
@@ -78,12 +87,25 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
dismiss.addEventListener("click", () => close());
header.append(heading, dismiss);
const body = node("div", "opportunities-extension-projection-body");
let productSelect = null;
if (products.length > 1) {
const label = node("label", "", "Product ");
productSelect = node("select", "opportunities-extension-projection-product");
productSelect.setAttribute("aria-label", "Product");
products.forEach((item, index) => {
const option = node("option", "", `${item.ProdGroupName || "Product"} | ${item.WorkloadName_c || item.RevnId || index + 1}`);
option.value = String(index); option.selected = item === product;
productSelect.append(option);
});
productSelect.addEventListener("change", () => open(products[Number(productSelect.value)], opportunity, trigger, products));
label.append(productSelect); body.append(label);
}
const details = node("dl", "opportunities-extension-projection-context");
const currency = product.RevnAmountCurcyCode || "USD";
for (const [label, value] of [
["Customer", opportunity.CustomerAccount?.PartyUniqueName || "-"], ["Product", product.ProdGroupName || "-"],
["Annual value", formatAmount(result.annual, currency)], ["Ramp months", String(result.ramp)],
["Consumption start", formatDate(result.start)], ["First month", `${result.firstMonthDays}/${result.firstMonthTotalDays} days`]
["Annual value", result.annual == null ? "-" : formatAmount(result.annual, currency)], ["Ramp months", String(result.ramp ?? "-")],
["Consumption start", result.start ? formatDate(result.start) : "-"], ["First month", result.firstMonthDays == null ? "-" : `${result.firstMonthDays}/${result.firstMonthTotalDays} days`]
]) details.append(node("dt", "", label), node("dd", "", value));
body.append(details);
const summary = node("p", "opportunities-extension-projection-summary", `${result.divergentCount} month${result.divergentCount === 1 ? "" : "s"} with a difference`);
@@ -128,6 +150,7 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
body.append(errorMessage);
const footer = node("footer", "opportunities-extension-projection-footer");
const updateButton = node("button", "opportunities-extension-projection-update", "Update estimates");
updateButton.disabled = result.status !== "divergent";
updateButton.type = "button";
footer.append(node("span", "", "Difference = forecast - calculated."), updateButton);
dialog.append(header, body, footer);
@@ -212,7 +235,7 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key === "Tab") {
const controls = [dismiss, viewport, ...table.querySelectorAll("button"), updateButton].filter(control => !control.disabled);
const controls = [dismiss, productSelect, viewport, ...table.querySelectorAll("button"), updateButton].filter(control => control && !control.disabled);
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();

View File

@@ -4,6 +4,12 @@
.opportunities-extension-projection-alert svg { display: block; flex: none; width: 19px; height: 19px; }
.opportunities-extension-cell-actions > .opportunities-extension-opportunity-projection-alert { width: 20px; height: 20px; flex-basis: 20px; }
.opportunities-extension-projection-alert:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-modal .opportunities-extension-projection-alert:disabled { color: #8f8a85; opacity: .55; cursor: not-allowed; background: transparent; }
.opportunities-extension-products-ramp { cursor: pointer; font-family: inherit; }
.opportunities-extension-products-ramp[data-booking="true"] { cursor: default; color: #714016; background: #fff0df; border-color: #c58b50; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-ramp[data-booking="true"] { color: #ffdbb5; background: #4b321e; border-color: #c99760; }
.opportunities-extension-products-ramp:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-projection-product { max-width: 100%; margin: 0 0 16px; padding: 8px; font: inherit; color: inherit; background: inherit; }
.opportunities-extension-action-form-dialog.opportunities-extension-projection-dialog { width: min(850px, 100%); display: flex; flex-direction: column; overflow: hidden; }
.opportunities-extension-projection-dialog > header { flex: 0 0 auto; padding: 18px 20px; }
.opportunities-extension-projection-dialog .opportunities-extension-action-form-heading { min-width: 0; overflow-wrap: anywhere; }

View File

@@ -8,6 +8,7 @@ function createFusionOpportunities({
maskToken,
getOpportunityTypeValues,
readResponseBodyForDebug,
resetDebugRequests = () => {},
environment
}) {
const { fetch } = environment;
@@ -35,6 +36,8 @@ function createFusionOpportunities({
if (!isCurrentPeriodRequest(requestVersion)) return null;
const payload = createOpportunitiesQueryPayload(dateRange, offset, savedSearchNumber);
const requestBody = JSON.stringify(payload);
// One history per query, preserving all pages of the same result set.
if (page === 1) resetDebugRequests();
const requestEntry = logRequestStart({
name: "requestOpportunities",
method: "POST",

View File

@@ -85,7 +85,7 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
let session = sessions.get(key);
if (!session) {
const details = {
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/",
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_semc/live/webApps/Dashboard/?page=shell&shell=main&main=manageresourcerequests",
active: false
};
if (Number.isInteger(sender?.tab?.windowId)) details.windowId = sender.tab.windowId;
@@ -102,8 +102,8 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
let tab;
try {
tab = await session.tab;
const deadline = Date.now() + 60000;
for (let attempt = 0; attempt < 240 && Date.now() < deadline; attempt += 1) {
const deadline = Date.now() + 70000;
for (let attempt = 0; attempt < 280 && Date.now() < deadline; attempt += 1) {
const pendingCookies = initialize && attempt === 0
? sessionCookies(sender) : waitForSessionCookies(session, sender);
const cookies = await (initialize ? Promise.race([session.cookieReady, pendingCookies]) : pendingCookies);
@@ -122,7 +122,7 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
if (error.httpStatus !== 401) throw error;
}
}
throw new Error("Service Request session could not be established after 60 seconds. Sign in to Oracle and retry.");
throw new Error("Service Request session could not be established after 70 seconds. Sign in to Oracle and retry.");
} catch (error) {
error.debugRequests = logs;
throw error;

View File

@@ -1,9 +1,26 @@
function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl }) {
function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl, sessionStorage }) {
const pending = new Map();
const identities = new Map();
function requestCurrentUser(sender) {
const key = JSON.stringify([sender?.tab?.windowId ?? null, sender?.tab?.cookieStoreId ?? "", Boolean(sender?.tab?.incognito)]);
// Windows share cookies; Firefox containers and private sessions do not.
const key = JSON.stringify([sender?.tab?.cookieStoreId ?? "", Boolean(sender?.tab?.incognito)]);
if (identities.has(key)) return Promise.resolve({ ok: true, user: identities.get(key), debugRequests: [] });
if (!pending.has(key)) {
pending.set(key, loadCurrentUser(sender).finally(() => pending.delete(key)));
pending.set(key, (async () => {
const storageKey = `opportunities-extension-current-user:${key}`;
try {
const stored = (await sessionStorage?.get(storageKey))?.[storageKey];
if (stored && typeof stored.id === "string" && stored.id.trim() && typeof stored.name === "string" && stored.name.trim()
&& Array.isArray(stored.roles) && stored.roles.every(role => typeof role === "string" && role.trim())) {
identities.set(key, stored);
return { ok: true, user: stored, debugRequests: [] };
}
} catch { /* Memory cache still works if session storage is unavailable. */ }
const result = await loadCurrentUser(sender);
identities.set(key, result.user);
try { await sessionStorage?.set({ [storageKey]: result.user }); } catch { /* Keep the successful in-memory identity. */ }
return result;
})().finally(() => pending.delete(key)));
}
return pending.get(key);
}