diff --git a/README.md b/README.md index c22a4b2..bae3af1 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,156 @@ -# Arch Panel Injector +# Arch Panel -Extensao Chrome Manifest V3 que adiciona o botao `Arch panel` ao header da pagina Oracle: +Extensao Chrome Manifest V3 que injeta o Arch Panel em paginas Oracle usadas pelo time de arquitetura, incluindo Workload Workbench e HCM FuseWelcome. -`https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=workload_workbench` +## Entry points -## O que ela faz +- `manifest.json`: fonte oficial da versao, permissoes e content scripts. +- `src/shared/config.js`: configuracao centralizada, constantes, IDs, endpoints e chaves de cache. +- `src/shared/utils.js`: helpers compartilhados de strings, numeros, datas, DOM, escaping e extração de payloads. +- `src/background/config.js`: configuracao especifica do service worker e URLs usadas pelos proxies de background. +- `src/content/styles.css`: estilos da UI injetados pelo Manifest V3. +- `src/content/shell-view.js`: shell visual injetado na pagina, incluindo tema, cores, alvo de insercao e scroll lock. +- `src/content/templates.js`: templates estaticos de DOM usados pelo content script. +- `src/content/hcm-page.js`: integracao com FuseWelcome, injecao do tile HCM e captura do token Worklist via iframe. +- `src/content/modal-events.js`: controller de eventos delegados da modal, incluindo clicks, inputs e navegacao por teclado. +- `src/content/workbench-controller.js`: controller de Workbench, dataset, refresh, detalhes, actions, forecast e calendario. +- `src/content/manage-time-controller.js`: controller da modal Manage Time, incluindo carregamento, diff, foco, validacao e gravacao. +- `src/content/export-html.js`: geracao do relatorio HTML standalone baixado pelo usuario. +- `src/repositories/workbench-repository.js`: acesso e normalizacao de dados do Workload Workbench e Worklist user API. +- `src/repositories/comcip-repository.js`: acesso e normalizacao de dados COMCIP, Manage Time e Service Requests. +- `src/repositories/cache-repository.js`: persistencia dos payloads de dashboard, task types e token Worklist no IndexedDB. +- `src/domain/ramp-forecast.js`: regras puras de calculo de ramp, delta de forecast e payload de atualizacao de forecast. +- `src/domain/workload-dataset.js`: regras puras de normalizacao de workloads, snapshots, export payloads e indicadores do dashboard. +- `src/domain/workload-calendar.js`: regras puras para calendario mensal/semanal de inicio dos workloads. +- `src/domain/time-management.js`: regras puras de diff, assinatura, payload, validacao e datas do Manage time. +- `src/domain/time-management-data.js`: normalizacao de payloads, options e DTOs usados pela modal Manage time. +- `src/services/storage-service.js`: infraestrutura generica de IndexedDB. +- `src/services/chrome-service.js`: wrappers Promise-based para APIs do Chrome usadas pelo content script. +- `src/services/preferences-service.js`: persistencia local de preferencias visuais e ultima data selecionada no Manage Time. +- `src/services/transport-service.js`: camada de transporte, page bridge, iframes autenticados e requests via background. +- `src/views/render-helpers.js`: helpers reutilizaveis de apresentacao para tabelas, links e resumo do header. +- `content-script.js`: entrypoint principal da UI e orquestracao de estado, eventos e requests. +- `page-bridge.js`: bridge executada no contexto da pagina SPA para capturar e reutilizar headers autorizados. +- `background.js`: service worker responsavel por proxy de requests, descoberta de frames e execucao em frames autenticados. +- `tools/bump-extension-version.ps1`: script obrigatorio para atualizar a versao da extensao. -- 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 +## Estrutura atual -## Como instalar no Chrome +```text +src/ + background/ + config.js + content/ + export-html.js + hcm-page.js + manage-time-controller.js + modal-events.js + shell-view.js + styles.css + templates.js + workbench-controller.js + domain/ + ramp-forecast.js + workload-dataset.js + workload-calendar.js + time-management.js + time-management-data.js + repositories/ + cache-repository.js + comcip-repository.js + workbench-repository.js + views/ + dashboard-view.js + manage-time-view.js + render-helpers.js + workbench-modals-view.js + services/ + chrome-service.js + preferences-service.js + storage-service.js + transport-service.js + shared/ + config.js + utils.js +background.js +content-script.js +page-bridge.js +manifest.json +``` -1. Abra `chrome://extensions` -2. Ative `Developer mode` -3. Clique em `Load unpacked` -4. Selecione a pasta `C:\Codex\Projects\arch-central-extension-browser` +O `manifest.json` carrega `src/shared/config.js`, services, repositories, domain modules e views antes de `content-script.js`. Esses arquivos expõem namespaces em `globalThis` para manter compatibilidade com content scripts classicos do Manifest V3 sem exigir bundler. -## Arquivos principais +O CSS principal fica em `src/content/styles.css` e e injetado diretamente pela propriedade `css` do content script no `manifest.json`. -- `manifest.json`: define a extensao e o carregamento do content script -- `content-script.js`: valida a URL, encontra o header e injeta o botao +Renderizadores maiores devem ficar em `src/views/`. O dashboard principal fica em `src/views/dashboard-view.js`, os modais de Workbench ficam em `src/views/workbench-modals-view.js` e a modal Manage Time fica em `src/views/manage-time-view.js`, mantendo `content-script.js` como orquestrador. + +Responsabilidades de shell injetado na pagina, como tema derivado do header Oracle, posicionamento do botao e bloqueio de scroll durante modal aberta, ficam em `src/content/shell-view.js`. A integracao especifica do HCM FuseWelcome fica em `src/content/hcm-page.js`. + +Regras de negocio puras devem ficar em `src/domain/`. Essa camada nao deve manipular DOM, IndexedDB, Chrome APIs ou executar requests; ela recebe dependencias simples por factory e retorna funcoes deterministicas. + +Requests, cache especifico da extensao e normalizadores de payload de APIs externas devem ficar em `src/repositories/`. Essa camada monta URLs, centraliza contratos de endpoint e transforma payloads remotos ou persistidos em DTOs consumidos pelo orquestrador. + +## Fluxos principais + +- Workload dashboard: carrega usuario, customers, workloads, actions e service requests. +- HCM tile: adiciona o tile `Arch Panel` dentro de `yourapps_groupNode_my_information` em `FuseWelcome`. +- Worklist token: captura o token do iframe Worklist SaasUI e persiste em IndexedDB para resolver o usuario atual. +- SPA Workbench iframe: cria um iframe oculto do Workload Workbench para obter contexto autenticado e executar requests da origem `spa.oracle.com`. +- COMCIP Time Entry iframe: cria um iframe oculto do Manage Time para requests dependentes da sessao COMCIP. +- Manage time: permite apontar horas, detectar criacao/alteracao/remocao e gravar via APIs de time entries. + +## IndexedDB + +Banco: `arch-panel-extension-db` + +Store: `datasets` + +Chaves conhecidas: + +- `workload-dashboard`: ultimo dataset do dashboard. +- `time-management-task-types`: cache de task types atualizado apenas em refresh data. +- `worklist-saasui-token`: token atual extraido do iframe Worklist. + +## Seguranca operacional + +- A extensao deve declarar apenas permissoes realmente usadas no `manifest.json`. +- A bridge de pagina deve trocar mensagens apenas com origins explicitamente permitidos. +- Tokens e headers de autorizacao nao devem ser logados. +- Exports HTML devem escapar JSON inline e valores renderizados. + +## Validacao local + +```powershell +node --check content-script.js +node --check background.js +node --check page-bridge.js +node --check src/background/config.js +node --check src/content/export-html.js +node --check src/content/hcm-page.js +node --check src/content/manage-time-controller.js +node --check src/content/modal-events.js +node --check src/content/shell-view.js +node --check src/content/templates.js +node --check src/content/workbench-controller.js +node --check src/repositories/workbench-repository.js +node --check src/repositories/comcip-repository.js +node --check src/repositories/cache-repository.js +node --check src/domain/ramp-forecast.js +node --check src/domain/workload-dataset.js +node --check src/domain/workload-calendar.js +node --check src/domain/time-management.js +node --check src/domain/time-management-data.js +node --check src/views/dashboard-view.js +node --check src/views/workbench-modals-view.js +node --check src/views/manage-time-view.js +node --check src/views/render-helpers.js +node --check src/shared/config.js +node --check src/shared/utils.js +node --check src/services/storage-service.js +node --check src/services/chrome-service.js +node --check src/services/preferences-service.js +node --check src/services/transport-service.js +``` ## Controle de versao diff --git a/background.js b/background.js index c46212d..43c1602 100644 --- a/background.js +++ b/background.js @@ -1,24 +1,20 @@ -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_TIME_ENTRY_APP_URL = - `${COMCIP_ORIGIN}/ic/builder/rt/oalset_timeentrymobile/live/webApps/timeentry/`; -const COMCIP_TIME_ENTRY_CLIENT_ID_PROBE_URL = - `${COMCIP_ORIGIN}/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/lookups?lookupType=SCTA_TASK_TYPE&type=CUSTOM`; -const COMCIP_TIME_ENTRY_APP_VERSION = "version_1774016034953"; -const COMCIP_TAB_URL_PATTERN = - `${COMCIP_ORIGIN}/ic/builder/rt/oalset_semc/live*`; -const COMCIP_TIME_ENTRY_TAB_URL_PATTERN = - `${COMCIP_ORIGIN}/ic/builder/rt/oalset_timeentrymobile/live*`; -const SPA_WORKBENCH_FRAME_URL = - "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=workload_workbench"; -const SPA_WORKBENCH_FRAME_ID = "arch-panel-extension-spa-frame"; -const SPA_WORKBENCH_FRAME_HOST_ID = "arch-panel-extension-spa-frame-host"; -const SPA_FRAME_DNR_RULE_ID = 92001; +importScripts("src/background/config.js"); + +const { + COMCIP_ORIGIN, + COMCIP_APP_URL, + COMCIP_QUERY_URL, + COMCIP_CLIENT_ID_PROBE_URL, + COMCIP_TIME_ENTRY_APP_URL, + COMCIP_TIME_ENTRY_CLIENT_ID_PROBE_URL, + COMCIP_TIME_ENTRY_APP_VERSION, + COMCIP_TAB_URL_PATTERN, + COMCIP_TIME_ENTRY_TAB_URL_PATTERN, + SPA_WORKBENCH_FRAME_URL, + SPA_WORKBENCH_FRAME_ID, + SPA_WORKBENCH_FRAME_HOST_ID, + SPA_FRAME_DNR_RULE_ID, +} = globalThis.ArchPanelBackgroundConfig; void installSpaFrameHeaderRules(); diff --git a/content-script.js b/content-script.js index 1eef7ec..f34f3c7 100644 --- a/content-script.js +++ b/content-script.js @@ -1,205 +1,150 @@ (() => { - 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 TARGET_APP_URL = - "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=workload_workbench"; - const HCM_ORIGIN = "https://eeho.fa.us2.oraclecloud.com"; - const HCM_WELCOME_PATHNAME = "/hcmUI/faces/FuseWelcome"; - const HCM_MY_INFORMATION_APPS_GROUP_ID = "yourapps_groupNode_my_information"; - const WORKLIST_ORIGIN = "https://worklist.oracle.com"; - const WORKLIST_SAASUI_PATHNAME = "/oalapp/pub/worklist/saasui/index.html"; - const USER_BUTTON_LABEL = "[[displayUser().fullName]]"; - const ALLOWED_STAGE_TYPES = ["SQL", "Pipeline", "Upside", "Forecast"]; + const { + TARGET_ORIGIN, + TARGET_PATHNAME, + TARGET_PARAM, + TARGET_PARAM_VALUE, + TARGET_APP_URL, + HCM_ORIGIN, + HCM_WELCOME_PATHNAME, + HCM_MY_INFORMATION_APPS_GROUP_ID, + WORKLIST_ORIGIN, + WORKLIST_SAASUI_PATHNAME, + USER_BUTTON_LABEL, + ALLOWED_STAGE_TYPES, + SELECTORS, + IDS, + CLASSES, + STORAGE_KEYS, + CACHE, + DETAIL_SORT_KEYS, + BRIDGE, + API_ENDPOINTS, + COMCIP_REQUEST, + COMCIP_TIME_ENTRY_FRAME_ID, + ACTION_TEAM_OPTIONS, + ACTION_COMPLEXITY_OPTIONS, + } = globalThis.ArchPanelConfig; - 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", - hcmTile: "arch-panel-extension-hcm-tile", - spaFrame: "arch-panel-extension-spa-frame", - spaFrameHost: "arch-panel-extension-spa-frame-host", - }; - - 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", - calendarView: "arch-panel-extension-calendar-view", - timeManagementSelectedDate: "arch-panel-extension-time-management-selected-date", - }; - - const CACHE = { - dbName: "arch-panel-extension-db", - dbVersion: 1, - storeName: "datasets", - datasetKey: "workload-dashboard", - taskTypeKey: "time-management-task-types", - worklistTokenKey: "worklist-saasui-token", - }; - - 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", - workbenchAuthHeadersType: "ARCH_PANEL_WORKBENCH_AUTH_HEADERS", - readyType: "ARCH_PANEL_BRIDGE_READY", - scriptId: "arch-panel-extension-page-bridge", - }; - - const API_ENDPOINTS = { - currentUser: - "/oalcrm/web/api/identity-management/users/current", - worklistCurrentUser: - "https://worklist.oracle.com/oalapp/pub/wlp/svc/user-service/user", - 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", - resourceCurrentUserUrl: - "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/resourceUsers", - timeEntryWeekDetailsUrl: - "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries/weekDetails", - activeServiceRequestsUrl: - "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries/activeServiceRequests", - timeEntriesUrl: - "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries", - activityServiceRequestsUrl: - "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/crmRestApiSearchResourcesLatestCustomActions/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests", - taskTypeUrl: - "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/lookups?lookupType=SCTA_TASK_TYPE&type=CUSTOM", - timeEntryAppUrl: - "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live/webApps/timeentry/", - appVersion: "version_1754044416761", - }; - const COMCIP_TIME_ENTRY_FRAME_ID = "arch-panel-extension-comcip-timeentry-frame"; - const ACTIVITY_REQUEST_REFERENCE_SR_NUMBERS = [ - "SR0001440593", - "SR0001440475", - "SR0001434642", - "SR0001434671", - "SR0001387564", - "SR0001439267", - "SR0001411935", - "SR0001428065", - "SR0001433200", - "SR0001433217", - "SR0001433030", - "SR0001430407", - "SR0001432386", - "SR0001431923", - "SR0001418256", - "SR0001398520", - "SR0001377462", - "SR0001367044", - "SR0001419998", - "SR0001399117", - "SR0001413684", - "SR0001422541", - "SR0001415397", - "SR0001418535", - "SR0001421040", - "SR0001421074", - "SR0001388866", - "SR0001335148", - "SR0001416756", - "SR0001380511", - "SR0001413965", - "SR0001413598", - "SR0001409121", - "SR0001404828", - "SR0001404549", - "SR0001402376", - "SR0001399121", - "SR0001294405", - "SR0001308472", - ]; - - 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 storageService = globalThis.ArchPanelStorageService; + const chromeService = globalThis.ArchPanelChromeService; + const { + normalizeText, + cleanString, + firstNonEmptyString, + normalizeString, + toNumber, + toIsoDateString, + extractArray, + mapWithConcurrency, + getFieldValue, + flattenDisplayRecord, + isVisible, + formatWholeNumber, + formatCurrency, + formatHours, + formatWeekNumber, + formatOperationalWeekLabel, + formatPercent, + formatProgressPercent, + formatDate, + getDateInputValue, + formatDateInputDisplay, + formatDateForTimeEntryRequest, + parseTimeEntryDate, + formatDateTime, + getLocalDateKey, + getOperationalWeekInfo, + getCalendarRowWeekDate, + parseDatePreservingDateOnly, + getErrorMessage, + escapeHtml, + cssEscape, + } = globalThis.ArchPanelUtils; + const { + getStoredTheme, + setStoredTheme, + getStoredSidebarCollapsed, + setStoredSidebarCollapsed, + getStoredCalendarView, + setStoredCalendarView, + getStoredTimeManagementSelectedDate, + setStoredTimeManagementSelectedDate, + } = globalThis.ArchPanelPreferencesService.create({ + STORAGE_KEYS, + getDateInputValue, + parseDatePreservingDateOnly, + }); + const { + applyDialogTheme, + ensureStyles, + applyTheme, + findUserAnchor, + resolveInsertionTarget, + lockScroll, + unlockScroll, + } = globalThis.ArchPanelShellView.create({ + IDS, + CLASSES, + USER_BUTTON_LABEL, + normalizeText, + isVisible, + }); + const { + normalizeWorkload, + isWonWorkload, + normalizeTeamValue, + teamContainsUser, + stripTransientFields, + createExportPayload, + createDatasetSnapshot, + normalizeCachedExportPayload, + } = globalThis.ArchPanelWorkloadDatasetDomain.create({ + ALLOWED_STAGE_TYPES, + cleanString, + firstNonEmptyString, + normalizeString, + toNumber, + extractArray, + formatDateTime, + normalizePendingServiceRequests: (...args) => + comcipRepository.normalizePendingServiceRequests(...args), + }); + const { + buildWorkloadStartCalendar, + getWeekStartDate, + getCalendarAnchorDate, + } = globalThis.ArchPanelWorkloadCalendarDomain.create({ + getLocalDateKey, + getOperationalWeekInfo, + getCalendarRowWeekDate, + formatOperationalWeekLabel, + formatDate, + parseDatePreservingDateOnly, + }); + const { + readCachedDataset, + writeCachedDataset, + readCachedTaskTypePayload, + writeCachedTaskTypePayload, + writeCachedWorklistToken, + readCachedWorklistToken, + } = globalThis.ArchPanelCacheRepository.create({ + CACHE, + storageService, + normalizeCachedExportPayload, + cleanString, + }); + const hcmPage = globalThis.ArchPanelHcmPage.create({ + HCM_MY_INFORMATION_APPS_GROUP_ID, + WORKLIST_ORIGIN, + WORKLIST_SAASUI_PATHNAME, + IDS, + cleanString, + hcmTileTemplate: globalThis.ArchPanelTemplates.hcmTile, + onOpenArchPanel: openModal, + writeCachedWorklistToken, + }); const appState = { theme: getStoredTheme(), @@ -231,16 +176,61 @@ month: new Date().getMonth(), }, pendingFocusSelector: "", + spaFrameLifecycleIntervalId: null, + lastSpaFrameLifecycleError: "", }; let syncFrame = 0; let restoreFocusTarget = null; let closeTimer = 0; - let requestCounter = 0; - let bridgeReadyPromise = null; - let cacheHydrationPromise = null; - let lastPersistedWorklistIframeUrl = ""; - const pendingBridgeRequests = new Map(); + const { + ensurePageBridge, + fetchJson, + sendComcipRequestMessage, + ensureSpaFrame, + handleBridgeMessage, + } = globalThis.ArchPanelTransportService.create({ + TARGET_ORIGIN, + TARGET_APP_URL, + IDS, + BRIDGE, + COMCIP_REQUEST, + COMCIP_TIME_ENTRY_FRAME_ID, + chromeService, + cleanString, + firstNonEmptyString, + isHcmWelcomePage, + getSessionAuthHeaders: () => appState.sessionAuthHeaders, + setSessionAuthHeaders: (headers) => { + appState.sessionAuthHeaders = headers; + }, + }); + const createExportHtml = globalThis.ArchPanelExportHtml.createExportHtml; + const { + renderServiceRequestDetailTable, + renderSortButton, + renderOpportunityLink, + renderWorkloadLink, + getHeaderSummary, + } = globalThis.ArchPanelRenderHelpers.create({ + appState, + escapeHtml, + cleanString, + formatWholeNumber, + formatCurrency, + formatDate, + formatHours, + }); + let workbenchController = null; + let modalEvents = null; + + function getSortedDetailItems() { + return workbenchController?.getSortedDetailItems() || []; + } + + function findWorkloadById(workloadId) { + return workbenchController?.findWorkloadById(workloadId) || null; + } function isTargetPage() { const currentUrl = new URL(window.location.href); @@ -280,9 +270,9 @@ mount?.remove(); ensureStyles(); ensureModal(); - syncHcmTile(); + hcmPage.syncHcmTile(); ensureSpaFrame(); - syncWorklistIframeToken(); + hcmPage.syncWorklistIframeToken(); return; } @@ -334,107 +324,6 @@ } } - function syncHcmTile() { - const appsGroup = document.getElementById(HCM_MY_INFORMATION_APPS_GROUP_ID); - const addTile = appsGroup?.querySelector(".flat-grid-cell.flat-grid-cell-addicon"); - - if (!addTile?.parentElement) { - document.getElementById(IDS.hcmTile)?.remove(); - return; - } - - const tile = document.getElementById(IDS.hcmTile) || createHcmTile(); - - if (tile.parentElement !== addTile.parentElement || tile.nextElementSibling !== addTile) { - addTile.parentElement.insertBefore(tile, addTile); - } - } - - function syncWorklistIframeToken(options = {}) { - if (!isHcmWelcomePage()) { - return null; - } - - for (const frame of document.querySelectorAll("iframe")) { - const tokenPayload = extractWorklistIframeToken(frame); - - if (tokenPayload?.token) { - persistWorklistIframeToken(tokenPayload, options); - return tokenPayload; - } - } - - return null; - } - - function extractWorklistIframeToken(frame) { - const rawSrc = cleanString(frame?.getAttribute?.("src") || frame?.src); - - if (!rawSrc) { - return null; - } - - try { - const url = new URL(rawSrc, window.location.href); - const token = cleanString(url.searchParams.get("token")); - - if ( - url.origin !== WORKLIST_ORIGIN || - url.pathname !== WORKLIST_SAASUI_PATHNAME || - !token - ) { - return null; - } - - return { - token, - url: url.toString(), - }; - } catch { - return null; - } - } - - function persistWorklistIframeToken(tokenPayload, options = {}) { - if (!options.force && tokenPayload.url === lastPersistedWorklistIframeUrl) { - return; - } - - lastPersistedWorklistIframeUrl = tokenPayload.url; - void writeCachedWorklistToken(tokenPayload); - } - - function createHcmTile() { - const template = document.createElement("template"); - template.innerHTML = ` -
- -
- `; - const tile = template.content.firstElementChild; - - tile.addEventListener("click", openHcmArchPanelTile); - tile.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") { - openHcmArchPanelTile(event); - } - }); - tile.tabIndex = 0; - tile.setAttribute("role", "button"); - tile.setAttribute("aria-label", "Open Arch Panel"); - - return tile; - } - - function openHcmArchPanelTile(event) { - event.preventDefault(); - event.stopPropagation(); - openModal(); - } - function createMount() { const mount = document.createElement("div"); mount.id = IDS.mount; @@ -453,37 +342,6 @@ 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); @@ -496,10 +354,10 @@ overlay.className = "arch-panel-extension-overlay"; overlay.hidden = true; overlay.setAttribute("aria-hidden", "true"); - overlay.addEventListener("click", handleOverlayClick); - overlay.addEventListener("change", handleOverlayChange); - overlay.addEventListener("input", handleOverlayInput); - overlay.addEventListener("keydown", handleOverlayKeydown); + overlay.addEventListener("click", modalEvents.handleOverlayClick); + overlay.addEventListener("change", modalEvents.handleOverlayChange); + overlay.addEventListener("input", modalEvents.handleOverlayInput); + overlay.addEventListener("keydown", modalEvents.handleOverlayKeydown); document.body.appendChild(overlay); renderModal(); @@ -507,535 +365,6 @@ 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.timeManagementModal) { - appState.timeManagementModal = 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"); - - if ( - action === "time-management-date-change" || - action === "time-management-sr-change" || - action === "time-management-activity-change" || - action === "time-management-task-type-change" - ) { - return; - } - - 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") { - shiftCalendarPeriod(-1); - return; - } - - if (action === "calendar-next") { - shiftCalendarPeriod(1); - return; - } - - if (action === "calendar-today") { - goToCurrentCalendarPeriod(); - return; - } - - if (action === "calendar-view") { - const calendarView = actionElement.getAttribute("data-calendar-view"); - - if (calendarView) { - setCalendarView(calendarView); - } - 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 === "open-time-management") { - void openTimeManagementModal(); - return; - } - - if (action === "close-time-management") { - appState.timeManagementModal = null; - renderModal(); - return; - } - - if (action === "time-management-add-sr") { - addTimeManagementEntryRow("sr"); - return; - } - - if (action === "time-management-add-non-service") { - addTimeManagementEntryRow("non-service"); - return; - } - - if (action === "time-management-save") { - void saveTimeManagementChanges(); - return; - } - - if (action === "time-management-remove-row") { - removeTimeManagementEntryRow(actionElement.getAttribute("data-row-id")); - return; - } - - if (action === "open-time-management-date-picker") { - if (!actionElement.matches("button.arch-panel-extension-date-picker-button")) { - return; - } - - toggleTimeManagementDatePicker(); - return; - } - - if (action === "time-management-date-week-prev") { - void shiftTimeManagementDateByWeeks(-1); - return; - } - - if (action === "time-management-date-week-next") { - void shiftTimeManagementDateByWeeks(1); - return; - } - - if (action === "time-management-date-picker-prev") { - shiftTimeManagementDatePickerMonth(-1); - return; - } - - if (action === "time-management-date-picker-next") { - shiftTimeManagementDatePickerMonth(1); - return; - } - - if (action === "time-management-date-picker-select") { - const selectedDate = actionElement.getAttribute("data-date-value"); - - if (selectedDate) { - void selectTimeManagementDate(selectedDate); - } - return; - } - - if (action === "time-management-combobox-option") { - selectTimeManagementComboboxOption( - actionElement.getAttribute("data-field"), - actionElement.getAttribute("data-value"), - actionElement.getAttribute("data-row-id") - ); - 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 handleOverlayChange(event) { - const target = event.target; - - if (!(target instanceof HTMLInputElement || target instanceof HTMLSelectElement)) { - return; - } - - const action = target.getAttribute("data-action"); - - if (action === "time-management-date-change") { - void loadTimeManagementData(target.value); - return; - } - - if (action === "time-management-sr-change") { - updateTimeManagementSelectedSr(target.value, target.getAttribute("data-row-id")); - return; - } - - if (action === "time-management-activity-change") { - updateTimeManagementField( - "selectedActivityValue", - target.value, - target.getAttribute("data-row-id") - ); - return; - } - - if (action === "time-management-task-type-change") { - updateTimeManagementField( - "selectedTaskTypeValue", - target.value, - target.getAttribute("data-row-id") - ); - return; - } - - if (action === "time-management-hours-change") { - updateTimeManagementDayHours( - target.getAttribute("data-row-id"), - target.getAttribute("data-date-key"), - target.value - ); - } - } - - function handleOverlayInput(event) { - const target = event.target; - - if (!(target instanceof HTMLInputElement)) { - return; - } - - const field = target.getAttribute("data-combobox-field"); - - if (!field) { - return; - } - - filterTimeManagementCombobox(target); - } - - function handleOverlayKeydown(event) { - const target = event.target; - - if (!(target instanceof HTMLInputElement)) { - return; - } - - if (target.getAttribute("data-action") === "time-management-hours-change") { - handleTimeManagementHourKeydown(event, target); - return; - } - - const field = target.getAttribute("data-combobox-field"); - - if (!field) { - return; - } - - if (![ - "ArrowDown", - "ArrowUp", - "Home", - "End", - "Enter", - "Escape", - ].includes(event.key)) { - return; - } - - handleTimeManagementComboboxKeydown(event, target); - } - - function handleTimeManagementHourKeydown(event, input) { - if ( - ![ - "ArrowLeft", - "ArrowRight", - "ArrowUp", - "ArrowDown", - "Home", - "End", - "Enter", - ].includes(event.key) - ) { - return; - } - - const nextInput = getNextTimeManagementHourInput(input, event); - - if (!nextInput) { - return; - } - - event.preventDefault(); - nextInput.focus(); - nextInput.select(); - } - - function getNextTimeManagementHourInput(currentInput, event) { - const rows = Array.from( - document.querySelectorAll( - ".arch-panel-extension-time-management-entry-row[data-row-id]" - ) - ); - const currentRow = currentInput.closest( - ".arch-panel-extension-time-management-entry-row" - ); - const rowIndex = rows.indexOf(currentRow); - - if (rowIndex < 0) { - return null; - } - - const currentInputs = getTimeManagementHourInputs(rows[rowIndex]); - const columnIndex = currentInputs.indexOf(currentInput); - - if (columnIndex < 0) { - return null; - } - - let nextRowIndex = rowIndex; - let nextColumnIndex = columnIndex; - - if (event.key === "ArrowLeft") { - nextColumnIndex -= 1; - } else if (event.key === "ArrowRight") { - nextColumnIndex += 1; - } else if (event.key === "ArrowUp") { - nextRowIndex -= 1; - } else if (event.key === "ArrowDown" || event.key === "Enter") { - nextRowIndex += event.shiftKey ? -1 : 1; - } else if (event.key === "Home") { - nextColumnIndex = 0; - } else if (event.key === "End") { - nextColumnIndex = currentInputs.length - 1; - } - - const nextRow = rows[nextRowIndex]; - - if (!nextRow) { - return null; - } - - const nextInputs = getTimeManagementHourInputs(nextRow); - - return nextInputs[nextColumnIndex] || null; - } - - function getTimeManagementHourInputs(row) { - return Array.from( - row.querySelectorAll('input[data-action="time-management-hours-change"]') - ); - } - - function toggleTimeManagementDatePicker() { - const selectedDate = getTimeManagementSelectedDate(); - const pickerDate = parseDatePreservingDateOnly(selectedDate); - const fallback = Number.isNaN(pickerDate.getTime()) ? new Date() : pickerDate; - - appState.timeManagementDatePicker = { - isOpen: !appState.timeManagementDatePicker.isOpen, - year: fallback.getFullYear(), - month: fallback.getMonth(), - }; - renderModal(); - } - - function shiftTimeManagementDatePickerMonth(delta) { - const picker = appState.timeManagementDatePicker; - const nextDate = new Date(picker.year, picker.month + delta, 1); - - appState.timeManagementDatePicker = { - isOpen: true, - year: nextDate.getFullYear(), - month: nextDate.getMonth(), - }; - renderModal(); - } - - async function selectTimeManagementDate(selectedDate) { - appState.timeManagementDatePicker = { - ...appState.timeManagementDatePicker, - isOpen: false, - }; - await loadTimeManagementData(selectedDate); - } - - async function shiftTimeManagementDateByWeeks(delta) { - const selectedDate = getTimeManagementSelectedDate(); - const parsedDate = parseDatePreservingDateOnly(selectedDate); - const baseDate = Number.isNaN(parsedDate.getTime()) ? new Date() : parsedDate; - - baseDate.setDate(baseDate.getDate() + (Number(delta) || 0) * 7); - appState.timeManagementDatePicker = { - ...appState.timeManagementDatePicker, - isOpen: false, - }; - await loadTimeManagementData(getDateInputValue(baseDate)); - } - - function getTimeManagementSelectedDate() { - return appState.timeManagementModal?.selectedDate || getDateInputValue(new Date()); - } - function renderModal() { const overlay = document.getElementById(IDS.overlay); @@ -1048,7 +377,7 @@ const requestedFocusSelector = appState.pendingFocusSelector || activeFocusSelector; overlay.innerHTML = createModalMarkup(); - applyDialogTheme(overlay); + applyDialogTheme(overlay, appState.theme); restoreModalScrollState(overlay, previousScrollState); @@ -1264,2161 +593,311 @@ `; } - 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 renderCurrentUserSidebar(user) { - const displayName = cleanString(user?.name || user?.userEmail); - const userEmail = cleanString(user?.userEmail); - const avatar = cleanString(user?.avatar); - const initials = getUserInitials(displayName || userEmail); - - return ` -
- ${ - avatar - ? `${escapeHtml(displayName ? `${displayName} avatar` : ` - : `` - } -
- ${escapeHtml(displayName || userEmail)} - ${escapeHtml(userEmail)} -
-
- `; - } - - function getUserInitials(value) { - const parts = cleanString(value) - .split(/\s+/) - .filter(Boolean) - .slice(0, 2); - - if (!parts.length) { - return "U"; - } - - return parts.map((part) => part.charAt(0).toUpperCase()).join(""); - } - - function getExtensionVersion() { - try { - return chrome.runtime.getManifest()?.version || "0.0.0"; - } catch { - return "0.0.0"; - } - } - - 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, - appState.calendarView, - appState.calendarAnchorDateKey - ); - - return ` -
- ${renderSrHoursIndicatorCard(summary)} - ${renderWorkloadStartCalendar(calendar)} -
- `; - } - - function renderSrHoursIndicatorCard(summary) { - const zeroToFourCount = summary.srHours.betweenZeroAndFour.length; - const aboveFourCount = summary.srHours.aboveFour.length; - const total = zeroToFourCount + aboveFourCount; - const zeroToFourPercent = total > 0 ? (zeroToFourCount / total) * 100 : 0; - const aboveFourPercent = total > 0 ? (aboveFourCount / total) * 100 : 0; - - return ` -
-
-
-

Horas apontadas

-
-
-
-
- - -
-
- SRs 0-4h: ${formatPercent(zeroToFourPercent)} - SRs >4h: ${formatPercent(aboveFourPercent)} -
-
-
- ${renderSrHoursIndicator( - "SRs 0-4h", - formatPercent(zeroToFourPercent), - zeroToFourCount, - "0-4" - )} - ${renderSrHoursIndicator( - "SRs >4h", - formatPercent(aboveFourPercent), - aboveFourCount, - "above-4" - )} -
-
- `; - } - - function renderSrHoursIndicator(label, detail, count, value) { - return ` - - `; - } - - function renderWorkloadStartCalendar(calendar) { - return ` -
-
-
-

Workload start calendar

-

${escapeHtml(calendar.periodLabel)}

-
-
-
- ${["month", "week"] - .map( - (view) => ` - - ` - ) - .join("")} -
- - ${formatWholeNumber(calendar.totalVisibleWorkloads)} starts - - -
-
-
- ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] - .map((day) => `${day}`) - .join("")} -
-
- ${calendar.days - .map((day) => renderCalendarDay(day, calendar.view)) - .join("")} -
-
- `; - } - - function renderCalendarDay(day, view = "month") { - if (view === "week") { - return renderCalendarWeekDay(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 renderCalendarWeekDay(day) { - const className = [ - "arch-panel-extension-calendar-day", - "is-week-day", - day.isToday ? "is-today" : "", - day.workloads.length ? "has-workload" : "", - ] - .filter(Boolean) - .join(" "); - const title = day.workloads - .map((workload) => workload.description || workload.name) - .filter(Boolean) - .join(" | "); - - return ` - - `; - } - - function renderCalendarWeekWorkload(workload) { - const label = workload.description || workload.name || "Workload"; - const customer = workload.customerName || ""; - - return ` - - ${escapeHtml(label)} - ${ - customer - ? `${escapeHtml( - customer - )}` - : "" - } - - `; - } - - 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" - ) - } -
- ${ - isServiceRequestDetail - ? `` - : "" - } - -
-
-
- `; - } - - 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 renderTimeManagementModal() { - const modal = appState.timeManagementModal; - - if (!modal) { - return ""; - } - - const selectedDate = modal.selectedDate || getDateInputValue(new Date()); - const selectedDateLabel = formatDateInputDisplay(selectedDate); - const canSaveTimeManagement = isTimeManagementSaveEnabled(modal); - const isSavingTimeManagement = Boolean(modal.isSaving); - - return ` -
-
-
-
-

Horas apontadas

-

Manage time

-
-
- -
- - ${renderTimeManagementHourIndicators(modal)} -
- - ${renderTimeManagementBody(modal)} - -
- - ${ - modal.saveErrorMessage - ? `

${escapeHtml(modal.saveErrorMessage)}

` - : "" - } - -
-
-
- `; - } - - function renderTimeManagementBody(modal) { - if (modal.status === "loading") { - return renderTimeManagementSkeleton(); - } - - if (modal.status === "error") { - return ` -
-

Erro na requisição

-

${escapeHtml( - modal.errorMessage || "Não foi possível carregar o apontamento." - )}

-
- `; - } - - const rows = getTimeManagementRows(modal); - - return ` -
- ${renderTimeManagementHeaderRow(modal.weekDays || [])} -
- ${ - rows.length - ? rows.map((row, index) => renderTimeManagementEntryRow(modal, row, index)).join("") - : `
- Nenhum apontamento de hora neste período -
` - } -
-
- `; - } - - function renderTimeManagementHeaderRow(weekDays) { - return ` - - `; - } - - function renderTimeManagementDayHeader(day) { - return ` - - ${escapeHtml(day.dayLabel || "-")} - ${escapeHtml(day.monthLabel || day.date || "-")} - - `; - } - - function renderTimeManagementEntryRow(modal, row, index) { - const rowId = cleanString(row?.id) || `row-${index}`; - const isHighlighted = - cleanString(modal?.highlightRowId) && - cleanString(modal?.highlightRowId) === rowId; - const selectedSr = cleanString(row?.selectedSrNumber); - const activityOptions = getTimeManagementActivityOptions(modal, selectedSr); - const selectedSrOption = findTimeManagementServiceRequestByNumber( - modal, - selectedSr - ); - const isNonServiceRequest = isNonServiceRequestSrNumber(selectedSr); - const selectedSrLabel = - selectedSrOption?.label || - formatTimeManagementServiceRequestLabel(selectedSrOption) || - selectedSr; - const selectedActivityValue = - findOptionValue(activityOptions, row?.selectedActivityValue) || - cleanString(row?.selectedActivityValue) || - ""; - const selectedActivityLabel = - findOptionLabel(activityOptions, selectedActivityValue) || selectedActivityValue; - const taskTypeOptions = getTimeManagementTaskTypeOptions(modal, selectedSr); - const selectedTaskTypeValue = - findOptionValue(taskTypeOptions, row?.selectedTaskTypeValue) || - cleanString(row?.selectedTaskTypeValue) || - ""; - const selectedTaskTypeLabel = - findOptionLabel(taskTypeOptions, selectedTaskTypeValue) || selectedTaskTypeValue; - - return ` -
- - - - - ${(modal.weekDays || []) - .map((day) => - renderTimeManagementDayInput(day, rowId, row?.dayHours) - ) - .join("")} - -
- `; - } - - function renderTimeManagementSkeleton() { - return ` -
-
- ${["Service Request", "Activity", "Task Type"] - .map( - (label) => - `${label}` - ) - .join("")} - ${Array.from({ length: 7 }, () => ` - - - - - `).join("")} - -
-
- ${Array.from({ length: 3 }, () => ` -
- -
- `).join("")} - ${Array.from({ length: 7 }, () => ` -
- -
- `).join("")} - -
-
- `; - } - - function renderSearchableCombobox({ - field, - rowId, - selectedValue, - value, - placeholder, - options, - valueKey, - labelKey, - fallbackLabelKey, - metaKey, - disabled = false, - }) { - const safeField = escapeHtml(field); - const safeRowId = escapeHtml(rowId || ""); - const isDisabled = Boolean(disabled); - const normalizedSelectedValue = normalizeString(selectedValue); - const normalizedOptions = (options || []) - .map((item) => ({ - value: cleanString(item?.[valueKey]), - label: - cleanString(item?.[labelKey]) || - cleanString(item?.[fallbackLabelKey]) || - cleanString(item?.[valueKey]), - meta: cleanString(item?.[metaKey]), - })) - .filter((item) => item.value || item.label); - - return ` - - - ${escapeHtml(value || placeholder || "")} - - - - - ${ - normalizedOptions.length - ? normalizedOptions - .map( - (item, index) => { - const isSelected = - normalizedSelectedValue && - normalizeString(item.value) === normalizedSelectedValue; - const meta = - item.meta && normalizeString(item.meta) !== normalizeString(item.label) - ? item.meta - : ""; - const optionId = `arch-panel-extension-combobox-${safeField}-${index}`; - - return ` - - `; - } - ) - .join("") - : `No options` - } - - - `; - } - - function renderTimeManagementDatePicker(selectedDate) { - const picker = appState.timeManagementDatePicker; - - if (!picker.isOpen) { - return ""; - } - - const monthStart = new Date(picker.year, picker.month, 1); - const calendarStart = getWeekStartDate(monthStart); - const selectedDateKey = getLocalDateKey(selectedDate); - const todayKey = getLocalDateKey(new Date()); - const monthLabel = new Intl.DateTimeFormat("en-US", { - month: "long", - year: "numeric", - }).format(monthStart); - const days = Array.from({ length: 42 }, (_, index) => { - const date = new Date(calendarStart); - date.setDate(calendarStart.getDate() + index); - const dateKey = getLocalDateKey(date); - const className = [ - "arch-panel-extension-date-picker-day", - date.getMonth() === picker.month ? "" : "is-muted", - dateKey === todayKey ? "is-today" : "", - dateKey === selectedDateKey ? "is-selected" : "", - ] - .filter(Boolean) - .join(" "); - - return ` - - `; - }); - - return ` -
-
- - ${escapeHtml(monthLabel)} - -
-
- ${["S", "M", "T", "W", "T", "F", "S"].map((day) => `${day}`).join("")} -
-
- ${days.join("")} -
-
- `; - } - - function renderTimeManagementHourIndicators(modal) { - if (modal?.status === "loading") { - return ` -
- ${Array.from({ length: 4 }, () => ` - - - - - `).join("")} -
- `; - } - - return ` -
- ${buildTimeManagementHourIndicators(modal) - .map( - (indicator) => ` - - ${escapeHtml(indicator.label)} - ${escapeHtml(formatHours(indicator.value))} - - ` - ) - .join("")} -
- `; - } - - function buildTimeManagementHourIndicators(modal) { - const totals = getTimeManagementRows(modal).reduce( - (accumulator, row) => { - const rowTotal = getTimeManagementRowTotalHours(row); - const category = getTimeManagementHourCategory(modal, row); - - accumulator[category] += rowTotal; - accumulator.total += rowTotal; - - return accumulator; - }, - { - service: 0, - nonService: 0, - timeOff: 0, - total: 0, - } - ); - - return [ - { label: "Service", value: totals.service }, - { label: "Non Service", value: totals.nonService }, - { label: "Time Off", value: totals.timeOff }, - { label: "Total hours", value: totals.total }, - ]; - } - - function getTimeManagementRowTotalHours(row) { - return Object.values(row?.dayHours || {}).reduce( - (sum, value) => sum + formatTimeManagementHourPayloadValue(value), - 0 - ); - } - - function getTimeManagementHourCategory(modal, row) { - if (isTimeManagementTimeOffRow(modal, row)) { - return "timeOff"; - } - - return isNonServiceRequestSrNumber(row?.selectedSrNumber) - ? "nonService" - : "service"; - } - - function isTimeManagementTimeOffRow(modal, row) { - const taskTypeValue = cleanString(row?.selectedTaskTypeValue); - const taskType = findTimeManagementTaskTypeByValue(modal, taskTypeValue); - const candidates = [ - taskTypeValue, - taskType?.label, - taskType?.tags, - ].map((value) => normalizeString(value)); - - return candidates.some( - (value) => - value === "TIME_OFF" || - value === "TIMEOFF" || - value.includes("TIME_OFF") || - value.includes("TIME OFF") - ); - } - - function findTimeManagementTaskTypeByValue(modal, value) { - const normalizedValue = normalizeString(value); - - if (!normalizedValue) { - return null; - } - - return (modal?.taskTypes || []).find( - (item) => normalizeString(item?.value) === normalizedValue - ) || null; - } - - function renderTimeManagementDayInput( - day, - rowId = "", - dayHours = {} - ) { - const dateKey = getTimeManagementDayKey(day); - const value = cleanString(dayHours?.[dateKey]); - - return ` - - `; - } - - function getTimeManagementDayKey(day) { - const parsedDate = parseTimeEntryDate(day?.date); - - if (!Number.isNaN(parsedDate.getTime())) { - return getLocalDateKey(parsedDate); - } - - return cleanString(day?.date); - } - - function renderSelectOptions(items, selectedValue, valueKey, labelKey, emptyLabel) { - const options = []; - - if (!items.length) { - options.push(``); - } - - return options - .concat( - items.map((item) => { - const value = cleanString(item?.[valueKey]); - const label = cleanString(item?.[labelKey]) || value || emptyLabel; - - return ` - - `; - }) - ) - .join(""); - } - - 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"; - } + const { + buildRampForecastComparisonRows, + hasRampForecastMismatch, + isNonZeroAmount, + parseForecastMonthNumber, + createForecastUpdatePayload, + } = globalThis.ArchPanelRampForecastDomain.create({ + toNumber, + normalizeString, + parseDatePreservingDateOnly, + formatWeekNumber, + }); + + const { + getTimeManagementRows, + isTimeManagementSaveEnabled, + getTimeManagementPendingChanges, + createTimeManagementRowSignature, + createTimeManagementSavePayload, + formatTimeManagementHourPayloadValue, + getTimeManagementDayKey, + getTimeManagementWeekdayName, + isNonServiceRequestSrNumber, + } = globalThis.ArchPanelTimeManagementDomain.create({ + cleanString, + normalizeString, + toNumber, + parseTimeEntryDate, + getLocalDateKey, + }); + + const { + normalizeInfoWeekResponse, + normalizeTimeManagementServiceRequests, + formatTimeManagementServiceRequestLabel, + mergeTimeManagementServiceRequests, + normalizeTimeManagementEntryRows, + normalizeTimeManagementEntryRowSelection, + getActivityRequestSrNumbers, + normalizeActivityRequest, + normalizeTaskTypeRequest, + getTimeManagementTaskTypeOptions, + getTimeManagementActivityOptions, + findTimeManagementServiceRequest, + findTimeManagementServiceRequestByNumber, + findOptionValue, + findOptionLabel, + normalizeTimeEntriesSummary, + groupTimeEntriesByResourceName, + getTimeEntryTimeSpent, + getTimeEntryActivity, + getTimeEntryTaskType, + } = globalThis.ArchPanelTimeManagementData.create({ + cleanString, + normalizeString, + firstNonEmptyString, + extractArray, + getFieldValue, + flattenDisplayRecord, + toNumber, + parseTimeEntryDate, + getLocalDateKey, + getTimeManagementDayKey, + getTimeManagementWeekdayName, + createTimeManagementRowSignature, + }); + + const workbenchRepository = globalThis.ArchPanelWorkbenchRepository.create({ + TARGET_ORIGIN, + API_ENDPOINTS, + fetchJson, + extractArray, + cleanString, + firstNonEmptyString, + getFieldValue, + toNumber, + normalizeWorkload, + isWonWorkload, + normalizeTeamValue, + }); + + const comcipRepository = globalThis.ArchPanelComcipRepository.create({ + COMCIP_REQUEST, + sendComcipRequest: sendComcipRequestMessage, + cleanString, + firstNonEmptyString, + extractArray, + getFieldValue, + toNumber, + formatDateForTimeEntryRequest, + }); + + const { + renderMainContent, + renderWorkloadTableRow, + } = globalThis.ArchPanelDashboardView.create({ + appState, + buildWorkloadStartCalendar, + hasRampForecastMismatch, + renderOpportunityLink, + renderWorkloadLink, + escapeHtml, + cleanString, + formatWholeNumber, + formatCurrency, + formatPercent, + formatProgressPercent, + formatDate, + }); + + const { + renderDetailModal, + renderActionFormModal, + renderRampComparisonModal, + renderForecastUpdateConfirmModal, + renderTimeEntriesDrawer, + } = globalThis.ArchPanelWorkbenchModalsView.create({ + appState, + DETAIL_SORT_KEYS, + ACTION_TEAM_OPTIONS, + ACTION_COMPLEXITY_OPTIONS, + getSortedDetailItems, + renderServiceRequestDetailTable, + renderSortButton, + renderWorkloadTableRow, + findWorkloadById, + renderOpportunityLink, + groupTimeEntriesByResourceName, + getTimeEntryTimeSpent, + getTimeEntryActivity, + getTimeEntryTaskType, + isNonZeroAmount, + escapeHtml, + cleanString, + formatWholeNumber, + formatCurrency, + formatDate, + formatHours, + }); + + const { + renderTimeManagementModal, + } = globalThis.ArchPanelManageTimeView.create({ + appState, + escapeHtml, + cleanString, + normalizeString, + formatDateInputDisplay, + getDateInputValue, + formatHours, + getWeekStartDate, + getLocalDateKey, + getTimeManagementDayKey, + isTimeManagementSaveEnabled, + getTimeManagementRows, + getTimeManagementActivityOptions, + findTimeManagementServiceRequestByNumber, + formatTimeManagementServiceRequestLabel, + isNonServiceRequestSrNumber, + findOptionValue, + findOptionLabel, + getTimeManagementTaskTypeOptions, + formatTimeManagementHourPayloadValue, + }); + + const manageTimeController = globalThis.ArchPanelManageTimeController.create({ + appState, + COMCIP_REQUEST, + comcipRepository, + readCachedTaskTypePayload, + writeCachedTaskTypePayload, + getStoredTimeManagementSelectedDate, + setStoredTimeManagementSelectedDate, + renderModal, + cleanString, + normalizeString, + getDateInputValue, + parseDatePreservingDateOnly, + getFieldValue, + extractArray, + getErrorMessage, + cssEscape, + getTimeManagementRows, + isTimeManagementSaveEnabled, + getTimeManagementPendingChanges, + createTimeManagementSavePayload, + isNonServiceRequestSrNumber, + normalizeInfoWeekResponse, + normalizeTimeManagementServiceRequests, + mergeTimeManagementServiceRequests, + normalizeTimeManagementEntryRows, + normalizeTimeManagementEntryRowSelection, + getActivityRequestSrNumbers, + normalizeActivityRequest, + normalizeTaskTypeRequest, + getTimeManagementTaskTypeOptions, + getTimeManagementActivityOptions, + findTimeManagementServiceRequest, + findOptionValue, + }); + const { + toggleTimeManagementDatePicker, + shiftTimeManagementDatePickerMonth, + selectTimeManagementDate, + shiftTimeManagementDateByWeeks, + openTimeManagementModal, + loadTimeManagementData, + addTimeManagementEntryRow, + removeTimeManagementEntryRow, + updateTimeManagementSelectedSr, + updateTimeManagementField, + updateTimeManagementDayHours, + saveTimeManagementChanges, + selectTimeManagementComboboxOption, + filterTimeManagementCombobox, + handleTimeManagementComboboxKeydown, + refreshTaskTypeCache, + } = manageTimeController; + + workbenchController = globalThis.ArchPanelWorkbenchController.create({ + appState, + IDS, + DETAIL_SORT_KEYS, + hcmPage, + workbenchRepository, + comcipRepository, + readCachedDataset, + writeCachedDataset, + readCachedWorklistToken, + setStoredCalendarView, + refreshTaskTypeCache, + renderModal, + cleanString, + normalizeString, + toNumber, + toIsoDateString, + mapWithConcurrency, + getLocalDateKey, + getErrorMessage, + formatWholeNumber, + formatProgressPercent, + formatDate, + teamContainsUser, + stripTransientFields, + createExportPayload, + createDatasetSnapshot, + buildRampForecastComparisonRows, + parseForecastMonthNumber, + createForecastUpdatePayload, + normalizeTimeEntriesSummary, + getCalendarAnchorDate, + }); + const { + refreshData, + hydrateDatasetFromCache, + openDetailModal, + openActionFormModal, + openRampComparisonModal, + openForecastUpdateConfirmModal, + submitForecastUpdate, + openTimeEntriesDrawer, + shiftCalendarPeriod, + goToCurrentCalendarPeriod, + setCalendarView, + submitActionForm, + toggleDetailSort, + } = workbenchController; + + modalEvents = globalThis.ArchPanelModalEvents.create({ + appState, + renderModal, + closeModal, + closeDetailModal, + toggleTheme, + toggleSidebar, + refreshData, + shiftCalendarPeriod, + goToCurrentCalendarPeriod, + setCalendarView, + downloadFile, + createExportHtml, + openDetailModal, + openActionFormModal, + openRampComparisonModal, + openForecastUpdateConfirmModal, + submitForecastUpdate, + openTimeEntriesDrawer, + openTimeManagementModal, + addTimeManagementEntryRow, + saveTimeManagementChanges, + removeTimeManagementEntryRow, + toggleTimeManagementDatePicker, + shiftTimeManagementDateByWeeks, + shiftTimeManagementDatePickerMonth, + selectTimeManagementDate, + selectTimeManagementComboboxOption, + submitActionForm, + toggleDetailSort, + loadTimeManagementData, + updateTimeManagementSelectedSr, + updateTimeManagementField, + updateTimeManagementDayHours, + filterTimeManagementCombobox, + handleTimeManagementComboboxKeydown, + }); function openModal() { const overlay = ensureModal(); @@ -3475,3996 +954,6 @@ }, 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 current resource user", 8); - - const resourceCurrentUser = await fetchResourceCurrentUser(user.userEmail).catch( - () => null - ); - - 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().catch( - () => [] - ); - updateLoadingProgress("Refreshing time management task types", 94); - await refreshTaskTypeCache().catch(() => null); - - updateLoadingProgress("Preparing dashboard cache", 96); - const finalCustomers = Array.from(customersMap.values()); - const exportPayload = createExportPayload( - user, - finalCustomers, - pendingServiceRequests, - resourceCurrentUser - ); - 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.", - }); - } - - async function sendComcipRequestMessage({ method, url, payload, timeoutMessage }) { - const useTimeEntryFrame = isComcipTimeEntryUrl(url); - - if (useTimeEntryFrame) { - await ensureComcipTimeEntryFrame(); - } - - 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, - useTimeEntryFrame, - 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 isComcipTimeEntryUrl(url) { - return String(url || "").includes("/ic/builder/rt/oalset_timeentrymobile/live"); - } - - function ensureComcipTimeEntryFrame() { - const existingFrame = document.getElementById(COMCIP_TIME_ENTRY_FRAME_ID); - - if (existingFrame instanceof HTMLIFrameElement) { - existingFrame.name = COMCIP_TIME_ENTRY_FRAME_ID; - return Promise.resolve(existingFrame); - } - - return new Promise((resolve, reject) => { - const frame = document.createElement("iframe"); - const timeout = window.setTimeout(() => { - reject(new Error("Timed out while preparing COMCIP Time Entry session frame.")); - }, 45000); - - frame.id = COMCIP_TIME_ENTRY_FRAME_ID; - frame.name = COMCIP_TIME_ENTRY_FRAME_ID; - frame.title = "COMCIP Time Entry session"; - frame.src = COMCIP_REQUEST.timeEntryAppUrl; - frame.setAttribute("aria-hidden", "true"); - frame.tabIndex = -1; - frame.style.position = "fixed"; - frame.style.width = "1px"; - frame.style.height = "1px"; - frame.style.opacity = "0"; - frame.style.pointerEvents = "none"; - frame.style.border = "0"; - frame.style.left = "-10000px"; - frame.style.top = "0"; - - frame.addEventListener( - "load", - () => { - window.clearTimeout(timeout); - resolve(frame); - }, - { once: true } - ); - frame.addEventListener( - "error", - () => { - window.clearTimeout(timeout); - frame.remove(); - reject(new Error("Unable to load COMCIP Time Entry session frame.")); - }, - { once: true } - ); - - document.documentElement.appendChild(frame); - }); - } - - 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 currentFrameToken = syncWorklistIframeToken({ force: true })?.token; - - const worklistToken = currentFrameToken || (await readCachedWorklistToken()); - - if (!worklistToken) { - throw new Error( - "Unable to resolve Worklist token. Reload the FuseWelcome page so the Worklist iframe token can be captured before refreshing data." - ); - } - - const url = new URL(API_ENDPOINTS.worklistCurrentUser); - url.search = new URLSearchParams({ - source: "saasui", - _: String(Date.now()), - token: worklistToken, - }).toString(); - - appState.sessionAuthHeaders = {}; - - const payload = await fetchJson(url.toString(), { forceExtension: true }); - const userPayload = normalizeWorklistUserPayload(payload); - const firstName = cleanString( - getFieldValue(userPayload, ["FirstName", "firstName", "User.FirstName"]) - ); - const lastName = cleanString( - getFieldValue(userPayload, ["LastName", "lastName", "User.LastName"]) - ); - const userEmail = cleanString( - getFieldValue(userPayload, [ - "Email", - "email", - "EmailAddress", - "emailAddress", - "User.Email", - "User.email", - "User.EmailAddress", - ]) - ); - const displayName = firstNonEmptyString([ - `${firstName} ${lastName}`.trim(), - userPayload?.DisplayName, - userPayload?.displayName, - userEmail, - ]); - - if (!userEmail) { - const userKeys = - userPayload && typeof userPayload === "object" - ? Object.keys(userPayload).join(", ") - : ""; - throw new Error( - `Unable to resolve userEmail from Worklist user response. Available top-level keys: ${Object.keys( - payload || {} - ).join(", ")}${userKeys ? ` | User keys: ${userKeys}` : ""}` - ); - } - - appState.sessionAuthHeaders = {}; - - return { - userEmail, - name: displayName || userEmail, - avatar: cleanString(getFieldValue(userPayload, ["ImageURL", "imageUrl", "User.ImageURL"])), - administrator: Boolean( - getFieldValue(userPayload, ["Administrator", "administrator", "User.Administrator"]) - ), - }; - } - - function normalizeWorklistUserPayload(payload) { - const candidate = payload?.User ?? payload?.user ?? payload; - - if (Array.isArray(candidate)) { - return candidate[0] || {}; - } - - if (candidate?.User) { - return normalizeWorklistUserPayload(candidate); - } - - return candidate || {}; - } - - async function fetchResourceCurrentUser(userEmail) { - const normalizedUserEmail = cleanString(userEmail); - - if (!normalizedUserEmail) { - throw new Error("Unable to request resource current user without userEmail."); - } - - const url = `${COMCIP_REQUEST.resourceCurrentUserUrl}?emailAddress=${encodeURIComponent( - normalizedUserEmail - )}`; - 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 resource user request failed (${response?.status || 0}).` - ); - } - - return response.payload; - } - - - 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 (shouldUseSpaFrameFetch(url) && !options.forceExtension) { - return fetchJsonViaSpaFrame(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 = resolveRequestUrl(url); - - if (!normalized) { - return false; - } - - try { - const parsedUrl = new URL(normalized, window.location.origin); - return parsedUrl.origin !== window.location.origin; - } catch { - return false; - } - } - - function shouldUseSpaFrameFetch(url) { - if (!isHcmWelcomePage()) { - return false; - } - - const normalized = resolveRequestUrl(url); - - if (!normalized) { - return false; - } - - try { - return new URL(normalized).origin === TARGET_ORIGIN; - } catch { - return false; - } - } - - async function fetchJsonViaSpaFrame(url, options = {}) { - const requestUrl = resolveRequestUrl(url); - const bridgeHeaders = await requestBridgeHeaders({ - url: requestUrl, - headers: options.headers, - body: options.body, - includeAllAuth: true, - }).catch(() => ({})); - - await ensureSpaFrame(); - - const response = await new Promise((resolve, reject) => { - chrome.runtime.sendMessage( - { - type: "ARCH_PANEL_SPA_FRAME_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 && - response.status === 0 && - /SPA Workbench frame did not return a result/i.test(cleanString(response.error)) - ) { - throw new Error(response.error || "SPA Workbench iframe did not return a result."); - } - - 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 debugFrameUrl = cleanString(response.debug?.frameUrl); - const debugUrl = cleanString(response.debug?.url); - const debugParts = [ - debugUrl ? `url: ${debugUrl}` : "", - debugFrameUrl ? `frame: ${debugFrameUrl}` : "", - 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 ensureSpaFrame() { - const frameHost = ensureSpaFrameHost(); - const existingFrame = document.getElementById(IDS.spaFrame); - const frameUrl = TARGET_APP_URL; - - if (existingFrame instanceof HTMLIFrameElement) { - if (existingFrame.parentElement !== frameHost) { - frameHost.appendChild(existingFrame); - } - - if (shouldNavigateSpaFrame(existingFrame, frameUrl)) { - navigateSpaFrame(existingFrame, frameUrl); - } - - return waitForFrameLoad(existingFrame); - } - - const frame = document.createElement("iframe"); - frame.id = IDS.spaFrame; - frame.name = IDS.spaFrame; - frame.dataset.archPanelFrame = "spa-workbench"; - frame.setAttribute("aria-hidden", "true"); - frame.setAttribute("referrerpolicy", "no-referrer-when-downgrade"); - frame.tabIndex = -1; - Object.assign(frame.style, { - position: "absolute", - width: "1440px", - height: "1000px", - opacity: "0", - pointerEvents: "none", - border: "0", - left: "0", - top: "0", - }); - - frameHost.appendChild(frame); - navigateSpaFrame(frame, frameUrl); - - return waitForFrameLoad(frame); - } - - function ensureSpaFrameHost() { - const existingHost = document.getElementById(IDS.spaFrameHost); - - if (existingHost) { - const desiredParent = document.body || document.documentElement; - - if (desiredParent && existingHost.parentElement !== desiredParent) { - desiredParent.appendChild(existingHost); - } - - Object.assign(existingHost.style, { - position: "fixed", - width: "1440px", - height: "1000px", - overflow: "hidden", - opacity: "0", - pointerEvents: "none", - border: "0", - left: "-20000px", - top: "0", - zIndex: "0", - }); - - return existingHost; - } - - const host = document.createElement("div"); - host.id = IDS.spaFrameHost; - host.setAttribute("aria-hidden", "true"); - Object.assign(host.style, { - position: "fixed", - width: "1440px", - height: "1000px", - overflow: "hidden", - opacity: "0", - pointerEvents: "none", - border: "0", - left: "-20000px", - top: "0", - zIndex: "0", - }); - - (document.body || document.documentElement).appendChild(host); - - return host; - } - - function shouldNavigateSpaFrame(frame, frameUrl) { - const currentSrc = cleanString(frame.getAttribute("src") || frame.src); - - if (!currentSrc || currentSrc === "about:blank") { - return true; - } - - try { - return new URL(currentSrc, window.location.href).toString() !== frameUrl; - } catch { - return currentSrc !== frameUrl; - } - } - - function navigateSpaFrame(frame, frameUrl) { - frame.dataset.archPanelLoaded = "false"; - frame.src = frameUrl; - } - - function waitForFrameLoad(frame) { - if (frame.dataset.archPanelLoaded === "true") { - return Promise.resolve(); - } - - return new Promise((resolve) => { - const timeout = window.setTimeout(() => { - resolve(); - }, 12000); - - frame.addEventListener( - "load", - () => { - frame.dataset.archPanelLoaded = "true"; - window.clearTimeout(timeout); - window.setTimeout(resolve, 1200); - }, - { once: true } - ); - }); - } - - async function fetchJsonViaExtension(url, options = {}) { - const requestUrl = resolveRequestUrl(url); - 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 options.omitAuthHeaders ? stripAuthHeaders(combinedHeaders) : combinedHeaders; - } - - function stripAuthHeaders(headers = {}) { - const sanitizedHeaders = {}; - - for (const [key, value] of Object.entries(headers || {})) { - const normalizedKey = cleanString(key).toLowerCase(); - - if ( - normalizedKey === "authorization" || - normalizedKey === "x-id-token" || - normalizedKey === "spa-ts-authorization" || - normalizedKey.endsWith("-authorization") - ) { - continue; - } - - sanitizedHeaders[key] = value; - } - - return sanitizedHeaders; - } - - 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 = [], - resourceCurrentUser = null - ) { - return { - generatedAt: new Date().toISOString(), - user, - resourceCurrentUser, - customers, - pendingServiceRequests, - }; - } - - function createDatasetSnapshot(exportPayload) { - const normalizedPayload = normalizeCachedExportPayload(exportPayload); - - return { - user: normalizedPayload.user, - resourceCurrentUser: normalizedPayload.resourceCurrentUser, - 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, - ]) || "", - avatar: cleanString(exportPayload?.user?.avatar || exportPayload?.user?.imageUrl), - administrator: Boolean(exportPayload?.user?.administrator), - }; - const resourceCurrentUser = exportPayload?.resourceCurrentUser ?? null; - 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, - resourceCurrentUser, - 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.")); - }; - }); - } - - async function readCachedTaskTypePayload() { - return withCacheStore("readonly", (store, resolve, reject) => { - const request = store.get(CACHE.taskTypeKey); - - request.onsuccess = () => { - resolve(request.result?.payload || null); - }; - - request.onerror = () => { - reject(request.error || new Error("Unable to read the cached task types.")); - }; - }); - } - - async function writeCachedTaskTypePayload(payload) { - await withCacheStore("readwrite", (store, resolve, reject) => { - const request = store.put({ - key: CACHE.taskTypeKey, - updatedAt: new Date().toISOString(), - payload, - }); - - request.onsuccess = () => { - resolve(); - }; - - request.onerror = () => { - reject(request.error || new Error("Unable to write the cached task types.")); - }; - }); - } - - async function writeCachedWorklistToken(tokenPayload) { - const token = cleanString(tokenPayload?.token); - - if (!token) { - return; - } - - await withCacheStore("readwrite", (store, resolve, reject) => { - const request = store.put({ - key: CACHE.worklistTokenKey, - updatedAt: new Date().toISOString(), - token, - sourceUrl: cleanString(tokenPayload?.url), - }); - - request.onsuccess = () => { - resolve(); - }; - - request.onerror = () => { - reject(request.error || new Error("Unable to write the cached Worklist token.")); - }; - }); - } - - async function readCachedWorklistToken() { - const record = await withCacheStore("readonly", (store, resolve, reject) => { - const request = store.get(CACHE.worklistTokenKey); - - request.onsuccess = () => { - resolve(request.result || null); - }; - - request.onerror = () => { - reject(request.error || new Error("Unable to read the cached Worklist token.")); - }; - }); - - return cleanString(record?.token); - } - - 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, - view = "month", - anchorDateKey = "" - ) { - const today = new Date(); - const normalizedView = view === "week" ? "week" : "month"; - const monthStart = new Date(year, month, 1); - const anchorDate = getCalendarAnchorDate(year, month, anchorDateKey); - const calendarStart = - normalizedView === "week" - ? getWeekStartDate(anchorDate) - : getWeekStartDate(monthStart); - const dayCount = normalizedView === "week" ? 7 : 42; - 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: dayCount }, (_, 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: normalizedView === "week" || date.getMonth() === month, - isToday: dateKey === getLocalDateKey(today), - workloads: workloadsForDay, - }; - }); - const weekEnd = new Date(calendarStart); - weekEnd.setDate(calendarStart.getDate() + 6); - const monthLabel = new Intl.DateTimeFormat("en-US", { - month: "long", - year: "numeric", - }).format(monthStart); - const weekLabel = `${formatDate(calendarStart)} - ${formatDate(weekEnd)}`; - - return { - view: normalizedView, - periodLabel: normalizedView === "week" ? weekLabel : monthLabel, - days, - totalVisibleWorkloads: days.reduce( - (sum, day) => sum + day.workloads.length, - 0 - ), - }; - } - - function getCalendarAnchorDate(year, month, anchorDateKey) { - const anchorDate = parseDatePreservingDateOnly(anchorDateKey); - - if (!Number.isNaN(anchorDate.getTime())) { - return anchorDate; - } - - return new Date(year, month, 1); - } - - function getWeekStartDate(value) { - const date = parseDatePreservingDateOnly(value); - const weekStart = Number.isNaN(date.getTime()) ? new Date() : new Date(date); - - weekStart.setDate(weekStart.getDate() - weekStart.getDay()); - return weekStart; - } - - 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 headers = { - accept: "*/*", - "content-type": "application/json", - }; - - await fetchJson(url, { - method: "PATCH", - omitAuthHeaders: true, - 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; - } - - async function openTimeManagementModal() { - const selectedDate = getStoredTimeManagementSelectedDate(); - - appState.timeManagementModal = createTimeManagementLoadingState(selectedDate); - renderModal(); - await loadTimeManagementData(selectedDate); - } - - async function loadTimeManagementData(selectedDate) { - const normalizedDate = cleanString(selectedDate) || getDateInputValue(new Date()); - const requestId = `${Date.now()}-${++requestCounter}`; - - setStoredTimeManagementSelectedDate(normalizedDate); - appState.timeManagementDatePicker = { - ...appState.timeManagementDatePicker, - isOpen: false, - }; - appState.timeManagementModal = { - ...(appState.timeManagementModal || {}), - requestId, - selectedDate: normalizedDate, - status: "loading", - errorMessage: "", - }; - renderModal(); - - try { - const resourcePartyId = getCurrentUserResourcePartyId(); - const taskTypePromise = fetchTaskTypeRequest(); - - if (!resourcePartyId) { - throw new Error("resourceCurrentUser.resourcePartyId não está disponível."); - } - - const weekInfoPayload = await fetchInfoWeekRequest(normalizedDate); - const weekDays = normalizeInfoWeekResponse(weekInfoPayload); - const weekId = cleanString(weekDays[0]?.weekId); - - if (!weekId) { - throw new Error("WeekId não encontrado no retorno de infoWeekRequest."); - } - - const timeEntryWeekUrl = buildTimeEntryWeekUrl(resourcePartyId, weekId); - const [srPayload, taskTypePayload, timeEntryWeekPayload] = await Promise.all([ - fetchActiveServiceRequests(resourcePartyId, weekId), - taskTypePromise, - fetchTimeEntryWeek(timeEntryWeekUrl), - ]); - const timeEntryRows = normalizeTimeManagementEntryRows( - timeEntryWeekPayload, - weekDays - ); - const serviceRequests = mergeTimeManagementServiceRequests( - normalizeTimeManagementServiceRequests(srPayload), - timeEntryRows - ); - const activitySrNumbers = getActivityRequestSrNumbers(srPayload, serviceRequests); - const activityRequest = await fetchCachedActivityRequest(activitySrNumbers); - const activityRequestUrl = activityRequest.url; - const activityPayload = activityRequest.payload; - - const activityOptionsBySr = normalizeActivityRequest( - activityPayload, - serviceRequests - ); - const taskTypes = normalizeTaskTypeRequest(taskTypePayload); - const rows = timeEntryRows; - const selectedSrNumber = rows[0]?.selectedSrNumber || ""; - const normalizedRows = rows.map((row, index) => - normalizeTimeManagementEntryRowSelection( - row, - index, - activityOptionsBySr, - taskTypes - ) - ); - - if (appState.timeManagementModal?.requestId !== requestId) { - return; - } - - appState.timeManagementModal = { - requestId, - selectedDate: normalizedDate, - status: "ready", - errorMessage: "", - weekId, - weekDays, - serviceRequests, - selectedSrNumber, - activityOptionsBySr, - activityRequestUrl, - activityRequestPayload: activityPayload, - timeEntryRows: normalizedRows, - selectedActivityValue: - normalizedRows[0]?.selectedActivityValue || "", - taskTypes, - selectedTaskTypeValue: normalizedRows[0]?.selectedTaskTypeValue || "", - highlightRowId: "", - deletedTimeEntryIds: [], - isSaving: false, - saveErrorMessage: "", - }; - renderModal(); - } catch (error) { - if (appState.timeManagementModal?.requestId !== requestId) { - return; - } - - appState.timeManagementModal = { - ...(appState.timeManagementModal || {}), - requestId, - selectedDate: normalizedDate, - status: "error", - errorMessage: getErrorMessage(error), - }; - renderModal(); - } - } - - function createTimeManagementLoadingState(selectedDate) { - return { - requestId: "", - selectedDate, - status: "loading", - errorMessage: "", - weekId: "", - weekDays: [], - serviceRequests: [], - selectedSrNumber: "", - activityOptionsBySr: {}, - activityRequestUrl: "", - activityRequestPayload: null, - selectedActivityValue: "", - taskTypes: [], - selectedTaskTypeValue: "", - timeEntryRows: [], - highlightRowId: "", - deletedTimeEntryIds: [], - isSaving: false, - saveErrorMessage: "", - }; - } - - function getTimeManagementRows(modal) { - return modal.timeEntryRows || []; - } - - function isTimeManagementSaveEnabled(modal) { - const pendingChanges = getTimeManagementPendingChanges(modal); - const changedRows = [ - ...pendingChanges.newRows, - ...pendingChanges.updatedRows, - ]; - - if ( - !pendingChanges.newRows.length && - !pendingChanges.updatedRows.length && - !pendingChanges.deletedIds.length - ) { - return false; - } - - return changedRows.every((row) => isTimeManagementRowReadyToSave(row)); - } - - function isTimeManagementRowReadyToSave(row) { - const selectedSrNumber = cleanString(row?.selectedSrNumber); - - if (!selectedSrNumber) { - return false; - } - - if (!hasPositiveTimeManagementHours(row)) { - return false; - } - - if (isNonServiceRequestSrNumber(selectedSrNumber)) { - return Boolean(cleanString(row?.selectedTaskTypeValue)); - } - - return Boolean( - cleanString(row?.selectedActivityValue) && - cleanString(row?.selectedTaskTypeValue) - ); - } - - function hasPositiveTimeManagementHours(row) { - return Object.values(row?.dayHours || {}).some((value) => { - const normalizedValue = cleanString(value); - - if (!normalizedValue) { - return false; - } - - const numericValue = Number(normalizedValue.replace(",", ".")); - - return Number.isFinite(numericValue) && numericValue > 0; - }); - } - - function createBlankTimeManagementEntryRow(srNumber = "", source = "blank") { - return { - id: `row-${Date.now()}-${Math.random().toString(16).slice(2)}`, - timeEntryId: "", - selectedSrNumber: cleanString(srNumber), - selectedActivityValue: "", - selectedTaskTypeValue: "", - dayHours: {}, - source, - originalSignature: "", - }; - } - - function addTimeManagementEntryRow(type) { - if (!appState.timeManagementModal) { - return; - } - - const existingDuplicateRow = findExistingTimeManagementDuplicateRow( - appState.timeManagementModal, - type - ); - - if (existingDuplicateRow) { - highlightTimeManagementDuplicateRow(existingDuplicateRow.id); - return; - } - - const srNumber = type === "non-service" ? "non-service-sr" : ""; - const nextRow = createBlankTimeManagementEntryRow(srNumber, "manual"); - const nextRows = [ - ...getTimeManagementRows(appState.timeManagementModal), - nextRow, - ]; - - appState.timeManagementModal = { - ...appState.timeManagementModal, - timeEntryRows: nextRows, - highlightRowId: "", - saveErrorMessage: "", - }; - appState.pendingFocusSelector = getTimeManagementComboboxFocusSelector( - nextRow.id, - type === "non-service" ? "taskType" : "sr" - ); - renderModal(); - } - - function removeTimeManagementEntryRow(rowId) { - if (!appState.timeManagementModal) { - return; - } - - const normalizedRowId = cleanString(rowId); - const rows = getTimeManagementRows(appState.timeManagementModal); - const removedRow = rows.find((row) => cleanString(row?.id) === normalizedRowId); - const removedTimeEntryId = cleanString(removedRow?.timeEntryId); - const remainingRows = rows.filter( - (row) => cleanString(row?.id) !== normalizedRowId - ); - const nextRows = remainingRows; - const deletedTimeEntryIds = removedTimeEntryId - ? Array.from( - new Set([ - ...(appState.timeManagementModal.deletedTimeEntryIds || []), - removedTimeEntryId, - ]) - ) - : appState.timeManagementModal.deletedTimeEntryIds || []; - - appState.timeManagementModal = { - ...appState.timeManagementModal, - timeEntryRows: nextRows, - selectedSrNumber: nextRows[0]?.selectedSrNumber || "", - selectedActivityValue: nextRows[0]?.selectedActivityValue || "", - selectedTaskTypeValue: nextRows[0]?.selectedTaskTypeValue || "", - highlightRowId: "", - deletedTimeEntryIds, - saveErrorMessage: "", - }; - appState.pendingFocusSelector = getTimeManagementPostDeleteFocusSelector( - nextRows, - rows, - normalizedRowId - ); - renderModal(); - } - - function getTimeManagementComboboxFocusSelector(rowId, field) { - return `.arch-panel-extension-combobox input[data-row-id="${cssEscape(rowId)}"][data-combobox-field="${cssEscape(field)}"]`; - } - - function getTimeManagementPostDeleteFocusSelector(nextRows, previousRows, removedRowId) { - if (!nextRows.length) { - return ""; - } - - const removedIndex = previousRows.findIndex( - (row) => cleanString(row?.id) === cleanString(removedRowId) - ); - const focusIndex = Math.min(Math.max(removedIndex, 0), nextRows.length - 1); - const focusRow = nextRows[focusIndex]; - - return focusRow?.id - ? `.arch-panel-extension-time-management-delete[data-row-id="${cssEscape(focusRow.id)}"]` - : ""; - } - - function getTimeManagementRowById(modal, rowId) { - const rows = getTimeManagementRows(modal); - const normalizedRowId = cleanString(rowId); - - return ( - rows.find((row) => cleanString(row?.id) === normalizedRowId) || - rows[0] || - null - ); - } - - function updateTimeManagementRows(modal, rowId, nextRow) { - const rows = getTimeManagementRows(modal); - const normalizedRowId = cleanString(rowId || nextRow?.id); - - return rows.map((row, index) => { - const isTarget = normalizedRowId - ? cleanString(row?.id) === normalizedRowId - : index === 0; - - return isTarget - ? { - ...row, - ...nextRow, - id: row?.id || nextRow?.id || `row-${index + 1}`, - } - : row; - }); - } - - function updateTimeManagementSelectedSr(srNumber, rowId = "") { - if (!appState.timeManagementModal) { - return; - } - - const targetRow = getTimeManagementRowById(appState.timeManagementModal, rowId); - const selectedServiceRequest = findTimeManagementServiceRequest( - appState.timeManagementModal, - srNumber - ); - const selectedSrNumber = selectedServiceRequest?.srNumber || cleanString(srNumber); - const duplicateRow = findDuplicateTimeManagementSrRow( - appState.timeManagementModal, - selectedSrNumber, - rowId - ); - - if (duplicateRow) { - highlightTimeManagementDuplicateRow(duplicateRow.id); - return; - } - - const nextRow = { - ...(targetRow || createBlankTimeManagementEntryRow()), - selectedSrNumber, - selectedActivityValue: "", - selectedTaskTypeValue: "", - }; - const nextRows = updateTimeManagementRows( - appState.timeManagementModal, - rowId, - nextRow - ); - - appState.timeManagementModal = { - ...appState.timeManagementModal, - timeEntryRows: nextRows, - selectedSrNumber: nextRows[0]?.selectedSrNumber || selectedSrNumber, - selectedActivityValue: nextRows[0]?.selectedActivityValue || "", - selectedTaskTypeValue: nextRows[0]?.selectedTaskTypeValue || "", - highlightRowId: "", - saveErrorMessage: "", - }; - renderModal(); - } - - function updateTimeManagementField(fieldName, inputValue, rowId = "") { - if (!appState.timeManagementModal) { - return; - } - - const targetRow = getTimeManagementRowById(appState.timeManagementModal, rowId); - const normalizedInput = cleanString(inputValue); - let value = normalizedInput; - - if (fieldName === "selectedActivityValue") { - const options = getTimeManagementActivityOptions( - appState.timeManagementModal, - targetRow?.selectedSrNumber || appState.timeManagementModal.selectedSrNumber - ); - value = findOptionValue(options, normalizedInput) || normalizedInput; - } - - if (fieldName === "selectedTaskTypeValue") { - const options = getTimeManagementTaskTypeOptions( - appState.timeManagementModal, - targetRow?.selectedSrNumber || appState.timeManagementModal.selectedSrNumber - ); - value = - findOptionValue(options, normalizedInput) || normalizedInput; - - const duplicateRow = findDuplicateTimeManagementNonServiceTaskTypeRow( - appState.timeManagementModal, - value, - rowId - ); - - if (duplicateRow) { - highlightTimeManagementDuplicateRow(duplicateRow.id); - return; - } - } - - const nextRows = updateTimeManagementRows(appState.timeManagementModal, rowId, { - ...(targetRow || createBlankTimeManagementEntryRow()), - [fieldName]: value, - }); - - appState.timeManagementModal = { - ...appState.timeManagementModal, - timeEntryRows: nextRows, - [fieldName]: value, - highlightRowId: "", - saveErrorMessage: "", - }; - } - - function findDuplicateTimeManagementSrRow(modal, srNumber, currentRowId = "") { - const normalizedSrNumber = normalizeString(srNumber); - const normalizedCurrentRowId = cleanString(currentRowId); - - if (!normalizedSrNumber || isNonServiceRequestSrNumber(srNumber)) { - return null; - } - - return getTimeManagementRows(modal).find((row) => { - return ( - cleanString(row?.id) !== normalizedCurrentRowId && - normalizeString(row?.selectedSrNumber) === normalizedSrNumber - ); - }) || null; - } - - function findExistingTimeManagementDuplicateRow(modal, type) { - const seen = new Map(); - - for (const row of getTimeManagementRows(modal)) { - const rowId = cleanString(row?.id); - let key = ""; - - if (type === "non-service") { - if (!isNonServiceRequestSrNumber(row?.selectedSrNumber)) { - continue; - } - - key = normalizeString(row?.selectedTaskTypeValue); - } else { - if (isNonServiceRequestSrNumber(row?.selectedSrNumber)) { - continue; - } - - key = normalizeString(row?.selectedSrNumber); - } - - if (!key) { - continue; - } - - if (seen.has(key)) { - return getTimeManagementRows(modal).find( - (candidate) => cleanString(candidate?.id) === seen.get(key) - ) || row; - } - - seen.set(key, rowId); - } - - return null; - } - - function findDuplicateTimeManagementNonServiceTaskTypeRow( - modal, - taskTypeValue, - currentRowId = "" - ) { - const normalizedTaskTypeValue = normalizeString(taskTypeValue); - const normalizedCurrentRowId = cleanString(currentRowId); - - if (!normalizedTaskTypeValue) { - return null; - } - - const currentRow = getTimeManagementRowById(modal, currentRowId); - - if (!isNonServiceRequestSrNumber(currentRow?.selectedSrNumber)) { - return null; - } - - return getTimeManagementRows(modal).find((row) => { - return ( - cleanString(row?.id) !== normalizedCurrentRowId && - isNonServiceRequestSrNumber(row?.selectedSrNumber) && - normalizeString(row?.selectedTaskTypeValue) === normalizedTaskTypeValue - ); - }) || null; - } - - function highlightTimeManagementDuplicateRow(rowId) { - const normalizedRowId = cleanString(rowId); - - if (!appState.timeManagementModal || !normalizedRowId) { - return; - } - - appState.pendingTimeManagementHighlightRowId = normalizedRowId; - appState.timeManagementModal = { - ...appState.timeManagementModal, - highlightRowId: normalizedRowId, - }; - renderModal(); - } - - function updateTimeManagementDayHours(rowId, dateKey, inputValue) { - if (!appState.timeManagementModal) { - return; - } - - const targetRow = getTimeManagementRowById(appState.timeManagementModal, rowId); - const nextRow = { - ...(targetRow || createBlankTimeManagementEntryRow()), - dayHours: { - ...(targetRow?.dayHours || {}), - [cleanString(dateKey)]: cleanString(inputValue), - }, - }; - - appState.timeManagementModal = { - ...appState.timeManagementModal, - timeEntryRows: updateTimeManagementRows( - appState.timeManagementModal, - rowId, - nextRow - ), - highlightRowId: "", - saveErrorMessage: "", - }; - renderModal(); - } - - async function saveTimeManagementChanges() { - const modal = appState.timeManagementModal; - - if (!modal || modal.isSaving || !isTimeManagementSaveEnabled(modal)) { - return; - } - - appState.timeManagementModal = { - ...modal, - isSaving: true, - saveErrorMessage: "", - }; - renderModal(); - - try { - const resourceId = getCurrentUserResourcePartyId(); - const weekId = cleanString(modal.weekId); - - if (!resourceId) { - throw new Error("resourceCurrentUser.resourcePartyId não está disponível."); - } - - if (!weekId) { - throw new Error("WeekId não encontrado para gravação do apontamento."); - } - - const changes = getTimeManagementPendingChanges(modal); - const baseUrl = COMCIP_REQUEST.timeEntriesUrl; - const deletedIds = changes.deletedIds; - const updatedPayload = changes.updatedRows.map((row) => - createTimeManagementSavePayload(row, modal.weekDays, weekId, resourceId, { - includeId: true, - }) - ); - const newPayload = changes.newRows.map((row) => - createTimeManagementSavePayload(row, modal.weekDays, weekId, resourceId) - ); - - for (const timeEntryId of deletedIds) { - await mutateComcipPayload( - "DELETE", - `${baseUrl}/${encodeURIComponent(timeEntryId)}`, - null, - "timeEntryDelete" - ); - } - - if (updatedPayload.length) { - await mutateComcipPayload( - "PUT", - baseUrl, - updatedPayload, - "timeEntryUpdate" - ); - } - - if (newPayload.length) { - await mutateComcipPayload("POST", baseUrl, newPayload, "timeEntryCreate"); - } - - await loadTimeManagementData(modal.selectedDate || getDateInputValue(new Date())); - } catch (error) { - appState.timeManagementModal = { - ...(appState.timeManagementModal || modal), - isSaving: false, - saveErrorMessage: getErrorMessage(error), - }; - renderModal(); - } - } - - function getTimeManagementPendingChanges(modal) { - const rows = getTimeManagementRows(modal); - - return { - newRows: rows.filter((row) => isNewTimeManagementRow(row)), - updatedRows: rows.filter((row) => isUpdatedTimeManagementRow(row)), - deletedIds: (modal?.deletedTimeEntryIds || []) - .map((id) => cleanString(id)) - .filter(Boolean), - }; - } - - function isNewTimeManagementRow(row) { - return cleanString(row?.source) === "manual" && !cleanString(row?.timeEntryId); - } - - function isUpdatedTimeManagementRow(row) { - const timeEntryId = cleanString(row?.timeEntryId); - const originalSignature = cleanString(row?.originalSignature); - - return Boolean( - timeEntryId && - originalSignature && - originalSignature !== createTimeManagementRowSignature(row) - ); - } - - function createTimeManagementRowSignature(row) { - return JSON.stringify({ - srNumber: cleanString(row?.selectedSrNumber), - activityCode: cleanString(row?.selectedActivityValue), - taskType: cleanString(row?.selectedTaskTypeValue), - dayHours: normalizeTimeManagementDayHours(row?.dayHours || {}), - }); - } - - function normalizeTimeManagementDayHours(dayHours) { - return Object.keys(dayHours || {}) - .sort() - .reduce((normalized, key) => { - normalized[key] = formatTimeManagementHourPayloadValue(dayHours[key]); - return normalized; - }, {}); - } - - function createTimeManagementSavePayload( - row, - weekDays, - weekId, - resourceId, - options = {} - ) { - const selectedSrNumber = cleanString(row?.selectedSrNumber); - const payload = { - srNumber: selectedSrNumber, - activityCode: isNonServiceRequestSrNumber(selectedSrNumber) - ? "" - : cleanString(row?.selectedActivityValue), - taskType: cleanString(row?.selectedTaskTypeValue), - deliveryType: "", - saturdayTaskHours: 0, - sundayTaskHours: 0, - mondayTaskHours: 0, - tuesdayTaskHours: 0, - wednesdayTaskHours: 0, - thursdayTaskHours: 0, - fridayTaskHours: 0, - notes: "", - weekId: toNumber(weekId) || weekId, - resourceId: cleanString(resourceId), - tag: isNonServiceRequestSrNumber(selectedSrNumber) - ? "NON_SERVICE" - : "SERVICE", - }; - - if (options.includeId) { - const id = cleanString(row?.timeEntryId); - payload.id = toNumber(id) || id; - } - - for (const day of weekDays || []) { - const weekdayName = getTimeManagementWeekdayName(day); - const dateKey = getTimeManagementDayKey(day); - const payloadKey = `${weekdayName}TaskHours`; - - if (payloadKey in payload) { - payload[payloadKey] = formatTimeManagementHourPayloadValue( - row?.dayHours?.[dateKey] - ); - } - } - - return payload; - } - - function formatTimeManagementHourPayloadValue(value) { - const normalizedValue = cleanString(value); - - if (!normalizedValue) { - return 0; - } - - const numericValue = Number(normalizedValue.replace(",", ".")); - - return Number.isFinite(numericValue) ? numericValue : 0; - } - - function selectTimeManagementComboboxOption(field, value, rowId = "") { - if (field === "sr") { - updateTimeManagementSelectedSr(value || "", rowId); - return; - } - - if (field === "activity") { - updateTimeManagementField("selectedActivityValue", value || "", rowId); - renderModal(); - return; - } - - if (field === "taskType") { - updateTimeManagementField("selectedTaskTypeValue", value || "", rowId); - renderModal(); - } - } - - function filterTimeManagementCombobox(input) { - const query = normalizeString(input.value); - const combobox = input.closest(".arch-panel-extension-combobox"); - - if (!combobox) { - return; - } - - const options = Array.from( - combobox.querySelectorAll(".arch-panel-extension-combobox-option") - ); - - for (const option of options) { - const searchText = normalizeString(option.getAttribute("data-search") || ""); - option.hidden = Boolean(query) && !searchText.includes(query); - } - - setActiveTimeManagementComboboxOption( - combobox, - getVisibleTimeManagementComboboxOptions(combobox).find((option) => - option.classList.contains("is-selected") - ) || getVisibleTimeManagementComboboxOptions(combobox)[0] - ); - } - - function handleTimeManagementComboboxKeydown(event, input) { - const combobox = input.closest(".arch-panel-extension-combobox"); - - if (!combobox) { - return; - } - - if (event.key === "Escape") { - clearActiveTimeManagementComboboxOption(combobox); - input.blur(); - return; - } - - const visibleOptions = getVisibleTimeManagementComboboxOptions(combobox); - - if (!visibleOptions.length) { - return; - } - - event.preventDefault(); - - if (event.key === "Enter") { - const activeOption = - visibleOptions.find((option) => option.classList.contains("is-active")) || - visibleOptions[0]; - activeOption.click(); - return; - } - - const activeIndex = visibleOptions.findIndex((option) => - option.classList.contains("is-active") - ); - const selectedIndex = visibleOptions.findIndex((option) => - option.classList.contains("is-selected") - ); - const baseIndex = activeIndex >= 0 ? activeIndex : selectedIndex; - let nextIndex = 0; - - if (event.key === "ArrowDown") { - nextIndex = baseIndex >= 0 ? baseIndex + 1 : 0; - } else if (event.key === "ArrowUp") { - nextIndex = baseIndex >= 0 ? baseIndex - 1 : visibleOptions.length - 1; - } else if (event.key === "Home") { - nextIndex = 0; - } else if (event.key === "End") { - nextIndex = visibleOptions.length - 1; - } - - if (nextIndex < 0) { - nextIndex = visibleOptions.length - 1; - } else if (nextIndex >= visibleOptions.length) { - nextIndex = 0; - } - - setActiveTimeManagementComboboxOption(combobox, visibleOptions[nextIndex]); - } - - function getVisibleTimeManagementComboboxOptions(combobox) { - return Array.from( - combobox.querySelectorAll(".arch-panel-extension-combobox-option") - ).filter((option) => !option.hidden); - } - - function setActiveTimeManagementComboboxOption(combobox, option) { - clearActiveTimeManagementComboboxOption(combobox); - - if (!option) { - return; - } - - option.classList.add("is-active"); - combobox - .querySelector("input[data-combobox-field]") - ?.setAttribute("aria-activedescendant", option.id || ""); - option.scrollIntoView({ block: "nearest" }); - } - - function clearActiveTimeManagementComboboxOption(combobox) { - combobox - .querySelectorAll(".arch-panel-extension-combobox-option.is-active") - .forEach((option) => option.classList.remove("is-active")); - combobox - .querySelector("input[data-combobox-field]") - ?.removeAttribute("aria-activedescendant"); - } - - async function fetchInfoWeekRequest(selectedDate) { - const dateValue = formatDateForTimeEntryRequest(selectedDate); - const url = `${COMCIP_REQUEST.timeEntryWeekDetailsUrl}?dateValue=${encodeURIComponent( - dateValue - )}`; - - return fetchComcipPayload(url, "infoWeekRequest"); - } - - async function fetchActiveServiceRequests(resourceId, weekId) { - const url = `${COMCIP_REQUEST.activeServiceRequestsUrl}?resourceId=${encodeURIComponent( - resourceId - )}&weekId=${encodeURIComponent(weekId)}`; - - return fetchComcipPayload(url, "sRRequest"); - } - - function buildTimeEntryWeekUrl(resourceId, weekId) { - return `${COMCIP_REQUEST.timeEntriesUrl}?resourceId=${encodeURIComponent( - resourceId - )}&sortBy=createdDate&sortOrder=ASC&weekId=${encodeURIComponent(weekId)}`; - } - - async function fetchTimeEntryWeek(url) { - return fetchComcipPayload(url, "timeEntryWeek"); - } - - async function fetchActivityRequest(srNumbers) { - return fetchComcipPayload(buildActivityRequestUrl(srNumbers), "activityRequest"); - } - - async function fetchCachedActivityRequest(srNumbers) { - const cacheKey = getActivityRequestCacheKey(srNumbers); - - if (!cacheKey) { - return { - url: "", - payload: null, - }; - } - - const cachedRequest = appState.timeManagementActivityCache.get(cacheKey); - - if (cachedRequest?.payload) { - return cachedRequest; - } - - if (cachedRequest?.promise) { - return cachedRequest.promise; - } - - const requestPromise = fetchActivityRequestWithFallback(srNumbers).then((result) => { - appState.timeManagementActivityCache.set(cacheKey, { - url: result.url, - payload: result.payload, - }); - - return result; - }).catch((error) => { - appState.timeManagementActivityCache.delete(cacheKey); - throw error; - }); - - appState.timeManagementActivityCache.set(cacheKey, { - promise: requestPromise, - }); - - return requestPromise; - } - - async function fetchActivityRequestWithFallback(srNumbers) { - let activityRequestUrl = buildActivityRequestUrl(srNumbers); - let activityPayload = await fetchComcipPayload( - activityRequestUrl, - "activityRequest" - ); - - if (activityRequestUrl && !hasActivityRequestItems(activityPayload)) { - activityRequestUrl = buildActivityRequestUrl( - ACTIVITY_REQUEST_REFERENCE_SR_NUMBERS - ); - activityPayload = await fetchComcipPayload( - activityRequestUrl, - "activityRequest" - ); - } - - return { - url: activityRequestUrl, - payload: activityPayload, - }; - } - - function getActivityRequestCacheKey(srNumbers) { - const normalizedSrNumbers = (srNumbers || []) - .map((srNumber) => cleanString(srNumber).toUpperCase()) - .filter((srNumber) => /^SR\d+$/i.test(srNumber)) - .sort(); - - return normalizedSrNumbers.join("|"); - } - - function buildActivityRequestUrl(srNumbers) { - const query = buildServiceRequestActivityQuery(srNumbers); - const fields = - "SrNumber,SRActivities_c,ProblemDescription,AccountPartyUniqueName,ServiceNew_c,OptyOwner_c,OptyName_c,OptyNumber_c,ReportedByPartyUniqueName,StatusCdMeaning,CountryText_c,AssigneePersonName,OptyStatus_c"; - - return [ - COMCIP_REQUEST.activityServiceRequestsUrl, - "?expand=ServiceRequest_SRToInternalSR_Tgt", - `&fields=${encodeActivityRequestParam(fields)}`, - "&limit=200", - "&onlyData=true", - `&q=${encodeActivityRequestParam(query)}`, - ].join(""); - } - - function encodeActivityRequestParam(value) { - return encodeURIComponent(String(value || "")) - .replace(/'/g, "%27") - .replace(/\(/g, "%28") - .replace(/\)/g, "%29"); - } - - async function fetchTaskTypeRequest() { - if (appState.taskTypeCache.promise) { - return appState.taskTypeCache.promise; - } - - appState.taskTypeCache.promise = (async () => { - return readCachedTaskTypePayload().catch(() => null); - })().finally(() => { - appState.taskTypeCache.promise = null; - }); - - return appState.taskTypeCache.promise; - } - - async function refreshTaskTypeCache() { - const payload = await fetchComcipPayload( - COMCIP_REQUEST.taskTypeUrl, - "taskTypeRequest" - ); - await writeCachedTaskTypePayload(payload); - - return payload; - } - - async function fetchComcipPayload(url, requestName) { - 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( - `${requestName}: ${ - payloadMessage || - response?.error || - `COMCIP request failed (${response?.status || 0}).` - }` - ); - } - - return response.payload; - } - - async function mutateComcipPayload(method, url, payload, requestName) { - const response = await sendComcipRequestMessage({ - method, - url, - payload, - timeoutMessage: `Timed out while executing ${requestName}.`, - }); - - 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( - `${requestName}: ${ - payloadMessage || - response?.error || - `COMCIP request failed (${response?.status || 0}).` - }` - ); - } - - return response.payload; - } - - function hasActivityRequestItems(payload) { - const count = Number(payload?.count); - - if (Number.isFinite(count) && count > 0) { - return true; - } - - return extractArray(payload, ["items"]).length > 0; - } - - function getCurrentUserResourcePartyId() { - const resourceCurrentUser = appState.dataset?.resourceCurrentUser; - const directResourcePartyId = cleanString( - getFieldValue(resourceCurrentUser, [ - "resourcePartyId", - "ResourcePartyId", - "ResourcePartyID", - "partyId", - "PartyId", - ]) - ); - - if (directResourcePartyId) { - return directResourcePartyId; - } - - const candidates = Array.isArray(resourceCurrentUser) - ? resourceCurrentUser - : extractArray(resourceCurrentUser, ["items", "data", "results", "content"]); - const source = candidates[0] || resourceCurrentUser; - - return cleanString( - getFieldValue(source, [ - "resourcePartyId", - "ResourcePartyId", - "ResourcePartyID", - "partyId", - "PartyId", - ]) - ); - } - - function normalizeInfoWeekResponse(payload) { - return extractArray(payload, ["items", "data", "results", "content"]) - .map((item) => { - const rawDate = cleanString(getFieldValue(item, ["Date", "date"])); - const parsedDate = parseTimeEntryDate(rawDate); - - return { - date: rawDate, - weekId: cleanString(getFieldValue(item, ["WeekId", "weekId"])), - dayLabel: Number.isNaN(parsedDate.getTime()) - ? rawDate - : new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(parsedDate), - monthLabel: Number.isNaN(parsedDate.getTime()) - ? rawDate - : new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - }).format(parsedDate), - }; - }) - .filter((item) => item.date || item.weekId); - } - - function normalizeTimeManagementServiceRequests(payload) { - return extractArray(payload, ["items", "data", "results", "content"]) - .map((item) => { - const srNumber = cleanString( - getFieldValue(item, [ - "srNumber", - "SrNumber", - "SRNumber", - "serviceRequestNumber", - "ServiceRequestNumber", - "serviceRequest", - "ServiceRequest", - ]) - ); - const srTitle = cleanString( - getFieldValue(item, ["srTitle", "SrTitle", "SRTitle", "title", "Title"]) - ); - const fallbackTitle = cleanString( - getFieldValue(item, ["problemDescription", "ProblemDescription"]) - ); - - return { - srNumber, - srTitle, - label: formatTimeManagementServiceRequestLabel({ - srNumber, - srTitle: srTitle || fallbackTitle, - }), - }; - }) - .filter((item) => item.srNumber); - } - - function formatTimeManagementServiceRequestLabel(serviceRequest) { - const srNumber = cleanString(serviceRequest?.srNumber); - const srTitle = cleanString(serviceRequest?.srTitle || serviceRequest?.label); - - if (srTitle && srNumber) { - return `${srTitle} (${srNumber})`; - } - - return srTitle || srNumber; - } - - function mergeTimeManagementServiceRequests(serviceRequests, timeEntryRows) { - const merged = [...(serviceRequests || [])]; - const seen = new Set(merged.map((item) => normalizeString(item.srNumber))); - - for (const row of timeEntryRows || []) { - const srNumber = cleanString(row?.selectedSrNumber); - const key = normalizeString(srNumber); - - if (!srNumber || seen.has(key)) { - continue; - } - - merged.push({ - srNumber, - srTitle: srNumber === "non-service-sr" ? "Non-service request" : srNumber, - label: formatTimeManagementServiceRequestLabel({ - srNumber, - srTitle: srNumber === "non-service-sr" ? "Non-service request" : srNumber, - }), - }); - seen.add(key); - } - - return merged; - } - - function normalizeTimeManagementEntryRows(payload, weekDays) { - return extractArray(payload, ["items", "data", "results", "content"]).map( - (item, index) => { - const timeEntryId = getTimeEntryId(item); - const srNumber = getTimeEntrySrNumber(item) || "non-service-sr"; - const activityValue = getTimeEntryActivity(item); - const taskTypeValue = getTimeEntryTaskType(item); - - return { - id: `entry-${index + 1}`, - timeEntryId, - selectedSrNumber: srNumber, - selectedActivityValue: activityValue, - selectedTaskTypeValue: taskTypeValue, - dayHours: getTimeEntryWeekDayHours(item, weekDays), - source: "timeEntryWeek", - originalSignature: "", - }; - } - ); - } - - function getTimeEntryWeekDayHours(item, weekDays = []) { - const dayHours = {}; - - for (const day of weekDays || []) { - const dateKey = getTimeManagementDayKey(day); - const weekdayName = getTimeManagementWeekdayName(day); - const value = getTimeEntryWeekdayTaskHours(item, weekdayName); - - if (dateKey) { - dayHours[dateKey] = formatTimeManagementHourValue(value); - } - } - - return dayHours; - } - - function getTimeManagementWeekdayName(day) { - const parsedDate = parseTimeEntryDate(day?.date); - - if (!Number.isNaN(parsedDate.getTime())) { - return new Intl.DateTimeFormat("en-US", { weekday: "long" }) - .format(parsedDate) - .toLowerCase(); - } - - const label = cleanString(day?.dayLabel || day?.date).toLowerCase(); - const aliases = { - sun: "sunday", - sunday: "sunday", - mon: "monday", - monday: "monday", - tue: "tuesday", - tuesday: "tuesday", - wed: "wednesday", - wednesday: "wednesday", - thu: "thursday", - thursday: "thursday", - fri: "friday", - friday: "friday", - sat: "saturday", - saturday: "saturday", - }; - - return aliases[label.slice(0, 3)] || aliases[label] || ""; - } - - function getTimeEntryWeekdayTaskHours(item, weekdayName) { - if (!weekdayName) { - return ""; - } - - const titleCaseWeekday = - weekdayName.charAt(0).toUpperCase() + weekdayName.slice(1); - const compactKey = `${weekdayName}TaskHours`; - const titleKey = `${titleCaseWeekday}TaskHours`; - const snakeKey = `${weekdayName}_task_hours`; - - return getTimeEntryField(item, [ - compactKey, - titleKey, - snakeKey, - `${weekdayName}.taskHours`, - `${titleCaseWeekday}.TaskHours`, - ]); - } - - function formatTimeManagementHourValue(value) { - if (value === undefined || value === null || value === "") { - return ""; - } - - return cleanString(value); - } - - function normalizeTimeManagementEntryRowSelection( - row, - index, - activityOptionsBySr, - taskTypes - ) { - const srNumber = cleanString(row?.selectedSrNumber); - const activitySrKey = Object.keys(activityOptionsBySr || {}).find( - (key) => normalizeString(key) === normalizeString(srNumber) - ); - const activityOptions = - activityOptionsBySr?.[srNumber] || activityOptionsBySr?.[activitySrKey] || []; - const taskTypeOptions = filterTaskTypesForServiceRequest(taskTypes || [], srNumber); - - return { - ...row, - id: cleanString(row?.id) || `entry-${index + 1}`, - timeEntryId: cleanString(row?.timeEntryId), - selectedSrNumber: srNumber, - selectedActivityValue: - findOptionValue(activityOptions, row?.selectedActivityValue) || - cleanString(row?.selectedActivityValue) || - "", - selectedTaskTypeValue: - findOptionValue(taskTypeOptions, row?.selectedTaskTypeValue) || - cleanString(row?.selectedTaskTypeValue) || - "", - dayHours: row?.dayHours || {}, - originalSignature: - cleanString(row?.originalSignature) || - (cleanString(row?.source) === "timeEntryWeek" - ? createTimeManagementRowSignature({ - ...row, - selectedSrNumber: srNumber, - selectedActivityValue: - findOptionValue(activityOptions, row?.selectedActivityValue) || - cleanString(row?.selectedActivityValue) || - "", - selectedTaskTypeValue: - findOptionValue(taskTypeOptions, row?.selectedTaskTypeValue) || - cleanString(row?.selectedTaskTypeValue) || - "", - dayHours: row?.dayHours || {}, - }) - : ""), - }; - } - - function getActivityRequestSrNumbers(srPayload, serviceRequests) { - const normalizedSrNumbers = [ - ...(serviceRequests || []).map((item) => item.srNumber), - ...collectSrNumbers(srPayload), - ]; - const seen = new Set(); - - return normalizedSrNumbers - .map((srNumber) => cleanString(srNumber).toUpperCase()) - .filter((srNumber) => /^SR\d+$/i.test(srNumber)) - .filter((srNumber) => { - const key = normalizeString(srNumber); - - if (seen.has(key)) { - return false; - } - - seen.add(key); - return true; - }); - } - - function collectSrNumbers(value, seen = new WeakSet()) { - if (!value) { - return []; - } - - if (typeof value === "string" || typeof value === "number") { - return Array.from(String(value).matchAll(/\bSR\d+\b/gi), (match) => match[0]); - } - - if (typeof value !== "object") { - return []; - } - - if (seen.has(value)) { - return []; - } - - seen.add(value); - - if (Array.isArray(value)) { - return value.flatMap((item) => collectSrNumbers(item, seen)); - } - - return Object.values(value).flatMap((nestedValue) => - collectSrNumbers(nestedValue, seen) - ); - } - - function normalizeActivityRequest(payload, serviceRequests) { - const activityOptionsBySr = {}; - const activityItemsBySr = new Map(); - - for (const item of extractArray(payload, ["items", "data", "results", "content"])) { - const srNumber = cleanString(getFieldValue(item, ["SrNumber", "srNumber"])); - const srKey = normalizeString(srNumber); - - if (srKey && !activityItemsBySr.has(srKey)) { - activityItemsBySr.set(srKey, item); - } - } - - for (const serviceRequest of serviceRequests || []) { - const srNumber = cleanString(serviceRequest?.srNumber); - const srKey = normalizeString(srNumber); - const activityItem = activityItemsBySr.get(srKey); - const options = activityItem ? normalizeSrActivityFieldOptions(activityItem) : []; - - if (srNumber) { - activityOptionsBySr[srNumber] = options; - } - } - - return activityOptionsBySr; - } - - function normalizeSrActivityFieldOptions(item) { - const activityText = firstNonEmptyString([ - getFieldValue(item, ["SRActivities_c", "srActivities_c"]), - getFieldValue(item, [ - "ServiceRequest_SRToInternalSR_Tgt.SRActivities_c", - "ServiceRequest_SRToInternalSR_Tgt.srActivities_c", - ]), - ]); - - if (activityText) { - return parseSemicolonActivityOptions(activityText); - } - - return normalizeActivityOptions(item); - } - - function normalizeActivityOptions(value) { - const recursiveActivityTextValues = collectActivityTextValues(value); - - if (recursiveActivityTextValues.length) { - return parseSemicolonActivityOptions(recursiveActivityTextValues.join(";")); - } - - const directActivityValue = - value && typeof value === "object" && !Array.isArray(value) - ? getFieldValue(value, ["SRActivities_c", "srActivities_c", "activities"]) - : ""; - const nestedActivityValue = - value && typeof value === "object" && !Array.isArray(value) - ? getFieldValue(value, [ - "ServiceRequest_SRToInternalSR_Tgt.SRActivities_c", - "ServiceRequest_SRToInternalSR_Tgt.srActivities_c", - ]) - : ""; - const nestedActivityObject = - value && typeof value === "object" && !Array.isArray(value) - ? getFieldValue(value, ["ServiceRequest_SRToInternalSR_Tgt"]) - : ""; - const explicitActivityText = firstNonEmptyString([ - directActivityValue && typeof directActivityValue !== "object" - ? directActivityValue - : "", - nestedActivityValue && typeof nestedActivityValue !== "object" - ? nestedActivityValue - : "", - ]); - - if (explicitActivityText) { - return parseSemicolonActivityOptions(explicitActivityText); - } - - const activityValue = - directActivityValue || - nestedActivityValue || - nestedActivityObject || - value; - const rawItems = Array.isArray(value) - ? value - : activityValue && typeof activityValue === "object" - ? extractArray(activityValue, [ - "items", - "data", - "results", - "content", - "activities", - "ServiceRequest_SRToInternalSR_Tgt", - ]) - : []; - const textValue = - activityValue && typeof activityValue === "object" - ? "" - : cleanString(activityValue); - const parsedTextItems = parseActivityTextOptions(textValue); - const items = rawItems.length - ? rawItems - : parsedTextItems; - const seen = new Set(); - - return items - .map((item) => { - const optionValue = - item && typeof item === "object" - ? firstNonEmptyString([ - item.activityCode, - item.ActivityCode, - item.code, - item.value, - item.name, - item.label, - ]) - : cleanString(item); - const optionLabel = - item && typeof item === "object" - ? firstNonEmptyString([ - item.activityName, - item.ActivityName, - item.meaning, - item.label, - item.name, - optionValue, - ]) - : optionValue; - const normalizedValue = cleanString(optionValue); - const normalizedLabel = cleanString(optionLabel); - - if (!normalizedValue || seen.has(normalizedValue)) { - return null; - } - - seen.add(normalizedValue); - return { - value: normalizedValue, - label: normalizedLabel || normalizedValue, - }; - }) - .filter(Boolean); - } - - function collectActivityTextValues(value, seen = new WeakSet()) { - if (!value || typeof value !== "object") { - return []; - } - - if (seen.has(value)) { - return []; - } - - seen.add(value); - - if (Array.isArray(value)) { - return value.flatMap((item) => collectActivityTextValues(item, seen)); - } - - const values = []; - const activityFieldNames = new Set([ - normalizeString("SRActivities_c"), - normalizeString("srActivities_c"), - normalizeString("activities"), - ]); - - for (const [key, nestedValue] of Object.entries(value)) { - if ( - activityFieldNames.has(normalizeString(key)) && - typeof nestedValue !== "object" - ) { - const text = cleanString(nestedValue); - - if (text) { - values.push(text); - } - } else if (nestedValue && typeof nestedValue === "object") { - values.push(...collectActivityTextValues(nestedValue, seen)); - } - } - - return values; - } - - function parseSemicolonActivityOptions(value) { - const seen = new Set(); - - return cleanString(value) - .split(";") - .map((item) => cleanString(item)) - .filter(Boolean) - .map((item) => { - const normalizedItem = normalizeString(item); - - if (seen.has(normalizedItem)) { - return null; - } - - seen.add(normalizedItem); - return { - value: item, - label: item, - }; - }) - .filter(Boolean); - } - - function parseActivityTextOptions(value) { - if (!value) { - return []; - } - - try { - const parsed = JSON.parse(value); - - if (Array.isArray(parsed)) { - return parsed; - } - - if (parsed && typeof parsed === "object") { - return extractArray(parsed, ["items", "data", "results", "content", "activities"]); - } - } catch { - // Continue with delimiter based parsing. - } - - return value - .split(/[;|\n]/) - .map((item) => item.trim()) - .filter(Boolean); - } - - function normalizeTaskTypeRequest(payload) { - return extractArray(payload, ["items", "data", "results", "content"]) - .map((item) => { - const value = cleanString( - getFieldValue(item, ["lookUpCode_c", "LookUpCode_c", "lookupCode"]) - ); - const label = cleanString( - getFieldValue(item, [ - "lookUpMeaning_c", - "LookUpMeaning_c", - "lookupMeaning", - "meaning", - ]) - ); - const tags = cleanString( - getFieldValue(item, ["tags_c", "Tags_c", "TAGS_C", "tags", "Tags"]) - ); - - return { - value, - label: label || value, - tags, - }; - }) - .filter((item) => item.value); - } - - function getTimeManagementTaskTypeOptions(modal, srNumber) { - const options = filterTaskTypesForServiceRequest(modal.taskTypes || [], srNumber); - - return options.length - ? options - : [{ value: "", label: "No task types available", tags: "" }]; - } - - function filterTaskTypesForServiceRequest(taskTypes, srNumber) { - const expectedTag = isServiceRequestSrNumber(srNumber) ? "SERVICE" : "NON_SERVICE"; - return (taskTypes || []).filter((item) => { - const tags = normalizeString(item?.tags); - - return tags === normalizeString(expectedTag); - }); - } - - function isServiceRequestSrNumber(srNumber) { - return /^SR\d+$/i.test(cleanString(srNumber)); - } - - function isNonServiceRequestSrNumber(srNumber) { - return normalizeString(srNumber) === normalizeString("non-service-sr"); - } - - function getTimeManagementActivityOptions(modal, srNumber) { - const normalizedSrNumber = normalizeString(srNumber); - const matchedSrKey = Object.keys(modal.activityOptionsBySr || {}).find( - (key) => normalizeString(key) === normalizedSrNumber - ); - const options = - modal.activityOptionsBySr?.[srNumber] || - modal.activityOptionsBySr?.[matchedSrKey] || - []; - - return options.length - ? options - : [{ value: "", label: "No activities available" }]; - } - - function findTimeManagementServiceRequest(modal, inputValue) { - const normalizedInput = normalizeString(inputValue); - - return (modal.serviceRequests || []).find((item) => { - return ( - normalizeString(item.srNumber) === normalizedInput || - normalizeString(item.label) === normalizedInput || - normalizeString(item.srTitle) === normalizedInput - ); - }); - } - - function findTimeManagementServiceRequestByNumber(modal, srNumber) { - const normalizedSrNumber = normalizeString(srNumber); - - return (modal.serviceRequests || []).find( - (item) => normalizeString(item.srNumber) === normalizedSrNumber - ); - } - - function findOptionValue(options, inputValue) { - const normalizedInput = normalizeString(inputValue); - const option = (options || []).find((item) => { - return ( - normalizeString(item.value) === normalizedInput || - normalizeString(item.label) === normalizedInput - ); - }); - - return option?.value || ""; - } - - function findOptionLabel(options, value) { - const normalizedValue = normalizeString(value); - const option = (options || []).find( - (item) => normalizeString(item.value) === normalizedValue - ); - - return option?.label || ""; - } - - function buildServiceRequestActivityQuery(srNumbers) { - const values = [...(srNumbers || [])] - .map((srNumber) => cleanString(srNumber)) - .filter(Boolean) - .map((srNumber) => `'${srNumber.replace(/'/g, "''")}'`) - .concat("'non-service-sr'") - .join(","); - - return `SrNumber in (${values})`; - } - function closeDetailModal() { if (appState.detailModal?.scope === "calendar-day" && appState.detailModal.value) { appState.pendingFocusSelector = getCalendarDayFocusSelector( @@ -7479,331 +968,6 @@ appState.forecastUpdateConfirmModal = null; } - function shiftCalendarPeriod(delta) { - const nextDate = - appState.calendarView === "week" - ? getShiftedCalendarWeekDate(delta) - : new Date(appState.calendarYear, appState.calendarMonth + delta, 1); - - appState.calendarYear = nextDate.getFullYear(); - appState.calendarMonth = nextDate.getMonth(); - appState.calendarAnchorDateKey = getLocalDateKey(nextDate); - renderModal(); - } - - function goToCurrentCalendarPeriod() { - const today = new Date(); - appState.calendarYear = today.getFullYear(); - appState.calendarMonth = today.getMonth(); - appState.calendarAnchorDateKey = getLocalDateKey(today); - renderModal(); - } - - function setCalendarView(view) { - const normalizedView = view === "week" ? "week" : "month"; - - if (appState.calendarView === normalizedView) { - return; - } - - appState.calendarView = normalizedView; - setStoredCalendarView(normalizedView); - - if (normalizedView === "week") { - const today = new Date(); - const anchorDate = - today.getFullYear() === appState.calendarYear && - today.getMonth() === appState.calendarMonth - ? today - : new Date(appState.calendarYear, appState.calendarMonth, 1); - - appState.calendarAnchorDateKey = getLocalDateKey(anchorDate); - appState.calendarYear = anchorDate.getFullYear(); - appState.calendarMonth = anchorDate.getMonth(); - } else { - const anchorDate = getCalendarAnchorDate( - appState.calendarYear, - appState.calendarMonth, - appState.calendarAnchorDateKey - ); - - appState.calendarYear = anchorDate.getFullYear(); - appState.calendarMonth = anchorDate.getMonth(); - } - - renderModal(); - } - - function getShiftedCalendarWeekDate(delta) { - const anchorDate = getCalendarAnchorDate( - appState.calendarYear, - appState.calendarMonth, - appState.calendarAnchorDateKey - ); - - anchorDate.setDate(anchorDate.getDate() + delta * 7); - return anchorDate; - } - - function shiftCalendarMonth(delta) { - shiftCalendarPeriod(delta); - } - - function goToCurrentCalendarMonth() { - goToCurrentCalendarPeriod(); - } - - 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 || [], - appState.dataset.exportPayload?.resourceCurrentUser || null - ); - 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); @@ -7816,635 +980,6 @@ }, 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); @@ -8457,3662 +992,6 @@ 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; - } - - #${IDS.hcmTile} { - cursor: pointer; - } - - #${IDS.hcmTile}:focus-visible { - outline: 2px solid #0b5cab; - outline-offset: 3px; - } - - .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); - --arch-workbench-header-fallback: #3f2d4f; - --arch-workbench-accent-fallback: #684b78; - --arch-shell-header-text: #ffffff; - --arch-shell-header-muted: rgba(255, 255, 255, 0.82); - --arch-shell-header-subtle: rgba(255, 255, 255, 0.68); - 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-workbench-header-fallback: #202a36; - --arch-workbench-accent-fallback: #39495b; - } - - .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: - linear-gradient( - 135deg, - var(--arch-workbench-header, var(--arch-workbench-header-fallback)) 0%, - var(--arch-workbench-accent, var(--arch-workbench-accent-fallback)) 100% - ); - border-bottom: 1px solid rgba(255, 255, 255, 0.16); - box-shadow: 0 8px 24px rgba(31, 31, 31, 0.16); - color: var(--arch-shell-header-text); - } - - .arch-panel-extension-brand { - display: flex; - align-items: center; - gap: 14px; - min-width: 0; - } - - .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.18); - color: var(--arch-shell-header-text); - font-size: 14px; - font-weight: 700; - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16); - } - - .arch-panel-extension-brand-label { - margin: 0 0 2px; - color: var(--arch-shell-header-muted); - font-size: 11px; - letter-spacing: 0.08em; - text-transform: uppercase; - } - - .arch-panel-extension-title { - margin: 0; - color: var(--arch-shell-header-text); - font-size: 24px; - line-height: 1.2; - font-weight: 600; - letter-spacing: -0.01em; - } - - .arch-panel-extension-shell-actions { - display: flex; - align-items: center; - gap: 12px; - flex: 0 0 auto; - } - - .arch-panel-extension-shell-note { - max-width: 36vw; - overflow: hidden; - color: var(--arch-shell-header-muted); - font-size: 12px; - line-height: 1.35; - text-overflow: ellipsis; - white-space: nowrap; - } - - .arch-panel-extension-theme-toggle { - appearance: none; - min-height: 34px; - padding: 0 14px; - border: 1px solid rgba(255, 255, 255, 0.28); - border-radius: 4px; - background: rgba(255, 255, 255, 0.12); - color: var(--arch-shell-header-text); - font: inherit; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: background-color 160ms ease, border-color 160ms ease, transform 160ms ease; - } - - .arch-panel-extension-shell-header .arch-panel-extension-theme-toggle:hover { - border-color: rgba(255, 255, 255, 0.44); - background: rgba(255, 255, 255, 0.2); - } - - .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.28); - background: rgba(255, 255, 255, 0.12); - color: var(--arch-shell-header-text); - } - - .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-utility-button.is-outline { - border-color: #0b5cab; - background: transparent; - color: #0b5cab; - } - - .arch-panel-extension-utility-button.is-outline:hover:not(:disabled) { - background: rgba(11, 92, 171, 0.08); - } - - .arch-panel-extension-utility-button.is-outline:disabled { - border-color: var(--wb-border); - background: transparent; - color: var(--wb-text-muted); - cursor: not-allowed; - opacity: 0.62; - } - - .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-current-user-card { - display: grid; - justify-items: start; - gap: 10px; - } - - .arch-panel-extension-current-user-avatar { - width: 48px; - height: 48px; - border: 1px solid var(--wb-border); - border-radius: 999px; - background: var(--wb-panel); - box-shadow: var(--wb-shadow); - color: var(--wb-text); - object-fit: cover; - } - - .arch-panel-extension-current-user-avatar.is-fallback { - display: inline-grid; - place-items: center; - background: var(--arch-workbench-header, var(--arch-workbench-header-fallback)); - color: #ffffff; - font-size: 15px; - font-weight: 700; - letter-spacing: 0.02em; - } - - .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-view-toggle { - display: inline-flex; - align-items: center; - overflow: hidden; - border: 1px solid var(--wb-border); - border-radius: 4px; - background: var(--wb-panel-soft); - } - - .arch-panel-extension-calendar-view-button { - appearance: none; - min-height: 28px; - padding: 0 9px; - border: 0; - border-right: 1px solid var(--wb-border); - background: transparent; - color: var(--wb-text-secondary); - font: inherit; - font-size: 12px; - font-weight: 700; - cursor: pointer; - } - - .arch-panel-extension-calendar-view-button:last-child { - border-right: 0; - } - - .arch-panel-extension-calendar-view-button:hover { - background: var(--wb-row-hover); - color: var(--wb-text); - } - - .arch-panel-extension-calendar-view-button.is-active { - background: #0b5cab; - color: #ffffff; - } - - .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-grid.is-week { - min-height: 300px; - } - - .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.is-week-day { - display: flex; - flex-direction: column; - min-height: 300px; - padding: 0; - overflow: hidden; - } - - .arch-panel-extension-calendar-week-day-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - min-height: 38px; - padding: 8px 9px; - border-bottom: 1px solid var(--wb-border); - background: color-mix(in srgb, var(--wb-panel) 72%, var(--wb-panel-soft)); - } - - .arch-panel-extension-calendar-day.is-week-day .arch-panel-extension-calendar-week-number { - position: static; - font-size: 9px; - opacity: 0.76; - } - - .arch-panel-extension-calendar-week-workloads { - display: flex; - flex: 1; - flex-direction: column; - gap: 7px; - padding: 8px; - overflow: auto; - } - - .arch-panel-extension-calendar-week-workload { - display: block; - border-left: 3px solid #d94f2b; - border-radius: 5px; - background: rgba(217, 79, 43, 0.14); - padding: 7px 8px; - color: var(--wb-text); - text-align: left; - } - - .arch-panel-extension-calendar-week-workload-title, - .arch-panel-extension-calendar-week-workload-meta { - display: block; - overflow: hidden; - text-overflow: ellipsis; - } - - .arch-panel-extension-calendar-week-workload-title { - font-size: 11px; - font-weight: 700; - line-height: 1.25; - } - - .arch-panel-extension-calendar-week-workload-meta { - margin-top: 3px; - color: var(--wb-text-secondary); - font-size: 10px; - line-height: 1.2; - white-space: nowrap; - } - - .arch-panel-extension-calendar-week-empty { - margin: auto; - color: var(--wb-text-muted); - font-size: 11px; - font-weight: 600; - } - - .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-time-management-dialog { - width: min(1760px, calc(100vw - 20px)); - max-height: calc(100vh - 88px); - display: flex; - flex-direction: column; - gap: 14px; - overflow: visible; - border: 1px solid var(--wb-border); - border-radius: 10px; - background: var(--wb-panel); - box-shadow: var(--wb-shadow); - padding: 18px; - } - - .arch-panel-extension-time-management-toolbar, - .arch-panel-extension-time-management-card { - border: 1px solid var(--wb-border); - border-radius: 8px; - background: var(--wb-panel-soft); - padding: 14px; - } - - .arch-panel-extension-time-management-card { - display: grid; - --arch-time-management-scrollbar-gutter: 16px; - gap: 4px; - min-height: 0; - overflow: visible; - } - - .arch-panel-extension-time-management-card.is-error { - border-color: rgba(217, 79, 43, 0.42); - } - - .arch-panel-extension-time-management-toolbar { - display: flex; - align-items: end; - gap: 14px; - flex-wrap: wrap; - } - - .arch-panel-extension-time-management-hour-indicators { - display: grid; - grid-template-columns: repeat(4, minmax(92px, 1fr)); - gap: 8px; - flex: 1 1 520px; - } - - .arch-panel-extension-time-management-hour-indicator { - display: grid; - gap: 3px; - min-height: 48px; - align-content: center; - border: 1px solid var(--wb-border); - border-radius: 8px; - background: var(--wb-panel); - padding: 7px 10px; - } - - .arch-panel-extension-time-management-hour-indicator span { - color: var(--wb-text-muted); - font-size: 10px; - font-weight: 800; - letter-spacing: 0.06em; - text-transform: uppercase; - } - - .arch-panel-extension-time-management-hour-indicator strong { - color: var(--wb-text); - font-size: 16px; - font-weight: 800; - line-height: 1.1; - } - - .arch-panel-extension-time-management-hour-indicator .arch-panel-extension-skeleton-text { - width: 72%; - } - - .arch-panel-extension-time-management-hour-indicator .arch-panel-extension-skeleton-title { - width: 42%; - height: 16px; - } - - .arch-panel-extension-date-picker { - position: relative; - display: inline-flex; - align-items: center; - gap: 5px; - width: auto; - } - - .arch-panel-extension-date-input-shell { - position: relative; - display: inline-flex; - align-items: center; - } - - .arch-panel-extension-date-picker-button { - position: absolute; - top: 50%; - right: 11px; - z-index: 1; - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border: 0; - border-radius: 5px; - background: transparent; - color: var(--wb-text-muted); - cursor: pointer; - font-size: 0; - line-height: 1; - padding: 0; - transform: translateY(-50%); - } - - .arch-panel-extension-date-step-button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 32px; - border: 1px solid var(--wb-border); - border-radius: 7px; - background: var(--wb-panel); - color: var(--wb-text-muted); - cursor: pointer; - padding: 0; - } - - .arch-panel-extension-date-step-button svg { - width: 16px; - height: 16px; - fill: currentColor; - pointer-events: none; - } - - .arch-panel-extension-date-step-button:hover { - border-color: var(--wb-border-strong); - background: var(--wb-row-hover); - color: var(--wb-text); - } - - .arch-panel-extension-date-picker-button svg { - width: 16px; - height: 16px; - fill: currentColor; - pointer-events: none; - } - - .arch-panel-extension-date-picker-button:hover { - background: var(--wb-row-hover); - color: var(--wb-text); - } - - .arch-panel-extension-date-picker-icon { - display: none; - position: absolute; - left: 11px; - z-index: 1; - color: var(--wb-text-muted); - font-size: 14px; - pointer-events: none; - } - - .arch-panel-extension-date-picker input { - width: 118px; - min-height: 32px; - padding-left: 12px; - padding-right: 38px; - } - - .arch-panel-extension-form-field .arch-panel-extension-date-picker input { - min-height: 32px; - padding: 0 38px 0 12px; - } - - .arch-panel-extension-date-picker input[readonly] { - cursor: default; - } - - .arch-panel-extension-date-picker-popover { - position: absolute; - top: calc(100% + 8px); - left: 0; - z-index: 6; - width: 292px; - border: 1px solid var(--wb-border); - border-radius: 10px; - background: var(--wb-panel); - box-shadow: 0 18px 42px rgba(0, 0, 0, 0.22); - padding: 12px; - } - - .arch-panel-extension-date-picker-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 10px; - } - - .arch-panel-extension-date-picker-head strong { - color: var(--wb-text); - font-size: 13px; - } - - .arch-panel-extension-date-picker-weekdays, - .arch-panel-extension-date-picker-grid { - display: grid; - grid-template-columns: repeat(7, minmax(0, 1fr)); - gap: 4px; - } - - .arch-panel-extension-date-picker-weekdays { - margin-bottom: 5px; - color: var(--wb-text-muted); - font-size: 10px; - font-weight: 800; - text-align: center; - } - - .arch-panel-extension-date-picker-day { - appearance: none; - min-width: 0; - height: 32px; - border: 1px solid transparent; - border-radius: 7px; - background: transparent; - color: var(--wb-text); - font: inherit; - font-size: 12px; - font-weight: 700; - cursor: pointer; - } - - .arch-panel-extension-date-picker-day:hover { - border-color: var(--wb-border-strong); - background: var(--wb-row-hover); - } - - .arch-panel-extension-date-picker-day.is-muted { - color: var(--wb-text-muted); - opacity: 0.55; - } - - .arch-panel-extension-date-picker-day.is-today { - border-color: #0b5cab; - } - - .arch-panel-extension-date-picker-day.is-selected { - border-color: #0b5cab; - background: #0b5cab; - color: #ffffff; - } - - .arch-panel-extension-time-management-select-grid { - display: grid; - grid-template-columns: minmax(240px, 1.15fr) minmax(220px, 1fr) minmax(180px, 0.8fr); - gap: 12px; - } - - .arch-panel-extension-time-management-rows { - display: grid; - gap: 10px; - max-height: min(52vh, 520px); - min-height: 0; - overflow-x: hidden; - overflow-y: auto; - padding: 4px 0; - scrollbar-gutter: stable; - } - - .arch-panel-extension-time-management-rows:has(.arch-panel-extension-combobox:focus-within) { - overflow: visible; - } - - .arch-panel-extension-time-management-empty { - grid-column: 1 / -1; - text-align: center; - } - - .arch-panel-extension-time-management-header-row { - display: grid; - grid-template-columns: - minmax(420px, 3.8fr) - minmax(150px, 0.95fr) - minmax(138px, 0.82fr) - repeat(7, minmax(52px, 0.28fr)) - 34px; - align-items: center; - gap: 6px; - padding-right: var(--arch-time-management-scrollbar-gutter); - } - - .arch-panel-extension-time-management-column-label { - color: var(--wb-text-secondary); - font-size: 12px; - font-weight: 600; - line-height: 1.2; - } - - .arch-panel-extension-time-management-entry-row { - display: grid; - grid-template-columns: - minmax(420px, 3.8fr) - minmax(150px, 0.95fr) - minmax(138px, 0.82fr) - repeat(7, minmax(52px, 0.28fr)) - 34px; - align-items: start; - gap: 6px; - } - - .arch-panel-extension-time-management-entry-row.is-compact { - align-items: center; - } - - .arch-panel-extension-time-management-entry-row.is-duplicate-highlight { - border-radius: 10px; - animation: arch-panel-extension-duplicate-pulse 1100ms ease-in-out 2; - box-shadow: - inset 0 0 0 3px rgba(217, 79, 43, 0.9), - 0 0 0 2px rgba(217, 79, 43, 0.12); - padding: 4px; - margin: -4px 0; - } - - .arch-panel-extension-time-management-entry-row.is-compact .arch-panel-extension-form-field { - gap: 0; - } - - @keyframes arch-panel-extension-duplicate-pulse { - 0% { - box-shadow: - inset 0 0 0 2px rgba(217, 79, 43, 0.35), - 0 0 0 0 rgba(217, 79, 43, 0.22); - } - 45% { - box-shadow: - inset 0 0 0 3px rgba(217, 79, 43, 0.95), - 0 0 0 6px rgba(217, 79, 43, 0.14); - } - 100% { - box-shadow: - inset 0 0 0 3px rgba(217, 79, 43, 0.72), - 0 0 0 0 rgba(217, 79, 43, 0); - } - } - - .arch-panel-extension-combobox { - position: relative; - display: block; - } - - .arch-panel-extension-combobox input { - min-height: 40px; - padding-left: 12px; - padding-right: 38px; - border-radius: 8px; - background: color-mix(in srgb, var(--wb-panel-soft) 86%, var(--wb-panel)); - color: transparent; - transition: - border-color 140ms ease, - box-shadow 140ms ease, - background 140ms ease; - } - - .arch-panel-extension-combobox input::placeholder { - color: transparent; - } - - .arch-panel-extension-combobox:focus-within input::placeholder { - color: var(--wb-text-muted); - } - - .arch-panel-extension-combobox-value { - position: absolute; - top: 50%; - right: 38px; - left: 12px; - z-index: 1; - overflow: hidden; - color: var(--wb-text); - font-size: 12px; - font-weight: 700; - line-height: 1.25; - pointer-events: none; - text-overflow: ellipsis; - transform: translateY(-50%); - white-space: nowrap; - } - - .arch-panel-extension-combobox:focus-within .arch-panel-extension-combobox-value { - display: none; - } - - .arch-panel-extension-combobox.is-disabled { - opacity: 0.72; - } - - .arch-panel-extension-combobox.is-disabled .arch-panel-extension-combobox-value, - .arch-panel-extension-combobox.is-disabled .arch-panel-extension-combobox-arrow { - color: var(--wb-text-muted); - } - - .arch-panel-extension-combobox.is-disabled input { - cursor: not-allowed; - } - - .arch-panel-extension-combobox.is-disabled:focus-within .arch-panel-extension-combobox-menu { - display: none; - } - - .arch-panel-extension-combobox input:focus { - border-color: #0b5cab; - background: var(--wb-panel); - box-shadow: 0 0 0 3px rgba(11, 92, 171, 0.16); - color: var(--wb-text); - outline: none; - } - - .arch-panel-extension-combobox-arrow { - position: absolute; - top: 50%; - right: 12px; - z-index: 1; - display: inline-flex; - width: 16px; - height: 16px; - color: var(--wb-text-muted); - pointer-events: none; - transform: translateY(-50%); - } - - .arch-panel-extension-combobox-arrow svg { - width: 100%; - height: 100%; - fill: currentColor; - } - - .arch-panel-extension-combobox-menu { - position: absolute; - top: calc(100% + 6px); - left: 0; - right: 0; - z-index: 10000; - display: none; - max-height: 260px; - overflow: auto; - border: 1px solid var(--wb-border-strong); - border-radius: 10px; - background: var(--wb-panel); - box-shadow: 0 18px 42px rgba(0, 0, 0, 0.22); - padding: 6px; - } - - .arch-panel-extension-combobox:focus-within .arch-panel-extension-combobox-menu { - display: grid; - gap: 4px; - } - - .arch-panel-extension-combobox-option { - appearance: none; - display: grid; - gap: 2px; - position: relative; - width: 100%; - min-height: 38px; - border: 0; - border-radius: 8px; - background: transparent; - color: var(--wb-text); - cursor: pointer; - font: inherit; - line-height: 1.25; - padding: 8px 10px; - text-align: left; - } - - .arch-panel-extension-combobox-option:hover, - .arch-panel-extension-combobox-option:focus, - .arch-panel-extension-combobox-option.is-active { - outline: none; - background: var(--wb-row-hover); - } - - .arch-panel-extension-combobox-option.is-active { - box-shadow: inset 3px 0 0 #0b5cab; - } - - .arch-panel-extension-combobox-option.is-selected { - background: rgba(11, 92, 171, 0.12); - color: var(--wb-text); - padding-right: 32px; - } - - .arch-panel-extension-combobox-option.is-selected::after { - content: "✓"; - position: absolute; - top: 50%; - right: 11px; - color: #0b5cab; - font-size: 14px; - font-weight: 800; - transform: translateY(-50%); - } - - .arch-panel-extension-dialog[data-theme="dark"] .arch-panel-extension-combobox-option.is-selected::after { - color: #9ed4f0; - } - - .arch-panel-extension-combobox-option-main { - color: var(--wb-text); - font-size: 12px; - font-weight: 700; - } - - .arch-panel-extension-combobox-option-meta { - color: var(--wb-text-muted); - font-size: 11px; - font-weight: 600; - } - - .arch-panel-extension-combobox-option[hidden] { - display: none; - } - - .arch-panel-extension-combobox-empty { - color: var(--wb-text-muted); - font-size: 12px; - padding: 8px 9px; - } - - .arch-panel-extension-time-management-days { - display: grid; - grid-template-columns: repeat(7, minmax(110px, 1fr)); - gap: 8px; - } - - .arch-panel-extension-time-management-day { - display: grid; - gap: 6px; - padding: 7px; - border: 1px solid var(--wb-border); - border-radius: 8px; - background: var(--wb-panel); - } - - .arch-panel-extension-time-management-day-head { - display: grid; - align-content: center; - justify-items: center; - gap: 1px; - min-height: 22px; - padding: 0; - color: var(--wb-text-secondary); - line-height: 1.15; - text-align: center; - } - - .arch-panel-extension-time-management-day-head strong { - color: var(--wb-text-secondary); - font-size: 12px; - font-weight: 600; - } - - .arch-panel-extension-time-management-day-head span { - color: var(--wb-text-muted); - font-size: 11px; - font-weight: 600; - text-transform: none; - } - - .arch-panel-extension-time-management-day input { - width: 100%; - min-height: 30px; - border: 1px solid var(--wb-border); - border-radius: 6px; - background: var(--wb-panel-soft); - color: var(--wb-text); - font: inherit; - font-size: 12px; - padding: 0 6px; - text-align: right; - } - - .arch-panel-extension-time-management-delete { - appearance: none; - align-self: center; - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border: 1px solid var(--wb-border); - border-radius: 7px; - background: var(--wb-panel); - color: var(--wb-text-muted); - cursor: pointer; - padding: 0; - } - - .arch-panel-extension-time-management-delete:hover, - .arch-panel-extension-time-management-delete:focus-visible { - border-color: rgba(217, 79, 43, 0.52); - background: rgba(217, 79, 43, 0.12); - color: #d94f2b; - outline: none; - } - - .arch-panel-extension-time-management-delete svg { - width: 16px; - height: 16px; - fill: currentColor; - pointer-events: none; - } - - .arch-panel-extension-time-management-request { - border-top: 1px solid var(--wb-border); - padding-top: 10px; - } - - .arch-panel-extension-time-management-request summary { - color: var(--wb-link); - cursor: pointer; - font-size: 12px; - font-weight: 700; - } - - .arch-panel-extension-time-management-request p { - margin: 10px 0 4px; - color: var(--wb-text-secondary); - font-size: 11px; - font-weight: 800; - letter-spacing: 0.02em; - text-transform: uppercase; - } - - .arch-panel-extension-time-management-request pre { - max-height: 220px; - margin: 8px 0 0; - overflow: auto; - border: 1px solid var(--wb-border); - border-radius: 7px; - background: var(--wb-panel); - color: var(--wb-text-secondary); - font-size: 11px; - line-height: 1.45; - padding: 10px; - white-space: pre-wrap; - word-break: break-all; - } - - .arch-panel-extension-skeleton { - display: block; - overflow: hidden; - border-radius: 6px; - background: linear-gradient( - 90deg, - color-mix(in srgb, var(--wb-border) 70%, transparent) 0%, - color-mix(in srgb, var(--wb-panel) 70%, #ffffff) 48%, - color-mix(in srgb, var(--wb-border) 70%, transparent) 100% - ); - background-size: 220% 100%; - animation: arch-panel-extension-skeleton 1.15s ease-in-out infinite; - } - - .arch-panel-extension-skeleton-control { - height: 38px; - } - - .arch-panel-extension-skeleton-title { - width: 62%; - height: 14px; - } - - .arch-panel-extension-skeleton-text { - width: 46%; - height: 11px; - } - - .arch-panel-extension-skeleton-input { - height: 34px; - } - - @keyframes arch-panel-extension-skeleton { - from { - background-position: 120% 0; - } - to { - background-position: -120% 0; - } - } - - .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; - align-items: center; - 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-time-management-footer-actions { - display: flex; - align-items: center; - gap: 8px; - margin-right: auto; - } - - .arch-panel-extension-time-management-save-error { - margin: 0 auto 0 0; - color: var(--wb-warning); - font-size: 12px; - font-weight: 700; - } - - .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-field .arch-panel-extension-combobox input { - min-height: 40px; - padding: 0 38px 0 12px; - border-radius: 8px; - background: color-mix(in srgb, var(--wb-panel-soft) 86%, var(--wb-panel)); - color: transparent; - } - - .arch-panel-extension-form-field .arch-panel-extension-combobox input:focus { - border-color: #0b5cab; - background: var(--wb-panel); - box-shadow: 0 0 0 3px rgba(11, 92, 171, 0.16); - color: var(--wb-text); - outline: none; - } - - .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 getStoredCalendarView() { - try { - return window.localStorage.getItem(STORAGE_KEYS.calendarView) === "week" - ? "week" - : "month"; - } catch { - return "month"; - } - } - - function setStoredCalendarView(value) { - try { - window.localStorage.setItem( - STORAGE_KEYS.calendarView, - value === "week" ? "week" : "month" - ); - } catch { - // Ignore storage issues and keep the in-memory calendar view. - } - } - - function getStoredTimeManagementSelectedDate() { - try { - const storedDate = window.localStorage.getItem( - STORAGE_KEYS.timeManagementSelectedDate - ); - - return isValidDateInputValue(storedDate) - ? storedDate - : getDateInputValue(new Date()); - } catch { - return getDateInputValue(new Date()); - } - } - - function setStoredTimeManagementSelectedDate(value) { - if (!isValidDateInputValue(value)) { - return; - } - - try { - window.localStorage.setItem( - STORAGE_KEYS.timeManagementSelectedDate, - value - ); - } catch { - // Ignore storage issues and keep the in-memory selected date. - } - } - - function isValidDateInputValue(value) { - if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || ""))) { - return false; - } - - const parsedDate = parseDatePreservingDateOnly(value); - - return !Number.isNaN(parsedDate.getTime()); - } - - 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 = resolveRequestUrl(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 resolveRequestUrl(url) { - const normalized = cleanString(url); - - if (!normalized) { - return ""; - } - - try { - const baseOrigin = - isHcmWelcomePage() && normalized.startsWith("/") - ? TARGET_ORIGIN - : window.location.origin; - - return new URL(normalized, baseOrigin).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 getDateInputValue(value) { - 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 formatDateInputDisplay(value) { - const date = parseDatePreservingDateOnly(value); - - if (Number.isNaN(date.getTime())) { - return cleanString(value); - } - - const day = String(date.getDate()).padStart(2, "0"); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const year = date.getFullYear(); - - return `${day}/${month}/${year}`; - } - - function formatDateForTimeEntryRequest(value) { - const date = parseDatePreservingDateOnly(value); - - if (Number.isNaN(date.getTime())) { - return ""; - } - - const day = String(date.getDate()).padStart(2, "0"); - const month = new Intl.DateTimeFormat("en-US", { month: "short" }) - .format(date) - .toUpperCase(); - const year = date.getFullYear(); - - return `${day}-${month}-${year}`; - } - - function parseTimeEntryDate(value) { - const text = cleanString(value); - const match = text.match(/^(\d{1,2})-([A-Z]{3})-(\d{4})$/i); - - if (!match) { - return parseDatePreservingDateOnly(text); - } - - const monthIndex = [ - "JAN", - "FEB", - "MAR", - "APR", - "MAY", - "JUN", - "JUL", - "AUG", - "SEP", - "OCT", - "NOV", - "DEC", - ].indexOf(match[2].toUpperCase()); - - if (monthIndex < 0) { - return new Date(Number.NaN); - } - - return new Date(Number(match[3]), monthIndex, Number(match[1])); - } - - 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 getTimeEntrySrNumber(item) { - return cleanString( - getTimeEntryField(item, [ - "srNumber", - "SrNumber", - "SRNumber", - "serviceRequestNumber", - "ServiceRequestNumber", - "serviceRequest.srNumber", - "ServiceRequest.SrNumber", - ]) - ); - } - - function getTimeEntryId(item) { - return cleanString( - getTimeEntryField(item, [ - "id", - "Id", - "ID", - "timeEntryId", - "TimeEntryId", - "timeEntryID", - "TimeEntryID", - ]) - ); - } - - 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 getTimeEntryDateKey(item, weekDays = []) { - const rawDate = cleanString( - getTimeEntryField(item, [ - "Date", - "date", - "dateValue", - "DateValue", - "timeEntryDate", - "TimeEntryDate", - "entryDate", - "EntryDate", - ]) - ); - - if (!rawDate) { - return weekDays.length === 1 ? getTimeManagementDayKey(weekDays[0]) : ""; - } - - const parsedDate = parseTimeEntryDate(rawDate); - - if (!Number.isNaN(parsedDate.getTime())) { - return getLocalDateKey(parsedDate); - } - - const matchingDay = (weekDays || []).find((day) => { - return normalizeString(day?.date) === normalizeString(rawDate); - }); - - return matchingDay ? getTimeManagementDayKey(matchingDay) : ""; - } - - 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 cssEscape(value) { - if (window.CSS?.escape) { - return window.CSS.escape(String(value || "")); - } - - return String(value || "").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); @@ -12168,69 +1047,6 @@ closeModal(); } - function handleBridgeMessage(event) { - const message = event.data; - - if (!message || message.source !== BRIDGE.source) { - return; - } - - if (message.type === BRIDGE.workbenchAuthHeadersType) { - rememberWorkbenchAuthHeaders(message.headers); - return; - } - - if (event.source !== window) { - 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 rememberWorkbenchAuthHeaders(headers) { - if (!headers || typeof headers !== "object") { - return; - } - - const nextHeaders = { - ...(appState.sessionAuthHeaders || {}), - }; - - for (const [key, value] of Object.entries(headers)) { - const normalizedKey = cleanString(key).toLowerCase(); - const normalizedValue = cleanString(value); - - if (!normalizedKey || !normalizedValue) { - continue; - } - - nextHeaders[normalizedKey] = normalizedValue; - } - - appState.sessionAuthHeaders = nextHeaders; - } - function observePage() { const observer = new MutationObserver(() => { scheduleSync(); @@ -12269,7 +1085,7 @@ } const syncLoadedToken = () => { - syncWorklistIframeToken({ force: true }); + hcmPage.syncWorklistIframeToken({ force: true }); }; if (document.readyState === "loading") { @@ -12287,8 +1103,24 @@ return; } + if (appState.spaFrameLifecycleIntervalId) { + return; + } + const syncFrame = () => { - ensureSpaFrame(); + void ensureSpaFrame().catch((error) => { + appState.lastSpaFrameLifecycleError = + error instanceof Error ? error.message : String(error); + }); + }; + + const stopSyncFrame = () => { + if (!appState.spaFrameLifecycleIntervalId) { + return; + } + + window.clearInterval(appState.spaFrameLifecycleIntervalId); + appState.spaFrameLifecycleIntervalId = null; }; if (document.readyState === "loading") { @@ -12299,7 +1131,9 @@ window.addEventListener("load", syncFrame); window.addEventListener("pageshow", syncFrame); - window.setInterval(syncFrame, 5000); + window.addEventListener("pagehide", stopSyncFrame); + window.addEventListener("beforeunload", stopSyncFrame); + appState.spaFrameLifecycleIntervalId = window.setInterval(syncFrame, 15000); } window.addEventListener("message", handleBridgeMessage); diff --git a/manifest.json b/manifest.json index f306458..d6ed3cc 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,9 @@ -{ +{ "manifest_version": 3, - "name": "Arch Panel Injector", - "version": "1.0.4", + "name": "Arch Panel", + "version": "1.0.5", "description": "Insere o botao Arch panel no header do Oracle Workload Workbench.", "permissions": [ - "cookies", "declarativeNetRequestWithHostAccess", "scripting", "tabs" @@ -35,7 +34,35 @@ "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html*", "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*" ], + "css": [ + "src/content/styles.css" + ], "js": [ + "src/shared/config.js", + "src/shared/utils.js", + "src/services/preferences-service.js", + "src/services/storage-service.js", + "src/services/chrome-service.js", + "src/services/transport-service.js", + "src/repositories/cache-repository.js", + "src/repositories/workbench-repository.js", + "src/repositories/comcip-repository.js", + "src/domain/ramp-forecast.js", + "src/domain/workload-dataset.js", + "src/domain/workload-calendar.js", + "src/domain/time-management.js", + "src/domain/time-management-data.js", + "src/content/shell-view.js", + "src/content/templates.js", + "src/content/hcm-page.js", + "src/content/modal-events.js", + "src/content/workbench-controller.js", + "src/content/manage-time-controller.js", + "src/content/export-html.js", + "src/views/render-helpers.js", + "src/views/dashboard-view.js", + "src/views/workbench-modals-view.js", + "src/views/manage-time-view.js", "content-script.js" ], "run_at": "document_start" @@ -53,128 +80,3 @@ } ] } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/page-bridge.js b/page-bridge.js index 143ccc3..702f557 100644 --- a/page-bridge.js +++ b/page-bridge.js @@ -10,6 +10,12 @@ const EXTRA_CAPTURE_ORIGINS = new Set([ "https://comcipapic-oalprod.integration.ocp.oraclecloud.com", ]); + const ALLOWED_TOP_MESSAGE_ORIGINS = new Set([ + "https://eeho.fa.us2.oraclecloud.com", + "https://spa.oracle.com", + ]); + const AUTH_STORAGE_HINT_PATTERN = + /(authorization|token|wwb|wlwb|provider|spa-ts|spats)/i; const SAFE_HEADER_BLOCKLIST = new Set([ "accept-encoding", "accept-language", @@ -48,6 +54,51 @@ } } + function getAllowedTopMessageOrigin() { + const candidates = []; + + try { + const referrerOrigin = document.referrer + ? new URL(document.referrer).origin + : ""; + + if (referrerOrigin) { + candidates.push(referrerOrigin); + } + } catch { + // Referrer parsing is best-effort; fall back to ancestorOrigins/current origin. + } + + try { + const ancestorOrigins = Array.from(window.location?.ancestorOrigins || []); + candidates.push(...ancestorOrigins); + } catch { + // ancestorOrigins is browser-specific and may be unavailable. + } + + if (window.top === window) { + candidates.push(getSafeTargetOrigin()); + } + + for (const origin of candidates) { + if (ALLOWED_TOP_MESSAGE_ORIGINS.has(origin)) { + return origin; + } + } + + return ""; + } + + function safePostToTop(payload) { + const targetOrigin = getAllowedTopMessageOrigin(); + + if (!targetOrigin) { + return false; + } + + return safePostMessage(payload, window.top, targetOrigin); + } + const nativeFetch = window.fetch.bind(window); const originalOpen = window.XMLHttpRequest.prototype.open; const originalSetRequestHeader = window.XMLHttpRequest.prototype.setRequestHeader; @@ -215,15 +266,13 @@ return; } - safePostMessage( + safePostToTop( { source: MESSAGE_SOURCE, type: "ARCH_PANEL_WORKBENCH_AUTH_HEADERS", url: normalizeUrl(url), headers: authHeaders, - }, - window.top, - "*" + } ); } @@ -356,7 +405,7 @@ const key = storage.key(index) || ""; const value = storage.getItem(key) || ""; - if (key || value) { + if (isAuthStorageCandidate(key, value)) { candidates.push([key, value]); } } @@ -372,6 +421,25 @@ return headers; } + function isAuthStorageCandidate(key, value) { + const normalizedKey = String(key || ""); + const normalizedValue = String(value || "").trim(); + + if (!normalizedKey && !normalizedValue) { + return false; + } + + if (AUTH_STORAGE_HINT_PATTERN.test(normalizedKey)) { + return true; + } + + if (looksLikeJwt(normalizedValue)) { + return true; + } + + return AUTH_STORAGE_HINT_PATTERN.test(normalizedValue); + } + function collectAuthFromStorageEntry(headers, key, value) { const normalizedKey = String(key || "").toLowerCase(); const normalizedValue = String(value || "").trim(); diff --git a/src/background/config.js b/src/background/config.js new file mode 100644 index 0000000..dad9ee1 --- /dev/null +++ b/src/background/config.js @@ -0,0 +1,39 @@ +(() => { + 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_TIME_ENTRY_APP_URL = + `${COMCIP_ORIGIN}/ic/builder/rt/oalset_timeentrymobile/live/webApps/timeentry/`; + const COMCIP_TIME_ENTRY_CLIENT_ID_PROBE_URL = + `${COMCIP_ORIGIN}/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/lookups?lookupType=SCTA_TASK_TYPE&type=CUSTOM`; + const COMCIP_TIME_ENTRY_APP_VERSION = "version_1774016034953"; + const COMCIP_TAB_URL_PATTERN = + `${COMCIP_ORIGIN}/ic/builder/rt/oalset_semc/live*`; + const COMCIP_TIME_ENTRY_TAB_URL_PATTERN = + `${COMCIP_ORIGIN}/ic/builder/rt/oalset_timeentrymobile/live*`; + const SPA_WORKBENCH_FRAME_URL = + "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=workload_workbench"; + const SPA_WORKBENCH_FRAME_ID = "arch-panel-extension-spa-frame"; + const SPA_WORKBENCH_FRAME_HOST_ID = "arch-panel-extension-spa-frame-host"; + const SPA_FRAME_DNR_RULE_ID = 92001; + + globalThis.ArchPanelBackgroundConfig = Object.freeze({ + COMCIP_ORIGIN, + COMCIP_APP_URL, + COMCIP_QUERY_URL, + COMCIP_CLIENT_ID_PROBE_URL, + COMCIP_TIME_ENTRY_APP_URL, + COMCIP_TIME_ENTRY_CLIENT_ID_PROBE_URL, + COMCIP_TIME_ENTRY_APP_VERSION, + COMCIP_TAB_URL_PATTERN, + COMCIP_TIME_ENTRY_TAB_URL_PATTERN, + SPA_WORKBENCH_FRAME_URL, + SPA_WORKBENCH_FRAME_ID, + SPA_WORKBENCH_FRAME_HOST_ID, + SPA_FRAME_DNR_RULE_ID, + }); +})(); diff --git a/src/content/export-html.js b/src/content/export-html.js new file mode 100644 index 0000000..423cd10 --- /dev/null +++ b/src/content/export-html.js @@ -0,0 +1,653 @@ +(() => { + function escapeJsonForInlineScript(json) { + return String(json || "") + .replace(//g, "\\u003e") + .replace(/&/g, "\\u0026") + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); + } + + function createExportHtml(dataset) { + const json = escapeJsonForInlineScript(JSON.stringify(dataset.exportPayload)); + + return ` + + + + + Arch Panel Dashboard + + + +
+
+ + +`; + } + + + globalThis.ArchPanelExportHtml = Object.freeze({ + createExportHtml, + }); +})(); diff --git a/src/content/hcm-page.js b/src/content/hcm-page.js new file mode 100644 index 0000000..185142e --- /dev/null +++ b/src/content/hcm-page.js @@ -0,0 +1,118 @@ +(() => { + function create({ + HCM_MY_INFORMATION_APPS_GROUP_ID, + WORKLIST_ORIGIN, + WORKLIST_SAASUI_PATHNAME, + IDS, + cleanString, + hcmTileTemplate, + onOpenArchPanel, + writeCachedWorklistToken, + }) { + let lastPersistedWorklistIframeUrl = ""; + + function syncHcmTile() { + const appsGroup = document.getElementById(HCM_MY_INFORMATION_APPS_GROUP_ID); + const addTile = appsGroup?.querySelector( + ".flat-grid-cell.flat-grid-cell-addicon" + ); + + if (!addTile?.parentElement) { + document.getElementById(IDS.hcmTile)?.remove(); + return; + } + + const tile = document.getElementById(IDS.hcmTile) || createHcmTile(); + + if ( + tile.parentElement !== addTile.parentElement || + tile.nextElementSibling !== addTile + ) { + addTile.parentElement.insertBefore(tile, addTile); + } + } + + function syncWorklistIframeToken(options = {}) { + for (const frame of document.querySelectorAll("iframe")) { + const tokenPayload = extractWorklistIframeToken(frame); + + if (tokenPayload?.token) { + persistWorklistIframeToken(tokenPayload, options); + return tokenPayload; + } + } + + return null; + } + + function extractWorklistIframeToken(frame) { + const rawSrc = cleanString(frame?.getAttribute?.("src") || frame?.src); + + if (!rawSrc) { + return null; + } + + try { + const url = new URL(rawSrc, window.location.href); + const token = cleanString(url.searchParams.get("token")); + + if ( + url.origin !== WORKLIST_ORIGIN || + url.pathname !== WORKLIST_SAASUI_PATHNAME || + !token + ) { + return null; + } + + return { + token, + url: url.toString(), + }; + } catch { + return null; + } + } + + function persistWorklistIframeToken(tokenPayload, options = {}) { + if (!options.force && tokenPayload.url === lastPersistedWorklistIframeUrl) { + return; + } + + lastPersistedWorklistIframeUrl = tokenPayload.url; + void writeCachedWorklistToken(tokenPayload); + } + + function createHcmTile() { + const template = document.createElement("template"); + template.innerHTML = hcmTileTemplate(IDS.hcmTile); + const tile = template.content.firstElementChild; + + tile.addEventListener("click", openHcmArchPanelTile); + tile.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + openHcmArchPanelTile(event); + } + }); + tile.tabIndex = 0; + tile.setAttribute("role", "button"); + tile.setAttribute("aria-label", "Open Arch Panel"); + + return tile; + } + + function openHcmArchPanelTile(event) { + event.preventDefault(); + event.stopPropagation(); + onOpenArchPanel(); + } + + return Object.freeze({ + syncHcmTile, + syncWorklistIframeToken, + }); + } + + globalThis.ArchPanelHcmPage = Object.freeze({ + create, + }); +})(); diff --git a/src/content/manage-time-controller.js b/src/content/manage-time-controller.js new file mode 100644 index 0000000..70b71ce --- /dev/null +++ b/src/content/manage-time-controller.js @@ -0,0 +1,944 @@ +(() => { + function create(dependencies) { + const { + appState, + COMCIP_REQUEST, + comcipRepository, + readCachedTaskTypePayload, + writeCachedTaskTypePayload, + getStoredTimeManagementSelectedDate, + setStoredTimeManagementSelectedDate, + renderModal, + cleanString, + normalizeString, + getDateInputValue, + parseDatePreservingDateOnly, + getFieldValue, + extractArray, + getErrorMessage, + cssEscape, + getTimeManagementRows, + isTimeManagementSaveEnabled, + getTimeManagementPendingChanges, + createTimeManagementSavePayload, + isNonServiceRequestSrNumber, + normalizeInfoWeekResponse, + normalizeTimeManagementServiceRequests, + mergeTimeManagementServiceRequests, + normalizeTimeManagementEntryRows, + normalizeTimeManagementEntryRowSelection, + getActivityRequestSrNumbers, + normalizeActivityRequest, + normalizeTaskTypeRequest, + getTimeManagementTaskTypeOptions, + getTimeManagementActivityOptions, + findTimeManagementServiceRequest, + findOptionValue, + } = dependencies; + + let requestCounter = 0; + + function toggleTimeManagementDatePicker() { + const selectedDate = getTimeManagementSelectedDate(); + const pickerDate = parseDatePreservingDateOnly(selectedDate); + const fallback = Number.isNaN(pickerDate.getTime()) ? new Date() : pickerDate; + + appState.timeManagementDatePicker = { + isOpen: !appState.timeManagementDatePicker.isOpen, + year: fallback.getFullYear(), + month: fallback.getMonth(), + }; + renderModal(); + } + + function shiftTimeManagementDatePickerMonth(delta) { + const picker = appState.timeManagementDatePicker; + const nextDate = new Date(picker.year, picker.month + delta, 1); + + appState.timeManagementDatePicker = { + isOpen: true, + year: nextDate.getFullYear(), + month: nextDate.getMonth(), + }; + renderModal(); + } + + async function selectTimeManagementDate(selectedDate) { + appState.timeManagementDatePicker = { + ...appState.timeManagementDatePicker, + isOpen: false, + }; + await loadTimeManagementData(selectedDate); + } + + async function shiftTimeManagementDateByWeeks(delta) { + const selectedDate = getTimeManagementSelectedDate(); + const parsedDate = parseDatePreservingDateOnly(selectedDate); + const baseDate = Number.isNaN(parsedDate.getTime()) + ? new Date() + : parsedDate; + + baseDate.setDate(baseDate.getDate() + (Number(delta) || 0) * 7); + appState.timeManagementDatePicker = { + ...appState.timeManagementDatePicker, + isOpen: false, + }; + await loadTimeManagementData(getDateInputValue(baseDate)); + } + + function getTimeManagementSelectedDate() { + return appState.timeManagementModal?.selectedDate || getDateInputValue(new Date()); + } + + async function openTimeManagementModal() { + const selectedDate = getStoredTimeManagementSelectedDate(); + + appState.timeManagementModal = createTimeManagementLoadingState(selectedDate); + renderModal(); + await loadTimeManagementData(selectedDate); + } + + async function loadTimeManagementData(selectedDate) { + const normalizedDate = cleanString(selectedDate) || getDateInputValue(new Date()); + const requestId = `${Date.now()}-${++requestCounter}`; + + setStoredTimeManagementSelectedDate(normalizedDate); + appState.timeManagementDatePicker = { + ...appState.timeManagementDatePicker, + isOpen: false, + }; + appState.timeManagementModal = { + ...(appState.timeManagementModal || {}), + requestId, + selectedDate: normalizedDate, + status: "loading", + errorMessage: "", + }; + renderModal(); + + try { + const resourcePartyId = getCurrentUserResourcePartyId(); + const taskTypePromise = fetchTaskTypeRequest(); + + if (!resourcePartyId) { + throw new Error("resourceCurrentUser.resourcePartyId nao esta disponivel."); + } + + const weekInfoPayload = await comcipRepository.fetchInfoWeekRequest( + normalizedDate + ); + const weekDays = normalizeInfoWeekResponse(weekInfoPayload); + const weekId = cleanString(weekDays[0]?.weekId); + + if (!weekId) { + throw new Error("WeekId nao encontrado no retorno de infoWeekRequest."); + } + + const timeEntryWeekUrl = comcipRepository.buildTimeEntryWeekUrl( + resourcePartyId, + weekId + ); + const [srPayload, taskTypePayload, timeEntryWeekPayload] = await Promise.all([ + comcipRepository.fetchActiveServiceRequests(resourcePartyId, weekId), + taskTypePromise, + comcipRepository.fetchTimeEntryWeek(timeEntryWeekUrl), + ]); + const timeEntryRows = normalizeTimeManagementEntryRows( + timeEntryWeekPayload, + weekDays + ); + const serviceRequests = mergeTimeManagementServiceRequests( + normalizeTimeManagementServiceRequests(srPayload), + timeEntryRows + ); + const activitySrNumbers = getActivityRequestSrNumbers( + srPayload, + serviceRequests + ); + const activityRequest = await fetchCachedActivityRequest(activitySrNumbers); + const activityRequestUrl = activityRequest.url; + const activityPayload = activityRequest.payload; + const activityOptionsBySr = normalizeActivityRequest( + activityPayload, + serviceRequests + ); + const taskTypes = normalizeTaskTypeRequest(taskTypePayload); + const rows = timeEntryRows; + const selectedSrNumber = rows[0]?.selectedSrNumber || ""; + const normalizedRows = rows.map((row, index) => + normalizeTimeManagementEntryRowSelection( + row, + index, + activityOptionsBySr, + taskTypes + ) + ); + + if (appState.timeManagementModal?.requestId !== requestId) { + return; + } + + appState.timeManagementModal = { + requestId, + selectedDate: normalizedDate, + status: "ready", + errorMessage: "", + weekId, + weekDays, + serviceRequests, + selectedSrNumber, + activityOptionsBySr, + activityRequestUrl, + activityRequestPayload: activityPayload, + timeEntryRows: normalizedRows, + selectedActivityValue: normalizedRows[0]?.selectedActivityValue || "", + taskTypes, + selectedTaskTypeValue: normalizedRows[0]?.selectedTaskTypeValue || "", + highlightRowId: "", + deletedTimeEntryIds: [], + isSaving: false, + saveErrorMessage: "", + }; + renderModal(); + } catch (error) { + if (appState.timeManagementModal?.requestId !== requestId) { + return; + } + + appState.timeManagementModal = { + ...(appState.timeManagementModal || {}), + requestId, + selectedDate: normalizedDate, + status: "error", + errorMessage: getErrorMessage(error), + }; + renderModal(); + } + } + + function createTimeManagementLoadingState(selectedDate) { + return { + requestId: "", + selectedDate, + status: "loading", + errorMessage: "", + weekId: "", + weekDays: [], + serviceRequests: [], + selectedSrNumber: "", + activityOptionsBySr: {}, + activityRequestUrl: "", + activityRequestPayload: null, + selectedActivityValue: "", + taskTypes: [], + selectedTaskTypeValue: "", + timeEntryRows: [], + highlightRowId: "", + deletedTimeEntryIds: [], + isSaving: false, + saveErrorMessage: "", + }; + } + + function createBlankTimeManagementEntryRow(srNumber = "", source = "blank") { + return { + id: `row-${Date.now()}-${Math.random().toString(16).slice(2)}`, + timeEntryId: "", + selectedSrNumber: cleanString(srNumber), + selectedActivityValue: "", + selectedTaskTypeValue: "", + dayHours: {}, + source, + originalSignature: "", + }; + } + + function addTimeManagementEntryRow(type) { + if (!appState.timeManagementModal) { + return; + } + + const existingDuplicateRow = findExistingTimeManagementDuplicateRow( + appState.timeManagementModal, + type + ); + + if (existingDuplicateRow) { + highlightTimeManagementDuplicateRow(existingDuplicateRow.id); + return; + } + + const srNumber = type === "non-service" ? "non-service-sr" : ""; + const nextRow = createBlankTimeManagementEntryRow(srNumber, "manual"); + const nextRows = [ + ...getTimeManagementRows(appState.timeManagementModal), + nextRow, + ]; + + appState.timeManagementModal = { + ...appState.timeManagementModal, + timeEntryRows: nextRows, + highlightRowId: "", + saveErrorMessage: "", + }; + appState.pendingFocusSelector = getTimeManagementComboboxFocusSelector( + nextRow.id, + type === "non-service" ? "taskType" : "sr" + ); + renderModal(); + } + + function removeTimeManagementEntryRow(rowId) { + if (!appState.timeManagementModal) { + return; + } + + const normalizedRowId = cleanString(rowId); + const rows = getTimeManagementRows(appState.timeManagementModal); + const removedRow = rows.find( + (row) => cleanString(row?.id) === normalizedRowId + ); + const removedTimeEntryId = cleanString(removedRow?.timeEntryId); + const nextRows = rows.filter( + (row) => cleanString(row?.id) !== normalizedRowId + ); + const deletedTimeEntryIds = removedTimeEntryId + ? Array.from( + new Set([ + ...(appState.timeManagementModal.deletedTimeEntryIds || []), + removedTimeEntryId, + ]) + ) + : appState.timeManagementModal.deletedTimeEntryIds || []; + + appState.timeManagementModal = { + ...appState.timeManagementModal, + timeEntryRows: nextRows, + selectedSrNumber: nextRows[0]?.selectedSrNumber || "", + selectedActivityValue: nextRows[0]?.selectedActivityValue || "", + selectedTaskTypeValue: nextRows[0]?.selectedTaskTypeValue || "", + highlightRowId: "", + deletedTimeEntryIds, + saveErrorMessage: "", + }; + appState.pendingFocusSelector = getTimeManagementPostDeleteFocusSelector( + nextRows, + rows, + normalizedRowId + ); + renderModal(); + } + + function getTimeManagementComboboxFocusSelector(rowId, field) { + return `.arch-panel-extension-combobox input[data-row-id="${cssEscape(rowId)}"][data-combobox-field="${cssEscape(field)}"]`; + } + + function getTimeManagementPostDeleteFocusSelector( + nextRows, + previousRows, + removedRowId + ) { + if (!nextRows.length) { + return ""; + } + + const removedIndex = previousRows.findIndex( + (row) => cleanString(row?.id) === cleanString(removedRowId) + ); + const focusIndex = Math.min(Math.max(removedIndex, 0), nextRows.length - 1); + const focusRow = nextRows[focusIndex]; + + return focusRow?.id + ? `.arch-panel-extension-time-management-delete[data-row-id="${cssEscape(focusRow.id)}"]` + : ""; + } + + function getTimeManagementRowById(modal, rowId) { + const rows = getTimeManagementRows(modal); + const normalizedRowId = cleanString(rowId); + + return ( + rows.find((row) => cleanString(row?.id) === normalizedRowId) || + rows[0] || + null + ); + } + + function updateTimeManagementRows(modal, rowId, nextRow) { + const rows = getTimeManagementRows(modal); + const normalizedRowId = cleanString(rowId || nextRow?.id); + + return rows.map((row, index) => { + const isTarget = normalizedRowId + ? cleanString(row?.id) === normalizedRowId + : index === 0; + + return isTarget + ? { + ...row, + ...nextRow, + id: row?.id || nextRow?.id || `row-${index + 1}`, + } + : row; + }); + } + + function updateTimeManagementSelectedSr(srNumber, rowId = "") { + if (!appState.timeManagementModal) { + return; + } + + const targetRow = getTimeManagementRowById(appState.timeManagementModal, rowId); + const selectedServiceRequest = findTimeManagementServiceRequest( + appState.timeManagementModal, + srNumber + ); + const selectedSrNumber = + selectedServiceRequest?.srNumber || cleanString(srNumber); + const duplicateRow = findDuplicateTimeManagementSrRow( + appState.timeManagementModal, + selectedSrNumber, + rowId + ); + + if (duplicateRow) { + highlightTimeManagementDuplicateRow(duplicateRow.id); + return; + } + + const nextRow = { + ...(targetRow || createBlankTimeManagementEntryRow()), + selectedSrNumber, + selectedActivityValue: "", + selectedTaskTypeValue: "", + }; + const nextRows = updateTimeManagementRows( + appState.timeManagementModal, + rowId, + nextRow + ); + + appState.timeManagementModal = { + ...appState.timeManagementModal, + timeEntryRows: nextRows, + selectedSrNumber: nextRows[0]?.selectedSrNumber || selectedSrNumber, + selectedActivityValue: nextRows[0]?.selectedActivityValue || "", + selectedTaskTypeValue: nextRows[0]?.selectedTaskTypeValue || "", + highlightRowId: "", + saveErrorMessage: "", + }; + renderModal(); + } + + function updateTimeManagementField(fieldName, inputValue, rowId = "") { + if (!appState.timeManagementModal) { + return; + } + + const targetRow = getTimeManagementRowById(appState.timeManagementModal, rowId); + const normalizedInput = cleanString(inputValue); + let value = normalizedInput; + + if (fieldName === "selectedActivityValue") { + const options = getTimeManagementActivityOptions( + appState.timeManagementModal, + targetRow?.selectedSrNumber || appState.timeManagementModal.selectedSrNumber + ); + value = findOptionValue(options, normalizedInput) || normalizedInput; + } + + if (fieldName === "selectedTaskTypeValue") { + const options = getTimeManagementTaskTypeOptions( + appState.timeManagementModal, + targetRow?.selectedSrNumber || appState.timeManagementModal.selectedSrNumber + ); + value = findOptionValue(options, normalizedInput) || normalizedInput; + + const duplicateRow = findDuplicateTimeManagementNonServiceTaskTypeRow( + appState.timeManagementModal, + value, + rowId + ); + + if (duplicateRow) { + highlightTimeManagementDuplicateRow(duplicateRow.id); + return; + } + } + + const nextRows = updateTimeManagementRows(appState.timeManagementModal, rowId, { + ...(targetRow || createBlankTimeManagementEntryRow()), + [fieldName]: value, + }); + + appState.timeManagementModal = { + ...appState.timeManagementModal, + timeEntryRows: nextRows, + [fieldName]: value, + highlightRowId: "", + saveErrorMessage: "", + }; + } + + function findDuplicateTimeManagementSrRow(modal, srNumber, currentRowId = "") { + const normalizedSrNumber = normalizeString(srNumber); + const normalizedCurrentRowId = cleanString(currentRowId); + + if (!normalizedSrNumber || isNonServiceRequestSrNumber(srNumber)) { + return null; + } + + return ( + getTimeManagementRows(modal).find((row) => { + return ( + cleanString(row?.id) !== normalizedCurrentRowId && + normalizeString(row?.selectedSrNumber) === normalizedSrNumber + ); + }) || null + ); + } + + function findExistingTimeManagementDuplicateRow(modal, type) { + const seen = new Map(); + + for (const row of getTimeManagementRows(modal)) { + const rowId = cleanString(row?.id); + let key = ""; + + if (type === "non-service") { + if (!isNonServiceRequestSrNumber(row?.selectedSrNumber)) { + continue; + } + + key = normalizeString(row?.selectedTaskTypeValue); + } else { + if (isNonServiceRequestSrNumber(row?.selectedSrNumber)) { + continue; + } + + key = normalizeString(row?.selectedSrNumber); + } + + if (!key) { + continue; + } + + if (seen.has(key)) { + return ( + getTimeManagementRows(modal).find( + (candidate) => cleanString(candidate?.id) === seen.get(key) + ) || row + ); + } + + seen.set(key, rowId); + } + + return null; + } + + function findDuplicateTimeManagementNonServiceTaskTypeRow( + modal, + taskTypeValue, + currentRowId = "" + ) { + const normalizedTaskTypeValue = normalizeString(taskTypeValue); + const normalizedCurrentRowId = cleanString(currentRowId); + + if (!normalizedTaskTypeValue) { + return null; + } + + const currentRow = getTimeManagementRowById(modal, currentRowId); + + if (!isNonServiceRequestSrNumber(currentRow?.selectedSrNumber)) { + return null; + } + + return ( + getTimeManagementRows(modal).find((row) => { + return ( + cleanString(row?.id) !== normalizedCurrentRowId && + isNonServiceRequestSrNumber(row?.selectedSrNumber) && + normalizeString(row?.selectedTaskTypeValue) === normalizedTaskTypeValue + ); + }) || null + ); + } + + function highlightTimeManagementDuplicateRow(rowId) { + const normalizedRowId = cleanString(rowId); + + if (!appState.timeManagementModal || !normalizedRowId) { + return; + } + + appState.pendingTimeManagementHighlightRowId = normalizedRowId; + appState.timeManagementModal = { + ...appState.timeManagementModal, + highlightRowId: normalizedRowId, + }; + renderModal(); + } + + function updateTimeManagementDayHours(rowId, dateKey, inputValue) { + if (!appState.timeManagementModal) { + return; + } + + const targetRow = getTimeManagementRowById(appState.timeManagementModal, rowId); + const nextRow = { + ...(targetRow || createBlankTimeManagementEntryRow()), + dayHours: { + ...(targetRow?.dayHours || {}), + [cleanString(dateKey)]: cleanString(inputValue), + }, + }; + + appState.timeManagementModal = { + ...appState.timeManagementModal, + timeEntryRows: updateTimeManagementRows( + appState.timeManagementModal, + rowId, + nextRow + ), + highlightRowId: "", + saveErrorMessage: "", + }; + renderModal(); + } + + async function saveTimeManagementChanges() { + const modal = appState.timeManagementModal; + + if (!modal || modal.isSaving || !isTimeManagementSaveEnabled(modal)) { + return; + } + + appState.timeManagementModal = { + ...modal, + isSaving: true, + saveErrorMessage: "", + }; + renderModal(); + + try { + const resourceId = getCurrentUserResourcePartyId(); + const weekId = cleanString(modal.weekId); + + if (!resourceId) { + throw new Error("resourceCurrentUser.resourcePartyId nao esta disponivel."); + } + + if (!weekId) { + throw new Error("WeekId nao encontrado para gravacao do apontamento."); + } + + const changes = getTimeManagementPendingChanges(modal); + const baseUrl = COMCIP_REQUEST.timeEntriesUrl; + const updatedPayload = changes.updatedRows.map((row) => + createTimeManagementSavePayload(row, modal.weekDays, weekId, resourceId, { + includeId: true, + }) + ); + const newPayload = changes.newRows.map((row) => + createTimeManagementSavePayload(row, modal.weekDays, weekId, resourceId) + ); + + for (const timeEntryId of changes.deletedIds) { + await comcipRepository.mutateComcipPayload( + "DELETE", + `${baseUrl}/${encodeURIComponent(timeEntryId)}`, + null, + "timeEntryDelete" + ); + } + + if (updatedPayload.length) { + await comcipRepository.mutateComcipPayload( + "PUT", + baseUrl, + updatedPayload, + "timeEntryUpdate" + ); + } + + if (newPayload.length) { + await comcipRepository.mutateComcipPayload( + "POST", + baseUrl, + newPayload, + "timeEntryCreate" + ); + } + + await loadTimeManagementData( + modal.selectedDate || getDateInputValue(new Date()) + ); + } catch (error) { + appState.timeManagementModal = { + ...(appState.timeManagementModal || modal), + isSaving: false, + saveErrorMessage: getErrorMessage(error), + }; + renderModal(); + } + } + + function selectTimeManagementComboboxOption(field, value, rowId = "") { + if (field === "sr") { + updateTimeManagementSelectedSr(value || "", rowId); + return; + } + + if (field === "activity") { + updateTimeManagementField("selectedActivityValue", value || "", rowId); + renderModal(); + return; + } + + if (field === "taskType") { + updateTimeManagementField("selectedTaskTypeValue", value || "", rowId); + renderModal(); + } + } + + function filterTimeManagementCombobox(input) { + const query = normalizeString(input.value); + const combobox = input.closest(".arch-panel-extension-combobox"); + + if (!combobox) { + return; + } + + const options = Array.from( + combobox.querySelectorAll(".arch-panel-extension-combobox-option") + ); + + for (const option of options) { + const searchText = normalizeString(option.getAttribute("data-search") || ""); + option.hidden = Boolean(query) && !searchText.includes(query); + } + + setActiveTimeManagementComboboxOption( + combobox, + getVisibleTimeManagementComboboxOptions(combobox).find((option) => + option.classList.contains("is-selected") + ) || getVisibleTimeManagementComboboxOptions(combobox)[0] + ); + } + + function handleTimeManagementComboboxKeydown(event, input) { + const combobox = input.closest(".arch-panel-extension-combobox"); + + if (!combobox) { + return; + } + + if (event.key === "Escape") { + clearActiveTimeManagementComboboxOption(combobox); + input.blur(); + return; + } + + const visibleOptions = getVisibleTimeManagementComboboxOptions(combobox); + + if (!visibleOptions.length) { + return; + } + + event.preventDefault(); + + if (event.key === "Enter") { + const activeOption = + visibleOptions.find((option) => option.classList.contains("is-active")) || + visibleOptions[0]; + activeOption.click(); + return; + } + + const activeIndex = visibleOptions.findIndex((option) => + option.classList.contains("is-active") + ); + const selectedIndex = visibleOptions.findIndex((option) => + option.classList.contains("is-selected") + ); + const baseIndex = activeIndex >= 0 ? activeIndex : selectedIndex; + let nextIndex = 0; + + if (event.key === "ArrowDown") { + nextIndex = baseIndex >= 0 ? baseIndex + 1 : 0; + } else if (event.key === "ArrowUp") { + nextIndex = baseIndex >= 0 ? baseIndex - 1 : visibleOptions.length - 1; + } else if (event.key === "Home") { + nextIndex = 0; + } else if (event.key === "End") { + nextIndex = visibleOptions.length - 1; + } + + if (nextIndex < 0) { + nextIndex = visibleOptions.length - 1; + } else if (nextIndex >= visibleOptions.length) { + nextIndex = 0; + } + + setActiveTimeManagementComboboxOption(combobox, visibleOptions[nextIndex]); + } + + function getVisibleTimeManagementComboboxOptions(combobox) { + return Array.from( + combobox.querySelectorAll(".arch-panel-extension-combobox-option") + ).filter((option) => !option.hidden); + } + + function setActiveTimeManagementComboboxOption(combobox, option) { + clearActiveTimeManagementComboboxOption(combobox); + + if (!option) { + return; + } + + option.classList.add("is-active"); + combobox + .querySelector("input[data-combobox-field]") + ?.setAttribute("aria-activedescendant", option.id || ""); + option.scrollIntoView({ block: "nearest" }); + } + + function clearActiveTimeManagementComboboxOption(combobox) { + combobox + .querySelectorAll(".arch-panel-extension-combobox-option.is-active") + .forEach((option) => option.classList.remove("is-active")); + combobox + .querySelector("input[data-combobox-field]") + ?.removeAttribute("aria-activedescendant"); + } + + async function fetchCachedActivityRequest(srNumbers) { + const cacheKey = getActivityRequestCacheKey(srNumbers); + + if (!cacheKey) { + return { + url: "", + payload: null, + }; + } + + const cachedRequest = appState.timeManagementActivityCache.get(cacheKey); + + if (cachedRequest?.payload) { + return cachedRequest; + } + + if (cachedRequest?.promise) { + return cachedRequest.promise; + } + + const requestPromise = comcipRepository + .fetchActivityRequest(srNumbers) + .then((result) => { + appState.timeManagementActivityCache.set(cacheKey, { + url: result.url, + payload: result.payload, + }); + + return result; + }) + .catch((error) => { + appState.timeManagementActivityCache.delete(cacheKey); + throw error; + }); + + appState.timeManagementActivityCache.set(cacheKey, { + promise: requestPromise, + }); + + return requestPromise; + } + + function getActivityRequestCacheKey(srNumbers) { + const normalizedSrNumbers = (srNumbers || []) + .map((srNumber) => cleanString(srNumber).toUpperCase()) + .filter((srNumber) => /^SR\d+$/i.test(srNumber)) + .sort(); + + return normalizedSrNumbers.join("|"); + } + + async function fetchTaskTypeRequest() { + if (appState.taskTypeCache.promise) { + return appState.taskTypeCache.promise; + } + + appState.taskTypeCache.promise = (async () => { + return readCachedTaskTypePayload().catch(() => null); + })().finally(() => { + appState.taskTypeCache.promise = null; + }); + + return appState.taskTypeCache.promise; + } + + async function refreshTaskTypeCache() { + const payload = await comcipRepository.fetchTaskTypePayload(); + await writeCachedTaskTypePayload(payload); + + return payload; + } + + function getCurrentUserResourcePartyId() { + const resourceCurrentUser = appState.dataset?.resourceCurrentUser; + const directResourcePartyId = cleanString( + getFieldValue(resourceCurrentUser, [ + "resourcePartyId", + "ResourcePartyId", + "ResourcePartyID", + "partyId", + "PartyId", + ]) + ); + + if (directResourcePartyId) { + return directResourcePartyId; + } + + const candidates = Array.isArray(resourceCurrentUser) + ? resourceCurrentUser + : extractArray(resourceCurrentUser, ["items", "data", "results", "content"]); + const source = candidates[0] || resourceCurrentUser; + + return cleanString( + getFieldValue(source, [ + "resourcePartyId", + "ResourcePartyId", + "ResourcePartyID", + "partyId", + "PartyId", + ]) + ); + } + + return Object.freeze({ + toggleTimeManagementDatePicker, + shiftTimeManagementDatePickerMonth, + selectTimeManagementDate, + shiftTimeManagementDateByWeeks, + openTimeManagementModal, + loadTimeManagementData, + addTimeManagementEntryRow, + removeTimeManagementEntryRow, + updateTimeManagementSelectedSr, + updateTimeManagementField, + updateTimeManagementDayHours, + saveTimeManagementChanges, + selectTimeManagementComboboxOption, + filterTimeManagementCombobox, + handleTimeManagementComboboxKeydown, + refreshTaskTypeCache, + }); + } + + globalThis.ArchPanelManageTimeController = Object.freeze({ + create, + }); +})(); diff --git a/src/content/modal-events.js b/src/content/modal-events.js new file mode 100644 index 0000000..e444fac --- /dev/null +++ b/src/content/modal-events.js @@ -0,0 +1,533 @@ +(() => { + function create(dependencies) { + const { + appState, + renderModal, + closeModal, + closeDetailModal, + toggleTheme, + toggleSidebar, + refreshData, + shiftCalendarPeriod, + goToCurrentCalendarPeriod, + setCalendarView, + downloadFile, + createExportHtml, + openDetailModal, + openActionFormModal, + openRampComparisonModal, + openForecastUpdateConfirmModal, + submitForecastUpdate, + openTimeEntriesDrawer, + openTimeManagementModal, + addTimeManagementEntryRow, + saveTimeManagementChanges, + removeTimeManagementEntryRow, + toggleTimeManagementDatePicker, + shiftTimeManagementDateByWeeks, + shiftTimeManagementDatePickerMonth, + selectTimeManagementDate, + selectTimeManagementComboboxOption, + submitActionForm, + toggleDetailSort, + loadTimeManagementData, + updateTimeManagementSelectedSr, + updateTimeManagementField, + updateTimeManagementDayHours, + filterTimeManagementCombobox, + handleTimeManagementComboboxKeydown, + } = dependencies; + + function handleOverlayClick(event) { + const overlay = document.getElementById( + globalThis.ArchPanelConfig.IDS.overlay + ); + + if (!overlay) { + return; + } + + if (event.target === overlay) { + if (appState.actionFormModal) { + appState.actionFormModal = null; + renderModal(); + } else if (appState.timeManagementModal) { + appState.timeManagementModal = 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"); + + if ( + action === "time-management-date-change" || + action === "time-management-sr-change" || + action === "time-management-activity-change" || + action === "time-management-task-type-change" + ) { + return; + } + + 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") { + shiftCalendarPeriod(-1); + return; + } + + if (action === "calendar-next") { + shiftCalendarPeriod(1); + return; + } + + if (action === "calendar-today") { + goToCurrentCalendarPeriod(); + return; + } + + if (action === "calendar-view") { + const calendarView = actionElement.getAttribute("data-calendar-view"); + + if (calendarView) { + setCalendarView(calendarView); + } + 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 === "open-time-management") { + void openTimeManagementModal(); + return; + } + + if (action === "close-time-management") { + appState.timeManagementModal = null; + renderModal(); + return; + } + + if (action === "time-management-add-sr") { + addTimeManagementEntryRow("sr"); + return; + } + + if (action === "time-management-add-non-service") { + addTimeManagementEntryRow("non-service"); + return; + } + + if (action === "time-management-save") { + void saveTimeManagementChanges(); + return; + } + + if (action === "time-management-remove-row") { + removeTimeManagementEntryRow(actionElement.getAttribute("data-row-id")); + return; + } + + if (action === "open-time-management-date-picker") { + if (!actionElement.matches("button.arch-panel-extension-date-picker-button")) { + return; + } + + toggleTimeManagementDatePicker(); + return; + } + + if (action === "time-management-date-week-prev") { + void shiftTimeManagementDateByWeeks(-1); + return; + } + + if (action === "time-management-date-week-next") { + void shiftTimeManagementDateByWeeks(1); + return; + } + + if (action === "time-management-date-picker-prev") { + shiftTimeManagementDatePickerMonth(-1); + return; + } + + if (action === "time-management-date-picker-next") { + shiftTimeManagementDatePickerMonth(1); + return; + } + + if (action === "time-management-date-picker-select") { + const selectedDate = actionElement.getAttribute("data-date-value"); + + if (selectedDate) { + void selectTimeManagementDate(selectedDate); + } + return; + } + + if (action === "time-management-combobox-option") { + selectTimeManagementComboboxOption( + actionElement.getAttribute("data-field"), + actionElement.getAttribute("data-value"), + actionElement.getAttribute("data-row-id") + ); + 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 handleOverlayChange(event) { + const target = event.target; + + if (!(target instanceof HTMLInputElement || target instanceof HTMLSelectElement)) { + return; + } + + const action = target.getAttribute("data-action"); + + if (action === "time-management-date-change") { + void loadTimeManagementData(target.value); + return; + } + + if (action === "time-management-sr-change") { + updateTimeManagementSelectedSr( + target.value, + target.getAttribute("data-row-id") + ); + return; + } + + if (action === "time-management-activity-change") { + updateTimeManagementField( + "selectedActivityValue", + target.value, + target.getAttribute("data-row-id") + ); + return; + } + + if (action === "time-management-task-type-change") { + updateTimeManagementField( + "selectedTaskTypeValue", + target.value, + target.getAttribute("data-row-id") + ); + return; + } + + if (action === "time-management-hours-change") { + updateTimeManagementDayHours( + target.getAttribute("data-row-id"), + target.getAttribute("data-date-key"), + target.value + ); + } + } + + function handleOverlayInput(event) { + const target = event.target; + + if (!(target instanceof HTMLInputElement)) { + return; + } + + const field = target.getAttribute("data-combobox-field"); + + if (!field) { + return; + } + + filterTimeManagementCombobox(target); + } + + function handleOverlayKeydown(event) { + const target = event.target; + + if (!(target instanceof HTMLInputElement)) { + return; + } + + if (target.getAttribute("data-action") === "time-management-hours-change") { + handleTimeManagementHourKeydown(event, target); + return; + } + + const field = target.getAttribute("data-combobox-field"); + + if (!field) { + return; + } + + if ( + !["ArrowDown", "ArrowUp", "Home", "End", "Enter", "Escape"].includes( + event.key + ) + ) { + return; + } + + handleTimeManagementComboboxKeydown(event, target); + } + + function handleTimeManagementHourKeydown(event, input) { + if ( + ![ + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown", + "Home", + "End", + "Enter", + ].includes(event.key) + ) { + return; + } + + const nextInput = getNextTimeManagementHourInput(input, event); + + if (!nextInput) { + return; + } + + event.preventDefault(); + nextInput.focus(); + nextInput.select(); + } + + function getNextTimeManagementHourInput(currentInput, event) { + const rows = Array.from( + document.querySelectorAll( + ".arch-panel-extension-time-management-entry-row[data-row-id]" + ) + ); + const currentRow = currentInput.closest( + ".arch-panel-extension-time-management-entry-row" + ); + const rowIndex = rows.indexOf(currentRow); + + if (rowIndex < 0) { + return null; + } + + const currentInputs = getTimeManagementHourInputs(rows[rowIndex]); + const columnIndex = currentInputs.indexOf(currentInput); + + if (columnIndex < 0) { + return null; + } + + let nextRowIndex = rowIndex; + let nextColumnIndex = columnIndex; + + if (event.key === "ArrowLeft") { + nextColumnIndex -= 1; + } else if (event.key === "ArrowRight") { + nextColumnIndex += 1; + } else if (event.key === "ArrowUp") { + nextRowIndex -= 1; + } else if (event.key === "ArrowDown" || event.key === "Enter") { + nextRowIndex += event.shiftKey ? -1 : 1; + } else if (event.key === "Home") { + nextColumnIndex = 0; + } else if (event.key === "End") { + nextColumnIndex = currentInputs.length - 1; + } + + const nextRow = rows[nextRowIndex]; + + if (!nextRow) { + return null; + } + + const nextInputs = getTimeManagementHourInputs(nextRow); + + return nextInputs[nextColumnIndex] || null; + } + + function getTimeManagementHourInputs(row) { + return Array.from( + row.querySelectorAll('input[data-action="time-management-hours-change"]') + ); + } + + return Object.freeze({ + handleOverlayClick, + handleOverlayChange, + handleOverlayInput, + handleOverlayKeydown, + }); + } + + globalThis.ArchPanelModalEvents = Object.freeze({ + create, + }); +})(); diff --git a/src/content/shell-view.js b/src/content/shell-view.js new file mode 100644 index 0000000..33e60d3 --- /dev/null +++ b/src/content/shell-view.js @@ -0,0 +1,328 @@ +(() => { + function create({ + IDS, + CLASSES, + USER_BUTTON_LABEL, + normalizeText, + isVisible, + }) { + function applyDialogTheme(overlay, theme) { + const dialog = overlay?.querySelector(`#${IDS.dialog}`); + + if (!dialog) { + return; + } + + dialog.setAttribute("data-theme", theme); + } + + function ensureStyles() { + // Styles are loaded by manifest.json. This function remains as a stable callsite. + } + + 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 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); + } + + return Object.freeze({ + applyDialogTheme, + ensureStyles, + applyTheme, + findUserAnchor, + resolveInsertionTarget, + lockScroll, + unlockScroll, + }); + } + + globalThis.ArchPanelShellView = Object.freeze({ + create, + }); +})(); diff --git a/src/content/styles.css b/src/content/styles.css new file mode 100644 index 0000000..fc5c255 --- /dev/null +++ b/src/content/styles.css @@ -0,0 +1,2530 @@ +html.arch-panel-extension-modal-open, +body.arch-panel-extension-modal-open { + 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-hcm-tile { + cursor: pointer; +} + +#arch-panel-extension-hcm-tile:focus-visible { + outline: 2px solid #0b5cab; + outline-offset: 3px; +} + +.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.is-open { + 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); + --arch-workbench-header-fallback: #3f2d4f; + --arch-workbench-accent-fallback: #684b78; + --arch-shell-header-text: #ffffff; + --arch-shell-header-muted: rgba(255, 255, 255, 0.82); + --arch-shell-header-subtle: rgba(255, 255, 255, 0.68); + 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-workbench-header-fallback: #202a36; + --arch-workbench-accent-fallback: #39495b; +} + +.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: + linear-gradient( + 135deg, + var(--arch-workbench-header, var(--arch-workbench-header-fallback)) 0%, + var(--arch-workbench-accent, var(--arch-workbench-accent-fallback)) 100% + ); + border-bottom: 1px solid rgba(255, 255, 255, 0.16); + box-shadow: 0 8px 24px rgba(31, 31, 31, 0.16); + color: var(--arch-shell-header-text); +} + +.arch-panel-extension-brand { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.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.18); + color: var(--arch-shell-header-text); + font-size: 14px; + font-weight: 700; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16); +} + +.arch-panel-extension-brand-label { + margin: 0 0 2px; + color: var(--arch-shell-header-muted); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.arch-panel-extension-title { + margin: 0; + color: var(--arch-shell-header-text); + font-size: 24px; + line-height: 1.2; + font-weight: 600; + letter-spacing: -0.01em; +} + +.arch-panel-extension-shell-actions { + display: flex; + align-items: center; + gap: 12px; + flex: 0 0 auto; +} + +.arch-panel-extension-shell-note { + max-width: 36vw; + overflow: hidden; + color: var(--arch-shell-header-muted); + font-size: 12px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.arch-panel-extension-theme-toggle { + appearance: none; + min-height: 34px; + padding: 0 14px; + border: 1px solid rgba(255, 255, 255, 0.28); + border-radius: 4px; + background: rgba(255, 255, 255, 0.12); + color: var(--arch-shell-header-text); + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background-color 160ms ease, border-color 160ms ease, transform 160ms ease; +} + +.arch-panel-extension-shell-header .arch-panel-extension-theme-toggle:hover { + border-color: rgba(255, 255, 255, 0.44); + background: rgba(255, 255, 255, 0.2); +} + +.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.28); + background: rgba(255, 255, 255, 0.12); + color: var(--arch-shell-header-text); +} + +.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-utility-button.is-outline { + border-color: #0b5cab; + background: transparent; + color: #0b5cab; +} + +.arch-panel-extension-utility-button.is-outline:hover:not(:disabled) { + background: rgba(11, 92, 171, 0.08); +} + +.arch-panel-extension-utility-button.is-outline:disabled { + border-color: var(--wb-border); + background: transparent; + color: var(--wb-text-muted); + cursor: not-allowed; + opacity: 0.62; +} + +.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-current-user-card { + display: grid; + justify-items: start; + gap: 10px; +} + +.arch-panel-extension-current-user-avatar { + width: 48px; + height: 48px; + border: 1px solid var(--wb-border); + border-radius: 999px; + background: var(--wb-panel); + box-shadow: var(--wb-shadow); + color: var(--wb-text); + object-fit: cover; +} + +.arch-panel-extension-current-user-avatar.is-fallback { + display: inline-grid; + place-items: center; + background: var(--arch-workbench-header, var(--arch-workbench-header-fallback)); + color: #ffffff; + font-size: 15px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.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-view-toggle { + display: inline-flex; + align-items: center; + overflow: hidden; + border: 1px solid var(--wb-border); + border-radius: 4px; + background: var(--wb-panel-soft); +} + +.arch-panel-extension-calendar-view-button { + appearance: none; + min-height: 28px; + padding: 0 9px; + border: 0; + border-right: 1px solid var(--wb-border); + background: transparent; + color: var(--wb-text-secondary); + font: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.arch-panel-extension-calendar-view-button:last-child { + border-right: 0; +} + +.arch-panel-extension-calendar-view-button:hover { + background: var(--wb-row-hover); + color: var(--wb-text); +} + +.arch-panel-extension-calendar-view-button.is-active { + background: #0b5cab; + color: #ffffff; +} + +.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-grid.is-week { + min-height: 300px; +} + +.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.is-week-day { + display: flex; + flex-direction: column; + min-height: 300px; + padding: 0; + overflow: hidden; +} + +.arch-panel-extension-calendar-week-day-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 38px; + padding: 8px 9px; + border-bottom: 1px solid var(--wb-border); + background: color-mix(in srgb, var(--wb-panel) 72%, var(--wb-panel-soft)); +} + +.arch-panel-extension-calendar-day.is-week-day .arch-panel-extension-calendar-week-number { + position: static; + font-size: 9px; + opacity: 0.76; +} + +.arch-panel-extension-calendar-week-workloads { + display: flex; + flex: 1; + flex-direction: column; + gap: 7px; + padding: 8px; + overflow: auto; +} + +.arch-panel-extension-calendar-week-workload { + display: block; + border-left: 3px solid #d94f2b; + border-radius: 5px; + background: rgba(217, 79, 43, 0.14); + padding: 7px 8px; + color: var(--wb-text); + text-align: left; +} + +.arch-panel-extension-calendar-week-workload-title, +.arch-panel-extension-calendar-week-workload-meta { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +.arch-panel-extension-calendar-week-workload-title { + font-size: 11px; + font-weight: 700; + line-height: 1.25; +} + +.arch-panel-extension-calendar-week-workload-meta { + margin-top: 3px; + color: var(--wb-text-secondary); + font-size: 10px; + line-height: 1.2; + white-space: nowrap; +} + +.arch-panel-extension-calendar-week-empty { + margin: auto; + color: var(--wb-text-muted); + font-size: 11px; + font-weight: 600; +} + +.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-time-management-dialog { + width: min(1760px, calc(100vw - 20px)); + max-height: calc(100vh - 88px); + display: flex; + flex-direction: column; + gap: 14px; + overflow: visible; + border: 1px solid var(--wb-border); + border-radius: 10px; + background: var(--wb-panel); + box-shadow: var(--wb-shadow); + padding: 18px; +} + +.arch-panel-extension-time-management-toolbar, +.arch-panel-extension-time-management-card { + border: 1px solid var(--wb-border); + border-radius: 8px; + background: var(--wb-panel-soft); + padding: 14px; +} + +.arch-panel-extension-time-management-card { + display: grid; + --arch-time-management-scrollbar-gutter: 16px; + gap: 4px; + min-height: 0; + overflow: visible; +} + +.arch-panel-extension-time-management-card.is-error { + border-color: rgba(217, 79, 43, 0.42); +} + +.arch-panel-extension-time-management-toolbar { + display: flex; + align-items: end; + gap: 14px; + flex-wrap: wrap; +} + +.arch-panel-extension-time-management-hour-indicators { + display: grid; + grid-template-columns: repeat(4, minmax(92px, 1fr)); + gap: 8px; + flex: 1 1 520px; +} + +.arch-panel-extension-time-management-hour-indicator { + display: grid; + gap: 3px; + min-height: 48px; + align-content: center; + border: 1px solid var(--wb-border); + border-radius: 8px; + background: var(--wb-panel); + padding: 7px 10px; +} + +.arch-panel-extension-time-management-hour-indicator span { + color: var(--wb-text-muted); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.arch-panel-extension-time-management-hour-indicator strong { + color: var(--wb-text); + font-size: 16px; + font-weight: 800; + line-height: 1.1; +} + +.arch-panel-extension-time-management-hour-indicator .arch-panel-extension-skeleton-text { + width: 72%; +} + +.arch-panel-extension-time-management-hour-indicator .arch-panel-extension-skeleton-title { + width: 42%; + height: 16px; +} + +.arch-panel-extension-date-picker { + position: relative; + display: inline-flex; + align-items: center; + gap: 5px; + width: auto; +} + +.arch-panel-extension-date-input-shell { + position: relative; + display: inline-flex; + align-items: center; +} + +.arch-panel-extension-date-picker-button { + position: absolute; + top: 50%; + right: 11px; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--wb-text-muted); + cursor: pointer; + font-size: 0; + line-height: 1; + padding: 0; + transform: translateY(-50%); +} + +.arch-panel-extension-date-step-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 32px; + border: 1px solid var(--wb-border); + border-radius: 7px; + background: var(--wb-panel); + color: var(--wb-text-muted); + cursor: pointer; + padding: 0; +} + +.arch-panel-extension-date-step-button svg { + width: 16px; + height: 16px; + fill: currentColor; + pointer-events: none; +} + +.arch-panel-extension-date-step-button:hover { + border-color: var(--wb-border-strong); + background: var(--wb-row-hover); + color: var(--wb-text); +} + +.arch-panel-extension-date-picker-button svg { + width: 16px; + height: 16px; + fill: currentColor; + pointer-events: none; +} + +.arch-panel-extension-date-picker-button:hover { + background: var(--wb-row-hover); + color: var(--wb-text); +} + +.arch-panel-extension-date-picker-icon { + display: none; + position: absolute; + left: 11px; + z-index: 1; + color: var(--wb-text-muted); + font-size: 14px; + pointer-events: none; +} + +.arch-panel-extension-date-picker input { + width: 118px; + min-height: 32px; + padding-left: 12px; + padding-right: 38px; +} + +.arch-panel-extension-form-field .arch-panel-extension-date-picker input { + min-height: 32px; + padding: 0 38px 0 12px; +} + +.arch-panel-extension-date-picker input[readonly] { + cursor: default; +} + +.arch-panel-extension-date-picker-popover { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 6; + width: 292px; + border: 1px solid var(--wb-border); + border-radius: 10px; + background: var(--wb-panel); + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.22); + padding: 12px; +} + +.arch-panel-extension-date-picker-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.arch-panel-extension-date-picker-head strong { + color: var(--wb-text); + font-size: 13px; +} + +.arch-panel-extension-date-picker-weekdays, +.arch-panel-extension-date-picker-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 4px; +} + +.arch-panel-extension-date-picker-weekdays { + margin-bottom: 5px; + color: var(--wb-text-muted); + font-size: 10px; + font-weight: 800; + text-align: center; +} + +.arch-panel-extension-date-picker-day { + appearance: none; + min-width: 0; + height: 32px; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + color: var(--wb-text); + font: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.arch-panel-extension-date-picker-day:hover { + border-color: var(--wb-border-strong); + background: var(--wb-row-hover); +} + +.arch-panel-extension-date-picker-day.is-muted { + color: var(--wb-text-muted); + opacity: 0.55; +} + +.arch-panel-extension-date-picker-day.is-today { + border-color: #0b5cab; +} + +.arch-panel-extension-date-picker-day.is-selected { + border-color: #0b5cab; + background: #0b5cab; + color: #ffffff; +} + +.arch-panel-extension-time-management-select-grid { + display: grid; + grid-template-columns: minmax(240px, 1.15fr) minmax(220px, 1fr) minmax(180px, 0.8fr); + gap: 12px; +} + +.arch-panel-extension-time-management-rows { + display: grid; + gap: 10px; + max-height: min(52vh, 520px); + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding: 4px 0; + scrollbar-gutter: stable; +} + +.arch-panel-extension-time-management-rows:has(.arch-panel-extension-combobox:focus-within) { + overflow: visible; +} + +.arch-panel-extension-time-management-empty { + grid-column: 1 / -1; + text-align: center; +} + +.arch-panel-extension-time-management-header-row { + display: grid; + grid-template-columns: + minmax(420px, 3.8fr) + minmax(150px, 0.95fr) + minmax(138px, 0.82fr) + repeat(7, minmax(52px, 0.28fr)) + 34px; + align-items: center; + gap: 6px; + padding-right: var(--arch-time-management-scrollbar-gutter); +} + +.arch-panel-extension-time-management-column-label { + color: var(--wb-text-secondary); + font-size: 12px; + font-weight: 600; + line-height: 1.2; +} + +.arch-panel-extension-time-management-entry-row { + display: grid; + grid-template-columns: + minmax(420px, 3.8fr) + minmax(150px, 0.95fr) + minmax(138px, 0.82fr) + repeat(7, minmax(52px, 0.28fr)) + 34px; + align-items: start; + gap: 6px; +} + +.arch-panel-extension-time-management-entry-row.is-compact { + align-items: center; +} + +.arch-panel-extension-time-management-entry-row.is-duplicate-highlight { + border-radius: 10px; + animation: arch-panel-extension-duplicate-pulse 1100ms ease-in-out 2; + box-shadow: + inset 0 0 0 3px rgba(217, 79, 43, 0.9), + 0 0 0 2px rgba(217, 79, 43, 0.12); + padding: 4px; + margin: -4px 0; +} + +.arch-panel-extension-time-management-entry-row.is-compact .arch-panel-extension-form-field { + gap: 0; +} + +@keyframes arch-panel-extension-duplicate-pulse { + 0% { + box-shadow: + inset 0 0 0 2px rgba(217, 79, 43, 0.35), + 0 0 0 0 rgba(217, 79, 43, 0.22); + } + 45% { + box-shadow: + inset 0 0 0 3px rgba(217, 79, 43, 0.95), + 0 0 0 6px rgba(217, 79, 43, 0.14); + } + 100% { + box-shadow: + inset 0 0 0 3px rgba(217, 79, 43, 0.72), + 0 0 0 0 rgba(217, 79, 43, 0); + } +} + +.arch-panel-extension-combobox { + position: relative; + display: block; +} + +.arch-panel-extension-combobox input { + min-height: 40px; + padding-left: 12px; + padding-right: 38px; + border-radius: 8px; + background: color-mix(in srgb, var(--wb-panel-soft) 86%, var(--wb-panel)); + color: transparent; + transition: + border-color 140ms ease, + box-shadow 140ms ease, + background 140ms ease; +} + +.arch-panel-extension-combobox input::placeholder { + color: transparent; +} + +.arch-panel-extension-combobox:focus-within input::placeholder { + color: var(--wb-text-muted); +} + +.arch-panel-extension-combobox-value { + position: absolute; + top: 50%; + right: 38px; + left: 12px; + z-index: 1; + overflow: hidden; + color: var(--wb-text); + font-size: 12px; + font-weight: 700; + line-height: 1.25; + pointer-events: none; + text-overflow: ellipsis; + transform: translateY(-50%); + white-space: nowrap; +} + +.arch-panel-extension-combobox:focus-within .arch-panel-extension-combobox-value { + display: none; +} + +.arch-panel-extension-combobox.is-disabled { + opacity: 0.72; +} + +.arch-panel-extension-combobox.is-disabled .arch-panel-extension-combobox-value, +.arch-panel-extension-combobox.is-disabled .arch-panel-extension-combobox-arrow { + color: var(--wb-text-muted); +} + +.arch-panel-extension-combobox.is-disabled input { + cursor: not-allowed; +} + +.arch-panel-extension-combobox.is-disabled:focus-within .arch-panel-extension-combobox-menu { + display: none; +} + +.arch-panel-extension-combobox input:focus { + border-color: #0b5cab; + background: var(--wb-panel); + box-shadow: 0 0 0 3px rgba(11, 92, 171, 0.16); + color: var(--wb-text); + outline: none; +} + +.arch-panel-extension-combobox-arrow { + position: absolute; + top: 50%; + right: 12px; + z-index: 1; + display: inline-flex; + width: 16px; + height: 16px; + color: var(--wb-text-muted); + pointer-events: none; + transform: translateY(-50%); +} + +.arch-panel-extension-combobox-arrow svg { + width: 100%; + height: 100%; + fill: currentColor; +} + +.arch-panel-extension-combobox-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: 10000; + display: none; + max-height: 260px; + overflow: auto; + border: 1px solid var(--wb-border-strong); + border-radius: 10px; + background: var(--wb-panel); + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.22); + padding: 6px; +} + +.arch-panel-extension-combobox:focus-within .arch-panel-extension-combobox-menu { + display: grid; + gap: 4px; +} + +.arch-panel-extension-combobox-option { + appearance: none; + display: grid; + gap: 2px; + position: relative; + width: 100%; + min-height: 38px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--wb-text); + cursor: pointer; + font: inherit; + line-height: 1.25; + padding: 8px 10px; + text-align: left; +} + +.arch-panel-extension-combobox-option:hover, +.arch-panel-extension-combobox-option:focus, +.arch-panel-extension-combobox-option.is-active { + outline: none; + background: var(--wb-row-hover); +} + +.arch-panel-extension-combobox-option.is-active { + box-shadow: inset 3px 0 0 #0b5cab; +} + +.arch-panel-extension-combobox-option.is-selected { + background: rgba(11, 92, 171, 0.12); + color: var(--wb-text); + padding-right: 32px; +} + +.arch-panel-extension-combobox-option.is-selected::after { + content: "✓"; + position: absolute; + top: 50%; + right: 11px; + color: #0b5cab; + font-size: 14px; + font-weight: 800; + transform: translateY(-50%); +} + +.arch-panel-extension-dialog[data-theme="dark"] .arch-panel-extension-combobox-option.is-selected::after { + color: #9ed4f0; +} + +.arch-panel-extension-combobox-option-main { + color: var(--wb-text); + font-size: 12px; + font-weight: 700; +} + +.arch-panel-extension-combobox-option-meta { + color: var(--wb-text-muted); + font-size: 11px; + font-weight: 600; +} + +.arch-panel-extension-combobox-option[hidden] { + display: none; +} + +.arch-panel-extension-combobox-empty { + color: var(--wb-text-muted); + font-size: 12px; + padding: 8px 9px; +} + +.arch-panel-extension-time-management-days { + display: grid; + grid-template-columns: repeat(7, minmax(110px, 1fr)); + gap: 8px; +} + +.arch-panel-extension-time-management-day { + display: grid; + gap: 6px; + padding: 7px; + border: 1px solid var(--wb-border); + border-radius: 8px; + background: var(--wb-panel); +} + +.arch-panel-extension-time-management-day-head { + display: grid; + align-content: center; + justify-items: center; + gap: 1px; + min-height: 22px; + padding: 0; + color: var(--wb-text-secondary); + line-height: 1.15; + text-align: center; +} + +.arch-panel-extension-time-management-day-head strong { + color: var(--wb-text-secondary); + font-size: 12px; + font-weight: 600; +} + +.arch-panel-extension-time-management-day-head span { + color: var(--wb-text-muted); + font-size: 11px; + font-weight: 600; + text-transform: none; +} + +.arch-panel-extension-time-management-day input { + width: 100%; + min-height: 30px; + border: 1px solid var(--wb-border); + border-radius: 6px; + background: var(--wb-panel-soft); + color: var(--wb-text); + font: inherit; + font-size: 12px; + padding: 0 6px; + text-align: right; +} + +.arch-panel-extension-time-management-delete { + appearance: none; + align-self: center; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--wb-border); + border-radius: 7px; + background: var(--wb-panel); + color: var(--wb-text-muted); + cursor: pointer; + padding: 0; +} + +.arch-panel-extension-time-management-delete:hover, +.arch-panel-extension-time-management-delete:focus-visible { + border-color: rgba(217, 79, 43, 0.52); + background: rgba(217, 79, 43, 0.12); + color: #d94f2b; + outline: none; +} + +.arch-panel-extension-time-management-delete svg { + width: 16px; + height: 16px; + fill: currentColor; + pointer-events: none; +} + +.arch-panel-extension-time-management-request { + border-top: 1px solid var(--wb-border); + padding-top: 10px; +} + +.arch-panel-extension-time-management-request summary { + color: var(--wb-link); + cursor: pointer; + font-size: 12px; + font-weight: 700; +} + +.arch-panel-extension-time-management-request p { + margin: 10px 0 4px; + color: var(--wb-text-secondary); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.arch-panel-extension-time-management-request pre { + max-height: 220px; + margin: 8px 0 0; + overflow: auto; + border: 1px solid var(--wb-border); + border-radius: 7px; + background: var(--wb-panel); + color: var(--wb-text-secondary); + font-size: 11px; + line-height: 1.45; + padding: 10px; + white-space: pre-wrap; + word-break: break-all; +} + +.arch-panel-extension-skeleton { + display: block; + overflow: hidden; + border-radius: 6px; + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--wb-border) 70%, transparent) 0%, + color-mix(in srgb, var(--wb-panel) 70%, #ffffff) 48%, + color-mix(in srgb, var(--wb-border) 70%, transparent) 100% + ); + background-size: 220% 100%; + animation: arch-panel-extension-skeleton 1.15s ease-in-out infinite; +} + +.arch-panel-extension-skeleton-control { + height: 38px; +} + +.arch-panel-extension-skeleton-title { + width: 62%; + height: 14px; +} + +.arch-panel-extension-skeleton-text { + width: 46%; + height: 11px; +} + +.arch-panel-extension-skeleton-input { + height: 34px; +} + +@keyframes arch-panel-extension-skeleton { + from { + background-position: 120% 0; + } + to { + background-position: -120% 0; + } +} + +.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; + align-items: center; + 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-time-management-footer-actions { + display: flex; + align-items: center; + gap: 8px; + margin-right: auto; +} + +.arch-panel-extension-time-management-save-error { + margin: 0 auto 0 0; + color: var(--wb-warning); + font-size: 12px; + font-weight: 700; +} + +.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-field .arch-panel-extension-combobox input { + min-height: 40px; + padding: 0 38px 0 12px; + border-radius: 8px; + background: color-mix(in srgb, var(--wb-panel-soft) 86%, var(--wb-panel)); + color: transparent; +} + +.arch-panel-extension-form-field .arch-panel-extension-combobox input:focus { + border-color: #0b5cab; + background: var(--wb-panel); + box-shadow: 0 0 0 3px rgba(11, 92, 171, 0.16); + color: var(--wb-text); + outline: none; +} + +.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; + } +} diff --git a/src/content/templates.js b/src/content/templates.js new file mode 100644 index 0000000..2028f3f --- /dev/null +++ b/src/content/templates.js @@ -0,0 +1,15 @@ +(() => { + function hcmTile(hcmTileId) { + return ` +
+ +
`; + } + + globalThis.ArchPanelTemplates = Object.freeze({ + hcmTile, + }); +})(); diff --git a/src/content/workbench-controller.js b/src/content/workbench-controller.js new file mode 100644 index 0000000..c938d46 --- /dev/null +++ b/src/content/workbench-controller.js @@ -0,0 +1,966 @@ +(() => { + function create({ + appState, + IDS, + DETAIL_SORT_KEYS, + hcmPage, + workbenchRepository, + comcipRepository, + readCachedDataset, + writeCachedDataset, + readCachedWorklistToken, + setStoredCalendarView, + refreshTaskTypeCache, + renderModal, + cleanString, + normalizeString, + toNumber, + toIsoDateString, + mapWithConcurrency, + getLocalDateKey, + getErrorMessage, + formatWholeNumber, + formatProgressPercent, + formatDate, + teamContainsUser, + stripTransientFields, + createExportPayload, + createDatasetSnapshot, + buildRampForecastComparisonRows, + parseForecastMonthNumber, + createForecastUpdatePayload, + normalizeTimeEntriesSummary, + getCalendarAnchorDate, + }) { + let cacheHydrationPromise = null; + + 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 current resource user", 8); + + const resourceCurrentUser = await comcipRepository + .fetchResourceCurrentUser(user.userEmail) + .catch(() => null); + + updateLoadingProgress("Loading customer summary pages", 12); + + const customers = await workbenchRepository.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 workbenchRepository.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([ + workbenchRepository.fetchWorkloadActions(workload.workloadId), + workbenchRepository.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 comcipRepository + .fetchPendingServiceRequests() + .catch(() => []); + updateLoadingProgress("Refreshing time management task types", 94); + await refreshTaskTypeCache().catch(() => null); + + updateLoadingProgress("Preparing dashboard cache", 96); + const finalCustomers = Array.from(customersMap.values()); + const exportPayload = createExportPayload( + user, + finalCustomers, + pendingServiceRequests, + resourceCurrentUser + ); + 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 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 currentFrameToken = hcmPage.syncWorklistIframeToken({ + force: true, + })?.token; + + const worklistToken = currentFrameToken || (await readCachedWorklistToken()); + + if (!worklistToken) { + throw new Error( + "Unable to resolve Worklist token. Reload the FuseWelcome page so the Worklist iframe token can be captured before refreshing data." + ); + } + + appState.sessionAuthHeaders = {}; + + const user = await workbenchRepository.fetchWorklistCurrentUser( + worklistToken + ); + + appState.sessionAuthHeaders = {}; + + return user; + } + + 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 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.dataset?.user?.userEmail || "" + ); + + 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); + await workbenchRepository.updateWorkloadForecast( + workloadId, + 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(); + } + } + + 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 comcipRepository.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(); + } + + function shiftCalendarPeriod(delta) { + const nextDate = + appState.calendarView === "week" + ? getShiftedCalendarWeekDate(delta) + : new Date(appState.calendarYear, appState.calendarMonth + delta, 1); + + appState.calendarYear = nextDate.getFullYear(); + appState.calendarMonth = nextDate.getMonth(); + appState.calendarAnchorDateKey = getLocalDateKey(nextDate); + renderModal(); + } + + function goToCurrentCalendarPeriod() { + const today = new Date(); + appState.calendarYear = today.getFullYear(); + appState.calendarMonth = today.getMonth(); + appState.calendarAnchorDateKey = getLocalDateKey(today); + renderModal(); + } + + function setCalendarView(view) { + const normalizedView = view === "week" ? "week" : "month"; + + if (appState.calendarView === normalizedView) { + return; + } + + appState.calendarView = normalizedView; + setStoredCalendarView(normalizedView); + + if (normalizedView === "week") { + const today = new Date(); + const anchorDate = + today.getFullYear() === appState.calendarYear && + today.getMonth() === appState.calendarMonth + ? today + : new Date(appState.calendarYear, appState.calendarMonth, 1); + + appState.calendarAnchorDateKey = getLocalDateKey(anchorDate); + appState.calendarYear = anchorDate.getFullYear(); + appState.calendarMonth = anchorDate.getMonth(); + } else { + const anchorDate = getCalendarAnchorDate( + appState.calendarYear, + appState.calendarMonth, + appState.calendarAnchorDateKey + ); + + appState.calendarYear = anchorDate.getFullYear(); + appState.calendarMonth = anchorDate.getMonth(); + } + + renderModal(); + } + + function shiftCalendarMonth(delta) { + shiftCalendarPeriod(delta); + } + + function goToCurrentCalendarMonth() { + goToCurrentCalendarPeriod(); + } + + function getShiftedCalendarWeekDate(delta) { + const anchorDate = getCalendarAnchorDate( + appState.calendarYear, + appState.calendarMonth, + appState.calendarAnchorDateKey + ); + + anchorDate.setDate(anchorDate.getDate() + delta * 7); + return anchorDate; + } + + 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 workbenchRepository.createWorkloadAction(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 || [], + appState.dataset.exportPayload?.resourceCurrentUser || null + ); + 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", + }); + } + + return Object.freeze({ + refreshData, + hydrateDatasetFromCache, + findWorkloadById, + openDetailModal, + openActionFormModal, + openRampComparisonModal, + openForecastUpdateConfirmModal, + submitForecastUpdate, + openTimeEntriesDrawer, + shiftCalendarPeriod, + goToCurrentCalendarPeriod, + setCalendarView, + shiftCalendarMonth, + goToCurrentCalendarMonth, + submitActionForm, + toggleDetailSort, + getSortedDetailItems, + }); + } + + globalThis.ArchPanelWorkbenchController = Object.freeze({ + create, + }); +})(); diff --git a/src/domain/ramp-forecast.js b/src/domain/ramp-forecast.js new file mode 100644 index 0000000..3563b20 --- /dev/null +++ b/src/domain/ramp-forecast.js @@ -0,0 +1,216 @@ +(() => { + function create(dependencies) { + const { + toNumber, + normalizeString, + parseDatePreservingDateOnly, + formatWeekNumber, + } = dependencies; + + 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 createForecastUpdatePayload(rows, 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, + })); + } + + return { + buildRampForecastComparisonRows, + hasRampForecastMismatch, + calculateRampForecastAmount, + getStartMonthProrationFactor, + isNonZeroAmount, + parseForecastMonthNumber, + formatForecastPeriodLabel, + createForecastUpdatePayload, + }; + } + + globalThis.ArchPanelRampForecastDomain = Object.freeze({ + create, + }); +})(); diff --git a/src/domain/time-management-data.js b/src/domain/time-management-data.js new file mode 100644 index 0000000..54b4ce8 --- /dev/null +++ b/src/domain/time-management-data.js @@ -0,0 +1,832 @@ +(() => { + function create(dependencies) { + const { + cleanString, + normalizeString, + firstNonEmptyString, + extractArray, + getFieldValue, + flattenDisplayRecord, + toNumber, + parseTimeEntryDate, + getLocalDateKey, + getTimeManagementDayKey, + getTimeManagementWeekdayName, + createTimeManagementRowSignature, + } = dependencies; + + function normalizeInfoWeekResponse(payload) { + return extractArray(payload, ["items", "data", "results", "content"]) + .map((item) => { + const rawDate = cleanString(getFieldValue(item, ["Date", "date"])); + const parsedDate = parseTimeEntryDate(rawDate); + + return { + date: rawDate, + weekId: cleanString(getFieldValue(item, ["WeekId", "weekId"])), + dayLabel: Number.isNaN(parsedDate.getTime()) + ? rawDate + : new Intl.DateTimeFormat("en-US", { weekday: "short" }).format( + parsedDate + ), + monthLabel: Number.isNaN(parsedDate.getTime()) + ? rawDate + : new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + }).format(parsedDate), + }; + }) + .filter((item) => item.date || item.weekId); + } + + function normalizeTimeManagementServiceRequests(payload) { + return extractArray(payload, ["items", "data", "results", "content"]) + .map((item) => { + const srNumber = cleanString( + getFieldValue(item, [ + "srNumber", + "SrNumber", + "SRNumber", + "serviceRequestNumber", + "ServiceRequestNumber", + "serviceRequest", + "ServiceRequest", + ]) + ); + const srTitle = cleanString( + getFieldValue(item, ["srTitle", "SrTitle", "SRTitle", "title", "Title"]) + ); + const fallbackTitle = cleanString( + getFieldValue(item, ["problemDescription", "ProblemDescription"]) + ); + + return { + srNumber, + srTitle, + label: formatTimeManagementServiceRequestLabel({ + srNumber, + srTitle: srTitle || fallbackTitle, + }), + }; + }) + .filter((item) => item.srNumber); + } + + function formatTimeManagementServiceRequestLabel(serviceRequest) { + const srNumber = cleanString(serviceRequest?.srNumber); + const srTitle = cleanString(serviceRequest?.srTitle || serviceRequest?.label); + + if (srTitle && srNumber) { + return `${srTitle} (${srNumber})`; + } + + return srTitle || srNumber; + } + + function mergeTimeManagementServiceRequests(serviceRequests, timeEntryRows) { + const merged = [...(serviceRequests || [])]; + const seen = new Set(merged.map((item) => normalizeString(item.srNumber))); + + for (const row of timeEntryRows || []) { + const srNumber = cleanString(row?.selectedSrNumber); + const key = normalizeString(srNumber); + + if (!srNumber || seen.has(key)) { + continue; + } + + merged.push({ + srNumber, + srTitle: srNumber === "non-service-sr" ? "Non-service request" : srNumber, + label: formatTimeManagementServiceRequestLabel({ + srNumber, + srTitle: srNumber === "non-service-sr" ? "Non-service request" : srNumber, + }), + }); + seen.add(key); + } + + return merged; + } + + function normalizeTimeManagementEntryRows(payload, weekDays) { + return extractArray(payload, ["items", "data", "results", "content"]).map( + (item, index) => { + const timeEntryId = getTimeEntryId(item); + const srNumber = getTimeEntrySrNumber(item) || "non-service-sr"; + const activityValue = getTimeEntryActivity(item); + const taskTypeValue = getTimeEntryTaskType(item); + + return { + id: `entry-${index + 1}`, + timeEntryId, + selectedSrNumber: srNumber, + selectedActivityValue: activityValue, + selectedTaskTypeValue: taskTypeValue, + dayHours: getTimeEntryWeekDayHours(item, weekDays), + source: "timeEntryWeek", + originalSignature: "", + }; + } + ); + } + + function getTimeEntryWeekDayHours(item, weekDays = []) { + const dayHours = {}; + + for (const day of weekDays || []) { + const dateKey = getTimeManagementDayKey(day); + const weekdayName = getTimeManagementWeekdayName(day); + const value = getTimeEntryWeekdayTaskHours(item, weekdayName); + + if (dateKey) { + dayHours[dateKey] = formatTimeManagementHourValue(value); + } + } + + return dayHours; + } + + function getTimeEntryWeekdayTaskHours(item, weekdayName) { + if (!weekdayName) { + return ""; + } + + const titleCaseWeekday = + weekdayName.charAt(0).toUpperCase() + weekdayName.slice(1); + const compactKey = `${weekdayName}TaskHours`; + const titleKey = `${titleCaseWeekday}TaskHours`; + const snakeKey = `${weekdayName}_task_hours`; + + return getTimeEntryField(item, [ + compactKey, + titleKey, + snakeKey, + `${weekdayName}.taskHours`, + `${titleCaseWeekday}.TaskHours`, + ]); + } + + function formatTimeManagementHourValue(value) { + if (value === undefined || value === null || value === "") { + return ""; + } + + return cleanString(value); + } + + function normalizeTimeManagementEntryRowSelection( + row, + index, + activityOptionsBySr, + taskTypes + ) { + const srNumber = cleanString(row?.selectedSrNumber); + const activitySrKey = Object.keys(activityOptionsBySr || {}).find( + (key) => normalizeString(key) === normalizeString(srNumber) + ); + const activityOptions = + activityOptionsBySr?.[srNumber] || activityOptionsBySr?.[activitySrKey] || []; + const taskTypeOptions = filterTaskTypesForServiceRequest( + taskTypes || [], + srNumber + ); + const selectedActivityValue = + findOptionValue(activityOptions, row?.selectedActivityValue) || + cleanString(row?.selectedActivityValue) || + ""; + const selectedTaskTypeValue = + findOptionValue(taskTypeOptions, row?.selectedTaskTypeValue) || + cleanString(row?.selectedTaskTypeValue) || + ""; + + return { + ...row, + id: cleanString(row?.id) || `entry-${index + 1}`, + timeEntryId: cleanString(row?.timeEntryId), + selectedSrNumber: srNumber, + selectedActivityValue, + selectedTaskTypeValue, + dayHours: row?.dayHours || {}, + originalSignature: + cleanString(row?.originalSignature) || + (cleanString(row?.source) === "timeEntryWeek" + ? createTimeManagementRowSignature({ + ...row, + selectedSrNumber: srNumber, + selectedActivityValue, + selectedTaskTypeValue, + dayHours: row?.dayHours || {}, + }) + : ""), + }; + } + + function getActivityRequestSrNumbers(srPayload, serviceRequests) { + const normalizedSrNumbers = [ + ...(serviceRequests || []).map((item) => item.srNumber), + ...collectSrNumbers(srPayload), + ]; + const seen = new Set(); + + return normalizedSrNumbers + .map((srNumber) => cleanString(srNumber).toUpperCase()) + .filter((srNumber) => /^SR\d+$/i.test(srNumber)) + .filter((srNumber) => { + const key = normalizeString(srNumber); + + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); + } + + function collectSrNumbers(value, seen = new WeakSet()) { + if (!value) { + return []; + } + + if (typeof value === "string" || typeof value === "number") { + return Array.from(String(value).matchAll(/\bSR\d+\b/gi), (match) => match[0]); + } + + if (typeof value !== "object") { + return []; + } + + if (seen.has(value)) { + return []; + } + + seen.add(value); + + if (Array.isArray(value)) { + return value.flatMap((item) => collectSrNumbers(item, seen)); + } + + return Object.values(value).flatMap((nestedValue) => + collectSrNumbers(nestedValue, seen) + ); + } + + function normalizeActivityRequest(payload, serviceRequests) { + const activityOptionsBySr = {}; + const activityItemsBySr = new Map(); + + for (const item of extractArray(payload, ["items", "data", "results", "content"])) { + const srNumber = cleanString(getFieldValue(item, ["SrNumber", "srNumber"])); + const srKey = normalizeString(srNumber); + + if (srKey && !activityItemsBySr.has(srKey)) { + activityItemsBySr.set(srKey, item); + } + } + + for (const serviceRequest of serviceRequests || []) { + const srNumber = cleanString(serviceRequest?.srNumber); + const srKey = normalizeString(srNumber); + const activityItem = activityItemsBySr.get(srKey); + const options = activityItem ? normalizeSrActivityFieldOptions(activityItem) : []; + + if (srNumber) { + activityOptionsBySr[srNumber] = options; + } + } + + return activityOptionsBySr; + } + + function normalizeSrActivityFieldOptions(item) { + const activityText = firstNonEmptyString([ + getFieldValue(item, ["SRActivities_c", "srActivities_c"]), + getFieldValue(item, [ + "ServiceRequest_SRToInternalSR_Tgt.SRActivities_c", + "ServiceRequest_SRToInternalSR_Tgt.srActivities_c", + ]), + ]); + + if (activityText) { + return parseSemicolonActivityOptions(activityText); + } + + return normalizeActivityOptions(item); + } + + function normalizeActivityOptions(value) { + const recursiveActivityTextValues = collectActivityTextValues(value); + + if (recursiveActivityTextValues.length) { + return parseSemicolonActivityOptions(recursiveActivityTextValues.join(";")); + } + + const directActivityValue = + value && typeof value === "object" && !Array.isArray(value) + ? getFieldValue(value, ["SRActivities_c", "srActivities_c", "activities"]) + : ""; + const nestedActivityValue = + value && typeof value === "object" && !Array.isArray(value) + ? getFieldValue(value, [ + "ServiceRequest_SRToInternalSR_Tgt.SRActivities_c", + "ServiceRequest_SRToInternalSR_Tgt.srActivities_c", + ]) + : ""; + const nestedActivityObject = + value && typeof value === "object" && !Array.isArray(value) + ? getFieldValue(value, ["ServiceRequest_SRToInternalSR_Tgt"]) + : ""; + const explicitActivityText = firstNonEmptyString([ + directActivityValue && typeof directActivityValue !== "object" + ? directActivityValue + : "", + nestedActivityValue && typeof nestedActivityValue !== "object" + ? nestedActivityValue + : "", + ]); + + if (explicitActivityText) { + return parseSemicolonActivityOptions(explicitActivityText); + } + + const activityValue = + directActivityValue || + nestedActivityValue || + nestedActivityObject || + value; + const rawItems = Array.isArray(value) + ? value + : activityValue && typeof activityValue === "object" + ? extractArray(activityValue, [ + "items", + "data", + "results", + "content", + "activities", + "ServiceRequest_SRToInternalSR_Tgt", + ]) + : []; + const textValue = + activityValue && typeof activityValue === "object" + ? "" + : cleanString(activityValue); + const parsedTextItems = parseActivityTextOptions(textValue); + const items = rawItems.length ? rawItems : parsedTextItems; + const seen = new Set(); + + return items + .map((item) => { + const optionValue = + item && typeof item === "object" + ? firstNonEmptyString([ + item.activityCode, + item.ActivityCode, + item.code, + item.value, + item.name, + item.label, + ]) + : cleanString(item); + const optionLabel = + item && typeof item === "object" + ? firstNonEmptyString([ + item.activityName, + item.ActivityName, + item.meaning, + item.label, + item.name, + optionValue, + ]) + : optionValue; + const normalizedValue = cleanString(optionValue); + const normalizedLabel = cleanString(optionLabel); + + if (!normalizedValue || seen.has(normalizedValue)) { + return null; + } + + seen.add(normalizedValue); + return { + value: normalizedValue, + label: normalizedLabel || normalizedValue, + }; + }) + .filter(Boolean); + } + + function collectActivityTextValues(value, seen = new WeakSet()) { + if (!value || typeof value !== "object") { + return []; + } + + if (seen.has(value)) { + return []; + } + + seen.add(value); + + if (Array.isArray(value)) { + return value.flatMap((item) => collectActivityTextValues(item, seen)); + } + + const values = []; + const activityFieldNames = new Set([ + normalizeString("SRActivities_c"), + normalizeString("srActivities_c"), + normalizeString("activities"), + ]); + + for (const [key, nestedValue] of Object.entries(value)) { + if ( + activityFieldNames.has(normalizeString(key)) && + typeof nestedValue !== "object" + ) { + const text = cleanString(nestedValue); + + if (text) { + values.push(text); + } + } else if (nestedValue && typeof nestedValue === "object") { + values.push(...collectActivityTextValues(nestedValue, seen)); + } + } + + return values; + } + + function parseSemicolonActivityOptions(value) { + const seen = new Set(); + + return cleanString(value) + .split(";") + .map((item) => cleanString(item)) + .filter(Boolean) + .map((item) => { + const normalizedItem = normalizeString(item); + + if (seen.has(normalizedItem)) { + return null; + } + + seen.add(normalizedItem); + return { + value: item, + label: item, + }; + }) + .filter(Boolean); + } + + function parseActivityTextOptions(value) { + if (!value) { + return []; + } + + try { + const parsed = JSON.parse(value); + + if (Array.isArray(parsed)) { + return parsed; + } + + if (parsed && typeof parsed === "object") { + return extractArray(parsed, [ + "items", + "data", + "results", + "content", + "activities", + ]); + } + } catch { + // Continue with delimiter based parsing. + } + + return value + .split(/[;|\n]/) + .map((item) => item.trim()) + .filter(Boolean); + } + + function normalizeTaskTypeRequest(payload) { + return extractArray(payload, ["items", "data", "results", "content"]) + .map((item) => { + const value = cleanString( + getFieldValue(item, ["lookUpCode_c", "LookUpCode_c", "lookupCode"]) + ); + const label = cleanString( + getFieldValue(item, [ + "lookUpMeaning_c", + "LookUpMeaning_c", + "lookupMeaning", + "meaning", + ]) + ); + const tags = cleanString( + getFieldValue(item, ["tags_c", "Tags_c", "TAGS_C", "tags", "Tags"]) + ); + + return { + value, + label: label || value, + tags, + }; + }) + .filter((item) => item.value); + } + + function getTimeManagementTaskTypeOptions(modal, srNumber) { + const options = filterTaskTypesForServiceRequest(modal.taskTypes || [], srNumber); + + return options.length + ? options + : [{ value: "", label: "No task types available", tags: "" }]; + } + + function filterTaskTypesForServiceRequest(taskTypes, srNumber) { + const expectedTag = isServiceRequestSrNumber(srNumber) + ? "SERVICE" + : "NON_SERVICE"; + return (taskTypes || []).filter((item) => { + const tags = normalizeString(item?.tags); + + return tags === normalizeString(expectedTag); + }); + } + + function isServiceRequestSrNumber(srNumber) { + return /^SR\d+$/i.test(cleanString(srNumber)); + } + + function getTimeManagementActivityOptions(modal, srNumber) { + const normalizedSrNumber = normalizeString(srNumber); + const matchedSrKey = Object.keys(modal.activityOptionsBySr || {}).find( + (key) => normalizeString(key) === normalizedSrNumber + ); + const options = + modal.activityOptionsBySr?.[srNumber] || + modal.activityOptionsBySr?.[matchedSrKey] || + []; + + return options.length + ? options + : [{ value: "", label: "No activities available" }]; + } + + function findTimeManagementServiceRequest(modal, inputValue) { + const normalizedInput = normalizeString(inputValue); + + return (modal.serviceRequests || []).find((item) => { + return ( + normalizeString(item.srNumber) === normalizedInput || + normalizeString(item.label) === normalizedInput || + normalizeString(item.srTitle) === normalizedInput + ); + }); + } + + function findTimeManagementServiceRequestByNumber(modal, srNumber) { + const normalizedSrNumber = normalizeString(srNumber); + + return (modal.serviceRequests || []).find( + (item) => normalizeString(item.srNumber) === normalizedSrNumber + ); + } + + function findOptionValue(options, inputValue) { + const normalizedInput = normalizeString(inputValue); + const option = (options || []).find((item) => { + return ( + normalizeString(item.value) === normalizedInput || + normalizeString(item.label) === normalizedInput + ); + }); + + return option?.value || ""; + } + + function findOptionLabel(options, value) { + const normalizedValue = normalizeString(value); + const option = (options || []).find( + (item) => normalizeString(item.value) === normalizedValue + ); + + return option?.label || ""; + } + + 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 getTimeEntrySrNumber(item) { + return cleanString( + getTimeEntryField(item, [ + "srNumber", + "SrNumber", + "SRNumber", + "serviceRequestNumber", + "ServiceRequestNumber", + "serviceRequest.srNumber", + "ServiceRequest.SrNumber", + ]) + ); + } + + function getTimeEntryId(item) { + return cleanString( + getTimeEntryField(item, [ + "id", + "Id", + "ID", + "timeEntryId", + "TimeEntryId", + "timeEntryID", + "TimeEntryID", + ]) + ); + } + + 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 getTimeEntryDateKey(item, weekDays = []) { + const rawDate = cleanString( + getTimeEntryField(item, [ + "Date", + "date", + "dateValue", + "DateValue", + "timeEntryDate", + "TimeEntryDate", + "entryDate", + "EntryDate", + ]) + ); + + if (!rawDate) { + return weekDays.length === 1 ? getTimeManagementDayKey(weekDays[0]) : ""; + } + + const parsedDate = parseTimeEntryDate(rawDate); + + if (!Number.isNaN(parsedDate.getTime())) { + return getLocalDateKey(parsedDate); + } + + const matchingDay = (weekDays || []).find((day) => { + return normalizeString(day?.date) === normalizeString(rawDate); + }); + + return matchingDay ? getTimeManagementDayKey(matchingDay) : ""; + } + + 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] : ""; + } + + return { + normalizeInfoWeekResponse, + normalizeTimeManagementServiceRequests, + formatTimeManagementServiceRequestLabel, + mergeTimeManagementServiceRequests, + normalizeTimeManagementEntryRows, + normalizeTimeManagementEntryRowSelection, + getActivityRequestSrNumbers, + normalizeActivityRequest, + normalizeTaskTypeRequest, + getTimeManagementTaskTypeOptions, + getTimeManagementActivityOptions, + findTimeManagementServiceRequest, + findTimeManagementServiceRequestByNumber, + findOptionValue, + findOptionLabel, + normalizeTimeEntriesSummary, + groupTimeEntriesByResourceName, + getTimeEntryTimeSpent, + getTimeEntryActivity, + getTimeEntryTaskType, + }; + } + + globalThis.ArchPanelTimeManagementData = Object.freeze({ + create, + }); +})(); diff --git a/src/domain/time-management.js b/src/domain/time-management.js new file mode 100644 index 0000000..9c0ce42 --- /dev/null +++ b/src/domain/time-management.js @@ -0,0 +1,240 @@ +(() => { + function create(dependencies) { + const { + cleanString, + normalizeString, + toNumber, + parseTimeEntryDate, + getLocalDateKey, + } = dependencies; + + function getTimeManagementRows(modal) { + return modal?.timeEntryRows || []; + } + + function isTimeManagementSaveEnabled(modal) { + const pendingChanges = getTimeManagementPendingChanges(modal); + const changedRows = [ + ...pendingChanges.newRows, + ...pendingChanges.updatedRows, + ]; + + if ( + !pendingChanges.newRows.length && + !pendingChanges.updatedRows.length && + !pendingChanges.deletedIds.length + ) { + return false; + } + + return changedRows.every((row) => isTimeManagementRowReadyToSave(row)); + } + + function isTimeManagementRowReadyToSave(row) { + const selectedSrNumber = cleanString(row?.selectedSrNumber); + + if (!selectedSrNumber) { + return false; + } + + if (!hasPositiveTimeManagementHours(row)) { + return false; + } + + if (isNonServiceRequestSrNumber(selectedSrNumber)) { + return Boolean(cleanString(row?.selectedTaskTypeValue)); + } + + return Boolean( + cleanString(row?.selectedActivityValue) && + cleanString(row?.selectedTaskTypeValue) + ); + } + + function hasPositiveTimeManagementHours(row) { + return Object.values(row?.dayHours || {}).some((value) => { + const normalizedValue = cleanString(value); + + if (!normalizedValue) { + return false; + } + + const numericValue = Number(normalizedValue.replace(",", ".")); + + return Number.isFinite(numericValue) && numericValue > 0; + }); + } + + function getTimeManagementPendingChanges(modal) { + const rows = getTimeManagementRows(modal); + + return { + newRows: rows.filter((row) => isNewTimeManagementRow(row)), + updatedRows: rows.filter((row) => isUpdatedTimeManagementRow(row)), + deletedIds: (modal?.deletedTimeEntryIds || []) + .map((id) => cleanString(id)) + .filter(Boolean), + }; + } + + function isNewTimeManagementRow(row) { + return cleanString(row?.source) === "manual" && !cleanString(row?.timeEntryId); + } + + function isUpdatedTimeManagementRow(row) { + const timeEntryId = cleanString(row?.timeEntryId); + const originalSignature = cleanString(row?.originalSignature); + + return Boolean( + timeEntryId && + originalSignature && + originalSignature !== createTimeManagementRowSignature(row) + ); + } + + function createTimeManagementRowSignature(row) { + return JSON.stringify({ + srNumber: cleanString(row?.selectedSrNumber), + activityCode: cleanString(row?.selectedActivityValue), + taskType: cleanString(row?.selectedTaskTypeValue), + dayHours: normalizeTimeManagementDayHours(row?.dayHours || {}), + }); + } + + function normalizeTimeManagementDayHours(dayHours) { + return Object.keys(dayHours || {}) + .sort() + .reduce((normalized, key) => { + normalized[key] = formatTimeManagementHourPayloadValue(dayHours[key]); + return normalized; + }, {}); + } + + function createTimeManagementSavePayload( + row, + weekDays, + weekId, + resourceId, + options = {} + ) { + const selectedSrNumber = cleanString(row?.selectedSrNumber); + const payload = { + srNumber: selectedSrNumber, + activityCode: isNonServiceRequestSrNumber(selectedSrNumber) + ? "" + : cleanString(row?.selectedActivityValue), + taskType: cleanString(row?.selectedTaskTypeValue), + deliveryType: "", + saturdayTaskHours: 0, + sundayTaskHours: 0, + mondayTaskHours: 0, + tuesdayTaskHours: 0, + wednesdayTaskHours: 0, + thursdayTaskHours: 0, + fridayTaskHours: 0, + notes: "", + weekId: toNumber(weekId) || weekId, + resourceId: cleanString(resourceId), + tag: isNonServiceRequestSrNumber(selectedSrNumber) + ? "NON_SERVICE" + : "SERVICE", + }; + + if (options.includeId) { + const id = cleanString(row?.timeEntryId); + payload.id = toNumber(id) || id; + } + + for (const day of weekDays || []) { + const weekdayName = getTimeManagementWeekdayName(day); + const dateKey = getTimeManagementDayKey(day); + const payloadKey = `${weekdayName}TaskHours`; + + if (payloadKey in payload) { + payload[payloadKey] = formatTimeManagementHourPayloadValue( + row?.dayHours?.[dateKey] + ); + } + } + + return payload; + } + + function formatTimeManagementHourPayloadValue(value) { + const normalizedValue = cleanString(value); + + if (!normalizedValue) { + return 0; + } + + const numericValue = Number(normalizedValue.replace(",", ".")); + + return Number.isFinite(numericValue) ? numericValue : 0; + } + + function getTimeManagementDayKey(day) { + const parsedDate = parseTimeEntryDate(day?.date); + + if (!Number.isNaN(parsedDate.getTime())) { + return getLocalDateKey(parsedDate); + } + + return cleanString(day?.date); + } + + function getTimeManagementWeekdayName(day) { + const parsedDate = parseTimeEntryDate(day?.date); + + if (!Number.isNaN(parsedDate.getTime())) { + return new Intl.DateTimeFormat("en-US", { weekday: "long" }) + .format(parsedDate) + .toLowerCase(); + } + + const label = cleanString(day?.dayLabel || day?.date).toLowerCase(); + const aliases = { + sun: "sunday", + sunday: "sunday", + mon: "monday", + monday: "monday", + tue: "tuesday", + tuesday: "tuesday", + wed: "wednesday", + wednesday: "wednesday", + thu: "thursday", + thursday: "thursday", + fri: "friday", + friday: "friday", + sat: "saturday", + saturday: "saturday", + }; + + return aliases[label.slice(0, 3)] || aliases[label] || ""; + } + + function isNonServiceRequestSrNumber(srNumber) { + return normalizeString(srNumber) === normalizeString("non-service-sr"); + } + + return { + getTimeManagementRows, + isTimeManagementSaveEnabled, + isTimeManagementRowReadyToSave, + hasPositiveTimeManagementHours, + getTimeManagementPendingChanges, + isNewTimeManagementRow, + isUpdatedTimeManagementRow, + createTimeManagementRowSignature, + normalizeTimeManagementDayHours, + createTimeManagementSavePayload, + formatTimeManagementHourPayloadValue, + getTimeManagementDayKey, + getTimeManagementWeekdayName, + isNonServiceRequestSrNumber, + }; + } + + globalThis.ArchPanelTimeManagementDomain = Object.freeze({ + create, + }); +})(); diff --git a/src/domain/workload-calendar.js b/src/domain/workload-calendar.js new file mode 100644 index 0000000..0366d5d --- /dev/null +++ b/src/domain/workload-calendar.js @@ -0,0 +1,107 @@ +(() => { + function create({ + getLocalDateKey, + getOperationalWeekInfo, + getCalendarRowWeekDate, + formatOperationalWeekLabel, + formatDate, + parseDatePreservingDateOnly, + }) { + function buildWorkloadStartCalendar( + workloads, + year, + month, + view = "month", + anchorDateKey = "" + ) { + const today = new Date(); + const normalizedView = view === "week" ? "week" : "month"; + const monthStart = new Date(year, month, 1); + const anchorDate = getCalendarAnchorDate(year, month, anchorDateKey); + const calendarStart = + normalizedView === "week" + ? getWeekStartDate(anchorDate) + : getWeekStartDate(monthStart); + const dayCount = normalizedView === "week" ? 7 : 42; + 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: dayCount }, (_, 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: normalizedView === "week" || date.getMonth() === month, + isToday: dateKey === getLocalDateKey(today), + workloads: workloadsForDay, + }; + }); + const weekEnd = new Date(calendarStart); + weekEnd.setDate(calendarStart.getDate() + 6); + const monthLabel = new Intl.DateTimeFormat("en-US", { + month: "long", + year: "numeric", + }).format(monthStart); + const weekLabel = `${formatDate(calendarStart)} - ${formatDate(weekEnd)}`; + + return { + view: normalizedView, + periodLabel: normalizedView === "week" ? weekLabel : monthLabel, + days, + totalVisibleWorkloads: days.reduce( + (sum, day) => sum + day.workloads.length, + 0 + ), + }; + } + + function getCalendarAnchorDate(year, month, anchorDateKey) { + const anchorDate = parseDatePreservingDateOnly(anchorDateKey); + + if (!Number.isNaN(anchorDate.getTime())) { + return anchorDate; + } + + return new Date(year, month, 1); + } + + function getWeekStartDate(value) { + const date = parseDatePreservingDateOnly(value); + const weekStart = Number.isNaN(date.getTime()) + ? new Date() + : new Date(date); + + weekStart.setDate(weekStart.getDate() - weekStart.getDay()); + return weekStart; + } + + return Object.freeze({ + buildWorkloadStartCalendar, + getCalendarAnchorDate, + getWeekStartDate, + }); + } + + globalThis.ArchPanelWorkloadCalendarDomain = Object.freeze({ + create, + }); +})(); diff --git a/src/domain/workload-dataset.js b/src/domain/workload-dataset.js new file mode 100644 index 0000000..28d01a7 --- /dev/null +++ b/src/domain/workload-dataset.js @@ -0,0 +1,324 @@ +(() => { + function create({ + ALLOWED_STAGE_TYPES, + cleanString, + firstNonEmptyString, + normalizeString, + toNumber, + extractArray, + formatDateTime, + normalizePendingServiceRequests, + }) { + 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 = [], + resourceCurrentUser = null + ) { + return { + generatedAt: new Date().toISOString(), + user, + resourceCurrentUser, + customers, + pendingServiceRequests, + }; + } + + function createDatasetSnapshot(exportPayload) { + const normalizedPayload = normalizeCachedExportPayload(exportPayload); + + return { + user: normalizedPayload.user, + resourceCurrentUser: normalizedPayload.resourceCurrentUser, + customers: normalizedPayload.customers, + exportPayload: normalizedPayload, + summary: buildSummary(normalizedPayload), + }; + } + + 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, + ]) || "", + avatar: cleanString( + exportPayload?.user?.avatar || exportPayload?.user?.imageUrl + ), + administrator: Boolean(exportPayload?.user?.administrator), + }; + const resourceCurrentUser = exportPayload?.resourceCurrentUser ?? null; + 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, + resourceCurrentUser, + customers: normalizedCustomers, + pendingServiceRequests, + }; + } + + function normalizeWorkloadForCache(workload) { + return { + 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) || + normalizeForecastType(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), + }; + } + + 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, + }; + } + + return Object.freeze({ + normalizeWorkload, + isWonWorkload, + normalizeForecastType, + normalizeTeamValue, + teamContainsUser, + stripTransientFields, + createExportPayload, + createDatasetSnapshot, + normalizeCachedExportPayload, + normalizeWorkloadForCache, + buildSummary, + }); + } + + globalThis.ArchPanelWorkloadDatasetDomain = Object.freeze({ + create, + }); +})(); diff --git a/src/repositories/cache-repository.js b/src/repositories/cache-repository.js new file mode 100644 index 0000000..5ce9f68 --- /dev/null +++ b/src/repositories/cache-repository.js @@ -0,0 +1,102 @@ +(() => { + function create({ + CACHE, + storageService, + normalizeCachedExportPayload, + cleanString, + }) { + async function readCachedDataset() { + const record = await storageService.readRecord( + CACHE, + CACHE.datasetKey, + "Unable to read the cached dataset." + ); + const payload = + record?.exportPayload || + record?.payload || + record?.dataset?.exportPayload || + record?.dataset || + null; + + return payload ? normalizeCachedExportPayload(payload) : null; + } + + async function writeCachedDataset(exportPayload) { + const normalizedPayload = normalizeCachedExportPayload(exportPayload); + + await storageService.writeRecord( + CACHE, + { + key: CACHE.datasetKey, + updatedAt: new Date().toISOString(), + exportPayload: normalizedPayload, + }, + "Unable to write the cached dataset." + ); + } + + async function readCachedTaskTypePayload() { + const record = await storageService.readRecord( + CACHE, + CACHE.taskTypeKey, + "Unable to read the cached task types." + ); + + return record?.payload || null; + } + + async function writeCachedTaskTypePayload(payload) { + await storageService.writeRecord( + CACHE, + { + key: CACHE.taskTypeKey, + updatedAt: new Date().toISOString(), + payload, + }, + "Unable to write the cached task types." + ); + } + + async function writeCachedWorklistToken(tokenPayload) { + const token = cleanString(tokenPayload?.token); + + if (!token) { + return; + } + + await storageService.writeRecord( + CACHE, + { + key: CACHE.worklistTokenKey, + updatedAt: new Date().toISOString(), + token, + sourceUrl: cleanString(tokenPayload?.url), + }, + "Unable to write the cached Worklist token." + ); + } + + async function readCachedWorklistToken() { + const record = await storageService.readRecord( + CACHE, + CACHE.worklistTokenKey, + "Unable to read the cached Worklist token." + ); + + return cleanString(record?.token); + } + + return Object.freeze({ + readCachedDataset, + writeCachedDataset, + readCachedTaskTypePayload, + writeCachedTaskTypePayload, + writeCachedWorklistToken, + readCachedWorklistToken, + }); + } + + globalThis.ArchPanelCacheRepository = Object.freeze({ + create, + }); +})(); diff --git a/src/repositories/comcip-repository.js b/src/repositories/comcip-repository.js new file mode 100644 index 0000000..65a71c4 --- /dev/null +++ b/src/repositories/comcip-repository.js @@ -0,0 +1,438 @@ +(() => { + function create(dependencies) { + const { + COMCIP_REQUEST, + sendComcipRequest, + cleanString, + firstNonEmptyString, + extractArray, + getFieldValue, + toNumber, + formatDateForTimeEntryRequest, + } = dependencies; + + async function fetchPendingServiceRequests() { + const response = await sendComcipRequest({ + method: "POST", + url: COMCIP_REQUEST.url, + payload: createComcipServiceRequestPayload(), + timeoutMessage: "Timed out while requesting COMCIP data.", + }); + + assertComcipResponse(response, "COMCIP"); + + return normalizePendingServiceRequests(response.payload); + } + + async function fetchResourceCurrentUser(userEmail) { + const normalizedUserEmail = cleanString(userEmail); + + if (!normalizedUserEmail) { + throw new Error("Unable to request resource current user without userEmail."); + } + + const url = `${COMCIP_REQUEST.resourceCurrentUserUrl}?emailAddress=${encodeURIComponent( + normalizedUserEmail + )}`; + + return fetchComcipPayload(url, "resourceCurrentUser"); + } + + async function fetchTimeEntriesSummary(srNumber) { + const url = `${COMCIP_REQUEST.timeEntriesSummaryUrl}?srNumber=${encodeURIComponent( + srNumber + )}`; + const response = await sendGet(url); + + assertComcipResponse(response, "timeEntriesSummary"); + + return response; + } + + async function fetchInfoWeekRequest(selectedDate) { + const dateValue = formatDateForTimeEntryRequest(selectedDate); + const url = `${COMCIP_REQUEST.timeEntryWeekDetailsUrl}?dateValue=${encodeURIComponent( + dateValue + )}`; + + return fetchComcipPayload(url, "infoWeekRequest"); + } + + async function fetchActiveServiceRequests(resourceId, weekId) { + const url = `${COMCIP_REQUEST.activeServiceRequestsUrl}?resourceId=${encodeURIComponent( + resourceId + )}&weekId=${encodeURIComponent(weekId)}`; + + return fetchComcipPayload(url, "sRRequest"); + } + + function buildTimeEntryWeekUrl(resourceId, weekId) { + return `${COMCIP_REQUEST.timeEntriesUrl}?resourceId=${encodeURIComponent( + resourceId + )}&sortBy=createdDate&sortOrder=ASC&weekId=${encodeURIComponent(weekId)}`; + } + + async function fetchTimeEntryWeek(url) { + return fetchComcipPayload(url, "timeEntryWeek"); + } + + async function fetchActivityRequest(srNumbers) { + const activityRequestUrl = buildActivityRequestUrl(srNumbers); + const activityPayload = await fetchComcipPayload( + activityRequestUrl, + "activityRequest" + ); + + return { + url: activityRequestUrl, + payload: activityPayload, + }; + } + + function buildActivityRequestUrl(srNumbers) { + const query = buildServiceRequestActivityQuery(srNumbers); + const fields = + "SrNumber,SRActivities_c,ProblemDescription,AccountPartyUniqueName,ServiceNew_c,OptyOwner_c,OptyName_c,OptyNumber_c,ReportedByPartyUniqueName,StatusCdMeaning,CountryText_c,AssigneePersonName,OptyStatus_c"; + + return [ + COMCIP_REQUEST.activityServiceRequestsUrl, + "?expand=ServiceRequest_SRToInternalSR_Tgt", + `&fields=${encodeActivityRequestParam(fields)}`, + "&limit=200", + "&onlyData=true", + `&q=${encodeActivityRequestParam(query)}`, + ].join(""); + } + + function buildServiceRequestActivityQuery(srNumbers) { + const values = [...(srNumbers || [])] + .map((srNumber) => cleanString(srNumber)) + .filter(Boolean) + .map((srNumber) => `'${srNumber.replace(/'/g, "''")}'`) + .concat("'non-service-sr'") + .join(","); + + return `SrNumber in (${values})`; + } + + function encodeActivityRequestParam(value) { + return encodeURIComponent(String(value || "")) + .replace(/'/g, "%27") + .replace(/\(/g, "%28") + .replace(/\)/g, "%29"); + } + + function fetchTaskTypePayload() { + return fetchComcipPayload(COMCIP_REQUEST.taskTypeUrl, "taskTypeRequest"); + } + + async function mutateComcipPayload(method, url, payload, requestName) { + const response = await sendComcipRequest({ + method, + url, + payload, + timeoutMessage: `Timed out while executing ${requestName}.`, + }); + + assertComcipResponse(response, requestName); + + return response.payload; + } + + async function fetchComcipPayload(url, requestName) { + const response = await sendGet(url); + + assertComcipResponse(response, requestName); + + return response.payload; + } + + function sendGet(url) { + return sendComcipRequest({ + method: "GET", + url, + payload: null, + timeoutMessage: "Timed out while requesting COMCIP data.", + }); + } + + function assertComcipResponse(response, requestName) { + if (response?.ok) { + return; + } + + 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( + `${requestName}: ${ + payloadMessage || + response?.error || + `COMCIP request failed (${response?.status || 0}).` + }` + ); + } + + 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", + ]) + ), + }; + } + + return { + fetchPendingServiceRequests, + fetchResourceCurrentUser, + fetchTimeEntriesSummary, + fetchInfoWeekRequest, + fetchActiveServiceRequests, + buildTimeEntryWeekUrl, + fetchTimeEntryWeek, + fetchActivityRequest, + fetchTaskTypePayload, + mutateComcipPayload, + normalizePendingServiceRequests, + }; + } + + globalThis.ArchPanelComcipRepository = Object.freeze({ + create, + }); +})(); diff --git a/src/repositories/workbench-repository.js b/src/repositories/workbench-repository.js new file mode 100644 index 0000000..c0c311b --- /dev/null +++ b/src/repositories/workbench-repository.js @@ -0,0 +1,292 @@ +(() => { + function create(dependencies) { + const { + TARGET_ORIGIN, + API_ENDPOINTS, + fetchJson, + extractArray, + cleanString, + firstNonEmptyString, + getFieldValue, + toNumber, + normalizeWorkload, + isWonWorkload, + normalizeTeamValue, + } = dependencies; + + async function fetchWorklistCurrentUser(worklistToken) { + const normalizedToken = cleanString(worklistToken); + + if (!normalizedToken) { + throw new Error( + "Unable to resolve Worklist token. Reload the FuseWelcome page so the Worklist iframe token can be captured before refreshing data." + ); + } + + const url = new URL(API_ENDPOINTS.worklistCurrentUser); + url.search = new URLSearchParams({ + source: "saasui", + _: String(Date.now()), + token: normalizedToken, + }).toString(); + + const payload = await fetchJson(url.toString(), { forceExtension: true }); + const userPayload = normalizeWorklistUserPayload(payload); + const firstName = cleanString( + getFieldValue(userPayload, ["FirstName", "firstName", "User.FirstName"]) + ); + const lastName = cleanString( + getFieldValue(userPayload, ["LastName", "lastName", "User.LastName"]) + ); + const userEmail = cleanString( + getFieldValue(userPayload, [ + "Email", + "email", + "EmailAddress", + "emailAddress", + "User.Email", + "User.email", + "User.EmailAddress", + ]) + ); + const displayName = firstNonEmptyString([ + `${firstName} ${lastName}`.trim(), + userPayload?.DisplayName, + userPayload?.displayName, + userEmail, + ]); + + if (!userEmail) { + const userKeys = + userPayload && typeof userPayload === "object" + ? Object.keys(userPayload).join(", ") + : ""; + throw new Error( + `Unable to resolve userEmail from Worklist user response. Available top-level keys: ${Object.keys( + payload || {} + ).join(", ")}${userKeys ? ` | User keys: ${userKeys}` : ""}` + ); + } + + return { + userEmail, + name: displayName || userEmail, + avatar: cleanString( + getFieldValue(userPayload, ["ImageURL", "imageUrl", "User.ImageURL"]) + ), + administrator: Boolean( + getFieldValue(userPayload, [ + "Administrator", + "administrator", + "User.Administrator", + ]) + ), + }; + } + + function normalizeWorklistUserPayload(payload) { + const candidate = payload?.User ?? payload?.user ?? payload; + + if (Array.isArray(candidate)) { + return candidate[0] || {}; + } + + if (candidate?.User) { + return normalizeWorklistUserPayload(candidate); + } + + return candidate || {}; + } + + 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 createWorkloadAction(payload) { + return fetchJson(API_ENDPOINTS.workloadActions, { + method: "POST", + headers: { + "content-type": "application/json; charset=UTF-8", + }, + body: JSON.stringify(payload), + }); + } + + async function updateWorkloadForecast(workloadId, payload) { + const url = `${API_ENDPOINTS.workloadServiceRequests}/${encodeURIComponent( + workloadId + )}/forecast`; + + return fetchJson(url, { + method: "PATCH", + omitAuthHeaders: true, + headers: { + accept: "*/*", + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); + } + + return { + fetchWorklistCurrentUser, + fetchAllCustomers, + fetchCustomerWorkloads, + fetchWorkloadActions, + fetchWorkloadServiceRequests, + createWorkloadAction, + updateWorkloadForecast, + }; + } + + globalThis.ArchPanelWorkbenchRepository = Object.freeze({ + create, + }); +})(); diff --git a/src/services/chrome-service.js b/src/services/chrome-service.js new file mode 100644 index 0000000..37bd89b --- /dev/null +++ b/src/services/chrome-service.js @@ -0,0 +1,25 @@ +(() => { + function sendMessage(message) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(message, (response) => { + const runtimeError = chrome.runtime.lastError; + + if (runtimeError) { + reject(new Error(runtimeError.message)); + return; + } + + resolve(response); + }); + }); + } + + function getExtensionUrl(path) { + return chrome.runtime.getURL(path); + } + + globalThis.ArchPanelChromeService = Object.freeze({ + sendMessage, + getExtensionUrl, + }); +})(); diff --git a/src/services/preferences-service.js b/src/services/preferences-service.js new file mode 100644 index 0000000..2c6d37e --- /dev/null +++ b/src/services/preferences-service.js @@ -0,0 +1,122 @@ +(() => { + function create({ + STORAGE_KEYS, + getDateInputValue, + parseDatePreservingDateOnly, + }) { + 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 { + // Keep in-memory UI state when browser storage is unavailable. + } + } + + 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 { + // Keep in-memory UI state when browser storage is unavailable. + } + } + + function getStoredCalendarView() { + try { + return window.localStorage.getItem(STORAGE_KEYS.calendarView) === "week" + ? "week" + : "month"; + } catch { + return "month"; + } + } + + function setStoredCalendarView(value) { + try { + window.localStorage.setItem( + STORAGE_KEYS.calendarView, + value === "week" ? "week" : "month" + ); + } catch { + // Keep in-memory UI state when browser storage is unavailable. + } + } + + function getStoredTimeManagementSelectedDate() { + try { + const storedDate = window.localStorage.getItem( + STORAGE_KEYS.timeManagementSelectedDate + ); + + return isValidDateInputValue(storedDate) + ? storedDate + : getDateInputValue(new Date()); + } catch { + return getDateInputValue(new Date()); + } + } + + function setStoredTimeManagementSelectedDate(value) { + if (!isValidDateInputValue(value)) { + return; + } + + try { + window.localStorage.setItem( + STORAGE_KEYS.timeManagementSelectedDate, + value + ); + } catch { + // Keep in-memory selected date when browser storage is unavailable. + } + } + + function isValidDateInputValue(value) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || ""))) { + return false; + } + + const parsedDate = parseDatePreservingDateOnly(value); + + return !Number.isNaN(parsedDate.getTime()); + } + + return Object.freeze({ + getStoredTheme, + setStoredTheme, + getStoredSidebarCollapsed, + setStoredSidebarCollapsed, + getStoredCalendarView, + setStoredCalendarView, + getStoredTimeManagementSelectedDate, + setStoredTimeManagementSelectedDate, + isValidDateInputValue, + }); + } + + globalThis.ArchPanelPreferencesService = Object.freeze({ + create, + }); +})(); diff --git a/src/services/storage-service.js b/src/services/storage-service.js new file mode 100644 index 0000000..4cad5b1 --- /dev/null +++ b/src/services/storage-service.js @@ -0,0 +1,95 @@ +(() => { + function openDatabase(cacheConfig) { + return new Promise((resolve, reject) => { + if (!globalThis.indexedDB) { + reject(new Error("IndexedDB is not available in this browser context.")); + return; + } + + const request = globalThis.indexedDB.open( + cacheConfig.dbName, + cacheConfig.dbVersion + ); + + request.onupgradeneeded = () => { + const database = request.result; + + if (!database.objectStoreNames.contains(cacheConfig.storeName)) { + database.createObjectStore(cacheConfig.storeName, { keyPath: "key" }); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error || new Error("Unable to open IndexedDB cache.")); + }; + }); + } + + async function withStore(cacheConfig, mode, callback) { + const database = await openDatabase(cacheConfig); + + return new Promise((resolve, reject) => { + const transaction = database.transaction(cacheConfig.storeName, mode); + const store = transaction.objectStore(cacheConfig.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); + }); + } + + function readRecord(cacheConfig, key, errorMessage) { + return withStore(cacheConfig, "readonly", (store, resolve, reject) => { + const request = store.get(key); + + request.onsuccess = () => { + resolve(request.result || null); + }; + + request.onerror = () => { + reject(request.error || new Error(errorMessage || "Unable to read cache.")); + }; + }); + } + + function writeRecord(cacheConfig, record, errorMessage) { + return withStore(cacheConfig, "readwrite", (store, resolve, reject) => { + const request = store.put(record); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(request.error || new Error(errorMessage || "Unable to write cache.")); + }; + }); + } + + globalThis.ArchPanelStorageService = Object.freeze({ + openDatabase, + withStore, + readRecord, + writeRecord, + }); +})(); diff --git a/src/services/transport-service.js b/src/services/transport-service.js new file mode 100644 index 0000000..a9ffb02 --- /dev/null +++ b/src/services/transport-service.js @@ -0,0 +1,709 @@ +(() => { + function create({ + TARGET_ORIGIN, + TARGET_APP_URL, + IDS, + BRIDGE, + COMCIP_REQUEST, + COMCIP_TIME_ENTRY_FRAME_ID, + chromeService, + cleanString, + firstNonEmptyString, + isHcmWelcomePage, + getSessionAuthHeaders, + setSessionAuthHeaders, + }) { + let requestCounter = 0; + let bridgeReadyPromise = null; + const pendingBridgeRequests = new Map(); + + 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 = chromeService.getExtensionUrl("page-bridge.js"); + script.async = false; + script.onload = () => { + script.remove(); + resolve(); + }; + script.onerror = () => { + resolve(); + }; + + (document.head || document.documentElement).appendChild(script); + }); + + return bridgeReadyPromise; + } + + async function sendComcipRequestMessage({ + method, + url, + payload, + timeoutMessage, + }) { + const useTimeEntryFrame = isComcipTimeEntryUrl(url); + + if (useTimeEntryFrame) { + await ensureComcipTimeEntryFrame(); + } + + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + reject( + new Error(timeoutMessage || "Timed out while requesting COMCIP data.") + ); + }, 130000); + + chromeService + .sendMessage({ + type: "ARCH_PANEL_COMCIP_REQUEST", + method, + url, + payload, + useTimeEntryFrame, + headers: { + "accept-language": navigator.language || "pt-BR", + "x-vb-application-version": COMCIP_REQUEST.appVersion, + }, + }) + .then((response) => { + window.clearTimeout(timeout); + resolve(response || null); + }) + .catch((error) => { + window.clearTimeout(timeout); + reject(error); + }); + }); + } + + function isComcipTimeEntryUrl(url) { + return String(url || "").includes( + "/ic/builder/rt/oalset_timeentrymobile/live" + ); + } + + function ensureComcipTimeEntryFrame() { + const existingFrame = document.getElementById(COMCIP_TIME_ENTRY_FRAME_ID); + + if (existingFrame instanceof HTMLIFrameElement) { + existingFrame.name = COMCIP_TIME_ENTRY_FRAME_ID; + return Promise.resolve(existingFrame); + } + + return new Promise((resolve, reject) => { + const frame = document.createElement("iframe"); + const timeout = window.setTimeout(() => { + reject( + new Error( + "Timed out while preparing COMCIP Time Entry session frame." + ) + ); + }, 45000); + + frame.id = COMCIP_TIME_ENTRY_FRAME_ID; + frame.name = COMCIP_TIME_ENTRY_FRAME_ID; + frame.title = "COMCIP Time Entry session"; + frame.src = COMCIP_REQUEST.timeEntryAppUrl; + frame.setAttribute("aria-hidden", "true"); + frame.tabIndex = -1; + frame.style.position = "fixed"; + frame.style.width = "1px"; + frame.style.height = "1px"; + frame.style.opacity = "0"; + frame.style.pointerEvents = "none"; + frame.style.border = "0"; + frame.style.left = "-10000px"; + frame.style.top = "0"; + + frame.addEventListener( + "load", + () => { + window.clearTimeout(timeout); + resolve(frame); + }, + { once: true } + ); + frame.addEventListener( + "error", + () => { + window.clearTimeout(timeout); + frame.remove(); + reject(new Error("Unable to load COMCIP Time Entry session frame.")); + }, + { once: true } + ); + + document.documentElement.appendChild(frame); + }); + } + + async function fetchJson(url, options = {}) { + if (options.forceExtension) { + return fetchJsonViaExtension(url, options); + } + + if (shouldUseSpaFrameFetch(url) && !options.forceExtension) { + return fetchJsonViaSpaFrame(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 = resolveRequestUrl(url); + + if (!normalized) { + return false; + } + + try { + const parsedUrl = new URL(normalized, window.location.origin); + return parsedUrl.origin !== window.location.origin; + } catch { + return false; + } + } + + function shouldUseSpaFrameFetch(url) { + if (!isHcmWelcomePage()) { + return false; + } + + const normalized = resolveRequestUrl(url); + + if (!normalized) { + return false; + } + + try { + return new URL(normalized).origin === TARGET_ORIGIN; + } catch { + return false; + } + } + + async function fetchJsonViaSpaFrame(url, options = {}) { + const requestUrl = resolveRequestUrl(url); + const bridgeHeaders = await requestBridgeHeaders({ + url: requestUrl, + headers: options.headers, + body: options.body, + includeAllAuth: true, + }).catch(() => ({})); + + await ensureSpaFrame(); + + const response = await chromeService.sendMessage({ + type: "ARCH_PANEL_SPA_FRAME_FETCH", + url: requestUrl, + options: { + method: options.method || "GET", + referrer: options.referrer, + headers: buildExtensionFetchHeaders( + requestUrl, + options, + bridgeHeaders || {} + ), + body: options.body, + }, + }); + + if (!response || typeof response !== "object") { + throw new Error(`No response received for ${requestUrl}`); + } + + if ( + !response.ok && + response.status === 0 && + /SPA Workbench frame did not return a result/i.test( + cleanString(response.error) + ) + ) { + throw new Error( + response.error || "SPA Workbench iframe did not return a result." + ); + } + + 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 debugFrameUrl = cleanString(response.debug?.frameUrl); + const debugUrl = cleanString(response.debug?.url); + const debugParts = [ + debugUrl ? `url: ${debugUrl}` : "", + debugFrameUrl ? `frame: ${debugFrameUrl}` : "", + 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 ensureSpaFrame() { + const frameHost = ensureSpaFrameHost(); + const existingFrame = document.getElementById(IDS.spaFrame); + const frameUrl = TARGET_APP_URL; + + if (existingFrame instanceof HTMLIFrameElement) { + if (existingFrame.parentElement !== frameHost) { + frameHost.appendChild(existingFrame); + } + + if (shouldNavigateSpaFrame(existingFrame, frameUrl)) { + navigateSpaFrame(existingFrame, frameUrl); + } + + return waitForFrameLoad(existingFrame); + } + + const frame = document.createElement("iframe"); + frame.id = IDS.spaFrame; + frame.name = IDS.spaFrame; + frame.dataset.archPanelFrame = "spa-workbench"; + frame.setAttribute("aria-hidden", "true"); + frame.setAttribute("referrerpolicy", "no-referrer-when-downgrade"); + frame.tabIndex = -1; + Object.assign(frame.style, { + position: "absolute", + width: "1440px", + height: "1000px", + opacity: "0", + pointerEvents: "none", + border: "0", + left: "0", + top: "0", + }); + + frameHost.appendChild(frame); + navigateSpaFrame(frame, frameUrl); + + return waitForFrameLoad(frame); + } + + function ensureSpaFrameHost() { + const existingHost = document.getElementById(IDS.spaFrameHost); + + if (existingHost) { + const desiredParent = document.body || document.documentElement; + + if (desiredParent && existingHost.parentElement !== desiredParent) { + desiredParent.appendChild(existingHost); + } + + Object.assign(existingHost.style, getSpaFrameHostStyle()); + + return existingHost; + } + + const host = document.createElement("div"); + host.id = IDS.spaFrameHost; + host.setAttribute("aria-hidden", "true"); + Object.assign(host.style, getSpaFrameHostStyle()); + + (document.body || document.documentElement).appendChild(host); + + return host; + } + + function getSpaFrameHostStyle() { + return { + position: "fixed", + width: "1440px", + height: "1000px", + overflow: "hidden", + opacity: "0", + pointerEvents: "none", + border: "0", + left: "-20000px", + top: "0", + zIndex: "0", + }; + } + + function shouldNavigateSpaFrame(frame, frameUrl) { + const currentSrc = cleanString(frame.getAttribute("src") || frame.src); + + if (!currentSrc || currentSrc === "about:blank") { + return true; + } + + try { + return new URL(currentSrc, window.location.href).toString() !== frameUrl; + } catch { + return currentSrc !== frameUrl; + } + } + + function navigateSpaFrame(frame, frameUrl) { + frame.dataset.archPanelLoaded = "false"; + frame.src = frameUrl; + } + + function waitForFrameLoad(frame) { + if (frame.dataset.archPanelLoaded === "true") { + return Promise.resolve(); + } + + return new Promise((resolve) => { + const timeout = window.setTimeout(() => { + resolve(); + }, 12000); + + frame.addEventListener( + "load", + () => { + frame.dataset.archPanelLoaded = "true"; + window.clearTimeout(timeout); + window.setTimeout(resolve, 1200); + }, + { once: true } + ); + }); + } + + async function fetchJsonViaExtension(url, options = {}) { + const requestUrl = resolveRequestUrl(url); + const bridgeHeaders = await requestBridgeHeaders({ + url: requestUrl, + headers: options.headers, + body: options.body, + includeAllAuth: true, + }); + const response = await chromeService.sendMessage({ + type: "ARCH_PANEL_EXTENSION_FETCH", + url: requestUrl, + options: { + method: options.method || "GET", + referrer: options.referrer, + headers: buildExtensionFetchHeaders( + requestUrl, + options, + bridgeHeaders || {} + ), + body: options.body, + }, + }); + + 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 || {}), + ...(getSessionAuthHeaders() || {}), + accept: "application/json, text/plain, */*", + ...(options.headers || {}), + }; + + return options.omitAuthHeaders + ? stripAuthHeaders(combinedHeaders) + : combinedHeaders; + } + + function stripAuthHeaders(headers = {}) { + const sanitizedHeaders = {}; + + for (const [key, value] of Object.entries(headers || {})) { + const normalizedKey = cleanString(key).toLowerCase(); + + if ( + normalizedKey === "authorization" || + normalizedKey === "x-id-token" || + normalizedKey === "spa-ts-authorization" || + normalizedKey.endsWith("-authorization") + ) { + continue; + } + + sanitizedHeaders[key] = value; + } + + return sanitizedHeaders; + } + + 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 toBridgeRequestUrl(url) { + const normalized = resolveRequestUrl(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 resolveRequestUrl(url) { + const normalized = cleanString(url); + + if (!normalized) { + return ""; + } + + try { + const baseOrigin = + isHcmWelcomePage() && normalized.startsWith("/") + ? TARGET_ORIGIN + : window.location.origin; + + return new URL(normalized, baseOrigin).toString(); + } catch { + return normalized; + } + } + + function handleBridgeMessage(event) { + const message = event.data; + + if (!message || message.source !== BRIDGE.source) { + return; + } + + if (message.type === BRIDGE.workbenchAuthHeadersType) { + rememberWorkbenchAuthHeaders(message.headers); + return; + } + + if (event.source !== window) { + 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 rememberWorkbenchAuthHeaders(headers) { + if (!headers || typeof headers !== "object") { + return; + } + + const nextHeaders = { + ...(getSessionAuthHeaders() || {}), + }; + + for (const [key, value] of Object.entries(headers)) { + const normalizedKey = cleanString(key).toLowerCase(); + const normalizedValue = cleanString(value); + + if (!normalizedKey || !normalizedValue) { + continue; + } + + nextHeaders[normalizedKey] = normalizedValue; + } + + setSessionAuthHeaders(nextHeaders); + } + + return Object.freeze({ + ensurePageBridge, + fetchJson, + sendComcipRequestMessage, + ensureSpaFrame, + handleBridgeMessage, + }); + } + + globalThis.ArchPanelTransportService = Object.freeze({ + create, + }); +})(); diff --git a/src/shared/config.js b/src/shared/config.js new file mode 100644 index 0000000..72f2f86 --- /dev/null +++ b/src/shared/config.js @@ -0,0 +1,189 @@ +(() => { + 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 TARGET_APP_URL = + "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=workload_workbench"; + const HCM_ORIGIN = "https://eeho.fa.us2.oraclecloud.com"; + const HCM_WELCOME_PATHNAME = "/hcmUI/faces/FuseWelcome"; + const HCM_MY_INFORMATION_APPS_GROUP_ID = "yourapps_groupNode_my_information"; + const WORKLIST_ORIGIN = "https://worklist.oracle.com"; + const WORKLIST_SAASUI_PATHNAME = "/oalapp/pub/worklist/saasui/index.html"; + 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", + hcmTile: "arch-panel-extension-hcm-tile", + spaFrame: "arch-panel-extension-spa-frame", + spaFrameHost: "arch-panel-extension-spa-frame-host", + }; + + 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", + calendarView: "arch-panel-extension-calendar-view", + timeManagementSelectedDate: "arch-panel-extension-time-management-selected-date", + }; + + const CACHE = { + dbName: "arch-panel-extension-db", + dbVersion: 1, + storeName: "datasets", + datasetKey: "workload-dashboard", + taskTypeKey: "time-management-task-types", + worklistTokenKey: "worklist-saasui-token", + }; + + 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", + workbenchAuthHeadersType: "ARCH_PANEL_WORKBENCH_AUTH_HEADERS", + readyType: "ARCH_PANEL_BRIDGE_READY", + scriptId: "arch-panel-extension-page-bridge", + }; + + const API_ENDPOINTS = { + currentUser: "/oalcrm/web/api/identity-management/users/current", + worklistCurrentUser: + "https://worklist.oracle.com/oalapp/pub/wlp/svc/user-service/user", + 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", + resourceCurrentUserUrl: + "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/resourceUsers", + timeEntryWeekDetailsUrl: + "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries/weekDetails", + activeServiceRequestsUrl: + "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries/activeServiceRequests", + timeEntriesUrl: + "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries", + activityServiceRequestsUrl: + "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/crmRestApiSearchResourcesLatestCustomActions/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests", + taskTypeUrl: + "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/lookups?lookupType=SCTA_TASK_TYPE&type=CUSTOM", + timeEntryAppUrl: + "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live/webApps/timeentry/", + appVersion: "version_1754044416761", + }; + + const COMCIP_TIME_ENTRY_FRAME_ID = "arch-panel-extension-comcip-timeentry-frame"; + + 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"]; + + globalThis.ArchPanelConfig = Object.freeze({ + TARGET_ORIGIN, + TARGET_PATHNAME, + TARGET_PARAM, + TARGET_PARAM_VALUE, + TARGET_APP_URL, + HCM_ORIGIN, + HCM_WELCOME_PATHNAME, + HCM_MY_INFORMATION_APPS_GROUP_ID, + WORKLIST_ORIGIN, + WORKLIST_SAASUI_PATHNAME, + USER_BUTTON_LABEL, + ALLOWED_STAGE_TYPES, + SELECTORS, + IDS, + CLASSES, + STORAGE_KEYS, + CACHE, + DETAIL_SORT_KEYS, + BRIDGE, + API_ENDPOINTS, + COMCIP_REQUEST, + COMCIP_TIME_ENTRY_FRAME_ID, + ACTION_TEAM_OPTIONS, + ACTION_COMPLEXITY_OPTIONS, + }); +})(); diff --git a/src/shared/utils.js b/src/shared/utils.js new file mode 100644 index 0000000..d739ab6 --- /dev/null +++ b/src/shared/utils.js @@ -0,0 +1,463 @@ +(() => { + 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 normalizeString(value) { + return normalizeText(value).toUpperCase(); + } + + 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 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 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 ""; + } + + 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 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 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 getDateInputValue(value) { + 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 formatDateInputDisplay(value) { + const date = parseDatePreservingDateOnly(value); + + if (Number.isNaN(date.getTime())) { + return cleanString(value); + } + + const day = String(date.getDate()).padStart(2, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const year = date.getFullYear(); + + return `${day}/${month}/${year}`; + } + + function formatDateForTimeEntryRequest(value) { + const date = parseDatePreservingDateOnly(value); + + if (Number.isNaN(date.getTime())) { + return ""; + } + + const day = String(date.getDate()).padStart(2, "0"); + const month = new Intl.DateTimeFormat("en-US", { month: "short" }) + .format(date) + .toUpperCase(); + const year = date.getFullYear(); + + return `${day}-${month}-${year}`; + } + + function parseTimeEntryDate(value) { + const text = cleanString(value); + const match = text.match(/^(\d{1,2})-([A-Z]{3})-(\d{4})$/i); + + if (!match) { + return parseDatePreservingDateOnly(text); + } + + const monthIndex = [ + "JAN", + "FEB", + "MAR", + "APR", + "MAY", + "JUN", + "JUL", + "AUG", + "SEP", + "OCT", + "NOV", + "DEC", + ].indexOf(match[2].toUpperCase()); + + if (monthIndex < 0) { + return new Date(Number.NaN); + } + + return new Date(Number(match[3]), monthIndex, Number(match[1])); + } + + 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); + + if (weekDate.getDay() === 0) { + weekDate.setDate(weekDate.getDate() + 1); + } + + return weekDate; + } + + 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 cssEscape(value) { + if (window.CSS?.escape) { + return window.CSS.escape(String(value || "")); + } + + return String(value || "").replace(/["\\]/g, "\\$&"); + } + + globalThis.ArchPanelUtils = Object.freeze({ + normalizeText, + cleanString, + firstNonEmptyString, + normalizeString, + toNumber, + toIsoDateString, + extractArray, + mapWithConcurrency, + getFieldValue, + flattenDisplayRecord, + isVisible, + formatWholeNumber, + formatCurrency, + formatHours, + formatWeekNumber, + formatOperationalWeekLabel, + formatPercent, + formatProgressPercent, + formatDate, + getDateInputValue, + formatDateInputDisplay, + formatDateForTimeEntryRequest, + parseTimeEntryDate, + formatDateTime, + getLocalDateKey, + getOperationalWeekInfo, + getCalendarRowWeekDate, + parseDatePreservingDateOnly, + getErrorMessage, + escapeHtml, + cssEscape, + }); +})(); diff --git a/src/views/dashboard-view.js b/src/views/dashboard-view.js new file mode 100644 index 0000000..48d53ef --- /dev/null +++ b/src/views/dashboard-view.js @@ -0,0 +1,714 @@ +(() => { + function create(dependencies) { + const { + appState, + buildWorkloadStartCalendar, + hasRampForecastMismatch, + renderOpportunityLink, + renderWorkloadLink, + escapeHtml, + cleanString, + formatWholeNumber, + formatCurrency, + formatPercent, + formatProgressPercent, + formatDate, + } = dependencies; + + 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 renderCurrentUserSidebar(user) { + const displayName = cleanString(user?.name || user?.userEmail); + const userEmail = cleanString(user?.userEmail); + const avatar = cleanString(user?.avatar); + const initials = getUserInitials(displayName || userEmail); + + return ` +
+ ${ + avatar + ? `${escapeHtml(displayName ? `${displayName} avatar` : ` + : `` + } +
+ ${escapeHtml(displayName || userEmail)} + ${escapeHtml(userEmail)} +
+
+ `; + } + + function getUserInitials(value) { + const parts = cleanString(value) + .split(/\s+/) + .filter(Boolean) + .slice(0, 2); + + if (!parts.length) { + return "U"; + } + + return parts.map((part) => part.charAt(0).toUpperCase()).join(""); + } + + function getExtensionVersion() { + try { + return chrome.runtime.getManifest()?.version || "0.0.0"; + } catch { + return "0.0.0"; + } + } + + 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, + appState.calendarView, + appState.calendarAnchorDateKey + ); + + return ` +
+ ${renderSrHoursIndicatorCard(summary)} + ${renderWorkloadStartCalendar(calendar)} +
+ `; + } + + function renderSrHoursIndicatorCard(summary) { + const zeroToFourCount = summary.srHours.betweenZeroAndFour.length; + const aboveFourCount = summary.srHours.aboveFour.length; + const total = zeroToFourCount + aboveFourCount; + const zeroToFourPercent = total > 0 ? (zeroToFourCount / total) * 100 : 0; + const aboveFourPercent = total > 0 ? (aboveFourCount / total) * 100 : 0; + + return ` +
+
+
+

Horas apontadas

+
+
+
+
+ + +
+
+ SRs 0-4h: ${formatPercent(zeroToFourPercent)} + SRs >4h: ${formatPercent(aboveFourPercent)} +
+
+
+ ${renderSrHoursIndicator( + "SRs 0-4h", + formatPercent(zeroToFourPercent), + zeroToFourCount, + "0-4" + )} + ${renderSrHoursIndicator( + "SRs >4h", + formatPercent(aboveFourPercent), + aboveFourCount, + "above-4" + )} +
+
+ `; + } + + function renderSrHoursIndicator(label, detail, count, value) { + return ` + + `; + } + + function renderWorkloadStartCalendar(calendar) { + return ` +
+
+
+

Workload start calendar

+

${escapeHtml(calendar.periodLabel)}

+
+
+
+ ${["month", "week"] + .map( + (view) => ` + + ` + ) + .join("")} +
+ + ${formatWholeNumber(calendar.totalVisibleWorkloads)} starts + + +
+
+
+ ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + .map((day) => `${day}`) + .join("")} +
+
+ ${calendar.days + .map((day) => renderCalendarDay(day, calendar.view)) + .join("")} +
+
+ `; + } + + function renderCalendarDay(day, view = "month") { + if (view === "week") { + return renderCalendarWeekDay(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 renderCalendarWeekDay(day) { + const className = [ + "arch-panel-extension-calendar-day", + "is-week-day", + day.isToday ? "is-today" : "", + day.workloads.length ? "has-workload" : "", + ] + .filter(Boolean) + .join(" "); + const title = day.workloads + .map((workload) => workload.description || workload.name) + .filter(Boolean) + .join(" | "); + + return ` + + `; + } + + function renderCalendarWeekWorkload(workload) { + const label = workload.description || workload.name || "Workload"; + const customer = workload.customerName || ""; + + return ` + + ${escapeHtml(label)} + ${ + customer + ? `${escapeHtml( + customer + )}` + : "" + } + + `; + } + + 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)}

+
+ `; + } + + + return { + renderMainContent, + renderWorkloadTableRow, + }; + } + + globalThis.ArchPanelDashboardView = Object.freeze({ + create, + }); +})(); diff --git a/src/views/manage-time-view.js b/src/views/manage-time-view.js new file mode 100644 index 0000000..5418654 --- /dev/null +++ b/src/views/manage-time-view.js @@ -0,0 +1,633 @@ +(() => { + function create(dependencies) { + const { + appState, + escapeHtml, + cleanString, + normalizeString, + formatDateInputDisplay, + getDateInputValue, + formatHours, + getWeekStartDate, + getLocalDateKey, + getTimeManagementDayKey, + isTimeManagementSaveEnabled, + getTimeManagementRows, + getTimeManagementActivityOptions, + findTimeManagementServiceRequestByNumber, + formatTimeManagementServiceRequestLabel, + isNonServiceRequestSrNumber, + findOptionValue, + findOptionLabel, + getTimeManagementTaskTypeOptions, + formatTimeManagementHourPayloadValue, + } = dependencies; + + function renderTimeManagementModal() { + const modal = appState.timeManagementModal; + + if (!modal) { + return ""; + } + + const selectedDate = modal.selectedDate || getDateInputValue(new Date()); + const selectedDateLabel = formatDateInputDisplay(selectedDate); + const canSaveTimeManagement = isTimeManagementSaveEnabled(modal); + const isSavingTimeManagement = Boolean(modal.isSaving); + + return ` +
+
+
+
+

Horas apontadas

+

Manage time

+
+
+ +
+ + ${renderTimeManagementHourIndicators(modal)} +
+ + ${renderTimeManagementBody(modal)} + +
+ + ${ + modal.saveErrorMessage + ? `

${escapeHtml(modal.saveErrorMessage)}

` + : "" + } + +
+
+
+ `; + } + + function renderTimeManagementBody(modal) { + if (modal.status === "loading") { + return renderTimeManagementSkeleton(); + } + + if (modal.status === "error") { + return ` +
+

Erro na requisição

+

${escapeHtml( + modal.errorMessage || "Não foi possível carregar o apontamento." + )}

+
+ `; + } + + const rows = getTimeManagementRows(modal); + + return ` +
+ ${renderTimeManagementHeaderRow(modal.weekDays || [])} +
+ ${ + rows.length + ? rows.map((row, index) => renderTimeManagementEntryRow(modal, row, index)).join("") + : `
+ Nenhum apontamento de hora neste período +
` + } +
+
+ `; + } + + function renderTimeManagementHeaderRow(weekDays) { + return ` + + `; + } + + function renderTimeManagementDayHeader(day) { + return ` + + ${escapeHtml(day.dayLabel || "-")} + ${escapeHtml(day.monthLabel || day.date || "-")} + + `; + } + + function renderTimeManagementEntryRow(modal, row, index) { + const rowId = cleanString(row?.id) || `row-${index}`; + const isHighlighted = + cleanString(modal?.highlightRowId) && + cleanString(modal?.highlightRowId) === rowId; + const selectedSr = cleanString(row?.selectedSrNumber); + const activityOptions = getTimeManagementActivityOptions(modal, selectedSr); + const selectedSrOption = findTimeManagementServiceRequestByNumber( + modal, + selectedSr + ); + const isNonServiceRequest = isNonServiceRequestSrNumber(selectedSr); + const selectedSrLabel = + selectedSrOption?.label || + formatTimeManagementServiceRequestLabel(selectedSrOption) || + selectedSr; + const selectedActivityValue = + findOptionValue(activityOptions, row?.selectedActivityValue) || + cleanString(row?.selectedActivityValue) || + ""; + const selectedActivityLabel = + findOptionLabel(activityOptions, selectedActivityValue) || selectedActivityValue; + const taskTypeOptions = getTimeManagementTaskTypeOptions(modal, selectedSr); + const selectedTaskTypeValue = + findOptionValue(taskTypeOptions, row?.selectedTaskTypeValue) || + cleanString(row?.selectedTaskTypeValue) || + ""; + const selectedTaskTypeLabel = + findOptionLabel(taskTypeOptions, selectedTaskTypeValue) || selectedTaskTypeValue; + + return ` +
+ + + + + ${(modal.weekDays || []) + .map((day) => + renderTimeManagementDayInput(day, rowId, row?.dayHours) + ) + .join("")} + +
+ `; + } + + function renderTimeManagementSkeleton() { + return ` +
+
+ ${["Service Request", "Activity", "Task Type"] + .map( + (label) => + `${label}` + ) + .join("")} + ${Array.from({ length: 7 }, () => ` + + + + + `).join("")} + +
+
+ ${Array.from({ length: 3 }, () => ` +
+ +
+ `).join("")} + ${Array.from({ length: 7 }, () => ` +
+ +
+ `).join("")} + +
+
+ `; + } + + function renderSearchableCombobox({ + field, + rowId, + selectedValue, + value, + placeholder, + options, + valueKey, + labelKey, + fallbackLabelKey, + metaKey, + disabled = false, + }) { + const safeField = escapeHtml(field); + const safeRowId = escapeHtml(rowId || ""); + const isDisabled = Boolean(disabled); + const normalizedSelectedValue = normalizeString(selectedValue); + const normalizedOptions = (options || []) + .map((item) => ({ + value: cleanString(item?.[valueKey]), + label: + cleanString(item?.[labelKey]) || + cleanString(item?.[fallbackLabelKey]) || + cleanString(item?.[valueKey]), + meta: cleanString(item?.[metaKey]), + })) + .filter((item) => item.value || item.label); + + return ` + + + ${escapeHtml(value || placeholder || "")} + + + + + ${ + normalizedOptions.length + ? normalizedOptions + .map( + (item, index) => { + const isSelected = + normalizedSelectedValue && + normalizeString(item.value) === normalizedSelectedValue; + const meta = + item.meta && normalizeString(item.meta) !== normalizeString(item.label) + ? item.meta + : ""; + const optionId = `arch-panel-extension-combobox-${safeField}-${index}`; + + return ` + + `; + } + ) + .join("") + : `No options` + } + + + `; + } + + function renderTimeManagementDatePicker(selectedDate) { + const picker = appState.timeManagementDatePicker; + + if (!picker.isOpen) { + return ""; + } + + const monthStart = new Date(picker.year, picker.month, 1); + const calendarStart = getWeekStartDate(monthStart); + const selectedDateKey = getLocalDateKey(selectedDate); + const todayKey = getLocalDateKey(new Date()); + const monthLabel = new Intl.DateTimeFormat("en-US", { + month: "long", + year: "numeric", + }).format(monthStart); + const days = Array.from({ length: 42 }, (_, index) => { + const date = new Date(calendarStart); + date.setDate(calendarStart.getDate() + index); + const dateKey = getLocalDateKey(date); + const className = [ + "arch-panel-extension-date-picker-day", + date.getMonth() === picker.month ? "" : "is-muted", + dateKey === todayKey ? "is-today" : "", + dateKey === selectedDateKey ? "is-selected" : "", + ] + .filter(Boolean) + .join(" "); + + return ` + + `; + }); + + return ` +
+
+ + ${escapeHtml(monthLabel)} + +
+
+ ${["S", "M", "T", "W", "T", "F", "S"].map((day) => `${day}`).join("")} +
+
+ ${days.join("")} +
+
+ `; + } + + function renderTimeManagementHourIndicators(modal) { + if (modal?.status === "loading") { + return ` +
+ ${Array.from({ length: 4 }, () => ` + + + + + `).join("")} +
+ `; + } + + return ` +
+ ${buildTimeManagementHourIndicators(modal) + .map( + (indicator) => ` + + ${escapeHtml(indicator.label)} + ${escapeHtml(formatHours(indicator.value))} + + ` + ) + .join("")} +
+ `; + } + + function buildTimeManagementHourIndicators(modal) { + const totals = getTimeManagementRows(modal).reduce( + (accumulator, row) => { + const rowTotal = getTimeManagementRowTotalHours(row); + const category = getTimeManagementHourCategory(modal, row); + + accumulator[category] += rowTotal; + accumulator.total += rowTotal; + + return accumulator; + }, + { + service: 0, + nonService: 0, + timeOff: 0, + total: 0, + } + ); + + return [ + { label: "Service", value: totals.service }, + { label: "Non Service", value: totals.nonService }, + { label: "Time Off", value: totals.timeOff }, + { label: "Total hours", value: totals.total }, + ]; + } + + function getTimeManagementRowTotalHours(row) { + return Object.values(row?.dayHours || {}).reduce( + (sum, value) => sum + formatTimeManagementHourPayloadValue(value), + 0 + ); + } + + function getTimeManagementHourCategory(modal, row) { + if (isTimeManagementTimeOffRow(modal, row)) { + return "timeOff"; + } + + return isNonServiceRequestSrNumber(row?.selectedSrNumber) + ? "nonService" + : "service"; + } + + function isTimeManagementTimeOffRow(modal, row) { + const taskTypeValue = cleanString(row?.selectedTaskTypeValue); + const taskType = findTimeManagementTaskTypeByValue(modal, taskTypeValue); + const candidates = [ + taskTypeValue, + taskType?.label, + taskType?.tags, + ].map((value) => normalizeString(value)); + + return candidates.some( + (value) => + value === "TIME_OFF" || + value === "TIMEOFF" || + value.includes("TIME_OFF") || + value.includes("TIME OFF") + ); + } + + function findTimeManagementTaskTypeByValue(modal, value) { + const normalizedValue = normalizeString(value); + + if (!normalizedValue) { + return null; + } + + return (modal?.taskTypes || []).find( + (item) => normalizeString(item?.value) === normalizedValue + ) || null; + } + + function renderTimeManagementDayInput( + day, + rowId = "", + dayHours = {} + ) { + const dateKey = getTimeManagementDayKey(day); + const value = cleanString(dayHours?.[dateKey]); + + return ` + + `; + } + + return { + renderTimeManagementModal, + }; + } + + globalThis.ArchPanelManageTimeView = Object.freeze({ + create, + }); +})(); diff --git a/src/views/render-helpers.js b/src/views/render-helpers.js new file mode 100644 index 0000000..a1de7c4 --- /dev/null +++ b/src/views/render-helpers.js @@ -0,0 +1,213 @@ +(() => { + function create({ + appState, + escapeHtml, + cleanString, + formatWholeNumber, + formatCurrency, + formatDate, + formatHours, + }) { + 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"; + } + + return Object.freeze({ + renderServiceRequestDetailTable, + renderSortButton, + renderOpportunityLink, + renderWorkloadLink, + getHeaderNote, + getHeaderSummary, + }); + } + + globalThis.ArchPanelRenderHelpers = Object.freeze({ + create, + }); +})(); diff --git a/src/views/workbench-modals-view.js b/src/views/workbench-modals-view.js new file mode 100644 index 0000000..97540c5 --- /dev/null +++ b/src/views/workbench-modals-view.js @@ -0,0 +1,518 @@ +(() => { + function create(dependencies) { + const { + appState, + DETAIL_SORT_KEYS, + ACTION_TEAM_OPTIONS, + ACTION_COMPLEXITY_OPTIONS, + getSortedDetailItems, + renderServiceRequestDetailTable, + renderSortButton, + renderWorkloadTableRow, + findWorkloadById, + renderOpportunityLink, + groupTimeEntriesByResourceName, + getTimeEntryTimeSpent, + getTimeEntryActivity, + getTimeEntryTaskType, + isNonZeroAmount, + escapeHtml, + cleanString, + formatWholeNumber, + formatCurrency, + formatDate, + formatHours, + } = dependencies; + + 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" + ) + } +
+ ${ + isServiceRequestDetail + ? `` + : "" + } + +
+
+
+ `; + } + + 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)}
+
+ `; + } + + + return { + renderDetailModal, + renderActionFormModal, + renderRampComparisonModal, + renderForecastUpdateConfirmModal, + renderTimeEntriesDrawer, + }; + } + + globalThis.ArchPanelWorkbenchModalsView = Object.freeze({ + create, + }); +})(); diff --git a/tools/bump-extension-version.ps1 b/tools/bump-extension-version.ps1 index b1c5d20..5627487 100644 --- a/tools/bump-extension-version.ps1 +++ b/tools/bump-extension-version.ps1 @@ -61,5 +61,6 @@ $updatedManifest = [regex]::Replace( 1 ) -Set-Content -LiteralPath $manifestPath -Value $updatedManifest -Encoding UTF8 +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +[System.IO.File]::WriteAllText($manifestPath, $updatedManifest, $utf8NoBom) Write-Output "Extension version updated: $currentVersion -> $nextVersion"