General source code refactoring
This commit is contained in:
103
src/features/ai-summary/content.js
Normal file
103
src/features/ai-summary/content.js
Normal file
@@ -0,0 +1,103 @@
|
||||
function createAiSummaryContent({
|
||||
aiSummaryState,
|
||||
invalidateSalesCommonToken,
|
||||
logRequestFailure,
|
||||
logRequestStart,
|
||||
logRequestSuccess,
|
||||
readResponseBodyForDebug,
|
||||
requestTokenRelaySalesCommon,
|
||||
environment
|
||||
}) {
|
||||
const { document, fetch } = environment;
|
||||
function requestGenerateAiSummary(optyNumber) {
|
||||
const key = String(optyNumber);
|
||||
const existing = aiSummaryState.aiSummariesByOptyNumber.get(key);
|
||||
if (existing && existing.status === "loading") {
|
||||
return existing.promise;
|
||||
}
|
||||
|
||||
const state = { status: "loading", summaryText: "", error: "", promise: null };
|
||||
aiSummaryState.aiSummariesByOptyNumber.set(key, state);
|
||||
state.promise = (async () => {
|
||||
let entry;
|
||||
try {
|
||||
let token = await requestTokenRelaySalesCommon();
|
||||
if (!token) {
|
||||
throw new Error("Authentication is required to generate the summary.");
|
||||
}
|
||||
const path = "/api/sales-common/gen-ai/v1/summarizations/generate";
|
||||
const url = `https://eeho.fa.us2.oraclecloud.com${path}`;
|
||||
const payload = { path, promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: key };
|
||||
let response;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const headers = { Accept: "application/json", Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
||||
entry = logRequestStart({
|
||||
name: "requestGenerateAiSummary", method: "POST", url, headers,
|
||||
body: JSON.stringify(payload, null, 2), metadata: { optyNumber: key, attempt: attempt + 1 }
|
||||
});
|
||||
response = await fetch(url, {
|
||||
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
|
||||
});
|
||||
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
|
||||
if (response.status !== 401 && response.status !== 403) {
|
||||
break;
|
||||
}
|
||||
// A late failure for an old token must not discard a newly refreshed token.
|
||||
invalidateSalesCommonToken(token);
|
||||
if (attempt === 0) {
|
||||
token = await requestTokenRelaySalesCommon();
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`requestGenerateAiSummary failed with status ${response.status}.`);
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (error) {
|
||||
throw new Error("requestGenerateAiSummary returned an invalid JSON response.");
|
||||
}
|
||||
if (!data || typeof data.summaryText !== "string" || !data.summaryText.trim()) {
|
||||
throw new Error("requestGenerateAiSummary returned an empty summary.");
|
||||
}
|
||||
state.summaryText = data.summaryText;
|
||||
state.status = "ready";
|
||||
} catch (error) {
|
||||
state.status = "error";
|
||||
state.error = error.message || "Unable to generate the AI summary.";
|
||||
if (entry) {
|
||||
logRequestFailure(entry, state.error);
|
||||
}
|
||||
} finally {
|
||||
// Update only visible summaries, without reopening rows or rebuilding the table.
|
||||
document.querySelectorAll(".opportunities-extension-ai-summary").forEach((alert) => {
|
||||
if (alert.dataset.optyNumber === key) {
|
||||
alert.replaceWith(createAiSummaryAlert(key));
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
return state.promise;
|
||||
}
|
||||
|
||||
function createAiSummaryAlert(optyNumber) {
|
||||
const state = aiSummaryState.aiSummariesByOptyNumber.get(optyNumber);
|
||||
const alert = document.createElement("div");
|
||||
alert.className = "opportunities-extension-ai-summary";
|
||||
alert.dataset.optyNumber = optyNumber;
|
||||
alert.setAttribute("role", "status");
|
||||
alert.setAttribute("aria-live", "polite");
|
||||
alert.setAttribute("aria-busy", String(!state || state.status === "loading"));
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = "Summary generated by AI";
|
||||
const description = document.createElement("p");
|
||||
description.textContent = !state || state.status === "loading"
|
||||
? "Generating summary..."
|
||||
: state.status === "error" ? state.error : state.summaryText;
|
||||
alert.classList.toggle("opportunities-extension-ai-summary-error", Boolean(state && state.status === "error"));
|
||||
alert.append(title, description);
|
||||
return alert;
|
||||
}
|
||||
|
||||
return { requestGenerateAiSummary, createAiSummaryAlert };
|
||||
}
|
||||
5
src/features/ai-summary/state.js
Normal file
5
src/features/ai-summary/state.js
Normal file
@@ -0,0 +1,5 @@
|
||||
function createAiSummaryState() {
|
||||
return {
|
||||
aiSummariesByOptyNumber: new Map()
|
||||
};
|
||||
}
|
||||
383
src/features/consumption/chart.js
Normal file
383
src/features/consumption/chart.js
Normal 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 };
|
||||
}
|
||||
155
src/features/consumption/model.js
Normal file
155
src/features/consumption/model.js
Normal 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 };
|
||||
}
|
||||
93
src/features/consumption/plans-action.js
Normal file
93
src/features/consumption/plans-action.js
Normal 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 };
|
||||
}
|
||||
5
src/features/consumption/plans-state.js
Normal file
5
src/features/consumption/plans-state.js
Normal file
@@ -0,0 +1,5 @@
|
||||
function createConsumptionPlansState() {
|
||||
return {
|
||||
accountPlansRequestState: new Map()
|
||||
};
|
||||
}
|
||||
358
src/features/consumption/plans-view.js
Normal file
358
src/features/consumption/plans-view.js
Normal 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 };
|
||||
}
|
||||
171
src/features/consumption/trend-controller.js
Normal file
171
src/features/consumption/trend-controller.js
Normal 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 };
|
||||
}
|
||||
7
src/features/consumption/trend-state.js
Normal file
7
src/features/consumption/trend-state.js
Normal file
@@ -0,0 +1,7 @@
|
||||
function createConsumptionTrendState() {
|
||||
return {
|
||||
consumptionTrendCache: new Map(),
|
||||
consumptionTrendWindowOffsets: new Map(),
|
||||
consumptionTrendSeriesFilters: new Map()
|
||||
};
|
||||
}
|
||||
198
src/features/debug/content-log.js
Normal file
198
src/features/debug/content-log.js
Normal file
@@ -0,0 +1,198 @@
|
||||
function createDebugContentLog({
|
||||
DEBUG_BODY_LIMIT,
|
||||
debugState,
|
||||
renderDebugPanel
|
||||
}) {
|
||||
function logRequestStart(details) {
|
||||
const entry = {
|
||||
id: debugState.requestLog.length + 1,
|
||||
name: details.name,
|
||||
method: details.method,
|
||||
url: details.url,
|
||||
headers: details.headers,
|
||||
status: "pending",
|
||||
startedAt: new Date(),
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
httpStatus: null,
|
||||
statusText: "",
|
||||
requestHeaders: details.headers,
|
||||
requestBody: details.body || "",
|
||||
metadata: details.metadata || {},
|
||||
responseHeaders: {},
|
||||
responseBody: "",
|
||||
responseBodyTruncated: false,
|
||||
error: ""
|
||||
};
|
||||
|
||||
debugState.requestLog.push(entry);
|
||||
renderDebugPanel();
|
||||
return entry;
|
||||
}
|
||||
|
||||
function logRequestSuccess(entry, response, responseBody) {
|
||||
entry.status = response.ok ? "success" : "failed";
|
||||
entry.completedAt = new Date();
|
||||
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
|
||||
entry.httpStatus = response.status;
|
||||
entry.statusText = response.statusText || "";
|
||||
entry.responseHeaders = headersToObject(response.headers);
|
||||
entry.responseBody = responseBody.value;
|
||||
entry.responseBodyTruncated = responseBody.truncated;
|
||||
renderDebugPanel();
|
||||
}
|
||||
|
||||
function logRequestFailure(entry, errorMessage) {
|
||||
entry.status = "failed";
|
||||
entry.completedAt = new Date();
|
||||
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
|
||||
entry.error = errorMessage;
|
||||
renderDebugPanel();
|
||||
}
|
||||
|
||||
function appendBackgroundRequestLog(entries) {
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
debugState.requestLog.push({
|
||||
id: debugState.requestLog.length + 1,
|
||||
name: entry.name || "backgroundRequest",
|
||||
method: entry.method || "GET",
|
||||
url: entry.url || "",
|
||||
headers: entry.requestHeaders || {},
|
||||
status: entry.status || "failed",
|
||||
startedAt: new Date(entry.startedAt),
|
||||
completedAt: entry.completedAt ? new Date(entry.completedAt) : null,
|
||||
durationMs: entry.durationMs,
|
||||
httpStatus: entry.httpStatus,
|
||||
statusText: entry.statusText || "",
|
||||
requestHeaders: entry.requestHeaders || {},
|
||||
requestBody: entry.requestBody || "",
|
||||
metadata: entry.metadata || { executionContext: "background" },
|
||||
responseHeaders: entry.responseHeaders || {},
|
||||
responseBody: entry.responseBody || "",
|
||||
responseBodyTruncated: Boolean(entry.responseBodyTruncated),
|
||||
error: entry.error || ""
|
||||
});
|
||||
});
|
||||
renderDebugPanel();
|
||||
}
|
||||
|
||||
async function readResponseBodyForDebug(response) {
|
||||
try {
|
||||
const text = await response.clone().text();
|
||||
return createDebugBody(text);
|
||||
} catch (error) {
|
||||
return {
|
||||
value: `Unable to read response body: ${error.message || "unknown error"}`,
|
||||
truncated: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createDebugBody(text) {
|
||||
if (text.length > DEBUG_BODY_LIMIT) {
|
||||
return {
|
||||
value: `${text.slice(0, DEBUG_BODY_LIMIT)}\n... truncated ${text.length - DEBUG_BODY_LIMIT} characters`,
|
||||
truncated: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: text || "(empty response body)",
|
||||
truncated: false
|
||||
};
|
||||
}
|
||||
|
||||
function headersToObject(headers) {
|
||||
const headerMap = {};
|
||||
|
||||
headers.forEach((value, key) => {
|
||||
headerMap[key] = value;
|
||||
});
|
||||
|
||||
return headerMap;
|
||||
}
|
||||
|
||||
function formatHeaderBlock(headers) {
|
||||
const entries = Object.entries(headers);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return "(none)";
|
||||
}
|
||||
|
||||
return entries
|
||||
.map(([key, value]) => `${key}: ${debugState.debugSecretsVisible || !isSensitiveDebugKey(key)
|
||||
? value
|
||||
: formatHiddenDebugValue(value)}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function formatDebugBody(body) {
|
||||
const text = body || "(none)";
|
||||
|
||||
if (debugState.debugSecretsVisible) {
|
||||
return text;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(maskSensitiveDebugValue(JSON.parse(text)), null, 2);
|
||||
} catch (error) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function maskSensitiveDebugValue(value, parentKey = "") {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => maskSensitiveDebugValue(item, parentKey));
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(value).map(([key, child]) => {
|
||||
const isSensitive = isSensitiveDebugKey(key)
|
||||
|| (key === "value" && /uiaasHeader|uiassHeader/i.test(parentKey));
|
||||
return [key, isSensitive
|
||||
? formatHiddenDebugValue(child)
|
||||
: maskSensitiveDebugValue(child, key)];
|
||||
}));
|
||||
}
|
||||
|
||||
function isSensitiveDebugKey(key) {
|
||||
return /authorization|cookie|password|secret|(^|[_-])token($|[_-])|token$/i.test(key);
|
||||
}
|
||||
|
||||
function formatHiddenDebugValue(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "(missing)";
|
||||
}
|
||||
|
||||
return `(present, ${String(value).length} characters)`;
|
||||
}
|
||||
|
||||
function formatMetadataBlock(metadata) {
|
||||
const entries = Object.entries(metadata);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return "(none)";
|
||||
}
|
||||
|
||||
return entries
|
||||
.map(([key, value]) => `${key}: ${value || "(none)"}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function maskToken(token) {
|
||||
if (token.length <= 10) {
|
||||
return "(present)";
|
||||
}
|
||||
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
return { logRequestStart, logRequestSuccess, logRequestFailure, appendBackgroundRequestLog, readResponseBodyForDebug, createDebugBody, headersToObject, formatHeaderBlock, formatDebugBody, maskSensitiveDebugValue, isSensitiveDebugKey, formatHiddenDebugValue, formatMetadataBlock, maskToken };
|
||||
}
|
||||
174
src/features/debug/content-view.js
Normal file
174
src/features/debug/content-view.js
Normal file
@@ -0,0 +1,174 @@
|
||||
function createDebugContentView({
|
||||
DEBUG_PANEL_ID,
|
||||
MODAL_ID,
|
||||
debugState,
|
||||
formatDebugBody,
|
||||
formatHeaderBlock,
|
||||
formatMetadataBlock,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
function toggleDebugPanel() {
|
||||
const modal = document.getElementById(MODAL_ID);
|
||||
|
||||
if (!modal || modal.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panel = document.getElementById(DEBUG_PANEL_ID);
|
||||
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
panel.hidden = !panel.hidden;
|
||||
renderDebugPanel();
|
||||
}
|
||||
|
||||
function renderDebugPanel() {
|
||||
const panel = document.getElementById(DEBUG_PANEL_ID);
|
||||
|
||||
if (!panel || panel.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
panel.textContent = "";
|
||||
|
||||
const panelHeader = document.createElement("header");
|
||||
panelHeader.className = "opportunities-extension-debug-panel-header";
|
||||
|
||||
const heading = document.createElement("div");
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = "Debug Requests";
|
||||
|
||||
const summary = document.createElement("p");
|
||||
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`;
|
||||
heading.append(title, summary);
|
||||
|
||||
const visibilityButton = document.createElement("button");
|
||||
visibilityButton.type = "button";
|
||||
visibilityButton.className = "opportunities-extension-icon-button opportunities-extension-debug-visibility-button";
|
||||
visibilityButton.setAttribute("aria-pressed", String(debugState.debugSecretsVisible));
|
||||
visibilityButton.setAttribute("aria-label", debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values");
|
||||
visibilityButton.title = debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values";
|
||||
visibilityButton.append(createDebugVisibilityIcon(debugState.debugSecretsVisible));
|
||||
visibilityButton.addEventListener("click", () => {
|
||||
debugState.debugSecretsVisible = !debugState.debugSecretsVisible;
|
||||
renderDebugPanel();
|
||||
});
|
||||
panelHeader.append(heading, visibilityButton);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "opportunities-extension-debug-list";
|
||||
|
||||
if (debugState.requestLog.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "opportunities-extension-debug-empty";
|
||||
empty.textContent = "No requests recorded yet.";
|
||||
list.append(empty);
|
||||
}
|
||||
|
||||
debugState.requestLog.forEach((entry) => {
|
||||
const item = document.createElement("details");
|
||||
item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`;
|
||||
|
||||
const heading = document.createElement("summary");
|
||||
heading.className = "opportunities-extension-debug-heading";
|
||||
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = `${entry.id}. ${entry.name}`;
|
||||
|
||||
const httpStatus = document.createElement("span");
|
||||
httpStatus.className = "opportunities-extension-debug-http-status";
|
||||
httpStatus.textContent = entry.httpStatus ? String(entry.httpStatus) : "pending";
|
||||
|
||||
const status = document.createElement("span");
|
||||
status.className = "opportunities-extension-debug-status";
|
||||
status.textContent = entry.status;
|
||||
|
||||
const summaryMeta = document.createElement("span");
|
||||
summaryMeta.className = "opportunities-extension-debug-summary-meta";
|
||||
summaryMeta.append(httpStatus, status);
|
||||
|
||||
heading.append(name, summaryMeta);
|
||||
|
||||
const lines = [
|
||||
`${entry.method} ${entry.url}`,
|
||||
`Started: ${formatDebugTime(entry.startedAt)}`,
|
||||
entry.completedAt ? `Completed: ${formatDebugTime(entry.completedAt)} (${entry.durationMs}ms)` : "Completed: pending",
|
||||
entry.httpStatus ? `HTTP: ${entry.httpStatus} ${entry.statusText}`.trim() : "",
|
||||
entry.error ? `Error: ${entry.error}` : "",
|
||||
`Response body truncated: ${entry.responseBodyTruncated ? "yes" : "no"}`
|
||||
].filter(Boolean);
|
||||
|
||||
const details = document.createElement("div");
|
||||
details.className = "opportunities-extension-debug-details";
|
||||
appendDebugBlock(details, "Request", lines.join("\n"));
|
||||
appendDebugBlock(details, "Request Metadata", formatMetadataBlock(entry.metadata));
|
||||
appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders));
|
||||
appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody));
|
||||
appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders));
|
||||
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody));
|
||||
|
||||
item.append(heading, details);
|
||||
list.append(item);
|
||||
});
|
||||
|
||||
panel.append(panelHeader, list);
|
||||
}
|
||||
|
||||
function createDebugVisibilityIcon(secretsVisible) {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const eye = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
const pupil = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.setAttribute("focusable", "false");
|
||||
eye.setAttribute("d", "M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12Z");
|
||||
pupil.setAttribute("cx", "12");
|
||||
pupil.setAttribute("cy", "12");
|
||||
pupil.setAttribute("r", "2.75");
|
||||
[eye, pupil].forEach((shape) => {
|
||||
shape.setAttribute("fill", "none");
|
||||
shape.setAttribute("stroke", "currentColor");
|
||||
shape.setAttribute("stroke-linecap", "round");
|
||||
shape.setAttribute("stroke-linejoin", "round");
|
||||
shape.setAttribute("stroke-width", "1.7");
|
||||
});
|
||||
svg.append(eye, pupil);
|
||||
|
||||
if (secretsVisible) {
|
||||
const slash = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
slash.setAttribute("d", "M4 4l16 16");
|
||||
slash.setAttribute("fill", "none");
|
||||
slash.setAttribute("stroke", "currentColor");
|
||||
slash.setAttribute("stroke-linecap", "round");
|
||||
slash.setAttribute("stroke-width", "1.9");
|
||||
svg.append(slash);
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
function appendDebugBlock(container, label, value) {
|
||||
const block = document.createElement("section");
|
||||
const heading = document.createElement("h3");
|
||||
const content = document.createElement("pre");
|
||||
|
||||
heading.textContent = label;
|
||||
content.textContent = value;
|
||||
block.append(heading, content);
|
||||
container.append(block);
|
||||
}
|
||||
|
||||
function formatDebugTime(date) {
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
return { toggleDebugPanel, renderDebugPanel, createDebugVisibilityIcon, appendDebugBlock, formatDebugTime };
|
||||
}
|
||||
6
src/features/debug/state.js
Normal file
6
src/features/debug/state.js
Normal file
@@ -0,0 +1,6 @@
|
||||
function createDebugState() {
|
||||
return {
|
||||
requestLog: [],
|
||||
debugSecretsVisible: false
|
||||
};
|
||||
}
|
||||
15
src/features/opportunities/columns.js
Normal file
15
src/features/opportunities/columns.js
Normal file
@@ -0,0 +1,15 @@
|
||||
function createOpportunitiesColumns({ formatOracleResponseDate, formatRevenue, formatWinProbability, getNestedValue, getOwnerName }) {
|
||||
return [
|
||||
{ key: "name", label: "Name", value: (item) => item.Name, display: (item) => item.Name },
|
||||
{ key: "optyNumber", label: "Opty Number", value: (item) => item.OptyNumber, display: (item) => item.OptyNumber },
|
||||
{ key: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]) },
|
||||
{ key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName },
|
||||
{ key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue },
|
||||
{ key: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) },
|
||||
{ key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode },
|
||||
{ key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
|
||||
{ key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c },
|
||||
{ key: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) },
|
||||
{ key: "actions", label: "Actions", value: () => "", display: () => "", sortable: false }
|
||||
];
|
||||
}
|
||||
146
src/features/opportunities/controller.js
Normal file
146
src/features/opportunities/controller.js
Normal file
@@ -0,0 +1,146 @@
|
||||
function createOpportunitiesController({
|
||||
STAGE_OPTIONS,
|
||||
STATUS_OPTIONS,
|
||||
authSessionState,
|
||||
consumptionPlansState,
|
||||
consumptionTrendState,
|
||||
getFiscalPeriodRange,
|
||||
opportunitiesDashboardState,
|
||||
opportunitiesFilterState,
|
||||
opportunitiesQueryState,
|
||||
opportunitiesTableState,
|
||||
preferencesState,
|
||||
productsContentState,
|
||||
renderProductsProgress,
|
||||
requestOpportunitiesPage,
|
||||
requestOpptyProductsInBackground,
|
||||
requestTokenRelayOnce,
|
||||
setOpportunitiesTableState
|
||||
}) {
|
||||
async function requestOpportunitiesForPeriod(period) {
|
||||
const dateRange = getFiscalPeriodRange(period, new Date());
|
||||
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
|
||||
|
||||
if (preferencesState.selectedOpportunityTypeValues.size === 0) {
|
||||
setOpportunitiesTableState({
|
||||
items: [],
|
||||
status: "empty",
|
||||
message: "Select at least one Opportunity Type View in Preferences."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dateRange) {
|
||||
setOpportunitiesTableState({
|
||||
items: [],
|
||||
status: "empty",
|
||||
message: "No opportunities to display for this period."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = authSessionState.accessToken || await requestTokenRelayOnce();
|
||||
|
||||
if (requestVersion !== opportunitiesQueryState.periodRequestVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
await requestOpportunities(token, period, dateRange, requestVersion);
|
||||
} catch (error) {
|
||||
// Authentication and request errors are recorded by their respective request handlers.
|
||||
}
|
||||
}
|
||||
|
||||
async function requestOpportunities(token, period, dateRange, requestVersion) {
|
||||
if (isCurrentPeriodRequest(requestVersion)) {
|
||||
productsContentState.currentProductsRequestId = "";
|
||||
productsContentState.opptyProductsByNumber = new Map();
|
||||
productsContentState.expandedProductOptyNumbers = new Set();
|
||||
productsContentState.productsProgress = {
|
||||
state: "idle",
|
||||
completed: 0,
|
||||
total: 0,
|
||||
failed: 0,
|
||||
cacheHits: 0
|
||||
};
|
||||
productsContentState.productsLastCachedAt = 0;
|
||||
consumptionPlansState.accountPlansRequestState = new Map();
|
||||
consumptionTrendState.consumptionTrendCache = new Map();
|
||||
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
||||
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
|
||||
renderProductsProgress();
|
||||
setOpportunitiesTableState({
|
||||
items: [],
|
||||
status: "loading",
|
||||
message: "Loading opportunities..."
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1);
|
||||
|
||||
if (allItems && isCurrentPeriodRequest(requestVersion)) {
|
||||
setOpportunitiesTableState({
|
||||
items: allItems,
|
||||
status: "ready",
|
||||
message: ""
|
||||
});
|
||||
requestOpptyProductsInBackground(allItems, token);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isCurrentPeriodRequest(requestVersion)) {
|
||||
setOpportunitiesTableState({
|
||||
items: [],
|
||||
status: "error",
|
||||
message: "Unable to load opportunities."
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function resetOpportunitiesTable() {
|
||||
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
|
||||
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);
|
||||
opportunitiesFilterState.selectedCustomerKeys = new Set();
|
||||
opportunitiesFilterState.customerSearch = "";
|
||||
opportunitiesFilterState.selectedOwnerKeys = new Set();
|
||||
opportunitiesFilterState.ownerSearch = "";
|
||||
opportunitiesFilterState.tableSearch = "";
|
||||
opportunitiesFilterState.hasSavedCustomerFilter = false;
|
||||
opportunitiesFilterState.hasSavedOwnerFilter = false;
|
||||
opportunitiesFilterState.customerFilterInitialized = false;
|
||||
opportunitiesFilterState.ownerFilterInitialized = false;
|
||||
productsContentState.currentProductsRequestId = "";
|
||||
productsContentState.opptyProductsByNumber = new Map();
|
||||
productsContentState.expandedProductOptyNumbers = new Set();
|
||||
productsContentState.productsProgress = {
|
||||
state: "idle",
|
||||
completed: 0,
|
||||
total: 0,
|
||||
failed: 0,
|
||||
cacheHits: 0
|
||||
};
|
||||
productsContentState.productsLastCachedAt = 0;
|
||||
consumptionPlansState.accountPlansRequestState = new Map();
|
||||
consumptionTrendState.consumptionTrendCache = new Map();
|
||||
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
||||
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
|
||||
opportunitiesDashboardState.stageDashboardAmounts = new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])]);
|
||||
opportunitiesTableState.opportunitiesTableState = {
|
||||
items: [],
|
||||
status: "idle",
|
||||
message: "",
|
||||
sortKey: "",
|
||||
sortDirection: "ascending"
|
||||
};
|
||||
}
|
||||
|
||||
function isCurrentPeriodRequest(requestVersion) {
|
||||
return requestVersion === opportunitiesQueryState.periodRequestVersion;
|
||||
}
|
||||
|
||||
return { requestOpportunitiesForPeriod, requestOpportunities, resetOpportunitiesTable, isCurrentPeriodRequest };
|
||||
}
|
||||
5
src/features/opportunities/dashboard-state.js
Normal file
5
src/features/opportunities/dashboard-state.js
Normal file
@@ -0,0 +1,5 @@
|
||||
function createOpportunitiesDashboardState({ STAGE_OPTIONS } = {}) {
|
||||
return {
|
||||
stageDashboardAmounts: new Map([["TOTAL", 0], ...STAGE_OPTIONS.map((stage) => [stage, 0])])
|
||||
};
|
||||
}
|
||||
119
src/features/opportunities/dashboard.js
Normal file
119
src/features/opportunities/dashboard.js
Normal file
@@ -0,0 +1,119 @@
|
||||
function createOpportunitiesDashboard({
|
||||
STAGE_DASHBOARD_ID,
|
||||
STAGE_OPTIONS,
|
||||
getNestedValue,
|
||||
opportunitiesDashboardState,
|
||||
environment
|
||||
}) {
|
||||
const { document, requestAnimationFrame, performance } = environment;
|
||||
function renderStageDashboard(items) {
|
||||
const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
|
||||
|
||||
if (!dashboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
dashboard.textContent = "";
|
||||
|
||||
const totalCard = document.createElement("section");
|
||||
const totalLabel = document.createElement("span");
|
||||
const totalValue = document.createElement("strong");
|
||||
|
||||
totalCard.className = "opportunities-extension-stage-dashboard-card opportunities-extension-total-dashboard-card";
|
||||
totalCard.setAttribute("data-stage", "TOTAL");
|
||||
totalLabel.textContent = "Total Opportunities";
|
||||
totalValue.className = "opportunities-extension-stage-dashboard-value";
|
||||
totalValue.setAttribute("aria-label", "Total opportunities");
|
||||
animateStageDashboardValue(
|
||||
totalValue,
|
||||
"TOTAL",
|
||||
items.length,
|
||||
(amount) => Math.round(amount).toLocaleString()
|
||||
);
|
||||
totalCard.append(totalLabel, totalValue);
|
||||
dashboard.append(totalCard);
|
||||
|
||||
STAGE_OPTIONS.forEach((stage) => {
|
||||
const stageItems = items.filter((item) => item.ForecastGroup_c === stage);
|
||||
const amount = stageItems.reduce((total, item) => {
|
||||
const value = Number(getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]));
|
||||
return Number.isFinite(value) ? total + value : total;
|
||||
}, 0);
|
||||
const card = document.createElement("section");
|
||||
const label = document.createElement("span");
|
||||
const value = document.createElement("strong");
|
||||
|
||||
card.className = "opportunities-extension-stage-dashboard-card";
|
||||
card.setAttribute("data-stage", stage);
|
||||
label.textContent = stage;
|
||||
value.className = "opportunities-extension-stage-dashboard-value";
|
||||
value.setAttribute("aria-label", `${stage} total amount`);
|
||||
|
||||
animateStageDashboardValue(
|
||||
value,
|
||||
stage,
|
||||
amount,
|
||||
createStageAmountFormatter(stageItems)
|
||||
);
|
||||
|
||||
card.append(label, value);
|
||||
dashboard.append(card);
|
||||
});
|
||||
}
|
||||
|
||||
function createStageAmountFormatter(items) {
|
||||
const currencies = new Set(items
|
||||
.map((item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]))
|
||||
.filter(Boolean));
|
||||
|
||||
if (currencies.size === 1) {
|
||||
const [currency] = currencies;
|
||||
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0
|
||||
});
|
||||
return (amount) => formatter.format(amount);
|
||||
} catch (error) {
|
||||
// Fall through to a neutral numeric total when the currency code is invalid.
|
||||
}
|
||||
}
|
||||
|
||||
return (amount) => amount.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0
|
||||
});
|
||||
}
|
||||
|
||||
function animateStageDashboardValue(element, stage, target, format) {
|
||||
const current = opportunitiesDashboardState.stageDashboardAmounts.get(stage) || 0;
|
||||
opportunitiesDashboardState.stageDashboardAmounts.set(stage, target);
|
||||
|
||||
if (current === target) {
|
||||
element.textContent = format(target);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
const duration = 480;
|
||||
|
||||
function tick(now) {
|
||||
const progress = Math.min((now - startTime) / duration, 1);
|
||||
const easedProgress = 1 - Math.pow(1 - progress, 3);
|
||||
const value = current + ((target - current) * easedProgress);
|
||||
|
||||
element.textContent = format(value);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(tick);
|
||||
} else {
|
||||
element.textContent = format(target);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
return { renderStageDashboard, createStageAmountFormatter, animateStageDashboardValue };
|
||||
}
|
||||
15
src/features/opportunities/filter-state.js
Normal file
15
src/features/opportunities/filter-state.js
Normal file
@@ -0,0 +1,15 @@
|
||||
function createOpportunitiesFilterState({ STAGE_OPTIONS, STATUS_OPTIONS } = {}) {
|
||||
return {
|
||||
selectedStages: new Set(STAGE_OPTIONS),
|
||||
selectedStatuses: new Set(STATUS_OPTIONS),
|
||||
selectedCustomerKeys: new Set(),
|
||||
customerSearch: "",
|
||||
selectedOwnerKeys: new Set(),
|
||||
ownerSearch: "",
|
||||
tableSearch: "",
|
||||
hasSavedCustomerFilter: false,
|
||||
hasSavedOwnerFilter: false,
|
||||
customerFilterInitialized: false,
|
||||
ownerFilterInitialized: false
|
||||
};
|
||||
}
|
||||
402
src/features/opportunities/filters.js
Normal file
402
src/features/opportunities/filters.js
Normal file
@@ -0,0 +1,402 @@
|
||||
function createOpportunitiesFilters({
|
||||
CUSTOMER_FILTER_BUTTON_ID,
|
||||
CUSTOMER_FILTER_LIST_ID,
|
||||
CUSTOMER_FILTER_PANEL_ID,
|
||||
CUSTOMER_FILTER_SEARCH_ID,
|
||||
CUSTOMER_FILTER_SELECT_ALL_ID,
|
||||
OPPORTUNITIES_COLUMNS,
|
||||
OWNER_FILTER_BUTTON_ID,
|
||||
OWNER_FILTER_LIST_ID,
|
||||
OWNER_FILTER_PANEL_ID,
|
||||
OWNER_FILTER_SEARCH_ID,
|
||||
OWNER_FILTER_SELECT_ALL_ID,
|
||||
STAGE_FILTERS_ID,
|
||||
STAGE_OPTIONS,
|
||||
STATUS_FILTERS_ID,
|
||||
STATUS_OPTIONS,
|
||||
getCustomerFilterSummary,
|
||||
getCustomerKey,
|
||||
getCustomerOptions,
|
||||
getOwnerKey,
|
||||
getOwnerOptions,
|
||||
opportunitiesFilterState,
|
||||
opportunitiesTableState,
|
||||
renderOpportunitiesTable,
|
||||
saveFilterPreferences,
|
||||
stringifySearchValue,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
function resetCustomerAndOwnerFiltersForPeriod() {
|
||||
opportunitiesFilterState.selectedCustomerKeys = new Set();
|
||||
opportunitiesFilterState.selectedOwnerKeys = new Set();
|
||||
opportunitiesFilterState.customerSearch = "";
|
||||
opportunitiesFilterState.ownerSearch = "";
|
||||
opportunitiesFilterState.hasSavedCustomerFilter = false;
|
||||
opportunitiesFilterState.hasSavedOwnerFilter = false;
|
||||
opportunitiesFilterState.customerFilterInitialized = false;
|
||||
opportunitiesFilterState.ownerFilterInitialized = false;
|
||||
}
|
||||
|
||||
function renderStageFilterButtons() {
|
||||
const controls = document.getElementById(STAGE_FILTERS_ID);
|
||||
|
||||
if (!controls) {
|
||||
return;
|
||||
}
|
||||
|
||||
controls.textContent = "";
|
||||
|
||||
STAGE_OPTIONS.forEach((stage) => {
|
||||
const button = document.createElement("button");
|
||||
const isPressed = opportunitiesFilterState.selectedStages.has(stage);
|
||||
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-stage-filter-button";
|
||||
button.setAttribute("data-stage", stage);
|
||||
button.setAttribute("aria-pressed", isPressed ? "true" : "false");
|
||||
button.textContent = stage;
|
||||
button.addEventListener("click", () => toggleStageFilter(stage));
|
||||
controls.append(button);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleStageFilter(stage) {
|
||||
if (opportunitiesFilterState.selectedStages.has(stage)) {
|
||||
opportunitiesFilterState.selectedStages.delete(stage);
|
||||
} else {
|
||||
opportunitiesFilterState.selectedStages.add(stage);
|
||||
}
|
||||
|
||||
saveFilterPreferences();
|
||||
renderStageFilterButtons();
|
||||
renderOpportunitiesTable();
|
||||
}
|
||||
|
||||
function renderStatusFilterButtons() {
|
||||
const controls = document.getElementById(STATUS_FILTERS_ID);
|
||||
|
||||
if (!controls) {
|
||||
return;
|
||||
}
|
||||
|
||||
controls.textContent = "";
|
||||
|
||||
STATUS_OPTIONS.forEach((status) => {
|
||||
const button = document.createElement("button");
|
||||
const isPressed = opportunitiesFilterState.selectedStatuses.has(status);
|
||||
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-status-filter-button";
|
||||
button.setAttribute("data-status", status);
|
||||
button.setAttribute("aria-pressed", isPressed ? "true" : "false");
|
||||
button.textContent = status;
|
||||
button.addEventListener("click", () => toggleStatusFilter(status));
|
||||
controls.append(button);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleStatusFilter(status) {
|
||||
if (opportunitiesFilterState.selectedStatuses.has(status)) {
|
||||
opportunitiesFilterState.selectedStatuses.delete(status);
|
||||
} else {
|
||||
opportunitiesFilterState.selectedStatuses.add(status);
|
||||
}
|
||||
|
||||
saveFilterPreferences();
|
||||
renderStatusFilterButtons();
|
||||
renderOpportunitiesTable();
|
||||
}
|
||||
|
||||
function toggleCustomerFilterPanel() {
|
||||
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
|
||||
const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
|
||||
|
||||
if (!panel || !button || button.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
panel.hidden = !panel.hidden;
|
||||
button.setAttribute("aria-expanded", panel.hidden ? "false" : "true");
|
||||
|
||||
if (!panel.hidden) {
|
||||
const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID);
|
||||
searchInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeCustomerFilterPanel(restoreFocus = true) {
|
||||
const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID);
|
||||
const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
|
||||
|
||||
if (!panel || panel.hidden) {
|
||||
return false;
|
||||
}
|
||||
|
||||
panel.hidden = true;
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
|
||||
if (restoreFocus) {
|
||||
button.focus();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function toggleOwnerFilterPanel() {
|
||||
const panel = document.getElementById(OWNER_FILTER_PANEL_ID);
|
||||
const button = document.getElementById(OWNER_FILTER_BUTTON_ID);
|
||||
|
||||
if (!panel || !button || button.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
panel.hidden = !panel.hidden;
|
||||
button.setAttribute("aria-expanded", panel.hidden ? "false" : "true");
|
||||
|
||||
if (!panel.hidden) {
|
||||
const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID);
|
||||
searchInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeOwnerFilterPanel(restoreFocus = true) {
|
||||
const panel = document.getElementById(OWNER_FILTER_PANEL_ID);
|
||||
const button = document.getElementById(OWNER_FILTER_BUTTON_ID);
|
||||
|
||||
if (!panel || panel.hidden) {
|
||||
return false;
|
||||
}
|
||||
|
||||
panel.hidden = true;
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
|
||||
if (restoreFocus) {
|
||||
button.focus();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetCustomerFilter(items) {
|
||||
const customerOptions = getCustomerOptions(items);
|
||||
if (!opportunitiesFilterState.customerFilterInitialized) {
|
||||
opportunitiesFilterState.selectedCustomerKeys = new Set(customerOptions.map((customer) => customer.key));
|
||||
opportunitiesFilterState.customerFilterInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
function resetOwnerFilter(items) {
|
||||
const ownerOptions = getOwnerOptions(items);
|
||||
if (!opportunitiesFilterState.ownerFilterInitialized) {
|
||||
opportunitiesFilterState.selectedOwnerKeys = new Set(ownerOptions.map((owner) => owner.key));
|
||||
opportunitiesFilterState.ownerFilterInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCustomerFilter() {
|
||||
const trigger = document.getElementById(CUSTOMER_FILTER_BUTTON_ID);
|
||||
const searchInput = document.getElementById(CUSTOMER_FILTER_SEARCH_ID);
|
||||
const selectAllCheckbox = document.getElementById(CUSTOMER_FILTER_SELECT_ALL_ID);
|
||||
const customerOptions = getCustomerOptions();
|
||||
|
||||
if (!trigger || !searchInput || !selectAllCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCount = customerOptions.filter((customer) => opportunitiesFilterState.selectedCustomerKeys.has(customer.key)).length;
|
||||
trigger.disabled = customerOptions.length === 0;
|
||||
trigger.textContent = getCustomerFilterSummary(customerOptions.length, selectedCount);
|
||||
searchInput.value = opportunitiesFilterState.customerSearch;
|
||||
selectAllCheckbox.disabled = customerOptions.length === 0;
|
||||
selectAllCheckbox.checked = customerOptions.length > 0 && selectedCount === customerOptions.length;
|
||||
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < customerOptions.length;
|
||||
renderCustomerFilterList();
|
||||
}
|
||||
|
||||
function renderCustomerFilterList() {
|
||||
const list = document.getElementById(CUSTOMER_FILTER_LIST_ID);
|
||||
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSearch = opportunitiesFilterState.customerSearch.trim().toLocaleLowerCase();
|
||||
const customerOptions = getCustomerOptions().filter((customer) => {
|
||||
return customer.label.toLocaleLowerCase().includes(normalizedSearch);
|
||||
});
|
||||
|
||||
list.textContent = "";
|
||||
|
||||
if (customerOptions.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "opportunities-extension-customer-filter-empty";
|
||||
empty.textContent = opportunitiesFilterState.customerSearch ? "No matching customers." : "No customers available.";
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
customerOptions.forEach((customer) => {
|
||||
const option = document.createElement("label");
|
||||
option.className = "opportunities-extension-customer-filter-option";
|
||||
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = opportunitiesFilterState.selectedCustomerKeys.has(customer.key);
|
||||
checkbox.addEventListener("change", () => {
|
||||
if (checkbox.checked) {
|
||||
opportunitiesFilterState.selectedCustomerKeys.add(customer.key);
|
||||
} else {
|
||||
opportunitiesFilterState.selectedCustomerKeys.delete(customer.key);
|
||||
}
|
||||
|
||||
opportunitiesFilterState.hasSavedCustomerFilter = true;
|
||||
saveFilterPreferences();
|
||||
renderCustomerFilter();
|
||||
renderOpportunitiesTable();
|
||||
});
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.textContent = customer.label;
|
||||
option.append(checkbox, label);
|
||||
list.append(option);
|
||||
});
|
||||
}
|
||||
|
||||
function renderOwnerFilter() {
|
||||
const trigger = document.getElementById(OWNER_FILTER_BUTTON_ID);
|
||||
const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID);
|
||||
const selectAllCheckbox = document.getElementById(OWNER_FILTER_SELECT_ALL_ID);
|
||||
const ownerOptions = getOwnerOptions();
|
||||
|
||||
if (!trigger || !searchInput || !selectAllCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCount = ownerOptions.filter((owner) => opportunitiesFilterState.selectedOwnerKeys.has(owner.key)).length;
|
||||
trigger.disabled = ownerOptions.length === 0;
|
||||
trigger.textContent = getCustomerFilterSummary(ownerOptions.length, selectedCount)
|
||||
.replace("customer", "owner");
|
||||
searchInput.value = opportunitiesFilterState.ownerSearch;
|
||||
selectAllCheckbox.disabled = ownerOptions.length === 0;
|
||||
selectAllCheckbox.checked = ownerOptions.length > 0 && selectedCount === ownerOptions.length;
|
||||
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < ownerOptions.length;
|
||||
renderOwnerFilterList();
|
||||
}
|
||||
|
||||
function renderOwnerFilterList() {
|
||||
const list = document.getElementById(OWNER_FILTER_LIST_ID);
|
||||
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSearch = opportunitiesFilterState.ownerSearch.trim().toLocaleLowerCase();
|
||||
const ownerOptions = getOwnerOptions().filter((owner) => {
|
||||
return owner.label.toLocaleLowerCase().includes(normalizedSearch);
|
||||
});
|
||||
|
||||
list.textContent = "";
|
||||
|
||||
if (ownerOptions.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "opportunities-extension-customer-filter-empty";
|
||||
empty.textContent = opportunitiesFilterState.ownerSearch ? "No matching owners." : "No owners available.";
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
ownerOptions.forEach((owner) => {
|
||||
const option = document.createElement("label");
|
||||
option.className = "opportunities-extension-customer-filter-option";
|
||||
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = opportunitiesFilterState.selectedOwnerKeys.has(owner.key);
|
||||
checkbox.addEventListener("change", () => {
|
||||
if (checkbox.checked) {
|
||||
opportunitiesFilterState.selectedOwnerKeys.add(owner.key);
|
||||
} else {
|
||||
opportunitiesFilterState.selectedOwnerKeys.delete(owner.key);
|
||||
}
|
||||
|
||||
opportunitiesFilterState.hasSavedOwnerFilter = true;
|
||||
saveFilterPreferences();
|
||||
renderOwnerFilter();
|
||||
renderOpportunitiesTable();
|
||||
});
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.textContent = owner.label;
|
||||
option.append(checkbox, label);
|
||||
list.append(option);
|
||||
});
|
||||
}
|
||||
|
||||
function getSortedOpportunities() {
|
||||
const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase();
|
||||
const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => {
|
||||
return opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c)
|
||||
&& matchesSelectedStatus(item.StatusCode)
|
||||
&& opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item))
|
||||
&& opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item))
|
||||
&& matchesTableSearch(item, normalizedSearch);
|
||||
});
|
||||
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);
|
||||
|
||||
if (!column) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const direction = opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending" ? 1 : -1;
|
||||
|
||||
return items.sort((first, second) => {
|
||||
const firstValue = column.value(first);
|
||||
const secondValue = column.value(second);
|
||||
const firstIsEmpty = firstValue === null || firstValue === undefined || firstValue === "";
|
||||
const secondIsEmpty = secondValue === null || secondValue === undefined || secondValue === "";
|
||||
|
||||
if (firstIsEmpty || secondIsEmpty) {
|
||||
if (firstIsEmpty && secondIsEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return firstIsEmpty ? 1 : -1;
|
||||
}
|
||||
|
||||
const firstNumber = Number(firstValue);
|
||||
const secondNumber = Number(secondValue);
|
||||
|
||||
if (Number.isFinite(firstNumber) && Number.isFinite(secondNumber)) {
|
||||
return (firstNumber - secondNumber) * direction;
|
||||
}
|
||||
|
||||
return String(firstValue).localeCompare(String(secondValue), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base"
|
||||
}) * direction;
|
||||
});
|
||||
}
|
||||
|
||||
function matchesTableSearch(item, normalizedSearch) {
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return OPPORTUNITIES_COLUMNS.some((column) => {
|
||||
const rawValue = stringifySearchValue(column.value(item));
|
||||
const displayValue = stringifySearchValue(column.display(item));
|
||||
return `${rawValue} ${displayValue}`.toLocaleLowerCase().includes(normalizedSearch);
|
||||
});
|
||||
}
|
||||
|
||||
function matchesSelectedStatus(statusCode) {
|
||||
if (statusCode === "WON_PENDING") {
|
||||
return opportunitiesFilterState.selectedStatuses.has("WON");
|
||||
}
|
||||
|
||||
return opportunitiesFilterState.selectedStatuses.has(statusCode);
|
||||
}
|
||||
|
||||
return { resetCustomerAndOwnerFiltersForPeriod, renderStageFilterButtons, toggleStageFilter, renderStatusFilterButtons, toggleStatusFilter, toggleCustomerFilterPanel, closeCustomerFilterPanel, toggleOwnerFilterPanel, closeOwnerFilterPanel, resetCustomerFilter, resetOwnerFilter, renderCustomerFilter, renderCustomerFilterList, renderOwnerFilter, renderOwnerFilterList, getSortedOpportunities, matchesTableSearch, matchesSelectedStatus };
|
||||
}
|
||||
55
src/features/opportunities/fiscal-periods.js
Normal file
55
src/features/opportunities/fiscal-periods.js
Normal file
@@ -0,0 +1,55 @@
|
||||
function createOpportunitiesFiscalPeriods() {
|
||||
function getFiscalPeriodRange(period, currentDate) {
|
||||
const currentYear = currentDate.getFullYear();
|
||||
const currentMonth = currentDate.getMonth();
|
||||
const fiscalStartYear = currentMonth >= 5 ? currentYear : currentYear - 1;
|
||||
const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3);
|
||||
|
||||
if (period === "Current Fiscal Year") {
|
||||
const isBeforeFiscalYearStart = currentMonth < 5;
|
||||
const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear;
|
||||
const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1;
|
||||
return createDateRange(startYear, 5, endYear, 4);
|
||||
}
|
||||
|
||||
let quarterOffset;
|
||||
let numberOfQuarters = 1;
|
||||
|
||||
if (period === "Current Quarter") {
|
||||
quarterOffset = currentQuarterIndex;
|
||||
} else if (period === "Next Quarter") {
|
||||
quarterOffset = currentQuarterIndex + 1;
|
||||
} else if (period === "Previous Quarter") {
|
||||
quarterOffset = currentQuarterIndex - 1;
|
||||
} else if (period === "4 Rolling Quarters (CQ + 3)") {
|
||||
quarterOffset = currentQuarterIndex;
|
||||
numberOfQuarters = 4;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const start = new Date(fiscalStartYear, 5 + (quarterOffset * 3), 1);
|
||||
const end = new Date(start.getFullYear(), start.getMonth() + (numberOfQuarters * 3), 0);
|
||||
|
||||
return {
|
||||
startCloseDate: formatOracleDate(start),
|
||||
endCloseDate: formatOracleDate(end)
|
||||
};
|
||||
}
|
||||
|
||||
function createDateRange(startYear, startMonth, endYear, endMonth) {
|
||||
return {
|
||||
startCloseDate: formatOracleDate(new Date(startYear, startMonth, 1)),
|
||||
endCloseDate: formatOracleDate(new Date(endYear, endMonth + 1, 0))
|
||||
};
|
||||
}
|
||||
|
||||
function formatOracleDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T00:00:00.000Z`;
|
||||
}
|
||||
|
||||
return { getFiscalPeriodRange, createDateRange, formatOracleDate };
|
||||
}
|
||||
174
src/features/opportunities/formatters.js
Normal file
174
src/features/opportunities/formatters.js
Normal file
@@ -0,0 +1,174 @@
|
||||
function createOpportunitiesFormatters({
|
||||
opportunitiesTableState
|
||||
}) {
|
||||
function getCustomerOptions(items) {
|
||||
const optionsByKey = new Map();
|
||||
|
||||
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
|
||||
const key = getCustomerKey(item);
|
||||
const label = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]);
|
||||
|
||||
if (key && label && !optionsByKey.has(key)) {
|
||||
optionsByKey.set(key, { key, label: String(label) });
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(optionsByKey.values()).sort((first, second) => {
|
||||
return first.label.localeCompare(second.label, undefined, {
|
||||
sensitivity: "base"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getCustomerKey(item) {
|
||||
const partyId = getNestedValue(item, ["CustomerAccount", "PartyId"]);
|
||||
const partyName = getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]);
|
||||
|
||||
if (partyId !== null && partyId !== undefined && partyId !== "") {
|
||||
return `party:${partyId}`;
|
||||
}
|
||||
|
||||
return partyName ? `name:${partyName}` : "";
|
||||
}
|
||||
|
||||
function getOwnerName(item) {
|
||||
const owner = item.Owner;
|
||||
|
||||
if (typeof owner === "string" || typeof owner === "number") {
|
||||
return owner;
|
||||
}
|
||||
|
||||
if (owner && typeof owner === "object") {
|
||||
const firstName = owner.PersonFirstName;
|
||||
const lastName = owner.PersonLastName;
|
||||
|
||||
if (firstName || lastName) {
|
||||
return [firstName, lastName].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
return owner.PartyName
|
||||
|| owner.PartyUniqueName
|
||||
|| owner.Name
|
||||
|| owner.DisplayName
|
||||
|| owner.ResourceName
|
||||
|| owner.OwnerName
|
||||
|| "";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function getCustomerFilterSummary(total, selected) {
|
||||
if (total === 0) {
|
||||
return "No customers";
|
||||
}
|
||||
|
||||
if (selected === total) {
|
||||
return "All customers";
|
||||
}
|
||||
|
||||
if (selected === 0) {
|
||||
return "No customers";
|
||||
}
|
||||
|
||||
return `${selected} customer${selected === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function getOwnerOptions(items) {
|
||||
const optionsByKey = new Map();
|
||||
|
||||
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
|
||||
const key = getOwnerKey(item);
|
||||
const label = getOwnerName(item);
|
||||
|
||||
if (key && label && !optionsByKey.has(key)) {
|
||||
optionsByKey.set(key, { key, label: String(label) });
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(optionsByKey.values()).sort((first, second) => {
|
||||
return first.label.localeCompare(second.label, undefined, {
|
||||
sensitivity: "base"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getOwnerKey(item) {
|
||||
const owner = item.Owner;
|
||||
const partyId = owner && typeof owner === "object" ? owner.PartyId : "";
|
||||
const name = getOwnerName(item);
|
||||
|
||||
if (partyId !== null && partyId !== undefined && partyId !== "") {
|
||||
return `party:${partyId}`;
|
||||
}
|
||||
|
||||
return name ? `name:${name}` : "";
|
||||
}
|
||||
|
||||
function stringifySearchValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function displayOpportunityValue(column, item) {
|
||||
const value = column.display(item);
|
||||
return value === null || value === undefined || value === "" ? "-" : String(value);
|
||||
}
|
||||
|
||||
function getNestedValue(value, path) {
|
||||
return path.reduce((result, key) => result && result[key], value);
|
||||
}
|
||||
|
||||
function formatWinProbability(item) {
|
||||
const value = getNestedValue(item, ["PrimaryRevenue", "WinProb"]);
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? `${number}%` : value;
|
||||
}
|
||||
|
||||
function formatRevenue(item) {
|
||||
const amount = getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]);
|
||||
const currency = getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"]);
|
||||
const number = Number(amount);
|
||||
|
||||
if (!Number.isFinite(number)) {
|
||||
return amount;
|
||||
}
|
||||
|
||||
if (!currency) {
|
||||
return number.toLocaleString();
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency
|
||||
}).format(number);
|
||||
} catch (error) {
|
||||
return `${currency} ${number.toLocaleString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatOracleResponseDate(value, includeTime) {
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/);
|
||||
|
||||
if (!match) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const date = `${match[3]}/${match[2]}/${match[1]}`;
|
||||
return includeTime && match[4] ? `${date} ${match[4]}:${match[5]}` : date;
|
||||
}
|
||||
|
||||
return { getCustomerOptions, getCustomerKey, getOwnerName, getCustomerFilterSummary, getOwnerOptions, getOwnerKey, stringifySearchValue, displayOpportunityValue, getNestedValue, formatWinProbability, formatRevenue, formatOracleResponseDate };
|
||||
}
|
||||
118
src/features/opportunities/links.js
Normal file
118
src/features/opportunities/links.js
Normal file
@@ -0,0 +1,118 @@
|
||||
function createOpportunitiesLinks({
|
||||
MODAL_ID,
|
||||
environment
|
||||
}) {
|
||||
const { document, navigator, window } = environment;
|
||||
function createDetailLink(label, href) {
|
||||
if (label === null || label === undefined || label === "") {
|
||||
const placeholder = document.createElement("span");
|
||||
placeholder.textContent = "-";
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
if (!href) {
|
||||
const text = document.createElement("span");
|
||||
text.textContent = String(label);
|
||||
return text;
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = href;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.textContent = String(label);
|
||||
return link;
|
||||
}
|
||||
|
||||
function createCopyOpportunityButton(optyNumber) {
|
||||
const button = document.createElement("button");
|
||||
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-copy-button";
|
||||
button.setAttribute("aria-label", `Copy opportunity number ${optyNumber}`);
|
||||
button.title = "Copy opportunity number";
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copyOpportunityNumber(String(optyNumber));
|
||||
});
|
||||
|
||||
icon.setAttribute("viewBox", "0 0 24 24");
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
path.setAttribute("d", "M8 8V5.5A2.5 2.5 0 0 1 10.5 3h8A2.5 2.5 0 0 1 21 5.5v8a2.5 2.5 0 0 1-2.5 2.5H16v2.5a2.5 2.5 0 0 1-2.5 2.5h-8A2.5 2.5 0 0 1 3 18.5v-8A2.5 2.5 0 0 1 5.5 8H8zm2 0h3.5A2.5 2.5 0 0 1 16 10.5v3.5h2.5a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5V8zm3.5 2h-8a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5z");
|
||||
icon.append(path);
|
||||
button.append(icon);
|
||||
return button;
|
||||
}
|
||||
|
||||
async function copyOpportunityNumber(optyNumber) {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(optyNumber);
|
||||
} else {
|
||||
const input = document.createElement("textarea");
|
||||
input.value = optyNumber;
|
||||
input.setAttribute("readonly", "true");
|
||||
input.style.position = "fixed";
|
||||
input.style.opacity = "0";
|
||||
document.body.append(input);
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
input.remove();
|
||||
}
|
||||
|
||||
showCopyConfirmation(`Opty Number ${optyNumber} copied.`);
|
||||
} catch (error) {
|
||||
showCopyConfirmation("Unable to copy opportunity number.", true);
|
||||
}
|
||||
}
|
||||
|
||||
function showCopyConfirmation(message, isError = false) {
|
||||
const modal = document.getElementById(MODAL_ID);
|
||||
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
let toast = modal.querySelector(".opportunities-extension-copy-toast");
|
||||
|
||||
if (!toast) {
|
||||
toast = document.createElement("div");
|
||||
toast.className = "opportunities-extension-copy-toast";
|
||||
toast.setAttribute("role", "status");
|
||||
modal.append(toast);
|
||||
}
|
||||
|
||||
toast.classList.toggle("opportunities-extension-copy-toast-error", isError);
|
||||
toast.textContent = message;
|
||||
toast.hidden = false;
|
||||
window.clearTimeout(toast.copyTimeout);
|
||||
toast.copyTimeout = window.setTimeout(() => {
|
||||
toast.hidden = true;
|
||||
}, 2400);
|
||||
}
|
||||
|
||||
function createStageBadge(stage) {
|
||||
const badge = document.createElement("span");
|
||||
const normalizedStage = typeof stage === "string" ? stage.toUpperCase() : "";
|
||||
|
||||
badge.className = "opportunities-extension-stage-badge";
|
||||
badge.setAttribute("data-stage", normalizedStage);
|
||||
badge.textContent = normalizedStage || "-";
|
||||
return badge;
|
||||
}
|
||||
|
||||
function createStatusBadge(status) {
|
||||
const badge = document.createElement("span");
|
||||
const normalizedStatus = typeof status === "string" ? status.toUpperCase() : "";
|
||||
|
||||
badge.className = "opportunities-extension-status-badge";
|
||||
badge.setAttribute("data-status", normalizedStatus);
|
||||
badge.textContent = normalizedStatus || "-";
|
||||
return badge;
|
||||
}
|
||||
|
||||
return { createDetailLink, createCopyOpportunityButton, copyOpportunityNumber, showCopyConfirmation, createStageBadge, createStatusBadge };
|
||||
}
|
||||
5
src/features/opportunities/query-state.js
Normal file
5
src/features/opportunities/query-state.js
Normal file
@@ -0,0 +1,5 @@
|
||||
function createOpportunitiesQueryState() {
|
||||
return {
|
||||
periodRequestVersion: 0
|
||||
};
|
||||
}
|
||||
11
src/features/opportunities/table-state.js
Normal file
11
src/features/opportunities/table-state.js
Normal file
@@ -0,0 +1,11 @@
|
||||
function createOpportunitiesTableState() {
|
||||
return {
|
||||
opportunitiesTableState: {
|
||||
items: [],
|
||||
status: "idle",
|
||||
message: "",
|
||||
sortKey: "",
|
||||
sortDirection: "ascending"
|
||||
}
|
||||
};
|
||||
}
|
||||
176
src/features/opportunities/table.js
Normal file
176
src/features/opportunities/table.js
Normal file
@@ -0,0 +1,176 @@
|
||||
function createOpportunitiesTable({
|
||||
ACCOUNT_DETAIL_URL,
|
||||
OPPORTUNITIES_COLUMNS,
|
||||
OPPORTUNITIES_TABLE_ID,
|
||||
OPPORTUNITY_DETAIL_URL,
|
||||
createCloudConsumptionButton,
|
||||
createCopyOpportunityButton,
|
||||
createDetailLink,
|
||||
createProductsCountButton,
|
||||
createProductsDetailRow,
|
||||
createStageBadge,
|
||||
createStatusBadge,
|
||||
displayOpportunityValue,
|
||||
getNestedValue,
|
||||
getSortedOpportunities,
|
||||
opportunitiesTableState,
|
||||
productsContentState,
|
||||
renderCustomerFilter,
|
||||
renderOwnerFilter,
|
||||
renderStageDashboard,
|
||||
resetCustomerFilter,
|
||||
resetOwnerFilter,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
function setOpportunitiesTableState(nextState) {
|
||||
if (nextState.status === "ready" && Array.isArray(nextState.items)) {
|
||||
resetCustomerFilter(nextState.items);
|
||||
resetOwnerFilter(nextState.items);
|
||||
}
|
||||
|
||||
opportunitiesTableState.opportunitiesTableState = {
|
||||
...opportunitiesTableState.opportunitiesTableState,
|
||||
...nextState,
|
||||
sortKey: nextState.items ? "" : opportunitiesTableState.opportunitiesTableState.sortKey,
|
||||
sortDirection: nextState.items ? "ascending" : opportunitiesTableState.opportunitiesTableState.sortDirection
|
||||
};
|
||||
renderOpportunitiesTable();
|
||||
renderCustomerFilter();
|
||||
renderOwnerFilter();
|
||||
}
|
||||
|
||||
function renderOpportunitiesTable() {
|
||||
const table = document.getElementById(OPPORTUNITIES_TABLE_ID);
|
||||
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
table.textContent = "";
|
||||
table.setAttribute("aria-busy", opportunitiesTableState.opportunitiesTableState.status === "loading" ? "true" : "false");
|
||||
|
||||
const caption = document.createElement("caption");
|
||||
caption.className = "opportunities-extension-visually-hidden";
|
||||
caption.textContent = "Opportunities";
|
||||
|
||||
const tableHead = document.createElement("thead");
|
||||
const headerRow = document.createElement("tr");
|
||||
|
||||
OPPORTUNITIES_COLUMNS.forEach((column) => {
|
||||
const header = document.createElement("th");
|
||||
const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key;
|
||||
header.scope = "col";
|
||||
|
||||
if (column.sortable === false) {
|
||||
const label = document.createElement("span");
|
||||
label.className = "opportunities-extension-table-header-label";
|
||||
label.textContent = column.label;
|
||||
header.append(label);
|
||||
} else {
|
||||
header.setAttribute("aria-sort", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
|
||||
|
||||
const sortButton = document.createElement("button");
|
||||
sortButton.type = "button";
|
||||
sortButton.className = "opportunities-extension-sort-button";
|
||||
sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
|
||||
sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`);
|
||||
sortButton.textContent = column.label;
|
||||
sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key));
|
||||
header.append(sortButton);
|
||||
}
|
||||
|
||||
headerRow.append(header);
|
||||
});
|
||||
|
||||
tableHead.append(headerRow);
|
||||
|
||||
const tableBody = document.createElement("tbody");
|
||||
const items = getSortedOpportunities();
|
||||
renderStageDashboard(items);
|
||||
|
||||
if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
|
||||
const row = document.createElement("tr");
|
||||
const cell = document.createElement("td");
|
||||
cell.className = "opportunities-extension-table-status";
|
||||
cell.colSpan = OPPORTUNITIES_COLUMNS.length;
|
||||
cell.textContent = opportunitiesTableState.opportunitiesTableState.message || "No opportunities found.";
|
||||
row.append(cell);
|
||||
tableBody.append(row);
|
||||
} else {
|
||||
items.forEach((item) => {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
OPPORTUNITIES_COLUMNS.forEach((column) => {
|
||||
const cell = document.createElement("td");
|
||||
|
||||
if (column.key === "stage") {
|
||||
cell.append(createStageBadge(column.display(item)));
|
||||
} else if (column.key === "status") {
|
||||
cell.append(createStatusBadge(column.display(item)));
|
||||
} else if (column.key === "actions") {
|
||||
const actionsContent = document.createElement("span");
|
||||
actionsContent.className = "opportunities-extension-actions-content";
|
||||
const productsResult = productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
|
||||
|
||||
if (productsResult) {
|
||||
actionsContent.append(createProductsCountButton(item.OptyNumber, productsResult));
|
||||
}
|
||||
|
||||
actionsContent.append(createCloudConsumptionButton(item));
|
||||
cell.append(actionsContent);
|
||||
} else if (column.key === "name" || column.key === "optyNumber") {
|
||||
const opportunityLink = createDetailLink(
|
||||
column.display(item),
|
||||
item.OptyNumber ? `${OPPORTUNITY_DETAIL_URL}${encodeURIComponent(item.OptyNumber)}` : ""
|
||||
);
|
||||
|
||||
if (column.key === "name" && item.OptyNumber) {
|
||||
const nameCellContent = document.createElement("span");
|
||||
nameCellContent.className = "opportunities-extension-name-content";
|
||||
nameCellContent.append(opportunityLink);
|
||||
cell.append(nameCellContent);
|
||||
} else if (column.key === "optyNumber" && item.OptyNumber) {
|
||||
const opportunityCellContent = document.createElement("span");
|
||||
opportunityCellContent.className = "opportunities-extension-opty-number-content";
|
||||
opportunityCellContent.append(opportunityLink, createCopyOpportunityButton(item.OptyNumber));
|
||||
cell.append(opportunityCellContent);
|
||||
} else {
|
||||
cell.append(opportunityLink);
|
||||
}
|
||||
} else if (column.key === "customer") {
|
||||
cell.append(createDetailLink(
|
||||
column.display(item),
|
||||
getNestedValue(item, ["CustomerAccount", "PartyId"])
|
||||
? `${ACCOUNT_DETAIL_URL}${encodeURIComponent(getNestedValue(item, ["CustomerAccount", "PartyId"]))}`
|
||||
: ""
|
||||
));
|
||||
} else {
|
||||
cell.textContent = displayOpportunityValue(column, item);
|
||||
}
|
||||
|
||||
row.append(cell);
|
||||
});
|
||||
|
||||
tableBody.append(row);
|
||||
|
||||
if (item.OptyNumber && productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber))) {
|
||||
tableBody.append(createProductsDetailRow(item));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
table.append(caption, tableHead, tableBody);
|
||||
}
|
||||
|
||||
function sortOpportunitiesBy(key) {
|
||||
const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key;
|
||||
opportunitiesTableState.opportunitiesTableState.sortKey = key;
|
||||
opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending"
|
||||
? "descending"
|
||||
: "ascending";
|
||||
renderOpportunitiesTable();
|
||||
}
|
||||
|
||||
return { setOpportunitiesTableState, renderOpportunitiesTable, sortOpportunitiesBy };
|
||||
}
|
||||
6
src/features/preferences/state.js
Normal file
6
src/features/preferences/state.js
Normal file
@@ -0,0 +1,6 @@
|
||||
function createPreferencesState({ DEFAULT_OPPORTUNITY_TYPE_VALUES, DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS } = {}) {
|
||||
return {
|
||||
selectedOpportunityTypeValues: new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES),
|
||||
opptyProductsCacheHours: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS
|
||||
};
|
||||
}
|
||||
124
src/features/preferences/storage.js
Normal file
124
src/features/preferences/storage.js
Normal file
@@ -0,0 +1,124 @@
|
||||
function createPreferencesStorage({
|
||||
DEFAULT_OPPORTUNITY_TYPE_VALUES,
|
||||
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
|
||||
FILTER_PREFERENCES_STORAGE_KEY,
|
||||
MODAL_ID,
|
||||
OPPORTUNITY_TYPE_OPTIONS,
|
||||
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
|
||||
OPPTY_PRODUCTS_CACHE_OPTIONS,
|
||||
PERIOD_OPTIONS,
|
||||
STAGE_OPTIONS,
|
||||
STATUS_OPTIONS,
|
||||
opportunitiesFilterState,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
const getLocalStorage = () => environment.localStorage;
|
||||
function restoreFilterPreferences(select) {
|
||||
try {
|
||||
const rawPreferences = getLocalStorage().getItem(FILTER_PREFERENCES_STORAGE_KEY);
|
||||
|
||||
if (!rawPreferences) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferences = JSON.parse(rawPreferences);
|
||||
const savedStages = Array.isArray(preferences.stages)
|
||||
? preferences.stages.filter((stage) => STAGE_OPTIONS.includes(stage))
|
||||
: null;
|
||||
const savedStatuses = Array.isArray(preferences.statuses)
|
||||
? preferences.statuses.filter((status) => STATUS_OPTIONS.includes(status))
|
||||
: null;
|
||||
|
||||
if (savedStages) {
|
||||
opportunitiesFilterState.selectedStages = new Set(savedStages);
|
||||
}
|
||||
|
||||
if (savedStatuses) {
|
||||
opportunitiesFilterState.selectedStatuses = new Set(savedStatuses);
|
||||
}
|
||||
|
||||
if (Array.isArray(preferences.customerKeys)) {
|
||||
opportunitiesFilterState.selectedCustomerKeys = new Set(preferences.customerKeys.map(String));
|
||||
opportunitiesFilterState.hasSavedCustomerFilter = true;
|
||||
opportunitiesFilterState.customerFilterInitialized = true;
|
||||
}
|
||||
|
||||
if (Array.isArray(preferences.ownerKeys)) {
|
||||
opportunitiesFilterState.selectedOwnerKeys = new Set(preferences.ownerKeys.map(String));
|
||||
opportunitiesFilterState.hasSavedOwnerFilter = true;
|
||||
opportunitiesFilterState.ownerFilterInitialized = true;
|
||||
}
|
||||
|
||||
opportunitiesFilterState.customerSearch = typeof preferences.customerSearch === "string" ? preferences.customerSearch : "";
|
||||
opportunitiesFilterState.ownerSearch = typeof preferences.ownerSearch === "string" ? preferences.ownerSearch : "";
|
||||
opportunitiesFilterState.tableSearch = typeof preferences.tableSearch === "string" ? preferences.tableSearch : "";
|
||||
|
||||
if (PERIOD_OPTIONS.includes(preferences.period)) {
|
||||
select.value = preferences.period;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore malformed or unavailable browser storage and use default filters.
|
||||
}
|
||||
}
|
||||
|
||||
function saveFilterPreferences(period) {
|
||||
try {
|
||||
const currentPeriod = period || document.querySelector(`#${MODAL_ID} select`)?.value || PERIOD_OPTIONS[0];
|
||||
getLocalStorage().setItem(FILTER_PREFERENCES_STORAGE_KEY, JSON.stringify({
|
||||
period: currentPeriod,
|
||||
stages: Array.from(opportunitiesFilterState.selectedStages),
|
||||
statuses: Array.from(opportunitiesFilterState.selectedStatuses),
|
||||
customerKeys: opportunitiesFilterState.hasSavedCustomerFilter ? Array.from(opportunitiesFilterState.selectedCustomerKeys) : null,
|
||||
ownerKeys: opportunitiesFilterState.hasSavedOwnerFilter ? Array.from(opportunitiesFilterState.selectedOwnerKeys) : null,
|
||||
customerSearch: opportunitiesFilterState.customerSearch,
|
||||
ownerSearch: opportunitiesFilterState.ownerSearch,
|
||||
tableSearch: opportunitiesFilterState.tableSearch
|
||||
}));
|
||||
} catch (error) {
|
||||
// Ignore unavailable browser storage; filters still work for the current modal.
|
||||
}
|
||||
}
|
||||
|
||||
function getSavedOpportunityTypeValues() {
|
||||
try {
|
||||
const rawPreferences = getLocalStorage().getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY);
|
||||
|
||||
if (!rawPreferences) {
|
||||
return DEFAULT_OPPORTUNITY_TYPE_VALUES;
|
||||
}
|
||||
|
||||
const preferences = JSON.parse(rawPreferences);
|
||||
const values = Array.isArray(preferences.opportunityTypeValues) ? preferences.opportunityTypeValues : null;
|
||||
|
||||
if (!values) {
|
||||
return DEFAULT_OPPORTUNITY_TYPE_VALUES;
|
||||
}
|
||||
|
||||
const allowedValues = new Set(OPPORTUNITY_TYPE_OPTIONS.map((option) => option.value));
|
||||
return values.filter((value) => allowedValues.has(value));
|
||||
} catch (error) {
|
||||
return DEFAULT_OPPORTUNITY_TYPE_VALUES;
|
||||
}
|
||||
}
|
||||
|
||||
function getSavedOpptyProductsCacheHours() {
|
||||
try {
|
||||
const rawPreferences = getLocalStorage().getItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY);
|
||||
|
||||
if (!rawPreferences) {
|
||||
return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
|
||||
}
|
||||
|
||||
const preferences = JSON.parse(rawPreferences);
|
||||
const cacheHours = Number(preferences.opptyProductsCacheHours);
|
||||
return OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === cacheHours)
|
||||
? cacheHours
|
||||
: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
|
||||
} catch (error) {
|
||||
return DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
|
||||
}
|
||||
}
|
||||
|
||||
return { restoreFilterPreferences, saveFilterPreferences, getSavedOpportunityTypeValues, getSavedOpptyProductsCacheHours };
|
||||
}
|
||||
165
src/features/preferences/view.js
Normal file
165
src/features/preferences/view.js
Normal file
@@ -0,0 +1,165 @@
|
||||
function createPreferencesView({
|
||||
DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS,
|
||||
MODAL_ID,
|
||||
OPPORTUNITY_TYPE_OPTIONS,
|
||||
OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY,
|
||||
OPPTY_PRODUCTS_CACHE_OPTIONS,
|
||||
PERIOD_OPTIONS,
|
||||
PREFERENCES_MODAL_ID,
|
||||
createCloseIcon,
|
||||
getSavedOpportunityTypeValues,
|
||||
getSavedOpptyProductsCacheHours,
|
||||
preferencesState,
|
||||
requestOpportunitiesForPeriod,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
const getLocalStorage = () => environment.localStorage;
|
||||
function openPreferencesModal() {
|
||||
const overlay = document.getElementById(MODAL_ID);
|
||||
|
||||
if (!overlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingModal = document.getElementById(PREFERENCES_MODAL_ID);
|
||||
|
||||
if (existingModal) {
|
||||
const savedValues = new Set(getSavedOpportunityTypeValues());
|
||||
existingModal.querySelectorAll("input[name='opportunityTypeView']").forEach((checkbox) => {
|
||||
checkbox.checked = savedValues.has(checkbox.value);
|
||||
});
|
||||
const cacheSelect = existingModal.querySelector("select[name='opptyProductsCacheHours']");
|
||||
|
||||
if (cacheSelect) {
|
||||
cacheSelect.value = String(getSavedOpptyProductsCacheHours());
|
||||
}
|
||||
|
||||
existingModal.hidden = false;
|
||||
existingModal.querySelector("input")?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const preferencesModal = document.createElement("section");
|
||||
preferencesModal.id = PREFERENCES_MODAL_ID;
|
||||
preferencesModal.className = "opportunities-extension-preferences-modal";
|
||||
preferencesModal.setAttribute("role", "dialog");
|
||||
preferencesModal.setAttribute("aria-modal", "true");
|
||||
preferencesModal.setAttribute("aria-labelledby", "opportunities-extension-preferences-title");
|
||||
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "opportunities-extension-preferences-dialog";
|
||||
|
||||
const header = document.createElement("header");
|
||||
header.className = "opportunities-extension-preferences-header";
|
||||
|
||||
const title = document.createElement("h2");
|
||||
title.id = "opportunities-extension-preferences-title";
|
||||
title.textContent = "Preferences";
|
||||
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.type = "button";
|
||||
closeButton.className = "opportunities-extension-icon-button";
|
||||
closeButton.setAttribute("aria-label", "Fechar preferências");
|
||||
closeButton.title = "Close";
|
||||
closeButton.append(createCloseIcon());
|
||||
closeButton.addEventListener("click", closePreferencesModal);
|
||||
header.append(title, closeButton);
|
||||
|
||||
const content = document.createElement("div");
|
||||
content.className = "opportunities-extension-preferences-content";
|
||||
|
||||
const fieldset = document.createElement("fieldset");
|
||||
fieldset.className = "opportunities-extension-preferences-fieldset";
|
||||
|
||||
const legend = document.createElement("legend");
|
||||
legend.textContent = "Opportunity Type View";
|
||||
fieldset.append(legend);
|
||||
|
||||
const selectedValues = new Set(getSavedOpportunityTypeValues());
|
||||
const options = document.createElement("div");
|
||||
options.className = "opportunities-extension-preferences-options";
|
||||
|
||||
OPPORTUNITY_TYPE_OPTIONS.forEach((option) => {
|
||||
const label = document.createElement("label");
|
||||
label.className = "opportunities-extension-preferences-option";
|
||||
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.name = "opportunityTypeView";
|
||||
checkbox.value = option.value;
|
||||
checkbox.checked = selectedValues.has(option.value);
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.textContent = option.label;
|
||||
label.append(checkbox, text);
|
||||
options.append(label);
|
||||
});
|
||||
|
||||
fieldset.append(options);
|
||||
|
||||
const cacheField = document.createElement("label");
|
||||
cacheField.className = "opportunities-extension-preferences-cache-field";
|
||||
|
||||
const cacheLabel = document.createElement("span");
|
||||
cacheLabel.textContent = "Product cache duration";
|
||||
|
||||
const cacheSelect = document.createElement("select");
|
||||
cacheSelect.name = "opptyProductsCacheHours";
|
||||
cacheSelect.setAttribute("aria-label", "Product cache duration");
|
||||
OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => {
|
||||
const optionElement = document.createElement("option");
|
||||
optionElement.value = String(option.value);
|
||||
optionElement.textContent = option.label;
|
||||
optionElement.selected = option.value === getSavedOpptyProductsCacheHours();
|
||||
cacheSelect.append(optionElement);
|
||||
});
|
||||
cacheField.append(cacheLabel, cacheSelect);
|
||||
content.append(fieldset, cacheField);
|
||||
|
||||
const footer = document.createElement("footer");
|
||||
footer.className = "opportunities-extension-preferences-footer";
|
||||
|
||||
const saveButton = document.createElement("button");
|
||||
saveButton.type = "button";
|
||||
saveButton.className = "opportunities-extension-button opportunities-extension-button-primary";
|
||||
saveButton.textContent = "Save";
|
||||
saveButton.addEventListener("click", () => {
|
||||
const selectedValues = Array.from(preferencesModal.querySelectorAll("input[name='opportunityTypeView']:checked"), (checkbox) => checkbox.value);
|
||||
const selectedCacheHours = Number(preferencesModal.querySelector("select[name='opptyProductsCacheHours']")?.value);
|
||||
preferencesState.selectedOpportunityTypeValues = new Set(selectedValues);
|
||||
preferencesState.opptyProductsCacheHours = OPPTY_PRODUCTS_CACHE_OPTIONS.some((option) => option.value === selectedCacheHours)
|
||||
? selectedCacheHours
|
||||
: DEFAULT_OPPTY_PRODUCTS_CACHE_HOURS;
|
||||
|
||||
try {
|
||||
getLocalStorage().setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({
|
||||
opportunityTypeValues: selectedValues,
|
||||
opptyProductsCacheHours: preferencesState.opptyProductsCacheHours
|
||||
}));
|
||||
} catch (error) {
|
||||
// Continue with the saved selection for the active modal when storage is unavailable.
|
||||
}
|
||||
|
||||
closePreferencesModal();
|
||||
const periodSelect = overlay.querySelector("select[name='opportunityPeriod']");
|
||||
requestOpportunitiesForPeriod(periodSelect?.value || PERIOD_OPTIONS[0]);
|
||||
});
|
||||
|
||||
footer.append(saveButton);
|
||||
dialog.append(header, content, footer);
|
||||
preferencesModal.append(dialog);
|
||||
overlay.append(preferencesModal);
|
||||
preferencesModal.querySelector("input")?.focus();
|
||||
}
|
||||
|
||||
function closePreferencesModal() {
|
||||
const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID);
|
||||
|
||||
if (preferencesModal) {
|
||||
preferencesModal.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { openPreferencesModal, closePreferencesModal };
|
||||
}
|
||||
107
src/features/products/background-jobs.js
Normal file
107
src/features/products/background-jobs.js
Normal file
@@ -0,0 +1,107 @@
|
||||
function createProductsBackgroundJobs({
|
||||
messageTypes,
|
||||
OPPTY_PRODUCTS_CONCURRENCY,
|
||||
getOpptyProducts,
|
||||
normalizeCacheMaxAge,
|
||||
tabsSendMessage
|
||||
}) {
|
||||
async function requestOpptyProducts(message, sender) {
|
||||
const requestId = typeof message.requestId === "string" ? message.requestId : "";
|
||||
const accessToken = typeof message.accessToken === "string" ? message.accessToken : "";
|
||||
const cacheMaxAgeMs = normalizeCacheMaxAge(message.cacheMaxAgeMs);
|
||||
const optyNumbers = Array.from(new Set(
|
||||
(Array.isArray(message.optyNumbers) ? message.optyNumbers : [])
|
||||
.filter(Boolean)
|
||||
.map(String)
|
||||
));
|
||||
|
||||
if (!requestId || !accessToken) {
|
||||
throw new Error("A request id and access token are required for requestOpptyProducts.");
|
||||
}
|
||||
|
||||
let nextIndex = 0;
|
||||
let completed = 0;
|
||||
let failed = 0;
|
||||
let cacheHits = 0;
|
||||
|
||||
const runWorker = async () => {
|
||||
while (nextIndex < optyNumbers.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
const optyNumber = optyNumbers[index];
|
||||
|
||||
try {
|
||||
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs);
|
||||
completed += 1;
|
||||
|
||||
if (result.fromCache) {
|
||||
cacheHits += 1;
|
||||
}
|
||||
|
||||
await sendProductsProgress(sender, {
|
||||
type: messageTypes.opptyProductsProgress,
|
||||
requestId,
|
||||
state: "running",
|
||||
optyNumber,
|
||||
items: result.items,
|
||||
fromCache: result.fromCache,
|
||||
cachedAt: result.cachedAt,
|
||||
completed,
|
||||
total: optyNumbers.length,
|
||||
failed,
|
||||
cacheHits
|
||||
});
|
||||
} catch (error) {
|
||||
completed += 1;
|
||||
failed += 1;
|
||||
await sendProductsProgress(sender, {
|
||||
type: messageTypes.opptyProductsProgress,
|
||||
requestId,
|
||||
state: "running",
|
||||
optyNumber,
|
||||
items: [],
|
||||
error: error.message || "Unable to load products.",
|
||||
completed,
|
||||
total: optyNumbers.length,
|
||||
failed,
|
||||
cacheHits
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
|
||||
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
||||
|
||||
const state = failed > 0 ? "complete-with-errors" : "complete";
|
||||
await sendProductsProgress(sender, {
|
||||
type: messageTypes.opptyProductsProgress,
|
||||
requestId,
|
||||
state,
|
||||
completed,
|
||||
total: optyNumbers.length,
|
||||
failed,
|
||||
cacheHits
|
||||
});
|
||||
|
||||
return {
|
||||
ok: failed === 0,
|
||||
requestId,
|
||||
completed,
|
||||
total: optyNumbers.length,
|
||||
failed,
|
||||
cacheHits
|
||||
};
|
||||
}
|
||||
|
||||
function sendProductsProgress(sender, message) {
|
||||
if (!sender || !sender.tab || !Number.isInteger(sender.tab.id)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const options = Number.isInteger(sender.frameId) ? { frameId: sender.frameId } : undefined;
|
||||
return tabsSendMessage(sender.tab.id, message, options).catch(() => {});
|
||||
}
|
||||
|
||||
return { requestOpptyProducts, sendProductsProgress };
|
||||
}
|
||||
7
src/features/products/cache-state.js
Normal file
7
src/features/products/cache-state.js
Normal file
@@ -0,0 +1,7 @@
|
||||
function createProductsCacheState() {
|
||||
return {
|
||||
productsCachePromise: null,
|
||||
productsCacheWritePromise: Promise.resolve(),
|
||||
inFlightProducts: new Map()
|
||||
};
|
||||
}
|
||||
70
src/features/products/cache.js
Normal file
70
src/features/products/cache.js
Normal file
@@ -0,0 +1,70 @@
|
||||
function createProductsCache({
|
||||
OPPTY_PRODUCTS_CACHE_KEY,
|
||||
fetchAllOpptyProducts,
|
||||
productsCacheState,
|
||||
storageLocalGet,
|
||||
storageLocalSet
|
||||
}) {
|
||||
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) {
|
||||
const cache = await getProductsCache();
|
||||
const cachedEntry = cache[optyNumber];
|
||||
|
||||
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
||||
return { items: cachedEntry.items, fromCache: true, cachedAt: Number(cachedEntry.fetchedAt) };
|
||||
}
|
||||
|
||||
if (!productsCacheState.inFlightProducts.has(optyNumber)) {
|
||||
const request = fetchAllOpptyProducts(optyNumber, accessToken)
|
||||
.then(async (items) => {
|
||||
const fetchedAt = Date.now();
|
||||
cache[optyNumber] = {
|
||||
fetchedAt,
|
||||
items
|
||||
};
|
||||
await saveProductsCache(cache).catch(() => {});
|
||||
return { items, fetchedAt };
|
||||
})
|
||||
.finally(() => {
|
||||
productsCacheState.inFlightProducts.delete(optyNumber);
|
||||
});
|
||||
productsCacheState.inFlightProducts.set(optyNumber, request);
|
||||
}
|
||||
|
||||
const fetchedResult = await productsCacheState.inFlightProducts.get(optyNumber);
|
||||
return {
|
||||
items: fetchedResult.items,
|
||||
cachedAt: fetchedResult.fetchedAt,
|
||||
fromCache: false
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCacheMaxAge(value) {
|
||||
const maxAge = Number(value);
|
||||
const defaultMaxAge = 24 * 60 * 60 * 1000;
|
||||
const maximumMaxAge = 30 * 24 * 60 * 60 * 1000;
|
||||
return Number.isFinite(maxAge) && maxAge > 0
|
||||
? Math.min(maxAge, maximumMaxAge)
|
||||
: defaultMaxAge;
|
||||
}
|
||||
|
||||
async function getProductsCache() {
|
||||
if (!productsCacheState.productsCachePromise) {
|
||||
productsCacheState.productsCachePromise = storageLocalGet(OPPTY_PRODUCTS_CACHE_KEY)
|
||||
.then((result) => result && typeof result[OPPTY_PRODUCTS_CACHE_KEY] === "object"
|
||||
? result[OPPTY_PRODUCTS_CACHE_KEY]
|
||||
: {})
|
||||
.catch(() => ({}));
|
||||
}
|
||||
|
||||
return productsCacheState.productsCachePromise;
|
||||
}
|
||||
|
||||
function saveProductsCache(cache) {
|
||||
productsCacheState.productsCacheWritePromise = productsCacheState.productsCacheWritePromise
|
||||
.catch(() => {})
|
||||
.then(() => storageLocalSet({ [OPPTY_PRODUCTS_CACHE_KEY]: cache }));
|
||||
return productsCacheState.productsCacheWritePromise;
|
||||
}
|
||||
|
||||
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||
}
|
||||
163
src/features/products/content-jobs.js
Normal file
163
src/features/products/content-jobs.js
Normal file
@@ -0,0 +1,163 @@
|
||||
function createProductsContentJobs({
|
||||
messageTypes,
|
||||
PRODUCTS_CACHE_TIMESTAMP_ID,
|
||||
PRODUCTS_MESSAGE_LISTENER_KEY,
|
||||
PRODUCTS_PROGRESS_ID,
|
||||
preferencesState,
|
||||
productsContentState,
|
||||
renderOpportunitiesTable,
|
||||
sendRuntimeMessage,
|
||||
environment
|
||||
}) {
|
||||
const { browser, chrome, document, window } = environment;
|
||||
function requestOpptyProductsInBackground(opportunities, token) {
|
||||
const optyNumbers = Array.from(new Set(opportunities
|
||||
.map((item) => item && item.OptyNumber)
|
||||
.filter(Boolean)
|
||||
.map(String)));
|
||||
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
productsContentState.currentProductsRequestId = requestId;
|
||||
productsContentState.opptyProductsByNumber = new Map();
|
||||
productsContentState.expandedProductOptyNumbers = new Set();
|
||||
productsContentState.productsProgress = {
|
||||
state: optyNumbers.length ? "running" : "complete",
|
||||
completed: 0,
|
||||
total: optyNumbers.length,
|
||||
failed: 0,
|
||||
cacheHits: 0
|
||||
};
|
||||
renderProductsProgress();
|
||||
|
||||
if (optyNumbers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendRuntimeMessage({
|
||||
type: messageTypes.requestOpptyProducts,
|
||||
requestId,
|
||||
accessToken: token,
|
||||
optyNumbers,
|
||||
cacheMaxAgeMs: preferencesState.opptyProductsCacheHours * 60 * 60 * 1000
|
||||
}).then((response) => {
|
||||
if (response && response.ok === false && response.requestId === productsContentState.currentProductsRequestId && productsContentState.productsProgress.state === "running") {
|
||||
productsContentState.productsProgress.state = "error";
|
||||
productsContentState.productsProgress.failed = productsContentState.productsProgress.total;
|
||||
renderProductsProgress();
|
||||
}
|
||||
}).catch(() => {
|
||||
if (requestId === productsContentState.currentProductsRequestId && productsContentState.productsProgress.state === "running") {
|
||||
productsContentState.productsProgress.state = "error";
|
||||
productsContentState.productsProgress.failed = productsContentState.productsProgress.total;
|
||||
renderProductsProgress();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ensureProductsBackgroundMessageHandler() {
|
||||
if (window[PRODUCTS_MESSAGE_LISTENER_KEY]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
|
||||
|
||||
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.onMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
window[PRODUCTS_MESSAGE_LISTENER_KEY] = true;
|
||||
runtimeApi.runtime.onMessage.addListener((message) => {
|
||||
if (!message || message.type !== messageTypes.opptyProductsProgress || message.requestId !== productsContentState.currentProductsRequestId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.optyNumber) {
|
||||
productsContentState.opptyProductsByNumber.set(String(message.optyNumber), {
|
||||
items: Array.isArray(message.items) ? message.items : [],
|
||||
fromCache: Boolean(message.fromCache),
|
||||
cachedAt: Number(message.cachedAt) || 0,
|
||||
error: message.error || ""
|
||||
});
|
||||
}
|
||||
|
||||
if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) {
|
||||
productsContentState.productsLastCachedAt = Number(message.cachedAt);
|
||||
}
|
||||
|
||||
productsContentState.productsProgress = {
|
||||
state: message.state || productsContentState.productsProgress.state,
|
||||
completed: Number(message.completed) || 0,
|
||||
total: Number(message.total) || productsContentState.productsProgress.total,
|
||||
failed: Number(message.failed) || 0,
|
||||
cacheHits: Number(message.cacheHits) || 0
|
||||
};
|
||||
renderProductsProgress();
|
||||
queueProductsTableRender();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function renderProductsProgress() {
|
||||
const badge = document.getElementById(PRODUCTS_PROGRESS_ID);
|
||||
|
||||
if (!badge) {
|
||||
return;
|
||||
}
|
||||
|
||||
badge.hidden = productsContentState.productsProgress.state === "idle";
|
||||
badge.setAttribute("data-state", productsContentState.productsProgress.state);
|
||||
|
||||
if (productsContentState.productsProgress.state === "running") {
|
||||
badge.textContent = `Products ${productsContentState.productsProgress.completed}/${productsContentState.productsProgress.total}`;
|
||||
badge.title = `${productsContentState.productsProgress.cacheHits} loaded from cache`;
|
||||
} else if (productsContentState.productsProgress.state === "complete") {
|
||||
badge.textContent = `Products ready ${productsContentState.productsProgress.total}`;
|
||||
badge.title = `${productsContentState.productsProgress.cacheHits} loaded from cache`;
|
||||
} else if (productsContentState.productsProgress.state === "complete-with-errors") {
|
||||
badge.textContent = `Products ${productsContentState.productsProgress.total - productsContentState.productsProgress.failed}/${productsContentState.productsProgress.total}`;
|
||||
badge.title = `${productsContentState.productsProgress.failed} product requests failed`;
|
||||
} else if (productsContentState.productsProgress.state === "error") {
|
||||
badge.textContent = "Products unavailable";
|
||||
badge.title = "Unable to load opportunity products";
|
||||
} else {
|
||||
badge.textContent = "Products ready 0";
|
||||
}
|
||||
|
||||
const timestamp = document.getElementById(PRODUCTS_CACHE_TIMESTAMP_ID);
|
||||
|
||||
if (timestamp) {
|
||||
timestamp.hidden = !productsContentState.productsLastCachedAt;
|
||||
timestamp.textContent = productsContentState.productsLastCachedAt
|
||||
? `Last update: ${formatProductsCacheTimestamp(productsContentState.productsLastCachedAt)}`
|
||||
: "";
|
||||
timestamp.title = productsContentState.productsLastCachedAt
|
||||
? `Latest product update: ${formatProductsCacheTimestamp(productsContentState.productsLastCachedAt)}`
|
||||
: "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatProductsCacheTimestamp(timestamp) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short"
|
||||
}).format(new Date(timestamp));
|
||||
} catch (error) {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
function queueProductsTableRender() {
|
||||
if (productsContentState.productsRenderQueued) {
|
||||
return;
|
||||
}
|
||||
|
||||
productsContentState.productsRenderQueued = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
productsContentState.productsRenderQueued = false;
|
||||
renderOpportunitiesTable();
|
||||
});
|
||||
}
|
||||
|
||||
return { requestOpptyProductsInBackground, ensureProductsBackgroundMessageHandler, renderProductsProgress, formatProductsCacheTimestamp, queueProductsTableRender };
|
||||
}
|
||||
16
src/features/products/content-state.js
Normal file
16
src/features/products/content-state.js
Normal file
@@ -0,0 +1,16 @@
|
||||
function createProductsContentState() {
|
||||
return {
|
||||
currentProductsRequestId: "",
|
||||
productsRenderQueued: false,
|
||||
opptyProductsByNumber: new Map(),
|
||||
expandedProductOptyNumbers: new Set(),
|
||||
productsProgress: {
|
||||
state: "idle",
|
||||
completed: 0,
|
||||
total: 0,
|
||||
failed: 0,
|
||||
cacheHits: 0
|
||||
},
|
||||
productsLastCachedAt: 0
|
||||
};
|
||||
}
|
||||
152
src/features/products/details.js
Normal file
152
src/features/products/details.js
Normal file
@@ -0,0 +1,152 @@
|
||||
function createProductsDetails({
|
||||
OPPORTUNITIES_COLUMNS,
|
||||
createAiSummaryAlert,
|
||||
formatOracleResponseDate,
|
||||
productsContentState,
|
||||
renderOpportunitiesTable,
|
||||
requestGenerateAiSummary,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
function createProductsCountButton(optyNumber, productsResult) {
|
||||
const normalizedOptyNumber = String(optyNumber);
|
||||
const button = document.createElement("button");
|
||||
const count = Array.isArray(productsResult.items) ? productsResult.items.length : 0;
|
||||
const isExpanded = productsContentState.expandedProductOptyNumbers.has(normalizedOptyNumber);
|
||||
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-products-count";
|
||||
button.textContent = String(count);
|
||||
button.setAttribute("aria-expanded", String(isExpanded));
|
||||
button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`);
|
||||
button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`;
|
||||
button.classList.toggle("opportunities-extension-products-count-error", Boolean(productsResult.error));
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (isExpanded) {
|
||||
productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber);
|
||||
} else {
|
||||
productsContentState.expandedProductOptyNumbers.add(normalizedOptyNumber);
|
||||
requestGenerateAiSummary(normalizedOptyNumber);
|
||||
}
|
||||
|
||||
renderOpportunitiesTable();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
function createProductsDetailRow(opportunity) {
|
||||
const row = document.createElement("tr");
|
||||
const cell = document.createElement("td");
|
||||
const optyNumber = String(opportunity.OptyNumber);
|
||||
const productsResult = productsContentState.opptyProductsByNumber.get(optyNumber);
|
||||
const products = productsResult && Array.isArray(productsResult.items) ? productsResult.items : [];
|
||||
|
||||
row.className = "opportunities-extension-products-detail-row";
|
||||
cell.colSpan = OPPORTUNITIES_COLUMNS.length;
|
||||
cell.className = "opportunities-extension-products-detail-cell";
|
||||
|
||||
const panel = document.createElement("section");
|
||||
panel.className = "opportunities-extension-products-detail";
|
||||
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "opportunities-extension-products-detail-heading";
|
||||
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = `Products for ${optyNumber}`;
|
||||
|
||||
const summary = document.createElement("span");
|
||||
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
|
||||
heading.append(title, summary);
|
||||
panel.append(heading);
|
||||
panel.append(createAiSummaryAlert(optyNumber));
|
||||
|
||||
if (productsResult && productsResult.error) {
|
||||
const error = document.createElement("p");
|
||||
error.className = "opportunities-extension-products-empty";
|
||||
error.textContent = productsResult.error;
|
||||
panel.append(error);
|
||||
} else if (products.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "opportunities-extension-products-empty";
|
||||
empty.textContent = "No products found for this opportunity.";
|
||||
panel.append(empty);
|
||||
} else {
|
||||
const table = document.createElement("table");
|
||||
table.className = "opportunities-extension-products-table";
|
||||
table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`);
|
||||
|
||||
const columns = [
|
||||
{ label: "Product Group", value: (item) => item.ProdGroupName || "-" },
|
||||
{ label: "Workload", value: (item) => item.WorkloadName_c || "-" },
|
||||
{ label: "Currency", value: (item) => item.RevnAmountCurcyCode || "-" },
|
||||
{ label: "Amount", value: (item) => formatProductAmount(item.RevnAmount, item.RevnAmountCurcyCode) },
|
||||
{ label: "Type", value: (item) => item.TypeCode || "-" },
|
||||
{ label: "Status", value: (item) => item.StatusCode || "-" },
|
||||
{ label: "Win Probability", value: (item) => formatProductWinProbability(item.WinProb) },
|
||||
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
|
||||
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
|
||||
{ label: "Ramp Months", value: (item) => formatProductNumber(item.RampMonths_c) }
|
||||
];
|
||||
|
||||
const head = document.createElement("thead");
|
||||
const headerRow = document.createElement("tr");
|
||||
columns.forEach((column) => {
|
||||
const header = document.createElement("th");
|
||||
header.scope = "col";
|
||||
header.textContent = column.label;
|
||||
headerRow.append(header);
|
||||
});
|
||||
head.append(headerRow);
|
||||
|
||||
const body = document.createElement("tbody");
|
||||
products.forEach((product) => {
|
||||
const productRow = document.createElement("tr");
|
||||
columns.forEach((column) => {
|
||||
const productCell = document.createElement("td");
|
||||
productCell.textContent = String(column.value(product));
|
||||
productRow.append(productCell);
|
||||
});
|
||||
body.append(productRow);
|
||||
});
|
||||
|
||||
table.append(head, body);
|
||||
panel.append(table);
|
||||
}
|
||||
|
||||
cell.append(panel);
|
||||
row.append(cell);
|
||||
return row;
|
||||
}
|
||||
|
||||
function formatProductNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(number) : "-";
|
||||
}
|
||||
|
||||
function formatProductAmount(value, currency) {
|
||||
const amount = Number(value);
|
||||
|
||||
if (!Number.isFinite(amount)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency || "USD"
|
||||
}).format(amount);
|
||||
} catch (error) {
|
||||
return `${currency || ""} ${formatProductNumber(amount)}`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function formatProductWinProbability(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? `${number}%` : "-";
|
||||
}
|
||||
|
||||
return { createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
|
||||
}
|
||||
Reference in New Issue
Block a user