Implemented the customer rate card view, including SKU search
This commit is contained in:
@@ -114,10 +114,11 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||
const requestConsumptionPlanDetail = (...args) => spaConsumption.requestConsumptionPlanDetail(...args);
|
||||
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
|
||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||
const runtimeApi = environment.browser || environment.chrome;
|
||||
function start() {
|
||||
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestOpptyProducts, runtimeApi });
|
||||
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
||||
}
|
||||
return {
|
||||
start, contracts,
|
||||
|
||||
@@ -9,6 +9,7 @@ function registerBackgroundMessages({
|
||||
requestConsumerTokenService,
|
||||
requestConsumptionTrend,
|
||||
requestConsumptionPlanDetail,
|
||||
requestRatecardByPlan,
|
||||
requestOpptyProducts,
|
||||
runtimeApi
|
||||
}) {
|
||||
@@ -116,6 +117,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestRatecardByPlan) {
|
||||
requestRatecardByPlan(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load rate card.",
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestConsumptionPlanDetail) {
|
||||
requestConsumptionPlanDetail(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load consumption plan details.",
|
||||
|
||||
@@ -373,16 +373,42 @@ function createContentApplication(overrides = {}) {
|
||||
renderPlanDetailBadges: (...args) => consumptionPlanDetailView.renderPlanDetailBadges(...args),
|
||||
environment
|
||||
});
|
||||
const ratecardModel = createRatecardModel();
|
||||
const consumptionHeaderView = createConsumptionHeaderView({
|
||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||
environment
|
||||
});
|
||||
const ratecardView = createRatecardView({
|
||||
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
||||
columns: ratecardModel.columns,
|
||||
formatValue: (...args) => ratecardModel.formatValue(...args),
|
||||
selectItems: (...args) => ratecardModel.selectItems(...args),
|
||||
createPlanDetails: (...args) => consumptionHeaderView.createDetails(...args),
|
||||
createConsumptionHeader: (...args) => consumptionHeaderView.createHeader(...args),
|
||||
environment
|
||||
});
|
||||
const ratecardController = createRatecardController({
|
||||
messageTypes,
|
||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
openRatecardDrawer: (...args) => ratecardView.openRatecardDrawer(...args),
|
||||
closePlanDetailPopover: (...args) => consumptionPlanDetailView.closePlanDetailPopover(...args),
|
||||
environment
|
||||
});
|
||||
const consumptionPlansView = createConsumptionPlansView({
|
||||
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
||||
DEFAULT_CONSUMPTION_TREND_PERIOD: contracts.DEFAULT_CONSUMPTION_TREND_PERIOD,
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||
createConsumptionHeader: (...args) => consumptionHeaderView.createHeader(...args),
|
||||
createConsumptionTrendSection: (...args) => consumptionTrendController.createConsumptionTrendSection(...args),
|
||||
loadPlanDetailForPanel: (...args) => consumptionPlanDetailController.loadPlanDetailForPanel(...args),
|
||||
closePlanDetailPopover: (...args) => consumptionPlanDetailView.closePlanDetailPopover(...args),
|
||||
createRatecardButton: (...args) => ratecardController.createRatecardButton(...args),
|
||||
closeRatecardDrawer: (...args) => ratecardView.closeRatecardDrawer(...args),
|
||||
createSvgElement: (...args) => consumptionChart.createSvgElement(...args),
|
||||
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||
createPlanDetails: (...args) => consumptionHeaderView.createDetails(...args),
|
||||
getNestedValue: (...args) => opportunitiesFormatters.getNestedValue(...args),
|
||||
loadConsumptionTrendForPanel: (...args) => consumptionTrendController.loadConsumptionTrendForPanel(...args),
|
||||
environment
|
||||
@@ -479,6 +505,9 @@ function createContentApplication(overrides = {}) {
|
||||
consumptionPlansView,
|
||||
consumptionPlanDetailController,
|
||||
consumptionPlanDetailView,
|
||||
ratecardModel,
|
||||
ratecardView,
|
||||
ratecardController,
|
||||
consumptionTrendController,
|
||||
consumptionChart,
|
||||
consumptionModel,
|
||||
|
||||
54
src/features/consumption/header-view.js
Normal file
54
src/features/consumption/header-view.js
Normal file
@@ -0,0 +1,54 @@
|
||||
function createConsumptionHeaderView({ createCloseIcon, formatOracleResponseDate, environment }) {
|
||||
const { document } = environment;
|
||||
function createHeader({ titleId, customerLabel, closeLabel, onClose, titleText = "Cloud Consumption" }) {
|
||||
const header = document.createElement("header");
|
||||
header.className = "opportunities-extension-account-plans-header";
|
||||
const heading = document.createElement("div");
|
||||
const title = document.createElement("h2");
|
||||
title.id = titleId;
|
||||
title.textContent = titleText;
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.textContent = customerLabel;
|
||||
heading.append(title, subtitle);
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.type = "button";
|
||||
closeButton.className = "opportunities-extension-icon-button";
|
||||
closeButton.setAttribute("aria-label", closeLabel);
|
||||
closeButton.title = "Close";
|
||||
closeButton.append(createCloseIcon());
|
||||
closeButton.addEventListener("click", onClose);
|
||||
header.append(heading, closeButton);
|
||||
return header;
|
||||
}
|
||||
function createDetails(plan, { contractAction = null } = {}) {
|
||||
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);
|
||||
if (field.label === "Contract ID" && contractAction) {
|
||||
group.className = "opportunities-extension-contract-row";
|
||||
const value = document.createElement("span");
|
||||
value.textContent = description.textContent;
|
||||
description.textContent = "";
|
||||
description.className = "opportunities-extension-contract-ratecard";
|
||||
description.append(value, contractAction);
|
||||
}
|
||||
group.append(term, description);
|
||||
details.append(group);
|
||||
});
|
||||
return details;
|
||||
}
|
||||
return { createHeader, createDetails };
|
||||
}
|
||||
@@ -2,12 +2,14 @@ function createConsumptionPlansView({
|
||||
ACCOUNT_PLANS_MODAL_ID,
|
||||
DEFAULT_CONSUMPTION_TREND_PERIOD,
|
||||
MODAL_ID,
|
||||
createCloseIcon,
|
||||
createConsumptionHeader,
|
||||
createConsumptionTrendSection,
|
||||
loadPlanDetailForPanel,
|
||||
closePlanDetailPopover,
|
||||
createRatecardButton,
|
||||
closeRatecardDrawer,
|
||||
createSvgElement,
|
||||
formatOracleResponseDate,
|
||||
createPlanDetails,
|
||||
getNestedValue,
|
||||
loadConsumptionTrendForPanel,
|
||||
environment
|
||||
@@ -31,12 +33,14 @@ function createConsumptionPlansView({
|
||||
}
|
||||
|
||||
closePlanDetailPopover();
|
||||
closeRatecardDrawer({ immediate: true, restoreFocus: false });
|
||||
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.dataset.customerLabel = getNestedValue(opportunity, ["CustomerAccount", "PartyUniqueName"]) || opportunity.Name || opportunity.OptyNumber || "Opportunity";
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.setAttribute("aria-labelledby", "opportunities-extension-account-plans-title");
|
||||
@@ -45,26 +49,12 @@ function createConsumptionPlansView({
|
||||
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 header = createConsumptionHeader({
|
||||
titleId: "opportunities-extension-account-plans-title",
|
||||
customerLabel: modal.dataset.customerLabel,
|
||||
closeLabel: "Close cloud consumption",
|
||||
onClose: closeAccountPlansModal
|
||||
});
|
||||
|
||||
const content = document.createElement("div");
|
||||
content.className = "opportunities-extension-account-plans-content";
|
||||
@@ -123,6 +113,7 @@ function createConsumptionPlansView({
|
||||
|
||||
tab.addEventListener("click", () => {
|
||||
closePlanDetailPopover();
|
||||
closeRatecardDrawer({ immediate: true, restoreFocus: false });
|
||||
tabList.querySelectorAll("[role='tab']").forEach((candidate) => {
|
||||
const isSelected = candidate === tab;
|
||||
candidate.setAttribute("aria-selected", String(isSelected));
|
||||
@@ -329,31 +320,12 @@ function createConsumptionPlansView({
|
||||
}
|
||||
|
||||
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;
|
||||
return createPlanDetails(plan, { contractAction: createRatecardButton(plan) });
|
||||
}
|
||||
|
||||
function closeAccountPlansModal() {
|
||||
closePlanDetailPopover();
|
||||
closeRatecardDrawer({ immediate: true, restoreFocus: false });
|
||||
const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
|
||||
|
||||
if (modal) {
|
||||
|
||||
@@ -24,7 +24,7 @@ function createDebugContentView({
|
||||
if (entry.name === "requestGenerateAiSummary") return "ai";
|
||||
if (entry.name === "requestInstallBase") return "installBase";
|
||||
if (entry.name === "requestCustomerServiceRequest") return "serviceRequests";
|
||||
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail)/.test(entry.name)) return "consumption";
|
||||
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
|
||||
return "other";
|
||||
}
|
||||
|
||||
|
||||
38
src/features/ratecard/controller.js
Normal file
38
src/features/ratecard/controller.js
Normal file
@@ -0,0 +1,38 @@
|
||||
function createRatecardController({ messageTypes, requestConsumerTokenServiceOnce, sendRuntimeMessage,
|
||||
appendBackgroundRequestLog, openRatecardDrawer, closePlanDetailPopover, environment }) {
|
||||
const pending = new Map();
|
||||
async function request(planNumber) {
|
||||
const authorizations = await requestConsumerTokenServiceOnce();
|
||||
const response = await sendRuntimeMessage({ type: messageTypes.requestRatecardByPlan, planNumber,
|
||||
authorization: authorizations && authorizations.accountPlans });
|
||||
appendBackgroundRequestLog(response?.debugRequests);
|
||||
if (!response?.ok) throw new Error(response?.error || "Unable to load rate card.");
|
||||
return response.items;
|
||||
}
|
||||
|
||||
async function showRatecard(plan, trigger) {
|
||||
closePlanDetailPopover();
|
||||
const handle = openRatecardDrawer(plan, trigger);
|
||||
if (!handle) return;
|
||||
try {
|
||||
const planNumber = String(plan.subPlanNum ?? "").trim();
|
||||
if (!planNumber) throw new Error("Plan number is unavailable for this account plan.");
|
||||
if (!pending.has(planNumber)) pending.set(planNumber, request(planNumber).finally(() => pending.delete(planNumber)));
|
||||
handle.showItems(await pending.get(planNumber));
|
||||
} catch (error) {
|
||||
handle.showError(error.message || "Unable to load rate card.");
|
||||
}
|
||||
}
|
||||
|
||||
function createRatecardButton(plan) {
|
||||
const button = environment.document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-ratecard-button";
|
||||
button.textContent = "Rate Card";
|
||||
button.title = "View rate card";
|
||||
button.setAttribute("aria-haspopup", "dialog");
|
||||
button.addEventListener("click", () => showRatecard(plan, button));
|
||||
return button;
|
||||
}
|
||||
return { createRatecardButton, showRatecard };
|
||||
}
|
||||
65
src/features/ratecard/model.js
Normal file
65
src/features/ratecard/model.js
Normal file
@@ -0,0 +1,65 @@
|
||||
function createRatecardModel() {
|
||||
const columns = [
|
||||
{ key: "orderNumber", label: "Order Number", width: 7 },
|
||||
{ key: "partNum", label: "Part Number", width: 6 },
|
||||
{ key: "productName", label: "Product Name", width: 22 },
|
||||
{ key: "uom", label: "UOM", width: 10 },
|
||||
{ key: "subLnStartSt", label: "Start Date", type: "date", width: 8 },
|
||||
{ key: "subLnEndDt", label: "End Date", type: "date", width: 8 },
|
||||
{ key: "usageMax", label: "Usage Max", type: "number", width: 5 },
|
||||
{ key: "rateCardDiscountPerc", label: "Discount (%)", type: "percent", width: 7 },
|
||||
{ key: "listPriceUsd", label: "List price (USD)", type: "price", width: 9 },
|
||||
{ key: "netUnitPriceUsd", label: "Net Unit Price (USD)", type: "price", width: 9 },
|
||||
{ key: "overagePriceUsd", label: "Overage Net Unit Price (USD)", type: "price", width: 9 }
|
||||
];
|
||||
const collator = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
|
||||
const prices = new Intl.NumberFormat("en-US", { minimumFractionDigits: 4, maximumFractionDigits: 20 });
|
||||
const numbers = new Intl.NumberFormat("en-US", { maximumFractionDigits: 20 });
|
||||
const months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
||||
const missing = value => value === null || value === undefined || String(value).trim() === "" || String(value).trim() === "-";
|
||||
const numeric = value => !missing(value) && Number.isFinite(Number(value)) ? Number(value) : null;
|
||||
const normalize = value => String(value ?? "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
|
||||
|
||||
function formatValue(item, column) {
|
||||
const value = item[column.key];
|
||||
if (missing(value)) return "-";
|
||||
const number = numeric(value);
|
||||
if (number !== null) {
|
||||
if (column.type === "price") return prices.format(number);
|
||||
if (column.type === "percent") return `${numbers.format(number)}%`;
|
||||
if (column.type === "number") return numbers.format(number);
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function dateValue(value) {
|
||||
const match = String(value).match(/^(\d{1,2})-([a-z]{3})-(\d{4})$/i);
|
||||
if (match) {
|
||||
const month = months.indexOf(match[2].toLowerCase());
|
||||
return month < 0 ? null : Date.UTC(Number(match[3]), month, Number(match[1]));
|
||||
}
|
||||
const iso = String(value).match(/^(\d{4})-(\d{2})-(\d{2})(?:T.*)?$/);
|
||||
return iso ? Date.UTC(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])) : null;
|
||||
}
|
||||
|
||||
function selectItems(items, search = "", sortKey = "", direction = "asc") {
|
||||
const query = normalize(search);
|
||||
const filtered = items.filter(item => !query || columns.some(column =>
|
||||
normalize(item[column.key]).includes(query) || normalize(formatValue(item, column)).includes(query)));
|
||||
const column = columns.find(candidate => candidate.key === sortKey);
|
||||
if (!column) return filtered;
|
||||
const sortValue = item => {
|
||||
const value = item[column.key];
|
||||
if (missing(value)) return null;
|
||||
if (column.type === "date") return dateValue(value);
|
||||
return ["price", "number", "percent"].includes(column.type) ? numeric(value) : String(value);
|
||||
};
|
||||
return filtered.sort((a, b) => {
|
||||
const left = sortValue(a), right = sortValue(b);
|
||||
if (left === null || right === null) return left === right ? 0 : left === null ? 1 : -1;
|
||||
const compared = typeof left === "number" ? left - right : collator.compare(left, right);
|
||||
return direction === "desc" ? -compared : compared;
|
||||
});
|
||||
}
|
||||
return { columns, formatValue, selectItems };
|
||||
}
|
||||
141
src/features/ratecard/styles.css
Normal file
141
src/features/ratecard/styles.css
Normal file
@@ -0,0 +1,141 @@
|
||||
.opportunities-extension-account-plan-details > .opportunities-extension-contract-row {
|
||||
align-items: center;
|
||||
}
|
||||
.opportunities-extension-contract-row > dt,
|
||||
.opportunities-extension-contract-row > dd,
|
||||
.opportunities-extension-contract-ratecard > span {
|
||||
line-height: 20px;
|
||||
}
|
||||
.opportunities-extension-contract-ratecard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 10px;
|
||||
}
|
||||
.opportunities-extension-ratecard-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: center;
|
||||
box-sizing: border-box;
|
||||
height: 28px;
|
||||
margin: 0;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #8aaeb9;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #006b80;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.opportunities-extension-ratecard-button:hover { background: #e8f4f6; }
|
||||
.opportunities-extension-ratecard-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
transition: background-color 320ms ease;
|
||||
}
|
||||
.opportunities-extension-ratecard-overlay[data-open="true"] { background: rgba(0, 0, 0, .32); }
|
||||
.opportunities-extension-ratecard-drawer {
|
||||
--ratecard-bg: #fff;
|
||||
--ratecard-heading: #faf9f8;
|
||||
--ratecard-text: #312d2a;
|
||||
--ratecard-muted: #615a55;
|
||||
--ratecard-border: #dedbd8;
|
||||
--ratecard-accent: #00758f;
|
||||
--ratecard-skeleton: #eae7e4;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto minmax(0, 1fr);
|
||||
width: min(1520px, calc(100% - 48px));
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: var(--ratecard-bg);
|
||||
color: var(--ratecard-text);
|
||||
box-shadow: -6px 0 24px rgba(0, 0, 0, .2);
|
||||
transform: translateX(100%);
|
||||
transition: transform 320ms cubic-bezier(.22, .61, .36, 1);
|
||||
}
|
||||
.opportunities-extension-ratecard-overlay[data-open="true"] .opportunities-extension-ratecard-drawer { transform: translateX(0); }
|
||||
.opportunities-extension-ratecard-header > div { min-width: 0; overflow-wrap: anywhere; }
|
||||
.opportunities-extension-ratecard-plan-details { padding: 12px 20px 0; }
|
||||
.opportunities-extension-ratecard-toolbar { display: flex; align-items: center; gap: 16px; padding: 12px 20px; }
|
||||
.opportunities-extension-ratecard-search {
|
||||
width: min(360px, 100%);
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--ratecard-muted);
|
||||
border-radius: 4px;
|
||||
background: var(--ratecard-bg);
|
||||
color: var(--ratecard-text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.opportunities-extension-ratecard-search::placeholder { color: var(--ratecard-muted); opacity: 1; }
|
||||
.opportunities-extension-ratecard-count { color: var(--ratecard-muted); font-size: 12px; white-space: nowrap; }
|
||||
.opportunities-extension-ratecard-viewport { overflow: auto; min-height: 0; min-width: 0; scrollbar-gutter: stable; }
|
||||
.opportunities-extension-ratecard-table { width: 100%; min-width: 1100px; table-layout: fixed; border-collapse: separate; border-spacing: 0; font-size: 12px; }
|
||||
.opportunities-extension-ratecard-table th, .opportunities-extension-ratecard-table td {
|
||||
padding: 11px 10px;
|
||||
border-bottom: 1px solid var(--ratecard-border);
|
||||
vertical-align: top;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
}
|
||||
.opportunities-extension-ratecard-table th { position: sticky; top: 0; z-index: 1; background: var(--ratecard-heading); }
|
||||
.opportunities-extension-ratecard-sort {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 12px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.opportunities-extension-ratecard-sort-icon { color: var(--ratecard-accent); text-align: center; }
|
||||
.opportunities-extension-ratecard-table .opportunities-extension-ratecard-numeric { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.opportunities-extension-ratecard-table .opportunities-extension-ratecard-notice { padding: 28px 20px; text-align: center; }
|
||||
.opportunities-extension-ratecard-notice [role="alert"] { color: #b3261e; }
|
||||
.opportunities-extension-ratecard-skeleton { display: block; width: 85%; height: 14px; border-radius: 3px; background: var(--ratecard-skeleton); animation: opportunities-ratecard-pulse 1.1s ease-in-out infinite alternate; }
|
||||
.opportunities-extension-ratecard-drawer :focus-visible, .opportunities-extension-ratecard-button:focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
|
||||
.opportunities-extension-ratecard-overlay[data-closing="true"] { pointer-events: none; background: rgba(0, 0, 0, 0); transition-duration: 180ms; }
|
||||
.opportunities-extension-ratecard-overlay[data-closing="true"] .opportunities-extension-ratecard-drawer { transform: translateX(100%); transition-duration: 180ms; }
|
||||
[data-theme="dark"] .opportunities-extension-ratecard-drawer {
|
||||
--ratecard-bg: #292725;
|
||||
--ratecard-heading: #35322f;
|
||||
--ratecard-text: #f5f3f1;
|
||||
--ratecard-muted: #c2bcb6;
|
||||
--ratecard-border: #514c47;
|
||||
--ratecard-accent: #59d1e5;
|
||||
--ratecard-skeleton: #514c47;
|
||||
}
|
||||
[data-theme="dark"] .opportunities-extension-ratecard-button { color: #59d1e5; border-color: #6298a2; background: #292725; }
|
||||
[data-theme="dark"] .opportunities-extension-ratecard-button:hover { background: #313f40; }
|
||||
[data-theme="dark"] .opportunities-extension-ratecard-notice [role="alert"] { color: #ffb4ab; }
|
||||
[data-theme="dark"] .opportunities-extension-ratecard-drawer :focus-visible, [data-theme="dark"] .opportunities-extension-ratecard-button:focus-visible { outline-color: #59d1e5; }
|
||||
@keyframes opportunities-ratecard-pulse { from { opacity: .45; } to { opacity: 1; } }
|
||||
@media (max-width: 720px) {
|
||||
.opportunities-extension-ratecard-drawer { width: 100%; }
|
||||
.opportunities-extension-ratecard-toolbar { flex-wrap: wrap; gap: 8px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.opportunities-extension-ratecard-overlay, .opportunities-extension-ratecard-drawer, .opportunities-extension-ratecard-skeleton,
|
||||
.opportunities-extension-ratecard-overlay[data-closing="true"], .opportunities-extension-ratecard-overlay[data-closing="true"] .opportunities-extension-ratecard-drawer { animation: none; transition: none; }
|
||||
}
|
||||
208
src/features/ratecard/view.js
Normal file
208
src/features/ratecard/view.js
Normal file
@@ -0,0 +1,208 @@
|
||||
function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, selectItems, createConsumptionHeader, createPlanDetails, environment }) {
|
||||
const { document, setTimeout, clearTimeout, requestAnimationFrame, cancelAnimationFrame } = environment;
|
||||
let active = null;
|
||||
let closing = null;
|
||||
let closeTimer = null;
|
||||
|
||||
function closeRatecardDrawer({ immediate = false, restoreFocus = true } = {}) {
|
||||
if (closeTimer !== null) clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
closing?.remove();
|
||||
closing = null;
|
||||
if (!active) return;
|
||||
const previous = active;
|
||||
active = null;
|
||||
if (previous.openFrame !== null) cancelAnimationFrame(previous.openFrame);
|
||||
previous.background.inert = previous.wasInert;
|
||||
if (immediate) previous.root.remove();
|
||||
else {
|
||||
closing = previous.root;
|
||||
closing.dataset.closing = "true";
|
||||
closing.inert = true;
|
||||
closeTimer = setTimeout(() => {
|
||||
closing?.remove();
|
||||
closing = null;
|
||||
closeTimer = null;
|
||||
}, 200);
|
||||
}
|
||||
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function openRatecardDrawer(plan, trigger) {
|
||||
const parent = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
|
||||
const background = parent?.querySelector(".opportunities-extension-account-plans-dialog");
|
||||
if (!parent || parent.hidden || !background) return null;
|
||||
closeRatecardDrawer({ immediate: true, restoreFocus: false });
|
||||
const element = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = `opportunities-extension-ratecard-${className}`;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
const root = element("div", "overlay");
|
||||
root.dataset.open = "false";
|
||||
const panel = element("section", "drawer");
|
||||
panel.setAttribute("role", "dialog");
|
||||
panel.setAttribute("aria-modal", "true");
|
||||
panel.setAttribute("aria-labelledby", "opportunities-extension-ratecard-title");
|
||||
panel.setAttribute("aria-busy", "true");
|
||||
const header = createConsumptionHeader({
|
||||
titleId: "opportunities-extension-ratecard-title",
|
||||
titleText: "Rate Card",
|
||||
customerLabel: parent.dataset.customerLabel,
|
||||
closeLabel: "Close rate card",
|
||||
onClose: () => closeRatecardDrawer()
|
||||
});
|
||||
header.classList.add("opportunities-extension-ratecard-header");
|
||||
const close = header.querySelector("button");
|
||||
const details = createPlanDetails(plan);
|
||||
details.classList.add("opportunities-extension-ratecard-plan-details");
|
||||
|
||||
const toolbar = element("div", "toolbar");
|
||||
const search = element("input", "search");
|
||||
search.type = "search";
|
||||
search.placeholder = "Search rate card";
|
||||
search.setAttribute("aria-label", "Search rate card");
|
||||
const count = element("span", "count", "Loading rate card...");
|
||||
count.setAttribute("role", "status");
|
||||
toolbar.append(search, count);
|
||||
const viewport = element("div", "viewport");
|
||||
viewport.tabIndex = 0;
|
||||
viewport.setAttribute("role", "region");
|
||||
viewport.setAttribute("aria-label", "Rate card items");
|
||||
const table = element("table", "table");
|
||||
const caption = element("caption", null, "Rate card items");
|
||||
caption.className = "opportunities-extension-visually-hidden";
|
||||
const colgroup = element("colgroup");
|
||||
const head = element("thead");
|
||||
const headRow = element("tr");
|
||||
const sortHeaders = [];
|
||||
const current = { root, background, wasInert: background.inert, trigger, items: [], status: "loading", sortKey: "", direction: "asc", openFrame: null };
|
||||
active = current;
|
||||
background.inert = true;
|
||||
const alive = () => active === current && root.isConnected && !parent.hidden;
|
||||
columns.forEach(column => {
|
||||
const col = element("col");
|
||||
col.style.width = `${column.width}%`;
|
||||
colgroup.append(col);
|
||||
const th = element("th");
|
||||
th.scope = "col";
|
||||
th.setAttribute("aria-sort", "none");
|
||||
const sort = element("button", "sort");
|
||||
sort.type = "button";
|
||||
sort.title = `Sort by ${column.label}`;
|
||||
const label = element("span", null, column.label);
|
||||
const icon = element("span", "sort-icon", "\u2195");
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
sort.append(label, icon);
|
||||
sort.addEventListener("click", () => {
|
||||
current.direction = current.sortKey === column.key && current.direction === "asc" ? "desc" : "asc";
|
||||
current.sortKey = column.key;
|
||||
updateSortHeaders();
|
||||
if (current.status === "ready") renderRows();
|
||||
});
|
||||
sortHeaders.push({ th, icon, key: column.key });
|
||||
th.append(sort);
|
||||
headRow.append(th);
|
||||
});
|
||||
head.append(headRow);
|
||||
const body = element("tbody");
|
||||
table.append(caption, colgroup, head, body);
|
||||
viewport.append(table);
|
||||
panel.append(header, details, toolbar, viewport);
|
||||
root.append(panel);
|
||||
parent.append(root);
|
||||
|
||||
function updateSortHeaders() {
|
||||
sortHeaders.forEach(({ th, icon, key }) => {
|
||||
const selected = key === current.sortKey;
|
||||
th.setAttribute("aria-sort", selected ? current.direction === "asc" ? "ascending" : "descending" : "none");
|
||||
icon.textContent = !selected ? "\u2195" : current.direction === "asc" ? "\u2191" : "\u2193";
|
||||
});
|
||||
}
|
||||
function showNotice(message, error = false) {
|
||||
body.textContent = "";
|
||||
const row = element("tr");
|
||||
const cell = element("td", "notice");
|
||||
cell.colSpan = columns.length;
|
||||
const text = element("p", null, message);
|
||||
if (error) text.setAttribute("role", "alert");
|
||||
cell.append(text);
|
||||
row.append(cell);
|
||||
body.append(row);
|
||||
}
|
||||
function renderRows() {
|
||||
if (!alive()) return;
|
||||
body.textContent = "";
|
||||
const visible = selectItems(current.items, search.value, current.sortKey, current.direction);
|
||||
count.textContent = `${visible.length} of ${current.items.length} items`;
|
||||
if (!visible.length) {
|
||||
showNotice(current.items.length ? "No matching rate card items." : "No rate card items found for this plan.");
|
||||
}
|
||||
visible.forEach(item => {
|
||||
const row = element("tr");
|
||||
columns.forEach(column => {
|
||||
const cell = element("td", null, formatValue(item, column));
|
||||
if (["price", "number", "percent"].includes(column.type)) cell.className = "opportunities-extension-ratecard-numeric";
|
||||
row.append(cell);
|
||||
});
|
||||
body.append(row);
|
||||
});
|
||||
viewport.scrollTop = 0;
|
||||
}
|
||||
for (let index = 0; index < 7; index += 1) {
|
||||
const row = element("tr");
|
||||
row.setAttribute("aria-hidden", "true");
|
||||
columns.forEach(() => {
|
||||
const cell = element("td");
|
||||
const skeleton = element("span", "skeleton");
|
||||
cell.append(skeleton);
|
||||
row.append(cell);
|
||||
});
|
||||
body.append(row);
|
||||
}
|
||||
search.addEventListener("input", () => { if (current.status === "ready") renderRows(); });
|
||||
root.addEventListener("click", event => { if (event.target === root) closeRatecardDrawer(); });
|
||||
root.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeRatecardDrawer();
|
||||
} else if (event.key === "Tab") {
|
||||
const controls = [close, search, viewport, ...headRow.querySelectorAll("button")];
|
||||
const index = controls.indexOf(document.activeElement);
|
||||
if (index < 0 || (event.shiftKey && index === 0) || (!event.shiftKey && index === controls.length - 1)) {
|
||||
event.preventDefault();
|
||||
controls[event.shiftKey ? controls.length - 1 : 0].focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
// Focusing offscreen content must not scroll the clipped overlay and cancel the slide.
|
||||
search.focus({ preventScroll: true });
|
||||
// Paint the offscreen position before transitioning to the fully open position.
|
||||
current.openFrame = requestAnimationFrame(() => {
|
||||
if (!alive()) return;
|
||||
current.openFrame = requestAnimationFrame(() => {
|
||||
current.openFrame = null;
|
||||
if (alive()) root.dataset.open = "true";
|
||||
});
|
||||
});
|
||||
return {
|
||||
showItems(items) {
|
||||
if (!alive()) return;
|
||||
current.items = items;
|
||||
current.status = "ready";
|
||||
panel.setAttribute("aria-busy", "false");
|
||||
renderRows();
|
||||
},
|
||||
showError(message) {
|
||||
if (!alive()) return;
|
||||
current.status = "error";
|
||||
panel.setAttribute("aria-busy", "false");
|
||||
count.textContent = "Rate card unavailable";
|
||||
showNotice(message, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
return { openRatecardDrawer, closeRatecardDrawer };
|
||||
}
|
||||
@@ -79,5 +79,29 @@ function createSpaConsumption({
|
||||
}
|
||||
}
|
||||
|
||||
return { requestConsumptionTrend, requestConsumptionPlanDetail };
|
||||
async function requestRatecardByPlan(message) {
|
||||
const debugRequests = [];
|
||||
try {
|
||||
const planNumber = typeof message.planNumber === "string" ? message.planNumber.trim() : "";
|
||||
const authorization = normalizeMessageAuthorization(message.authorization, "g2m-authorization");
|
||||
if (!planNumber) throw new Error("Plan number is required for the rate card.");
|
||||
if (!authorization.value) throw new Error("OCICONS-TS authorization is unavailable for the rate card.");
|
||||
const items = await fetchJson("https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/ratecard/byPlan", {
|
||||
method: "POST", credentials: "include", cache: "no-store",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json", "G2m-Authorization": authorization.value },
|
||||
body: JSON.stringify({ planNumber, recordsSize: 10000 })
|
||||
}, "requestRatecardByPlan", debugRequests);
|
||||
if (!Array.isArray(items) || items.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
|
||||
throw new Error("requestRatecardByPlan returned invalid rate card items.");
|
||||
}
|
||||
return { ok: true, planNumber, items, debugRequests };
|
||||
} catch (error) {
|
||||
const entry = debugRequests[debugRequests.length - 1];
|
||||
if (entry && entry.status === "success") { entry.status = "failed"; entry.error = error.message; }
|
||||
error.debugRequests = debugRequests;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan };
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ function createMessageTypes() {
|
||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail"
|
||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
||||
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user