commit 45cfceff0e02eedf6e36ec8e35f0a6b436e7be71 Author: Paulo Porto Date: Thu Apr 30 12:38:36 2026 -0300 Commit inicial diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0a03ec --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Arch Panel Injector + +Extensao Chrome Manifest V3 que adiciona o botao `Arch panel` ao header da pagina Oracle: + +`https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=workload_workbench` + +## O que ela faz + +- roda apenas na pagina alvo do Oracle Workload Workbench +- injeta o botao imediatamente a esquerda de `.oj-oal-ux-global-header-content` +- reaproveita as cores e a tipografia do proprio header para manter compatibilidade visual +- observa mudancas da SPA para recolocar o botao se o layout for remontado + +## Como instalar no Chrome + +1. Abra `chrome://extensions` +2. Ative `Developer mode` +3. Clique em `Load unpacked` +4. Selecione a pasta `C:\Codex\Projects\arch-central-extension-browser` + +## Arquivos principais + +- `manifest.json`: define a extensao e o carregamento do content script +- `content-script.js`: valida a URL, encontra o header e injeta o botao diff --git a/background.js b/background.js new file mode 100644 index 0000000..5e35cd7 --- /dev/null +++ b/background.js @@ -0,0 +1,306 @@ +const COMCIP_ORIGIN = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com"; +const COMCIP_APP_URL = + `${COMCIP_ORIGIN}/ic/builder/rt/oalset_semc/live/webApps/Dashboard/?page=shell&shell=main&main=service-requests-detailed-view`; +const COMCIP_QUERY_URL = + `${COMCIP_ORIGIN}/ic/builder/rt/oalset_semc/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPIElastic/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries`; +const COMCIP_CLIENT_ID_PROBE_URL = + `${COMCIP_ORIGIN}/ic/builder/rt/oalset_semc/live;profile=PROD/services/auth/1.1/proxy/oalsetSeaaSOKECustomRestAPI/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/countries`; +const COMCIP_TAB_URL_PATTERN = + `${COMCIP_ORIGIN}/ic/builder/rt/oalset_semc/live*`; + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if ( + !message || + (message.type !== "ARCH_PANEL_COMCIP_POST" && + message.type !== "ARCH_PANEL_COMCIP_REQUEST") + ) { + return false; + } + + requestComcipFromPage({ + url: message.url || COMCIP_QUERY_URL, + method: message.method || "POST", + payload: message.payload, + headers: message.headers, + }) + .then(sendResponse) + .catch((error) => { + sendResponse({ + ok: false, + status: 0, + error: error instanceof Error ? error.message : String(error), + }); + }); + + return true; +}); + +async function requestComcipFromPage({ url, method = "POST", payload, headers = {} }) { + const target = await getComcipTab(); + + try { + await waitForComcipTabReady(target.tab.id); + await waitForComcipSessionReady(target.tab.id, headers); + + let response = await executeComcipFetch(target.tab.id, url, method, payload, headers); + + if (isAuthorizationFailure(response)) { + await delay(1500); + await waitForComcipSessionReady(target.tab.id, headers); + response = await executeComcipFetch(target.tab.id, url, method, payload, headers); + } + + return response; + } finally { + if (target.created && target.tab.id) { + await chrome.tabs.remove(target.tab.id).catch(() => {}); + } + } +} + +async function getComcipTab() { + const tabs = await chrome.tabs.query({ url: COMCIP_TAB_URL_PATTERN }); + const existing = tabs.find((tab) => tab.id && !tab.discarded); + + if (existing) { + return { + tab: existing, + created: false, + }; + } + + const created = await chrome.tabs.create({ + url: COMCIP_APP_URL, + active: false, + }); + + return { + tab: created, + created: true, + }; +} + +async function waitForComcipTabReady(tabId) { + const startedAt = Date.now(); + + while (Date.now() - startedAt < 45000) { + const tab = await chrome.tabs.get(tabId); + const url = String(tab.url || ""); + + if (tab.status === "complete" && url.startsWith(COMCIP_ORIGIN)) { + await delay(1000); + return; + } + + await delay(500); + } + + throw new Error("Timed out while loading the COMCIP origin page."); +} + +async function waitForComcipSessionReady(tabId, headers = {}) { + const startedAt = Date.now(); + let lastStatus = ""; + + while (Date.now() - startedAt < 60000) { + const probe = await executeComcipProbe(tabId, headers).catch((error) => ({ + ok: false, + status: 0, + error: error instanceof Error ? error.message : String(error), + })); + + if (probe?.ok && probe?.appBuilderClientId) { + return probe; + } + + lastStatus = probe?.status + ? `${probe.status} ${probe.statusText || ""}`.trim() + : probe?.error || "not ready"; + await delay(1000); + } + + throw new Error(`Timed out while waiting for COMCIP authenticated session (${lastStatus}).`); +} + +async function executeComcipProbe(tabId, headers = {}) { + const [injectionResult] = await chrome.scripting.executeScript({ + target: { tabId }, + world: "MAIN", + args: [COMCIP_CLIENT_ID_PROBE_URL, headers || {}], + func: async (probeUrl, sourceHeaders) => { + const APP_VERSION = "version_1754044416761"; + const response = await fetch(probeUrl, { + method: "GET", + credentials: "include", + cache: "no-store", + headers: { + accept: "*/*", + authorization: "Session", + "accept-language": + sourceHeaders["accept-language"] || navigator.language || "pt-BR", + "x-vb-application-version": + sourceHeaders["x-vb-application-version"] || APP_VERSION, + }, + }); + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + appBuilderClientId: response.headers.get("x-appbuilder-client-id") || "", + href: window.location.href, + }; + }, + }); + + if (!injectionResult) { + throw new Error("COMCIP probe did not return a result."); + } + + return injectionResult.result; +} + +async function executeComcipFetch(tabId, requestUrl, method, payload, headers) { + const [injectionResult] = await chrome.scripting.executeScript({ + target: { tabId }, + world: "MAIN", + args: [requestUrl, method || "POST", payload || null, headers || {}], + func: async (requestUrl, requestMethod, requestPayload, sourceHeaders) => { + const APP_VERSION = "version_1754044416761"; + const CLIENT_ID_PROBE_URL = + `${window.location.origin}/ic/builder/rt/oalset_semc/live;profile=PROD/services/auth/1.1/proxy/oalsetSeaaSOKECustomRestAPI/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/countries`; + + function parseResponseText(text) { + if (!text) { + return null; + } + + try { + return JSON.parse(text); + } catch { + return text; + } + } + + function pickResponseHeaders(headers) { + const safeHeaders = {}; + const exposedHeaders = [ + "content-type", + "x-appbuilder-client-id", + "x-invalid-appbuilder-client-id", + "x-appbuilder-repeat-request", + "vb-proxy-status-actual", + "vb-proxy-version", + ]; + + for (const key of exposedHeaders) { + const value = headers.get(key); + + if (value) { + safeHeaders[key] = value; + } + } + + return safeHeaders; + } + + async function resolveAppBuilderClientId() { + try { + const response = await fetch(CLIENT_ID_PROBE_URL, { + method: "GET", + credentials: "include", + cache: "no-store", + headers: { + accept: "*/*", + authorization: "Session", + "accept-language": + sourceHeaders["accept-language"] || navigator.language || "pt-BR", + "x-vb-application-version": + sourceHeaders["x-vb-application-version"] || APP_VERSION, + }, + }); + + return response.headers.get("x-appbuilder-client-id") || ""; + } catch { + return ""; + } + } + + const appBuilderClientId = + sourceHeaders["x-appbuilder-client-id"] || (await resolveAppBuilderClientId()); + const requestHeaders = { + accept: "*/*", + authorization: "Session", + "vb-proxy-header-preference": "transient", + "x-vb-application-version": + sourceHeaders["x-vb-application-version"] || APP_VERSION, + "accept-language": + sourceHeaders["accept-language"] || navigator.language || "pt-BR", + }; + + if (appBuilderClientId) { + requestHeaders["x-appbuilder-client-id"] = appBuilderClientId; + } + + const normalizedMethod = String(requestMethod || "POST").toUpperCase(); + const fetchOptions = { + method: normalizedMethod, + credentials: "include", + cache: "no-store", + headers: requestHeaders, + }; + + if (normalizedMethod !== "GET" && normalizedMethod !== "HEAD") { + requestHeaders["content-type"] = "application/json"; + fetchOptions.body = JSON.stringify(requestPayload || {}); + } + + const response = await fetch(requestUrl, { + ...fetchOptions, + }); + const text = await response.text(); + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + headers: pickResponseHeaders(response.headers), + payload: parseResponseText(text), + debug: { + href: window.location.href, + origin: window.location.origin, + headerKeys: Object.keys(requestHeaders), + }, + }; + }, + }); + + if (!injectionResult) { + throw new Error("COMCIP page script did not return a result."); + } + + return injectionResult.result; +} + +function isAuthorizationFailure(response) { + if (!response) { + return false; + } + + const payloadText = + typeof response.payload === "string" + ? response.payload + : JSON.stringify(response.payload || ""); + + return ( + response.status === 401 || + /401 Authorization Required/i.test(payloadText) || + /Authorization Required/i.test(payloadText) + ); +} + +function delay(milliseconds) { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} diff --git a/content-script.js b/content-script.js new file mode 100644 index 0000000..9ba06c8 --- /dev/null +++ b/content-script.js @@ -0,0 +1,7343 @@ +(() => { + const TARGET_ORIGIN = "https://spa.oracle.com"; + const TARGET_PATHNAME = + "/oalcrm/web/api/g2m-consumer-application/ui/index.html"; + const TARGET_PARAM = "ojr"; + const TARGET_PARAM_VALUE = "workload_workbench"; + const USER_BUTTON_LABEL = "[[displayUser().fullName]]"; + const ALLOWED_STAGE_TYPES = ["SQL", "Pipeline", "Upside", "Forecast"]; + + const SELECTORS = { + header: ".oj-oal-ux-global-header.oj-oal-ux-global-header-pillar-cx", + functions: ".oj-flex.oj-oal-ux-global-header-functions", + }; + + const IDS = { + mount: "arch-panel-extension-mount", + button: "arch-panel-extension-button", + style: "arch-panel-extension-style", + overlay: "arch-panel-extension-overlay", + dialog: "arch-panel-extension-dialog", + }; + + const CLASSES = { + modalOpen: "arch-panel-extension-modal-open", + overlayOpen: "is-open", + }; + + const STORAGE_KEYS = { + theme: "arch-panel-extension-theme", + sidebarCollapsed: "arch-panel-extension-sidebar-collapsed", + }; + + const CACHE = { + dbName: "arch-panel-extension-db", + dbVersion: 1, + storeName: "datasets", + datasetKey: "workload-dashboard", + }; + + const DETAIL_SORT_KEYS = { + opportunityNumber: "opportunityNumber", + customerName: "customerName", + workload: "workload", + rampMonths: "rampMonths", + adjustedACR: "adjustedACR", + opportunityForecastTypeGroup: "opportunityForecastTypeGroup", + hasSR: "hasSR", + hasAction: "hasAction", + }; + + const BRIDGE = { + source: "arch-panel-extension", + fetchType: "ARCH_PANEL_FETCH", + resultType: "ARCH_PANEL_FETCH_RESULT", + headersType: "ARCH_PANEL_HEADERS", + headersResultType: "ARCH_PANEL_HEADERS_RESULT", + readyType: "ARCH_PANEL_BRIDGE_READY", + scriptId: "arch-panel-extension-page-bridge", + }; + + const API_ENDPOINTS = { + currentUser: + "/oalcrm/web/api/identity-management/users/current", + customerSummary: + "/oalcrm/web/api/provider-proxy/wwb-provider/service/elastic/customers/listSummary", + customerWorkloads: + "/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/customers", + workloadActions: + "/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions", + workloadServiceRequests: + "/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads", + }; + + const COMCIP_REQUEST = { + url: + "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_semc/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPIElastic/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries", + timeEntriesSummaryUrl: + "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_semc/live;profile=PROD/services/auth/1.1/proxy/oalsetSeaaSOKECustomRestAPI/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries/summary", + appVersion: "version_1754044416761", + }; + + const ACTION_TEAM_OPTIONS = [ + { + value: "DM_AUTONOMOUS", + label: "Data Platform - Autonomous Database", + }, + { + value: "CI_COMPUTE", + label: "Cloud Infrastructure - Compute including HPC", + }, + { + value: "APP_DEV_AI_CLOUD_SERVICES", + label: "App Dev - AI Cloud Services", + }, + { + value: "DEV_CLOUD_NATIVE_APP", + label: "App Dev - Cloud Native", + }, + { + value: "CI_BIG_COMPUTE_HPC", + label: "Cloud Infrastructure - AI Infra/GPU", + }, + { + value: "CI_MULTI_CLOUD", + label: "Cloud Infrastructure - Multi-Cloud", + }, + { + value: "CI_STORAGE", + label: "Cloud Infrastructure - Storage", + }, + { + value: "CI_VMWARE", + label: "Cloud Infrastructure - VMware", + }, + { + value: "DALH_DESIGN", + label: "Data Platform - Analytical Data Platform/Lakehouse", + }, + { + value: "DATA_INTEGRATION", + label: "Data Platform - Data Integration", + }, + { + value: "DM_UPGRADE", + label: "Data Platform - Modernise", + }, + ]; + + const ACTION_COMPLEXITY_OPTIONS = ["LOW", "MEDIUM", "HIGH", "VERY_HIGH"]; + + const appState = { + theme: getStoredTheme(), + sidebarCollapsed: getStoredSidebarCollapsed(), + calendarYear: new Date().getFullYear(), + calendarMonth: new Date().getMonth(), + status: "idle", + loadingMessage: "", + loadingProgress: 0, + errorMessage: "", + dataset: null, + sessionAuthHeaders: {}, + detailModal: null, + actionFormModal: null, + rampComparisonModal: null, + forecastUpdateConfirmModal: null, + timeEntriesDrawer: null, + pendingFocusSelector: "", + }; + + let syncFrame = 0; + let restoreFocusTarget = null; + let closeTimer = 0; + let requestCounter = 0; + let bridgeReadyPromise = null; + let cacheHydrationPromise = null; + const pendingBridgeRequests = new Map(); + + function isTargetPage() { + const currentUrl = new URL(window.location.href); + + return ( + currentUrl.origin === TARGET_ORIGIN && + currentUrl.pathname === TARGET_PATHNAME && + currentUrl.searchParams.get(TARGET_PARAM) === TARGET_PARAM_VALUE + ); + } + + function scheduleSync() { + if (syncFrame) { + return; + } + + syncFrame = window.requestAnimationFrame(() => { + syncFrame = 0; + syncButton(); + }); + } + + function syncButton() { + const mount = document.getElementById(IDS.mount); + + if (!isTargetPage()) { + mount?.remove(); + removeModal(); + unlockScroll(); + return; + } + + const header = document.querySelector(SELECTORS.header); + const functionsContainer = header?.querySelector(SELECTORS.functions); + const anchor = functionsContainer ? findUserAnchor(functionsContainer) : null; + + if (!header || !functionsContainer) { + return; + } + + ensureStyles(); + + const themedMount = mount ?? createMount(); + const overlay = ensureModal(); + + applyTheme(header, functionsContainer, anchor, themedMount, overlay); + + const targetSibling = resolveInsertionTarget( + functionsContainer, + anchor, + themedMount + ); + + if ( + themedMount.parentElement !== functionsContainer || + themedMount.nextElementSibling !== targetSibling + ) { + const verifiedTarget = + targetSibling && targetSibling.parentElement === functionsContainer + ? targetSibling + : null; + + if (verifiedTarget) { + functionsContainer.insertBefore(themedMount, verifiedTarget); + } else { + functionsContainer.appendChild(themedMount); + } + } + } + + function createMount() { + const mount = document.createElement("div"); + mount.id = IDS.mount; + mount.className = "arch-panel-extension-entry"; + + const button = document.createElement("button"); + button.id = IDS.button; + button.type = "button"; + button.className = "arch-panel-extension-button"; + button.textContent = "Arch panel"; + button.setAttribute("aria-label", "Open Arch panel"); + button.addEventListener("click", openModal); + + mount.appendChild(button); + + return mount; + } + + function ensurePageBridge() { + if (bridgeReadyPromise) { + return bridgeReadyPromise; + } + + bridgeReadyPromise = new Promise((resolve) => { + const existingScript = document.getElementById(BRIDGE.scriptId); + + if (existingScript) { + resolve(); + return; + } + + const script = document.createElement("script"); + script.id = BRIDGE.scriptId; + script.src = chrome.runtime.getURL("page-bridge.js"); + script.async = false; + script.onload = () => { + script.remove(); + resolve(); + }; + script.onerror = () => { + resolve(); + }; + + (document.head || document.documentElement).appendChild(script); + }); + + return bridgeReadyPromise; + } + + function ensureModal() { + const existingOverlay = document.getElementById(IDS.overlay); + + if (existingOverlay) { + return existingOverlay; + } + + const overlay = document.createElement("div"); + overlay.id = IDS.overlay; + overlay.className = "arch-panel-extension-overlay"; + overlay.hidden = true; + overlay.setAttribute("aria-hidden", "true"); + overlay.addEventListener("click", handleOverlayClick); + + document.body.appendChild(overlay); + renderModal(); + + return overlay; + } + + function handleOverlayClick(event) { + const overlay = document.getElementById(IDS.overlay); + + if (!overlay) { + return; + } + + if (event.target === overlay) { + if (appState.actionFormModal) { + appState.actionFormModal = null; + renderModal(); + } else if (appState.forecastUpdateConfirmModal) { + appState.forecastUpdateConfirmModal = null; + renderModal(); + } else if (appState.rampComparisonModal) { + appState.rampComparisonModal = null; + renderModal(); + } else if (appState.detailModal) { + closeDetailModal(); + renderModal(); + } else { + closeModal(); + } + + return; + } + + const actionElement = event.target.closest("[data-action]"); + + if (!actionElement) { + return; + } + + const action = actionElement.getAttribute("data-action"); + + event.preventDefault(); + + if (action === "close") { + closeModal(); + return; + } + + if (action === "toggle-theme") { + toggleTheme(); + return; + } + + if (action === "toggle-sidebar") { + toggleSidebar(); + return; + } + + if (action === "refresh") { + void refreshData(); + return; + } + + if (action === "calendar-prev") { + shiftCalendarMonth(-1); + return; + } + + if (action === "calendar-next") { + shiftCalendarMonth(1); + return; + } + + if (action === "calendar-today") { + goToCurrentCalendarMonth(); + return; + } + + if (action === "download-json") { + if (appState.dataset) { + downloadFile( + "arch-panel-data.json", + JSON.stringify(appState.dataset.exportPayload, null, 2), + "application/json" + ); + } + + return; + } + + if (action === "download-html") { + if (appState.dataset) { + downloadFile( + "arch-panel-dashboard.html", + createExportHtml(appState.dataset), + "text/html" + ); + } + + return; + } + + if (action === "open-detail") { + const detailScope = actionElement.getAttribute("data-detail-scope"); + const detailValue = actionElement.getAttribute("data-detail-value"); + + openDetailModal(detailScope, detailValue); + return; + } + + if (action === "open-action-form") { + const workloadId = actionElement.getAttribute("data-workload-id"); + + if (workloadId) { + openActionFormModal(workloadId); + } + + return; + } + + if (action === "open-ramp-comparison") { + const workloadId = actionElement.getAttribute("data-workload-id"); + + if (workloadId) { + openRampComparisonModal(workloadId); + } + + return; + } + + if (action === "close-ramp-comparison") { + appState.forecastUpdateConfirmModal = null; + appState.rampComparisonModal = null; + renderModal(); + return; + } + + if (action === "open-forecast-update-confirm") { + openForecastUpdateConfirmModal(); + return; + } + + if (action === "close-forecast-update-confirm") { + appState.forecastUpdateConfirmModal = null; + renderModal(); + return; + } + + if (action === "submit-forecast-update") { + void submitForecastUpdate(); + return; + } + + if (action === "close-action-form") { + appState.actionFormModal = null; + renderModal(); + return; + } + + if (action === "open-time-entries") { + const srNumber = actionElement.getAttribute("data-sr-number"); + + if (srNumber) { + void openTimeEntriesDrawer(srNumber); + } + + return; + } + + if (action === "close-time-entries") { + appState.timeEntriesDrawer = null; + renderModal(); + return; + } + + if (action === "retry-time-entries") { + const srNumber = appState.timeEntriesDrawer?.srNumber; + + if (srNumber) { + void openTimeEntriesDrawer(srNumber); + } + + return; + } + + if (action === "submit-action-form") { + const form = actionElement.closest("form"); + + if (form instanceof HTMLFormElement) { + void submitActionForm(form); + } + + return; + } + + if (action === "close-detail") { + closeDetailModal(); + renderModal(); + return; + } + + if (action === "sort-detail") { + const sortKey = actionElement.getAttribute("data-sort-key"); + + if (sortKey) { + toggleDetailSort(sortKey); + } + } + } + + function renderModal() { + const overlay = document.getElementById(IDS.overlay); + + if (!overlay) { + return; + } + + const previousScrollState = captureModalScrollState(overlay); + const activeFocusSelector = getRestorableFocusSelector(document.activeElement); + const requestedFocusSelector = appState.pendingFocusSelector || activeFocusSelector; + + overlay.innerHTML = createModalMarkup(); + applyDialogTheme(overlay); + + restoreModalScrollState(overlay, previousScrollState); + + window.requestAnimationFrame(() => { + if ( + !appState.detailModal && + !appState.actionFormModal && + !appState.rampComparisonModal && + !appState.forecastUpdateConfirmModal && + requestedFocusSelector + ) { + const focusTarget = overlay.querySelector(requestedFocusSelector); + + if (focusTarget instanceof HTMLElement) { + focusTarget.focus({ preventScroll: true }); + } + } + + appState.pendingFocusSelector = ""; + }); + } + + function captureModalScrollState(overlay) { + const mainShell = overlay.querySelector(".arch-panel-extension-main-shell"); + const shellBody = overlay.querySelector(".arch-panel-extension-shell-body"); + const dialog = overlay.querySelector(".arch-panel-extension-dialog"); + const detailTable = overlay.querySelector( + ".arch-panel-extension-table-wrap.is-detail" + ); + + return { + windowX: window.scrollX, + windowY: window.scrollY, + documentTop: document.documentElement.scrollTop, + bodyTop: document.body.scrollTop, + mainShellTop: mainShell ? mainShell.scrollTop : 0, + shellBodyTop: shellBody ? shellBody.scrollTop : 0, + dialogTop: dialog ? dialog.scrollTop : 0, + detailTableTop: detailTable ? detailTable.scrollTop : 0, + detailTableLeft: detailTable ? detailTable.scrollLeft : 0, + }; + } + + function restoreModalScrollState(overlay, scrollState) { + const restore = () => { + const mainShell = overlay.querySelector(".arch-panel-extension-main-shell"); + const shellBody = overlay.querySelector(".arch-panel-extension-shell-body"); + const dialog = overlay.querySelector(".arch-panel-extension-dialog"); + const detailTable = overlay.querySelector( + ".arch-panel-extension-table-wrap.is-detail" + ); + + if (mainShell) { + mainShell.scrollTop = scrollState.mainShellTop; + } + + if (shellBody) { + shellBody.scrollTop = scrollState.shellBodyTop; + } + + if (dialog) { + dialog.scrollTop = scrollState.dialogTop; + } + + if (detailTable) { + detailTable.scrollTop = scrollState.detailTableTop; + detailTable.scrollLeft = scrollState.detailTableLeft; + } + + document.documentElement.scrollTop = scrollState.documentTop; + document.body.scrollTop = scrollState.bodyTop; + window.scrollTo(scrollState.windowX, scrollState.windowY); + }; + + window.requestAnimationFrame(() => { + restore(); + window.requestAnimationFrame(restore); + }); + } + + function getRestorableFocusSelector(element) { + if (!(element instanceof HTMLElement)) { + return ""; + } + + const action = element.getAttribute("data-action"); + + if ( + action === "calendar-prev" || + action === "calendar-next" || + action === "calendar-today" + ) { + return `[data-action="${action}"]`; + } + + const detailScope = element.getAttribute("data-detail-scope"); + const detailValue = element.getAttribute("data-detail-value"); + + if (detailScope === "calendar-day" && detailValue) { + return getCalendarDayFocusSelector(detailValue); + } + + return ""; + } + + function getCalendarDayFocusSelector(dateKey) { + return `[data-action="open-detail"][data-detail-scope="calendar-day"][data-detail-value="${dateKey}"]`; + } + + function createModalMarkup() { + return ` + + `; + } + + function renderMainContent() { + if (appState.status === "loading") { + return ` +
+
+

Loading data

+

Building the workload dataset

+

${escapeHtml(appState.loadingMessage)}

+
+
+ Overall progress +
+
+
+ + ${formatProgressPercent(appState.loadingProgress || 0)} + +
+
+
+
+ `; + } + + if (appState.status === "error") { + return ` +
+
+

Request failed

+

Unable to assemble the dashboard

+

${escapeHtml(appState.errorMessage)}

+
+ +
+
+
+ `; + } + + if (!appState.dataset) { + return ` +
+
+

Ready

+

Start the live data extraction

+

+ No cached snapshot is available yet. Use Refresh data to fetch the current user, customers, workloads, actions and service requests. +

+
+ +
+
+
+ `; + } + + const { user, summary } = appState.dataset; + + return ` + + +
+
+ ${renderMetricCard("Total clients", formatWholeNumber(summary.totalCustomers), "Loaded from listSummary")} + ${renderMetricCard("Total workloads", formatWholeNumber(summary.totalWorkloads), "Excluding Won workloads")} + ${renderMetricCard("Total (ACR)", formatCurrency(summary.totalEligibleAcr), "Across eligible workloads")} + ${renderMetricCard("Pending SRs", formatWholeNumber(summary.pendingServiceRequests.length), "SVC_PENDING_DELIVERY")} +
+ +
+ ${summary.stageIndicators.map((indicator) => renderStageIndicator(indicator)).join("")} +
+ +
+ ${renderBooleanIndicator( + "Status de SRs", + "Workloads com SR", + summary.srStatus.trueCount, + "Workloads sem SR", + summary.srStatus.falseCount, + "sr" + )} + ${renderBooleanIndicator( + "Status de Consumption Plan/action", + "Workloads com action", + summary.actionStatus.trueCount, + "Workloads sem action", + summary.actionStatus.falseCount, + "action" + )} +
+ + ${renderSrHoursAndCalendarSection(summary)} + +
+ `; + } + + function renderMetricCard(label, value, detail) { + return ` +
+

${escapeHtml(label)}

+

${escapeHtml(value)}

+

${escapeHtml(detail)}

+
+ `; + } + + function renderStageIndicator(indicator) { + return ` + + `; + } + + function renderBooleanIndicator( + title, + trueLabel, + trueCount, + falseLabel, + falseCount, + scope + ) { + const total = trueCount + falseCount; + const truePercent = total > 0 ? (trueCount / total) * 100 : 0; + const falsePercent = total > 0 ? (falseCount / total) * 100 : 0; + + return ` +
+
+
+

${escapeHtml(title)}

+
+
+
+
+ + +
+
+ ${escapeHtml(trueLabel)}: ${formatPercent(truePercent)} + ${escapeHtml(falseLabel)}: ${formatPercent(falsePercent)} +
+
+
+ + +
+
+ `; + } + + function renderSrHoursAndCalendarSection(summary) { + const calendar = buildWorkloadStartCalendar( + summary.allWorkloads, + appState.calendarYear, + appState.calendarMonth + ); + + return ` +
+
+ ${renderSrHoursIndicator( + "SRs 0-4h", + "Horas apontadas entre 0 e 4", + summary.srHours.betweenZeroAndFour.length, + "0-4" + )} + ${renderSrHoursIndicator( + "SRs >4h", + "Horas apontadas acima de 4", + summary.srHours.aboveFour.length, + "above-4" + )} +
+ ${renderWorkloadStartCalendar(calendar)} +
+ `; + } + + function renderSrHoursIndicator(label, detail, count, value) { + return ` + + `; + } + + function renderWorkloadStartCalendar(calendar) { + return ` +
+
+
+

Workload start calendar

+

${escapeHtml(calendar.monthLabel)}

+
+
+ + ${formatWholeNumber(calendar.totalVisibleWorkloads)} starts + + +
+
+
+ ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + .map((day) => `${day}`) + .join("")} +
+
+ ${calendar.days.map((day) => renderCalendarDay(day)).join("")} +
+
+ `; + } + + function renderCalendarDay(day) { + const firstWorkload = day.workloads[0]; + const calendarWorkloadLabel = + firstWorkload?.description || firstWorkload?.name || "Workload"; + const className = [ + "arch-panel-extension-calendar-day", + day.isCurrentMonth ? "" : "is-muted", + day.isToday ? "is-today" : "", + day.workloads.length ? "has-workload" : "", + ] + .filter(Boolean) + .join(" "); + + return ` + + `; + } + + function renderWorkloadTable(workloads) { + return ` +
+ + + + + + + + + + + + + + + + + + + + + + + ${workloads.map((workload) => renderWorkloadTableRow(workload)).join("")} + +
OpportunityCustomerWorkloadACRTypeTem SR?Tem Action?
+
+ `; + } + + function renderWorkloadTableRow( + workload, + includeConsumptionStartDate = false, + includeRampMonths = false + ) { + const hasRampMismatch = + includeRampMonths && hasRampForecastMismatch(workload); + + return ` + + ${renderOpportunityLink(workload)} + ${escapeHtml(workload.customerName || "")} + ${renderWorkloadLink(workload, hasRampMismatch)} + ${ + includeConsumptionStartDate + ? `${escapeHtml(formatDate(workload.consumptionStartDate) || "-")}` + : "" + } + ${ + includeRampMonths + ? `${renderRampMonthsButton(workload)}` + : "" + } + ${formatCurrency(workload.adjustedACR)} + ${escapeHtml(workload.opportunityForecastTypeGroup)} + ${renderBooleanBadge(workload.hasSR)} + ${renderHasActionCell(workload)} + + `; + } + + function renderRampMonthsButton(workload) { + return ` + + `; + } + + function renderBooleanBadge(value) { + const isTrue = Boolean(value); + const variantClass = isTrue ? "is-success" : "is-danger"; + const label = isTrue ? "Sim" : "Não"; + + return ` + + ${label} + + `; + } + + function renderHasActionCell(workload) { + if (workload.hasAction) { + return renderBooleanBadge(true); + } + + return ` +
+ ${renderBooleanBadge(false)} + +
+ `; + } + + function renderInsightRow(label, value, detail) { + return ` +
+
+

${escapeHtml(label)}

+

${escapeHtml(detail)}

+
+

${escapeHtml(value)}

+
+ `; + } + + function renderDetailModal() { + if (!appState.detailModal) { + return ""; + } + + const isServiceRequestDetail = appState.detailModal.type === "serviceRequests"; + const detailItems = isServiceRequestDetail + ? appState.detailModal.items || [] + : getSortedDetailItems(); + + return ` +
+
+
+
+

${escapeHtml(appState.detailModal.label)}

+

${escapeHtml(appState.detailModal.title)}

+
+
+
+ ${ + isServiceRequestDetail + ? `${formatWholeNumber(detailItems.length)} SRs` + : `${formatWholeNumber(detailItems.length)} workloads + + Total (ACR): ${formatCurrency(appState.detailModal.totalAcr)}` + } +
+ ${ + isServiceRequestDetail + ? renderServiceRequestDetailTable(detailItems) + : renderDetailTable( + detailItems, + appState.detailModal.scope === "calendar-day" + ) + } +
+ +
+
+
+ `; + } + + function renderActionFormModal() { + if (!appState.actionFormModal || !appState.dataset) { + return ""; + } + + const workload = findWorkloadById(appState.actionFormModal.workloadId); + const userEmail = appState.dataset.user?.userEmail || ""; + + if (!workload) { + return ""; + } + + const modalError = cleanString(appState.actionFormModal.errorMessage); + const isSubmitting = Boolean(appState.actionFormModal.isSubmitting); + + return ` +
+
+
+
+

Create workload action

+

Criar action

+
+ +
+ +
+ ${renderOpportunityLink(workload)} + + ${escapeHtml(workload.customerName || "")} + + ${escapeHtml(workload.name || "Unnamed workload")} +
+ + ${modalError ? `

${escapeHtml(modalError)}

` : ""} + +
+ + + + + + + + + + + +
+ + + + + + + +
+ +
+ + +
+
+
+
+ `; + } + + function renderRampComparisonModal() { + const modal = appState.rampComparisonModal; + + if (!modal) { + return ""; + } + + const rows = modal.rows || []; + const title = `${modal.workload.customerName || "Unknown customer"} | ${ + modal.workload.name || "Unnamed workload" + }${ + modal.workload.description ? ` | ${modal.workload.description}` : "" + }`; + + return ` +
+
+
+
+

Ramp forecast comparison

+

${escapeHtml(title)}

+
+
+
+ Ramp months: ${formatWholeNumber(modal.workload.rampMonths || 0)} + + Adjusted ACR: ${formatCurrency(modal.workload.adjustedACR)} + + Consumption start: ${escapeHtml( + formatDate(modal.workload.consumptionStartDate) || "-" + )} +
+ ${renderRampComparisonPivotTable(rows)} +
+ + +
+
+
+ `; + } + + function renderRampComparisonPivotTable(rows) { + if (!rows.length) { + return `
Nenhum forecast encontrado para comparar.
`; + } + + return ` +
+ + + + + ${rows.map((row) => ``).join("")} + + + + ${rows + .map( + (row) => + `` + ) + .join("")} + + + + ${rows + .map( + (row) => + `` + ) + .join("")} + + + + ${rows + .map( + (row) => + `` + ) + .join("")} + + +
Period${escapeHtml(row.periodLabel)}
Current adjusted${formatCurrency(row.currentValue)}
Calculated ramp${formatCurrency(row.calculatedValue)}
Delta${formatCurrency(row.delta)}
+
+ `; + } + + function renderForecastUpdateConfirmModal() { + const modal = appState.forecastUpdateConfirmModal; + + if (!modal) { + return ""; + } + + return ` +
+
+
+
+

Confirmar atualização

+

Atualizar forecast pelo Calculated ramp?

+
+
+

+ Esta ação enviará ${formatWholeNumber( + modal.payload.length + )} meses calculados para o workload atual. Os valores atuais do forecast serão substituídos pelos valores da linha Calculated ramp. +

+ ${ + modal.errorMessage + ? `

${escapeHtml( + modal.errorMessage + )}

` + : "" + } +
+ + +
+
+
+ `; + } + + function renderTimeEntriesDrawer() { + const drawer = appState.timeEntriesDrawer; + + if (!drawer) { + return ""; + } + + const items = Array.isArray(drawer.items) ? drawer.items : []; + + return ` + + `; + } + + function renderTimeEntriesDrawerBody(drawer, items) { + if (drawer.status === "loading") { + return ` +
+

Carregando

+

Buscando dados

+

Consultando o resumo de time entries na origem COMCIP.

+
+ `; + } + + if (drawer.status === "error") { + return ` +
+

Erro na requisição

+

${escapeHtml(drawer.errorMessage || "Não foi possível carregar os apontamentos.")}

+ +
+ `; + } + + const groupedItems = groupTimeEntriesByResourceName(items); + + return ` +
+ ${formatWholeNumber(items.length)} registros + ${formatWholeNumber(groupedItems.length)} resources +
+ ${ + items.length + ? groupedItems.map((group) => renderTimeEntryGroup(group)).join("") + : `
Nenhum registro retornado para esta SR.
` + } + `; + } + + function renderTimeEntryGroup(group) { + const totalTimeSpent = group.items.reduce((sum, item) => { + return sum + getTimeEntryTimeSpent(item); + }, 0); + + return ` +
+
+
+

resourceName

+

${escapeHtml(group.resourceName)}

+
+ ${formatHours(totalTimeSpent)} total +
+
+ + + + + + + + + + ${group.items.map((item) => renderTimeEntryRow(item)).join("")} + + + + + + + +
ActivityTask typeTime spent
Total${formatHours(totalTimeSpent)}
+
+
+ `; + } + + function renderTimeEntryRow(item) { + return ` + + ${escapeHtml(getTimeEntryActivity(item) || "-")} + ${escapeHtml(getTimeEntryTaskType(item) || "-")} + ${formatHours(getTimeEntryTimeSpent(item))} + + `; + } + + function renderDetailTable(items, includeConsumptionStartDate = false) { + return ` +
+ + + + + + + ${includeConsumptionStartDate ? "" : ""} + + + + + + + + + ${items + .map((workload) => + renderWorkloadTableRow(workload, includeConsumptionStartDate, true) + ) + .join("")} + +
${renderSortButton("Opportunity", DETAIL_SORT_KEYS.opportunityNumber)}${renderSortButton("Customer", DETAIL_SORT_KEYS.customerName)}${renderSortButton("Workload", DETAIL_SORT_KEYS.workload)}consumptionStartDate${renderSortButton("Ramp months", DETAIL_SORT_KEYS.rampMonths, true)}${renderSortButton("ACR", DETAIL_SORT_KEYS.adjustedACR, true)}${renderSortButton("Type", DETAIL_SORT_KEYS.opportunityForecastTypeGroup)}${renderSortButton("Tem SR?", DETAIL_SORT_KEYS.hasSR)}${renderSortButton("Tem Action?", DETAIL_SORT_KEYS.hasAction)}
+
+ `; + } + + function buildRampForecastComparisonRows(workloads) { + return (workloads || []) + .flatMap((workload) => { + const startDate = parseDatePreservingDateOnly(workload.consumptionStartDate); + + if (Number.isNaN(startDate.getTime())) { + return []; + } + + const startMonth = startDate.getMonth() + 1; + const startYear = startDate.getFullYear(); + const workloadLabel = `${workload.name || "Unnamed workload"}${ + workload.description ? ` | ${workload.description}` : "" + }`; + + return (workload.forecast || []) + .map((forecastItem) => { + const forecastMonth = parseForecastMonthNumber(forecastItem.month); + const forecastYear = toNumber(forecastItem.year); + + if (!forecastMonth || !forecastYear) { + return null; + } + + const monthIndex = + (forecastYear - startYear) * 12 + (forecastMonth - startMonth) + 1; + + const currentValue = toNumber(forecastItem.adjustedConsumptionAmount); + const calculatedValue = + monthIndex >= 1 && monthIndex <= 12 + ? calculateRampForecastAmount({ + adjustedACR: workload.adjustedACR, + rampMonths: workload.rampMonths, + monthIndex, + consumptionStartDate: startDate, + }) + : 0; + + return { + workloadLabel, + periodLabel: formatForecastPeriodLabel(forecastYear, forecastMonth), + currentValue, + calculatedValue, + delta: currentValue - calculatedValue, + monthIndex, + year: forecastYear, + month: forecastMonth, + sortKey: `${workloadLabel}|${String(forecastYear).padStart( + 4, + "0" + )}-${String(forecastMonth).padStart(2, "0")}`, + }; + }) + .filter(Boolean); + }) + .sort((left, right) => left.sortKey.localeCompare(right.sortKey)); + } + + function hasRampForecastMismatch(workload) { + return buildRampForecastComparisonRows([workload]).some((row) => + isNonZeroAmount(row.delta) + ); + } + + function calculateRampForecastAmount({ + adjustedACR, + rampMonths, + monthIndex, + consumptionStartDate, + }) { + const annualValue = toNumber(adjustedACR); + const ramp = Math.max(0, toNumber(rampMonths)); + const month = Math.max(1, toNumber(monthIndex)); + + if (!annualValue) { + return 0; + } + + if (ramp <= 0) { + return Math.round( + (annualValue / 12) * + getStartMonthProrationFactor(month, consumptionStartDate) + ); + } + + const steadyStateMonthlyAmount = (2 * annualValue) / (24 - ramp); + const rampStepCount = ramp + 1; + const calculatedAmount = + month <= rampStepCount + ? steadyStateMonthlyAmount * (month / rampStepCount) + : steadyStateMonthlyAmount; + + return Math.round( + calculatedAmount * getStartMonthProrationFactor(month, consumptionStartDate) + ); + } + + function getStartMonthProrationFactor(monthIndex, consumptionStartDate) { + if (toNumber(monthIndex) !== 1) { + return 1; + } + + const startDate = parseDatePreservingDateOnly(consumptionStartDate); + + if (Number.isNaN(startDate.getTime())) { + return 1; + } + + const daysInMonth = new Date( + startDate.getFullYear(), + startDate.getMonth() + 1, + 0 + ).getDate(); + const remainingDays = daysInMonth - startDate.getDate() + 1; + + return Math.max(0, Math.min(1, remainingDays / daysInMonth)); + } + + function isNonZeroAmount(value) { + return Math.abs(toNumber(value)) > 0.000001; + } + + function parseForecastMonthNumber(value) { + if (typeof value === "number") { + return value >= 1 && value <= 12 ? value : 0; + } + + const normalized = normalizeString(value); + const numericMonth = Number.parseInt(normalized, 10); + + if (numericMonth >= 1 && numericMonth <= 12) { + return numericMonth; + } + + const monthMap = { + JAN: 1, + JANUARY: 1, + FEB: 2, + FEBRUARY: 2, + MAR: 3, + MARCH: 3, + APR: 4, + APRIL: 4, + MAY: 5, + JUN: 6, + JUNE: 6, + JUL: 7, + JULY: 7, + AUG: 8, + AUGUST: 8, + SEP: 9, + SEPT: 9, + SEPTEMBER: 9, + OCT: 10, + OCTOBER: 10, + NOV: 11, + NOVEMBER: 11, + DEC: 12, + DECEMBER: 12, + }; + + return monthMap[normalized] || 0; + } + + function formatForecastPeriodLabel(year, month) { + const fiscalYear = month >= 6 ? year + 1 : year; + const fiscalYearLabel = formatWeekNumber(fiscalYear % 100); + const monthLabel = new Intl.DateTimeFormat("en-US", { + month: "short", + }).format(new Date(year, month - 1, 1)); + + return `FY${fiscalYearLabel} ${monthLabel}`; + } + + function renderServiceRequestDetailTable(items) { + return ` +
+ + + + + + + + + + + + + + + + ${items + .map( + (serviceRequest) => ` + + + + + + + + + + + + ` + ) + .join("")} + +
CustomerSR NumberQueueServicePillarOppty valueHoras apontadasCustomer deadlineOppty Status
${escapeHtml(serviceRequest.customerName || "-")}${renderServiceRequestLink(serviceRequest)}${escapeHtml(serviceRequest.queueName || "-")}${escapeHtml(serviceRequest.serviceName || "-")}${escapeHtml(serviceRequest.pillarLabel || "-")}${formatCurrency(serviceRequest.opportunityValue)}${renderReportedHoursLink(serviceRequest)}${escapeHtml(formatDate(serviceRequest.customerDeadline) || "-")}${escapeHtml(serviceRequest.opportunityStatusLabel || "-")}
+
+ `; + } + + function renderSortButton(label, sortKey, isNumeric = false) { + const currentKey = appState.detailModal?.sortKey || ""; + const currentDirection = appState.detailModal?.sortDirection || "asc"; + const indicator = + currentKey === sortKey ? (currentDirection === "asc" ? " ↑" : " ↓") : ""; + + return ` + + `; + } + + function renderOpportunityLink(workload) { + const label = workload.opportunityNumber || "Opportunity"; + + if (!workload.opportunityId || !workload.opportunityNumber) { + return escapeHtml(label); + } + + const href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?id=${encodeURIComponent( + workload.opportunityId + )}&puid=${encodeURIComponent(workload.opportunityNumber)}`; + + return `${escapeHtml(label)}`; + } + + function renderServiceRequestLink(serviceRequest) { + const srNumber = cleanString(serviceRequest?.srNumber); + + if (!srNumber) { + return "-"; + } + + const href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent( + srNumber + )}`; + + return `${escapeHtml(srNumber)}`; + } + + function renderReportedHoursLink(serviceRequest) { + const srNumber = cleanString(serviceRequest?.srNumber); + const label = formatHours(serviceRequest?.totalHoursWorked); + + if (!srNumber) { + return escapeHtml(label); + } + + return ` + + `; + } + + function renderWorkloadLink(workload, showRampAlert = false) { + const description = workload.description ? ` | ${workload.description}` : ""; + const label = `${workload.name || "Unnamed workload"}${description}`; + + if (!workload.workloadId) { + return `${escapeHtml(label)}${renderRampAlertButton(workload, showRampAlert)}`; + } + + const href = `https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=workload_workbench/workload_details;workloadId=${encodeURIComponent( + workload.workloadId + )}`; + + return ` + + ${escapeHtml(label)} + ${renderRampAlertButton(workload, showRampAlert)} + + `; + } + + function renderRampAlertButton(workload, showRampAlert) { + if (!showRampAlert || !workload.workloadId) { + return ""; + } + + return ` + + `; + } + + function getHeaderNote() { + if (appState.status === "loading") { + return appState.loadingMessage || "Loading"; + } + + if (appState.status === "error") { + return "Extraction failed"; + } + + if (appState.dataset) { + return `${appState.dataset.user.userEmail} · ${formatWholeNumber( + appState.dataset.summary.eligibleWorkloads.length + )} eligible workloads`; + } + + return "Ready"; + } + + function getHeaderSummary() { + if (appState.status === "loading") { + return appState.loadingMessage || "Loading"; + } + + if (appState.status === "error") { + return "Extraction failed"; + } + + if (appState.dataset) { + return `${appState.dataset.user.userEmail} | ${formatWholeNumber( + appState.dataset.summary.eligibleWorkloads.length + )} eligible workloads`; + } + + return "Ready"; + } + + function openModal() { + const overlay = ensureModal(); + + if (!overlay) { + return; + } + + window.clearTimeout(closeTimer); + restoreFocusTarget = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + + overlay.hidden = false; + overlay.setAttribute("aria-hidden", "false"); + lockScroll(); + + window.requestAnimationFrame(() => { + overlay.classList.add(CLASSES.overlayOpen); + const primaryAction = overlay.querySelector("[data-action='close']"); + primaryAction?.focus(); + }); + + if (!appState.dataset && appState.status !== "loading") { + void hydrateDatasetFromCache(); + } + } + + function closeModal() { + const overlay = document.getElementById(IDS.overlay); + + if (!overlay || overlay.hidden) { + return; + } + + overlay.classList.remove(CLASSES.overlayOpen); + overlay.setAttribute("aria-hidden", "true"); + unlockScroll(); + appState.detailModal = null; + appState.actionFormModal = null; + appState.rampComparisonModal = null; + appState.forecastUpdateConfirmModal = null; + appState.timeEntriesDrawer = null; + + if (restoreFocusTarget && restoreFocusTarget.isConnected) { + restoreFocusTarget.focus(); + } + + restoreFocusTarget = null; + + window.clearTimeout(closeTimer); + closeTimer = window.setTimeout(() => { + overlay.hidden = true; + }, 160); + } + + async function refreshData() { + if (appState.status === "loading") { + return; + } + + try { + appState.status = "loading"; + appState.errorMessage = ""; + updateLoadingProgress("Requesting current user", 4); + + const user = await fetchCurrentUser(); + + updateLoadingProgress("Loading customer summary pages", 12); + + const customers = await fetchAllCustomers(user.userEmail); + + updateLoadingProgress( + `Loading workloads for ${formatWholeNumber(customers.length)} customers`, + 24 + ); + + let completedCustomers = 0; + const customersWithWorkloads = await mapWithConcurrency( + customers, + 4, + async (customer, index) => { + updateLoadingProgress( + `Loading workloads ${index + 1}/${customers.length}: ${customer.name}`, + getSegmentProgress(24, 52, completedCustomers, customers.length) + ); + + const workloads = await fetchCustomerWorkloads(customer); + completedCustomers += 1; + updateLoadingProgress( + `Loaded workloads ${completedCustomers}/${customers.length}: ${customer.name}`, + getSegmentProgress(24, 52, completedCustomers, customers.length) + ); + + return { + customerId: customer.customerId, + name: customer.name, + workloads, + }; + } + ); + + const allWorkloads = customersWithWorkloads.flatMap((customer) => { + return customer.workloads.map((workload) => ({ + ...workload, + customerId: customer.customerId, + customerName: customer.name, + })); + }); + + updateLoadingProgress( + `Loading actions and service requests for ${formatWholeNumber( + allWorkloads.length + )} workloads`, + 56 + ); + + let completedWorkloads = 0; + const enrichedWorkloads = await mapWithConcurrency( + allWorkloads, + 6, + async (workload, index) => { + updateLoadingProgress( + `Loading actions and SRs ${index + 1}/${allWorkloads.length}: ${workload.name}`, + getSegmentProgress(56, 86, completedWorkloads, allWorkloads.length) + ); + + const [actions, serviceRequests] = await Promise.all([ + fetchWorkloadActions(workload.workloadId), + fetchWorkloadServiceRequests(workload.workloadId), + ]); + completedWorkloads += 1; + updateLoadingProgress( + `Loaded actions and SRs ${completedWorkloads}/${allWorkloads.length}: ${workload.name}`, + getSegmentProgress(56, 86, completedWorkloads, allWorkloads.length) + ); + + const hasSR = serviceRequests.some((serviceRequest) => { + return ( + normalizeString(serviceRequest.status) === "SVC_PENDING_DELIVERY" && + teamContainsUser(serviceRequest.team, user.userEmail) + ); + }); + + const hasAction = actions.some((action) => { + return normalizeString(action.owner) === normalizeString(user.userEmail); + }); + + return { + ...workload, + actions, + serviceRequests, + hasSR, + hasAction, + }; + } + ); + + const customersMap = new Map( + customersWithWorkloads.map((customer) => [ + customer.customerId, + { + customerId: customer.customerId, + name: customer.name, + workloads: [], + }, + ]) + ); + + for (const workload of enrichedWorkloads) { + const customer = customersMap.get(workload.customerId); + + if (customer) { + customer.workloads.push(stripTransientFields(workload)); + } + } + + updateLoadingProgress("Loading pending delivery service requests", 90); + + const pendingServiceRequests = await fetchPendingServiceRequests(); + updateLoadingProgress("Preparing dashboard cache", 96); + const finalCustomers = Array.from(customersMap.values()); + const exportPayload = createExportPayload( + user, + finalCustomers, + pendingServiceRequests + ); + const dataset = createDatasetSnapshot(exportPayload); + + await writeCachedDataset(exportPayload); + updateLoadingProgress("Dashboard ready", 100); + + appState.dataset = dataset; + appState.status = "ready"; + appState.loadingMessage = ""; + appState.loadingProgress = 0; + appState.detailModal = null; + renderModal(); + } catch (error) { + appState.status = "error"; + appState.errorMessage = getErrorMessage(error); + appState.loadingMessage = ""; + appState.loadingProgress = 0; + renderModal(); + } + } + + function updateLoadingProgress(message, progress) { + appState.loadingMessage = message; + appState.loadingProgress = Math.max(0, Math.min(Number(progress) || 0, 100)); + + if (!updateLoadingProgressDom()) { + renderModal(); + window.requestAnimationFrame(() => { + updateLoadingProgressDom(); + }); + } + } + + function updateLoadingProgressDom() { + const overlay = document.getElementById(IDS.overlay); + const progress = Math.max(0, Math.min(appState.loadingProgress || 0, 100)); + const stateCopy = overlay?.querySelector(".arch-panel-extension-state-copy"); + const progressRoot = overlay?.querySelector( + ".arch-panel-extension-loading-progress" + ); + const progressBar = overlay?.querySelector( + ".arch-panel-extension-loading-progress-fill" + ); + const progressTrackValue = overlay?.querySelector( + ".arch-panel-extension-loading-progress-value" + ); + const shellNote = overlay?.querySelector(".arch-panel-extension-shell-note"); + + if (!stateCopy || !progressRoot || !progressBar) { + return false; + } + + stateCopy.textContent = appState.loadingMessage; + progressRoot.setAttribute("aria-valuenow", String(Math.round(progress))); + progressBar.style.width = `${progress}%`; + + if (progressTrackValue) { + progressTrackValue.textContent = formatProgressPercent(progress); + } + + if (shellNote) { + shellNote.textContent = appState.loadingMessage || "Loading"; + } + + return true; + } + + function getSegmentProgress(start, end, current, total) { + const denominator = Math.max(Number(total) || 0, 1); + const ratio = Math.max(0, Math.min((Number(current) || 0) / denominator, 1)); + + return start + (end - start) * ratio; + } + + async function fetchPendingServiceRequests() { + const response = await sendComcipPostMessage(); + + if (!response?.ok) { + const payloadMessage = + typeof response?.payload === "string" + ? cleanString(response.payload) + : firstNonEmptyString([ + response?.payload?.message, + response?.payload?.error, + response?.payload?.title, + response?.payload?.detail, + ]); + + throw new Error( + payloadMessage || + response?.error || + `COMCIP request failed (${response?.status || 0}).` + ); + } + + return normalizePendingServiceRequests(response.payload); + } + + function sendComcipPostMessage() { + return sendComcipRequestMessage({ + method: "POST", + url: COMCIP_REQUEST.url, + payload: createComcipServiceRequestPayload(), + timeoutMessage: "Timed out while requesting COMCIP data.", + }); + } + + function sendComcipGetMessage(url) { + return sendComcipRequestMessage({ + method: "GET", + url, + payload: null, + timeoutMessage: "Timed out while requesting COMCIP data.", + }); + } + + function sendComcipRequestMessage({ method, url, payload, timeoutMessage }) { + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + reject(new Error(timeoutMessage || "Timed out while requesting COMCIP data.")); + }, 130000); + + chrome.runtime.sendMessage( + { + type: "ARCH_PANEL_COMCIP_REQUEST", + method, + url, + payload, + headers: { + "accept-language": navigator.language || "pt-BR", + "x-vb-application-version": COMCIP_REQUEST.appVersion, + }, + }, + (response) => { + window.clearTimeout(timeout); + const runtimeError = chrome.runtime.lastError; + + if (runtimeError) { + reject(new Error(runtimeError.message)); + return; + } + + resolve(response || null); + } + ); + }); + } + + function createComcipServiceRequestPayload() { + return { + entity: "CRMServiceRequest", + offset: 0, + limit: 100, + onlyData: true, + language: "US", + fields: [ + "SrNumber", + "Title", + "LastUpdateDate", + "CreationDate", + "PrimaryPillarNew_c", + "PrimaryPillarNew_c_localizedValue", + "CountryText_c", + "OpportunityName_Id_c.OptyId", + "OpportunityName_Id_c.Name", + "OpportunityName_Id_c.OptyNumber", + "OpportunityName_Id_c.OpportunityType_c", + "OpportunityName_Id_c.StatusCode", + "OpportunityName_Id_c.EffectiveDate", + "OpportunityName_Id_c.StatusCode_localizedValue", + "OpportunityName_Id_c.PrimaryRevenue.WinProb", + "OpportunityName_Id_c.PrimaryRevenue.RevnAmount", + "OpportunityName_Id_c.PrimaryRevenue.CrmConversionRate", + "OpportunityName_Id_c.WorkloadAmountUSDWorkspace_c", + "CustomerAccount.PartyId", + "CustomerAccount.PartyUniqueName", + "CustomerAccount.PartyNumber", + "CustomerAccount.NamedFlag", + "StatusCd", + "StatusCd_localizedValue", + "ServiceNew_Id_c", + "CustomerDeadline_c", + "Owner.PartyId", + "Owner.PartyName", + "Owner.PrimaryEmail.EmailAddress", + "Owner.ResourceEmailAddress", + "ReportedByPartyId.PrimaryEmail.EmailAddress", + "Queue.QueueId", + "Queue.QueueName", + "ServiceRequest_Id_SRToInternalSR", + "ReportedHours_c", + "ProblemDescription", + "AccountRegistryId_c", + ], + sort: [ + { + attribute: "LastUpdateDate", + direction: "descending", + }, + ], + q: { + op: "$and", + criteria: [ + { + op: "$eq", + attribute: "RecordSet", + value: "ORA_SVC_MY_SUBORD_SR_TEAM_SRS", + }, + { + op: "$in", + attribute: "StatusCd", + values: ["SVC_PENDING_DELIVERY"], + }, + ], + }, + aggregations: { + StatusCd: { + ignore: true, + terms: { + attribute: "StatusCd", + includeTerms: [ + "SVC_PENDING_ACCEPTANCE", + "SVC_PENDING_DELIVERY", + "SVC_PENDING_RESOURCING", + "SVC_SE_MGR_CLOSED", + "SVC_REJECT", + ], + excludeTerms: ["SVC_PENDING_DISPATCHER", "ORA_SVC_NEW", "SVC_REJECT"], + minBucketCount: 1, + maxNumberOfBuckets: 5, + other: false, + missing: false, + localize: true, + }, + }, + Queue: createTermsAggregation("Queue", 10), + CustomerAccount: createTermsAggregation("CustomerAccount", 5), + OpportunityStatus: createTermsAggregation( + "OpportunityName_Id_c.StatusCode", + 6 + ), + Service: createTermsAggregation("ServiceNew_Id_c", 5), + Country: createTermsAggregation("CountryText_c", 5), + Pillar: createTermsAggregation("PrimaryPillarNew_c", 5), + WinProb: { + ignore: true, + range: { + attribute: "OpportunityName_Id_c.PrimaryRevenue.WinProb", + ranges: [ + { key: "0-10 % SQL", from: 0, to: 11 }, + { key: "20-30 % Pipeline", from: 20, to: 31 }, + { key: "40-50 % Upside", from: 40, to: 51 }, + { key: "60-90 % Forecast", from: 60, to: 91 }, + { key: "100 % Won", from: 100, to: 101 }, + ], + }, + }, + Amount: { + ignore: true, + range: { + attribute: "OpportunityName_Id_c.PrimaryRevenue.RevnAmount", + ranges: [ + { key: "0-10,000", from: 0, to: 10000 }, + { key: "10,000-50,000", from: 10000, to: 50000 }, + { key: "50,000-100,000", from: 50000, to: 100000 }, + { key: ">=100,000", from: 100000 }, + ], + }, + }, + OpportunityName_Id_c: createTermsAggregation("OpportunityName_Id_c", 5), + }, + }; + } + + function createTermsAggregation(attribute, maxNumberOfBuckets) { + return { + ignore: true, + terms: { + attribute, + includeTerms: [], + minBucketCount: 1, + maxNumberOfBuckets, + other: false, + missing: false, + localize: true, + }, + }; + } + + function normalizePendingServiceRequests(payload) { + const items = Array.isArray(payload) + ? payload + : extractArray(payload, ["items", "data", "results", "content"]); + + return items + .map((item) => normalizePendingServiceRequest(item)) + .filter((item) => item.srNumber || item.customerName || item.lastUpdateDate) + .sort((left, right) => { + return ( + new Date(right.lastUpdateDate || 0).getTime() - + new Date(left.lastUpdateDate || 0).getTime() + ); + }); + } + + function normalizePendingServiceRequest(item) { + return { + customerName: cleanString( + getFieldValue(item, [ + "CustomerAccount.PartyUniqueName", + "CustomerAccount.PartyName", + "CustomerAccount.Name", + "customerName", + ]) + ), + lastUpdateDate: cleanString(getFieldValue(item, ["LastUpdateDate", "lastUpdateDate"])), + srNumber: cleanString(getFieldValue(item, ["SrNumber", "srNumber"])), + queueName: cleanString( + getFieldValue(item, ["Queue.QueueName", "QueueName", "queueName"]) + ), + requestedBy: cleanString( + getFieldValue(item, [ + "ReportedByPartyId.PrimaryEmail.EmailAddress", + "ReportedByPartyId.EmailAddress", + "requestedBy", + ]) + ), + creationDate: cleanString(getFieldValue(item, ["CreationDate", "creationDate"])), + opportunityNumber: cleanString( + getFieldValue(item, [ + "OpportunityName_Id_c.OptyNumber", + "OptyNumber", + "opportunityNumber", + ]) + ), + srStatusLabel: cleanString( + getFieldValue(item, ["StatusCd_localizedValue", "StatusCd", "srStatusLabel"]) + ), + opportunityStatusLabel: cleanString( + getFieldValue(item, [ + "OpportunityName_Id_c.StatusCode_localizedValue", + "OpportunityName_Id_c.StatusCode", + "opportunityStatusLabel", + ]) + ), + serviceName: cleanString( + getFieldValue(item, [ + "ServiceNew_Id_c.RecordName", + "ServiceNew_Id_c", + "serviceName", + ]) + ), + country: cleanString(getFieldValue(item, ["CountryText_c", "country"])), + totalHoursWorked: toNumber( + getFieldValue(item, ["ReportedHours_c", "totalHoursWorked"]) + ), + customerDeadline: cleanString( + getFieldValue(item, ["CustomerDeadline_c", "customerDeadline"]) + ), + opportunityValue: toNumber( + getFieldValue(item, [ + "WorkloadAmountUSDWorkspace_c", + "OpportunityName_Id_c.WorkloadAmountUSDWorkspace_c", + "OpportunityName_Id_c.PrimaryRevenue.RevnAmount", + "opportunityValue", + ]) + ), + pillarLabel: cleanString( + getFieldValue(item, [ + "PrimaryPillarNew_c_localizedValue", + "PrimaryPillarNew_c", + "pillarLabel", + ]) + ), + }; + } + + function getFieldValue(source, paths) { + if (!source || typeof source !== "object") { + return ""; + } + + for (const path of paths) { + if (Object.prototype.hasOwnProperty.call(source, path)) { + return source[path]; + } + + const parts = String(path).split("."); + let current = source; + + for (const part of parts) { + if (!current || typeof current !== "object") { + current = ""; + break; + } + + current = current[part]; + } + + if (current !== undefined && current !== null && current !== "") { + return current; + } + } + + return ""; + } + + async function hydrateDatasetFromCache() { + if (appState.dataset || cacheHydrationPromise) { + return cacheHydrationPromise; + } + + appState.status = "loading"; + appState.errorMessage = ""; + appState.loadingMessage = "Loading cached snapshot"; + appState.loadingProgress = 18; + renderModal(); + + cacheHydrationPromise = (async () => { + try { + const exportPayload = await readCachedDataset(); + appState.loadingProgress = exportPayload ? 100 : 0; + + if (exportPayload) { + appState.dataset = createDatasetSnapshot(exportPayload); + appState.status = "ready"; + } else { + appState.status = "idle"; + } + } catch (error) { + appState.status = "error"; + appState.errorMessage = getErrorMessage(error); + } finally { + appState.loadingMessage = ""; + appState.loadingProgress = 0; + cacheHydrationPromise = null; + renderModal(); + } + })(); + + return cacheHydrationPromise; + } + + async function fetchCurrentUser() { + const payload = await fetchJson(API_ENDPOINTS.currentUser); + const userPayload = payload?.user ?? payload; + const userEmail = cleanString(userPayload?.id); + + appState.sessionAuthHeaders = extractAuthHeaders(payload?.token); + + if (!userEmail) { + throw new Error( + `Unable to resolve userEmail from users/current.user.id. Available top-level keys: ${Object.keys( + payload || {} + ).join(", ")}` + ); + } + + return { + userEmail, + name: + firstNonEmptyString([ + userPayload?.name, + userPayload?.displayName, + userPayload?.fullName, + userPayload?.profile?.name, + ]) || userEmail, + administrator: Boolean(userPayload?.administrator), + }; + } + + + async function fetchAllCustomers(userEmail) { + const limit = 49; + let offset = 0; + const customers = []; + const seen = new Set(); + + while (true) { + const url = new URL(API_ENDPOINTS.customerSummary, TARGET_ORIGIN); + url.search = new URLSearchParams({ + emailAddress: userEmail, + projectionPeriod: "ALL", + recordSet: "IAM_ON_SR_TEAM", + growth: "All", + projectedGrowth: "All", + attainmentBand: "All", + projectedConsumption: "All", + customerStatus: "All", + workloadStatus: "All", + lastProjectionUpdate: "All", + revenueForecastType: "All", + customerProgramCode: "All", + planType: "All", + workLoadLivePeriod: "All", + activeConsumptionOnly: "false", + favorite: "false", + workloadDeleted: "false", + awsFlag: "N", + googleFlag: "N", + azureFlag: "N", + excludePayAsYouGoOnly: "false", + projectionReview: "N", + pillarFilter: "dpOci", + sortBy: "CUSTOMER_NAME", + sortOrder: "ASC", + limit: String(limit), + offset: String(offset), + }).toString(); + + const payload = await fetchJson(url.toString()); + const items = extractArray(payload, [ + "items", + "customers", + "content", + "results", + "records", + "data", + ]); + + const mapped = items + .map((item) => ({ + customerId: cleanString(item?.id), + name: item?.name || "", + })) + .filter((item) => item.customerId); + + for (const item of mapped) { + if (!seen.has(item.customerId)) { + seen.add(item.customerId); + customers.push(item); + } + } + + if (mapped.length < limit) { + break; + } + + offset += limit; + } + + return customers; + } + + async function fetchCustomerWorkloads(customer) { + const url = new URL( + `${API_ENDPOINTS.customerWorkloads}/${encodeURIComponent( + customer.customerId + )}/workloads`, + TARGET_ORIGIN + ); + url.search = new URLSearchParams({ + workloadStatus: "Pipeline,Implementation,Live", + opportunityForecastType: "All", + includeForEstimates: "ALL", + financialYear: "ROLLING_4_QTRS", + }).toString(); + + const payload = await fetchJson(url.toString()); + const items = extractArray(payload, [ + "items", + "workloads", + "content", + "results", + "data", + ]); + + return items + .map((item) => normalizeWorkload(item)) + .filter(Boolean) + .filter((workload) => !isWonWorkload(workload)); + } + + async function fetchWorkloadActions(workloadId) { + const url = new URL(API_ENDPOINTS.workloadActions, TARGET_ORIGIN); + url.search = new URLSearchParams({ + parentType: "WORKLOAD", + parentId: workloadId, + }).toString(); + + const payload = await fetchJson(url.toString()); + const items = extractArray(payload, ["items", "actions", "content", "results", "data"]); + + return items.map((item) => ({ + owner: item?.owner || "", + role: item?.role || "", + name: item?.name || "", + team: normalizeTeamValue(item?.team), + startDate: item?.startDate || "", + endDate: item?.endDate || "", + complexity: item?.complexity || "", + })); + } + + async function fetchWorkloadServiceRequests(workloadId) { + const url = new URL( + `${API_ENDPOINTS.workloadServiceRequests}/${encodeURIComponent( + workloadId + )}/serviceRequests`, + TARGET_ORIGIN + ); + + const payload = await fetchJson(url.toString()); + const items = extractArray(payload, [ + "items", + "serviceRequests", + "content", + "results", + "data", + ]); + + return items.map((item) => ({ + srNumber: item?.srNumber || "", + status: item?.status || "", + team: normalizeTeamValue(item?.team), + })); + } + + async function fetchJson(url, options = {}) { + if (options.forceExtension) { + return fetchJsonViaExtension(url, options); + } + + if (shouldUseExtensionFetch(url) && !options.forcePageBridge) { + return fetchJsonViaExtension(url, options); + } + + await ensurePageBridge(); + + const requestId = `${Date.now()}-${++requestCounter}`; + const requestUrl = toBridgeRequestUrl(url); + + const response = await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + pendingBridgeRequests.delete(requestId); + reject(new Error(`Timed out while requesting ${requestUrl}`)); + }, 60000); + + pendingBridgeRequests.set(requestId, { + resolve, + reject, + timeout, + }); + + window.postMessage( + { + source: BRIDGE.source, + type: BRIDGE.fetchType, + requestId, + url: requestUrl, + options: { + method: options.method || "GET", + transport: options.transport || "fetch", + referrer: options.referrer, + headers: { + accept: "application/json, text/plain, */*", + ...(options.headers || {}), + }, + body: options.body, + }, + }, + window.location.origin + ); + }); + + if (!response.ok) { + const payloadMessage = + typeof response.payload === "string" + ? cleanString(response.payload) + : firstNonEmptyString([ + response.payload?.message, + response.payload?.error, + response.payload?.title, + response.payload?.detail, + ]); + const debugHeaderKeys = Array.isArray(response.debug?.headerKeys) + ? response.debug.headerKeys.join(", ") + : ""; + const debugScopeKeys = Array.isArray(response.debug?.scopeKeys) + ? response.debug.scopeKeys.join(", ") + : ""; + const debugParts = [ + debugHeaderKeys ? `captured headers: ${debugHeaderKeys}` : "", + debugScopeKeys ? `scopes: ${debugScopeKeys}` : "", + ].filter(Boolean); + const debugSuffix = debugParts.length > 0 + ? ` | ${debugParts.join(" | ")}` + : ""; + + throw new Error( + `${payloadMessage || response.error || `Request failed (${response.status}) for ${requestUrl}`}${debugSuffix}` + ); + } + + return response.payload; + } + + function shouldUseExtensionFetch(url) { + const normalized = cleanString(url); + + if (!normalized) { + return false; + } + + try { + const parsedUrl = new URL(normalized, window.location.origin); + return parsedUrl.origin !== window.location.origin; + } catch { + return false; + } + } + + async function fetchJsonViaExtension(url, options = {}) { + const requestUrl = new URL(url, window.location.origin).toString(); + const bridgeHeaders = await requestBridgeHeaders({ + url: requestUrl, + headers: options.headers, + body: options.body, + includeAllAuth: true, + }); + const response = await new Promise((resolve, reject) => { + chrome.runtime.sendMessage( + { + type: "ARCH_PANEL_EXTENSION_FETCH", + url: requestUrl, + options: { + method: options.method || "GET", + referrer: options.referrer, + headers: buildExtensionFetchHeaders( + requestUrl, + options, + bridgeHeaders || {} + ), + body: options.body, + }, + }, + (result) => { + const runtimeError = chrome.runtime.lastError; + + if (runtimeError) { + reject(new Error(runtimeError.message)); + return; + } + + resolve(result || null); + } + ); + }); + + if (!response || typeof response !== "object") { + throw new Error(`No response received for ${requestUrl}`); + } + + if (!response.ok) { + const payloadMessage = + typeof response.payload === "string" + ? cleanString(response.payload) + : firstNonEmptyString([ + response.payload?.message, + response.payload?.error, + response.payload?.title, + response.payload?.detail, + ]); + const debugHeaderKeys = Array.isArray(response.debug?.headerKeys) + ? response.debug.headerKeys.join(", ") + : ""; + const debugUrl = cleanString(response.debug?.url); + const debugParts = [ + debugUrl ? `url: ${debugUrl}` : "", + debugHeaderKeys ? `captured headers: ${debugHeaderKeys}` : "", + ].filter(Boolean); + const debugSuffix = debugParts.length > 0 + ? ` | ${debugParts.join(" | ")}` + : ""; + + throw new Error( + `${ + payloadMessage || + response.error || + `Request failed (${response.status}) for ${requestUrl}` + }${debugSuffix}` + ); + } + + return response.payload; + } + + function buildExtensionFetchHeaders(requestUrl, options = {}, bridgeHeaders = {}) { + const combinedHeaders = { + ...(bridgeHeaders || {}), + ...(appState.sessionAuthHeaders || {}), + accept: "application/json, text/plain, */*", + ...(options.headers || {}), + }; + + return combinedHeaders; + } + + async function requestBridgeHeaders(options = {}) { + await ensurePageBridge(); + + const requestId = `${Date.now()}-${++requestCounter}`; + + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + pendingBridgeRequests.delete(requestId); + reject(new Error("Timed out while requesting bridge headers")); + }, 30000); + + pendingBridgeRequests.set(requestId, { + resolve, + reject, + timeout, + }); + + window.postMessage( + { + source: BRIDGE.source, + type: BRIDGE.headersType, + requestId, + options, + }, + window.location.origin + ); + }); + } + + function normalizeWorkload(item) { + const workloadId = cleanString(item?.workloadId || item?.id); + + if (!workloadId) { + return null; + } + + const opportunityForecastType = item?.opportunityForecastType || ""; + const opportunityForecastTypeGroup = normalizeForecastType( + opportunityForecastType + ); + + return { + workloadId, + name: item?.name || "", + description: item?.description || "", + consumptionStartDate: item?.consumptionStartDate || "", + workloadStatus: item?.workloadStatus || "", + opportunityForecastType, + opportunityForecastTypeGroup, + opportunityId: cleanString(item?.opportunityId), + opportunityNumber: item?.opportunityNumber || "", + adjustedACR: toNumber(item?.adjustedACR), + rampMonths: toNumber(item?.rampMonths), + forecast: extractArray(item?.forecast, ["items", "content", "results", "data"]).map( + (forecastItem) => ({ + month: forecastItem?.month || "", + year: toNumber(forecastItem?.year), + projectedConsumptionAmount: toNumber( + forecastItem?.projectedConsumptionAmount + ), + adjustedConsumptionAmount: toNumber( + forecastItem?.adjustedConsumptionAmount + ), + createdBy: forecastItem?.createdBy || "", + createdDate: forecastItem?.createdDate || "", + updatedBy: forecastItem?.updatedBy || "", + updatedDate: forecastItem?.updatedDate || "", + }) + ), + }; + } + + function isWonWorkload(workload) { + const forecastType = normalizeString(workload.opportunityForecastTypeGroup); + const workloadStatus = normalizeString(workload.workloadStatus); + + return forecastType === "WON" || workloadStatus === "WON"; + } + + function normalizeForecastType(value) { + const text = String(value || "").trim(); + + if (!text) { + return "Unknown"; + } + + const [head] = text.split(" -"); + return head.trim() || "Unknown"; + } + + function normalizeTeamValue(value) { + if (Array.isArray(value)) { + return value.map((item) => normalizeTeamValue(item)).join(" | "); + } + + if (value && typeof value === "object") { + return Object.values(value).map((item) => normalizeTeamValue(item)).join(" | "); + } + + return String(value || ""); + } + + function teamContainsUser(team, userEmail) { + return normalizeString(team).includes(normalizeString(userEmail)); + } + + function stripTransientFields(workload) { + return { + workloadId: workload.workloadId, + name: workload.name, + description: workload.description, + consumptionStartDate: workload.consumptionStartDate, + workloadStatus: workload.workloadStatus, + opportunityForecastType: workload.opportunityForecastType, + opportunityForecastTypeGroup: workload.opportunityForecastTypeGroup, + opportunityId: workload.opportunityId, + opportunityNumber: workload.opportunityNumber, + adjustedACR: workload.adjustedACR, + rampMonths: workload.rampMonths, + forecast: workload.forecast, + customerId: workload.customerId, + customerName: workload.customerName, + actions: workload.actions, + serviceRequests: workload.serviceRequests, + hasSR: workload.hasSR, + hasAction: workload.hasAction, + }; + } + + function createExportPayload(user, customers, pendingServiceRequests = []) { + return { + generatedAt: new Date().toISOString(), + user, + customers, + pendingServiceRequests, + }; + } + + function createDatasetSnapshot(exportPayload) { + const normalizedPayload = normalizeCachedExportPayload(exportPayload); + + return { + user: normalizedPayload.user, + customers: normalizedPayload.customers, + exportPayload: normalizedPayload, + summary: buildSummary(normalizedPayload), + }; + } + + function findWorkloadById(workloadId) { + if (!appState.dataset || !workloadId) { + return null; + } + + for (const customer of appState.dataset.customers || []) { + for (const workload of customer.workloads || []) { + if (workload.workloadId === workloadId) { + return { + ...workload, + customerId: workload.customerId || customer.customerId, + customerName: workload.customerName || customer.name, + }; + } + } + } + + return null; + } + + function applyForecastUpdateToDataset(workloadId, payload) { + if (!appState.dataset || !workloadId) { + return; + } + + const previousDetailModal = appState.detailModal + ? { + scope: appState.detailModal.scope, + value: appState.detailModal.value, + sortKey: appState.detailModal.sortKey, + sortDirection: appState.detailModal.sortDirection, + type: appState.detailModal.type, + } + : null; + const updatesByPeriod = new Map( + (payload || []).map((item) => [ + `${toNumber(item.year)}-${toNumber(item.month)}`, + toNumber(item.amount), + ]) + ); + + function updateForecast(workload) { + if (!workload || workload.workloadId !== workloadId) { + return workload; + } + + workload.forecast = (workload.forecast || []).map((forecastItem) => { + const month = parseForecastMonthNumber(forecastItem.month); + const year = toNumber(forecastItem.year); + const key = `${year}-${month}`; + + if (!updatesByPeriod.has(key)) { + return forecastItem; + } + + return { + ...forecastItem, + adjustedConsumptionAmount: updatesByPeriod.get(key), + updatedBy: appState.dataset.user?.userEmail || forecastItem.updatedBy, + updatedDate: new Date().toISOString(), + }; + }); + + return workload; + } + + for (const customer of appState.dataset.customers || []) { + customer.workloads = (customer.workloads || []).map((workload) => + updateForecast(workload) + ); + } + + for (const customer of appState.dataset.exportPayload?.customers || []) { + customer.workloads = (customer.workloads || []).map((workload) => + updateForecast(workload) + ); + } + + appState.dataset = createDatasetSnapshot(appState.dataset.exportPayload); + + if (previousDetailModal?.scope && previousDetailModal.type !== "serviceRequests") { + restoreDetailModal(previousDetailModal); + } + + void writeCachedDataset(appState.dataset.exportPayload); + } + + function normalizeCachedExportPayload(exportPayload) { + const normalizedUser = { + userEmail: cleanString(exportPayload?.user?.userEmail), + name: + firstNonEmptyString([ + exportPayload?.user?.name, + exportPayload?.user?.displayName, + exportPayload?.user?.fullName, + exportPayload?.user?.profile?.name, + exportPayload?.user?.userEmail, + ]) || "", + administrator: Boolean(exportPayload?.user?.administrator), + }; + const normalizedCustomers = extractArray(exportPayload?.customers, ["customers"]) + .map((customer) => ({ + customerId: cleanString(customer?.customerId || customer?.id), + name: cleanString(customer?.name), + workloads: extractArray(customer?.workloads, ["workloads"]) + .map((workload) => stripTransientFields(normalizeWorkloadForCache(workload))) + .filter((workload) => workload.workloadId), + })) + .filter((customer) => customer.customerId); + const pendingServiceRequests = normalizePendingServiceRequests( + extractArray(exportPayload?.pendingServiceRequests, [ + "pendingServiceRequests", + "items", + "data", + "results", + ]) + ); + + return { + generatedAt: + firstNonEmptyString([exportPayload?.generatedAt]) || new Date().toISOString(), + user: normalizedUser, + customers: normalizedCustomers, + pendingServiceRequests, + }; + } + + function normalizeWorkloadForCache(workload) { + const normalized = { + workloadId: cleanString(workload?.workloadId), + name: cleanString(workload?.name), + description: cleanString(workload?.description), + consumptionStartDate: workload?.consumptionStartDate || "", + workloadStatus: cleanString(workload?.workloadStatus), + opportunityForecastType: cleanString(workload?.opportunityForecastType), + opportunityForecastTypeGroup: + cleanString(workload?.opportunityForecastTypeGroup) || + normalizeOpportunityForecastType(workload?.opportunityForecastType), + opportunityId: cleanString(workload?.opportunityId), + opportunityNumber: cleanString(workload?.opportunityNumber), + adjustedACR: toNumber(workload?.adjustedACR), + rampMonths: toNumber(workload?.rampMonths), + forecast: extractArray(workload?.forecast, ["forecast"]).map((entry) => ({ + month: entry?.month || "", + year: entry?.year || "", + projectedConsumptionAmount: toNumber(entry?.projectedConsumptionAmount), + adjustedConsumptionAmount: toNumber(entry?.adjustedConsumptionAmount), + createdBy: entry?.createdBy || "", + createdDate: entry?.createdDate || "", + updatedBy: entry?.updatedBy || "", + updatedDate: entry?.updatedDate || "", + })), + customerId: cleanString(workload?.customerId), + customerName: cleanString(workload?.customerName), + actions: extractArray(workload?.actions, ["actions"]).map((action) => ({ + owner: cleanString(action?.owner), + role: cleanString(action?.role), + name: cleanString(action?.name), + team: normalizeTeamValue(action?.team), + startDate: action?.startDate || "", + endDate: action?.endDate || "", + complexity: cleanString(action?.complexity), + })), + serviceRequests: extractArray(workload?.serviceRequests, [ + "serviceRequests", + ]).map((serviceRequest) => ({ + srNumber: cleanString(serviceRequest?.srNumber), + status: cleanString(serviceRequest?.status), + team: normalizeTeamValue(serviceRequest?.team), + })), + hasSR: Boolean(workload?.hasSR), + hasAction: Boolean(workload?.hasAction), + }; + + return normalized; + } + + function openCacheDatabase() { + return new Promise((resolve, reject) => { + if (!window.indexedDB) { + reject(new Error("IndexedDB is not available in this browser context.")); + return; + } + + const request = window.indexedDB.open(CACHE.dbName, CACHE.dbVersion); + + request.onupgradeneeded = () => { + const database = request.result; + + if (!database.objectStoreNames.contains(CACHE.storeName)) { + database.createObjectStore(CACHE.storeName, { keyPath: "key" }); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error || new Error("Unable to open IndexedDB cache.")); + }; + }); + } + + async function withCacheStore(mode, callback) { + const database = await openCacheDatabase(); + + return new Promise((resolve, reject) => { + const transaction = database.transaction(CACHE.storeName, mode); + const store = transaction.objectStore(CACHE.storeName); + + transaction.oncomplete = () => { + database.close(); + }; + + transaction.onerror = () => { + const error = + transaction.error || new Error("IndexedDB transaction failed."); + database.close(); + reject(error); + }; + + transaction.onabort = () => { + const error = + transaction.error || new Error("IndexedDB transaction was aborted."); + database.close(); + reject(error); + }; + + callback(store, resolve, reject); + }); + } + + async function readCachedDataset() { + const record = await withCacheStore("readonly", (store, resolve, reject) => { + const request = store.get(CACHE.datasetKey); + + request.onsuccess = () => { + const payload = + request.result?.exportPayload || + request.result?.payload || + request.result?.dataset?.exportPayload || + request.result?.dataset || + null; + + resolve(payload); + }; + + request.onerror = () => { + reject(request.error || new Error("Unable to read the cached dataset.")); + }; + }); + + return record ? normalizeCachedExportPayload(record) : null; + } + + async function writeCachedDataset(exportPayload) { + const normalizedPayload = normalizeCachedExportPayload(exportPayload); + + await withCacheStore("readwrite", (store, resolve, reject) => { + const request = store.put({ + key: CACHE.datasetKey, + updatedAt: new Date().toISOString(), + exportPayload: normalizedPayload, + }); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(request.error || new Error("Unable to write the cached dataset.")); + }; + }); + } + + function buildSummary(exportPayload) { + const customers = exportPayload.customers || []; + const allWorkloads = customers.flatMap((customer) => customer.workloads || []); + const eligibleWorkloads = allWorkloads + .filter((workload) => + ALLOWED_STAGE_TYPES.includes(workload.opportunityForecastTypeGroup) + ) + .sort((left, right) => right.adjustedACR - left.adjustedACR); + const totalEligibleAcr = eligibleWorkloads.reduce((sum, workload) => { + return sum + workload.adjustedACR; + }, 0); + const stageIndicators = ALLOWED_STAGE_TYPES.map((label) => { + const items = eligibleWorkloads.filter( + (workload) => workload.opportunityForecastTypeGroup === label + ); + + return { + label, + count: items.length, + totalAcr: items.reduce((sum, workload) => sum + workload.adjustedACR, 0), + }; + }); + const srStatus = { + trueCount: eligibleWorkloads.filter((workload) => workload.hasSR).length, + falseCount: eligibleWorkloads.filter((workload) => !workload.hasSR).length, + }; + const actionStatus = { + trueCount: eligibleWorkloads.filter((workload) => workload.hasAction).length, + falseCount: eligibleWorkloads.filter((workload) => !workload.hasAction).length, + }; + const pendingServiceRequests = exportPayload.pendingServiceRequests || []; + const srHours = { + betweenZeroAndFour: pendingServiceRequests.filter((serviceRequest) => { + const hours = toNumber(serviceRequest.totalHoursWorked); + return hours >= 0 && hours <= 4; + }), + aboveFour: pendingServiceRequests.filter((serviceRequest) => { + return toNumber(serviceRequest.totalHoursWorked) > 4; + }), + }; + + return { + generatedAtLabel: formatDateTime(exportPayload.generatedAt), + totalCustomers: customers.length, + totalWorkloads: allWorkloads.length, + allWorkloads, + eligibleWorkloads, + totalEligibleAcr, + stageIndicators, + srStatus, + actionStatus, + pendingServiceRequests, + srHours, + }; + } + + function buildWorkloadStartCalendar(workloads, year, month) { + const today = new Date(); + const monthStart = new Date(year, month, 1); + const calendarStart = new Date(monthStart); + calendarStart.setDate(monthStart.getDate() - monthStart.getDay()); + const workloadMap = new Map(); + + for (const workload of workloads || []) { + const dateKey = getLocalDateKey(workload.consumptionStartDate); + + if (!dateKey) { + continue; + } + + const bucket = workloadMap.get(dateKey) || []; + bucket.push(workload); + workloadMap.set(dateKey, bucket); + } + + const days = Array.from({ length: 42 }, (_, index) => { + const date = new Date(calendarStart); + date.setDate(calendarStart.getDate() + index); + const dateKey = getLocalDateKey(date); + const workloadsForDay = workloadMap.get(dateKey) || []; + + return { + dateKey, + dayNumber: date.getDate(), + isWeekStart: index % 7 === 0, + operationalWeekLabel: formatOperationalWeekLabel( + getOperationalWeekInfo(getCalendarRowWeekDate(date)) + ), + isCurrentMonth: date.getMonth() === month, + isToday: dateKey === getLocalDateKey(today), + workloads: workloadsForDay, + }; + }); + + return { + monthLabel: new Intl.DateTimeFormat("en-US", { + month: "long", + year: "numeric", + }).format(monthStart), + days, + totalVisibleWorkloads: days.reduce( + (sum, day) => sum + day.workloads.length, + 0 + ), + }; + } + + function openDetailModal(scope, value) { + if (!appState.dataset) { + return; + } + + const eligibleWorkloads = appState.dataset.summary.eligibleWorkloads; + let items = []; + let label = ""; + let title = ""; + + if (scope === "stage") { + items = eligibleWorkloads.filter( + (workload) => workload.opportunityForecastTypeGroup === value + ); + label = `Opportunity type: ${value}`; + title = `${value} workloads`; + } else if (scope === "sr") { + const flag = value === "true"; + items = eligibleWorkloads.filter((workload) => workload.hasSR === flag); + label = "Status de SRs"; + title = flag ? "Workloads com SR" : "Workloads sem SR"; + } else if (scope === "action") { + const flag = value === "true"; + items = eligibleWorkloads.filter((workload) => workload.hasAction === flag); + label = "Status de Consumption Plan/action"; + title = flag ? "Workloads com action" : "Workloads sem action"; + } else if (scope === "sr-hours") { + const pendingServiceRequests = appState.dataset.summary.pendingServiceRequests || []; + const isAboveFour = value === "above-4"; + items = pendingServiceRequests.filter((serviceRequest) => { + const hours = toNumber(serviceRequest.totalHoursWorked); + return isAboveFour ? hours > 4 : hours >= 0 && hours <= 4; + }); + items.sort((left, right) => { + return toNumber(left.totalHoursWorked) - toNumber(right.totalHoursWorked); + }); + label = "SRs por horas reportadas"; + title = isAboveFour ? "SRs acima de 4h" : "SRs entre 0 e 4h"; + } else if (scope === "calendar-day") { + const allWorkloads = appState.dataset.summary.allWorkloads || []; + items = allWorkloads.filter((workload) => { + return getLocalDateKey(workload.consumptionStartDate) === value; + }); + label = "Workload start calendar"; + title = `Workloads starting ${formatDate(value) || value}`; + } + + appState.detailModal = { + label, + title, + items, + totalAcr: + scope === "sr-hours" + ? 0 + : items.reduce((sum, workload) => sum + workload.adjustedACR, 0), + sortKey: DETAIL_SORT_KEYS.adjustedACR, + sortDirection: "desc", + scope, + value, + type: scope === "sr-hours" ? "serviceRequests" : "workloads", + }; + + renderModal(); + } + + function openActionFormModal(workloadId) { + appState.actionFormModal = { + workloadId, + errorMessage: "", + isSubmitting: false, + }; + renderModal(); + } + + function openRampComparisonModal(workloadId) { + const workload = findWorkloadById(workloadId); + + if (!workload) { + return; + } + + appState.rampComparisonModal = { + workload, + rows: buildRampForecastComparisonRows([workload]), + }; + renderModal(); + } + + function openForecastUpdateConfirmModal() { + if (!appState.rampComparisonModal || !appState.dataset) { + return; + } + + const payload = createForecastUpdatePayload( + appState.rampComparisonModal.rows || [] + ); + + appState.forecastUpdateConfirmModal = { + workloadId: appState.rampComparisonModal.workload.workloadId, + payload, + isSubmitting: false, + errorMessage: "", + }; + renderModal(); + } + + async function submitForecastUpdate() { + const modal = appState.forecastUpdateConfirmModal; + + if (!modal || modal.isSubmitting) { + return; + } + + appState.forecastUpdateConfirmModal = { + ...modal, + isSubmitting: true, + errorMessage: "", + }; + renderModal(); + + try { + const workloadId = cleanString(modal.workloadId); + const url = `${API_ENDPOINTS.workloadServiceRequests}/${encodeURIComponent( + workloadId + )}/forecast`; + const wwbAuthorization = + appState.sessionAuthHeaders?.["wwb-provider-authorization"] || + appState.sessionAuthHeaders?.["wwb-provider-Authorization"] || + ""; + const headers = { + accept: "*/*", + "content-type": "application/json", + }; + + if (wwbAuthorization) { + headers["wwb-provider-Authorization"] = wwbAuthorization; + } + + await fetchJson(url, { + method: "PATCH", + forcePageBridge: true, + transport: "xhr", + headers, + body: JSON.stringify(modal.payload), + }); + + applyForecastUpdateToDataset(workloadId, modal.payload); + const updatedWorkload = findWorkloadById(workloadId); + + appState.forecastUpdateConfirmModal = null; + + if (updatedWorkload) { + appState.rampComparisonModal = { + workload: updatedWorkload, + rows: buildRampForecastComparisonRows([updatedWorkload]), + }; + } + + renderModal(); + } catch (error) { + appState.forecastUpdateConfirmModal = { + ...modal, + isSubmitting: false, + errorMessage: getErrorMessage(error), + }; + renderModal(); + } + } + + function createForecastUpdatePayload(rows) { + const userEmail = appState.dataset?.user?.userEmail || ""; + + return (rows || []) + .filter((row) => row.year && row.month) + .map((row) => ({ + year: toNumber(row.year), + month: toNumber(row.month), + amount: toNumber(row.calculatedValue), + updatedBy: userEmail, + })); + } + + async function openTimeEntriesDrawer(srNumber) { + const normalizedSrNumber = cleanString(srNumber); + + if (!normalizedSrNumber) { + return; + } + + appState.timeEntriesDrawer = { + srNumber: normalizedSrNumber, + status: "loading", + errorMessage: "", + payload: null, + items: [], + }; + renderModal(); + + try { + const response = await fetchTimeEntriesSummary(normalizedSrNumber); + + appState.timeEntriesDrawer = { + srNumber: normalizedSrNumber, + status: "ready", + errorMessage: "", + payload: response.payload, + items: normalizeTimeEntriesSummary(response.payload), + }; + } catch (error) { + appState.timeEntriesDrawer = { + srNumber: normalizedSrNumber, + status: "error", + errorMessage: getErrorMessage(error), + payload: null, + items: [], + }; + } + + renderModal(); + } + + async function fetchTimeEntriesSummary(srNumber) { + const url = `${COMCIP_REQUEST.timeEntriesSummaryUrl}?srNumber=${encodeURIComponent( + srNumber + )}`; + const response = await sendComcipGetMessage(url); + + if (!response?.ok) { + const payloadMessage = + typeof response?.payload === "string" + ? cleanString(response.payload) + : firstNonEmptyString([ + response?.payload?.message, + response?.payload?.error, + response?.payload?.title, + response?.payload?.detail, + ]); + + throw new Error( + payloadMessage || + response?.error || + `COMCIP request failed (${response?.status || 0}).` + ); + } + + return response; + } + + function closeDetailModal() { + if (appState.detailModal?.scope === "calendar-day" && appState.detailModal.value) { + appState.pendingFocusSelector = getCalendarDayFocusSelector( + appState.detailModal.value + ); + } + + appState.detailModal = null; + appState.timeEntriesDrawer = null; + appState.rampComparisonModal = null; + appState.forecastUpdateConfirmModal = null; + } + + function shiftCalendarMonth(delta) { + const nextDate = new Date(appState.calendarYear, appState.calendarMonth + delta, 1); + appState.calendarYear = nextDate.getFullYear(); + appState.calendarMonth = nextDate.getMonth(); + renderModal(); + } + + function goToCurrentCalendarMonth() { + const today = new Date(); + appState.calendarYear = today.getFullYear(); + appState.calendarMonth = today.getMonth(); + renderModal(); + } + + async function submitActionForm(form) { + if (!appState.dataset || !appState.actionFormModal) { + return; + } + + if (!form.reportValidity()) { + return; + } + + const formData = new FormData(form); + const parentId = cleanString(formData.get("parentId")); + const ownerEmail = cleanString(formData.get("ownerEmail")); + const startDate = toIsoDateString(formData.get("startDate")); + const endDate = toIsoDateString(formData.get("endDate")); + const payload = { + parentType: "WORKLOAD", + parentId, + action: { + ownerEmail, + role: "CLOUD_ARCHITECT", + name: "SUCCESS_PLAN_CONSUM_RVW", + team: cleanString(formData.get("team")), + tags: "", + startDate, + endDate, + status: "COMPLETE", + complexity: cleanString(formData.get("complexity")), + notes: [], + objective: "", + createdBy: cleanString(formData.get("createdBy")), + updatedBy: cleanString(formData.get("updatedBy")), + }, + }; + + try { + appState.actionFormModal = { + ...appState.actionFormModal, + isSubmitting: true, + errorMessage: "", + }; + renderModal(); + + await fetchJson(API_ENDPOINTS.workloadActions, { + method: "POST", + headers: { + "content-type": "application/json; charset=UTF-8", + }, + body: JSON.stringify(payload), + }); + + await applyCreatedActionToDataset(payload); + appState.actionFormModal = null; + renderModal(); + } catch (error) { + appState.actionFormModal = { + ...appState.actionFormModal, + isSubmitting: false, + errorMessage: getErrorMessage(error), + }; + renderModal(); + } + } + + async function applyCreatedActionToDataset(payload) { + if (!appState.dataset) { + return; + } + + const updatedCustomers = (appState.dataset.customers || []).map((customer) => ({ + ...customer, + workloads: (customer.workloads || []).map((workload) => { + if (workload.workloadId !== payload.parentId) { + return workload; + } + + return { + ...workload, + hasAction: true, + actions: [ + ...(workload.actions || []), + { + owner: payload.action.ownerEmail, + role: payload.action.role, + name: payload.action.name, + team: payload.action.team, + startDate: payload.action.startDate, + endDate: payload.action.endDate, + complexity: payload.action.complexity, + }, + ], + }; + }), + })); + const exportPayload = createExportPayload( + appState.dataset.user, + updatedCustomers, + appState.dataset.exportPayload?.pendingServiceRequests || [] + ); + const nextDataset = createDatasetSnapshot(exportPayload); + const previousDetailModal = appState.detailModal + ? { + scope: appState.detailModal.scope, + value: appState.detailModal.value, + sortKey: appState.detailModal.sortKey, + sortDirection: appState.detailModal.sortDirection, + } + : null; + + appState.dataset = nextDataset; + await writeCachedDataset(exportPayload); + + if (previousDetailModal?.scope) { + restoreDetailModal(previousDetailModal); + } + } + + function restoreDetailModal(detailState) { + const eligibleWorkloads = appState.dataset?.summary?.eligibleWorkloads || []; + let items = []; + let label = ""; + let title = ""; + + if (detailState.scope === "stage") { + items = eligibleWorkloads.filter( + (workload) => workload.opportunityForecastTypeGroup === detailState.value + ); + label = `Opportunity type: ${detailState.value}`; + title = `${detailState.value} workloads`; + } else if (detailState.scope === "sr") { + const flag = detailState.value === "true"; + items = eligibleWorkloads.filter((workload) => workload.hasSR === flag); + label = "Status de SRs"; + title = flag ? "Workloads com SR" : "Workloads sem SR"; + } else if (detailState.scope === "action") { + const flag = detailState.value === "true"; + items = eligibleWorkloads.filter((workload) => workload.hasAction === flag); + label = "Status de Consumption Plan/action"; + title = flag ? "Workloads com action" : "Workloads sem action"; + } else if (detailState.scope === "calendar-day") { + const allWorkloads = appState.dataset?.summary?.allWorkloads || []; + items = allWorkloads.filter((workload) => { + return getLocalDateKey(workload.consumptionStartDate) === detailState.value; + }); + label = "Workload start calendar"; + title = `Workloads starting ${formatDate(detailState.value) || detailState.value}`; + } + + appState.detailModal = { + label, + title, + items, + totalAcr: items.reduce((sum, workload) => sum + workload.adjustedACR, 0), + sortKey: detailState.sortKey || DETAIL_SORT_KEYS.adjustedACR, + sortDirection: detailState.sortDirection || "desc", + scope: detailState.scope, + value: detailState.value, + type: "workloads", + }; + } + + function toggleDetailSort(sortKey) { + if (!appState.detailModal) { + return; + } + + const nextDirection = + appState.detailModal.sortKey === sortKey && + appState.detailModal.sortDirection === "asc" + ? "desc" + : "asc"; + + appState.detailModal = { + ...appState.detailModal, + sortKey, + sortDirection: nextDirection, + }; + + renderModal(); + } + + function getSortedDetailItems() { + if (!appState.detailModal) { + return []; + } + + return sortWorkloads( + appState.detailModal.items, + appState.detailModal.sortKey, + appState.detailModal.sortDirection + ); + } + + function sortWorkloads(items, sortKey, sortDirection = "asc") { + const factor = sortDirection === "desc" ? -1 : 1; + + return [...items].sort((left, right) => { + const comparison = compareWorkloadValues( + getSortableWorkloadValue(left, sortKey), + getSortableWorkloadValue(right, sortKey) + ); + + if (comparison !== 0) { + return comparison * factor; + } + + return compareWorkloadValues( + getSortableWorkloadValue(left, DETAIL_SORT_KEYS.workload), + getSortableWorkloadValue(right, DETAIL_SORT_KEYS.workload) + ); + }); + } + + function getSortableWorkloadValue(workload, sortKey) { + switch (sortKey) { + case DETAIL_SORT_KEYS.opportunityNumber: + return cleanString(workload.opportunityNumber); + case DETAIL_SORT_KEYS.customerName: + return cleanString(workload.customerName); + case DETAIL_SORT_KEYS.workload: + return `${cleanString(workload.name)}|${cleanString(workload.description)}`; + case DETAIL_SORT_KEYS.rampMonths: + return toNumber(workload.rampMonths); + case DETAIL_SORT_KEYS.adjustedACR: + return toNumber(workload.adjustedACR); + case DETAIL_SORT_KEYS.opportunityForecastTypeGroup: + return cleanString(workload.opportunityForecastTypeGroup); + case DETAIL_SORT_KEYS.hasSR: + return Boolean(workload.hasSR); + case DETAIL_SORT_KEYS.hasAction: + return Boolean(workload.hasAction); + default: + return cleanString(workload.name); + } + } + + function compareWorkloadValues(left, right) { + if (typeof left === "number" || typeof right === "number") { + return toNumber(left) - toNumber(right); + } + + if (typeof left === "boolean" || typeof right === "boolean") { + return Number(Boolean(left)) - Number(Boolean(right)); + } + + return String(left || "").localeCompare(String(right || ""), undefined, { + numeric: true, + sensitivity: "base", + }); + } + + function downloadFile(filename, content, contentType) { + const blob = new Blob([content], { type: contentType }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + window.setTimeout(() => { + URL.revokeObjectURL(url); + }, 1000); + } + + function createExportHtml(dataset) { + const json = JSON.stringify(dataset.exportPayload); + + return ` + + + + + Arch Panel Dashboard + + + +
+
+ + +`; + } + + function toggleTheme() { + appState.theme = appState.theme === "dark" ? "light" : "dark"; + setStoredTheme(appState.theme); + renderModal(); + } + + function toggleSidebar() { + appState.sidebarCollapsed = !appState.sidebarCollapsed; + setStoredSidebarCollapsed(appState.sidebarCollapsed); + renderModal(); + } + + function applyDialogTheme(overlay) { + const dialog = overlay?.querySelector(`#${IDS.dialog}`); + + if (!dialog) { + return; + } + + dialog.setAttribute("data-theme", appState.theme); + } + + function ensureStyles() { + if (document.getElementById(IDS.style)) { + return; + } + + const style = document.createElement("style"); + style.id = IDS.style; + style.textContent = ` + html.${CLASSES.modalOpen}, + body.${CLASSES.modalOpen} { + overflow: hidden !important; + } + + .arch-panel-extension-entry { + --arch-panel-bg: rgba(255, 255, 255, 0.12); + --arch-panel-bg-hover: rgba(255, 255, 255, 0.2); + --arch-panel-border: rgba(255, 255, 255, 0.26); + --arch-panel-fg: #ffffff; + --arch-panel-focus: rgba(255, 255, 255, 0.72); + --arch-panel-font: inherit; + --arch-workbench-header: #4b335d; + --arch-workbench-accent: #6c4d80; + display: inline-flex; + align-items: center; + margin-inline-end: 12px; + flex: 0 0 auto; + } + + .arch-panel-extension-button { + appearance: none; + border: 1px solid var(--arch-panel-border); + background: var(--arch-panel-bg); + color: var(--arch-panel-fg); + border-radius: 999px; + min-height: 36px; + padding: 0 14px; + display: inline-flex; + align-items: center; + justify-content: center; + font-family: var(--arch-panel-font); + font-size: 13px; + font-weight: 600; + line-height: 1; + letter-spacing: 0.01em; + cursor: pointer; + transition: + background-color 160ms ease, + border-color 160ms ease, + transform 160ms ease; + } + + .arch-panel-extension-button:hover { + background: var(--arch-panel-bg-hover); + } + + .arch-panel-extension-button:active { + transform: translateY(1px); + } + + .arch-panel-extension-button:focus-visible, + .arch-panel-extension-close:focus-visible, + .arch-panel-extension-theme-toggle:focus-visible, + .arch-panel-extension-sidebar-button:focus-visible, + .arch-panel-extension-utility-button:focus-visible, + .arch-panel-extension-summary-card-button:focus-visible, + .arch-panel-extension-sort-button:focus-visible, + .arch-panel-extension-indicator-card:focus-visible, + .arch-panel-extension-row-link:focus-visible { + outline: 2px solid var(--arch-panel-focus); + outline-offset: 2px; + } + + .arch-panel-extension-overlay { + position: fixed; + inset: 0; + z-index: 2147483647; + display: flex; + opacity: 0; + pointer-events: none; + background: rgba(13, 17, 24, 0.56); + backdrop-filter: blur(8px); + transition: opacity 160ms ease; + } + + .arch-panel-extension-overlay.${CLASSES.overlayOpen} { + opacity: 1; + pointer-events: auto; + } + + .arch-panel-extension-dialog { + width: 100vw; + height: 100vh; + display: grid; + grid-template-rows: auto 1fr; + --wb-app-bg: #f5f4f3; + --wb-panel: #ffffff; + --wb-panel-soft: #fafafa; + --wb-sidebar: #f3f2f1; + --wb-border: #d9d9d9; + --wb-border-strong: #c8c8c8; + --wb-row-divider: #e6e6e6; + --wb-divider: #ececec; + --wb-row-hover: #f9fbfc; + --wb-text: #1f1f1f; + --wb-text-secondary: #5f6670; + --wb-text-muted: #7b8189; + --wb-link: #006b9a; + --wb-info: #dceef7; + --wb-success: #3fa35c; + --wb-warning: #d94f2b; + --wb-shadow: 0 1px 2px rgba(31, 31, 31, 0.04); + background: var(--wb-app-bg); + color: var(--wb-text); + font-family: "Oracle Sans", "Segoe UI", Arial, sans-serif; + } + + .arch-panel-extension-dialog[data-theme="dark"] { + --wb-app-bg: #141b24; + --wb-panel: #1f2833; + --wb-panel-soft: #273240; + --wb-sidebar: #19222c; + --wb-border: #364150; + --wb-border-strong: #566171; + --wb-row-divider: #333d4b; + --wb-divider: #303a46; + --wb-row-hover: #273240; + --wb-text: #f4f7fb; + --wb-text-secondary: #c6d0da; + --wb-text-muted: #9ca8b5; + --wb-link: #84c7e3; + --wb-info: #233847; + --wb-success: #5ec280; + --wb-warning: #f0946c; + --wb-shadow: none; + } + + .arch-panel-extension-dialog * { + box-sizing: border-box; + } + + .arch-panel-extension-shell-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + min-height: 64px; + padding: 12px 24px; + background: var(--arch-workbench-header); + color: #ffffff; + } + + .arch-panel-extension-brand { + display: flex; + align-items: center; + gap: 14px; + } + + .arch-panel-extension-brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 40px; + height: 40px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.14); + color: #ffffff; + font-size: 14px; + font-weight: 700; + } + + .arch-panel-extension-brand-label { + margin: 0 0 2px; + color: rgba(255, 255, 255, 0.72); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .arch-panel-extension-title { + margin: 0; + color: #ffffff; + font-size: 24px; + line-height: 1.2; + font-weight: 600; + } + + .arch-panel-extension-shell-actions { + display: flex; + align-items: center; + gap: 12px; + } + + .arch-panel-extension-shell-note { + color: rgba(255, 255, 255, 0.76); + font-size: 12px; + } + + .arch-panel-extension-theme-toggle { + appearance: none; + min-height: 34px; + padding: 0 14px; + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 4px; + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + } + + .arch-panel-extension-close { + appearance: none; + min-height: 34px; + padding: 0 14px; + border: 1px solid var(--wb-border); + border-radius: 4px; + background: var(--wb-panel-soft); + color: var(--wb-text); + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + box-shadow: none; + } + + .arch-panel-extension-close:hover { + border-color: var(--wb-border-strong); + background: var(--wb-row-hover); + } + + .arch-panel-extension-shell-header .arch-panel-extension-close { + border-color: rgba(255, 255, 255, 0.24); + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + } + + .arch-panel-extension-shell-header .arch-panel-extension-close:hover { + border-color: rgba(255, 255, 255, 0.42); + background: rgba(255, 255, 255, 0.16); + } + + .arch-panel-extension-detail-dialog .arch-panel-extension-close { + border-color: var(--wb-border); + background: var(--wb-panel-soft); + color: var(--wb-text); + box-shadow: none; + } + + .arch-panel-extension-sidebar-button, + .arch-panel-extension-utility-button { + appearance: none; + min-height: 34px; + padding: 0 14px; + border: 1px solid var(--wb-border); + border-radius: 4px; + background: var(--wb-panel); + color: var(--wb-text); + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + } + + .arch-panel-extension-utility-button.is-primary { + border-color: #0b5cab; + background: #0b5cab; + color: #ffffff; + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.16); + box-shadow: 0 8px 18px rgba(11, 92, 171, 0.24); + } + + .arch-panel-extension-utility-button.is-primary:hover { + border-color: #084a8a; + background: #084a8a; + } + + .arch-panel-extension-utility-button.is-primary:disabled { + border-color: var(--wb-border); + background: var(--wb-panel-soft); + color: var(--wb-text-muted); + box-shadow: none; + cursor: not-allowed; + } + + .arch-panel-extension-shell-body { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + min-height: 0; + position: relative; + } + + .arch-panel-extension-shell-body.is-sidebar-collapsed { + grid-template-columns: minmax(0, 1fr); + } + + .arch-panel-extension-sidebar { + background: var(--wb-sidebar); + border-right: 1px solid var(--wb-border); + padding: 20px 14px; + } + + .arch-panel-extension-sidebar.is-collapsed { + display: none; + } + + .arch-panel-extension-sidebar-group + .arch-panel-extension-sidebar-group { + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid var(--wb-border); + } + + .arch-panel-extension-sidebar-label, + .arch-panel-extension-page-eyebrow, + .arch-panel-extension-panel-label, + .arch-panel-extension-state-eyebrow { + margin: 0 0 8px; + color: var(--wb-text-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .arch-panel-extension-sidebar-data { + display: grid; + gap: 6px; + color: var(--wb-text-secondary); + font-size: 12px; + } + + .arch-panel-extension-sidebar-data strong { + color: var(--wb-text); + font-size: 13px; + } + + .arch-panel-extension-sidebar-button { + width: 100%; + justify-content: center; + display: inline-flex; + align-items: center; + border-color: var(--wb-border); + background: var(--wb-panel); + color: var(--wb-text); + } + + .arch-panel-extension-sidebar-button + .arch-panel-extension-sidebar-button { + margin-top: 8px; + } + + .arch-panel-extension-main-shell { + display: flex; + flex-direction: column; + min-width: 0; + overflow: auto; + overflow-anchor: none; + background: var(--wb-app-bg); + padding: 20px 24px 24px; + } + + .arch-panel-extension-main-shell:has(.arch-panel-extension-state-card) { + grid-column: 1 / -1; + align-items: stretch; + justify-content: center; + min-height: min(720px, calc(100vh - 120px)); + } + + .arch-panel-extension-page-hero { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 18px; + } + + .arch-panel-extension-page-title, + .arch-panel-extension-state-title { + margin: 0; + color: var(--wb-text); + font-size: 34px; + line-height: 1.15; + font-weight: 700; + } + + .arch-panel-extension-page-subtitle, + .arch-panel-extension-state-copy { + margin: 10px 0 0; + max-width: 760px; + color: var(--wb-text-secondary); + font-size: 13px; + line-height: 1.55; + } + + .arch-panel-extension-hero-aside { + display: grid; + gap: 8px; + justify-items: end; + } + + .arch-panel-extension-hero-pill { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--wb-border); + border-radius: 14px; + background: var(--wb-info); + color: var(--wb-text); + font-size: 12px; + font-weight: 600; + } + + .arch-panel-extension-hero-note { + color: var(--wb-text-secondary); + font-size: 12px; + } + + .arch-panel-extension-summary-row, + .arch-panel-extension-indicator-grid, + .arch-panel-extension-status-grid, + .arch-panel-extension-content-grid, + .arch-panel-extension-boolean-grid { + display: grid; + gap: 14px; + } + + .arch-panel-extension-summary-row { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-bottom: 16px; + } + + .arch-panel-extension-indicator-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-bottom: 16px; + } + + .arch-panel-extension-status-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-bottom: 16px; + } + + .arch-panel-extension-content-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .arch-panel-extension-summary-card, + .arch-panel-extension-panel, + .arch-panel-extension-indicator-card, + .arch-panel-extension-state-card { + border: 1px solid var(--wb-border); + border-radius: 8px; + background: var(--wb-panel); + box-shadow: var(--wb-shadow); + } + + .arch-panel-extension-summary-card, + .arch-panel-extension-state-card, + .arch-panel-extension-indicator-card { + padding: 16px; + } + + .arch-panel-extension-state-card { + width: 100%; + min-height: 320px; + display: flex; + flex-direction: column; + justify-content: center; + padding: 34px 40px; + } + + .arch-panel-extension-loading-progress { + display: grid; + gap: 10px; + width: 100%; + margin-top: 30px; + } + + .arch-panel-extension-loading-progress-meta { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 16px; + color: var(--wb-text-secondary); + font-size: 12px; + font-weight: 700; + } + + .arch-panel-extension-loading-progress-track { + position: relative; + height: 30px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--wb-accent) 50%, var(--wb-border-strong)); + border-radius: 8px; + background: color-mix(in srgb, var(--wb-panel-soft) 68%, #000000); + } + + .arch-panel-extension-loading-progress-fill { + position: absolute; + inset: 0 auto 0 0; + width: 0; + min-width: 0; + border-radius: inherit; + background: linear-gradient(90deg, #1f7a4d, #2fbf71); + transition: width 520ms ease; + } + + .arch-panel-extension-loading-progress-value { + position: absolute; + inset: 0 auto 0 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + justify-content: center; + min-width: 54px; + margin: 4px 0; + padding: 0 8px; + border: 1px solid rgba(255, 255, 255, 0.26); + border-radius: 999px; + background: rgba(7, 12, 18, 0.72); + color: #ffffff; + font-size: 12px; + font-weight: 800; + line-height: 1; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28); + text-shadow: none; + pointer-events: none; + } + + .arch-panel-extension-summary-card-button { + appearance: none; + width: 100%; + text-align: left; + color: inherit; + font: inherit; + line-height: inherit; + cursor: pointer; + } + + .arch-panel-extension-panel { + padding: 16px; + } + + .arch-panel-extension-summary-label, + .arch-panel-extension-summary-detail, + .arch-panel-extension-indicator-label, + .arch-panel-extension-indicator-subtitle, + .arch-panel-extension-indicator-total, + .arch-panel-extension-insight-detail, + .arch-panel-extension-stage-detail { + margin: 0; + } + + .arch-panel-extension-summary-label, + .arch-panel-extension-indicator-label { + color: var(--wb-text-secondary); + font-size: 12px; + } + + .arch-panel-extension-summary-value, + .arch-panel-extension-indicator-value { + margin: 8px 0 4px; + color: var(--wb-text); + font-size: 26px; + line-height: 1.2; + font-weight: 700; + } + + .arch-panel-extension-summary-detail, + .arch-panel-extension-indicator-subtitle, + .arch-panel-extension-indicator-total { + color: var(--wb-text-muted); + font-size: 11px; + } + + .arch-panel-extension-indicator-card { + appearance: none; + text-align: left; + color: inherit; + cursor: pointer; + } + + .arch-panel-extension-boolean-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 12px; + } + + .arch-panel-extension-progress { + display: grid; + gap: 8px; + margin-bottom: 12px; + } + + .arch-panel-extension-progress-track { + display: flex; + width: 100%; + height: 10px; + overflow: hidden; + border-radius: 999px; + background: var(--wb-panel-soft); + border: 1px solid var(--wb-border); + } + + .arch-panel-extension-progress-segment { + height: 100%; + } + + .arch-panel-extension-progress-segment.is-success { + background: linear-gradient(90deg, #7fd29a, #3fa35c); + } + + .arch-panel-extension-progress-segment.is-danger { + background: linear-gradient(90deg, #f2a28d, #d94f2b); + } + + .arch-panel-extension-progress-labels { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--wb-text-secondary); + font-size: 11px; + font-weight: 600; + } + + .arch-panel-extension-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; + } + + .arch-panel-extension-panel-title { + margin: 0; + color: var(--wb-text); + font-size: 16px; + font-weight: 600; + } + + .arch-panel-extension-panel-meta { + display: flex; + align-items: center; + gap: 8px; + color: var(--wb-text-muted); + font-size: 12px; + } + + .arch-panel-extension-operations-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + } + + .arch-panel-extension-sr-hour-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + align-content: start; + } + + .arch-panel-extension-calendar-card { + min-width: 0; + } + + .arch-panel-extension-calendar-actions { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--wb-text-muted); + font-size: 12px; + } + + .arch-panel-extension-calendar-nav { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--wb-border); + border-radius: 4px; + background: var(--wb-panel-soft); + color: var(--wb-text); + font-size: 18px; + line-height: 1; + cursor: pointer; + } + + .arch-panel-extension-calendar-nav:hover { + border-color: var(--wb-accent); + color: var(--wb-accent); + } + + .arch-panel-extension-calendar-today { + appearance: none; + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--wb-border); + border-radius: 4px; + background: var(--wb-panel-soft); + color: var(--wb-text); + font: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; + } + + .arch-panel-extension-calendar-today:hover { + border-color: var(--wb-accent); + color: var(--wb-accent); + } + + .arch-panel-extension-calendar-weekdays, + .arch-panel-extension-calendar-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + } + + .arch-panel-extension-calendar-weekdays { + gap: 6px; + margin-bottom: 6px; + color: var(--wb-text-muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + } + + .arch-panel-extension-calendar-weekdays span { + padding: 0 6px; + } + + .arch-panel-extension-calendar-grid { + gap: 6px; + } + + .arch-panel-extension-calendar-day { + appearance: none; + position: relative; + display: block; + width: 100%; + text-align: left; + min-height: 74px; + overflow: hidden; + border: 1px solid var(--wb-border); + border-radius: 6px; + background: var(--wb-panel-soft); + padding: 7px; + font: inherit; + cursor: pointer; + } + + .arch-panel-extension-calendar-day:disabled { + cursor: default; + } + + .arch-panel-extension-calendar-day.has-workload:hover { + border-color: #f26c4f; + background: rgba(217, 79, 43, 0.22); + } + + .arch-panel-extension-calendar-day.is-muted { + opacity: 0.42; + } + + .arch-panel-extension-calendar-day.is-today { + border-color: var(--wb-accent); + box-shadow: inset 0 0 0 1px var(--wb-accent); + } + + .arch-panel-extension-calendar-day.has-workload { + border-color: rgba(217, 79, 43, 0.82); + background: rgba(217, 79, 43, 0.14); + } + + .arch-panel-extension-calendar-date { + color: var(--wb-text); + font-size: 12px; + font-weight: 800; + } + + .arch-panel-extension-calendar-week-number { + position: absolute; + right: 7px; + bottom: 6px; + color: var(--wb-text-muted); + font-size: 9px; + font-weight: 800; + letter-spacing: 0.04em; + opacity: 0.72; + pointer-events: none; + } + + .arch-panel-extension-calendar-workload { + display: block; + margin-top: 8px; + color: var(--wb-text); + font-size: 11px; + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + + .arch-panel-extension-calendar-count { + position: absolute; + top: 6px; + right: 6px; + display: flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + background: #d94f2b; + color: #ffffff; + font-size: 11px; + font-weight: 800; + } + + .arch-panel-extension-empty-state { + border: 1px dashed var(--wb-border); + border-radius: 8px; + padding: 16px; + color: var(--wb-text-muted); + font-size: 13px; + } + + .arch-panel-extension-panel-dot { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--wb-border-strong); + } + + .arch-panel-extension-table-wrap { + overflow: auto; + border: 1px solid var(--wb-border); + border-radius: 6px; + } + + .arch-panel-extension-table-wrap.is-detail { + max-height: calc(100vh - 220px); + } + + .arch-panel-extension-table-wrap.is-sr-list { + overflow: auto; + } + + .arch-panel-extension-table-wrap.is-ramp-pivot { + max-height: min(460px, calc(100vh - 260px)); + } + + .arch-panel-extension-table { + width: 100%; + border-collapse: collapse; + background: var(--wb-panel); + } + + .arch-panel-extension-table th, + .arch-panel-extension-table td { + padding: 12px 14px; + border-bottom: 1px solid var(--wb-row-divider); + font-size: 12px; + text-align: left; + white-space: nowrap; + } + + .arch-panel-extension-table th { + background: var(--wb-panel-soft); + color: var(--wb-text-secondary); + font-weight: 600; + } + + .arch-panel-extension-table td { + color: var(--wb-text); + } + + .arch-panel-extension-ramp-pivot-table th:first-child { + min-width: 150px; + position: sticky; + left: 0; + z-index: 1; + } + + .arch-panel-extension-ramp-pivot-table tbody th { + background: var(--wb-panel-soft); + color: var(--wb-text-secondary); + font-weight: 700; + } + + .arch-panel-extension-ramp-pivot-table .is-delta-different { + color: #d94f2b; + font-weight: 800; + } + + .arch-panel-extension-sr-table td { + white-space: normal; + vertical-align: top; + } + + .arch-panel-extension-sr-main { + width: 78%; + } + + .arch-panel-extension-sr-side { + width: 22%; + text-align: right; + } + + .arch-panel-extension-sr-line { + display: flex; + flex-wrap: wrap; + gap: 10px 14px; + margin-top: 6px; + } + + .arch-panel-extension-sr-line.is-head { + margin-top: 0; + } + + .arch-panel-extension-sr-customer { + color: var(--wb-text); + font-size: 13px; + } + + .arch-panel-extension-sr-moment { + color: var(--wb-text); + font-size: 18px; + font-weight: 700; + } + + .arch-panel-extension-sr-exact, + .arch-panel-extension-sr-hours { + margin-top: 6px; + color: var(--wb-text-secondary); + font-size: 12px; + } + + .arch-panel-extension-table-empty { + padding: 18px; + border: 1px dashed var(--wb-border); + border-radius: 8px; + color: var(--wb-text-secondary); + font-size: 13px; + } + + .arch-panel-extension-table-wrap.is-main .arch-panel-extension-table { + table-layout: fixed; + } + + .arch-panel-extension-table-wrap.is-main + .arch-panel-extension-col-opportunity { + width: 14%; + } + + .arch-panel-extension-table-wrap.is-main + .arch-panel-extension-col-customer { + width: 16%; + } + + .arch-panel-extension-table-wrap.is-main + .arch-panel-extension-col-workload { + width: auto; + } + + .arch-panel-extension-table-wrap.is-main + .arch-panel-extension-col-acr { + width: 11%; + } + + .arch-panel-extension-table-wrap.is-main + .arch-panel-extension-col-type { + width: 11%; + } + + .arch-panel-extension-table-wrap.is-main + .arch-panel-extension-col-flag { + width: 9%; + } + + .arch-panel-extension-table-wrap.is-main + .arch-panel-extension-table + th:nth-child(3), + .arch-panel-extension-table-wrap.is-main + .arch-panel-extension-table + td:nth-child(3) { + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; + } + + .arch-panel-extension-table-wrap.is-main + .arch-panel-extension-table + td:nth-child(3) + .arch-panel-extension-row-link { + display: block; + } + + .arch-panel-extension-table-wrap.is-main + .arch-panel-extension-table + th:nth-child(2), + .arch-panel-extension-table-wrap.is-main + .arch-panel-extension-table + td:nth-child(2) { + white-space: normal; + overflow-wrap: anywhere; + } + + .arch-panel-extension-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 54px; + min-height: 24px; + padding: 0 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + } + + .arch-panel-extension-badge.is-success { + background: #dcfce7; + border: 1px solid #86efac; + color: #166534; + } + + .arch-panel-extension-badge.is-danger { + background: #fee2e2; + border: 1px solid #fca5a5; + color: #991b1b; + } + + .arch-panel-extension-action-cell { + display: inline-flex; + align-items: center; + gap: 8px; + } + + .arch-panel-extension-add-action-button { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border: 1px solid var(--wb-border); + border-radius: 999px; + background: var(--wb-panel-soft); + color: var(--wb-text); + font: inherit; + font-size: 18px; + line-height: 1; + cursor: pointer; + } + + .arch-panel-extension-table tbody tr:hover { + background: var(--wb-row-hover); + } + + .arch-panel-extension-table th.is-numeric, + .arch-panel-extension-table td.is-numeric { + text-align: right; + } + + .arch-panel-extension-row-link { + appearance: none; + padding: 0; + border: 0; + background: transparent; + color: var(--wb-link); + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + text-decoration: none; + } + + .arch-panel-extension-workload-link-cell { + display: inline-flex; + align-items: center; + gap: 7px; + } + + .arch-panel-extension-ramp-alert-button { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: 1px solid #d94f2b; + border-radius: 999px; + background: #fee2e2; + color: #991b1b; + font: inherit; + font-size: 12px; + font-weight: 900; + line-height: 1; + cursor: pointer; + } + + .arch-panel-extension-ramp-alert-button:hover { + background: #fecaca; + } + + .arch-panel-extension-insight-list, + .arch-panel-extension-stage-list, + .arch-panel-extension-side-column { + display: grid; + gap: 14px; + } + + .arch-panel-extension-insight { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding-top: 12px; + border-top: 1px solid var(--wb-divider); + } + + .arch-panel-extension-insight:first-child { + padding-top: 0; + border-top: 0; + } + + .arch-panel-extension-insight-label, + .arch-panel-extension-stage-label { + margin: 0 0 4px; + color: var(--wb-text); + font-size: 12px; + font-weight: 600; + } + + .arch-panel-extension-insight-detail, + .arch-panel-extension-stage-detail { + color: var(--wb-text-muted); + font-size: 11px; + line-height: 1.45; + } + + .arch-panel-extension-insight-value { + margin: 0; + color: var(--wb-text); + font-size: 16px; + font-weight: 700; + text-align: right; + } + + .arch-panel-extension-stage-row { + display: grid; + gap: 8px; + } + + .arch-panel-extension-stage-copy { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + } + + .arch-panel-extension-stage-values { + display: grid; + justify-items: end; + gap: 2px; + color: var(--wb-text-secondary); + font-size: 12px; + } + + .arch-panel-extension-stage-detail-row { + color: var(--wb-text-secondary); + font-size: 12px; + } + + .arch-panel-extension-stage-bar { + position: relative; + height: 8px; + border-radius: 999px; + overflow: hidden; + background: color-mix(in srgb, var(--wb-text-secondary) 14%, transparent); + } + + .arch-panel-extension-stage-bar span { + position: absolute; + inset: 0 auto 0 0; + border-radius: inherit; + background: linear-gradient(90deg, var(--wb-accent), color-mix(in srgb, var(--wb-accent) 52%, #ffffff)); + } + + .arch-panel-extension-detail-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: rgba(10, 14, 20, 0.68); + backdrop-filter: blur(6px); + } + + .arch-panel-extension-time-drawer { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 2; + width: min(560px, calc(100vw - 32px)); + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px; + border-left: 1px solid var(--wb-border); + background: var(--wb-panel); + box-shadow: -18px 0 42px rgba(0, 0, 0, 0.28); + overflow: auto; + animation: arch-panel-extension-slide-in 180ms ease-out; + } + + .arch-panel-extension-time-drawer-head { + position: sticky; + top: -18px; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 0 0 12px; + border-bottom: 1px solid var(--wb-border); + background: var(--wb-panel); + } + + .arch-panel-extension-time-drawer-close { + border-color: var(--wb-border); + background: var(--wb-panel-soft); + color: var(--wb-text); + box-shadow: none; + } + + .arch-panel-extension-time-drawer-close:hover { + border-color: var(--wb-border-strong); + background: var(--wb-row-hover); + } + + .arch-panel-extension-time-drawer-actions { + position: sticky; + bottom: -18px; + display: flex; + justify-content: flex-end; + margin: auto -18px -18px; + padding: 12px 18px 18px; + border-top: 1px solid var(--wb-border); + background: linear-gradient(180deg, rgba(23, 30, 39, 0), var(--wb-panel) 30%); + } + + .arch-panel-extension-time-drawer-meta { + display: flex; + gap: 8px; + color: var(--wb-text-muted); + font-size: 12px; + } + + .arch-panel-extension-time-drawer-state, + .arch-panel-extension-time-drawer-json { + border: 1px solid var(--wb-border); + border-radius: 8px; + background: var(--wb-panel-soft); + padding: 14px; + } + + .arch-panel-extension-time-drawer-state.is-error { + border-color: rgba(217, 79, 43, 0.42); + } + + .arch-panel-extension-time-entry-group { + display: grid; + gap: 10px; + } + + .arch-panel-extension-time-entry-group + .arch-panel-extension-time-entry-group { + padding-top: 12px; + border-top: 1px solid var(--wb-border); + } + + .arch-panel-extension-time-entry-group-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + } + + .arch-panel-extension-time-entry-group-head h4 { + margin: 3px 0 0; + color: var(--wb-text); + font-size: 16px; + line-height: 1.25; + } + + .arch-panel-extension-time-entry-group-head span { + color: var(--wb-text-muted); + font-size: 12px; + white-space: nowrap; + } + + .arch-panel-extension-time-entry-table-wrap { + overflow: auto; + border: 1px solid var(--wb-border); + border-radius: 8px; + } + + .arch-panel-extension-time-entry-table { + width: 100%; + border-collapse: collapse; + background: var(--wb-panel); + } + + .arch-panel-extension-time-entry-table th, + .arch-panel-extension-time-entry-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--wb-row-divider); + color: var(--wb-text); + font-size: 12px; + text-align: left; + vertical-align: top; + } + + .arch-panel-extension-time-entry-table th { + background: var(--wb-panel-soft); + color: var(--wb-text-secondary); + font-weight: 700; + } + + .arch-panel-extension-time-entry-table tfoot th { + border-bottom: 0; + color: var(--wb-text); + } + + .arch-panel-extension-time-entry-table .is-numeric { + text-align: right; + white-space: nowrap; + } + + .arch-panel-extension-time-drawer-json summary { + color: var(--wb-link); + font-size: 12px; + font-weight: 700; + cursor: pointer; + } + + .arch-panel-extension-time-drawer-json pre { + max-height: 320px; + margin: 12px 0 0; + overflow: auto; + color: var(--wb-text); + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; + } + + @keyframes arch-panel-extension-slide-in { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } + } + + .arch-panel-extension-detail-dialog { + width: min(1520px, calc(100vw - 32px)); + max-height: calc(100vh - 88px); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--wb-border); + border-radius: 10px; + background: var(--wb-panel); + box-shadow: var(--wb-shadow); + padding: 18px; + } + + .arch-panel-extension-ramp-dialog { + width: min(1320px, calc(100vw - 48px)); + max-height: calc(100vh - 120px); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--wb-border); + border-radius: 10px; + background: var(--wb-panel); + box-shadow: var(--wb-shadow); + padding: 18px; + } + + .arch-panel-extension-action-dialog { + width: min(980px, calc(100vw - 32px)); + max-height: calc(100vh - 88px); + overflow: auto; + border: 1px solid var(--wb-border); + border-radius: 10px; + background: var(--wb-panel); + box-shadow: var(--wb-shadow); + padding: 18px; + } + + .arch-panel-extension-confirm-dialog { + width: min(560px, calc(100vw - 32px)); + max-height: calc(100vh - 120px); + overflow: auto; + border: 1px solid var(--wb-border); + border-radius: 10px; + background: var(--wb-panel); + box-shadow: var(--wb-shadow); + padding: 18px; + } + + .arch-panel-extension-detail-title { + margin: 0; + color: var(--wb-text); + font-size: 18px; + font-weight: 700; + } + + .arch-panel-extension-detail-subtitle { + margin: 0 0 14px; + color: var(--wb-text-muted); + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; + } + + .arch-panel-extension-detail-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 8px; + } + + .arch-panel-extension-detail-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; + color: var(--wb-text-muted); + font-size: 12px; + } + + .arch-panel-extension-detail-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + position: sticky; + bottom: 0; + margin-top: 16px; + padding-top: 12px; + background: linear-gradient(180deg, rgba(23, 30, 39, 0), var(--wb-panel) 35%); + } + + .arch-panel-extension-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin-top: 18px; + } + + .arch-panel-extension-form-field { + display: grid; + gap: 6px; + } + + .arch-panel-extension-form-field span { + color: var(--wb-text-secondary); + font-size: 12px; + font-weight: 600; + } + + .arch-panel-extension-form-field input, + .arch-panel-extension-form-field select { + width: 100%; + min-height: 38px; + padding: 0 12px; + border: 1px solid var(--wb-border); + border-radius: 6px; + background: var(--wb-panel-soft); + color: var(--wb-text); + font: inherit; + } + + .arch-panel-extension-form-error { + margin: 12px 0 0; + color: #d94f2b; + font-size: 12px; + font-weight: 600; + } + + .arch-panel-extension-sort-button { + appearance: none; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-weight: 600; + cursor: pointer; + } + + .arch-panel-extension-sort-button.is-numeric { + justify-content: flex-end; + width: 100%; + } + + .arch-panel-extension-table-wrap.is-detail { + flex: 1 1 auto; + min-height: 0; + max-height: calc(100vh - 270px); + overflow: auto; + } + + .arch-panel-extension-table-wrap.is-detail .arch-panel-extension-table th { + position: sticky; + top: 0; + z-index: 3; + } + + .arch-panel-extension-table-wrap.is-detail .arch-panel-extension-table thead { + position: relative; + z-index: 3; + } + + .arch-panel-extension-table-wrap.is-detail .arch-panel-extension-table { + border-collapse: separate; + border-spacing: 0; + } + + .arch-panel-extension-table-wrap.is-detail .arch-panel-extension-table { + table-layout: fixed; + } + + .arch-panel-extension-table-wrap.is-detail .arch-panel-extension-table th, + .arch-panel-extension-table-wrap.is-detail .arch-panel-extension-table td { + white-space: normal; + vertical-align: top; + overflow-wrap: anywhere; + } + + .arch-panel-extension-state-actions { + display: flex; + gap: 12px; + margin-top: 18px; + } + + .arch-panel-extension-state-card.is-error { + border-color: rgba(217, 79, 43, 0.42); + } + + @media (max-width: 1280px) { + .arch-panel-extension-summary-row, + .arch-panel-extension-indicator-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (max-width: 1100px) { + .arch-panel-extension-shell-body, + .arch-panel-extension-content-grid, + .arch-panel-extension-status-grid, + .arch-panel-extension-operations-grid { + grid-template-columns: 1fr; + } + + .arch-panel-extension-sidebar { + display: none; + } + } + + @media (max-width: 780px) { + .arch-panel-extension-shell-header, + .arch-panel-extension-page-hero { + flex-direction: column; + align-items: flex-start; + } + + .arch-panel-extension-summary-row, + .arch-panel-extension-indicator-grid, + .arch-panel-extension-boolean-grid, + .arch-panel-extension-sr-hour-grid { + grid-template-columns: 1fr; + } + + .arch-panel-extension-state-card { + min-height: 260px; + padding: 26px 24px; + } + } + + @media (max-width: 980px) { + .arch-panel-extension-action-dialog { + width: min(720px, calc(100vw - 24px)); + } + + .arch-panel-extension-form-grid { + grid-template-columns: 1fr; + } + } + `; + + document.head.appendChild(style); + } + + function applyTheme(header, functionsContainer, anchor, mount, overlay) { + const headerStyle = window.getComputedStyle(header); + const functionsStyle = window.getComputedStyle(functionsContainer); + const sourceBackground = resolveBackgroundColor(header, headerStyle); + const sourceForeground = resolveForegroundColor( + header, + functionsContainer, + anchor, + headerStyle, + functionsStyle + ); + const workbenchHeader = mix(sourceBackground, { r: 75, g: 51, b: 93, a: 1 }, 0.52); + const workbenchAccent = mix(workbenchHeader, { r: 108, g: 77, b: 128, a: 1 }, 0.38); + const focusColor = mix(sourceForeground, workbenchHeader, 0.28); + const themeTargets = [mount, overlay].filter(Boolean); + + for (const target of themeTargets) { + target.style.setProperty( + "--arch-panel-bg", + toColor(mix(sourceBackground, sourceForeground, 0.12)) + ); + target.style.setProperty( + "--arch-panel-bg-hover", + toColor(mix(sourceBackground, sourceForeground, 0.22)) + ); + target.style.setProperty( + "--arch-panel-border", + toColor(mix(sourceBackground, sourceForeground, 0.32)) + ); + target.style.setProperty("--arch-panel-fg", toColor(sourceForeground, 1)); + target.style.setProperty("--arch-panel-focus", toColor(focusColor, 0.94)); + target.style.setProperty( + "--arch-panel-font", + functionsStyle.fontFamily || headerStyle.fontFamily || "inherit" + ); + target.style.setProperty("--arch-workbench-header", toColor(workbenchHeader, 1)); + target.style.setProperty("--arch-workbench-accent", toColor(workbenchAccent, 1)); + } + } + + function resolveForegroundColor( + header, + functionsContainer, + anchor, + headerStyle, + functionsStyle + ) { + const salesPlanningElement = findTextElement(header, "Sales Planning"); + const salesPlanningColor = salesPlanningElement + ? parseColor(window.getComputedStyle(salesPlanningElement).color) + : null; + const functionsItemColor = findVisibleChildColor(functionsContainer); + const userColor = anchor + ? parseColor(window.getComputedStyle(anchor).color) + : null; + + return ( + userColor || + functionsItemColor || + parseColor(functionsStyle.color) || + salesPlanningColor || + parseColor(headerStyle.color) || { + r: 255, + g: 255, + b: 255, + a: 1, + } + ); + } + + function resolveBackgroundColor(element, style) { + const directBackground = parseColor(style.backgroundColor); + + if (directBackground && directBackground.a > 0) { + return directBackground; + } + + let current = element.parentElement; + + while (current) { + const currentBackground = parseColor( + window.getComputedStyle(current).backgroundColor + ); + + if (currentBackground && currentBackground.a > 0) { + return currentBackground; + } + + current = current.parentElement; + } + + return { r: 75, g: 51, b: 93, a: 1 }; + } + + function parseColor(value) { + if (!value || value === "transparent") { + return null; + } + + const rgbMatch = value.match( + /rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})(?:[\s,/]+([.\d]+))?\s*\)/i + ); + + if (rgbMatch) { + return { + r: Number.parseInt(rgbMatch[1], 10), + g: Number.parseInt(rgbMatch[2], 10), + b: Number.parseInt(rgbMatch[3], 10), + a: rgbMatch[4] === undefined ? 1 : Number.parseFloat(rgbMatch[4]), + }; + } + + const hexMatch = value.match(/^#([\da-f]{3,8})$/i); + + if (!hexMatch) { + return null; + } + + const hexValue = hexMatch[1]; + + if (hexValue.length === 3 || hexValue.length === 4) { + const [r, g, b, a = "f"] = hexValue.split(""); + + return { + r: Number.parseInt(r + r, 16), + g: Number.parseInt(g + g, 16), + b: Number.parseInt(b + b, 16), + a: Number.parseInt(a + a, 16) / 255, + }; + } + + if (hexValue.length === 6 || hexValue.length === 8) { + return { + r: Number.parseInt(hexValue.slice(0, 2), 16), + g: Number.parseInt(hexValue.slice(2, 4), 16), + b: Number.parseInt(hexValue.slice(4, 6), 16), + a: + hexValue.length === 8 + ? Number.parseInt(hexValue.slice(6, 8), 16) / 255 + : 1, + }; + } + + return null; + } + + function mix(base, tint, amount) { + const ratio = clamp(amount, 0, 1); + + return { + r: Math.round(base.r + (tint.r - base.r) * ratio), + g: Math.round(base.g + (tint.g - base.g) * ratio), + b: Math.round(base.b + (tint.b - base.b) * ratio), + a: base.a + (tint.a - base.a) * ratio, + }; + } + + function toColor(color, alphaOverride) { + const alpha = alphaOverride ?? color.a ?? 1; + + return `rgba(${color.r}, ${color.g}, ${color.b}, ${clamp(alpha, 0, 1)})`; + } + + function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); + } + + function findUserAnchor(container) { + const selector = [ + `button[label="${USER_BUTTON_LABEL}"]`, + `[role="button"][label="${USER_BUTTON_LABEL}"]`, + `oj-button[label="${USER_BUTTON_LABEL}"]`, + `button[aria-label="${USER_BUTTON_LABEL}"]`, + `[role="button"][aria-label="${USER_BUTTON_LABEL}"]`, + `[label="${USER_BUTTON_LABEL}"]`, + `[aria-label="${USER_BUTTON_LABEL}"]`, + ].join(", "); + + const directMatch = container.querySelector(selector); + + if (directMatch && isVisible(directMatch)) { + return directMatch; + } + + const fallbackMatch = Array.from(container.querySelectorAll("*")).find( + (element) => { + if (!isVisible(element) || element.closest(`#${IDS.mount}`)) { + return false; + } + + return ( + element.getAttribute("label") === USER_BUTTON_LABEL || + element.getAttribute("aria-label") === USER_BUTTON_LABEL + ); + } + ); + + return fallbackMatch || null; + } + + function findTextElement(container, expectedText) { + const normalizedExpected = normalizeText(expectedText); + const elements = Array.from(container.querySelectorAll("*")); + + return ( + elements.find((element) => { + const text = normalizeText(element.textContent || ""); + + return text && text.includes(normalizedExpected); + }) || null + ); + } + + function resolveInsertionTarget(container, anchor, mount) { + const directAnchor = anchor ? getDirectChild(anchor, container) : null; + const fallbackTarget = findFirstInsertionTarget(container, mount); + const candidate = directAnchor || fallbackTarget; + + if (!candidate) { + return null; + } + + if (candidate === mount) { + return findNextValidSibling(container, mount); + } + + return candidate.parentElement === container ? candidate : null; + } + + function getDirectChild(element, container) { + let current = element; + + while (current && current.parentElement !== container) { + current = current.parentElement; + } + + return current && current.parentElement === container ? current : null; + } + + function findFirstInsertionTarget(container, mount) { + return Array.from(container.children).find((child) => child !== mount) || null; + } + + function findNextValidSibling(container, mount) { + let sibling = mount.nextElementSibling; + + while (sibling) { + if (sibling.parentElement === container) { + return sibling; + } + + sibling = sibling.nextElementSibling; + } + + return null; + } + + function findVisibleChildColor(container) { + const visibleChild = Array.from(container.querySelectorAll("*")).find( + (element) => isVisible(element) && !element.closest(`#${IDS.mount}`) + ); + + return visibleChild + ? parseColor(window.getComputedStyle(visibleChild).color) + : null; + } + + function extractArray(value, candidateKeys = []) { + if (Array.isArray(value)) { + return value; + } + + if (!value || typeof value !== "object") { + return []; + } + + for (const key of candidateKeys) { + if (Array.isArray(value[key])) { + return value[key]; + } + } + + for (const nestedValue of Object.values(value)) { + if (Array.isArray(nestedValue)) { + return nestedValue; + } + + if (nestedValue && typeof nestedValue === "object") { + const nestedArray = extractArray(nestedValue, candidateKeys); + + if (nestedArray.length > 0) { + return nestedArray; + } + } + } + + return []; + } + + async function mapWithConcurrency(items, concurrency, mapper) { + const results = new Array(items.length); + let nextIndex = 0; + + async function worker() { + while (nextIndex < items.length) { + const currentIndex = nextIndex; + nextIndex += 1; + results[currentIndex] = await mapper(items[currentIndex], currentIndex); + } + } + + const workerCount = Math.min(concurrency, items.length || 1); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + + return results; + } + + function getStoredTheme() { + try { + const storedTheme = window.localStorage.getItem(STORAGE_KEYS.theme); + + return storedTheme === "dark" ? "dark" : "light"; + } catch { + return "light"; + } + } + + function setStoredTheme(theme) { + try { + window.localStorage.setItem(STORAGE_KEYS.theme, theme); + } catch { + // Ignore storage issues and keep the in-memory theme. + } + } + + function getStoredSidebarCollapsed() { + try { + return window.localStorage.getItem(STORAGE_KEYS.sidebarCollapsed) === "true"; + } catch { + return false; + } + } + + function setStoredSidebarCollapsed(value) { + try { + window.localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, String(Boolean(value))); + } catch { + // Ignore storage issues and keep the in-memory state. + } + } + + function normalizeText(value) { + return String(value || "").replace(/\s+/g, " ").trim(); + } + + function cleanString(value) { + return normalizeText(value); + } + + function firstNonEmptyString(values) { + for (const value of values) { + const normalized = cleanString(value); + + if (normalized) { + return normalized; + } + } + + return ""; + } + + function extractAuthHeaders(tokenPayload) { + if (!tokenPayload) { + return {}; + } + + if (typeof tokenPayload === "string") { + return { + authorization: `Bearer ${tokenPayload}`, + }; + } + + const tokenSources = [ + tokenPayload, + tokenPayload?.token, + tokenPayload?.tokens, + tokenPayload?.oauth, + tokenPayload?.oauthToken, + tokenPayload?.authentication, + tokenPayload?.auth, + ].filter(Boolean); + const headers = {}; + const explicitHeaderSources = [ + tokenPayload?.headers, + tokenPayload?.requestHeaders, + tokenPayload?.authHeaders, + ].filter((value) => value && typeof value === "object"); + + for (const explicitHeaderSource of explicitHeaderSources) { + for (const [key, value] of Object.entries(explicitHeaderSource)) { + const normalizedKey = cleanString(key).toLowerCase(); + const normalizedValue = cleanString(value); + + if (!normalizedKey || !normalizedValue) { + continue; + } + + headers[normalizedKey] = normalizedValue; + } + } + + for (const tokenSource of tokenSources) { + const tokenType = + firstNonEmptyString([ + tokenSource?.tokenType, + tokenSource?.type, + tokenSource?.token_type, + ]) || "Bearer"; + const accessToken = firstNonEmptyString([ + tokenSource?.accessToken, + tokenSource?.access_token, + tokenSource?.token, + tokenSource?.jwt, + ]); + const idToken = firstNonEmptyString([ + tokenSource?.idToken, + tokenSource?.id_token, + ]); + + if (accessToken && !headers.authorization) { + headers.authorization = `${tokenType} ${accessToken}`; + } + + if (idToken && !headers["x-id-token"]) { + headers["x-id-token"] = idToken; + } + } + + return headers; + } + + function normalizeString(value) { + return normalizeText(value).toUpperCase(); + } + + function toBridgeRequestUrl(url) { + const normalized = cleanString(url); + + if (!normalized) { + return normalized; + } + + try { + const parsedUrl = new URL(normalized, window.location.origin); + + if (parsedUrl.origin === window.location.origin) { + return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`; + } + + return parsedUrl.toString(); + } catch { + return normalized; + } + } + + function toNumber(value) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + const numeric = Number.parseFloat(String(value ?? "").replace(/,/g, "")); + return Number.isFinite(numeric) ? numeric : 0; + } + + function toIsoDateString(value) { + const normalized = cleanString(value); + + if (!normalized) { + return ""; + } + + const date = new Date(`${normalized}T12:00:00.000Z`); + + return Number.isNaN(date.getTime()) ? normalized : date.toISOString(); + } + + function isVisible(element) { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + + return ( + style.display !== "none" && + style.visibility !== "hidden" && + Number.parseFloat(style.opacity || "1") > 0 && + rect.width > 0 && + rect.height > 0 + ); + } + + function formatWholeNumber(value) { + return new Intl.NumberFormat("en-US", { + maximumFractionDigits: 0, + }).format(value); + } + + function formatCurrency(value) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }).format(value || 0); + } + + function formatHours(value) { + return new Intl.NumberFormat("pt-BR", { + maximumFractionDigits: 2, + }).format(toNumber(value)); + } + + function formatWeekNumber(value) { + return String(toNumber(value)).padStart(2, "0"); + } + + function formatOperationalWeekLabel(weekInfo) { + if (!weekInfo) { + return ""; + } + + return `FY${formatWeekNumber(weekInfo.fiscalYear % 100)}-Q${ + weekInfo.quarter + }-W${formatWeekNumber(weekInfo.weekNumber)}`; + } + + function formatPercent(value) { + return new Intl.NumberFormat("en-US", { + maximumFractionDigits: 1, + }).format(value || 0) + "%"; + } + + function formatProgressPercent(value) { + return new Intl.NumberFormat("en-US", { + maximumFractionDigits: 0, + }).format(value || 0) + "%"; + } + + function formatRelativeMoment(value) { + if (!value) { + return "-"; + } + + const target = new Date(value); + + if (Number.isNaN(target.getTime())) { + return String(value); + } + + let remaining = Math.max(Date.now() - target.getTime(), 0); + const day = 24 * 60 * 60 * 1000; + const hour = 60 * 60 * 1000; + const minute = 60 * 1000; + const second = 1000; + const days = Math.floor(remaining / day); + remaining -= days * day; + const hours = Math.floor(remaining / hour); + remaining -= hours * hour; + const minutes = Math.floor(remaining / minute); + remaining -= minutes * minute; + const seconds = Math.floor(remaining / second); + + return `${days}d ${hours}h ${minutes}m ${seconds}s`; + } + + function formatDate(value) { + if (!value) { + return ""; + } + + const date = parseDatePreservingDateOnly(value); + + if (Number.isNaN(date.getTime())) { + return String(value); + } + + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + }).format(date); + } + + function formatDateTime(value) { + if (!value) { + return ""; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return String(value); + } + + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); + } + + function getLocalDateKey(value) { + if (!value) { + return ""; + } + + const date = parseDatePreservingDateOnly(value); + + if (Number.isNaN(date.getTime())) { + return ""; + } + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; + } + + function getOperationalWeekInfo(value) { + const date = parseDatePreservingDateOnly(value); + + if (Number.isNaN(date.getTime())) { + return null; + } + + const operationalYearStart = new Date(date.getFullYear(), 5, 1); + + if (date < operationalYearStart) { + operationalYearStart.setFullYear(operationalYearStart.getFullYear() - 1); + } + + const normalizedDate = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() + ); + const normalizedStart = new Date( + operationalYearStart.getFullYear(), + operationalYearStart.getMonth(), + operationalYearStart.getDate() + ); + const daysSinceStart = Math.floor( + (normalizedDate.getTime() - normalizedStart.getTime()) / 86400000 + ); + const monthsSinceStart = + (normalizedDate.getFullYear() - normalizedStart.getFullYear()) * 12 + + normalizedDate.getMonth() - + normalizedStart.getMonth(); + + return { + fiscalYear: operationalYearStart.getFullYear() + 1, + quarter: Math.floor(monthsSinceStart / 3) + 1, + weekNumber: Math.floor(daysSinceStart / 7) + 1, + }; + } + + function getCalendarRowWeekDate(rowStartDate) { + const weekDate = new Date(rowStartDate); + + // The calendar renders rows from Sunday, but ISO week numbering starts on Monday. + if (weekDate.getDay() === 0) { + weekDate.setDate(weekDate.getDate() + 1); + } + + return weekDate; + } + + function normalizeTimeEntriesSummary(payload) { + if (Array.isArray(payload)) { + return payload; + } + + const items = extractArray(payload, [ + "items", + "data", + "results", + "content", + "entries", + "timeEntries", + "summary", + "summaries", + ]); + + if (items.length) { + return items; + } + + if (payload && typeof payload === "object") { + return [payload]; + } + + return []; + } + + function groupTimeEntriesByResourceName(items) { + const groups = new Map(); + + for (const item of items) { + const resourceName = + getTimeEntryResourceName(item) || "Sem resourceName"; + const group = groups.get(resourceName) || { + resourceName, + items: [], + }; + + group.items.push(item); + groups.set(resourceName, group); + } + + return Array.from(groups.values()).sort((left, right) => { + return left.resourceName.localeCompare(right.resourceName); + }); + } + + function getTimeEntryResourceName(item) { + const directValue = getFieldValue(item, [ + "resourceName", + "ResourceName", + "resource_name", + "resource.name", + "Resource.Name", + "resource.displayName", + "Resource.DisplayName", + "resource.display_name", + "employeeName", + "EmployeeName", + "personName", + "PersonName", + ]); + + if (directValue) { + return cleanString(directValue); + } + + const flattenedMatch = flattenDisplayRecord(item).find(([key, value]) => { + return key.toLowerCase().endsWith("resourcename") && cleanString(value); + }); + + return flattenedMatch ? cleanString(flattenedMatch[1]) : ""; + } + + function getTimeEntryActivity(item) { + return cleanString( + getTimeEntryField(item, [ + "activityCode", + "ActivityCode", + "activity.code", + "Activity.Code", + ]) + ); + } + + function getTimeEntryTaskType(item) { + return cleanString( + getTimeEntryField(item, [ + "TaskType", + "taskType", + "task_type", + "task.type", + "Task.Type", + ]) + ); + } + + function getTimeEntryTimeSpent(item) { + return toNumber( + getTimeEntryField(item, [ + "timeSpent", + "TimeSpent", + "time_spent", + "time.spent", + "Time.Spent", + ]) + ); + } + + function getTimeEntryField(item, keys) { + const directValue = getFieldValue(item, keys); + + if (directValue !== undefined && directValue !== null && directValue !== "") { + return directValue; + } + + const normalizedKeys = keys.map((key) => key.toLowerCase().replace(/[^a-z0-9]/g, "")); + const flattenedMatch = flattenDisplayRecord(item).find(([key, value]) => { + const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, ""); + + return normalizedKeys.includes(normalizedKey) && cleanString(value); + }); + + return flattenedMatch ? flattenedMatch[1] : ""; + } + + function flattenDisplayRecord(value, prefix = "", output = []) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + if (prefix) { + output.push([prefix, value]); + } + + return output; + } + + for (const [key, nestedValue] of Object.entries(value)) { + const nextKey = prefix ? `${prefix}.${key}` : key; + + if ( + nestedValue && + typeof nestedValue === "object" && + !Array.isArray(nestedValue) + ) { + flattenDisplayRecord(nestedValue, nextKey, output); + } else { + output.push([nextKey, nestedValue]); + } + } + + return output; + } + + function formatDisplayValue(value) { + if (value === null || value === undefined || value === "") { + return "-"; + } + + if (Array.isArray(value) || typeof value === "object") { + return JSON.stringify(value); + } + + return String(value); + } + + function parseDatePreservingDateOnly(value) { + if (value instanceof Date) { + return value; + } + + if (typeof value === "string") { + const dateOnlyMatch = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})$/); + + if (dateOnlyMatch) { + return new Date( + Number(dateOnlyMatch[1]), + Number(dateOnlyMatch[2]) - 1, + Number(dateOnlyMatch[3]) + ); + } + } + + return new Date(value); + } + + function getErrorMessage(error) { + if (error instanceof Error) { + return error.message; + } + + return "Unexpected error while fetching live data."; + } + + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function lockScroll() { + document.documentElement.classList.add(CLASSES.modalOpen); + document.body.classList.add(CLASSES.modalOpen); + } + + function unlockScroll() { + document.documentElement.classList.remove(CLASSES.modalOpen); + document.body.classList.remove(CLASSES.modalOpen); + } + + function removeModal() { + const overlay = document.getElementById(IDS.overlay); + + window.clearTimeout(closeTimer); + restoreFocusTarget = null; + + if (overlay) { + overlay.remove(); + } + } + + function handleGlobalKeydown(event) { + if (event.key !== "Escape") { + return; + } + + if (appState.actionFormModal) { + appState.actionFormModal = null; + renderModal(); + return; + } + + if (appState.forecastUpdateConfirmModal) { + appState.forecastUpdateConfirmModal = null; + renderModal(); + return; + } + + if (appState.rampComparisonModal) { + appState.rampComparisonModal = null; + renderModal(); + return; + } + + if (appState.timeEntriesDrawer) { + appState.timeEntriesDrawer = null; + renderModal(); + return; + } + + if (appState.detailModal) { + closeDetailModal(); + renderModal(); + return; + } + + closeModal(); + } + + function handleBridgeMessage(event) { + if (event.source !== window) { + return; + } + + const message = event.data; + + if (!message || message.source !== BRIDGE.source) { + return; + } + + if (message.type === BRIDGE.readyType) { + return; + } + + if ( + message.type !== BRIDGE.resultType && + message.type !== BRIDGE.headersResultType + ) { + return; + } + + const pending = pendingBridgeRequests.get(message.requestId); + + if (!pending) { + return; + } + + pendingBridgeRequests.delete(message.requestId); + window.clearTimeout(pending.timeout); + pending.resolve( + message.type === BRIDGE.headersResultType ? message.headers || {} : message + ); + } + + function observePage() { + const observer = new MutationObserver(() => { + scheduleSync(); + }); + + observer.observe(document.documentElement, { + childList: true, + subtree: true, + }); + } + + function observeHistory() { + const { pushState, replaceState } = window.history; + + window.history.pushState = function pushStatePatched(...args) { + const result = pushState.apply(this, args); + scheduleSync(); + return result; + }; + + window.history.replaceState = function replaceStatePatched(...args) { + const result = replaceState.apply(this, args); + scheduleSync(); + return result; + }; + + window.addEventListener("popstate", scheduleSync); + window.addEventListener("hashchange", scheduleSync); + } + + window.addEventListener("message", handleBridgeMessage); + document.addEventListener("keydown", handleGlobalKeydown); + void ensurePageBridge(); + observePage(); + observeHistory(); + scheduleSync(); +})(); diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..6373d7a --- /dev/null +++ b/manifest.json @@ -0,0 +1,38 @@ +{ + "manifest_version": 3, + "name": "Arch Panel Injector", + "version": "1.0.0", + "description": "Insere o botao Arch panel no header do Oracle Workload Workbench.", + "permissions": [ + "cookies", + "scripting", + "tabs" + ], + "host_permissions": [ + "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html*" + ], + "js": [ + "content-script.js" + ], + "run_at": "document_start" + } + ], + "web_accessible_resources": [ + { + "resources": [ + "page-bridge.js" + ], + "matches": [ + "https://spa.oracle.com/*" + ] + } + ] +} diff --git a/page-bridge.js b/page-bridge.js new file mode 100644 index 0000000..8663ed6 --- /dev/null +++ b/page-bridge.js @@ -0,0 +1,471 @@ +(() => { + if (window.__archPanelPageBridgeInstalled) { + return; + } + + window.__archPanelPageBridgeInstalled = true; + + const MESSAGE_SOURCE = "arch-panel-extension"; + const API_PATH_FRAGMENT = "/oalcrm/web/api/"; + const EXTRA_CAPTURE_ORIGINS = new Set([ + "https://comcipapic-oalprod.integration.ocp.oraclecloud.com", + ]); + const SAFE_HEADER_BLOCKLIST = new Set([ + "accept-encoding", + "accept-language", + "connection", + "content-length", + "cookie", + "host", + "origin", + "referer", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", + "user-agent", + ]); + + const bridgeState = { + globalHeaders: new Map(), + scopedHeaders: new Map(), + }; + + const nativeFetch = window.fetch.bind(window); + const originalOpen = window.XMLHttpRequest.prototype.open; + const originalSetRequestHeader = window.XMLHttpRequest.prototype.setRequestHeader; + const originalSend = window.XMLHttpRequest.prototype.send; + + function isIntegrationOrigin(url) { + try { + return ( + new URL(url, window.location.origin).origin === + "https://comcipapic-oalprod.integration.ocp.oraclecloud.com" + ); + } catch { + return false; + } + } + + function normalizeUrl(input) { + try { + return new URL(input, window.location.origin).toString(); + } catch { + return ""; + } + } + + function shouldCapture(url) { + if (!url) { + return false; + } + + if (url.includes(API_PATH_FRAGMENT)) { + return true; + } + + try { + return EXTRA_CAPTURE_ORIGINS.has(new URL(url).origin); + } catch { + return false; + } + } + + function normalizeHeaders(input) { + const normalized = new Map(); + + if (!input) { + return normalized; + } + + const headers = new Headers(input); + + headers.forEach((value, key) => { + const lowerKey = key.toLowerCase(); + + if (SAFE_HEADER_BLOCKLIST.has(lowerKey)) { + return; + } + + if (lowerKey.startsWith("sec-")) { + return; + } + + normalized.set(lowerKey, value); + }); + + return normalized; + } + + function getScopeKeys(url) { + const normalizedUrl = normalizeUrl(url); + + if (!normalizedUrl) { + return []; + } + + try { + const parsedUrl = new URL(normalizedUrl); + const path = parsedUrl.pathname; + const keys = [`path:${path}`]; + const providerProxyMatch = path.match( + /\/provider-proxy\/([^/]+)\// + ); + const serviceMatch = path.match( + /\/provider-proxy\/[^/]+\/service\/([^/]+)\// + ); + + if (path.includes("/provider-proxy/")) { + keys.push("scope:provider-proxy"); + } + + if (path.includes("/identity-management/")) { + keys.push("scope:identity-management"); + } + + if (path.includes("/workbench-proxy/")) { + keys.push("scope:workbench-proxy"); + } + + if (path.includes("/elastic/")) { + keys.push("scope:elastic"); + } + + if (providerProxyMatch?.[1]) { + keys.push(`provider:${providerProxyMatch[1]}`); + } + + if (serviceMatch?.[1]) { + keys.push(`service:${serviceMatch[1]}`); + } + + keys.push("scope:api"); + + return keys; + } catch { + return []; + } + } + + function mergeIntoBucket(bucket, headers) { + headers.forEach((value, key) => { + bucket.set(key, value); + }); + } + + function isAuthorizationLikeHeader(key) { + const normalizedKey = String(key || "").toLowerCase(); + + return ( + normalizedKey === "authorization" || + normalizedKey.endsWith("-authorization") || + normalizedKey.includes("token") + ); + } + + function rememberHeaders(url, input) { + const headers = normalizeHeaders(input); + + if (headers.size === 0) { + return; + } + + headers.forEach((value, key) => { + if (!isAuthorizationLikeHeader(key)) { + bridgeState.globalHeaders.set(key, value); + } + }); + + for (const scopeKey of getScopeKeys(url)) { + const bucket = bridgeState.scopedHeaders.get(scopeKey) || new Map(); + mergeIntoBucket(bucket, headers); + bridgeState.scopedHeaders.set(scopeKey, bucket); + } + } + + function buildHeaders(url, input, body) { + const merged = new Headers(); + const activeScopeKeys = getScopeKeys(url); + + bridgeState.globalHeaders.forEach((value, key) => { + merged.set(key, value); + }); + + for (const scopeKey of activeScopeKeys) { + const bucket = bridgeState.scopedHeaders.get(scopeKey); + + if (!bucket) { + continue; + } + + bucket.forEach((value, key) => { + merged.set(key, value); + }); + } + + normalizeHeaders(input).forEach((value, key) => { + merged.set(key, value); + }); + + if (!merged.has("accept")) { + merged.set("accept", "application/json, text/plain, */*"); + } + + if (!merged.has("x-requested-with")) { + merged.set("x-requested-with", "XMLHttpRequest"); + } + + if (activeScopeKeys.includes("scope:provider-proxy")) { + const allowedProviderHeaders = new Set([ + "accept", + "authorization", + "content-type", + "spa-ts-authorization", + "wwb-provider-authorization", + "x-id-token", + "x-requested-with", + ]); + + for (const key of Array.from(merged.keys())) { + if (key.endsWith("-authorization") && !allowedProviderHeaders.has(key)) { + merged.delete(key); + } + } + + if (merged.has("wwb-provider-authorization")) { + merged.delete("authorization"); + } + } + + if (isIntegrationOrigin(url)) { + const allowedIntegrationHeaders = new Set([ + "accept", + "authorization", + "content-type", + "spa-ts-authorization", + "wwb-provider-authorization", + "x-id-token", + "x-requested-with", + ]); + + for (const key of Array.from(merged.keys())) { + if (!allowedIntegrationHeaders.has(key)) { + merged.delete(key); + } + } + + if (merged.has("wwb-provider-authorization")) { + merged.delete("authorization"); + } + } + + if (!body) { + merged.delete("content-type"); + } + + return merged; + } + + function buildHeaderSnapshot(options = {}) { + const snapshot = new Headers(); + const scopeUrl = options.url || window.location.href; + const merged = buildHeaders(scopeUrl, options.headers, options.body); + + merged.forEach((value, key) => { + snapshot.set(key, value); + }); + + if (options.includeAllAuth) { + bridgeState.scopedHeaders.forEach((bucket) => { + bucket.forEach((value, key) => { + if (isAuthorizationLikeHeader(key)) { + snapshot.set(key, value); + } + }); + }); + } + + return Object.fromEntries(snapshot.entries()); + } + + const trackedFetch = async function archPanelTrackedFetch(input, init) { + const url = normalizeUrl(typeof input === "string" ? input : input?.url); + + if (shouldCapture(url)) { + rememberHeaders(url, init?.headers || input?.headers); + } + + return nativeFetch(input, init); + }; + + window.fetch = trackedFetch; + + window.XMLHttpRequest.prototype.open = function archPanelTrackedOpen(method, url, ...rest) { + this.__archPanelTrackedUrl = normalizeUrl(url); + this.__archPanelTrackedHeaders = new Headers(); + return originalOpen.call(this, method, url, ...rest); + }; + + window.XMLHttpRequest.prototype.setRequestHeader = function archPanelTrackedSetRequestHeader(name, value) { + if (this.__archPanelTrackedHeaders) { + this.__archPanelTrackedHeaders.set(name, value); + } + + return originalSetRequestHeader.call(this, name, value); + }; + + window.XMLHttpRequest.prototype.send = function archPanelTrackedSend(body) { + if (shouldCapture(this.__archPanelTrackedUrl)) { + rememberHeaders(this.__archPanelTrackedUrl, this.__archPanelTrackedHeaders); + } + + return originalSend.call(this, body); + }; + + function requestViaOriginalXhr(url, options, headers) { + return new Promise((resolve, reject) => { + const xhr = new window.XMLHttpRequest(); + + originalOpen.call(xhr, options?.method || "GET", url, true); + xhr.withCredentials = true; + xhr.responseType = "text"; + xhr.timeout = 60000; + + headers.forEach((value, key) => { + originalSetRequestHeader.call(xhr, key, value); + }); + + xhr.onload = () => { + resolve({ + ok: xhr.status >= 200 && xhr.status < 300, + status: xhr.status, + text: xhr.responseText || "", + }); + }; + + xhr.onerror = () => { + reject(new Error("Failed to fetch")); + }; + + xhr.onabort = () => { + reject(new Error("Request aborted")); + }; + + xhr.ontimeout = () => { + reject(new Error(`Timed out while requesting ${url}`)); + }; + + originalSend.call(xhr, options?.body ?? null); + }); + } + + window.addEventListener("message", async (event) => { + if (event.source !== window) { + return; + } + + const message = event.data; + + if (!message || message.source !== MESSAGE_SOURCE) { + return; + } + + if (message.type === "ARCH_PANEL_HEADERS") { + window.postMessage( + { + source: MESSAGE_SOURCE, + type: "ARCH_PANEL_HEADERS_RESULT", + requestId: message.requestId, + headers: buildHeaderSnapshot(message.options), + }, + window.location.origin + ); + return; + } + + if (message.type !== "ARCH_PANEL_FETCH") { + return; + } + + try { + const mergedHeaders = buildHeaders( + message.url, + message.options?.headers, + message.options?.body + ); + const useXhr = message.options?.transport === "xhr"; + const response = useXhr + ? await requestViaOriginalXhr(message.url, message.options, mergedHeaders) + : await (async () => { + const activeFetch = + typeof window.fetch === "function" + ? window.fetch.bind(window) + : nativeFetch; + const fetchResponse = await activeFetch(message.url, { + method: message.options?.method || "GET", + credentials: "include", + headers: mergedHeaders, + body: message.options?.body, + }); + + return { + ok: fetchResponse.ok, + status: fetchResponse.status, + text: await fetchResponse.text(), + }; + })(); + + const rawText = response.text; + let payload; + + try { + payload = rawText ? JSON.parse(rawText) : null; + } catch { + payload = rawText; + } + + window.postMessage( + { + source: MESSAGE_SOURCE, + type: "ARCH_PANEL_FETCH_RESULT", + requestId: message.requestId, + ok: response.ok, + status: response.status, + debug: { + headerKeys: Array.from(mergedHeaders.keys()), + scopeKeys: getScopeKeys(message.url), + transport: useXhr ? "xhr" : "fetch", + }, + payload, + }, + window.location.origin + ); + } catch (error) { + window.postMessage( + { + source: MESSAGE_SOURCE, + type: "ARCH_PANEL_FETCH_RESULT", + requestId: message.requestId, + ok: false, + status: 0, + debug: { + scopeKeys: getScopeKeys(message.url), + transport: message.options?.transport || "fetch", + }, + error: error instanceof Error ? error.message : String(error), + }, + window.location.origin + ); + } + }); + + window.postMessage( + { + source: MESSAGE_SOURCE, + type: "ARCH_PANEL_BRIDGE_READY", + }, + window.location.origin + ); +})();