General source code refactoring

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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