Badge indicator showing whether an opportunity has an associated Workload Workbench Action

This commit is contained in:
2026-09-24 17:00:32 -03:00
parent f9595b94e0
commit f74071e39e
30 changed files with 1625 additions and 68 deletions

View File

@@ -13,7 +13,7 @@ function createDebugContentView({
["all", "All"], ["authentication", "Authentication"],
["opportunities", "Opportunities"], ["products", "Products"],
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"],
["serviceRequests", "Service Requests"], ["other", "Other"]
["serviceRequests", "Service Requests"], ["actions", "Actions"], ["other", "Other"]
];
let selectedCategory = "all";
const expandedIds = new Set();
@@ -23,6 +23,7 @@ function createDebugContentView({
if (/^request(?:TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
if (entry.name === "requestOpportunities") return "opportunities";
if (entry.name === "requestOpptyProducts") return "products";
if (entry.name === "requestWlActions") return "actions";
if (entry.name === "requestGenerateAiSummary") return "ai";
if (entry.name === "requestInstallBase") return "installBase";
if (entry.name === "requestCustomerServiceRequest" || entry.name === "resquestCrmServiceRequest") return "serviceRequests";

View File

@@ -15,6 +15,7 @@ function createOpportunitiesController({
requestOpportunitiesPage,
loadCrmServiceRequests,
resetCrmServiceRequests,
resetWorkloadActions,
requestOpptyProductsInBackground,
requestTokenRelayOnce,
setOpportunitiesTableState
@@ -106,6 +107,7 @@ function createOpportunitiesController({
}
function resetOpportunitiesTable() {
resetWorkloadActions();
resetCrmServiceRequests();
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);

View File

@@ -1,9 +1,9 @@
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, environment }) {
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, environment }) {
let status = "idle";
let byOpportunity = new Map();
let pending = null;
let generation = 0;
function reset() { generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); }
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); }
function load(token, version) {
if (!isCurrentPeriodRequest(version)) return;
if (!pending) pending = run(token, version, generation);
@@ -32,22 +32,25 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
function createBadge(optyNumber) {
if (!optyNumber || status === "idle" || status === "loading") return null;
const items = byOpportunity.get(String(optyNumber)) || [];
const badge = environment.document.createElement("span");
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span");
badge.className = "opportunities-extension-crm-sr-badge";
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing");
badge.textContent = status === "error" ? "SR ?" : items.length ? "\u2705 SR" : "\u26d4 SR";
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
badge.title = status === "error" ? "Unable to check CRM service requests. See Debug Requests."
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
: items.map(item => [
`SR Number: ${display(item.SrNumber)}`, `Title: ${display(item.Title)}`,
const descriptions = items.map(item => [
`Title: ${display(item.Title)}`,
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
`Reported Hours: ${display(item.ReportedHours_c)}`,
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/D"}`,
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
].join("\n")).join("\n\n");
badge.setAttribute("aria-label", badge.title);
].join("\n"));
const description = status === "error" ? "Unable to check CRM service requests. See Debug Requests."
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
: items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
badge.setAttribute("aria-label", description);
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
else badge.title = description;
badge.tabIndex = 0;
return badge;
}

View File

@@ -0,0 +1,128 @@
function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, environment }) {
const { document, window, setTimeout, clearTimeout } = environment;
let active = null;
let sequence = 0;
function close(restoreFocus = false) {
if (!active) return;
const entry = active;
active = null;
clearTimeout(entry.timer);
entry.panel.remove();
entry.button.setAttribute("aria-expanded", "false");
document.removeEventListener("pointerdown", entry.outside, true);
document.removeEventListener("keydown", entry.keydown, true);
window.removeEventListener("resize", entry.position);
document.removeEventListener("scroll", entry.scroll, true);
if (restoreFocus && entry.button.isConnected) {
entry.suppressFocus = true;
entry.button.focus();
entry.suppressFocus = false;
}
}
function bind(button, items, descriptions) {
button.type = "button";
button.setAttribute("aria-haspopup", "dialog");
button.setAttribute("aria-expanded", "false");
const id = `opportunities-extension-sr-details-${++sequence}`;
button.setAttribute("aria-controls", id);
const entry = { button, panel: null, timer: null, pinned: false, suppressFocus: false };
const contains = target => target && (button.contains(target) || entry.panel?.contains(target));
entry.outside = event => { if (!contains(event.target)) close(); };
entry.scroll = event => { if (!entry.panel.contains(event.target)) close(); };
entry.keydown = event => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
close(true);
}
};
entry.position = () => {
const rect = button.getBoundingClientRect();
const width = Math.min(480, window.innerWidth - 24);
entry.panel.style.width = `${width}px`;
entry.panel.style.maxHeight = `${Math.min(480, window.innerHeight - 24)}px`;
const height = entry.panel.getBoundingClientRect().height;
entry.panel.style.left = `${Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))}px`;
entry.panel.style.top = `${Math.max(12, Math.min(rect.bottom + 6, window.innerHeight - height - 12))}px`;
};
function leave() {
clearTimeout(entry.timer);
if (!entry.pinned && !contains(document.activeElement)) {
entry.timer = setTimeout(() => { if (active === entry) close(); }, 200);
}
}
function focusOut(event) { if (!contains(event.relatedTarget) && active === entry) close(); }
function open() {
if (entry.suppressFocus) return;
clearTimeout(entry.timer);
if (active === entry) return;
close();
const host = document.getElementById(MODAL_ID);
if (!host) return;
const panel = document.createElement("div");
entry.panel = panel;
entry.pinned = false;
panel.id = id;
panel.className = "opportunities-extension-sr-popover";
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-label", "CRM service requests");
const dismiss = document.createElement("button");
dismiss.type = "button";
dismiss.className = "opportunities-extension-sr-dismiss";
dismiss.title = "Close service request details";
dismiss.setAttribute("aria-label", dismiss.title);
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => close(true));
panel.append(dismiss);
items.forEach((item, index) => {
const section = document.createElement("section");
const heading = document.createElement("div");
heading.className = "opportunities-extension-sr-number";
const number = item.SrNumber;
const link = document.createElement(number ? "a" : "span");
link.textContent = number ? String(number) : "N/D";
if (number) {
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(number))}`;
link.target = "_blank";
link.rel = "noopener noreferrer";
}
const label = document.createElement("span");
label.textContent = "SR Number:";
heading.append(label, link);
if (number) heading.append(createCopyButton(String(number), "SR number", "SR Number"));
const details = document.createElement("div");
details.className = "opportunities-extension-sr-details";
details.textContent = descriptions[index];
section.append(heading, details);
panel.append(section);
});
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));
panel.addEventListener("pointerleave", leave);
panel.addEventListener("focusout", focusOut);
host.append(panel);
active = entry;
button.setAttribute("aria-expanded", "true");
entry.position();
document.addEventListener("pointerdown", entry.outside, true);
document.addEventListener("keydown", entry.keydown, true);
document.addEventListener("scroll", entry.scroll, true);
window.addEventListener("resize", entry.position);
}
button.addEventListener("pointerenter", open);
button.addEventListener("pointerleave", leave);
button.addEventListener("focus", open);
button.addEventListener("focusout", focusOut);
button.addEventListener("click", event => {
event.stopPropagation();
if (active === entry && entry.pinned) close();
else {
open();
if (active === entry) {
entry.pinned = true;
entry.panel.querySelector("a, button").focus();
}
}
});
}
return { bind, close };
}

View File

@@ -24,19 +24,19 @@ function createOpportunitiesLinks({
return link;
}
function createCopyOpportunityButton(optyNumber) {
function createCopyOpportunityButton(optyNumber, label = "opportunity number", confirmationLabel = "Opty Number") {
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.setAttribute("aria-label", `Copy ${label} ${optyNumber}`);
button.title = `Copy ${label}`;
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
copyOpportunityNumber(String(optyNumber));
copyOpportunityNumber(String(optyNumber), label, confirmationLabel);
});
icon.setAttribute("viewBox", "0 0 24 24");
@@ -47,7 +47,7 @@ function createOpportunitiesLinks({
return button;
}
async function copyOpportunityNumber(optyNumber) {
async function copyOpportunityNumber(optyNumber, label = "opportunity number", confirmationLabel = "Opty Number") {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(optyNumber);
@@ -63,9 +63,9 @@ function createOpportunitiesLinks({
input.remove();
}
showCopyConfirmation(`Opty Number ${optyNumber} copied.`);
showCopyConfirmation(`${confirmationLabel} ${optyNumber} copied.`);
} catch (error) {
showCopyConfirmation("Unable to copy opportunity number.", true);
showCopyConfirmation(`Unable to copy ${label}.`, true);
}
}

View File

@@ -1,4 +1,13 @@
/* Compact filters use available CSS width, including Windows display scaling. */
.opportunities-extension-products-progress,
.opportunities-extension-auth-badge { opacity: 1; transition: opacity 300ms ease; }
.opportunities-extension-products-progress[data-fading="true"],
.opportunities-extension-auth-badge[data-fading="true"] { opacity: 0; }
.opportunities-extension-auth-badge[hidden] { display: none; }
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-products-progress,
.opportunities-extension-auth-badge { transition: none; }
}
.opportunities-extension-crm-sr-badge {
flex: 0 0 auto;
padding: 0 6px;
@@ -13,10 +22,12 @@
}
.opportunities-extension-crm-sr-badge[data-state="missing"] { color: #9f201b; background: #fde8e7; border-color: #c74640; }
.opportunities-extension-crm-sr-badge[data-state="error"] { color: #785000; background: #fff0cd; border-color: #987021; }
.opportunities-extension-wl-actions-badge[data-state="loading"] { color: #5f5a55; background: #eceae8; border-color: #8f8a85; }
.opportunities-extension-crm-sr-badge:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge { color: #b8e4c6; background: #214d34; border-color: #6eac82; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge[data-state="missing"] { color: #ffd9d5; background: #542824; border-color: #ef938b; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-crm-sr-badge[data-state="error"] { color: #ffe0a3; background: #584219; border-color: #c99b4b; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-wl-actions-badge[data-state="loading"] { color: #dedbd7; background: #484541; border-color: #8f8a85; }
.opportunities-extension-form {
display: grid;
@@ -279,3 +290,24 @@
position: static;
justify-self: end;
}
.opportunities-extension-crm-sr-badge[type="button"] { cursor: pointer; font-family: inherit; }
.opportunities-extension-sr-popover {
position: fixed; z-index: 8; box-sizing: border-box; overflow: auto;
padding: 12px; border: 1px solid #b9b5b0; border-radius: 6px;
background: #fff; color: #302e2b; box-shadow: 0 6px 24px #0003;
font: 12px/1.5 Arial, sans-serif; text-align: left; overflow-wrap: anywhere;
}
.opportunities-extension-sr-popover section { padding: 8px 0; }
.opportunities-extension-sr-popover section + section { border-top: 1px solid #dedbd7; }
.opportunities-extension-sr-number { display: flex; align-items: center; gap: 8px; padding-right: 30px; font-weight: 600; }
.opportunities-extension-sr-popover a { color: #00677d; text-decoration: underline; }
.opportunities-extension-sr-details { white-space: pre-wrap; margin-top: 6px; }
.opportunities-extension-sr-dismiss {
position: absolute; top: 6px; right: 6px; width: 28px; height: 28px;
display: grid; place-items: center; padding: 4px; border: 0;
background: transparent; color: inherit; cursor: pointer;
}
.opportunities-extension-sr-dismiss svg { width: 16px; height: 16px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover { background: #292827; color: #f1efec; border-color: #77716b; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover section + section { border-color: #5f5953; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover a { color: #7ddced; }

View File

@@ -11,6 +11,8 @@ function createOpportunitiesTable({
createProductsCountButton,
createProductsRampBadge,
createCrmServiceRequestBadge,
closeCrmSrPopover,
createWorkloadActionsBadge,
createProductsDetailRow,
createStageBadge,
createStatusBadge,
@@ -51,6 +53,7 @@ function createOpportunitiesTable({
return;
}
closeCrmSrPopover();
table.textContent = "";
table.setAttribute("data-opportunities-layout", "inline-actions");
table.setAttribute("aria-busy", opportunitiesTableState.opportunitiesTableState.status === "loading" ? "true" : "false");
@@ -137,6 +140,8 @@ function createOpportunitiesTable({
}
const srBadge = createCrmServiceRequestBadge(item.OptyNumber);
if (srBadge) productActions.append(srBadge);
const actionsBadge = createWorkloadActionsBadge(item.OptyNumber);
if (actionsBadge) productActions.append(actionsBadge);
nameCellContent.append(opportunityLink, productActions);
cell.append(nameCellContent);
} else if (column.key === "optyNumber" && item.OptyNumber) {

View File

@@ -0,0 +1,87 @@
function createOpportunityWorkloadActions({ authSessionState, requestConsumerTokenServiceOnce, sendRuntimeMessage,
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, environment }) {
let session = newSession();
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0 }; }
function reset(enabled = true) {
session.enabled = false;
session.queue.length = 0;
session = newSession();
session.enabled = enabled;
}
function idsFor(products) {
return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))];
}
function loadProducts(products) {
if (!session.enabled) return;
for (const revnId of idsFor(products)) {
if (session.records.has(revnId)) continue;
const record = { status: "loading", actions: [], hasNext: false };
session.records.set(revnId, record);
session.queue.push({ revnId, record });
}
pump(session);
}
function reopen() {
reset();
for (const products of productsContentState.opptyProductsByNumber.values()) loadProducts(products);
queueProductsTableRender();
}
function pump(current) {
while (current.enabled && current === session && current.running < 4 && current.queue.length) {
const job = current.queue.shift();
current.running += 1;
run(current, job).finally(() => { current.running -= 1; pump(current); });
}
}
async function run(current, { revnId, record }) {
try {
const authorizations = await requestConsumerTokenServiceOnce();
if (!current.enabled || current !== session) return;
const result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
if (!current.enabled || current !== session) return;
appendBackgroundRequestLog(result?.debugRequests);
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
record.actions = result.actions;
record.hasNext = result.hasNext;
record.status = "ready";
} catch (error) {
if (!current.enabled || current !== session) return;
record.status = "error";
record.error = error.message || "Unable to load workload actions.";
} finally {
if (current.enabled && current === session) queueProductsTableRender();
}
}
function createBadge(optyNumber) {
if (!session.enabled || !optyNumber) return null;
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
if (!products) return null;
const ids = idsFor(products);
const records = ids.map(id => session.records.get(id));
const actions = records.flatMap(record => record?.actions || []);
const pending = records.some(record => !record || record.status === "loading");
const uncertain = products.error || (products.items || []).some(item => !String(item?.RevnId ?? "").trim())
|| records.some(record => record?.status === "error" || record?.hasNext);
const principal = authSessionState.principal;
const owned = Boolean(principal) && actions.some(action => typeof action.owner === "string" && action.owner.trim() === principal);
const state = owned ? "matched" : pending ? "loading" : uncertain || !principal ? "error" : "missing";
const badge = environment.document.createElement("span");
badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge";
badge.setAttribute("data-state", state);
badge.textContent = state === "matched" ? "\u2705 Act" : state === "missing" ? "\u26d4 Act" : state === "loading" ? "Act ..." : "Act ?";
badge.title = actions.length ? actions.map(action => [
`Action: ${action.name ?? "N/D"}`, `ID: ${action.id ?? "N/D"}`, `Status: ${action.status ?? "N/D"}`,
`Owner: ${action.owner || "N/D"}`, `Email: ${action.ownerEmail || "N/D"}`, `Workload: ${action.workloadName || "N/D"}`,
`Description: ${action.workloadDescription || "N/D"}`, `Start: ${action.startDate || "N/D"}`, `End: ${action.endDate || "N/D"}`
].join("\n")).join("\n\n") : state === "missing" ? "No actions found for this opportunity's products."
: state === "loading" ? "Loading workload actions..." : "Unable to confirm workload actions. Check product RevnId and Debug Requests.";
if (records.some(record => record?.hasNext)) badge.title += "\nAdditional actions exist; pagination is not enabled.";
if (!principal) badge.title += "\nTokenrelay principal is unavailable; ownership cannot be verified.";
else if (actions.length && state === "missing") badge.title += "\nNone of the returned actions belong to the signed-in principal.";
if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked.";
badge.tabIndex = 0;
badge.setAttribute("aria-label", badge.title);
return badge;
}
return { reset, reopen, loadProducts, createBadge };
}

View File

@@ -7,10 +7,43 @@ function createProductsContentJobs({
preferencesState,
productsContentState,
renderOpportunitiesTable,
loadWorkloadActions,
sendRuntimeMessage,
environment
}) {
const { browser, chrome, document, window } = environment;
const { browser, chrome, document, window, setTimeout, clearTimeout, matchMedia } = environment;
let completion = null;
function updateCompletionVisibility(badge) {
const requestId = productsContentState.currentProductsRequestId;
const complete = productsContentState.productsProgress.state === "complete";
if (completion && (!complete || completion.requestId !== requestId || completion.badge !== badge)) {
clearTimeout(completion.waitTimer);
clearTimeout(completion.hideTimer);
completion.badge.setAttribute("data-fading", "false");
completion = null;
}
if (!complete) {
badge.setAttribute("data-fading", "false");
return;
}
if (completion) {
badge.hidden = completion.hidden;
return;
}
const current = { requestId, badge, hidden: false, waitTimer: null, hideTimer: null };
completion = current;
badge.setAttribute("data-fading", "false");
current.waitTimer = setTimeout(() => {
if (completion !== current) return;
badge.setAttribute("data-fading", "true");
const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
current.hideTimer = setTimeout(() => {
if (completion !== current) return;
current.hidden = true;
badge.hidden = true;
}, reducedMotion ? 0 : 300);
}, 5000);
}
function requestOpptyProductsInBackground(opportunities, token) {
const optyNumbers = Array.from(new Set(opportunities
.map((item) => item && item.OptyNumber)
@@ -86,6 +119,7 @@ function createProductsContentJobs({
cachedAt: Number(message.cachedAt) || 0,
error: message.error || ""
});
loadWorkloadActions(productsContentState.opptyProductsByNumber.get(String(message.optyNumber)));
}
if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) {
@@ -130,6 +164,7 @@ function createProductsContentJobs({
} else {
badge.textContent = "Products ready 0";
}
updateCompletionVisibility(badge);
const timestamp = document.getElementById(PRODUCTS_CACHE_TIMESTAMP_ID);