Compare commits

...

6 Commits

11 changed files with 2539 additions and 296 deletions

View File

@@ -1,42 +1,50 @@
# Opportunities Extension # Opportunities Extension 1.0.0
Extensao de navegador para adicionar o atalho "Opportunities Extension" nas paginas Oracle Fusion permitidas. Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table.
## Estrutura de desenvolvimento ## Development Structure
- `src/content.js`: script unico mantido no projeto. - `src/content.js`: single source file maintained by the project.
- `scripts/build.mjs`: gera os pacotes especificos para cada navegador. - `scripts/build.mjs`: generates browser-specific packages.
- `dist/chromium/`: output para Google Chrome e Microsoft Edge. - `dist/chromium/`: output for Google Chrome and Microsoft Edge.
- `dist/firefox/`: output para Firefox. - `dist/firefox/`: output for Firefox.
## Build ## Build
Use o Node.js: Use Node.js:
```bash ```bash
npm run build npm run build
``` ```
O build recria `dist/chromium` e `dist/firefox` a partir da mesma fonte. The build recreates `dist/chromium` and `dist/firefox` from the same source.
## Como carregar em modo desenvolvimento ## Release 1.0.0
- Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search.
- Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators.
- Opportunity type view preferences persisted in the browser.
- Light and dark themes based on Oracle Redwood.
- Separate outputs for Chromium/Edge and Firefox.
## Loading in Development Mode
### Chrome ou Microsoft Edge ### Chrome ou Microsoft Edge
1. Abra `chrome://extensions` ou `edge://extensions`. 1. Open `chrome://extensions` or `edge://extensions`.
2. Ative o modo de desenvolvedor. 2. Enable Developer mode.
3. Clique em "Carregar sem compactacao". 3. Click **Load unpacked**.
4. Selecione a pasta `dist/chromium`. 4. Select the `dist/chromium` folder.
### Firefox ### Firefox
1. Abra `about:debugging#/runtime/this-firefox`. 1. Open `about:debugging#/runtime/this-firefox`.
2. Clique em "Carregar extensao temporaria". 2. Click **Load Temporary Add-on**.
3. Selecione o arquivo `dist/firefox/manifest.json`. 3. Select the `dist/firefox/manifest.json` file.
## Paginas atendidas ## Supported Pages
- `https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome` - `https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome`
- `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome` - `https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome`
Ao encontrar o grupo `#yourapps_groupNode_sales`, a extensao adiciona o tile antes do item `.flat-grid-cell.flat-grid-cell-addicon`. Ao clicar no tile, exibe o alerta `Deu certo`. When it finds the `#yourapps_groupNode_sales` group, the extension adds the tile before the `.flat-grid-cell.flat-grid-cell-addicon` item. Clicking the tile opens the opportunities interface.

View File

@@ -10,13 +10,17 @@
]; ];
const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F"; const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F";
const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-"; const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-";
const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list";
const COOKIE_REFRESH_TIMEOUT_MS = 15000;
const COOKIE_REFRESH_POLL_MS = 250;
const runtimeApi = typeof browser !== "undefined" ? browser : chrome; const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || message.type !== "opportunitiesExtension.getXsrfToken") { if (!message || !message.type) {
return false; return false;
} }
if (message.type === "opportunitiesExtension.getXsrfToken") {
getXsrfTokenCookie() getXsrfTokenCookie()
.then((result) => { .then((result) => {
sendResponse({ sendResponse({
@@ -39,7 +43,147 @@
}); });
return true; return true;
}
if (message.type === "opportunitiesExtension.refreshXsrfCookie") {
refreshXsrfCookie(sender)
.then(sendResponse)
.catch((error) => {
sendResponse({
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
error: error.message || "Unable to refresh XSRF cookie."
}); });
});
return true;
}
return false;
});
async function refreshXsrfCookie(sender) {
const startedAt = Date.now();
const previous = (await getXsrfTokenCookie()).cookie;
const createProperties = {
url: OPPORTUNITIES_LIST_URL,
active: false
};
if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) {
createProperties.windowId = sender.tab.windowId;
}
if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) {
createProperties.cookieStoreId = sender.tab.cookieStoreId;
}
let refreshTab;
try {
refreshTab = await tabsCreate(createProperties);
const current = await waitForXsrfCookieChange(previous);
const refreshed = hasCookieChanged(previous, current);
return {
ok: Boolean(current),
refreshed,
cookieName: current ? current.name : "",
durationMs: Date.now() - startedAt,
error: refreshed ? "" : "XSRF cookie was not created or changed before timeout."
};
} finally {
if (refreshTab && Number.isInteger(refreshTab.id)) {
try {
await tabsRemove(refreshTab.id);
} catch (error) {
// The temporary tab may already have been closed.
}
}
}
}
async function waitForXsrfCookieChange(previousCookie) {
const deadline = Date.now() + COOKIE_REFRESH_TIMEOUT_MS;
let currentCookie = null;
while (Date.now() < deadline) {
currentCookie = (await getXsrfTokenCookie()).cookie;
if (hasCookieChanged(previousCookie, currentCookie)) {
return currentCookie;
}
await wait(COOKIE_REFRESH_POLL_MS);
}
return currentCookie;
}
function hasCookieChanged(previousCookie, currentCookie) {
if (!currentCookie || !currentCookie.value) {
return false;
}
if (!previousCookie) {
return true;
}
return previousCookie.name !== currentCookie.name
|| previousCookie.value !== currentCookie.value
|| previousCookie.expirationDate !== currentCookie.expirationDate;
}
function supportsFirefoxCookieStoreTabs() {
return Boolean(
runtimeApi.runtime
&& typeof runtimeApi.runtime.getBrowserInfo === "function"
);
}
function tabsCreate(details) {
if (runtimeApi.tabs.create.length <= 1) {
return runtimeApi.tabs.create(details);
}
return new Promise((resolve, reject) => {
runtimeApi.tabs.create(details, (tab) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(tab);
});
});
}
function tabsRemove(tabId) {
if (runtimeApi.tabs.remove.length <= 1) {
return runtimeApi.tabs.remove(tabId);
}
return new Promise((resolve, reject) => {
runtimeApi.tabs.remove(tabId, () => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve();
});
});
}
function wait(durationMs) {
return new Promise((resolve) => setTimeout(resolve, durationMs));
}
async function getXsrfTokenCookie() { async function getXsrfTokenCookie() {
const lookupDetails = []; const lookupDetails = [];

View File

@@ -26,6 +26,8 @@
const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard"; const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard";
const FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences"; const FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences";
const THEME_STORAGE_KEY = "opportunities-extension-theme"; const THEME_STORAGE_KEY = "opportunities-extension-theme";
const OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY = "opportunities-extension-opportunity-type-preferences";
const PREFERENCES_MODAL_ID = "opportunities-extension-preferences-modal";
const OPPORTUNITY_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid="; const OPPORTUNITY_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid=";
const ACCOUNT_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/accounts/accounts-detail?id="; const ACCOUNT_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/accounts/accounts-detail?id=";
const DEBUG_BODY_LIMIT = 12000; const DEBUG_BODY_LIMIT = 12000;
@@ -40,6 +42,24 @@
}; };
const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"]; const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"];
const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"]; const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"];
const OPPORTUNITY_TYPE_OPTIONS = [
{ label: "All records I can see", value: "ORA_ALLOPTIES" },
{ label: "I am credit receiver", value: "ORA_CREDITRECEIVER_ISME" },
{ label: "I am on the team", value: "ORA_MYSALESTEAMOPTIES" },
{ label: "I or my subordinate is a credit receiver", value: "ORA_CREDITRECEIVER_MYORG" },
{ label: "I own", value: "ORA_MYOPTIES" },
{ label: "My subordinates are on the team", value: "ORA_MYSUBORDSSALESTEAMOPTIES" },
{ label: "My subordinates own", value: "ORA_MYSUBORDINATESOPTIES" },
{ label: "My territory", value: "ORA_MYASSGTERROPTIES" },
{ label: "My territory hierarchy", value: "ORA_MYASSGDESCTERROPTIES" },
{ label: "My territory membership", value: "ORA_MYASSGMEMBTERROPTIES" },
{ label: "My territory membership hierarchy", value: "ORA_MYASSGMEMBDESCTERROPTIES" }
];
const DEFAULT_OPPORTUNITY_TYPE_VALUES = [
"ORA_MYSALESTEAMOPTIES",
"ORA_MYASSGTERROPTIES",
"ORA_CREDITRECEIVER_ISME"
];
let tokenRelayRequest = null; let tokenRelayRequest = null;
let accessToken = ""; let accessToken = "";
let periodRequestVersion = 0; let periodRequestVersion = 0;
@@ -52,6 +72,7 @@
let selectedOwnerKeys = new Set(); let selectedOwnerKeys = new Set();
let ownerSearch = ""; let ownerSearch = "";
let tableSearch = ""; let tableSearch = "";
let selectedOpportunityTypeValues = new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES);
let hasSavedCustomerFilter = false; let hasSavedCustomerFilter = false;
let hasSavedOwnerFilter = false; let hasSavedOwnerFilter = false;
let customerFilterInitialized = false; let customerFilterInitialized = false;
@@ -79,9 +100,9 @@
{ key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName }, { key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName },
{ key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue }, { 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: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) },
{ key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c },
{ key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
{ key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode }, { 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: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }
]; ];
const ALLOWED_PATHS = [ const ALLOWED_PATHS = [
@@ -176,6 +197,7 @@
periodRequestVersion = 0; periodRequestVersion = 0;
authStatus = AUTH_STATUS.idle; authStatus = AUTH_STATUS.idle;
resetOpportunitiesTable(); resetOpportunitiesTable();
selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues());
overlay.id = MODAL_ID; overlay.id = MODAL_ID;
overlay.className = "opportunities-extension-modal"; overlay.className = "opportunities-extension-modal";
overlay.setAttribute("data-theme", getSavedTheme()); overlay.setAttribute("data-theme", getSavedTheme());
@@ -226,9 +248,17 @@
}); });
applyTheme(overlay, themeButton, overlay.getAttribute("data-theme")); applyTheme(overlay, themeButton, overlay.getAttribute("data-theme"));
const preferencesButton = document.createElement("button");
preferencesButton.type = "button";
preferencesButton.className = "opportunities-extension-icon-button";
preferencesButton.setAttribute("aria-label", "Abrir preferências");
preferencesButton.title = "Preferences";
preferencesButton.append(createSettingsIcon());
preferencesButton.addEventListener("click", openPreferencesModal);
const headerActions = document.createElement("div"); const headerActions = document.createElement("div");
headerActions.className = "opportunities-extension-header-actions"; headerActions.className = "opportunities-extension-header-actions";
headerActions.append(themeButton, closeButton); headerActions.append(themeButton, preferencesButton, closeButton);
header.append(titleBlock, headerActions); header.append(titleBlock, headerActions);
@@ -242,7 +272,7 @@
field.className = "opportunities-extension-field"; field.className = "opportunities-extension-field";
const labelText = document.createElement("span"); const labelText = document.createElement("span");
labelText.textContent = "Periodo"; labelText.textContent = "Reference";
const selectWrap = document.createElement("span"); const selectWrap = document.createElement("span");
selectWrap.className = "opportunities-extension-select-wrap"; selectWrap.className = "opportunities-extension-select-wrap";
@@ -502,8 +532,98 @@
} }
async function requestTokenRelay() { async function requestTokenRelay() {
let refreshResult = null;
let refreshAttempted = false;
while (true) {
const xsrfTokenSource = await getXsrfToken(); const xsrfTokenSource = await getXsrfToken();
const requestEntry = logRequestStart({
if (!xsrfTokenSource.token) {
if (refreshAttempted) {
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult);
logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh.");
throw new Error("XSRF token cookie not found after automatic refresh.");
}
refreshResult = await refreshXsrfCookie("missing-cookie");
refreshAttempted = true;
continue;
}
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult);
try {
const response = await fetch(TOKEN_RELAY_URL, {
method: "GET",
credentials: "include",
headers: {
"x-xsrf-token": xsrfTokenSource.token
}
});
const responseBody = await readResponseBodyForDebug(response);
if (!response.ok) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted && isInvalidXsrfStatus(response.status)) {
refreshResult = await refreshXsrfCookie(`tokenrelay-${response.status}`);
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw new Error(`Token relay failed with status ${response.status}.`);
}
let responseData;
try {
responseData = await response.json();
} catch (error) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted) {
refreshResult = await refreshXsrfCookie("tokenrelay-invalid-response");
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw error;
}
if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted) {
refreshResult = await refreshXsrfCookie("tokenrelay-missing-access-token");
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw new Error("Token relay response does not contain access_token.");
}
logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({
...responseData,
access_token: maskToken(responseData.access_token)
}, null, 2)));
return responseData.access_token;
} catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed.");
throw error;
}
}
}
function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult) {
return logRequestStart({
name: "requestTokenRelay", name: "requestTokenRelay",
method: "GET", method: "GET",
url: TOKEN_RELAY_URL, url: TOKEN_RELAY_URL,
@@ -519,62 +639,40 @@
xsrfLookupDetails: xsrfTokenSource.lookupDetails && xsrfTokenSource.lookupDetails.length xsrfLookupDetails: xsrfTokenSource.lookupDetails && xsrfTokenSource.lookupDetails.length
? xsrfTokenSource.lookupDetails.join(" | ") ? xsrfTokenSource.lookupDetails.join(" | ")
: "(none)", : "(none)",
xsrfTokenError: xsrfTokenSource.error || "" xsrfTokenError: xsrfTokenSource.error || "",
xsrfRefresh: formatXsrfRefreshResult(refreshResult)
} }
}); });
if (!xsrfTokenSource.token) {
logRequestFailure(requestEntry, "XSRF token cookie not found.");
throw new Error("XSRF token cookie not found.");
} }
try { function isInvalidXsrfStatus(status) {
const response = await fetch(TOKEN_RELAY_URL, { return status === 400 || status === 401 || status === 403;
method: "GET",
credentials: "include",
headers: {
"x-xsrf-token": xsrfTokenSource.token
}
});
const responseBody = await readResponseBodyForDebug(response);
if (!response.ok) {
logRequestSuccess(requestEntry, response, responseBody);
throw new Error(`Token relay failed with status ${response.status}.`);
} }
let responseData; function formatXsrfRefreshResult(result) {
if (!result) {
try { return "not-requested";
responseData = await response.json();
} catch (error) {
logRequestSuccess(requestEntry, response, responseBody);
throw error;
} }
if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) { const outcome = result.refreshed ? "refreshed" : "not-refreshed";
logRequestSuccess(requestEntry, response, responseBody); const cookieName = result.cookieName || "(none)";
throw new Error("Token relay response does not contain access_token."); const duration = Number.isFinite(result.durationMs) ? `${result.durationMs}ms` : "unknown";
} return `${result.reason}; ${outcome}; cookie=${cookieName}; duration=${duration}; ${result.error || ""}`.trim();
logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({
...responseData,
access_token: maskToken(responseData.access_token)
}, null, 2)));
return responseData.access_token;
} catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed.");
throw error;
}
} }
async function requestOpportunitiesForPeriod(period) { async function requestOpportunitiesForPeriod(period) {
const dateRange = getFiscalPeriodRange(period, new Date()); const dateRange = getFiscalPeriodRange(period, new Date());
const requestVersion = ++periodRequestVersion; const requestVersion = ++periodRequestVersion;
if (selectedOpportunityTypeValues.size === 0) {
setOpportunitiesTableState({
items: [],
status: "empty",
message: "Select at least one Opportunity Type View in Preferences."
});
return;
}
if (!dateRange) { if (!dateRange) {
setOpportunitiesTableState({ setOpportunitiesTableState({
items: [], items: [],
@@ -1302,6 +1400,13 @@
} }
if (owner && typeof owner === "object") { 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 return owner.PartyName
|| owner.PartyUniqueName || owner.PartyUniqueName
|| owner.Name || owner.Name
@@ -1435,7 +1540,7 @@
const normalizedSearch = tableSearch.trim().toLocaleLowerCase(); const normalizedSearch = tableSearch.trim().toLocaleLowerCase();
const items = opportunitiesTableState.items.filter((item) => { const items = opportunitiesTableState.items.filter((item) => {
return selectedStages.has(item.ForecastGroup_c) return selectedStages.has(item.ForecastGroup_c)
&& selectedStatuses.has(item.StatusCode) && matchesSelectedStatus(item.StatusCode)
&& selectedCustomerKeys.has(getCustomerKey(item)) && selectedCustomerKeys.has(getCustomerKey(item))
&& selectedOwnerKeys.has(getOwnerKey(item)) && selectedOwnerKeys.has(getOwnerKey(item))
&& matchesTableSearch(item, normalizedSearch); && matchesTableSearch(item, normalizedSearch);
@@ -1488,6 +1593,14 @@
}); });
} }
function matchesSelectedStatus(statusCode) {
if (statusCode === "WON_PENDING") {
return selectedStatuses.has("WON");
}
return selectedStatuses.has(statusCode);
}
function stringifySearchValue(value) { function stringifySearchValue(value) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return ""; return "";
@@ -1679,10 +1792,11 @@
criteria: [ criteria: [
{ {
op: "$or", op: "$or",
criteria: [ criteria: Array.from(selectedOpportunityTypeValues, (value) => ({
{ op: "$eq", attribute: "RecordSet", value: "ORA_MYSALESTEAMOPTIES" }, op: "$eq",
{ op: "$eq", attribute: "RecordSet", value: "ORA_MYASSGTERROPTIES" } attribute: "RecordSet",
] value
}))
}, },
{ {
op: "$wi", op: "$wi",
@@ -2091,6 +2205,52 @@
}); });
} }
function refreshXsrfCookie(reason) {
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {
return Promise.resolve({
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
reason,
error: "Runtime messaging API unavailable."
});
}
return new Promise((resolve) => {
runtimeApi.runtime.sendMessage({
type: "opportunitiesExtension.refreshXsrfCookie"
}, (response) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
resolve({
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
reason,
error: lastError.message
});
return;
}
resolve({
...(response || {
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
error: "Empty refresh response."
}),
reason
});
});
});
}
function updateAuthBadge(status) { function updateAuthBadge(status) {
const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`); const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`);
@@ -2115,6 +2275,7 @@
const modal = document.getElementById(MODAL_ID); const modal = document.getElementById(MODAL_ID);
if (modal) { if (modal) {
closePreferencesModal();
modal.hidden = true; modal.hidden = true;
} }
@@ -2168,6 +2329,185 @@
return svg; return svg;
} }
function createSettingsIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const upperControl = document.createElementNS("http://www.w3.org/2000/svg", "circle");
const lowerControl = document.createElementNS("http://www.w3.org/2000/svg", "circle");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("class", "opportunities-extension-settings-icon");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
appendPath(svg, "", "M4 7h16M4 17h16");
[upperControl, lowerControl].forEach((control, index) => {
control.setAttribute("cx", index === 0 ? "9" : "15");
control.setAttribute("cy", index === 0 ? "7" : "17");
control.setAttribute("r", "2.5");
control.setAttribute("fill", "currentColor");
control.setAttribute("stroke", "currentColor");
control.setAttribute("stroke-width", "1.8");
});
svg.querySelector("path").setAttribute("fill", "none");
svg.querySelector("path").setAttribute("stroke", "currentColor");
svg.querySelector("path").setAttribute("stroke-linecap", "round");
svg.querySelector("path").setAttribute("stroke-width", "1.8");
svg.append(upperControl, lowerControl);
return svg;
}
function createCloseIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const path = 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");
path.setAttribute("d", "m6 6 12 12M18 6 6 18");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-width", "1.8");
svg.append(path);
return svg;
}
function getSavedOpportunityTypeValues() {
try {
const rawPreferences = localStorage.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 openPreferencesModal() {
const overlay = document.getElementById(MODAL_ID);
if (!overlay) {
return;
}
const existingModal = document.getElementById(PREFERENCES_MODAL_ID);
if (existingModal) {
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);
content.append(fieldset);
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);
selectedOpportunityTypeValues = new Set(selectedValues);
try {
localStorage.setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({
opportunityTypeValues: selectedValues
}));
} 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;
}
}
function ensureEscapeKeyHandler() { function ensureEscapeKeyHandler() {
if (window[ESCAPE_LISTENER_KEY]) { if (window[ESCAPE_LISTENER_KEY]) {
return; return;
@@ -2189,6 +2529,14 @@
} }
if (event.key === "Escape") { if (event.key === "Escape") {
const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID);
if (preferencesModal && !preferencesModal.hidden) {
closePreferencesModal();
event.preventDefault();
return;
}
if (closeCustomerFilterPanel()) { if (closeCustomerFilterPanel()) {
event.preventDefault(); event.preventDefault();
return; return;
@@ -2365,6 +2713,11 @@
vertical-align: middle; vertical-align: middle;
} }
.opportunities-extension-icon-button .opportunities-extension-settings-icon {
width: 20px;
height: 20px;
}
.opportunities-extension-icon-button:hover, .opportunities-extension-icon-button:hover,
.opportunities-extension-icon-button:focus { .opportunities-extension-icon-button:focus {
border-color: #b8b2ad; border-color: #b8b2ad;
@@ -2447,7 +2800,7 @@
} }
.opportunities-extension-total-dashboard-card { .opportunities-extension-total-dashboard-card {
border-top-color: #00758f; border-top-color: #312d2a;
} }
.opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] {
@@ -2867,12 +3220,13 @@
border: 1px solid #dedbd7; border: 1px solid #dedbd7;
border-radius: 4px; border-radius: 4px;
background: #ffffff; background: #ffffff;
overflow: auto; overflow-x: auto;
overflow-y: auto;
} }
.opportunities-extension-table { .opportunities-extension-table {
width: 100%; width: 100%;
min-width: 1440px; min-width: 0;
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; table-layout: fixed;
color: #312d2a; color: #312d2a;
@@ -2907,7 +3261,7 @@
} }
.opportunities-extension-table th:nth-child(1) { .opportunities-extension-table th:nth-child(1) {
width: 14%; width: 17%;
} }
.opportunities-extension-table th:nth-child(2) { .opportunities-extension-table th:nth-child(2) {
@@ -2915,7 +3269,7 @@
} }
.opportunities-extension-table th:nth-child(3) { .opportunities-extension-table th:nth-child(3) {
width: 17%; width: 19%;
} }
.opportunities-extension-table th:nth-child(4) { .opportunities-extension-table th:nth-child(4) {
@@ -2923,19 +3277,19 @@
} }
.opportunities-extension-table th:nth-child(5) { .opportunities-extension-table th:nth-child(5) {
width: 9%; width: 8%;
} }
.opportunities-extension-table th:nth-child(6) { .opportunities-extension-table th:nth-child(6) {
width: 9%; width: 8%;
} }
.opportunities-extension-table th:nth-child(7) { .opportunities-extension-table th:nth-child(7) {
width: 8%; width: 7%;
} }
.opportunities-extension-table th:nth-child(8) { .opportunities-extension-table th:nth-child(8) {
width: 8%; width: 6%;
} }
.opportunities-extension-table th:nth-child(9) { .opportunities-extension-table th:nth-child(9) {
@@ -2949,10 +3303,30 @@
.opportunities-extension-table td:nth-child(2), .opportunities-extension-table td:nth-child(2),
.opportunities-extension-table td:nth-child(5), .opportunities-extension-table td:nth-child(5),
.opportunities-extension-table td:nth-child(6), .opportunities-extension-table td:nth-child(6),
.opportunities-extension-table td:nth-child(7), .opportunities-extension-table td:nth-child(8) {
.opportunities-extension-table td:nth-child(8), white-space: nowrap;
.opportunities-extension-table td:nth-child(9), overflow: hidden;
text-overflow: ellipsis;
}
.opportunities-extension-table td:nth-child(9) {
padding-right: 6px;
padding-left: 6px;
white-space: nowrap;
overflow: visible;
}
.opportunities-extension-table td:nth-child(7) {
padding-right: 6px;
padding-left: 6px;
white-space: nowrap;
overflow: visible;
}
.opportunities-extension-table td:nth-child(10) { .opportunities-extension-table td:nth-child(10) {
padding-right: 6px;
padding-left: 6px;
font-size: 12px;
white-space: nowrap; white-space: nowrap;
} }
@@ -3112,14 +3486,16 @@
align-items: center; align-items: center;
width: 100%; width: 100%;
min-height: 44px; min-height: 44px;
padding: 10px 30px 10px 16px; padding: 8px 42px 8px 16px;
border: 0; border: 0;
background: transparent; background: transparent;
color: inherit; color: inherit;
font: inherit; font: inherit;
font-weight: inherit; font-weight: inherit;
letter-spacing: 0; letter-spacing: 0;
line-height: 1.2;
text-align: left; text-align: left;
white-space: normal;
cursor: pointer; cursor: pointer;
} }
@@ -3170,6 +3546,135 @@
border: 0; border: 0;
} }
.opportunities-extension-preferences-modal {
position: absolute;
inset: 0;
z-index: 3;
display: grid;
place-items: center;
padding: 24px;
background: rgba(0, 0, 0, .42);
}
.opportunities-extension-preferences-modal[hidden] {
display: none !important;
}
.opportunities-extension-preferences-dialog {
display: grid;
width: min(680px, 100%);
max-height: min(720px, calc(100vh - 48px));
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
border: 1px solid #8f8a85;
border-radius: 4px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(0, 0, 0, .24);
color: #312d2a;
}
.opportunities-extension-preferences-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
border-bottom: 1px solid #dedbd7;
}
.opportunities-extension-preferences-header h2 {
margin: 0;
font-size: 20px;
font-weight: 700;
line-height: 1.2;
}
.opportunities-extension-preferences-content {
min-height: 0;
overflow: auto;
padding: 20px;
}
.opportunities-extension-preferences-fieldset {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.opportunities-extension-preferences-fieldset legend {
margin-bottom: 12px;
padding: 0;
color: #312d2a;
font-size: 15px;
font-weight: 700;
line-height: 1.3;
}
.opportunities-extension-preferences-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 20px;
}
.opportunities-extension-preferences-option {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
padding: 9px 8px;
border-radius: 4px;
color: #312d2a;
font-size: 14px;
line-height: 1.35;
cursor: pointer;
}
.opportunities-extension-preferences-option:hover {
background: #f0eeeb;
}
.opportunities-extension-preferences-option input {
width: 18px;
height: 18px;
flex: 0 0 18px;
margin: 0;
accent-color: #00758f;
cursor: pointer;
}
.opportunities-extension-preferences-footer {
display: flex;
justify-content: flex-end;
padding: 14px 20px;
border-top: 1px solid #dedbd7;
}
.opportunities-extension-button {
min-width: 88px;
min-height: 36px;
padding: 7px 16px;
border: 1px solid transparent;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
}
.opportunities-extension-button-primary {
border-color: #00758f;
background: #00758f;
color: #ffffff;
}
.opportunities-extension-button-primary:hover,
.opportunities-extension-button-primary:focus-visible {
border-color: #005e73;
background: #005e73;
outline: none;
}
.opportunities-extension-debug-panel { .opportunities-extension-debug-panel {
position: fixed; position: fixed;
right: 24px; right: 24px;
@@ -3368,6 +3873,7 @@
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-surface, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-surface,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-panel, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-panel,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-dialog,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] { .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] {
border-color: #4e4a46; border-color: #4e4a46;
@@ -3405,6 +3911,9 @@
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header h2,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-fieldset legend,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-heading, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-heading,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 { .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 {
@@ -3416,6 +3925,30 @@
border-bottom-color: #4e4a46; border-bottom-color: #4e4a46;
} }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-footer {
border-color: #4e4a46;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option:hover {
background: #3b3937;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option input {
accent-color: #43c4d5;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary {
border-color: #00a6be;
background: #008aa6;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary:hover,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary:focus-visible {
border-color: #43c4d5;
background: #006d7a;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option:hover, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option:hover,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table tbody tr:hover, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table tbody tr:hover,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sort-button:hover, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sort-button:hover,
@@ -3471,7 +4004,7 @@
} }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-total-dashboard-card { .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-total-dashboard-card {
border-top-color: #43c4d5; border-top-color: #f6f4f2;
} }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] {
@@ -3569,6 +4102,62 @@
color: #f6f4f2; color: #f6f4f2;
} }
@media (min-width: 1201px) and (max-width: 1450px) {
.opportunities-extension-table th:nth-child(1) {
width: 16%;
}
.opportunities-extension-table th:nth-child(3) {
width: 18%;
}
.opportunities-extension-table th:nth-child(4) {
width: 12%;
}
.opportunities-extension-table th:nth-child(6) {
width: 7%;
}
.opportunities-extension-table th:nth-child(7) {
width: 8%;
}
.opportunities-extension-table th:nth-child(10) {
width: 10%;
}
}
@media (max-width: 1200px) {
.opportunities-extension-table {
min-width: 1180px;
}
.opportunities-extension-table th:nth-child(1) {
width: 15%;
}
.opportunities-extension-table th:nth-child(3) {
width: 17%;
}
.opportunities-extension-table th:nth-child(4) {
width: 13%;
}
.opportunities-extension-table th:nth-child(6) {
width: 6%;
}
.opportunities-extension-table th:nth-child(7) {
width: 9%;
}
.opportunities-extension-table th:nth-child(10) {
width: 11%;
}
}
@media (max-width: 700px) { @media (max-width: 700px) {
.opportunities-extension-header { .opportunities-extension-header {
padding: 24px 20px 18px; padding: 24px 20px 18px;
@@ -3627,6 +4216,18 @@
font-size: 18px; font-size: 18px;
} }
.opportunities-extension-preferences-modal {
padding: 12px;
}
.opportunities-extension-preferences-dialog {
max-height: calc(100vh - 24px);
}
.opportunities-extension-preferences-options {
grid-template-columns: 1fr;
}
.opportunities-extension-stage-filter-controls { .opportunities-extension-stage-filter-controls {
gap: 6px; gap: 6px;
} }

View File

@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Opportunities Extension", "name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", "description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "0.1.0", "version": "1.0.0",
"permissions": [ "permissions": [
"cookies" "cookies"
], ],

View File

@@ -10,13 +10,17 @@
]; ];
const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F"; const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F";
const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-"; const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-";
const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list";
const COOKIE_REFRESH_TIMEOUT_MS = 15000;
const COOKIE_REFRESH_POLL_MS = 250;
const runtimeApi = typeof browser !== "undefined" ? browser : chrome; const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || message.type !== "opportunitiesExtension.getXsrfToken") { if (!message || !message.type) {
return false; return false;
} }
if (message.type === "opportunitiesExtension.getXsrfToken") {
getXsrfTokenCookie() getXsrfTokenCookie()
.then((result) => { .then((result) => {
sendResponse({ sendResponse({
@@ -39,7 +43,147 @@
}); });
return true; return true;
}
if (message.type === "opportunitiesExtension.refreshXsrfCookie") {
refreshXsrfCookie(sender)
.then(sendResponse)
.catch((error) => {
sendResponse({
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
error: error.message || "Unable to refresh XSRF cookie."
}); });
});
return true;
}
return false;
});
async function refreshXsrfCookie(sender) {
const startedAt = Date.now();
const previous = (await getXsrfTokenCookie()).cookie;
const createProperties = {
url: OPPORTUNITIES_LIST_URL,
active: false
};
if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) {
createProperties.windowId = sender.tab.windowId;
}
if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) {
createProperties.cookieStoreId = sender.tab.cookieStoreId;
}
let refreshTab;
try {
refreshTab = await tabsCreate(createProperties);
const current = await waitForXsrfCookieChange(previous);
const refreshed = hasCookieChanged(previous, current);
return {
ok: Boolean(current),
refreshed,
cookieName: current ? current.name : "",
durationMs: Date.now() - startedAt,
error: refreshed ? "" : "XSRF cookie was not created or changed before timeout."
};
} finally {
if (refreshTab && Number.isInteger(refreshTab.id)) {
try {
await tabsRemove(refreshTab.id);
} catch (error) {
// The temporary tab may already have been closed.
}
}
}
}
async function waitForXsrfCookieChange(previousCookie) {
const deadline = Date.now() + COOKIE_REFRESH_TIMEOUT_MS;
let currentCookie = null;
while (Date.now() < deadline) {
currentCookie = (await getXsrfTokenCookie()).cookie;
if (hasCookieChanged(previousCookie, currentCookie)) {
return currentCookie;
}
await wait(COOKIE_REFRESH_POLL_MS);
}
return currentCookie;
}
function hasCookieChanged(previousCookie, currentCookie) {
if (!currentCookie || !currentCookie.value) {
return false;
}
if (!previousCookie) {
return true;
}
return previousCookie.name !== currentCookie.name
|| previousCookie.value !== currentCookie.value
|| previousCookie.expirationDate !== currentCookie.expirationDate;
}
function supportsFirefoxCookieStoreTabs() {
return Boolean(
runtimeApi.runtime
&& typeof runtimeApi.runtime.getBrowserInfo === "function"
);
}
function tabsCreate(details) {
if (runtimeApi.tabs.create.length <= 1) {
return runtimeApi.tabs.create(details);
}
return new Promise((resolve, reject) => {
runtimeApi.tabs.create(details, (tab) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(tab);
});
});
}
function tabsRemove(tabId) {
if (runtimeApi.tabs.remove.length <= 1) {
return runtimeApi.tabs.remove(tabId);
}
return new Promise((resolve, reject) => {
runtimeApi.tabs.remove(tabId, () => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve();
});
});
}
function wait(durationMs) {
return new Promise((resolve) => setTimeout(resolve, durationMs));
}
async function getXsrfTokenCookie() { async function getXsrfTokenCookie() {
const lookupDetails = []; const lookupDetails = [];

View File

@@ -26,6 +26,8 @@
const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard"; const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard";
const FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences"; const FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences";
const THEME_STORAGE_KEY = "opportunities-extension-theme"; const THEME_STORAGE_KEY = "opportunities-extension-theme";
const OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY = "opportunities-extension-opportunity-type-preferences";
const PREFERENCES_MODAL_ID = "opportunities-extension-preferences-modal";
const OPPORTUNITY_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid="; const OPPORTUNITY_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid=";
const ACCOUNT_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/accounts/accounts-detail?id="; const ACCOUNT_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/accounts/accounts-detail?id=";
const DEBUG_BODY_LIMIT = 12000; const DEBUG_BODY_LIMIT = 12000;
@@ -40,6 +42,24 @@
}; };
const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"]; const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"];
const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"]; const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"];
const OPPORTUNITY_TYPE_OPTIONS = [
{ label: "All records I can see", value: "ORA_ALLOPTIES" },
{ label: "I am credit receiver", value: "ORA_CREDITRECEIVER_ISME" },
{ label: "I am on the team", value: "ORA_MYSALESTEAMOPTIES" },
{ label: "I or my subordinate is a credit receiver", value: "ORA_CREDITRECEIVER_MYORG" },
{ label: "I own", value: "ORA_MYOPTIES" },
{ label: "My subordinates are on the team", value: "ORA_MYSUBORDSSALESTEAMOPTIES" },
{ label: "My subordinates own", value: "ORA_MYSUBORDINATESOPTIES" },
{ label: "My territory", value: "ORA_MYASSGTERROPTIES" },
{ label: "My territory hierarchy", value: "ORA_MYASSGDESCTERROPTIES" },
{ label: "My territory membership", value: "ORA_MYASSGMEMBTERROPTIES" },
{ label: "My territory membership hierarchy", value: "ORA_MYASSGMEMBDESCTERROPTIES" }
];
const DEFAULT_OPPORTUNITY_TYPE_VALUES = [
"ORA_MYSALESTEAMOPTIES",
"ORA_MYASSGTERROPTIES",
"ORA_CREDITRECEIVER_ISME"
];
let tokenRelayRequest = null; let tokenRelayRequest = null;
let accessToken = ""; let accessToken = "";
let periodRequestVersion = 0; let periodRequestVersion = 0;
@@ -52,6 +72,7 @@
let selectedOwnerKeys = new Set(); let selectedOwnerKeys = new Set();
let ownerSearch = ""; let ownerSearch = "";
let tableSearch = ""; let tableSearch = "";
let selectedOpportunityTypeValues = new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES);
let hasSavedCustomerFilter = false; let hasSavedCustomerFilter = false;
let hasSavedOwnerFilter = false; let hasSavedOwnerFilter = false;
let customerFilterInitialized = false; let customerFilterInitialized = false;
@@ -79,9 +100,9 @@
{ key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName }, { key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName },
{ key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue }, { 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: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) },
{ key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c },
{ key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
{ key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode }, { 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: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }
]; ];
const ALLOWED_PATHS = [ const ALLOWED_PATHS = [
@@ -176,6 +197,7 @@
periodRequestVersion = 0; periodRequestVersion = 0;
authStatus = AUTH_STATUS.idle; authStatus = AUTH_STATUS.idle;
resetOpportunitiesTable(); resetOpportunitiesTable();
selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues());
overlay.id = MODAL_ID; overlay.id = MODAL_ID;
overlay.className = "opportunities-extension-modal"; overlay.className = "opportunities-extension-modal";
overlay.setAttribute("data-theme", getSavedTheme()); overlay.setAttribute("data-theme", getSavedTheme());
@@ -226,9 +248,17 @@
}); });
applyTheme(overlay, themeButton, overlay.getAttribute("data-theme")); applyTheme(overlay, themeButton, overlay.getAttribute("data-theme"));
const preferencesButton = document.createElement("button");
preferencesButton.type = "button";
preferencesButton.className = "opportunities-extension-icon-button";
preferencesButton.setAttribute("aria-label", "Abrir preferências");
preferencesButton.title = "Preferences";
preferencesButton.append(createSettingsIcon());
preferencesButton.addEventListener("click", openPreferencesModal);
const headerActions = document.createElement("div"); const headerActions = document.createElement("div");
headerActions.className = "opportunities-extension-header-actions"; headerActions.className = "opportunities-extension-header-actions";
headerActions.append(themeButton, closeButton); headerActions.append(themeButton, preferencesButton, closeButton);
header.append(titleBlock, headerActions); header.append(titleBlock, headerActions);
@@ -242,7 +272,7 @@
field.className = "opportunities-extension-field"; field.className = "opportunities-extension-field";
const labelText = document.createElement("span"); const labelText = document.createElement("span");
labelText.textContent = "Periodo"; labelText.textContent = "Reference";
const selectWrap = document.createElement("span"); const selectWrap = document.createElement("span");
selectWrap.className = "opportunities-extension-select-wrap"; selectWrap.className = "opportunities-extension-select-wrap";
@@ -502,8 +532,98 @@
} }
async function requestTokenRelay() { async function requestTokenRelay() {
let refreshResult = null;
let refreshAttempted = false;
while (true) {
const xsrfTokenSource = await getXsrfToken(); const xsrfTokenSource = await getXsrfToken();
const requestEntry = logRequestStart({
if (!xsrfTokenSource.token) {
if (refreshAttempted) {
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult);
logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh.");
throw new Error("XSRF token cookie not found after automatic refresh.");
}
refreshResult = await refreshXsrfCookie("missing-cookie");
refreshAttempted = true;
continue;
}
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult);
try {
const response = await fetch(TOKEN_RELAY_URL, {
method: "GET",
credentials: "include",
headers: {
"x-xsrf-token": xsrfTokenSource.token
}
});
const responseBody = await readResponseBodyForDebug(response);
if (!response.ok) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted && isInvalidXsrfStatus(response.status)) {
refreshResult = await refreshXsrfCookie(`tokenrelay-${response.status}`);
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw new Error(`Token relay failed with status ${response.status}.`);
}
let responseData;
try {
responseData = await response.json();
} catch (error) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted) {
refreshResult = await refreshXsrfCookie("tokenrelay-invalid-response");
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw error;
}
if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted) {
refreshResult = await refreshXsrfCookie("tokenrelay-missing-access-token");
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw new Error("Token relay response does not contain access_token.");
}
logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({
...responseData,
access_token: maskToken(responseData.access_token)
}, null, 2)));
return responseData.access_token;
} catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed.");
throw error;
}
}
}
function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult) {
return logRequestStart({
name: "requestTokenRelay", name: "requestTokenRelay",
method: "GET", method: "GET",
url: TOKEN_RELAY_URL, url: TOKEN_RELAY_URL,
@@ -519,62 +639,40 @@
xsrfLookupDetails: xsrfTokenSource.lookupDetails && xsrfTokenSource.lookupDetails.length xsrfLookupDetails: xsrfTokenSource.lookupDetails && xsrfTokenSource.lookupDetails.length
? xsrfTokenSource.lookupDetails.join(" | ") ? xsrfTokenSource.lookupDetails.join(" | ")
: "(none)", : "(none)",
xsrfTokenError: xsrfTokenSource.error || "" xsrfTokenError: xsrfTokenSource.error || "",
xsrfRefresh: formatXsrfRefreshResult(refreshResult)
} }
}); });
if (!xsrfTokenSource.token) {
logRequestFailure(requestEntry, "XSRF token cookie not found.");
throw new Error("XSRF token cookie not found.");
} }
try { function isInvalidXsrfStatus(status) {
const response = await fetch(TOKEN_RELAY_URL, { return status === 400 || status === 401 || status === 403;
method: "GET",
credentials: "include",
headers: {
"x-xsrf-token": xsrfTokenSource.token
}
});
const responseBody = await readResponseBodyForDebug(response);
if (!response.ok) {
logRequestSuccess(requestEntry, response, responseBody);
throw new Error(`Token relay failed with status ${response.status}.`);
} }
let responseData; function formatXsrfRefreshResult(result) {
if (!result) {
try { return "not-requested";
responseData = await response.json();
} catch (error) {
logRequestSuccess(requestEntry, response, responseBody);
throw error;
} }
if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) { const outcome = result.refreshed ? "refreshed" : "not-refreshed";
logRequestSuccess(requestEntry, response, responseBody); const cookieName = result.cookieName || "(none)";
throw new Error("Token relay response does not contain access_token."); const duration = Number.isFinite(result.durationMs) ? `${result.durationMs}ms` : "unknown";
} return `${result.reason}; ${outcome}; cookie=${cookieName}; duration=${duration}; ${result.error || ""}`.trim();
logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({
...responseData,
access_token: maskToken(responseData.access_token)
}, null, 2)));
return responseData.access_token;
} catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed.");
throw error;
}
} }
async function requestOpportunitiesForPeriod(period) { async function requestOpportunitiesForPeriod(period) {
const dateRange = getFiscalPeriodRange(period, new Date()); const dateRange = getFiscalPeriodRange(period, new Date());
const requestVersion = ++periodRequestVersion; const requestVersion = ++periodRequestVersion;
if (selectedOpportunityTypeValues.size === 0) {
setOpportunitiesTableState({
items: [],
status: "empty",
message: "Select at least one Opportunity Type View in Preferences."
});
return;
}
if (!dateRange) { if (!dateRange) {
setOpportunitiesTableState({ setOpportunitiesTableState({
items: [], items: [],
@@ -1302,6 +1400,13 @@
} }
if (owner && typeof owner === "object") { 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 return owner.PartyName
|| owner.PartyUniqueName || owner.PartyUniqueName
|| owner.Name || owner.Name
@@ -1435,7 +1540,7 @@
const normalizedSearch = tableSearch.trim().toLocaleLowerCase(); const normalizedSearch = tableSearch.trim().toLocaleLowerCase();
const items = opportunitiesTableState.items.filter((item) => { const items = opportunitiesTableState.items.filter((item) => {
return selectedStages.has(item.ForecastGroup_c) return selectedStages.has(item.ForecastGroup_c)
&& selectedStatuses.has(item.StatusCode) && matchesSelectedStatus(item.StatusCode)
&& selectedCustomerKeys.has(getCustomerKey(item)) && selectedCustomerKeys.has(getCustomerKey(item))
&& selectedOwnerKeys.has(getOwnerKey(item)) && selectedOwnerKeys.has(getOwnerKey(item))
&& matchesTableSearch(item, normalizedSearch); && matchesTableSearch(item, normalizedSearch);
@@ -1488,6 +1593,14 @@
}); });
} }
function matchesSelectedStatus(statusCode) {
if (statusCode === "WON_PENDING") {
return selectedStatuses.has("WON");
}
return selectedStatuses.has(statusCode);
}
function stringifySearchValue(value) { function stringifySearchValue(value) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return ""; return "";
@@ -1679,10 +1792,11 @@
criteria: [ criteria: [
{ {
op: "$or", op: "$or",
criteria: [ criteria: Array.from(selectedOpportunityTypeValues, (value) => ({
{ op: "$eq", attribute: "RecordSet", value: "ORA_MYSALESTEAMOPTIES" }, op: "$eq",
{ op: "$eq", attribute: "RecordSet", value: "ORA_MYASSGTERROPTIES" } attribute: "RecordSet",
] value
}))
}, },
{ {
op: "$wi", op: "$wi",
@@ -2091,6 +2205,52 @@
}); });
} }
function refreshXsrfCookie(reason) {
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {
return Promise.resolve({
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
reason,
error: "Runtime messaging API unavailable."
});
}
return new Promise((resolve) => {
runtimeApi.runtime.sendMessage({
type: "opportunitiesExtension.refreshXsrfCookie"
}, (response) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
resolve({
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
reason,
error: lastError.message
});
return;
}
resolve({
...(response || {
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
error: "Empty refresh response."
}),
reason
});
});
});
}
function updateAuthBadge(status) { function updateAuthBadge(status) {
const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`); const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`);
@@ -2115,6 +2275,7 @@
const modal = document.getElementById(MODAL_ID); const modal = document.getElementById(MODAL_ID);
if (modal) { if (modal) {
closePreferencesModal();
modal.hidden = true; modal.hidden = true;
} }
@@ -2168,6 +2329,185 @@
return svg; return svg;
} }
function createSettingsIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const upperControl = document.createElementNS("http://www.w3.org/2000/svg", "circle");
const lowerControl = document.createElementNS("http://www.w3.org/2000/svg", "circle");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("class", "opportunities-extension-settings-icon");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
appendPath(svg, "", "M4 7h16M4 17h16");
[upperControl, lowerControl].forEach((control, index) => {
control.setAttribute("cx", index === 0 ? "9" : "15");
control.setAttribute("cy", index === 0 ? "7" : "17");
control.setAttribute("r", "2.5");
control.setAttribute("fill", "currentColor");
control.setAttribute("stroke", "currentColor");
control.setAttribute("stroke-width", "1.8");
});
svg.querySelector("path").setAttribute("fill", "none");
svg.querySelector("path").setAttribute("stroke", "currentColor");
svg.querySelector("path").setAttribute("stroke-linecap", "round");
svg.querySelector("path").setAttribute("stroke-width", "1.8");
svg.append(upperControl, lowerControl);
return svg;
}
function createCloseIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const path = 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");
path.setAttribute("d", "m6 6 12 12M18 6 6 18");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-width", "1.8");
svg.append(path);
return svg;
}
function getSavedOpportunityTypeValues() {
try {
const rawPreferences = localStorage.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 openPreferencesModal() {
const overlay = document.getElementById(MODAL_ID);
if (!overlay) {
return;
}
const existingModal = document.getElementById(PREFERENCES_MODAL_ID);
if (existingModal) {
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);
content.append(fieldset);
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);
selectedOpportunityTypeValues = new Set(selectedValues);
try {
localStorage.setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({
opportunityTypeValues: selectedValues
}));
} 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;
}
}
function ensureEscapeKeyHandler() { function ensureEscapeKeyHandler() {
if (window[ESCAPE_LISTENER_KEY]) { if (window[ESCAPE_LISTENER_KEY]) {
return; return;
@@ -2189,6 +2529,14 @@
} }
if (event.key === "Escape") { if (event.key === "Escape") {
const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID);
if (preferencesModal && !preferencesModal.hidden) {
closePreferencesModal();
event.preventDefault();
return;
}
if (closeCustomerFilterPanel()) { if (closeCustomerFilterPanel()) {
event.preventDefault(); event.preventDefault();
return; return;
@@ -2365,6 +2713,11 @@
vertical-align: middle; vertical-align: middle;
} }
.opportunities-extension-icon-button .opportunities-extension-settings-icon {
width: 20px;
height: 20px;
}
.opportunities-extension-icon-button:hover, .opportunities-extension-icon-button:hover,
.opportunities-extension-icon-button:focus { .opportunities-extension-icon-button:focus {
border-color: #b8b2ad; border-color: #b8b2ad;
@@ -2447,7 +2800,7 @@
} }
.opportunities-extension-total-dashboard-card { .opportunities-extension-total-dashboard-card {
border-top-color: #00758f; border-top-color: #312d2a;
} }
.opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] {
@@ -2867,12 +3220,13 @@
border: 1px solid #dedbd7; border: 1px solid #dedbd7;
border-radius: 4px; border-radius: 4px;
background: #ffffff; background: #ffffff;
overflow: auto; overflow-x: auto;
overflow-y: auto;
} }
.opportunities-extension-table { .opportunities-extension-table {
width: 100%; width: 100%;
min-width: 1440px; min-width: 0;
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; table-layout: fixed;
color: #312d2a; color: #312d2a;
@@ -2907,7 +3261,7 @@
} }
.opportunities-extension-table th:nth-child(1) { .opportunities-extension-table th:nth-child(1) {
width: 14%; width: 17%;
} }
.opportunities-extension-table th:nth-child(2) { .opportunities-extension-table th:nth-child(2) {
@@ -2915,7 +3269,7 @@
} }
.opportunities-extension-table th:nth-child(3) { .opportunities-extension-table th:nth-child(3) {
width: 17%; width: 19%;
} }
.opportunities-extension-table th:nth-child(4) { .opportunities-extension-table th:nth-child(4) {
@@ -2923,19 +3277,19 @@
} }
.opportunities-extension-table th:nth-child(5) { .opportunities-extension-table th:nth-child(5) {
width: 9%; width: 8%;
} }
.opportunities-extension-table th:nth-child(6) { .opportunities-extension-table th:nth-child(6) {
width: 9%; width: 8%;
} }
.opportunities-extension-table th:nth-child(7) { .opportunities-extension-table th:nth-child(7) {
width: 8%; width: 7%;
} }
.opportunities-extension-table th:nth-child(8) { .opportunities-extension-table th:nth-child(8) {
width: 8%; width: 6%;
} }
.opportunities-extension-table th:nth-child(9) { .opportunities-extension-table th:nth-child(9) {
@@ -2949,10 +3303,30 @@
.opportunities-extension-table td:nth-child(2), .opportunities-extension-table td:nth-child(2),
.opportunities-extension-table td:nth-child(5), .opportunities-extension-table td:nth-child(5),
.opportunities-extension-table td:nth-child(6), .opportunities-extension-table td:nth-child(6),
.opportunities-extension-table td:nth-child(7), .opportunities-extension-table td:nth-child(8) {
.opportunities-extension-table td:nth-child(8), white-space: nowrap;
.opportunities-extension-table td:nth-child(9), overflow: hidden;
text-overflow: ellipsis;
}
.opportunities-extension-table td:nth-child(9) {
padding-right: 6px;
padding-left: 6px;
white-space: nowrap;
overflow: visible;
}
.opportunities-extension-table td:nth-child(7) {
padding-right: 6px;
padding-left: 6px;
white-space: nowrap;
overflow: visible;
}
.opportunities-extension-table td:nth-child(10) { .opportunities-extension-table td:nth-child(10) {
padding-right: 6px;
padding-left: 6px;
font-size: 12px;
white-space: nowrap; white-space: nowrap;
} }
@@ -3112,14 +3486,16 @@
align-items: center; align-items: center;
width: 100%; width: 100%;
min-height: 44px; min-height: 44px;
padding: 10px 30px 10px 16px; padding: 8px 42px 8px 16px;
border: 0; border: 0;
background: transparent; background: transparent;
color: inherit; color: inherit;
font: inherit; font: inherit;
font-weight: inherit; font-weight: inherit;
letter-spacing: 0; letter-spacing: 0;
line-height: 1.2;
text-align: left; text-align: left;
white-space: normal;
cursor: pointer; cursor: pointer;
} }
@@ -3170,6 +3546,135 @@
border: 0; border: 0;
} }
.opportunities-extension-preferences-modal {
position: absolute;
inset: 0;
z-index: 3;
display: grid;
place-items: center;
padding: 24px;
background: rgba(0, 0, 0, .42);
}
.opportunities-extension-preferences-modal[hidden] {
display: none !important;
}
.opportunities-extension-preferences-dialog {
display: grid;
width: min(680px, 100%);
max-height: min(720px, calc(100vh - 48px));
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
border: 1px solid #8f8a85;
border-radius: 4px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(0, 0, 0, .24);
color: #312d2a;
}
.opportunities-extension-preferences-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
border-bottom: 1px solid #dedbd7;
}
.opportunities-extension-preferences-header h2 {
margin: 0;
font-size: 20px;
font-weight: 700;
line-height: 1.2;
}
.opportunities-extension-preferences-content {
min-height: 0;
overflow: auto;
padding: 20px;
}
.opportunities-extension-preferences-fieldset {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.opportunities-extension-preferences-fieldset legend {
margin-bottom: 12px;
padding: 0;
color: #312d2a;
font-size: 15px;
font-weight: 700;
line-height: 1.3;
}
.opportunities-extension-preferences-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 20px;
}
.opportunities-extension-preferences-option {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
padding: 9px 8px;
border-radius: 4px;
color: #312d2a;
font-size: 14px;
line-height: 1.35;
cursor: pointer;
}
.opportunities-extension-preferences-option:hover {
background: #f0eeeb;
}
.opportunities-extension-preferences-option input {
width: 18px;
height: 18px;
flex: 0 0 18px;
margin: 0;
accent-color: #00758f;
cursor: pointer;
}
.opportunities-extension-preferences-footer {
display: flex;
justify-content: flex-end;
padding: 14px 20px;
border-top: 1px solid #dedbd7;
}
.opportunities-extension-button {
min-width: 88px;
min-height: 36px;
padding: 7px 16px;
border: 1px solid transparent;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
}
.opportunities-extension-button-primary {
border-color: #00758f;
background: #00758f;
color: #ffffff;
}
.opportunities-extension-button-primary:hover,
.opportunities-extension-button-primary:focus-visible {
border-color: #005e73;
background: #005e73;
outline: none;
}
.opportunities-extension-debug-panel { .opportunities-extension-debug-panel {
position: fixed; position: fixed;
right: 24px; right: 24px;
@@ -3368,6 +3873,7 @@
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-surface, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-surface,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-panel, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-panel,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-dialog,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] { .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] {
border-color: #4e4a46; border-color: #4e4a46;
@@ -3405,6 +3911,9 @@
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header h2,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-fieldset legend,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-heading, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-heading,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 { .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 {
@@ -3416,6 +3925,30 @@
border-bottom-color: #4e4a46; border-bottom-color: #4e4a46;
} }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-footer {
border-color: #4e4a46;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option:hover {
background: #3b3937;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option input {
accent-color: #43c4d5;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary {
border-color: #00a6be;
background: #008aa6;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary:hover,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary:focus-visible {
border-color: #43c4d5;
background: #006d7a;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option:hover, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option:hover,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table tbody tr:hover, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table tbody tr:hover,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sort-button:hover, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sort-button:hover,
@@ -3471,7 +4004,7 @@
} }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-total-dashboard-card { .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-total-dashboard-card {
border-top-color: #43c4d5; border-top-color: #f6f4f2;
} }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] {
@@ -3569,6 +4102,62 @@
color: #f6f4f2; color: #f6f4f2;
} }
@media (min-width: 1201px) and (max-width: 1450px) {
.opportunities-extension-table th:nth-child(1) {
width: 16%;
}
.opportunities-extension-table th:nth-child(3) {
width: 18%;
}
.opportunities-extension-table th:nth-child(4) {
width: 12%;
}
.opportunities-extension-table th:nth-child(6) {
width: 7%;
}
.opportunities-extension-table th:nth-child(7) {
width: 8%;
}
.opportunities-extension-table th:nth-child(10) {
width: 10%;
}
}
@media (max-width: 1200px) {
.opportunities-extension-table {
min-width: 1180px;
}
.opportunities-extension-table th:nth-child(1) {
width: 15%;
}
.opportunities-extension-table th:nth-child(3) {
width: 17%;
}
.opportunities-extension-table th:nth-child(4) {
width: 13%;
}
.opportunities-extension-table th:nth-child(6) {
width: 6%;
}
.opportunities-extension-table th:nth-child(7) {
width: 9%;
}
.opportunities-extension-table th:nth-child(10) {
width: 11%;
}
}
@media (max-width: 700px) { @media (max-width: 700px) {
.opportunities-extension-header { .opportunities-extension-header {
padding: 24px 20px 18px; padding: 24px 20px 18px;
@@ -3627,6 +4216,18 @@
font-size: 18px; font-size: 18px;
} }
.opportunities-extension-preferences-modal {
padding: 12px;
}
.opportunities-extension-preferences-dialog {
max-height: calc(100vh - 24px);
}
.opportunities-extension-preferences-options {
grid-template-columns: 1fr;
}
.opportunities-extension-stage-filter-controls { .opportunities-extension-stage-filter-controls {
gap: 6px; gap: 6px;
} }

View File

@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Opportunities Extension", "name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", "description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "0.1.0", "version": "1.0.0",
"permissions": [ "permissions": [
"cookies" "cookies"
], ],

View File

@@ -1,6 +1,6 @@
{ {
"name": "opportunities-extension", "name": "opportunities-extension",
"version": "0.1.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -13,7 +13,7 @@ const baseManifest = {
manifest_version: 3, manifest_version: 3,
name: "Opportunities Extension", name: "Opportunities Extension",
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.", description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
version: "0.1.0", version: "1.0.0",
permissions: [ permissions: [
"cookies" "cookies"
], ],

View File

@@ -10,13 +10,17 @@
]; ];
const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F"; const XSRF_COOKIE_NAME = "XSRF-TOKEN-US2DZ2V_F";
const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-"; const XSRF_COOKIE_PREFIX = "XSRF-TOKEN-";
const OPPORTUNITIES_LIST_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-list";
const COOKIE_REFRESH_TIMEOUT_MS = 15000;
const COOKIE_REFRESH_POLL_MS = 250;
const runtimeApi = typeof browser !== "undefined" ? browser : chrome; const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => { runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || message.type !== "opportunitiesExtension.getXsrfToken") { if (!message || !message.type) {
return false; return false;
} }
if (message.type === "opportunitiesExtension.getXsrfToken") {
getXsrfTokenCookie() getXsrfTokenCookie()
.then((result) => { .then((result) => {
sendResponse({ sendResponse({
@@ -39,7 +43,147 @@
}); });
return true; return true;
}
if (message.type === "opportunitiesExtension.refreshXsrfCookie") {
refreshXsrfCookie(sender)
.then(sendResponse)
.catch((error) => {
sendResponse({
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
error: error.message || "Unable to refresh XSRF cookie."
}); });
});
return true;
}
return false;
});
async function refreshXsrfCookie(sender) {
const startedAt = Date.now();
const previous = (await getXsrfTokenCookie()).cookie;
const createProperties = {
url: OPPORTUNITIES_LIST_URL,
active: false
};
if (sender && sender.tab && Number.isInteger(sender.tab.windowId)) {
createProperties.windowId = sender.tab.windowId;
}
if (supportsFirefoxCookieStoreTabs() && sender && sender.tab && sender.tab.cookieStoreId) {
createProperties.cookieStoreId = sender.tab.cookieStoreId;
}
let refreshTab;
try {
refreshTab = await tabsCreate(createProperties);
const current = await waitForXsrfCookieChange(previous);
const refreshed = hasCookieChanged(previous, current);
return {
ok: Boolean(current),
refreshed,
cookieName: current ? current.name : "",
durationMs: Date.now() - startedAt,
error: refreshed ? "" : "XSRF cookie was not created or changed before timeout."
};
} finally {
if (refreshTab && Number.isInteger(refreshTab.id)) {
try {
await tabsRemove(refreshTab.id);
} catch (error) {
// The temporary tab may already have been closed.
}
}
}
}
async function waitForXsrfCookieChange(previousCookie) {
const deadline = Date.now() + COOKIE_REFRESH_TIMEOUT_MS;
let currentCookie = null;
while (Date.now() < deadline) {
currentCookie = (await getXsrfTokenCookie()).cookie;
if (hasCookieChanged(previousCookie, currentCookie)) {
return currentCookie;
}
await wait(COOKIE_REFRESH_POLL_MS);
}
return currentCookie;
}
function hasCookieChanged(previousCookie, currentCookie) {
if (!currentCookie || !currentCookie.value) {
return false;
}
if (!previousCookie) {
return true;
}
return previousCookie.name !== currentCookie.name
|| previousCookie.value !== currentCookie.value
|| previousCookie.expirationDate !== currentCookie.expirationDate;
}
function supportsFirefoxCookieStoreTabs() {
return Boolean(
runtimeApi.runtime
&& typeof runtimeApi.runtime.getBrowserInfo === "function"
);
}
function tabsCreate(details) {
if (runtimeApi.tabs.create.length <= 1) {
return runtimeApi.tabs.create(details);
}
return new Promise((resolve, reject) => {
runtimeApi.tabs.create(details, (tab) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve(tab);
});
});
}
function tabsRemove(tabId) {
if (runtimeApi.tabs.remove.length <= 1) {
return runtimeApi.tabs.remove(tabId);
}
return new Promise((resolve, reject) => {
runtimeApi.tabs.remove(tabId, () => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
reject(new Error(lastError.message));
return;
}
resolve();
});
});
}
function wait(durationMs) {
return new Promise((resolve) => setTimeout(resolve, durationMs));
}
async function getXsrfTokenCookie() { async function getXsrfTokenCookie() {
const lookupDetails = []; const lookupDetails = [];

View File

@@ -26,6 +26,8 @@
const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard"; const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard";
const FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences"; const FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences";
const THEME_STORAGE_KEY = "opportunities-extension-theme"; const THEME_STORAGE_KEY = "opportunities-extension-theme";
const OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY = "opportunities-extension-opportunity-type-preferences";
const PREFERENCES_MODAL_ID = "opportunities-extension-preferences-modal";
const OPPORTUNITY_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid="; const OPPORTUNITY_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid=";
const ACCOUNT_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/accounts/accounts-detail?id="; const ACCOUNT_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/accounts/accounts-detail?id=";
const DEBUG_BODY_LIMIT = 12000; const DEBUG_BODY_LIMIT = 12000;
@@ -40,6 +42,24 @@
}; };
const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"]; const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"];
const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"]; const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"];
const OPPORTUNITY_TYPE_OPTIONS = [
{ label: "All records I can see", value: "ORA_ALLOPTIES" },
{ label: "I am credit receiver", value: "ORA_CREDITRECEIVER_ISME" },
{ label: "I am on the team", value: "ORA_MYSALESTEAMOPTIES" },
{ label: "I or my subordinate is a credit receiver", value: "ORA_CREDITRECEIVER_MYORG" },
{ label: "I own", value: "ORA_MYOPTIES" },
{ label: "My subordinates are on the team", value: "ORA_MYSUBORDSSALESTEAMOPTIES" },
{ label: "My subordinates own", value: "ORA_MYSUBORDINATESOPTIES" },
{ label: "My territory", value: "ORA_MYASSGTERROPTIES" },
{ label: "My territory hierarchy", value: "ORA_MYASSGDESCTERROPTIES" },
{ label: "My territory membership", value: "ORA_MYASSGMEMBTERROPTIES" },
{ label: "My territory membership hierarchy", value: "ORA_MYASSGMEMBDESCTERROPTIES" }
];
const DEFAULT_OPPORTUNITY_TYPE_VALUES = [
"ORA_MYSALESTEAMOPTIES",
"ORA_MYASSGTERROPTIES",
"ORA_CREDITRECEIVER_ISME"
];
let tokenRelayRequest = null; let tokenRelayRequest = null;
let accessToken = ""; let accessToken = "";
let periodRequestVersion = 0; let periodRequestVersion = 0;
@@ -52,6 +72,7 @@
let selectedOwnerKeys = new Set(); let selectedOwnerKeys = new Set();
let ownerSearch = ""; let ownerSearch = "";
let tableSearch = ""; let tableSearch = "";
let selectedOpportunityTypeValues = new Set(DEFAULT_OPPORTUNITY_TYPE_VALUES);
let hasSavedCustomerFilter = false; let hasSavedCustomerFilter = false;
let hasSavedOwnerFilter = false; let hasSavedOwnerFilter = false;
let customerFilterInitialized = false; let customerFilterInitialized = false;
@@ -79,9 +100,9 @@
{ key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName }, { key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName },
{ key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue }, { 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: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) },
{ key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c },
{ key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
{ key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode }, { 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: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }
]; ];
const ALLOWED_PATHS = [ const ALLOWED_PATHS = [
@@ -176,6 +197,7 @@
periodRequestVersion = 0; periodRequestVersion = 0;
authStatus = AUTH_STATUS.idle; authStatus = AUTH_STATUS.idle;
resetOpportunitiesTable(); resetOpportunitiesTable();
selectedOpportunityTypeValues = new Set(getSavedOpportunityTypeValues());
overlay.id = MODAL_ID; overlay.id = MODAL_ID;
overlay.className = "opportunities-extension-modal"; overlay.className = "opportunities-extension-modal";
overlay.setAttribute("data-theme", getSavedTheme()); overlay.setAttribute("data-theme", getSavedTheme());
@@ -226,9 +248,17 @@
}); });
applyTheme(overlay, themeButton, overlay.getAttribute("data-theme")); applyTheme(overlay, themeButton, overlay.getAttribute("data-theme"));
const preferencesButton = document.createElement("button");
preferencesButton.type = "button";
preferencesButton.className = "opportunities-extension-icon-button";
preferencesButton.setAttribute("aria-label", "Abrir preferências");
preferencesButton.title = "Preferences";
preferencesButton.append(createSettingsIcon());
preferencesButton.addEventListener("click", openPreferencesModal);
const headerActions = document.createElement("div"); const headerActions = document.createElement("div");
headerActions.className = "opportunities-extension-header-actions"; headerActions.className = "opportunities-extension-header-actions";
headerActions.append(themeButton, closeButton); headerActions.append(themeButton, preferencesButton, closeButton);
header.append(titleBlock, headerActions); header.append(titleBlock, headerActions);
@@ -242,7 +272,7 @@
field.className = "opportunities-extension-field"; field.className = "opportunities-extension-field";
const labelText = document.createElement("span"); const labelText = document.createElement("span");
labelText.textContent = "Periodo"; labelText.textContent = "Reference";
const selectWrap = document.createElement("span"); const selectWrap = document.createElement("span");
selectWrap.className = "opportunities-extension-select-wrap"; selectWrap.className = "opportunities-extension-select-wrap";
@@ -502,8 +532,98 @@
} }
async function requestTokenRelay() { async function requestTokenRelay() {
let refreshResult = null;
let refreshAttempted = false;
while (true) {
const xsrfTokenSource = await getXsrfToken(); const xsrfTokenSource = await getXsrfToken();
const requestEntry = logRequestStart({
if (!xsrfTokenSource.token) {
if (refreshAttempted) {
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult);
logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh.");
throw new Error("XSRF token cookie not found after automatic refresh.");
}
refreshResult = await refreshXsrfCookie("missing-cookie");
refreshAttempted = true;
continue;
}
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult);
try {
const response = await fetch(TOKEN_RELAY_URL, {
method: "GET",
credentials: "include",
headers: {
"x-xsrf-token": xsrfTokenSource.token
}
});
const responseBody = await readResponseBodyForDebug(response);
if (!response.ok) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted && isInvalidXsrfStatus(response.status)) {
refreshResult = await refreshXsrfCookie(`tokenrelay-${response.status}`);
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw new Error(`Token relay failed with status ${response.status}.`);
}
let responseData;
try {
responseData = await response.json();
} catch (error) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted) {
refreshResult = await refreshXsrfCookie("tokenrelay-invalid-response");
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw error;
}
if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) {
logRequestSuccess(requestEntry, response, responseBody);
if (!refreshAttempted) {
refreshResult = await refreshXsrfCookie("tokenrelay-missing-access-token");
refreshAttempted = true;
requestEntry.metadata.xsrfRefresh = formatXsrfRefreshResult(refreshResult);
renderDebugPanel();
continue;
}
throw new Error("Token relay response does not contain access_token.");
}
logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({
...responseData,
access_token: maskToken(responseData.access_token)
}, null, 2)));
return responseData.access_token;
} catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed.");
throw error;
}
}
}
function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult) {
return logRequestStart({
name: "requestTokenRelay", name: "requestTokenRelay",
method: "GET", method: "GET",
url: TOKEN_RELAY_URL, url: TOKEN_RELAY_URL,
@@ -519,62 +639,40 @@
xsrfLookupDetails: xsrfTokenSource.lookupDetails && xsrfTokenSource.lookupDetails.length xsrfLookupDetails: xsrfTokenSource.lookupDetails && xsrfTokenSource.lookupDetails.length
? xsrfTokenSource.lookupDetails.join(" | ") ? xsrfTokenSource.lookupDetails.join(" | ")
: "(none)", : "(none)",
xsrfTokenError: xsrfTokenSource.error || "" xsrfTokenError: xsrfTokenSource.error || "",
xsrfRefresh: formatXsrfRefreshResult(refreshResult)
} }
}); });
if (!xsrfTokenSource.token) {
logRequestFailure(requestEntry, "XSRF token cookie not found.");
throw new Error("XSRF token cookie not found.");
} }
try { function isInvalidXsrfStatus(status) {
const response = await fetch(TOKEN_RELAY_URL, { return status === 400 || status === 401 || status === 403;
method: "GET",
credentials: "include",
headers: {
"x-xsrf-token": xsrfTokenSource.token
}
});
const responseBody = await readResponseBodyForDebug(response);
if (!response.ok) {
logRequestSuccess(requestEntry, response, responseBody);
throw new Error(`Token relay failed with status ${response.status}.`);
} }
let responseData; function formatXsrfRefreshResult(result) {
if (!result) {
try { return "not-requested";
responseData = await response.json();
} catch (error) {
logRequestSuccess(requestEntry, response, responseBody);
throw error;
} }
if (!responseData || typeof responseData.access_token !== "string" || !responseData.access_token) { const outcome = result.refreshed ? "refreshed" : "not-refreshed";
logRequestSuccess(requestEntry, response, responseBody); const cookieName = result.cookieName || "(none)";
throw new Error("Token relay response does not contain access_token."); const duration = Number.isFinite(result.durationMs) ? `${result.durationMs}ms` : "unknown";
} return `${result.reason}; ${outcome}; cookie=${cookieName}; duration=${duration}; ${result.error || ""}`.trim();
logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({
...responseData,
access_token: maskToken(responseData.access_token)
}, null, 2)));
return responseData.access_token;
} catch (error) {
logRequestFailure(requestEntry, error.message || "Request failed.");
throw error;
}
} }
async function requestOpportunitiesForPeriod(period) { async function requestOpportunitiesForPeriod(period) {
const dateRange = getFiscalPeriodRange(period, new Date()); const dateRange = getFiscalPeriodRange(period, new Date());
const requestVersion = ++periodRequestVersion; const requestVersion = ++periodRequestVersion;
if (selectedOpportunityTypeValues.size === 0) {
setOpportunitiesTableState({
items: [],
status: "empty",
message: "Select at least one Opportunity Type View in Preferences."
});
return;
}
if (!dateRange) { if (!dateRange) {
setOpportunitiesTableState({ setOpportunitiesTableState({
items: [], items: [],
@@ -1302,6 +1400,13 @@
} }
if (owner && typeof owner === "object") { 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 return owner.PartyName
|| owner.PartyUniqueName || owner.PartyUniqueName
|| owner.Name || owner.Name
@@ -1435,7 +1540,7 @@
const normalizedSearch = tableSearch.trim().toLocaleLowerCase(); const normalizedSearch = tableSearch.trim().toLocaleLowerCase();
const items = opportunitiesTableState.items.filter((item) => { const items = opportunitiesTableState.items.filter((item) => {
return selectedStages.has(item.ForecastGroup_c) return selectedStages.has(item.ForecastGroup_c)
&& selectedStatuses.has(item.StatusCode) && matchesSelectedStatus(item.StatusCode)
&& selectedCustomerKeys.has(getCustomerKey(item)) && selectedCustomerKeys.has(getCustomerKey(item))
&& selectedOwnerKeys.has(getOwnerKey(item)) && selectedOwnerKeys.has(getOwnerKey(item))
&& matchesTableSearch(item, normalizedSearch); && matchesTableSearch(item, normalizedSearch);
@@ -1488,6 +1593,14 @@
}); });
} }
function matchesSelectedStatus(statusCode) {
if (statusCode === "WON_PENDING") {
return selectedStatuses.has("WON");
}
return selectedStatuses.has(statusCode);
}
function stringifySearchValue(value) { function stringifySearchValue(value) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return ""; return "";
@@ -1679,10 +1792,11 @@
criteria: [ criteria: [
{ {
op: "$or", op: "$or",
criteria: [ criteria: Array.from(selectedOpportunityTypeValues, (value) => ({
{ op: "$eq", attribute: "RecordSet", value: "ORA_MYSALESTEAMOPTIES" }, op: "$eq",
{ op: "$eq", attribute: "RecordSet", value: "ORA_MYASSGTERROPTIES" } attribute: "RecordSet",
] value
}))
}, },
{ {
op: "$wi", op: "$wi",
@@ -2091,6 +2205,52 @@
}); });
} }
function refreshXsrfCookie(reason) {
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {
return Promise.resolve({
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
reason,
error: "Runtime messaging API unavailable."
});
}
return new Promise((resolve) => {
runtimeApi.runtime.sendMessage({
type: "opportunitiesExtension.refreshXsrfCookie"
}, (response) => {
const lastError = runtimeApi.runtime.lastError;
if (lastError) {
resolve({
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
reason,
error: lastError.message
});
return;
}
resolve({
...(response || {
ok: false,
refreshed: false,
cookieName: "",
durationMs: 0,
error: "Empty refresh response."
}),
reason
});
});
});
}
function updateAuthBadge(status) { function updateAuthBadge(status) {
const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`); const badge = document.querySelector(`#${MODAL_ID} .opportunities-extension-auth-badge`);
@@ -2115,6 +2275,7 @@
const modal = document.getElementById(MODAL_ID); const modal = document.getElementById(MODAL_ID);
if (modal) { if (modal) {
closePreferencesModal();
modal.hidden = true; modal.hidden = true;
} }
@@ -2168,6 +2329,185 @@
return svg; return svg;
} }
function createSettingsIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const upperControl = document.createElementNS("http://www.w3.org/2000/svg", "circle");
const lowerControl = document.createElementNS("http://www.w3.org/2000/svg", "circle");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("class", "opportunities-extension-settings-icon");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
appendPath(svg, "", "M4 7h16M4 17h16");
[upperControl, lowerControl].forEach((control, index) => {
control.setAttribute("cx", index === 0 ? "9" : "15");
control.setAttribute("cy", index === 0 ? "7" : "17");
control.setAttribute("r", "2.5");
control.setAttribute("fill", "currentColor");
control.setAttribute("stroke", "currentColor");
control.setAttribute("stroke-width", "1.8");
});
svg.querySelector("path").setAttribute("fill", "none");
svg.querySelector("path").setAttribute("stroke", "currentColor");
svg.querySelector("path").setAttribute("stroke-linecap", "round");
svg.querySelector("path").setAttribute("stroke-width", "1.8");
svg.append(upperControl, lowerControl);
return svg;
}
function createCloseIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const path = 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");
path.setAttribute("d", "m6 6 12 12M18 6 6 18");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-width", "1.8");
svg.append(path);
return svg;
}
function getSavedOpportunityTypeValues() {
try {
const rawPreferences = localStorage.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 openPreferencesModal() {
const overlay = document.getElementById(MODAL_ID);
if (!overlay) {
return;
}
const existingModal = document.getElementById(PREFERENCES_MODAL_ID);
if (existingModal) {
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);
content.append(fieldset);
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);
selectedOpportunityTypeValues = new Set(selectedValues);
try {
localStorage.setItem(OPPORTUNITY_TYPE_PREFERENCES_STORAGE_KEY, JSON.stringify({
opportunityTypeValues: selectedValues
}));
} 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;
}
}
function ensureEscapeKeyHandler() { function ensureEscapeKeyHandler() {
if (window[ESCAPE_LISTENER_KEY]) { if (window[ESCAPE_LISTENER_KEY]) {
return; return;
@@ -2189,6 +2529,14 @@
} }
if (event.key === "Escape") { if (event.key === "Escape") {
const preferencesModal = document.getElementById(PREFERENCES_MODAL_ID);
if (preferencesModal && !preferencesModal.hidden) {
closePreferencesModal();
event.preventDefault();
return;
}
if (closeCustomerFilterPanel()) { if (closeCustomerFilterPanel()) {
event.preventDefault(); event.preventDefault();
return; return;
@@ -2365,6 +2713,11 @@
vertical-align: middle; vertical-align: middle;
} }
.opportunities-extension-icon-button .opportunities-extension-settings-icon {
width: 20px;
height: 20px;
}
.opportunities-extension-icon-button:hover, .opportunities-extension-icon-button:hover,
.opportunities-extension-icon-button:focus { .opportunities-extension-icon-button:focus {
border-color: #b8b2ad; border-color: #b8b2ad;
@@ -2447,7 +2800,7 @@
} }
.opportunities-extension-total-dashboard-card { .opportunities-extension-total-dashboard-card {
border-top-color: #00758f; border-top-color: #312d2a;
} }
.opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] {
@@ -2867,12 +3220,13 @@
border: 1px solid #dedbd7; border: 1px solid #dedbd7;
border-radius: 4px; border-radius: 4px;
background: #ffffff; background: #ffffff;
overflow: auto; overflow-x: auto;
overflow-y: auto;
} }
.opportunities-extension-table { .opportunities-extension-table {
width: 100%; width: 100%;
min-width: 1440px; min-width: 0;
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; table-layout: fixed;
color: #312d2a; color: #312d2a;
@@ -2907,7 +3261,7 @@
} }
.opportunities-extension-table th:nth-child(1) { .opportunities-extension-table th:nth-child(1) {
width: 14%; width: 17%;
} }
.opportunities-extension-table th:nth-child(2) { .opportunities-extension-table th:nth-child(2) {
@@ -2915,7 +3269,7 @@
} }
.opportunities-extension-table th:nth-child(3) { .opportunities-extension-table th:nth-child(3) {
width: 17%; width: 19%;
} }
.opportunities-extension-table th:nth-child(4) { .opportunities-extension-table th:nth-child(4) {
@@ -2923,19 +3277,19 @@
} }
.opportunities-extension-table th:nth-child(5) { .opportunities-extension-table th:nth-child(5) {
width: 9%; width: 8%;
} }
.opportunities-extension-table th:nth-child(6) { .opportunities-extension-table th:nth-child(6) {
width: 9%; width: 8%;
} }
.opportunities-extension-table th:nth-child(7) { .opportunities-extension-table th:nth-child(7) {
width: 8%; width: 7%;
} }
.opportunities-extension-table th:nth-child(8) { .opportunities-extension-table th:nth-child(8) {
width: 8%; width: 6%;
} }
.opportunities-extension-table th:nth-child(9) { .opportunities-extension-table th:nth-child(9) {
@@ -2949,10 +3303,30 @@
.opportunities-extension-table td:nth-child(2), .opportunities-extension-table td:nth-child(2),
.opportunities-extension-table td:nth-child(5), .opportunities-extension-table td:nth-child(5),
.opportunities-extension-table td:nth-child(6), .opportunities-extension-table td:nth-child(6),
.opportunities-extension-table td:nth-child(7), .opportunities-extension-table td:nth-child(8) {
.opportunities-extension-table td:nth-child(8), white-space: nowrap;
.opportunities-extension-table td:nth-child(9), overflow: hidden;
text-overflow: ellipsis;
}
.opportunities-extension-table td:nth-child(9) {
padding-right: 6px;
padding-left: 6px;
white-space: nowrap;
overflow: visible;
}
.opportunities-extension-table td:nth-child(7) {
padding-right: 6px;
padding-left: 6px;
white-space: nowrap;
overflow: visible;
}
.opportunities-extension-table td:nth-child(10) { .opportunities-extension-table td:nth-child(10) {
padding-right: 6px;
padding-left: 6px;
font-size: 12px;
white-space: nowrap; white-space: nowrap;
} }
@@ -3112,14 +3486,16 @@
align-items: center; align-items: center;
width: 100%; width: 100%;
min-height: 44px; min-height: 44px;
padding: 10px 30px 10px 16px; padding: 8px 42px 8px 16px;
border: 0; border: 0;
background: transparent; background: transparent;
color: inherit; color: inherit;
font: inherit; font: inherit;
font-weight: inherit; font-weight: inherit;
letter-spacing: 0; letter-spacing: 0;
line-height: 1.2;
text-align: left; text-align: left;
white-space: normal;
cursor: pointer; cursor: pointer;
} }
@@ -3170,6 +3546,135 @@
border: 0; border: 0;
} }
.opportunities-extension-preferences-modal {
position: absolute;
inset: 0;
z-index: 3;
display: grid;
place-items: center;
padding: 24px;
background: rgba(0, 0, 0, .42);
}
.opportunities-extension-preferences-modal[hidden] {
display: none !important;
}
.opportunities-extension-preferences-dialog {
display: grid;
width: min(680px, 100%);
max-height: min(720px, calc(100vh - 48px));
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
border: 1px solid #8f8a85;
border-radius: 4px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(0, 0, 0, .24);
color: #312d2a;
}
.opportunities-extension-preferences-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
border-bottom: 1px solid #dedbd7;
}
.opportunities-extension-preferences-header h2 {
margin: 0;
font-size: 20px;
font-weight: 700;
line-height: 1.2;
}
.opportunities-extension-preferences-content {
min-height: 0;
overflow: auto;
padding: 20px;
}
.opportunities-extension-preferences-fieldset {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.opportunities-extension-preferences-fieldset legend {
margin-bottom: 12px;
padding: 0;
color: #312d2a;
font-size: 15px;
font-weight: 700;
line-height: 1.3;
}
.opportunities-extension-preferences-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 20px;
}
.opportunities-extension-preferences-option {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
padding: 9px 8px;
border-radius: 4px;
color: #312d2a;
font-size: 14px;
line-height: 1.35;
cursor: pointer;
}
.opportunities-extension-preferences-option:hover {
background: #f0eeeb;
}
.opportunities-extension-preferences-option input {
width: 18px;
height: 18px;
flex: 0 0 18px;
margin: 0;
accent-color: #00758f;
cursor: pointer;
}
.opportunities-extension-preferences-footer {
display: flex;
justify-content: flex-end;
padding: 14px 20px;
border-top: 1px solid #dedbd7;
}
.opportunities-extension-button {
min-width: 88px;
min-height: 36px;
padding: 7px 16px;
border: 1px solid transparent;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
}
.opportunities-extension-button-primary {
border-color: #00758f;
background: #00758f;
color: #ffffff;
}
.opportunities-extension-button-primary:hover,
.opportunities-extension-button-primary:focus-visible {
border-color: #005e73;
background: #005e73;
outline: none;
}
.opportunities-extension-debug-panel { .opportunities-extension-debug-panel {
position: fixed; position: fixed;
right: 24px; right: 24px;
@@ -3368,6 +3873,7 @@
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-surface, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table-surface,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-panel, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-panel,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-dialog,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] { .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item[open] {
border-color: #4e4a46; border-color: #4e4a46;
@@ -3405,6 +3911,9 @@
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-select-all,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header h2,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-fieldset legend,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-heading, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-heading,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-item pre,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 { .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel h2 {
@@ -3416,6 +3925,30 @@
border-bottom-color: #4e4a46; border-bottom-color: #4e4a46;
} }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-header,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-footer {
border-color: #4e4a46;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option:hover {
background: #3b3937;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-preferences-option input {
accent-color: #43c4d5;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary {
border-color: #00a6be;
background: #008aa6;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary:hover,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-button-primary:focus-visible {
border-color: #43c4d5;
background: #006d7a;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option:hover, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-filter-option:hover,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table tbody tr:hover, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-table tbody tr:hover,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sort-button:hover, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sort-button:hover,
@@ -3471,7 +4004,7 @@
} }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-total-dashboard-card { .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-total-dashboard-card {
border-top-color: #43c4d5; border-top-color: #f6f4f2;
} }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] {
@@ -3569,6 +4102,62 @@
color: #f6f4f2; color: #f6f4f2;
} }
@media (min-width: 1201px) and (max-width: 1450px) {
.opportunities-extension-table th:nth-child(1) {
width: 16%;
}
.opportunities-extension-table th:nth-child(3) {
width: 18%;
}
.opportunities-extension-table th:nth-child(4) {
width: 12%;
}
.opportunities-extension-table th:nth-child(6) {
width: 7%;
}
.opportunities-extension-table th:nth-child(7) {
width: 8%;
}
.opportunities-extension-table th:nth-child(10) {
width: 10%;
}
}
@media (max-width: 1200px) {
.opportunities-extension-table {
min-width: 1180px;
}
.opportunities-extension-table th:nth-child(1) {
width: 15%;
}
.opportunities-extension-table th:nth-child(3) {
width: 17%;
}
.opportunities-extension-table th:nth-child(4) {
width: 13%;
}
.opportunities-extension-table th:nth-child(6) {
width: 6%;
}
.opportunities-extension-table th:nth-child(7) {
width: 9%;
}
.opportunities-extension-table th:nth-child(10) {
width: 11%;
}
}
@media (max-width: 700px) { @media (max-width: 700px) {
.opportunities-extension-header { .opportunities-extension-header {
padding: 24px 20px 18px; padding: 24px 20px 18px;
@@ -3627,6 +4216,18 @@
font-size: 18px; font-size: 18px;
} }
.opportunities-extension-preferences-modal {
padding: 12px;
}
.opportunities-extension-preferences-dialog {
max-height: calc(100vh - 24px);
}
.opportunities-extension-preferences-options {
grid-template-columns: 1fr;
}
.opportunities-extension-stage-filter-controls { .opportunities-extension-stage-filter-controls {
gap: 6px; gap: 6px;
} }