Badge indicator showing whether an opportunity has an associated Service Request

This commit is contained in:
2026-09-24 14:03:34 -03:00
parent b8c493fc20
commit f9595b94e0
14 changed files with 656 additions and 8 deletions

View File

@@ -104,6 +104,8 @@ function createContentApplication(overrides = {}) {
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args),
resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args),
loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args),
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args),
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args),
@@ -176,6 +178,8 @@ function createContentApplication(overrides = {}) {
productsContentState,
renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args),
requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args),
loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args),
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args),
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
@@ -193,6 +197,18 @@ function createContentApplication(overrides = {}) {
environment
});
const opportunitiesFiscalPeriods = createOpportunitiesFiscalPeriods();
const fusionCrmServiceRequests = createFusionCrmServiceRequests({
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
maskToken: (...args) => debugContentLog.maskToken(...args), environment
});
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
});
const opportunitiesTable = createOpportunitiesTable({
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
OPPORTUNITIES_COLUMNS: columns,
@@ -205,6 +221,7 @@ function createContentApplication(overrides = {}) {
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args),
createCrmServiceRequestBadge: (...args) => opportunityCrmServiceRequests.createBadge(...args),
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
createStatusBadge: (...args) => opportunitiesLinks.createStatusBadge(...args),
@@ -468,6 +485,7 @@ function createContentApplication(overrides = {}) {
}
return {
start, contracts,
fusionCrmServiceRequests, opportunityCrmServiceRequests,
authSessionState,
authSalesCommonState,
opportunitiesQueryState,

View File

@@ -51,6 +51,8 @@ function createContentModal({
renderStageFilterButtons,
renderStatusFilterButtons,
requestOpportunitiesForPeriod,
loadCrmServiceRequests,
resetCrmServiceRequests,
requestTokenRelayOnce,
resetCustomerAndOwnerFiltersForPeriod,
resetOpportunitiesTable,
@@ -73,7 +75,8 @@ function createContentModal({
existingModal.querySelector("select").focus();
document.documentElement.classList.add("opportunities-extension-scroll-lock");
ensureEscapeKeyHandler();
requestTokenRelayOnce().catch(() => {});
requestTokenRelayOnce().then(token => loadCrmServiceRequests(token, opportunitiesQueryState.periodRequestVersion))
.then(() => renderOpportunitiesTable()).catch(() => {});
return;
}
@@ -429,6 +432,7 @@ function createContentModal({
}
function closeOpportunitiesModal() {
resetCrmServiceRequests();
const modal = document.getElementById(MODAL_ID);
if (modal) {

View File

@@ -25,7 +25,7 @@ function createDebugContentView({
if (entry.name === "requestOpptyProducts") return "products";
if (entry.name === "requestGenerateAiSummary") return "ai";
if (entry.name === "requestInstallBase") return "installBase";
if (entry.name === "requestCustomerServiceRequest") return "serviceRequests";
if (entry.name === "requestCustomerServiceRequest" || entry.name === "resquestCrmServiceRequest") return "serviceRequests";
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
return "other";
}

View File

@@ -13,6 +13,8 @@ function createOpportunitiesController({
productsContentState,
renderProductsProgress,
requestOpportunitiesPage,
loadCrmServiceRequests,
resetCrmServiceRequests,
requestOpptyProductsInBackground,
requestTokenRelayOnce,
setOpportunitiesTableState
@@ -78,6 +80,8 @@ function createOpportunitiesController({
}
try {
await loadCrmServiceRequests(token, requestVersion);
if (!isCurrentPeriodRequest(requestVersion)) return;
const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1);
if (allItems && isCurrentPeriodRequest(requestVersion)) {
@@ -102,6 +106,7 @@ function createOpportunitiesController({
}
function resetOpportunitiesTable() {
resetCrmServiceRequests();
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);
opportunitiesFilterState.selectedCustomerKeys = new Set();

View File

@@ -0,0 +1,55 @@
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, 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 load(token, version) {
if (!isCurrentPeriodRequest(version)) return;
if (!pending) pending = run(token, version, generation);
return pending;
}
async function run(token, version, currentGeneration) {
const isActive = () => currentGeneration === generation;
status = "loading";
try {
const items = await requestCrmServiceRequest(token, version, isActive);
if (!items || !isActive()) return;
const grouped = new Map();
for (const item of items) {
const number = item?.OpportunityName_Id_c?.OptyNumber;
if (!number) continue;
const key = String(number);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(item);
}
byOpportunity = grouped;
status = "ready";
} catch {
if (isActive()) status = "error";
}
}
function createBadge(optyNumber) {
if (!optyNumber || status === "idle" || status === "loading") return null;
const items = byOpportunity.get(String(optyNumber)) || [];
const badge = environment.document.createElement("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)}`,
`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);
badge.tabIndex = 0;
return badge;
}
return { reset, load, createBadge };
}

View File

@@ -1,4 +1,23 @@
/* Compact filters use available CSS width, including Windows display scaling. */
.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;
}
.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-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-form {
display: grid;
grid-template-columns: max-content minmax(110px, 1fr) minmax(110px, 1fr) minmax(145px, 180px) max-content max-content;

View File

@@ -10,6 +10,7 @@ function createOpportunitiesTable({
createDetailLink,
createProductsCountButton,
createProductsRampBadge,
createCrmServiceRequestBadge,
createProductsDetailRow,
createStageBadge,
createStatusBadge,
@@ -134,6 +135,8 @@ function createOpportunitiesTable({
const rampBadge = createProductsRampBadge(productsResult);
if (rampBadge) productActions.append(rampBadge);
}
const srBadge = createCrmServiceRequestBadge(item.OptyNumber);
if (srBadge) productActions.append(srBadge);
nameCellContent.append(opportunityLink, productActions);
cell.append(nameCellContent);
} else if (column.key === "optyNumber" && item.OptyNumber) {

View File

@@ -0,0 +1,60 @@
function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
logRequestStart, logRequestSuccess, logRequestFailure, readResponseBodyForDebug, maskToken, environment }) {
const { fetch } = environment;
const url = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries";
function createPayload(offset) {
return {
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true,
entity: "CRMServiceRequest", copiedFrom: "queries/c521309c-1310-49a0-9723-da2eb1988205",
q: { op: "$and", criteria: [
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
] },
keywords: null,
fields: ["SrNumber", "OpportunityName_Id_c.OptyNumber", "ReportedHours_c", "_actions", "Title",
"Owner.PersonFirstName", "Owner.PersonLastName", "Owner.PrimaryEmail.EmailAddress", "DoNotCallFlag", "DoNotEmailFlag"],
sort: [{ attribute: "_score", direction: "descending" }], language: "en",
keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c",
"OpportunityName_Id_c.OptyNumber", "Owner"], skipInValidFields: true, limit: 15, offset
};
}
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version)) {
const items = [];
let offset = 0;
while (isActive()) {
const payload = createPayload(offset);
const headers = {
Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`,
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 } });
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}.`);
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;
items.push(...data.items);
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;
} catch (error) {
logRequestFailure(entry, error.message || "CRM service request query failed.");
throw error;
}
}
return null;
}
return { createPayload, requestCrmServiceRequest };
}