diff --git a/README.md b/README.md index 7f3ac4b..101d0ed 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ The build recreates `dist/chromium` and `dist/firefox` from the same source. - Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators. - Opportunity type view preferences persisted in the browser. - Background product retrieval with configurable caching, progress tracking, and expandable product details. +- Cloud consumption account-plan lookup from each opportunity, displayed in a tabbed detail modal. - Light and dark themes based on Oracle Redwood. - Separate outputs for Chromium/Edge and Firefox. diff --git a/dist/chromium/background.js b/dist/chromium/background.js index 87093ac..d8f0fa1 100644 --- a/dist/chromium/background.js +++ b/dist/chromium/background.js @@ -17,6 +17,31 @@ 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 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 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", @@ -96,9 +121,413 @@ return true; } + if (message.type === "opportunitiesExtension.requestConsumerTokenService") { + requestConsumerTokenService() + .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 === "opportunitiesExtension.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; + } + return false; }); + async function requestConsumerTokenService() { + const debugRequests = []; + + try { + const tokenResponse = await fetchJson(CUSTOMER_TOKEN_SERVICE_URL, { + method: "GET", + credentials: "include", + cache: "no-store", + headers: { + Accept: "application/json", + "Cache-Control": "no-cache", + Pragma: "no-cache" + } + }, "requestConsumerTokenService", 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 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; + } + } + + 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) + : "" + }; + } + + 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.`); + } + + return parseJsonResponse(responsePayload.text, requestName); + } + + 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) { + throw new Error(`${requestName} returned an invalid JSON response.`); + } + } + + 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); + } + } + + 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; + } + async function requestOpptyProducts(message, sender) { const requestId = typeof message.requestId === "string" ? message.requestId : ""; const accessToken = typeof message.accessToken === "string" ? message.accessToken : ""; diff --git a/dist/chromium/content.js b/dist/chromium/content.js index e55ed4d..ade6ea6 100644 --- a/dist/chromium/content.js +++ b/dist/chromium/content.js @@ -30,6 +30,7 @@ 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="; @@ -73,10 +74,12 @@ { label: "7 days", value: 168 } ]; let tokenRelayRequest = null; + let consumerTokenServiceRequest = null; let accessToken = ""; let periodRequestVersion = 0; let authStatus = AUTH_STATUS.idle; let requestLog = []; + let debugSecretsVisible = false; let selectedStages = new Set(STAGE_OPTIONS); let selectedStatuses = new Set(STATUS_OPTIONS); let selectedCustomerKeys = new Set(); @@ -98,6 +101,7 @@ cacheHits: 0 }; let productsLastCachedAt = 0; + let accountPlansRequestState = new Map(); let hasSavedCustomerFilter = false; let hasSavedOwnerFilter = false; let customerFilterInitialized = false; @@ -128,7 +132,8 @@ { 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: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }, + { key: "actions", label: "Actions", value: () => "", display: () => "", sortable: false } ]; const ALLOWED_PATHS = [ "/hcmUI/faces/FuseWelcome", @@ -204,6 +209,7 @@ function openOpportunitiesModal() { ensureExtensionStyles(); + debugSecretsVisible = false; const existingModal = document.getElementById(MODAL_ID); @@ -219,6 +225,7 @@ const overlay = document.createElement("div"); requestLog = []; tokenRelayRequest = null; + consumerTokenServiceRequest = null; accessToken = ""; periodRequestVersion = 0; authStatus = AUTH_STATUS.idle; @@ -554,6 +561,7 @@ .then((token) => { accessToken = token; authStatus = AUTH_STATUS.authenticated; + requestConsumerTokenServiceOnce().catch(() => {}); return token; }) .catch((error) => { @@ -571,6 +579,26 @@ return tokenRelayRequest; } + function requestConsumerTokenServiceOnce() { + if (!consumerTokenServiceRequest) { + consumerTokenServiceRequest = sendRuntimeMessage({ + type: "opportunitiesExtension.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 consumerTokenServiceRequest; + } + async function requestTokenRelay() { let refreshResult = null; let refreshAttempted = false; @@ -748,6 +776,7 @@ cacheHits: 0 }; productsLastCachedAt = 0; + accountPlansRequestState = new Map(); renderProductsProgress(); setOpportunitiesTableState({ items: [], @@ -1058,6 +1087,7 @@ cacheHits: 0 }; productsLastCachedAt = 0; + accountPlansRequestState = new Map(); stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); opportunitiesTableState = { items: [], @@ -1183,17 +1213,25 @@ const header = document.createElement("th"); const isSorted = opportunitiesTableState.sortKey === column.key; header.scope = "col"; - header.setAttribute("aria-sort", isSorted ? opportunitiesTableState.sortDirection : "none"); - const sortButton = document.createElement("button"); - sortButton.type = "button"; - sortButton.className = "opportunities-extension-sort-button"; - sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.sortDirection : "none"); - sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.sortDirection}` : ""}`); - sortButton.textContent = column.label; - sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key)); + 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.sortDirection : "none"); + + const sortButton = document.createElement("button"); + sortButton.type = "button"; + sortButton.className = "opportunities-extension-sort-button"; + sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.sortDirection : "none"); + sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.sortDirection}` : ""}`); + sortButton.textContent = column.label; + sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key)); + header.append(sortButton); + } - header.append(sortButton); headerRow.append(header); }); @@ -1222,6 +1260,8 @@ cell.append(createStageBadge(column.display(item))); } else if (column.key === "status") { cell.append(createStatusBadge(column.display(item))); + } else if (column.key === "actions") { + cell.append(createCloudConsumptionButton(item)); } else if (column.key === "name" || column.key === "optyNumber") { const opportunityLink = createDetailLink( column.display(item), @@ -1916,6 +1956,290 @@ return button; } + function createCloudConsumptionButton(opportunity) { + const button = document.createElement("button"); + const optyNumber = String(opportunity.OptyNumber || ""); + const partyNumber = getNestedValue(opportunity, ["CustomerAccount", "PartyNumber"]); + const requestState = 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 cloud consumption" : "View cloud 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; + } + + accountPlansRequestState.set(optyNumber, { status: "loading" }); + openAccountPlansModal(opportunity, [], "loading"); + renderOpportunitiesTable(); + requestConsumerTokenServiceOnce() + .then((authorizations) => sendRuntimeMessage({ + type: "opportunitiesExtension.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."); + } + + 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."; + 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; + } + + 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 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)); + + 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; + }); + }); + tabList.append(tab); + panels.append(panel); + }); + + content.append(tabList, panels); + } + + dialog.append(header, content); + modal.append(dialog); + overlay.append(modal); + modal.querySelector("[role='tab'], .opportunities-extension-icon-button")?.focus(); + } + + 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"; + [96, 124, 108].forEach((width) => { + const tab = document.createElement("span"); + tab.className = "opportunities-extension-account-plans-skeleton-bar"; + tab.style.width = `${width}px`; + 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); + }); + + skeleton.append(announcement, tabs, details); + 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; + } + } + function createProductsCountButton(optyNumber, productsResult) { const normalizedOptyNumber = String(optyNumber); const button = document.createElement("button"); @@ -2342,6 +2666,36 @@ renderDebugPanel(); } + function appendBackgroundRequestLog(entries) { + if (!Array.isArray(entries) || entries.length === 0) { + return; + } + + entries.forEach((entry) => { + requestLog.push({ + id: 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(); @@ -2386,10 +2740,56 @@ } return entries - .map(([key, value]) => `${key}: ${value}`) + .map(([key, value]) => `${key}: ${debugSecretsVisible || !isSensitiveDebugKey(key) + ? value + : formatHiddenDebugValue(value)}`) .join("\n"); } + function formatDebugBody(body) { + const text = body || "(none)"; + + if (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); @@ -2436,11 +2836,29 @@ 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 = `${requestLog.length} request${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(debugSecretsVisible)); + visibilityButton.setAttribute("aria-label", debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"); + visibilityButton.title = debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"; + visibilityButton.append(createDebugVisibilityIcon(debugSecretsVisible)); + visibilityButton.addEventListener("click", () => { + debugSecretsVisible = !debugSecretsVisible; + renderDebugPanel(); + }); + panelHeader.append(heading, visibilityButton); const list = document.createElement("div"); list.className = "opportunities-extension-debug-list"; @@ -2490,15 +2908,49 @@ appendDebugBlock(details, "Request", lines.join("\n")); appendDebugBlock(details, "Request Metadata", formatMetadataBlock(entry.metadata)); appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders)); - appendDebugBlock(details, "Request Body", entry.requestBody || "(none)"); + appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody)); appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders)); - appendDebugBlock(details, "Response Body", entry.responseBody || "(none)"); + appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody)); item.append(heading, details); list.append(item); }); - panel.append(title, summary, list); + 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) { @@ -2668,9 +3120,11 @@ if (modal) { closePreferencesModal(); + closeAccountPlansModal(); modal.hidden = true; } + debugSecretsVisible = false; document.documentElement.classList.remove("opportunities-extension-scroll-lock"); } @@ -2972,6 +3426,14 @@ } 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) { @@ -3764,7 +4226,7 @@ } .opportunities-extension-table th:nth-child(1) { - width: 17%; + width: 16%; } .opportunities-extension-table th:nth-child(2) { @@ -3772,11 +4234,11 @@ } .opportunities-extension-table th:nth-child(3) { - width: 19%; + width: 18%; } .opportunities-extension-table th:nth-child(4) { - width: 12%; + width: 11%; } .opportunities-extension-table th:nth-child(5) { @@ -3800,7 +4262,11 @@ } .opportunities-extension-table th:nth-child(10) { - width: 8%; + width: 7%; + } + + .opportunities-extension-table th:nth-child(11) { + width: 4%; } .opportunities-extension-table td:nth-child(2), @@ -3833,6 +4299,13 @@ white-space: nowrap; } + .opportunities-extension-table td:nth-child(11) { + padding-right: 6px; + padding-left: 6px; + text-align: center; + white-space: nowrap; + } + .opportunities-extension-table tbody tr:hover { background: #f7f6f4; } @@ -4009,6 +4482,46 @@ outline: none; } + .opportunities-extension-action-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 5px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: #00758f; + cursor: pointer; + } + + .opportunities-extension-action-button svg { + width: 21px; + height: 21px; + } + + .opportunities-extension-action-button:hover, + .opportunities-extension-action-button:focus-visible { + border-color: #7ca7b2; + background: #e8f4f6; + color: #004f63; + outline: none; + } + + .opportunities-extension-action-button:disabled { + cursor: wait; + opacity: .72; + } + + .opportunities-extension-action-button-loading svg { + animation: opportunities-extension-pulse 1s ease-in-out infinite; + } + + @keyframes opportunities-extension-pulse { + 50% { opacity: .35; } + } + .opportunities-extension-copy-toast { position: fixed; right: 28px; @@ -4124,6 +4637,13 @@ cursor: pointer; } + .opportunities-extension-table-header-label { + display: inline-flex; + align-items: center; + min-height: 44px; + padding: 8px 6px; + } + .opportunities-extension-sort-button::after { position: absolute; right: 15px; @@ -4185,6 +4705,217 @@ display: none !important; } + .opportunities-extension-account-plans-modal { + position: absolute; + inset: 0; + z-index: 4; + display: grid; + place-items: center; + padding: 24px; + background: rgba(0, 0, 0, .42); + } + + .opportunities-extension-account-plans-modal[hidden] { + display: none !important; + } + + .opportunities-extension-account-plans-dialog { + display: grid; + width: min(860px, 100%); + max-height: min(720px, calc(100vh - 48px)); + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + box-shadow: 0 8px 24px rgba(0, 0, 0, .24); + color: #312d2a; + } + + .opportunities-extension-account-plans-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid #dedbd7; + } + + .opportunities-extension-account-plans-header h2 { + margin: 0; + font-size: 20px; + line-height: 1.2; + } + + .opportunities-extension-account-plans-header p { + margin: 5px 0 0; + color: #5f5a55; + font-size: 13px; + line-height: 1.35; + } + + .opportunities-extension-account-plans-content { + min-height: 0; + overflow: auto; + } + + .opportunities-extension-account-plans-tabs { + display: flex; + gap: 4px; + overflow-x: auto; + padding: 12px 20px 0; + border-bottom: 1px solid #dedbd7; + } + + .opportunities-extension-account-plan-tab { + min-height: 38px; + flex: 0 0 auto; + padding: 7px 12px; + border: 0; + border-bottom: 3px solid transparent; + background: transparent; + color: #5f5a55; + font-size: 13px; + font-weight: 600; + cursor: pointer; + } + + .opportunities-extension-account-plan-tab:hover { + background: #f0eeeb; + color: #312d2a; + } + + .opportunities-extension-account-plan-tab[aria-selected="true"] { + border-bottom-color: #00758f; + color: #006d7a; + } + + .opportunities-extension-account-plan-tab:focus-visible { + outline: 2px solid #00758f; + outline-offset: -2px; + } + + .opportunities-extension-account-plan-panel { + padding: 24px 20px 28px; + } + + .opportunities-extension-account-plan-panel[hidden] { + display: none !important; + } + + .opportunities-extension-account-plan-details { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 28px; + margin: 0; + } + + .opportunities-extension-account-plan-details > div { + display: grid; + grid-template-columns: minmax(130px, 38%) minmax(0, 1fr); + gap: 12px; + padding: 13px 0; + border-bottom: 1px solid #ebe8e5; + } + + .opportunities-extension-account-plan-details dt { + color: #5f5a55; + font-size: 12px; + font-weight: 700; + } + + .opportunities-extension-account-plan-details dd { + min-width: 0; + margin: 0; + color: #312d2a; + font-size: 13px; + overflow-wrap: anywhere; + } + + .opportunities-extension-account-plan-status-indicator { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: #9b9691; + box-shadow: 0 0 0 3px #efedeb; + vertical-align: middle; + } + + .opportunities-extension-account-plan-status-indicator[data-active="true"] { + background: #3f6f17; + box-shadow: 0 0 0 3px #e2f2e5; + } + + .opportunities-extension-account-plan-tab .opportunities-extension-account-plan-status-indicator { + margin-right: 8px; + } + + .opportunities-extension-account-plans-skeleton-tabs { + display: flex; + gap: 16px; + min-height: 51px; + align-items: center; + padding: 0 20px; + border-bottom: 1px solid #dedbd7; + } + + .opportunities-extension-account-plans-skeleton-details { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 28px; + padding: 11px 20px 28px; + } + + .opportunities-extension-account-plans-skeleton-details > div { + display: grid; + grid-template-columns: minmax(110px, 35%) minmax(0, 1fr); + align-items: center; + gap: 14px; + min-height: 52px; + border-bottom: 1px solid #ebe8e5; + } + + .opportunities-extension-account-plans-skeleton-bar { + display: block; + height: 12px; + border-radius: 3px; + background: #dedbd7; + animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; + } + + .opportunities-extension-account-plans-skeleton-tabs .opportunities-extension-account-plans-skeleton-bar { + height: 14px; + } + + .opportunities-extension-account-plans-skeleton-label { + width: 74%; + height: 10px; + } + + @keyframes opportunities-extension-skeleton-pulse { + from { opacity: .48; } + to { opacity: 1; } + } + + @media (prefers-reduced-motion: reduce) { + .opportunities-extension-account-plans-skeleton-bar { + animation: none; + } + } + + .opportunities-extension-account-plans-empty, + .opportunities-extension-account-plans-error { + margin: 0; + padding: 28px 20px; + color: #5f5a55; + font-size: 14px; + } + + .opportunities-extension-account-plans-error { + color: #9f2d20; + } + .opportunities-extension-preferences-dialog { display: grid; width: min(680px, 100%); @@ -4349,18 +5080,38 @@ } .opportunities-extension-debug-panel h2 { - margin: 0 0 4px; + margin: 0; font-size: 18px; font-weight: 700; line-height: 1.25; } .opportunities-extension-debug-panel p { - margin: 0 0 12px; + margin: 4px 0 0; color: #5f5a55; font-size: 13px; } + .opportunities-extension-debug-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + } + + .opportunities-extension-debug-visibility-button { + width: 34px; + height: 34px; + flex: 0 0 34px; + color: #312d2a; + } + + .opportunities-extension-debug-visibility-button svg { + width: 20px; + height: 20px; + } + .opportunities-extension-debug-list { display: grid; gap: 10px; @@ -4515,20 +5266,42 @@ } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-header p, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header p, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card span, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-empty, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail-heading span, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-empty, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-empty, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details dt, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel p, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-empty { color: #c9c5c1; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-error { + color: #ffb4a8; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-bar { + background: #5b5753; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator { + background: #8f8a85; + box-shadow: 0 0 0 3px #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator[data-active="true"] { + background: #7fb05b; + box-shadow: 0 0 0 3px #314527; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-form, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-surface, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-panel, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-dialog, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-dialog, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] { @@ -4569,10 +5342,12 @@ .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header h2, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header h2, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-fieldset legend, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-cache-field, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail-heading, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details dd, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-heading, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 { @@ -4585,10 +5360,29 @@ } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-footer { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-footer, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details > div, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-tabs, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-details > div { border-color: #4e4a46; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab { + color: #c9c5c1; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab:hover { + background: #3b3937; + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab[aria-selected="true"] { + border-bottom-color: #43c4d5; + color: #91e4ed; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option:hover { background: #3b3937; } @@ -4675,13 +5469,16 @@ } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table a, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button { color: #43c4d5; } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table a:hover, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:focus-visible { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:focus-visible, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button:hover, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button:focus-visible { background: #1d4e55; color: #91e4ed; } @@ -4927,10 +5724,26 @@ padding: 12px; } + .opportunities-extension-account-plans-modal { + padding: 12px; + } + .opportunities-extension-preferences-dialog { max-height: calc(100vh - 24px); } + .opportunities-extension-account-plans-dialog { + max-height: calc(100vh - 24px); + } + + .opportunities-extension-account-plan-details { + grid-template-columns: 1fr; + } + + .opportunities-extension-account-plans-skeleton-details { + grid-template-columns: 1fr; + } + .opportunities-extension-preferences-options { grid-template-columns: 1fr; } diff --git a/dist/chromium/manifest.json b/dist/chromium/manifest.json index 2017767..220048f 100644 --- a/dist/chromium/manifest.json +++ b/dist/chromium/manifest.json @@ -8,7 +8,8 @@ "storage" ], "host_permissions": [ - "https://eeho.fa.us2.oraclecloud.com/*" + "https://eeho.fa.us2.oraclecloud.com/*", + "https://spa.oracle.com/*" ], "content_scripts": [ { diff --git a/dist/firefox/background.js b/dist/firefox/background.js index 87093ac..d8f0fa1 100644 --- a/dist/firefox/background.js +++ b/dist/firefox/background.js @@ -17,6 +17,31 @@ 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 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 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", @@ -96,9 +121,413 @@ return true; } + if (message.type === "opportunitiesExtension.requestConsumerTokenService") { + requestConsumerTokenService() + .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 === "opportunitiesExtension.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; + } + return false; }); + async function requestConsumerTokenService() { + const debugRequests = []; + + try { + const tokenResponse = await fetchJson(CUSTOMER_TOKEN_SERVICE_URL, { + method: "GET", + credentials: "include", + cache: "no-store", + headers: { + Accept: "application/json", + "Cache-Control": "no-cache", + Pragma: "no-cache" + } + }, "requestConsumerTokenService", 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 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; + } + } + + 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) + : "" + }; + } + + 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.`); + } + + return parseJsonResponse(responsePayload.text, requestName); + } + + 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) { + throw new Error(`${requestName} returned an invalid JSON response.`); + } + } + + 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); + } + } + + 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; + } + async function requestOpptyProducts(message, sender) { const requestId = typeof message.requestId === "string" ? message.requestId : ""; const accessToken = typeof message.accessToken === "string" ? message.accessToken : ""; diff --git a/dist/firefox/content.js b/dist/firefox/content.js index e55ed4d..ade6ea6 100644 --- a/dist/firefox/content.js +++ b/dist/firefox/content.js @@ -30,6 +30,7 @@ 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="; @@ -73,10 +74,12 @@ { label: "7 days", value: 168 } ]; let tokenRelayRequest = null; + let consumerTokenServiceRequest = null; let accessToken = ""; let periodRequestVersion = 0; let authStatus = AUTH_STATUS.idle; let requestLog = []; + let debugSecretsVisible = false; let selectedStages = new Set(STAGE_OPTIONS); let selectedStatuses = new Set(STATUS_OPTIONS); let selectedCustomerKeys = new Set(); @@ -98,6 +101,7 @@ cacheHits: 0 }; let productsLastCachedAt = 0; + let accountPlansRequestState = new Map(); let hasSavedCustomerFilter = false; let hasSavedOwnerFilter = false; let customerFilterInitialized = false; @@ -128,7 +132,8 @@ { 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: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }, + { key: "actions", label: "Actions", value: () => "", display: () => "", sortable: false } ]; const ALLOWED_PATHS = [ "/hcmUI/faces/FuseWelcome", @@ -204,6 +209,7 @@ function openOpportunitiesModal() { ensureExtensionStyles(); + debugSecretsVisible = false; const existingModal = document.getElementById(MODAL_ID); @@ -219,6 +225,7 @@ const overlay = document.createElement("div"); requestLog = []; tokenRelayRequest = null; + consumerTokenServiceRequest = null; accessToken = ""; periodRequestVersion = 0; authStatus = AUTH_STATUS.idle; @@ -554,6 +561,7 @@ .then((token) => { accessToken = token; authStatus = AUTH_STATUS.authenticated; + requestConsumerTokenServiceOnce().catch(() => {}); return token; }) .catch((error) => { @@ -571,6 +579,26 @@ return tokenRelayRequest; } + function requestConsumerTokenServiceOnce() { + if (!consumerTokenServiceRequest) { + consumerTokenServiceRequest = sendRuntimeMessage({ + type: "opportunitiesExtension.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 consumerTokenServiceRequest; + } + async function requestTokenRelay() { let refreshResult = null; let refreshAttempted = false; @@ -748,6 +776,7 @@ cacheHits: 0 }; productsLastCachedAt = 0; + accountPlansRequestState = new Map(); renderProductsProgress(); setOpportunitiesTableState({ items: [], @@ -1058,6 +1087,7 @@ cacheHits: 0 }; productsLastCachedAt = 0; + accountPlansRequestState = new Map(); stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); opportunitiesTableState = { items: [], @@ -1183,17 +1213,25 @@ const header = document.createElement("th"); const isSorted = opportunitiesTableState.sortKey === column.key; header.scope = "col"; - header.setAttribute("aria-sort", isSorted ? opportunitiesTableState.sortDirection : "none"); - const sortButton = document.createElement("button"); - sortButton.type = "button"; - sortButton.className = "opportunities-extension-sort-button"; - sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.sortDirection : "none"); - sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.sortDirection}` : ""}`); - sortButton.textContent = column.label; - sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key)); + 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.sortDirection : "none"); + + const sortButton = document.createElement("button"); + sortButton.type = "button"; + sortButton.className = "opportunities-extension-sort-button"; + sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.sortDirection : "none"); + sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.sortDirection}` : ""}`); + sortButton.textContent = column.label; + sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key)); + header.append(sortButton); + } - header.append(sortButton); headerRow.append(header); }); @@ -1222,6 +1260,8 @@ cell.append(createStageBadge(column.display(item))); } else if (column.key === "status") { cell.append(createStatusBadge(column.display(item))); + } else if (column.key === "actions") { + cell.append(createCloudConsumptionButton(item)); } else if (column.key === "name" || column.key === "optyNumber") { const opportunityLink = createDetailLink( column.display(item), @@ -1916,6 +1956,290 @@ return button; } + function createCloudConsumptionButton(opportunity) { + const button = document.createElement("button"); + const optyNumber = String(opportunity.OptyNumber || ""); + const partyNumber = getNestedValue(opportunity, ["CustomerAccount", "PartyNumber"]); + const requestState = 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 cloud consumption" : "View cloud 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; + } + + accountPlansRequestState.set(optyNumber, { status: "loading" }); + openAccountPlansModal(opportunity, [], "loading"); + renderOpportunitiesTable(); + requestConsumerTokenServiceOnce() + .then((authorizations) => sendRuntimeMessage({ + type: "opportunitiesExtension.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."); + } + + 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."; + 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; + } + + 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 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)); + + 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; + }); + }); + tabList.append(tab); + panels.append(panel); + }); + + content.append(tabList, panels); + } + + dialog.append(header, content); + modal.append(dialog); + overlay.append(modal); + modal.querySelector("[role='tab'], .opportunities-extension-icon-button")?.focus(); + } + + 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"; + [96, 124, 108].forEach((width) => { + const tab = document.createElement("span"); + tab.className = "opportunities-extension-account-plans-skeleton-bar"; + tab.style.width = `${width}px`; + 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); + }); + + skeleton.append(announcement, tabs, details); + 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; + } + } + function createProductsCountButton(optyNumber, productsResult) { const normalizedOptyNumber = String(optyNumber); const button = document.createElement("button"); @@ -2342,6 +2666,36 @@ renderDebugPanel(); } + function appendBackgroundRequestLog(entries) { + if (!Array.isArray(entries) || entries.length === 0) { + return; + } + + entries.forEach((entry) => { + requestLog.push({ + id: 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(); @@ -2386,10 +2740,56 @@ } return entries - .map(([key, value]) => `${key}: ${value}`) + .map(([key, value]) => `${key}: ${debugSecretsVisible || !isSensitiveDebugKey(key) + ? value + : formatHiddenDebugValue(value)}`) .join("\n"); } + function formatDebugBody(body) { + const text = body || "(none)"; + + if (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); @@ -2436,11 +2836,29 @@ 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 = `${requestLog.length} request${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(debugSecretsVisible)); + visibilityButton.setAttribute("aria-label", debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"); + visibilityButton.title = debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"; + visibilityButton.append(createDebugVisibilityIcon(debugSecretsVisible)); + visibilityButton.addEventListener("click", () => { + debugSecretsVisible = !debugSecretsVisible; + renderDebugPanel(); + }); + panelHeader.append(heading, visibilityButton); const list = document.createElement("div"); list.className = "opportunities-extension-debug-list"; @@ -2490,15 +2908,49 @@ appendDebugBlock(details, "Request", lines.join("\n")); appendDebugBlock(details, "Request Metadata", formatMetadataBlock(entry.metadata)); appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders)); - appendDebugBlock(details, "Request Body", entry.requestBody || "(none)"); + appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody)); appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders)); - appendDebugBlock(details, "Response Body", entry.responseBody || "(none)"); + appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody)); item.append(heading, details); list.append(item); }); - panel.append(title, summary, list); + 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) { @@ -2668,9 +3120,11 @@ if (modal) { closePreferencesModal(); + closeAccountPlansModal(); modal.hidden = true; } + debugSecretsVisible = false; document.documentElement.classList.remove("opportunities-extension-scroll-lock"); } @@ -2972,6 +3426,14 @@ } 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) { @@ -3764,7 +4226,7 @@ } .opportunities-extension-table th:nth-child(1) { - width: 17%; + width: 16%; } .opportunities-extension-table th:nth-child(2) { @@ -3772,11 +4234,11 @@ } .opportunities-extension-table th:nth-child(3) { - width: 19%; + width: 18%; } .opportunities-extension-table th:nth-child(4) { - width: 12%; + width: 11%; } .opportunities-extension-table th:nth-child(5) { @@ -3800,7 +4262,11 @@ } .opportunities-extension-table th:nth-child(10) { - width: 8%; + width: 7%; + } + + .opportunities-extension-table th:nth-child(11) { + width: 4%; } .opportunities-extension-table td:nth-child(2), @@ -3833,6 +4299,13 @@ white-space: nowrap; } + .opportunities-extension-table td:nth-child(11) { + padding-right: 6px; + padding-left: 6px; + text-align: center; + white-space: nowrap; + } + .opportunities-extension-table tbody tr:hover { background: #f7f6f4; } @@ -4009,6 +4482,46 @@ outline: none; } + .opportunities-extension-action-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 5px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: #00758f; + cursor: pointer; + } + + .opportunities-extension-action-button svg { + width: 21px; + height: 21px; + } + + .opportunities-extension-action-button:hover, + .opportunities-extension-action-button:focus-visible { + border-color: #7ca7b2; + background: #e8f4f6; + color: #004f63; + outline: none; + } + + .opportunities-extension-action-button:disabled { + cursor: wait; + opacity: .72; + } + + .opportunities-extension-action-button-loading svg { + animation: opportunities-extension-pulse 1s ease-in-out infinite; + } + + @keyframes opportunities-extension-pulse { + 50% { opacity: .35; } + } + .opportunities-extension-copy-toast { position: fixed; right: 28px; @@ -4124,6 +4637,13 @@ cursor: pointer; } + .opportunities-extension-table-header-label { + display: inline-flex; + align-items: center; + min-height: 44px; + padding: 8px 6px; + } + .opportunities-extension-sort-button::after { position: absolute; right: 15px; @@ -4185,6 +4705,217 @@ display: none !important; } + .opportunities-extension-account-plans-modal { + position: absolute; + inset: 0; + z-index: 4; + display: grid; + place-items: center; + padding: 24px; + background: rgba(0, 0, 0, .42); + } + + .opportunities-extension-account-plans-modal[hidden] { + display: none !important; + } + + .opportunities-extension-account-plans-dialog { + display: grid; + width: min(860px, 100%); + max-height: min(720px, calc(100vh - 48px)); + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + box-shadow: 0 8px 24px rgba(0, 0, 0, .24); + color: #312d2a; + } + + .opportunities-extension-account-plans-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid #dedbd7; + } + + .opportunities-extension-account-plans-header h2 { + margin: 0; + font-size: 20px; + line-height: 1.2; + } + + .opportunities-extension-account-plans-header p { + margin: 5px 0 0; + color: #5f5a55; + font-size: 13px; + line-height: 1.35; + } + + .opportunities-extension-account-plans-content { + min-height: 0; + overflow: auto; + } + + .opportunities-extension-account-plans-tabs { + display: flex; + gap: 4px; + overflow-x: auto; + padding: 12px 20px 0; + border-bottom: 1px solid #dedbd7; + } + + .opportunities-extension-account-plan-tab { + min-height: 38px; + flex: 0 0 auto; + padding: 7px 12px; + border: 0; + border-bottom: 3px solid transparent; + background: transparent; + color: #5f5a55; + font-size: 13px; + font-weight: 600; + cursor: pointer; + } + + .opportunities-extension-account-plan-tab:hover { + background: #f0eeeb; + color: #312d2a; + } + + .opportunities-extension-account-plan-tab[aria-selected="true"] { + border-bottom-color: #00758f; + color: #006d7a; + } + + .opportunities-extension-account-plan-tab:focus-visible { + outline: 2px solid #00758f; + outline-offset: -2px; + } + + .opportunities-extension-account-plan-panel { + padding: 24px 20px 28px; + } + + .opportunities-extension-account-plan-panel[hidden] { + display: none !important; + } + + .opportunities-extension-account-plan-details { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 28px; + margin: 0; + } + + .opportunities-extension-account-plan-details > div { + display: grid; + grid-template-columns: minmax(130px, 38%) minmax(0, 1fr); + gap: 12px; + padding: 13px 0; + border-bottom: 1px solid #ebe8e5; + } + + .opportunities-extension-account-plan-details dt { + color: #5f5a55; + font-size: 12px; + font-weight: 700; + } + + .opportunities-extension-account-plan-details dd { + min-width: 0; + margin: 0; + color: #312d2a; + font-size: 13px; + overflow-wrap: anywhere; + } + + .opportunities-extension-account-plan-status-indicator { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: #9b9691; + box-shadow: 0 0 0 3px #efedeb; + vertical-align: middle; + } + + .opportunities-extension-account-plan-status-indicator[data-active="true"] { + background: #3f6f17; + box-shadow: 0 0 0 3px #e2f2e5; + } + + .opportunities-extension-account-plan-tab .opportunities-extension-account-plan-status-indicator { + margin-right: 8px; + } + + .opportunities-extension-account-plans-skeleton-tabs { + display: flex; + gap: 16px; + min-height: 51px; + align-items: center; + padding: 0 20px; + border-bottom: 1px solid #dedbd7; + } + + .opportunities-extension-account-plans-skeleton-details { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 28px; + padding: 11px 20px 28px; + } + + .opportunities-extension-account-plans-skeleton-details > div { + display: grid; + grid-template-columns: minmax(110px, 35%) minmax(0, 1fr); + align-items: center; + gap: 14px; + min-height: 52px; + border-bottom: 1px solid #ebe8e5; + } + + .opportunities-extension-account-plans-skeleton-bar { + display: block; + height: 12px; + border-radius: 3px; + background: #dedbd7; + animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; + } + + .opportunities-extension-account-plans-skeleton-tabs .opportunities-extension-account-plans-skeleton-bar { + height: 14px; + } + + .opportunities-extension-account-plans-skeleton-label { + width: 74%; + height: 10px; + } + + @keyframes opportunities-extension-skeleton-pulse { + from { opacity: .48; } + to { opacity: 1; } + } + + @media (prefers-reduced-motion: reduce) { + .opportunities-extension-account-plans-skeleton-bar { + animation: none; + } + } + + .opportunities-extension-account-plans-empty, + .opportunities-extension-account-plans-error { + margin: 0; + padding: 28px 20px; + color: #5f5a55; + font-size: 14px; + } + + .opportunities-extension-account-plans-error { + color: #9f2d20; + } + .opportunities-extension-preferences-dialog { display: grid; width: min(680px, 100%); @@ -4349,18 +5080,38 @@ } .opportunities-extension-debug-panel h2 { - margin: 0 0 4px; + margin: 0; font-size: 18px; font-weight: 700; line-height: 1.25; } .opportunities-extension-debug-panel p { - margin: 0 0 12px; + margin: 4px 0 0; color: #5f5a55; font-size: 13px; } + .opportunities-extension-debug-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + } + + .opportunities-extension-debug-visibility-button { + width: 34px; + height: 34px; + flex: 0 0 34px; + color: #312d2a; + } + + .opportunities-extension-debug-visibility-button svg { + width: 20px; + height: 20px; + } + .opportunities-extension-debug-list { display: grid; gap: 10px; @@ -4515,20 +5266,42 @@ } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-header p, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header p, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card span, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-empty, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail-heading span, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-empty, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-empty, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details dt, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel p, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-empty { color: #c9c5c1; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-error { + color: #ffb4a8; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-bar { + background: #5b5753; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator { + background: #8f8a85; + box-shadow: 0 0 0 3px #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator[data-active="true"] { + background: #7fb05b; + box-shadow: 0 0 0 3px #314527; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-form, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-surface, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-panel, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-dialog, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-dialog, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] { @@ -4569,10 +5342,12 @@ .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header h2, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header h2, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-fieldset legend, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-cache-field, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail-heading, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details dd, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-heading, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 { @@ -4585,10 +5360,29 @@ } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-footer { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-footer, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details > div, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-tabs, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-details > div { border-color: #4e4a46; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab { + color: #c9c5c1; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab:hover { + background: #3b3937; + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab[aria-selected="true"] { + border-bottom-color: #43c4d5; + color: #91e4ed; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option:hover { background: #3b3937; } @@ -4675,13 +5469,16 @@ } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table a, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button { color: #43c4d5; } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table a:hover, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:focus-visible { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:focus-visible, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button:hover, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button:focus-visible { background: #1d4e55; color: #91e4ed; } @@ -4927,10 +5724,26 @@ padding: 12px; } + .opportunities-extension-account-plans-modal { + padding: 12px; + } + .opportunities-extension-preferences-dialog { max-height: calc(100vh - 24px); } + .opportunities-extension-account-plans-dialog { + max-height: calc(100vh - 24px); + } + + .opportunities-extension-account-plan-details { + grid-template-columns: 1fr; + } + + .opportunities-extension-account-plans-skeleton-details { + grid-template-columns: 1fr; + } + .opportunities-extension-preferences-options { grid-template-columns: 1fr; } diff --git a/dist/firefox/manifest.json b/dist/firefox/manifest.json index 77c6e28..66e0a20 100644 --- a/dist/firefox/manifest.json +++ b/dist/firefox/manifest.json @@ -8,7 +8,8 @@ "storage" ], "host_permissions": [ - "https://eeho.fa.us2.oraclecloud.com/*" + "https://eeho.fa.us2.oraclecloud.com/*", + "https://spa.oracle.com/*" ], "content_scripts": [ { diff --git a/scripts/build.mjs b/scripts/build.mjs index 1bb7eed..2ce768f 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -19,7 +19,8 @@ const baseManifest = { "storage" ], host_permissions: [ - "https://eeho.fa.us2.oraclecloud.com/*" + "https://eeho.fa.us2.oraclecloud.com/*", + "https://spa.oracle.com/*" ], content_scripts: [ { diff --git a/src/background.js b/src/background.js index 87093ac..d8f0fa1 100644 --- a/src/background.js +++ b/src/background.js @@ -17,6 +17,31 @@ 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 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 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", @@ -96,9 +121,413 @@ return true; } + if (message.type === "opportunitiesExtension.requestConsumerTokenService") { + requestConsumerTokenService() + .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 === "opportunitiesExtension.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; + } + return false; }); + async function requestConsumerTokenService() { + const debugRequests = []; + + try { + const tokenResponse = await fetchJson(CUSTOMER_TOKEN_SERVICE_URL, { + method: "GET", + credentials: "include", + cache: "no-store", + headers: { + Accept: "application/json", + "Cache-Control": "no-cache", + Pragma: "no-cache" + } + }, "requestConsumerTokenService", 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 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; + } + } + + 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) + : "" + }; + } + + 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.`); + } + + return parseJsonResponse(responsePayload.text, requestName); + } + + 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) { + throw new Error(`${requestName} returned an invalid JSON response.`); + } + } + + 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); + } + } + + 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; + } + async function requestOpptyProducts(message, sender) { const requestId = typeof message.requestId === "string" ? message.requestId : ""; const accessToken = typeof message.accessToken === "string" ? message.accessToken : ""; diff --git a/src/content.js b/src/content.js index e55ed4d..ade6ea6 100644 --- a/src/content.js +++ b/src/content.js @@ -30,6 +30,7 @@ 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="; @@ -73,10 +74,12 @@ { label: "7 days", value: 168 } ]; let tokenRelayRequest = null; + let consumerTokenServiceRequest = null; let accessToken = ""; let periodRequestVersion = 0; let authStatus = AUTH_STATUS.idle; let requestLog = []; + let debugSecretsVisible = false; let selectedStages = new Set(STAGE_OPTIONS); let selectedStatuses = new Set(STATUS_OPTIONS); let selectedCustomerKeys = new Set(); @@ -98,6 +101,7 @@ cacheHits: 0 }; let productsLastCachedAt = 0; + let accountPlansRequestState = new Map(); let hasSavedCustomerFilter = false; let hasSavedOwnerFilter = false; let customerFilterInitialized = false; @@ -128,7 +132,8 @@ { 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: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }, + { key: "actions", label: "Actions", value: () => "", display: () => "", sortable: false } ]; const ALLOWED_PATHS = [ "/hcmUI/faces/FuseWelcome", @@ -204,6 +209,7 @@ function openOpportunitiesModal() { ensureExtensionStyles(); + debugSecretsVisible = false; const existingModal = document.getElementById(MODAL_ID); @@ -219,6 +225,7 @@ const overlay = document.createElement("div"); requestLog = []; tokenRelayRequest = null; + consumerTokenServiceRequest = null; accessToken = ""; periodRequestVersion = 0; authStatus = AUTH_STATUS.idle; @@ -554,6 +561,7 @@ .then((token) => { accessToken = token; authStatus = AUTH_STATUS.authenticated; + requestConsumerTokenServiceOnce().catch(() => {}); return token; }) .catch((error) => { @@ -571,6 +579,26 @@ return tokenRelayRequest; } + function requestConsumerTokenServiceOnce() { + if (!consumerTokenServiceRequest) { + consumerTokenServiceRequest = sendRuntimeMessage({ + type: "opportunitiesExtension.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 consumerTokenServiceRequest; + } + async function requestTokenRelay() { let refreshResult = null; let refreshAttempted = false; @@ -748,6 +776,7 @@ cacheHits: 0 }; productsLastCachedAt = 0; + accountPlansRequestState = new Map(); renderProductsProgress(); setOpportunitiesTableState({ items: [], @@ -1058,6 +1087,7 @@ cacheHits: 0 }; productsLastCachedAt = 0; + accountPlansRequestState = new Map(); stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]); opportunitiesTableState = { items: [], @@ -1183,17 +1213,25 @@ const header = document.createElement("th"); const isSorted = opportunitiesTableState.sortKey === column.key; header.scope = "col"; - header.setAttribute("aria-sort", isSorted ? opportunitiesTableState.sortDirection : "none"); - const sortButton = document.createElement("button"); - sortButton.type = "button"; - sortButton.className = "opportunities-extension-sort-button"; - sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.sortDirection : "none"); - sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.sortDirection}` : ""}`); - sortButton.textContent = column.label; - sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key)); + 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.sortDirection : "none"); + + const sortButton = document.createElement("button"); + sortButton.type = "button"; + sortButton.className = "opportunities-extension-sort-button"; + sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.sortDirection : "none"); + sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.sortDirection}` : ""}`); + sortButton.textContent = column.label; + sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key)); + header.append(sortButton); + } - header.append(sortButton); headerRow.append(header); }); @@ -1222,6 +1260,8 @@ cell.append(createStageBadge(column.display(item))); } else if (column.key === "status") { cell.append(createStatusBadge(column.display(item))); + } else if (column.key === "actions") { + cell.append(createCloudConsumptionButton(item)); } else if (column.key === "name" || column.key === "optyNumber") { const opportunityLink = createDetailLink( column.display(item), @@ -1916,6 +1956,290 @@ return button; } + function createCloudConsumptionButton(opportunity) { + const button = document.createElement("button"); + const optyNumber = String(opportunity.OptyNumber || ""); + const partyNumber = getNestedValue(opportunity, ["CustomerAccount", "PartyNumber"]); + const requestState = 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 cloud consumption" : "View cloud 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; + } + + accountPlansRequestState.set(optyNumber, { status: "loading" }); + openAccountPlansModal(opportunity, [], "loading"); + renderOpportunitiesTable(); + requestConsumerTokenServiceOnce() + .then((authorizations) => sendRuntimeMessage({ + type: "opportunitiesExtension.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."); + } + + 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."; + 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; + } + + 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 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)); + + 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; + }); + }); + tabList.append(tab); + panels.append(panel); + }); + + content.append(tabList, panels); + } + + dialog.append(header, content); + modal.append(dialog); + overlay.append(modal); + modal.querySelector("[role='tab'], .opportunities-extension-icon-button")?.focus(); + } + + 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"; + [96, 124, 108].forEach((width) => { + const tab = document.createElement("span"); + tab.className = "opportunities-extension-account-plans-skeleton-bar"; + tab.style.width = `${width}px`; + 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); + }); + + skeleton.append(announcement, tabs, details); + 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; + } + } + function createProductsCountButton(optyNumber, productsResult) { const normalizedOptyNumber = String(optyNumber); const button = document.createElement("button"); @@ -2342,6 +2666,36 @@ renderDebugPanel(); } + function appendBackgroundRequestLog(entries) { + if (!Array.isArray(entries) || entries.length === 0) { + return; + } + + entries.forEach((entry) => { + requestLog.push({ + id: 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(); @@ -2386,10 +2740,56 @@ } return entries - .map(([key, value]) => `${key}: ${value}`) + .map(([key, value]) => `${key}: ${debugSecretsVisible || !isSensitiveDebugKey(key) + ? value + : formatHiddenDebugValue(value)}`) .join("\n"); } + function formatDebugBody(body) { + const text = body || "(none)"; + + if (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); @@ -2436,11 +2836,29 @@ 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 = `${requestLog.length} request${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(debugSecretsVisible)); + visibilityButton.setAttribute("aria-label", debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"); + visibilityButton.title = debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"; + visibilityButton.append(createDebugVisibilityIcon(debugSecretsVisible)); + visibilityButton.addEventListener("click", () => { + debugSecretsVisible = !debugSecretsVisible; + renderDebugPanel(); + }); + panelHeader.append(heading, visibilityButton); const list = document.createElement("div"); list.className = "opportunities-extension-debug-list"; @@ -2490,15 +2908,49 @@ appendDebugBlock(details, "Request", lines.join("\n")); appendDebugBlock(details, "Request Metadata", formatMetadataBlock(entry.metadata)); appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders)); - appendDebugBlock(details, "Request Body", entry.requestBody || "(none)"); + appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody)); appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders)); - appendDebugBlock(details, "Response Body", entry.responseBody || "(none)"); + appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody)); item.append(heading, details); list.append(item); }); - panel.append(title, summary, list); + 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) { @@ -2668,9 +3120,11 @@ if (modal) { closePreferencesModal(); + closeAccountPlansModal(); modal.hidden = true; } + debugSecretsVisible = false; document.documentElement.classList.remove("opportunities-extension-scroll-lock"); } @@ -2972,6 +3426,14 @@ } 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) { @@ -3764,7 +4226,7 @@ } .opportunities-extension-table th:nth-child(1) { - width: 17%; + width: 16%; } .opportunities-extension-table th:nth-child(2) { @@ -3772,11 +4234,11 @@ } .opportunities-extension-table th:nth-child(3) { - width: 19%; + width: 18%; } .opportunities-extension-table th:nth-child(4) { - width: 12%; + width: 11%; } .opportunities-extension-table th:nth-child(5) { @@ -3800,7 +4262,11 @@ } .opportunities-extension-table th:nth-child(10) { - width: 8%; + width: 7%; + } + + .opportunities-extension-table th:nth-child(11) { + width: 4%; } .opportunities-extension-table td:nth-child(2), @@ -3833,6 +4299,13 @@ white-space: nowrap; } + .opportunities-extension-table td:nth-child(11) { + padding-right: 6px; + padding-left: 6px; + text-align: center; + white-space: nowrap; + } + .opportunities-extension-table tbody tr:hover { background: #f7f6f4; } @@ -4009,6 +4482,46 @@ outline: none; } + .opportunities-extension-action-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 5px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: #00758f; + cursor: pointer; + } + + .opportunities-extension-action-button svg { + width: 21px; + height: 21px; + } + + .opportunities-extension-action-button:hover, + .opportunities-extension-action-button:focus-visible { + border-color: #7ca7b2; + background: #e8f4f6; + color: #004f63; + outline: none; + } + + .opportunities-extension-action-button:disabled { + cursor: wait; + opacity: .72; + } + + .opportunities-extension-action-button-loading svg { + animation: opportunities-extension-pulse 1s ease-in-out infinite; + } + + @keyframes opportunities-extension-pulse { + 50% { opacity: .35; } + } + .opportunities-extension-copy-toast { position: fixed; right: 28px; @@ -4124,6 +4637,13 @@ cursor: pointer; } + .opportunities-extension-table-header-label { + display: inline-flex; + align-items: center; + min-height: 44px; + padding: 8px 6px; + } + .opportunities-extension-sort-button::after { position: absolute; right: 15px; @@ -4185,6 +4705,217 @@ display: none !important; } + .opportunities-extension-account-plans-modal { + position: absolute; + inset: 0; + z-index: 4; + display: grid; + place-items: center; + padding: 24px; + background: rgba(0, 0, 0, .42); + } + + .opportunities-extension-account-plans-modal[hidden] { + display: none !important; + } + + .opportunities-extension-account-plans-dialog { + display: grid; + width: min(860px, 100%); + max-height: min(720px, calc(100vh - 48px)); + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + border: 1px solid #8f8a85; + border-radius: 4px; + background: #ffffff; + box-shadow: 0 8px 24px rgba(0, 0, 0, .24); + color: #312d2a; + } + + .opportunities-extension-account-plans-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid #dedbd7; + } + + .opportunities-extension-account-plans-header h2 { + margin: 0; + font-size: 20px; + line-height: 1.2; + } + + .opportunities-extension-account-plans-header p { + margin: 5px 0 0; + color: #5f5a55; + font-size: 13px; + line-height: 1.35; + } + + .opportunities-extension-account-plans-content { + min-height: 0; + overflow: auto; + } + + .opportunities-extension-account-plans-tabs { + display: flex; + gap: 4px; + overflow-x: auto; + padding: 12px 20px 0; + border-bottom: 1px solid #dedbd7; + } + + .opportunities-extension-account-plan-tab { + min-height: 38px; + flex: 0 0 auto; + padding: 7px 12px; + border: 0; + border-bottom: 3px solid transparent; + background: transparent; + color: #5f5a55; + font-size: 13px; + font-weight: 600; + cursor: pointer; + } + + .opportunities-extension-account-plan-tab:hover { + background: #f0eeeb; + color: #312d2a; + } + + .opportunities-extension-account-plan-tab[aria-selected="true"] { + border-bottom-color: #00758f; + color: #006d7a; + } + + .opportunities-extension-account-plan-tab:focus-visible { + outline: 2px solid #00758f; + outline-offset: -2px; + } + + .opportunities-extension-account-plan-panel { + padding: 24px 20px 28px; + } + + .opportunities-extension-account-plan-panel[hidden] { + display: none !important; + } + + .opportunities-extension-account-plan-details { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 28px; + margin: 0; + } + + .opportunities-extension-account-plan-details > div { + display: grid; + grid-template-columns: minmax(130px, 38%) minmax(0, 1fr); + gap: 12px; + padding: 13px 0; + border-bottom: 1px solid #ebe8e5; + } + + .opportunities-extension-account-plan-details dt { + color: #5f5a55; + font-size: 12px; + font-weight: 700; + } + + .opportunities-extension-account-plan-details dd { + min-width: 0; + margin: 0; + color: #312d2a; + font-size: 13px; + overflow-wrap: anywhere; + } + + .opportunities-extension-account-plan-status-indicator { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: #9b9691; + box-shadow: 0 0 0 3px #efedeb; + vertical-align: middle; + } + + .opportunities-extension-account-plan-status-indicator[data-active="true"] { + background: #3f6f17; + box-shadow: 0 0 0 3px #e2f2e5; + } + + .opportunities-extension-account-plan-tab .opportunities-extension-account-plan-status-indicator { + margin-right: 8px; + } + + .opportunities-extension-account-plans-skeleton-tabs { + display: flex; + gap: 16px; + min-height: 51px; + align-items: center; + padding: 0 20px; + border-bottom: 1px solid #dedbd7; + } + + .opportunities-extension-account-plans-skeleton-details { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 28px; + padding: 11px 20px 28px; + } + + .opportunities-extension-account-plans-skeleton-details > div { + display: grid; + grid-template-columns: minmax(110px, 35%) minmax(0, 1fr); + align-items: center; + gap: 14px; + min-height: 52px; + border-bottom: 1px solid #ebe8e5; + } + + .opportunities-extension-account-plans-skeleton-bar { + display: block; + height: 12px; + border-radius: 3px; + background: #dedbd7; + animation: opportunities-extension-skeleton-pulse 1.15s ease-in-out infinite alternate; + } + + .opportunities-extension-account-plans-skeleton-tabs .opportunities-extension-account-plans-skeleton-bar { + height: 14px; + } + + .opportunities-extension-account-plans-skeleton-label { + width: 74%; + height: 10px; + } + + @keyframes opportunities-extension-skeleton-pulse { + from { opacity: .48; } + to { opacity: 1; } + } + + @media (prefers-reduced-motion: reduce) { + .opportunities-extension-account-plans-skeleton-bar { + animation: none; + } + } + + .opportunities-extension-account-plans-empty, + .opportunities-extension-account-plans-error { + margin: 0; + padding: 28px 20px; + color: #5f5a55; + font-size: 14px; + } + + .opportunities-extension-account-plans-error { + color: #9f2d20; + } + .opportunities-extension-preferences-dialog { display: grid; width: min(680px, 100%); @@ -4349,18 +5080,38 @@ } .opportunities-extension-debug-panel h2 { - margin: 0 0 4px; + margin: 0; font-size: 18px; font-weight: 700; line-height: 1.25; } .opportunities-extension-debug-panel p { - margin: 0 0 12px; + margin: 4px 0 0; color: #5f5a55; font-size: 13px; } + .opportunities-extension-debug-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + } + + .opportunities-extension-debug-visibility-button { + width: 34px; + height: 34px; + flex: 0 0 34px; + color: #312d2a; + } + + .opportunities-extension-debug-visibility-button svg { + width: 20px; + height: 20px; + } + .opportunities-extension-debug-list { display: grid; gap: 10px; @@ -4515,20 +5266,42 @@ } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-header p, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header p, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card span, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-empty, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail-heading span, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-empty, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-empty, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details dt, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel p, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-empty { color: #c9c5c1; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-error { + color: #ffb4a8; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-bar { + background: #5b5753; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator { + background: #8f8a85; + box-shadow: 0 0 0 3px #3b3937; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-status-indicator[data-active="true"] { + background: #7fb05b; + box-shadow: 0 0 0 3px #314527; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-form, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-surface, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-panel, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-dialog, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-dialog, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-table, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] { @@ -4569,10 +5342,12 @@ .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header h2, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header h2, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-fieldset legend, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-cache-field, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-detail-heading, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details dd, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-heading, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 { @@ -4585,10 +5360,29 @@ } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-footer { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-footer, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-header, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-tabs, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-details > div, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-tabs, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plans-skeleton-details > div { border-color: #4e4a46; } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab { + color: #c9c5c1; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab:hover { + background: #3b3937; + color: #f6f4f2; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-account-plan-tab[aria-selected="true"] { + border-bottom-color: #43c4d5; + color: #91e4ed; + } + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option:hover { background: #3b3937; } @@ -4675,13 +5469,16 @@ } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table a, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button { color: #43c4d5; } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table a:hover, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:hover, - .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:focus-visible { + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-copy-button:focus-visible, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button:hover, + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-button:focus-visible { background: #1d4e55; color: #91e4ed; } @@ -4927,10 +5724,26 @@ padding: 12px; } + .opportunities-extension-account-plans-modal { + padding: 12px; + } + .opportunities-extension-preferences-dialog { max-height: calc(100vh - 24px); } + .opportunities-extension-account-plans-dialog { + max-height: calc(100vh - 24px); + } + + .opportunities-extension-account-plan-details { + grid-template-columns: 1fr; + } + + .opportunities-extension-account-plans-skeleton-details { + grid-template-columns: 1fr; + } + .opportunities-extension-preferences-options { grid-template-columns: 1fr; }