Replaced labels with icons in the SR and Action badges

This commit is contained in:
2026-09-27 19:18:24 -03:00
parent de4da0eec6
commit 1a3decbeb6
37 changed files with 1856 additions and 279 deletions

View File

@@ -103,14 +103,22 @@ function createAuthConsumerSession({
const isHtmlFallback = error
&& error.code === "INVALID_JSON_RESPONSE"
&& error.httpStatus === 200;
const isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
const retryReason = isSessionUnauthorized
? `HTTP ${error.httpStatus} response requires SPA session recovery`
: "HTTP 200 response did not contain valid JSON";
const debugEntry = debugRequests[debugRequests.length - 1];
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
if (isHtmlFallback) {
if (needsSessionRecovery) {
debugEntry.metadata.retryLimitReached = "true";
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
debugEntry.metadata.retryReason = retryReason;
debugEntry.error = isHtmlFallback
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
: `requestConsumerTokenService failed with status ${error.httpStatus} after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
error.message = debugEntry.error;
}
}
@@ -138,7 +146,7 @@ function createAuthConsumerSession({
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON";
debugEntry.metadata.retryReason = retryReason;
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
}

View File

@@ -55,6 +55,7 @@ function createContentApplication(overrides = {}) {
environment
});
const contentModal = createContentModal({
closeActionForm: (...args) => workloadActionForm.close(...args),
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
closeCrmSrPopover: () => crmSrPopover.close(),
ensureCurrentUser: () => currentUser.ensure(),
@@ -220,7 +221,9 @@ function createContentApplication(overrides = {}) {
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
});
const opportunityBadgeContent = createOpportunityBadgeContent({ environment });
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
activityCache,
reportProgress: (...args) => activityProgress.update("sr", ...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
@@ -356,6 +359,7 @@ function createContentApplication(overrides = {}) {
});
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment });
const activityFilters = createActivityFilters({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
getSrSummary: number => opportunityCrmServiceRequests.getSummary(number),
getActionSummary: number => opportunityWorkloadActions.getSummary(number),
@@ -364,9 +368,9 @@ function createContentApplication(overrides = {}) {
});
const productActivities = createProductActivities({
productsContentState, opportunitiesQueryState,
resetCrm: () => opportunityCrmServiceRequests.reset(),
loadCrm: (...args) => opportunityCrmServiceRequests.load(...args),
loadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
blockActions: products => opportunityWorkloadActions.blockProducts(products),
render: () => {
productsContentJobs.renderProductsProgress();
productsContentJobs.queueProductsTableRender();
@@ -386,7 +390,19 @@ function createContentApplication(overrides = {}) {
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
environment
});
const workloadActionForm = createWorkloadActionForm({
getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
MODAL_ID: contracts.MODAL_ID,
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
isMissingAction: number => opportunityWorkloadActions.getSummary(number)?.state === "missing",
createCloseIcon: () => contentIcons.createCloseIcon(),
environment
});
const opportunityWorkloadActions = createOpportunityWorkloadActions({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
createActionButton: number => workloadActionForm.createButton(number),
closeActionForm: (...args) => workloadActionForm.close(...args),
activityCache,
reportProgress: (...args) => activityProgress.update("actions", ...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
@@ -398,6 +414,7 @@ function createContentApplication(overrides = {}) {
productsContentState, messageTypes, environment
});
const productsDetails = createProductsDetails({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
OPPORTUNITIES_COLUMNS: columns,
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
@@ -561,6 +578,7 @@ function createContentApplication(overrides = {}) {
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
}
return {
workloadActionForm,
activityFilters,
start, contracts,
opportunityWorkloadActions,

View File

@@ -28,6 +28,7 @@ function createContentModal({
closeAccountPlansModal,
closeInstallBaseModal,
closeServiceRequestsModal,
closeActionForm,
closeCustomerFilterPanel,
closeOwnerFilterPanel,
closePreferencesModal,
@@ -470,6 +471,7 @@ function createContentModal({
closeAccountPlansModal();
closeInstallBaseModal();
closeServiceRequestsModal();
closeActionForm(false);
modal.hidden = true;
}
@@ -498,6 +500,10 @@ function createContentModal({
}
if (event.key === "Escape") {
if (closeActionForm()) {
event.preventDefault();
return;
}
if (closeServiceRequestsModal()) {
event.preventDefault();
return;

View File

@@ -1,4 +1,4 @@
function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSummary, renderTable, environment }) {
function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSummary, renderTable, setBadgeContent, environment }) {
const selections = { sr: new Set(["matched", "missing"]), actions: new Set(["matched", "missing"]) };
let container = null;
let form = null;
@@ -12,24 +12,32 @@ function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSum
container = environment.document.createElement("div");
container.className = "opportunities-extension-badge-filters";
for (const [kind, title, options] of [
["sr", "Service Request", [["matched", "\u2705 SR"], ["under4", "\u2705 SR < 4h"], ["missing", "\u26d4 SR"]]],
["actions", "Actions", [["matched", "\u2705 Action"], ["missing", "\u26d4 Action"]]]
["sr", "Service Request", ["matched", "under4", "missing"]],
["actions", "Act", ["matched", "missing"]]
]) {
const section = environment.document.createElement("section");
section.className = "opportunities-extension-activity-filter";
const label = environment.document.createElement("span");
label.textContent = title;
const tooltip = kind === "sr"
? "Whether a Service Request has been created for the opportunities."
: "Whether the Action is registered in Workload Workbench.";
label.title = tooltip;
const controls = environment.document.createElement("div");
controls.className = "opportunities-extension-activity-filter-controls";
controls.setAttribute("role", "group");
controls.setAttribute("aria-label", `Filter by ${title}`);
for (const [value, text] of options) {
for (const value of options) {
const button = environment.document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-activity-filter-button";
button.setAttribute("data-value", value);
button.textContent = text;
if (value === "under4") button.title = "At least one SR with reported hours below 4; replaces other SR filters.";
setBadgeContent(button, kind, value === "under4" ? "< 4h" : "");
const description = value === "under4"
? "At least one SR with reported hours below 4; replaces other SR filters."
: `${kind === "sr" ? "Service Request" : "Action"} ${value === "matched" ? "registered" : "not registered"}`;
button.title = `${tooltip}\n${description}`;
button.setAttribute("aria-label", description);
button.addEventListener("click", () => toggle(kind, value));
buttons.push({ button, kind, value });
controls.append(button);

View File

@@ -0,0 +1,63 @@
/*
* Lucide icons: package, chart-no-axes-column-increasing, clipboard-list, list-checks.
* https://github.com/lucide-icons/lucide (ISC License)
* Copyright (c) 2026 Lucide Icons and Contributors
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
function createOpportunityBadgeContent({ environment }) {
const shapes = {
products: [
["path", { d: "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" }],
["path", { d: "M12 22V12" }],
["polyline", { points: "3.29 7 12 12 20.71 7" }],
["path", { d: "m7.5 4.27 9 5.15" }]
],
ramp: [
["path", { d: "M5 21v-6" }],
["path", { d: "M12 21V9" }],
["path", { d: "M19 21V3" }]
],
sr: [
["rect", { width: "8", height: "4", x: "8", y: "2", rx: "1", ry: "1" }],
["path", { d: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" }],
["path", { d: "M12 11h4" }],
["path", { d: "M12 16h4" }],
["path", { d: "M8 11h.01" }],
["path", { d: "M8 16h.01" }]
],
actions: [
["path", { d: "M13 5h8" }],
["path", { d: "M13 12h8" }],
["path", { d: "M13 19h8" }],
["path", { d: "m3 17 2 2 4-4" }],
["path", { d: "m3 7 2 2 4-4" }]
]
};
function setContent(badge, kind, value) {
badge.textContent = String(value);
const namespace = "http://www.w3.org/2000/svg";
const icon = environment.document.createElementNS(namespace, "svg");
for (const [name, attribute] of Object.entries({
class: "opportunities-extension-badge-icon", viewBox: "0 0 24 24",
width: "16", height: "16", fill: "none", stroke: "currentColor",
"stroke-width": "2.25", "stroke-linecap": "round", "stroke-linejoin": "round",
"aria-hidden": "true", focusable: "false", "data-badge-icon": kind
})) icon.setAttribute(name, attribute);
for (const [tag, attributes] of shapes[kind]) {
const shape = environment.document.createElementNS(namespace, tag);
for (const [name, attribute] of Object.entries(attributes)) shape.setAttribute(name, attribute);
icon.append(shape);
}
badge.append(icon);
}
return { setContent };
}

View File

@@ -1,12 +1,20 @@
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, reportProgress, activityCache, environment }) {
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, environment }) {
let status = "idle";
let byOpportunity = new Map();
let pending = null;
let generation = 0;
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); reportProgress("idle"); }
let loadedItems = [];
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); loadedItems = []; reportProgress("idle"); }
function load(token, version, cacheOptions = null) {
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
if (!pending) pending = run(token, version, generation, cacheOptions);
if (status === "ready") {
return Promise.resolve(cacheOptions ? activityCache.write("sr", "all", cacheOptions.cachedAt, loadedItems) : undefined).then(() => true);
}
if (!pending) {
const request = run(token, version, generation, cacheOptions);
pending = request;
request.finally(() => { if (pending === request) pending = null; });
}
return pending;
}
async function run(token, version, currentGeneration, cacheOptions) {
@@ -14,8 +22,8 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
status = "loading";
try {
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
if (!isActive()) return;
if (!cached && cacheOptions?.cacheOnly) { status = "idle"; return; }
if (!isActive()) return false;
if (!cached && cacheOptions?.cacheOnly) { status = "idle"; return false; }
let items = cached?.items;
if (!items) {
reportProgress("running", 0);
@@ -24,7 +32,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
});
if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items);
}
if (!items || !isActive()) return;
if (!items || !isActive()) return false;
const grouped = new Map();
for (const item of items) {
const number = item?.OpportunityName_Id_c?.OptyNumber;
@@ -34,10 +42,13 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
grouped.get(key).push(item);
}
byOpportunity = grouped;
loadedItems = items;
status = "ready";
if (!cached) reportProgress("complete", items.length, items.length);
return true;
} catch {
if (isActive()) { status = "error"; reportProgress("error"); }
return false;
}
}
function getSummary(optyNumber) {
@@ -62,7 +73,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
const reportedHours = summary.reportedHours !== null
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
: "N/D";
badge.textContent = status === "error" ? "SR ?" : items.length ? `\u2705 SR (${reportedHours}${reportedHours === "N/D" ? "" : "h"})` : "\u26d4 SR";
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/D" ? "" : "h"}` : "0h");
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
const descriptions = items.map(item => [
`Title: ${display(item.Title)}`,
@@ -76,8 +87,8 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
: !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);
badge.title = "Whether a Service Request has been created for the opportunities.";
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
else badge.title = description;
badge.tabIndex = 0;
return badge;
}

View File

@@ -6,7 +6,7 @@ function createOpportunitiesDashboard({
environment
}) {
const { document, requestAnimationFrame, performance } = environment;
function renderStageDashboard(items) {
function renderStageDashboard(items, totalCount = items.length) {
const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
if (!dashboard) {
@@ -23,12 +23,12 @@ function createOpportunitiesDashboard({
totalCard.setAttribute("data-stage", "TOTAL");
totalLabel.textContent = "Total Opportunities";
totalValue.className = "opportunities-extension-stage-dashboard-value";
totalValue.setAttribute("aria-label", "Total opportunities");
totalValue.setAttribute("aria-label", `${items.length} filtered opportunities out of ${totalCount} total`);
animateStageDashboardValue(
totalValue,
"TOTAL",
items.length,
(amount) => Math.round(amount).toLocaleString()
(amount) => `${Math.round(amount).toLocaleString()}/${totalCount.toLocaleString()}`
);
totalCard.append(totalLabel, totalValue);
dashboard.append(totalCard);

View File

@@ -336,11 +336,14 @@ function createOpportunitiesFilters({
function getSortedOpportunities() {
const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase();
const allStages = STAGE_OPTIONS.every(stage => opportunitiesFilterState.selectedStages.has(stage));
const allCustomers = getCustomerOptions().every(option => opportunitiesFilterState.selectedCustomerKeys.has(option.key));
const allOwners = getOwnerOptions().every(option => opportunitiesFilterState.selectedOwnerKeys.has(option.key));
const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => {
return opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c)
return (allStages || opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c))
&& matchesSelectedStatus(item.StatusCode)
&& opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item))
&& opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item))
&& (allCustomers || opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)))
&& (allOwners || opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)))
&& matchesTableSearch(item, normalizedSearch)
&& matchesActivities(item);
});
@@ -393,6 +396,7 @@ function createOpportunitiesFilters({
}
function matchesSelectedStatus(statusCode) {
if (STATUS_OPTIONS.every(status => opportunitiesFilterState.selectedStatuses.has(status))) return true;
if (statusCode === "WON_PENDING") {
return opportunitiesFilterState.selectedStatuses.has("WON");
}

View File

@@ -79,7 +79,7 @@ function createOpportunitiesFormatters({
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
const key = getOwnerKey(item);
const label = getOwnerName(item);
const label = String(getOwnerName(item)).trim() || "-";
if (key && label && !optionsByKey.has(key)) {
optionsByKey.set(key, { key, label: String(label) });
@@ -96,7 +96,9 @@ function createOpportunitiesFormatters({
function getOwnerKey(item) {
const owner = item.Owner;
const partyId = owner && typeof owner === "object" ? owner.PartyId : "";
const name = getOwnerName(item);
const name = String(getOwnerName(item)).trim();
if (!name) return "owner:unassigned";
if (partyId !== null && partyId !== undefined && partyId !== "") {
return `party:${partyId}`;

View File

@@ -1,5 +1,5 @@
function createProductActivities({ productsContentState, opportunitiesQueryState, resetCrm,
loadCrm, loadActions, render }) {
function createProductActivities({ productsContentState, opportunitiesQueryState,
loadCrm, loadActions, blockActions, render }) {
let cycle = null;
function begin(requestId, token) {
cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false };
@@ -11,26 +11,32 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
current.completed = true;
const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error);
if (!products.length) return;
resetCrm();
const pending = [];
const seen = new Set();
for (const result of products) {
const items = result.items.filter(item => {
const id = String(item?.RevnId ?? "");
if (seen.has(id)) return false;
seen.add(id);
return true;
});
pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true }));
}
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
pending.push(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) }));
return Promise.allSettled(pending).then(() => {
if (cycle !== current || productsContentState.currentProductsRequestId !== requestId
|| opportunitiesQueryState.periodRequestVersion !== current.version) return;
const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId
&& opportunitiesQueryState.periodRequestVersion === current.version;
return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })).then(async success => {
if (!isActive()) return;
if (success === false) {
products.forEach(blockActions);
render();
return;
}
const pending = [];
const seen = new Set();
for (const result of products) {
const items = result.items.filter(item => {
const id = String(item?.RevnId ?? "");
if (seen.has(id)) return false;
seen.add(id);
return true;
});
pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true }));
}
await Promise.allSettled(pending);
if (!isActive()) return;
if (cachedAt > 0) productsContentState.productsLastCachedAt = cachedAt;
render();
}).catch(() => {});
}).catch(() => { if (isActive()) { products.forEach(blockActions); render(); } });
}
return { begin, complete };
}

View File

@@ -9,14 +9,7 @@
.opportunities-extension-auth-badge { transition: none; }
}
.opportunities-extension-crm-sr-badge {
flex: 0 0 auto;
padding: 0 6px;
border: 1px solid #408254;
border-radius: 8px;
font-size: 10px;
line-height: 18px;
font-weight: 600;
white-space: nowrap;
color: #185b30;
background: #e1f1e5;
}
@@ -91,14 +84,71 @@
}
.opportunities-extension-form .opportunities-extension-stage-filter-button,
.opportunities-extension-form .opportunities-extension-status-filter-button {
.opportunities-extension-form .opportunities-extension-status-filter-button,
.opportunities-extension-form .opportunities-extension-activity-filter-button {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 28px;
min-height: 28px;
padding: 4px 7px;
border: 1px solid currentColor;
border-radius: 18px;
font-family: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
line-height: 18px;
white-space: nowrap;
}
.opportunities-extension-cell-actions > .opportunities-extension-products-count,
.opportunities-extension-cell-actions > .opportunities-extension-products-ramp,
.opportunities-extension-cell-actions > .opportunities-extension-crm-sr-badge {
display: inline-flex;
align-items: center;
justify-content: flex-start;
box-sizing: border-box;
flex: 0 0 auto;
gap: 4px;
height: 20px;
min-height: 20px;
margin: 0;
padding: 0 6px;
border-width: 1px;
border-style: solid;
border-radius: 8px;
font-family: inherit;
font-size: 10px;
font-style: normal;
font-weight: 600;
line-height: 16px;
letter-spacing: 0;
text-align: center;
vertical-align: middle;
white-space: nowrap;
}
.opportunities-extension-cell-actions .opportunities-extension-badge-icon,
.opportunities-extension-activity-filter-button .opportunities-extension-badge-icon {
display: block;
order: -1;
flex: 0 0 16px;
width: 16px;
height: 16px;
margin: 0;
padding: 0;
border: 0;
color: inherit;
fill: none;
stroke: currentColor;
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
@media (max-width: 1150px) {
.opportunities-extension-form {
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -139,17 +189,10 @@
column-gap: 8px;
}
.opportunities-extension-activity-filter-button {
border: 1px solid currentColor;
border-radius: 18px;
min-height: 28px;
padding: 4px 7px;
font: inherit;
font-size: 11px;
font-weight: 600;
line-height: 18px;
white-space: nowrap;
min-width: 32px;
gap: 4px;
color: #185b30;
background: transparent;
background: #fff;
cursor: pointer;
}
.opportunities-extension-activity-filter-button[data-value="missing"] { color: #9f201b; }
@@ -160,6 +203,7 @@
.opportunities-extension-activity-filter-button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter { color: #eeeae6; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button { color: #b8e4c6; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button:not([aria-pressed="true"]) { background: #333130; border-color: #6a6560; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="missing"] { color: #ffd9d5; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="under4"] { color: #b5e9fc; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[aria-pressed="true"] { border-color: currentColor; }
@@ -186,6 +230,105 @@
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: minmax(0, 1fr); }
}
.opportunities-extension-add-action {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
flex: 0 0 16px;
width: 16px;
height: 16px;
padding: 0;
margin: 0 0 0 2px;
border: 0;
border-radius: 3px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.opportunities-extension-add-action:hover { background: rgba(127, 127, 127, .2); }
.opportunities-extension-add-action:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-action-form-modal {
position: absolute;
inset: 0;
z-index: 5;
display: grid;
place-items: center;
padding: 12px;
background: rgba(0, 0, 0, .42);
}
.opportunities-extension-action-form-dialog {
box-sizing: border-box;
width: min(560px, 100%);
max-height: calc(100vh - 24px);
overflow-y: auto;
background: #fff;
color: #312d2a;
border: 1px solid #b9b4ae;
border-radius: 6px;
box-shadow: 0 12px 36px rgba(0, 0, 0, .25);
}
.opportunities-extension-action-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
padding: 18px;
}
.opportunities-extension-action-form-heading { min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-action-form-context {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 6px 12px;
margin: 12px 0 0;
font-size: 12px;
line-height: 18px;
}
.opportunities-extension-action-form-context dt { font-weight: 600; }
.opportunities-extension-action-form-context dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-action-form-field { display: grid; gap: 6px; min-width: 0; font-size: 13px; }
.opportunities-extension-action-form-wide,
.opportunities-extension-action-form-footer { grid-column: 1 / -1; }
.opportunities-extension-action-form-field input,
.opportunities-extension-action-form-field textarea {
box-sizing: border-box;
min-width: 0;
width: 100%;
border: 1px solid #8f8983;
border-radius: 4px;
padding: 8px;
font: inherit;
line-height: 20px;
color: inherit;
background: #fff;
}
.opportunities-extension-action-form-field textarea { resize: vertical; min-height: 96px; }
.opportunities-extension-action-form-footer { display: flex; justify-content: flex-end; gap: 8px; }
.opportunities-extension-action-form-footer button {
border: 1px solid #8f8983;
border-radius: 4px;
padding: 7px 14px;
font: inherit;
font-size: 13px;
background: transparent;
color: inherit;
cursor: pointer;
}
.opportunities-extension-action-form-footer button:disabled { opacity: .55; cursor: not-allowed; }
.opportunities-extension-action-form :focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-dialog {
color-scheme: dark;
background: #292725;
color: #eeeae6;
border-color: #756f68;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field input,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field textarea { background: #211f1d; border-color: #918a82; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form :focus-visible { outline-color: #80d6e8; }
@media (max-width: 420px) {
.opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); }
}
/* Keep layout rules on the outer table, never on expanded product tables. */
.opportunities-extension-table[data-opportunities-layout="inline-actions"] {
width: 100%;
@@ -281,18 +424,11 @@
}
.opportunities-extension-products-ramp {
display: inline-block;
min-width: 0;
max-width: 100%;
padding: 0 6px;
border: 1px solid #bba5cf;
border-radius: 8px;
background: #f0e8f7;
color: #63358a;
font-size: 10px;
font-weight: 600;
line-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -101,7 +101,7 @@ function createOpportunitiesTable({
const tableBody = document.createElement("tbody");
const items = getSortedOpportunities();
renderStageDashboard(items);
renderStageDashboard(items, opportunitiesTableState.opportunitiesTableState.items.length);
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");

View File

@@ -0,0 +1,111 @@
function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, formatDate, createCloseIcon, environment }) {
const { document } = environment;
let active = null;
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function close(restoreFocus = true) {
if (!active) return false;
const previous = active;
active = null;
previous.modal.remove();
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function createButton(optyNumber) {
const button = element("button", "opportunities-extension-add-action", "+");
button.type = "button";
button.title = "Add Action";
button.setAttribute("aria-label", `Add Action for ${optyNumber}`);
button.setAttribute("aria-haspopup", "dialog");
button.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
open(optyNumber, button);
});
return button;
}
function open(optyNumber, trigger) {
if (!allowsCrmActivities() || !isMissingAction(optyNumber)) return;
const host = document.getElementById(MODAL_ID);
if (!host || host.hidden) return;
close(false);
const modal = element("section", "opportunities-extension-action-form-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-action-form-title");
const dialog = element("div", "opportunities-extension-action-form-dialog");
const header = element("header", "opportunities-extension-account-plans-header");
const heading = element("div", "opportunities-extension-action-form-heading");
const title = element("h2", "", "Add Action");
title.id = "opportunities-extension-action-form-title";
heading.append(title, element("p", "", String(optyNumber)));
const opportunity = getOpportunity(optyNumber);
const details = element("dl", "opportunities-extension-action-form-context");
for (const [label, value] of [
["Customer", opportunity?.CustomerAccount?.PartyUniqueName],
["Name", opportunity?.Name],
["Close Date", formatDate(opportunity?.EffectiveDate)]
]) {
details.append(element("dt", "", label), element("dd", "", value == null || value === "" ? "-" : String(value)));
}
heading.append(details);
const dismiss = element("button", "opportunities-extension-icon-button");
dismiss.type = "button";
dismiss.title = "Close";
dismiss.setAttribute("aria-label", "Close Action form");
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => close());
header.append(heading, dismiss);
const form = element("form", "opportunities-extension-action-form");
// Form-only version: never submit or navigate until the API contract is provided.
form.addEventListener("submit", event => event.preventDefault());
const fields = [];
for (const [name, label, type] of [
["team", "Team", "text"], ["startDate", "Start date", "date"],
["endDate", "End Date", "date"], ["status", "Status", "text"],
["complexity", "Complexity", "text"], ["notes", "Notes", "textarea"]
]) {
const field = element("label", "opportunities-extension-action-form-field");
const input = element(type === "textarea" ? "textarea" : "input");
if (type !== "textarea") input.type = type;
else input.rows = 5;
input.name = name;
input.id = `opportunities-extension-action-form-${name}`;
field.htmlFor = input.id;
if (name === "notes" || name === "team") field.className += " opportunities-extension-action-form-wide";
field.append(element("span", "", label), input);
form.append(field);
fields.push(input);
}
const footer = element("footer", "opportunities-extension-action-form-footer");
const save = element("button", "opportunities-extension-action-form-save", "Save");
save.type = "submit";
save.disabled = true;
save.title = "Gravacao ainda nao disponivel";
const cancel = element("button", "", "Cancel");
cancel.type = "button";
cancel.addEventListener("click", () => close());
footer.append(save, cancel);
form.append(footer);
dialog.append(header, form);
modal.append(dialog);
host.append(modal);
active = { modal, trigger };
modal.addEventListener("click", event => { if (event.target === modal) close(); });
modal.addEventListener("keydown", event => {
if (event.key !== "Tab") return;
const controls = [dismiss, ...fields, cancel];
const index = controls.indexOf(document.activeElement);
if (index === -1 || (!event.shiftKey && index === controls.length - 1) || (event.shiftKey && index === 0)) {
event.preventDefault();
controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
});
fields[0].focus();
}
return { createButton, open, close };
}

View File

@@ -1,5 +1,5 @@
function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage,
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, reportProgress, activityCache, environment }) {
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, reportProgress, activityCache, createActionButton, closeActionForm, setBadgeContent, environment }) {
let session = newSession();
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0, progress: new Set() }; }
function updateProgress(current) {
@@ -10,6 +10,7 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
reportProgress(completed < records.length ? "running" : failed ? "complete-with-errors" : "complete", completed, records.length);
}
function reset(enabled = true) {
closeActionForm(false);
session.enabled = false;
for (const job of session.queue) job.record.resolveCompletion();
session.queue.length = 0;
@@ -46,6 +47,12 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
for (const products of productsContentState.opptyProductsByNumber.values()) loadProducts(products);
queueProductsTableRender();
}
function blockProducts(products) {
if (!allowsCrmActivities()) return;
for (const id of idsFor(products)) {
session.records.set(id, { status: "error", actions: [], hasNext: false, error: "CRM service requests unavailable." });
}
}
function pump(current) {
while (current.enabled && current === session && current.running < 4 && current.queue.length) {
const job = current.queue.shift();
@@ -66,12 +73,21 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
if (cacheOptions?.cacheOnly) { record.status = "error"; record.error = "No matching activity cache for this product update."; return; }
current.progress.add(record);
updateProgress(current);
const authorizations = await requestConsumerTokenServiceOnce();
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
const result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
appendBackgroundRequestLog(result?.debugRequests);
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
let result;
for (let attempt = 1; attempt <= 5; attempt += 1) {
try {
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
const authorizations = await requestConsumerTokenServiceOnce();
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
appendBackgroundRequestLog(result?.debugRequests);
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
break;
} catch (error) {
if (attempt === 5) throw error;
}
}
record.actions = result.actions;
record.hasNext = result.hasNext;
record.status = "ready";
@@ -107,7 +123,7 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
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 ?";
setBadgeContent(badge, "actions", state === "matched" || state === "missing" ? actions.length : state === "loading" ? "..." : "?");
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"}`,
@@ -120,7 +136,8 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
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);
if (state === "missing") badge.append(createActionButton(String(optyNumber)));
return badge;
}
return { reset, reopen, loadProducts, createBadge, getSummary };
return { reset, reopen, loadProducts, blockProducts, createBadge, getSummary };
}

View File

@@ -1,5 +1,6 @@
function createProductsDetails({
OPPORTUNITIES_COLUMNS,
setBadgeContent,
createAiSummaryAlert,
formatOracleResponseDate,
productsContentState,
@@ -17,7 +18,7 @@ function createProductsDetails({
const badge = document.createElement("span");
badge.className = "opportunities-extension-products-ramp";
badge.setAttribute("data-missing", String(values.includes("N/D")));
badge.textContent = `RM ${values.join(",")}`;
setBadgeContent(badge, "ramp", values.join(","));
badge.title = `Ramp Months: ${values.join(", ")}`;
badge.setAttribute("aria-label", badge.title);
return badge;
@@ -31,7 +32,7 @@ function createProductsDetails({
button.type = "button";
button.className = "opportunities-extension-products-count";
button.textContent = String(count);
setBadgeContent(button, "products", 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"}`;

View File

@@ -21,6 +21,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version), onProgress = () => {}) {
const items = [];
let offset = 0;
let attempt = 1;
while (isActive()) {
const payload = createPayload(offset);
const headers = {
@@ -28,31 +29,33 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
};
const entry = logRequestStart({ name: "resquestCrmServiceRequest", method: "POST", url,
headers: { ...headers, Authorization: `Bearer ${maskToken(token)}` }, body: JSON.stringify(payload, null, 2), metadata: { offset } });
headers: { ...headers, Authorization: `Bearer ${maskToken(token)}` }, body: JSON.stringify(payload, null, 2), metadata: { offset, attempt } });
try {
const response = await fetch(url, {
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
});
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
if (!response.ok) throw new Error(`CRM service request query failed with status ${response.status}.`);
if (response.status !== 200) throw new Error(`CRM service request query failed with status ${response.status}.`);
const data = await response.json();
if (!data || !Array.isArray(data.items) || typeof data.hasMore !== "boolean") {
throw new Error("CRM service request query returned an invalid response.");
}
if (!isActive()) return null;
const received = data.count === undefined ? data.items.length : Number(data.count);
const current = data.offset === undefined ? offset : Number(data.offset);
if (data.hasMore && (!data.items.length || !Number.isInteger(received) || received <= 0 || !Number.isInteger(current)
|| current !== offset || current + received <= offset)) {
throw new Error("CRM service request pagination did not advance.");
}
items.push(...data.items);
onProgress(items.length, Number.isFinite(data.totalResults) && data.totalResults >= items.length ? data.totalResults : null);
if (!data.hasMore) return items;
const received = data.count === undefined ? data.items.length : Number(data.count);
const current = data.offset === undefined ? offset : Number(data.offset);
if (!data.items.length || !Number.isInteger(received) || received <= 0 || !Number.isInteger(current)
|| current !== offset || current + received <= offset) {
throw new Error("CRM service request pagination did not advance.");
}
offset = current + received;
attempt = 1;
} catch (error) {
logRequestFailure(entry, error.message || "CRM service request query failed.");
throw error;
if (attempt === 5) throw error;
attempt += 1;
}
}
return null;

View File

@@ -25,7 +25,10 @@ function createHttpBackgroundJson({
appendBackgroundDebugEntry(debugRequests, debugEntry);
if (!response.ok) {
throw new Error(`${requestName} failed with status ${response.status}.`);
const error = new Error(`${requestName} failed with status ${response.status}.`);
error.httpStatus = response.status;
debugEntry.error = error.message;
throw error;
}
if (responsePayload.error) {