Implemented the customer installed-base view

This commit is contained in:
2026-09-22 16:30:07 -03:00
parent 8d73954362
commit 3ee8eb6f21
22 changed files with 1102 additions and 19 deletions

View File

@@ -58,7 +58,8 @@ function createAuthConsumerSession({
ok: true,
authorizations: {
accountSummary: accountSummaryAuthorization,
accountPlans: accountPlansAuthorization
accountPlans: accountPlansAuthorization,
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", "")
},
debugRequests
};

View File

@@ -34,6 +34,12 @@ function createBackgroundApplication(overrides = {}) {
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaInstallBase = createSpaInstallBase({
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaConsumption = createSpaConsumption({
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
@@ -97,12 +103,13 @@ function createBackgroundApplication(overrides = {}) {
const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args);
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
function start() {
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,
@@ -110,6 +117,7 @@ function createBackgroundApplication(overrides = {}) {
authConsumerSession,
authSpaCredentials,
spaAccounts,
spaInstallBase,
spaConsumption,
spaResponseShapes,
httpBackgroundJson,

View File

@@ -4,6 +4,7 @@ function registerBackgroundMessages({
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestConsumerTokenService,
requestConsumptionTrend,
requestOpptyProducts,
@@ -83,6 +84,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}
if (message.type === messageTypes.requestInstallBase) {
requestInstallBase(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load installed base.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestAccountPlans) {
requestAccountPlans(message)
.then(sendResponse)

View File

@@ -76,6 +76,7 @@ function createContentApplication(overrides = {}) {
applyTheme: (...args) => contentTheme.applyTheme(...args),
authSessionState,
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
closeInstallBaseModal: (...args) => installBaseView.closeInstallBaseModal(...args),
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
@@ -197,6 +198,7 @@ function createContentApplication(overrides = {}) {
OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID,
OPPORTUNITY_DETAIL_URL: contracts.OPPORTUNITY_DETAIL_URL,
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
createInstallBaseButton: (...args) => installBaseAction.createInstallBaseButton(...args),
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
@@ -317,6 +319,20 @@ function createContentApplication(overrides = {}) {
requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...args),
environment
});
const installBaseView = createInstallBaseView({
MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
environment
});
const installBaseAction = createInstallBaseAction({
messageTypes,
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
openInstallBaseModal: (...args) => installBaseView.openInstallBaseModal(...args),
environment
});
const consumptionPlansAction = createConsumptionPlansAction({
messageTypes,
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
@@ -425,6 +441,8 @@ function createContentApplication(overrides = {}) {
productsDetails,
aiSummaryContent,
consumptionPlansAction,
installBaseAction,
installBaseView,
consumptionPlansView,
consumptionTrendController,
consumptionChart,

View File

@@ -26,6 +26,7 @@ function createContentModal({
applyTheme,
authSessionState,
closeAccountPlansModal,
closeInstallBaseModal,
closeCustomerFilterPanel,
closeOwnerFilterPanel,
closePreferencesModal,
@@ -432,6 +433,7 @@ function createContentModal({
if (modal) {
closePreferencesModal();
closeAccountPlansModal();
closeInstallBaseModal();
modal.hidden = true;
}
@@ -460,6 +462,10 @@ function createContentModal({
}
if (event.key === "Escape") {
if (closeInstallBaseModal()) {
event.preventDefault();
return;
}
const accountPlansModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
if (accountPlansModal && !accountPlansModal.hidden) {

View File

@@ -11,7 +11,7 @@ function createDebugContentView({
const categories = [
["all", "All"], ["authentication", "Authentication"],
["opportunities", "Opportunities"], ["products", "Products"],
["ai", "AI"], ["consumption", "Consumption"], ["other", "Other"]
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"], ["other", "Other"]
];
let selectedCategory = "all";
const expandedIds = new Set();
@@ -21,6 +21,7 @@ function createDebugContentView({
if (entry.name === "requestOpportunities") return "opportunities";
if (entry.name === "requestOpptyProducts") return "products";
if (entry.name === "requestGenerateAiSummary") return "ai";
if (entry.name === "requestInstallBase") return "installBase";
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService)/.test(entry.name)) return "consumption";
return "other";
}

View File

@@ -0,0 +1,53 @@
function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce, sendRuntimeMessage,
appendBackgroundRequestLog, openInstallBaseModal, environment }) {
const { document } = environment;
const pending = new Map();
async function load(partyNumber) {
const authorizations = await requestConsumerTokenServiceOnce();
const response = await sendRuntimeMessage({
type: messageTypes.requestInstallBase, partyNumber,
authorizations: { accountSummary: authorizations.accountSummary, installBase: authorizations.installBase }
});
appendBackgroundRequestLog(response?.debugRequests);
if (!response?.ok) throw new Error(response?.error || "Unable to load installed base.");
return response.items;
}
function createInstallBaseButton(opportunity) {
const button = document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-action-button";
button.title = "View customer installed base";
button.setAttribute("aria-label", `View installed base for ${opportunity.CustomerAccount?.PartyUniqueName || "customer"}`);
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
icon.setAttribute("viewBox", "0 0 24 24");
icon.setAttribute("aria-hidden", "true");
icon.setAttribute("focusable", "false");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", "M4 3h16v7H4z M4 14h16v7H4z M7 6.5h.01 M7 17.5h.01 M11 6.5h6 M11 17.5h6 M8 10v4 M16 10v4");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.7");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
icon.append(path);
button.append(icon);
button.addEventListener("click", async () => {
const view = openInstallBaseModal(opportunity, button);
const partyNumber = String(opportunity.CustomerAccount?.PartyNumber || "");
try {
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
if (!pending.has(partyNumber)) {
const request = load(partyNumber).finally(() => pending.delete(partyNumber));
pending.set(partyNumber, request);
}
view.render(await pending.get(partyNumber));
} catch (error) {
view.render([], error.message || "Unable to load installed base.");
}
});
return button;
}
return { createInstallBaseButton };
}

View File

@@ -0,0 +1,43 @@
.opportunities-extension-account-plans-dialog.opportunities-extension-install-base-dialog {
width: min(1700px, 100%);
}
.opportunities-extension-install-base-content {
min-width: 0;
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
}
.opportunities-extension-install-base-message { margin: 16px; font-size: 13px; }
.opportunities-extension-install-base-table {
width: 100%; min-width: 1400px; table-layout: fixed; border-collapse: collapse; font-size: 12px;
}
.opportunities-extension-install-base-table col { width: 7%; }
.opportunities-extension-install-base-table .install-base-col-partyName { width: 12%; }
.opportunities-extension-install-base-table .install-base-col-productGroupName { width: 13%; }
.opportunities-extension-install-base-table .install-base-col-productName { width: 13%; }
.opportunities-extension-install-base-table .install-base-col-quantity { width: 5%; }
.opportunities-extension-install-base-table .install-base-col-quantityType { width: 8%; }
.opportunities-extension-install-base-table th,
.opportunities-extension-install-base-table td {
padding: 12px 8px; text-align: left; vertical-align: middle; overflow-wrap: anywhere;
border-bottom: 1px solid #e8e5e1; line-height: 1.4;
}
.opportunities-extension-install-base-table th { position: sticky; top: 0; z-index: 1; background: #faf9f8; }
.opportunities-extension-install-base-table td[data-format="amount"],
.opportunities-extension-install-base-table td[data-format="quantity"] { text-align: right; font-variant-numeric: tabular-nums; }
.opportunities-extension-install-base-table td[data-format="date"] { white-space: nowrap; }
.opportunities-extension-install-base-status {
display: inline-block; padding: 3px 6px; border-radius: 12px; font-size: 10px; font-weight: 600;
color: #5f5a55; background: #eceae8; white-space: nowrap;
}
.opportunities-extension-install-base-status[data-status="ACTIVE"] { color: #17693a; background: #e1f1e5; }
.opportunities-extension-install-base-status[data-status="UNSUPPORTED"] { color: #855300; background: #fff0d6; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th { background: #353331; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table td { border-color: #484541; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-status { color: #dedbd7; background: #484541; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-status[data-status="ACTIVE"] { color: #b8e4c6; background: #214d34; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-status[data-status="UNSUPPORTED"] { color: #ffdfa2; background: #614418; }
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-install-base-table .opportunities-extension-account-plans-skeleton-bar { animation: none; }
}

View File

@@ -0,0 +1,126 @@
function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, environment }) {
const { document } = environment;
let active = null;
const columns = [
["partyName", "Customer"], ["productGroupName", "Product Group"], ["productName", "Product"],
["currentSupport", "Current Support", "amount"], ["orderNumber", "Order Number"],
["supportStatus", "Support Status", "status"], ["quantityType", "Quantity Type"],
["quantity", "Quantity", "quantity"], ["supportStartDate", "Support Start Date", "date"],
["supportEndDate", "Support End Date", "date"], ["licenseValue", "License Value", "amount"],
["shipDate", "Ship Date", "date"]
];
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function closeInstallBaseModal() {
if (!active) return false;
const previous = active;
active = null;
previous.modal.remove();
if (previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function createTable(items, loading = false) {
const table = element("table", "opportunities-extension-install-base-table");
table.setAttribute("aria-label", "Installed base items");
const head = element("thead");
const header = element("tr");
const group = element("colgroup");
columns.forEach(([key, label]) => {
const col = element("col");
col.className = `install-base-col-${key}`;
group.append(col);
const th = element("th", "", label);
th.scope = "col";
header.append(th);
});
head.append(header);
const body = element("tbody");
(loading ? Array.from({ length: 8 }, () => ({})) : items).forEach(item => {
const row = element("tr");
columns.forEach(([key, , type]) => {
const cell = element("td");
if (loading) {
const bar = element("span", "opportunities-extension-account-plans-skeleton-bar");
bar.setAttribute("aria-hidden", "true");
cell.append(bar);
} else {
const value = item[key];
const missing = value === null || value === undefined || value === "";
let text = missing ? "-" : String(value).trim();
if (!missing && type === "date") text = formatOracleResponseDate(value);
if (!missing && (type === "amount" || type === "quantity") && Number.isFinite(Number(value))) {
text = new Intl.NumberFormat("en-US", {
minimumFractionDigits: type === "amount" ? 2 : 0, maximumFractionDigits: type === "amount" ? 2 : 4
}).format(Number(value));
}
if (type === "status") {
const badge = element("span", "opportunities-extension-install-base-status", text);
badge.dataset.status = text.toUpperCase();
cell.append(badge);
} else cell.textContent = text;
if (type) cell.dataset.format = type;
}
row.append(cell);
});
body.append(row);
});
table.append(group, head, body);
return table;
}
function openInstallBaseModal(opportunity, trigger) {
closeInstallBaseModal();
const overlay = document.getElementById(MODAL_ID);
if (!overlay || overlay.hidden) return { render() {} };
const modal = element("section", "opportunities-extension-account-plans-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-busy", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-install-base-title");
const dialog = element("div", "opportunities-extension-account-plans-dialog opportunities-extension-install-base-dialog");
const header = element("header", "opportunities-extension-account-plans-header");
const heading = element("div");
const title = element("h2", "", "Installed Base");
title.id = "opportunities-extension-install-base-title";
heading.append(title, element("p", "", opportunity.CustomerAccount?.PartyUniqueName || "Customer"));
const close = element("button", "opportunities-extension-icon-button");
close.type = "button";
close.title = "Close";
close.setAttribute("aria-label", "Close installed base");
close.append(createCloseIcon());
close.addEventListener("click", closeInstallBaseModal);
header.append(heading, close);
const content = element("div", "opportunities-extension-install-base-content");
content.tabIndex = 0;
content.setAttribute("role", "region");
content.setAttribute("aria-label", "Installed base results");
const status = element("p", "opportunities-extension-install-base-message", "Loading installed base...");
status.setAttribute("role", "status");
content.append(status, createTable([], true));
dialog.append(header, content);
modal.append(dialog);
overlay.append(modal);
active = { modal, trigger };
modal.addEventListener("keydown", event => {
if (event.key === "Tab") {
event.preventDefault();
(document.activeElement === close ? content : close).focus();
}
});
close.focus();
return { render(items, error = "") {
// A closed or replaced dialog must never receive a late response.
if (active?.modal !== modal || overlay.hidden) return;
modal.setAttribute("aria-busy", "false");
content.textContent = "";
status.textContent = error || (items.length ? `${items.length} installed base items` : "No installed base items found for this customer.");
status.setAttribute("role", error ? "alert" : "status");
content.append(status);
if (!error && items.length) content.append(createTable(items));
} };
}
return { openInstallBaseModal, closeInstallBaseModal, createTable };
}

View File

@@ -4,6 +4,7 @@ function createOpportunitiesTable({
OPPORTUNITIES_TABLE_ID,
OPPORTUNITY_DETAIL_URL,
createCloudConsumptionButton,
createInstallBaseButton,
createCopyOpportunityButton,
createDetailLink,
createProductsCountButton,
@@ -148,7 +149,7 @@ function createOpportunitiesTable({
);
const customerActions = document.createElement("span");
customerActions.className = "opportunities-extension-cell-actions";
customerActions.append(createCloudConsumptionButton(item));
customerActions.append(createCloudConsumptionButton(item), createInstallBaseButton(item));
customerCellContent.append(customerLink, customerActions);
cell.append(customerCellContent);
} else {

View File

@@ -0,0 +1,60 @@
function createSpaInstallBase({ ACCOUNT_SUMMARY_URL, fetchJson, findNestedObject, normalizeMessageAuthorization }) {
const endpoint = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-installbase-provider/service/account/";
async function requestInstallBase(message) {
const debugRequests = [];
try {
const partyNumber = String(message.partyNumber || "").trim();
const summaryAuth = normalizeMessageAuthorization(message.authorizations?.accountSummary, "account-ti-authorization");
const installAuth = normalizeMessageAuthorization(message.authorizations?.installBase, "");
if (!partyNumber) throw new Error("Customer Party Number is required.");
if (!summaryAuth.value) throw new Error("ACCP-TS authorization is unavailable.");
if (!installAuth.name || !installAuth.value) throw new Error("SAIB-TS uiaasHeader.label/value is unavailable.");
const summary = await fetchJson(ACCOUNT_SUMMARY_URL, {
method: "POST", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", [summaryAuth.name]: summaryAuth.value },
body: JSON.stringify([{ key: "regId", value: partyNumber }])
}, "requestAccountSummary", debugRequests);
const account = findNestedObject(summary, value =>
(typeof value.registryId === "string" || typeof value.registryId === "number") && String(value.registryId).trim() !== "");
if (!account) throw new Error("registryId was not found in requestAccountSummary.");
const payload = {
text: "", accountHierarchy: "single", accountScope: "all", productScope: "all",
installStatus: ["active", "inactive", "unsupported"], productIds: null,
contractEndDate: { to: null, from: null }, contractStartDate: { to: null, from: null },
purchaseStartDate: { to: null, from: null }, registryIds: [], gecPartyNumbers: null
};
const items = [];
let offset = 0;
// Bound malformed pagination without silently returning a partial result.
for (let page = 0; page < 1000; page += 1) {
const url = `${endpoint}${encodeURIComponent(String(account.registryId).trim())}?orderBy=currentSupport%3Adesc&limit=25&offset=${offset}`;
const result = await fetchJson(url, {
method: "POST", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", [installAuth.name]: installAuth.value },
body: JSON.stringify(payload)
}, "requestInstallBase", debugRequests);
if (!result || !Array.isArray(result.items) || typeof result.hasMore !== "boolean"
|| result.items.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
throw new Error("requestInstallBase returned an invalid collection.");
}
if (result.offset !== undefined && result.offset !== offset) throw new Error("requestInstallBase returned an unexpected offset.");
items.push(...result.items);
if (!result.hasMore) return { ok: true, items, debugRequests };
if (!result.items.length) throw new Error("requestInstallBase pagination made no progress.");
offset += result.items.length;
}
throw new Error("requestInstallBase pagination limit reached.");
} catch (error) {
error.debugRequests = debugRequests;
throw error;
} finally {
// The provider chooses the credential header name; never rely on its spelling for redaction.
for (const entry of debugRequests) {
for (const key of Object.keys(entry.requestHeaders || {})) {
if (!/^(accept|content-type)$/i.test(key)) entry.requestHeaders[key] = "(present)";
}
}
}
}
return { requestInstallBase };
}

View File

@@ -8,6 +8,7 @@ function createMessageTypes() {
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
});
}