Compare commits

...

10 Commits

Author SHA1 Message Date
086cad88f5 Lançamento da versão 1.0.10 2026-08-28 10:39:51 -03:00
Paulo Porto
904e210ac8 Fechamento da versão 1.0.7 2026-05-27 11:14:30 -03:00
Paulo Porto
945da04db0 Ajustes visuais no botão de light/dark mode 2026-05-27 11:12:57 -03:00
Paulo Porto
cbf9550f0b Ajustes visuais no comportamento da função de mostrar/ocultar a sidebar 2026-05-27 11:08:37 -03:00
Paulo Porto
29a461ea63 Implementação de cache para melhoria de performance na navegação Manage time 2026-05-27 10:17:36 -03:00
Paulo Porto
946fab12ca Lançamento da versão 1.0.6 2026-05-26 11:30:44 -03:00
Paulo Porto
f38bae4479 Implementação do tooltip nas SRs do Manage time 2026-05-25 19:21:35 -03:00
Paulo Porto
c989088ab1 Reestruturação dos arquivos e pastas do código da extensão 2026-05-13 23:40:29 -03:00
Paulo Porto
f196067b21 Ajustes de usabilidade no Manage time 2026-05-08 17:41:46 -03:00
Paulo Porto
f574ba936c Correção na chamada das APIs via iframe 2026-05-07 02:14:06 -03:00
33 changed files with 14758 additions and 11898 deletions

161
README.md
View File

@@ -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

View File

@@ -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();
@@ -63,7 +59,6 @@ async function installSpaFrameHeaderRules() {
],
},
condition: {
initiatorDomains: ["eeho.fa.us2.oraclecloud.com"],
requestDomains: ["spa.oracle.com"],
resourceTypes: ["sub_frame"],
urlFilter: "||spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html",
@@ -181,7 +176,7 @@ async function executeSpaFrameFetch({ senderTabId, url, options = {} }) {
return executeSpaFrameScriptFetch(senderTabId, url, options);
}
async function ensureSpaFrameElementInTab(tabId) {
async function ensureSpaFrameElementInTab(tabId, options = {}) {
await chrome.scripting.executeScript({
target: { tabId, frameIds: [0] },
world: "MAIN",
@@ -189,8 +184,9 @@ async function ensureSpaFrameElementInTab(tabId) {
SPA_WORKBENCH_FRAME_HOST_ID,
SPA_WORKBENCH_FRAME_ID,
SPA_WORKBENCH_FRAME_URL,
Boolean(options.forceReload),
],
func: (hostId, frameId, frameUrl) => {
func: (hostId, frameId, frameUrl, forceReload) => {
const desiredParent = document.body || document.documentElement;
if (!desiredParent) {
@@ -250,7 +246,16 @@ async function ensureSpaFrameElementInTab(tabId) {
host.appendChild(frame);
}
if (!frame.src || frame.src === "about:blank" || frame.src !== frameUrl) {
if (
forceReload ||
!frame.src ||
frame.src === "about:blank" ||
frame.src !== frameUrl
) {
if (forceReload) {
frame.src = "about:blank";
}
frame.src = frameUrl;
}
@@ -262,8 +267,23 @@ async function ensureSpaFrameElementInTab(tabId) {
async function waitForSpaFramePresent(tabId) {
const startedAt = Date.now();
let lastFrames = "";
let lastEnsureAt = 0;
let forceReloaded = false;
while (Date.now() - startedAt < 45000) {
const elapsed = Date.now() - startedAt;
if (Date.now() - lastEnsureAt > 2500) {
await ensureSpaFrameElementInTab(tabId, {
forceReload: elapsed > 7000 && !forceReloaded,
});
lastEnsureAt = Date.now();
if (elapsed > 7000) {
forceReloaded = true;
}
}
const frames = await getFrameHints(tabId).catch(() => []);
const spaFrame = frames.find((frame) => isSpaFrameHint(frame));

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,16 @@
{
{
"manifest_version": 3,
"name": "Arch Panel Injector",
"version": "1.0.2",
"name": "Arch Panel",
"version": "1.0.10",
"description": "Insere o botao Arch panel no header do Oracle Workload Workbench.",
"permissions": [
"cookies",
"declarativeNetRequestWithHostAccess",
"scripting",
"tabs"
],
"host_permissions": [
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*",
"https://*.oraclecloud.com/*",
"https://eeho.fa.us2.oraclecloud.com/*",
"https://spa.oracle.com/*",
"https://worklist.oracle.com/*"
@@ -32,10 +32,38 @@
},
{
"matches": [
"https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html*",
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*"
"https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*",
"https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"
],
"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"
@@ -47,132 +75,10 @@
"page-bridge.js"
],
"matches": [
"https://*.oraclecloud.com/*",
"https://eeho.fa.us2.oraclecloud.com/*",
"https://spa.oracle.com/*"
]
}
]
}

View File

@@ -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,70 @@
}
}
function getAllowedTopMessageOrigin() {
const candidates = [];
if (window.top === window) {
return getSafeTargetOrigin();
}
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.
}
for (const origin of candidates) {
if (isAllowedTopMessageOrigin(origin)) {
return origin;
}
}
return "";
}
function isAllowedTopMessageOrigin(origin) {
if (ALLOWED_TOP_MESSAGE_ORIGINS.has(origin)) {
return true;
}
try {
const url = new URL(origin);
const hostname = url.hostname.toLowerCase();
return (
url.protocol === "https:" &&
hostname.endsWith(".oraclecloud.com") &&
hostname.includes(".fa.")
);
} catch {
return false;
}
}
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 +285,13 @@
return;
}
safePostMessage(
safePostToTop(
{
source: MESSAGE_SOURCE,
type: "ARCH_PANEL_WORKBENCH_AUTH_HEADERS",
url: normalizeUrl(url),
headers: authHeaders,
},
window.top,
"*"
}
);
}
@@ -356,7 +424,7 @@
const key = storage.key(index) || "";
const value = storage.getItem(key) || "";
if (key || value) {
if (isAuthStorageCandidate(key, value)) {
candidates.push([key, value]);
}
}
@@ -372,6 +440,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();

39
src/background/config.js Normal file
View File

@@ -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,
});
})();

653
src/content/export-html.js Normal file
View File

@@ -0,0 +1,653 @@
(() => {
function escapeJsonForInlineScript(json) {
return String(json || "")
.replace(/</g, "\\u003c")
.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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Arch Panel Dashboard</title>
<style>
:root {
--bg: #0f141b;
--panel: #171e27;
--panel-strong: #202a36;
--border: #33404f;
--text: #f4f7fb;
--text-muted: #a8b4c2;
--accent: #7dc6e7;
--accent-soft: rgba(125, 198, 231, 0.14);
--success: #61bf81;
--warning: #ef8d67;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: linear-gradient(180deg, #0d1218, #121923 45%, #10161e 100%);
color: var(--text);
font: 14px/1.45 "Segoe UI", Arial, sans-serif;
}
.shell {
max-width: 1480px;
margin: 0 auto;
padding: 28px;
}
.hero, .panel {
border: 1px solid var(--border);
background: var(--panel);
border-radius: 18px;
}
.hero {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 24px;
margin-bottom: 18px;
}
.eyebrow {
margin: 0 0 8px;
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
}
h1, h2, h3, p { margin: 0; }
h1 { font-size: 34px; line-height: 1.1; }
.subtitle { margin-top: 12px; color: var(--text-muted); max-width: 720px; }
.pill {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--text);
border: 1px solid rgba(255,255,255,.08);
font-weight: 600;
}
.grid {
display: grid;
gap: 16px;
}
.metrics { grid-template-columns: repeat(4, minmax(0,1fr)); margin-bottom: 18px; }
.metric, .boolean-card {
border: 1px solid var(--border);
background: var(--panel);
border-radius: 16px;
padding: 18px;
}
.metric-label { color: var(--text-muted); font-size: 12px; }
.metric-value { margin-top: 8px; font-size: 28px; font-weight: 700; }
.metric-detail { margin-top: 6px; color: var(--text-muted); font-size: 12px; }
.indicator-grid { grid-template-columns: repeat(4, minmax(0,1fr)); margin-bottom: 18px; }
.status-grid { grid-template-columns: repeat(2, minmax(0,1fr)); margin-bottom: 18px; }
.boolean-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; margin-top: 14px; }
.progress { display: grid; gap: 8px; margin-bottom: 12px; }
.progress-track {
display: flex;
width: 100%;
height: 10px;
overflow: hidden;
border-radius: 999px;
background: rgba(255,255,255,.06);
border: 1px solid var(--border);
}
.progress-segment { height: 100%; }
.progress-segment.is-success { background: linear-gradient(90deg, #7fd29a, #3fa35c); }
.progress-segment.is-danger { background: linear-gradient(90deg, #f2a28d, #d94f2b); }
.progress-labels {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
}
.boolean-card {
appearance: none;
color: var(--text);
text-align: left;
cursor: pointer;
}
.boolean-card strong { display: block; margin-top: 8px; font-size: 28px; }
.layout { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }
.panel { padding: 18px; }
.panel-head { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.panel-title { margin-top: 6px; font-size: 18px; }
table { width: 100%; border-collapse: collapse; }
.main-table { table-layout: fixed; }
.main-table col.col-opportunity { width: 14%; }
.main-table col.col-customer { width: 16%; }
.main-table col.col-workload { width: auto; }
.main-table col.col-acr { width: 11%; }
.main-table col.col-type { width: 11%; }
.main-table col.col-flag { width: 9%; }
th, td { padding: 12px 14px; border-bottom: 1px solid var(--border); font-size: 12px; white-space: nowrap; }
th { color: var(--text-muted); text-align: left; background: var(--panel-strong); }
td { color: var(--text); }
td.is-numeric, th.is-numeric { text-align: right; }
.main-table th:nth-child(3),
.main-table td:nth-child(3) {
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.main-table td:nth-child(3) a {
display: block;
}
.main-table th:nth-child(2),
.main-table td:nth-child(2) {
white-space: normal;
overflow-wrap: anywhere;
}
tr:hover td { background: rgba(255,255,255,.02); }
a { color: var(--accent); text-decoration: none; }
.status {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.status.true { color: #d9f9e4; background: rgba(97,191,129,.2); }
.status.false { color: #ffd8cc; background: rgba(239,141,103,.18); }
.row-stack { display: grid; gap: 12px; }
.row {
display: grid;
gap: 8px;
padding: 12px 0;
border-top: 1px solid var(--border);
}
.row:first-child { border-top: 0; padding-top: 0; }
.row-top {
display: flex;
justify-content: space-between;
gap: 12px;
}
.row-label { font-weight: 700; }
.row-sub { color: var(--text-muted); font-size: 12px; }
.bar { position: relative; height: 8px; border-radius: 999px; overflow: hidden; background: rgba(255,255,255,.08); }
.bar > span { position: absolute; inset: 0 auto 0 0; border-radius: inherit; background: linear-gradient(90deg, var(--accent), #4b8ce2); }
.sr-table td { white-space: normal; vertical-align: top; }
.sr-main { width: 78%; }
.sr-side { width: 22%; text-align: right; }
.sr-line { display: flex; flex-wrap: wrap; gap: 10px 14px; margin-top: 6px; }
.sr-line.head { margin-top: 0; }
.sr-customer { color: var(--text); font-size: 13px; font-weight: 700; }
.sr-moment { color: var(--text); font-size: 18px; font-weight: 700; }
.sr-exact, .sr-hours { margin-top: 6px; color: var(--text-muted); font-size: 12px; }
.empty {
padding: 18px;
border: 1px dashed var(--border);
border-radius: 8px;
color: var(--text-muted);
font-size: 13px;
}
.overlay {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(8, 12, 18, .78);
backdrop-filter: blur(10px);
}
.overlay.open { display: flex; }
.detail {
width: min(1480px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow: auto;
border: 1px solid var(--border);
background: var(--panel);
border-radius: 18px;
padding: 18px;
}
.detail-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-bottom: 12px;
}
.close {
appearance: none;
min-height: 34px;
padding: 0 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-strong);
color: var(--text);
cursor: pointer;
}
.detail-actions {
display: flex;
justify-content: flex-end;
position: sticky;
bottom: 0;
margin-top: 16px;
padding-top: 12px;
background: linear-gradient(180deg, rgba(23, 30, 39, 0), var(--panel) 35%);
}
.detail table { table-layout: fixed; }
.detail th, .detail td {
white-space: normal;
vertical-align: top;
overflow-wrap: anywhere;
}
.detail-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.sort-button {
appearance: none;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.sort-button.is-numeric {
width: 100%;
text-align: right;
}
.clickable {
appearance: none;
width: 100%;
color: inherit;
text-align: left;
cursor: pointer;
}
@media (max-width: 1100px) {
.metrics, .indicator-grid, .status-grid, .layout { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div id="app"></div>
<div id="detailOverlay" class="overlay"></div>
<script>
const payload = ${json};
const allowedTypes = ["SQL", "Pipeline", "Upside", "Forecast"];
const detailSortKeys = {
opportunityNumber: "opportunityNumber",
customerName: "customerName",
workload: "workload",
adjustedACR: "adjustedACR",
opportunityForecastTypeGroup: "opportunityForecastTypeGroup",
hasSR: "hasSR",
hasAction: "hasAction"
};
let detailState = null;
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function normalizeForecastType(value) {
const text = String(value || "").trim();
return text ? text.split(" -")[0].trim() || "Unknown" : "Unknown";
}
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 formatPercent(value) {
return new Intl.NumberFormat("en-US", {
maximumFractionDigits: 1
}).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 flattenWorkloads() {
return (payload.customers || []).flatMap((customer) =>
(customer.workloads || []).map((workload) => ({
...workload,
customerName: customer.name
}))
);
}
function buildSummary(workloads) {
const eligible = workloads
.map((workload) => ({
...workload,
opportunityForecastTypeGroup: normalizeForecastType(workload.opportunityForecastTypeGroup || workload.opportunityForecastType)
}))
.filter((workload) => allowedTypes.includes(workload.opportunityForecastTypeGroup))
.sort((a, b) => (b.adjustedACR || 0) - (a.adjustedACR || 0));
return {
eligible,
stageIndicators: allowedTypes.map((label) => {
const items = eligible.filter((workload) => workload.opportunityForecastTypeGroup === label);
return {
label,
count: items.length,
totalAcr: items.reduce((sum, item) => sum + (item.adjustedACR || 0), 0)
};
}),
sr: {
trueCount: eligible.filter((item) => item.hasSR).length,
falseCount: eligible.filter((item) => !item.hasSR).length
},
action: {
trueCount: eligible.filter((item) => item.hasAction).length,
falseCount: eligible.filter((item) => !item.hasAction).length
}
};
}
function opportunityLink(workload) {
if (!workload.opportunityId || !workload.opportunityNumber) {
return escapeHtml(workload.opportunityNumber || "Opportunity");
}
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 '<a href="' + href + '" target="_blank" rel="noreferrer">' + escapeHtml(workload.opportunityNumber) + '</a>';
}
function workloadLink(workload) {
const label = (workload.name || "Unnamed workload") + (workload.description ? " | " + workload.description : "");
if (!workload.workloadId) {
return escapeHtml(label);
}
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 '<a href="' + href + '" target="_blank" rel="noreferrer">' + escapeHtml(label) + '</a>';
}
function statusToken(value) {
const label = value === "true" ? "Sim" : "Não";
return '<span class="status ' + value + '">' + label + '</span>';
}
function renderTable(items) {
return '<table class="main-table"><colgroup><col class="col-opportunity"><col class="col-customer"><col class="col-workload"><col class="col-acr"><col class="col-type"><col class="col-flag"><col class="col-flag"></colgroup><thead><tr><th>Opportunity</th><th>Customer</th><th>Workload</th><th class="is-numeric">ACR</th><th>Type</th><th>Tem SR?</th><th>Tem Action?</th></tr></thead><tbody>' +
items.map((workload) => '<tr><td>' + opportunityLink(workload) + '</td><td>' + escapeHtml(workload.customerName || '') + '</td><td>' + workloadLink(workload) + '</td><td class="is-numeric">' + formatCurrency(workload.adjustedACR) + '</td><td>' + escapeHtml(workload.opportunityForecastTypeGroup) + '</td><td>' + statusToken(String(Boolean(workload.hasSR))) + '</td><td>' + statusToken(String(Boolean(workload.hasAction))) + '</td></tr>').join('') +
'</tbody></table>';
}
function getSortableValue(workload, sortKey) {
if (sortKey === detailSortKeys.opportunityNumber) {
return String(workload.opportunityNumber || "");
}
if (sortKey === detailSortKeys.customerName) {
return String(workload.customerName || "");
}
if (sortKey === detailSortKeys.workload) {
return String((workload.name || "") + "|" + (workload.description || ""));
}
if (sortKey === detailSortKeys.adjustedACR) {
return Number(workload.adjustedACR || 0);
}
if (sortKey === detailSortKeys.opportunityForecastTypeGroup) {
return String(workload.opportunityForecastTypeGroup || "");
}
if (sortKey === detailSortKeys.hasSR) {
return Boolean(workload.hasSR);
}
if (sortKey === detailSortKeys.hasAction) {
return Boolean(workload.hasAction);
}
return String(workload.name || "");
}
function compareSortableValues(left, right) {
if (typeof left === "number" || typeof right === "number") {
return Number(left || 0) - Number(right || 0);
}
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 sortItems(items, sortKey, sortDirection) {
const factor = sortDirection === "desc" ? -1 : 1;
return items.slice().sort((left, right) => {
const comparison = compareSortableValues(
getSortableValue(left, sortKey),
getSortableValue(right, sortKey)
);
if (comparison !== 0) {
return comparison * factor;
}
return compareSortableValues(
getSortableValue(left, detailSortKeys.workload),
getSortableValue(right, detailSortKeys.workload)
);
});
}
function renderSortButton(label, sortKey, isNumeric) {
const direction = detailState && detailState.sortKey === sortKey
? (detailState.sortDirection === "asc" ? " ↑" : " ↓")
: "";
return '<button class="sort-button' + (isNumeric ? ' is-numeric' : '') + '" data-sort-key="' + sortKey + '">' + label + direction + '</button>';
}
function renderDetailTable(items) {
return '<table><thead><tr><th>' + renderSortButton('Opportunity', detailSortKeys.opportunityNumber, false) + '</th><th>' + renderSortButton('Customer', detailSortKeys.customerName, false) + '</th><th>' + renderSortButton('Workload', detailSortKeys.workload, false) + '</th><th class="is-numeric">' + renderSortButton('ACR', detailSortKeys.adjustedACR, true) + '</th><th>' + renderSortButton('Type', detailSortKeys.opportunityForecastTypeGroup, false) + '</th><th>' + renderSortButton('HasSR', detailSortKeys.hasSR, false) + '</th><th>' + renderSortButton('HasAction', detailSortKeys.hasAction, false) + '</th></tr></thead><tbody>' +
items.map((workload) => '<tr><td>' + opportunityLink(workload) + '</td><td>' + escapeHtml(workload.customerName || '') + '</td><td>' + workloadLink(workload) + '</td><td class="is-numeric">' + formatCurrency(workload.adjustedACR) + '</td><td>' + escapeHtml(workload.opportunityForecastTypeGroup) + '</td><td>' + statusToken(String(Boolean(workload.hasSR))) + '</td><td>' + statusToken(String(Boolean(workload.hasAction))) + '</td></tr>').join('') +
'</tbody></table>';
}
function openDetail(title, label, items) {
const totalAcr = items.reduce((sum, item) => sum + (item.adjustedACR || 0), 0);
const overlay = document.getElementById("detailOverlay");
overlay.innerHTML = '<div class="detail"><div class="detail-head"><div><p class="eyebrow">' + label + '</p><h2>' + title + '</h2></div><button class="close" id="closeDetail">Close</button></div><p class="subtitle">' + formatWholeNumber(items.length) + ' workloads · Total (ACR): ' + formatCurrency(totalAcr) + '</p><div style="margin-top:16px">' + renderTable(items) + '</div></div>';
overlay.classList.add("open");
document.getElementById("closeDetail").addEventListener("click", () => overlay.classList.remove("open"));
overlay.onclick = (event) => {
if (event.target === overlay) {
overlay.classList.remove("open");
}
};
}
function renderSortButtonStateful(label, sortKey, isNumeric) {
const direction = detailState && detailState.sortKey === sortKey
? (detailState.sortDirection === "asc" ? " ^" : " v")
: "";
return '<button class="sort-button' + (isNumeric ? ' is-numeric' : '') + '" data-sort-key="' + sortKey + '">' + label + direction + '</button>';
}
function renderDetailTableStateful(items) {
return '<table><thead><tr><th>' + renderSortButtonStateful('Opportunity', detailSortKeys.opportunityNumber, false) + '</th><th>' + renderSortButtonStateful('Customer', detailSortKeys.customerName, false) + '</th><th>' + renderSortButtonStateful('Workload', detailSortKeys.workload, false) + '</th><th class="is-numeric">' + renderSortButtonStateful('ACR', detailSortKeys.adjustedACR, true) + '</th><th>' + renderSortButtonStateful('Type', detailSortKeys.opportunityForecastTypeGroup, false) + '</th><th>' + renderSortButtonStateful('Tem SR?', detailSortKeys.hasSR, false) + '</th><th>' + renderSortButtonStateful('Tem Action?', detailSortKeys.hasAction, false) + '</th></tr></thead><tbody>' +
items.map((workload) => '<tr><td>' + opportunityLink(workload) + '</td><td>' + escapeHtml(workload.customerName || '') + '</td><td>' + workloadLink(workload) + '</td><td class="is-numeric">' + formatCurrency(workload.adjustedACR) + '</td><td>' + escapeHtml(workload.opportunityForecastTypeGroup) + '</td><td>' + statusToken(String(Boolean(workload.hasSR))) + '</td><td>' + statusToken(String(Boolean(workload.hasAction))) + '</td></tr>').join('') +
'</tbody></table>';
}
function openDetailStateful(title, label, items) {
detailState = {
title: title,
label: label,
items: items.slice(),
sortKey: detailSortKeys.adjustedACR,
sortDirection: "desc"
};
renderDetailStateful();
}
function renderDetailStateful() {
if (!detailState) {
return;
}
const totalAcr = detailState.items.reduce((sum, item) => sum + (item.adjustedACR || 0), 0);
const sortedItems = sortItems(detailState.items, detailState.sortKey, detailState.sortDirection);
const overlay = document.getElementById("detailOverlay");
overlay.innerHTML = '<div class="detail"><div class="detail-head"><div><p class="eyebrow">' + detailState.label + '</p><h2>' + detailState.title + '</h2></div><button class="close" id="closeDetail">Fechar</button></div><p class="subtitle">' + formatWholeNumber(sortedItems.length) + ' workloads · Total (ACR): ' + formatCurrency(totalAcr) + '</p><div style="margin-top:16px">' + renderDetailTableStateful(sortedItems) + '</div><div class="detail-actions"><button class="close" id="closeDetailFooter">Fechar</button></div></div>';
overlay.classList.add("open");
const close = () => {
overlay.classList.remove("open");
detailState = null;
};
document.getElementById("closeDetail").addEventListener("click", close);
document.getElementById("closeDetailFooter").addEventListener("click", close);
overlay.querySelectorAll("[data-sort-key]").forEach((button) => {
button.addEventListener("click", () => {
const sortKey = button.getAttribute("data-sort-key");
const nextDirection = detailState.sortKey === sortKey && detailState.sortDirection === "asc"
? "desc"
: "asc";
detailState.sortKey = sortKey;
detailState.sortDirection = nextDirection;
renderDetailStateful();
});
});
overlay.onclick = (event) => {
if (event.target === overlay) {
close();
}
};
}
function render() {
const workloads = flattenWorkloads();
const summary = buildSummary(workloads);
const app = document.getElementById("app");
app.innerHTML = '<div class="shell">' +
'<section class="hero"><div><p class="eyebrow">Live Oracle dataset</p><h1>Arch panel export</h1><p class="subtitle">Generated from user, customer, workload, action and service request APIs.</p></div><div><span class="pill">' + formatCurrency(summary.eligible.reduce((sum, item) => sum + (item.adjustedACR || 0), 0)) + ' total eligible ACR</span></div></section>' +
'<section class="grid metrics">' +
'<article class="metric"><p class="metric-label">Total clients</p><p class="metric-value">' + formatWholeNumber(payload.customers.length) + '</p><p class="metric-detail">Loaded from listSummary</p></article>' +
'<article class="metric"><p class="metric-label">Total workloads</p><p class="metric-value">' + formatWholeNumber(workloads.length) + '</p><p class="metric-detail">Won workloads excluded upstream</p></article>' +
'<article class="metric"><p class="metric-label">Current user</p><p class="metric-value" style="font-size:18px">' + escapeHtml(payload.user.name || payload.user.userEmail) + '</p><p class="metric-detail">' + escapeHtml(payload.user.userEmail) + '</p></article>' +
'</section>' +
'<section class="grid indicator-grid">' +
summary.stageIndicators.map((indicator) => '<button class="metric clickable" data-kind="stage" data-value="' + indicator.label + '"><p class="metric-label">' + indicator.label + '</p><p class="metric-value">' + formatWholeNumber(indicator.count) + '</p><p class="metric-detail">Total (ACR): ' + formatCurrency(indicator.totalAcr) + '</p></button>').join('') +
'</section>' +
'<section class="grid status-grid">' +
'<article class="panel"><p class="eyebrow">Status de SRs</p><div class="progress"><div class="progress-track"><span class="progress-segment is-success" style="width:' + ((summary.sr.trueCount + summary.sr.falseCount) > 0 ? (summary.sr.trueCount / (summary.sr.trueCount + summary.sr.falseCount)) * 100 : 0) + '%"></span><span class="progress-segment is-danger" style="width:' + ((summary.sr.trueCount + summary.sr.falseCount) > 0 ? (summary.sr.falseCount / (summary.sr.trueCount + summary.sr.falseCount)) * 100 : 0) + '%"></span></div><div class="progress-labels"><span>Workloads com SR: ' + formatPercent((summary.sr.trueCount + summary.sr.falseCount) > 0 ? (summary.sr.trueCount / (summary.sr.trueCount + summary.sr.falseCount)) * 100 : 0) + '</span><span>Workloads sem SR: ' + formatPercent((summary.sr.trueCount + summary.sr.falseCount) > 0 ? (summary.sr.falseCount / (summary.sr.trueCount + summary.sr.falseCount)) * 100 : 0) + '</span></div></div><div class="boolean-grid"><button class="boolean-card" data-kind="sr" data-value="true"><span>Workloads com SR</span><strong>' + formatWholeNumber(summary.sr.trueCount) + '</strong></button><button class="boolean-card" data-kind="sr" data-value="false"><span>Workloads sem SR</span><strong>' + formatWholeNumber(summary.sr.falseCount) + '</strong></button></div></article>' +
'<article class="panel"><p class="eyebrow">Status de Consumption Plan/action</p><div class="progress"><div class="progress-track"><span class="progress-segment is-success" style="width:' + ((summary.action.trueCount + summary.action.falseCount) > 0 ? (summary.action.trueCount / (summary.action.trueCount + summary.action.falseCount)) * 100 : 0) + '%"></span><span class="progress-segment is-danger" style="width:' + ((summary.action.trueCount + summary.action.falseCount) > 0 ? (summary.action.falseCount / (summary.action.trueCount + summary.action.falseCount)) * 100 : 0) + '%"></span></div><div class="progress-labels"><span>Workloads com action: ' + formatPercent((summary.action.trueCount + summary.action.falseCount) > 0 ? (summary.action.trueCount / (summary.action.trueCount + summary.action.falseCount)) * 100 : 0) + '</span><span>Workloads sem action: ' + formatPercent((summary.action.trueCount + summary.action.falseCount) > 0 ? (summary.action.falseCount / (summary.action.trueCount + summary.action.falseCount)) * 100 : 0) + '</span></div></div><div class="boolean-grid"><button class="boolean-card" data-kind="action" data-value="true"><span>Workloads com action</span><strong>' + formatWholeNumber(summary.action.trueCount) + '</strong></button><button class="boolean-card" data-kind="action" data-value="false"><span>Workloads sem action</span><strong>' + formatWholeNumber(summary.action.falseCount) + '</strong></button></div></article>' +
'</section>' +
'</div>';
app.querySelectorAll("[data-kind]").forEach((element) => {
element.addEventListener("click", () => {
const kind = element.getAttribute("data-kind");
const value = element.getAttribute("data-value");
let items = [];
let title = "";
let label = "";
if (kind === "stage") {
items = summary.eligible.filter((item) => item.opportunityForecastTypeGroup === value);
label = "Opportunity type: " + value;
title = value + " workloads";
} else if (kind === "sr") {
const flag = value === "true";
items = summary.eligible.filter((item) => Boolean(item.hasSR) === flag);
label = "Status de SRs";
title = flag ? "Workloads com SR" : "Workloads sem SR";
} else if (kind === "action") {
const flag = value === "true";
items = summary.eligible.filter((item) => Boolean(item.hasAction) === flag);
label = "Status de Consumption Plan/action";
title = flag ? "Workloads com action" : "Workloads sem action";
}
openDetailStateful(title, label, items);
});
});
}
render();
</script>
</body>
</html>`;
}
globalThis.ArchPanelExportHtml = Object.freeze({
createExportHtml,
});
})();

146
src/content/hcm-page.js Normal file
View File

@@ -0,0 +1,146 @@
(() => {
function create({
HCM_MY_INFORMATION_APPS_GROUP_ID,
WORKLIST_ORIGIN,
WORKLIST_SAASUI_PATHNAME,
IDS,
cleanString,
isJwtExpired,
hcmTileTemplate,
onOpenArchPanel,
writeCachedWorklistToken,
}) {
let lastPersistedWorklistIframeUrl = "";
function syncHcmTile() {
const insertion = resolveHcmTileInsertion();
if (!insertion?.container) {
document.getElementById(IDS.hcmTile)?.remove();
return;
}
const tile = document.getElementById(IDS.hcmTile) || createHcmTile();
if (
tile.parentElement !== insertion.container ||
tile.nextElementSibling !== insertion.before
) {
insertion.container.insertBefore(tile, insertion.before || null);
}
}
function resolveHcmTileInsertion() {
const appsGroup = document.getElementById(HCM_MY_INFORMATION_APPS_GROUP_ID);
if (!appsGroup) {
return null;
}
const addTile = appsGroup.querySelector(
".flat-grid-cell.flat-grid-cell-addicon"
);
if (addTile?.parentElement && appsGroup.contains(addTile.parentElement)) {
return {
container: addTile.parentElement,
before: addTile,
};
}
return {
container: appsGroup,
before: null,
};
}
function syncWorklistIframeToken(options = {}) {
for (const frame of document.querySelectorAll("iframe")) {
const tokenPayload = extractWorklistIframeToken(frame);
if (!tokenPayload?.token || isWorklistTokenExpired(tokenPayload.token)) {
continue;
}
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 isWorklistTokenExpired(token) {
return typeof isJwtExpired === "function" && isJwtExpired(token, 120);
}
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,
});
})();

File diff suppressed because it is too large Load Diff

866
src/content/modal-events.js Normal file
View File

@@ -0,0 +1,866 @@
(() => {
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;
let timeManagementTooltipHideTimer = 0;
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;
}
if (
event.target instanceof HTMLInputElement &&
event.target.getAttribute("data-combobox-field")
) {
window.requestAnimationFrame(() => {
positionTimeManagementCombobox(event.target);
scrollSelectedTimeManagementComboboxOptionIntoView(event.target);
});
}
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;
}
if (
action === "time-management-combobox-option" &&
event.target?.closest?.(".arch-panel-extension-service-request-tooltip")
) {
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);
positionTimeManagementCombobox(target);
}
function handleOverlayFocusIn(event) {
const target = event.target;
if (
target instanceof HTMLInputElement &&
target.getAttribute("data-combobox-field")
) {
window.requestAnimationFrame(() => {
clearTimeManagementComboboxTooltipOptions(
target.closest(".arch-panel-extension-combobox")
);
hideTimeManagementServiceRequestTooltipPortal();
positionTimeManagementCombobox(target);
scrollSelectedTimeManagementComboboxOptionIntoView(target);
});
}
}
function handleOverlayPointerOver(event) {
const portal = event.target?.closest?.(
".arch-panel-extension-service-request-tooltip-portal"
);
if (portal instanceof HTMLElement) {
clearTimeManagementTooltipHideTimer();
return;
}
const option = event.target?.closest?.(
".arch-panel-extension-combobox-option.has-tooltip"
);
if (option instanceof HTMLElement) {
setActiveTimeManagementComboboxTooltipOption(option);
}
}
function handleOverlayPointerMove(event) {
if (
event.target?.closest?.(".arch-panel-extension-service-request-tooltip-portal")
) {
return;
}
const option = event.target?.closest?.(
".arch-panel-extension-combobox-option.has-tooltip"
);
if (option instanceof HTMLElement) {
setActiveTimeManagementComboboxTooltipOption(option);
}
}
function handleOverlayPointerOut(event) {
const portal = event.target?.closest?.(
".arch-panel-extension-service-request-tooltip-portal"
);
const nextTarget = event.relatedTarget;
if (portal instanceof HTMLElement) {
if (nextTarget instanceof Node && portal.contains(nextTarget)) {
return;
}
scheduleTimeManagementTooltipHide();
return;
}
const menu = event.target?.closest?.(
".arch-panel-extension-combobox-menu.has-service-request-tooltips"
);
if (!(menu instanceof HTMLElement)) {
return;
}
if (nextTarget instanceof Node && menu.contains(nextTarget)) {
return;
}
scheduleTimeManagementTooltipHide(menu);
}
function scheduleTimeManagementTooltipHide(menu = null) {
clearTimeManagementTooltipHideTimer();
timeManagementTooltipHideTimer = window.setTimeout(() => {
const portal = getTimeManagementServiceRequestTooltipPortal(false);
const hoveredOption = menu
? menu.querySelector(".arch-panel-extension-combobox-option.has-tooltip:hover")
: document.querySelector(".arch-panel-extension-combobox-option.has-tooltip:hover");
if (
!hoveredOption &&
!(portal instanceof HTMLElement && portal.matches(":hover"))
) {
clearTimeManagementComboboxTooltipOptions(menu || document.body);
hideTimeManagementServiceRequestTooltipPortal();
}
}, 500);
}
function clearTimeManagementTooltipHideTimer() {
if (timeManagementTooltipHideTimer) {
window.clearTimeout(timeManagementTooltipHideTimer);
timeManagementTooltipHideTimer = 0;
}
}
function handleOverlayScroll(event) {
const menu = event.target;
if (
!(menu instanceof HTMLElement) ||
!menu.classList.contains("arch-panel-extension-combobox-menu")
) {
return;
}
const activeOption = menu.querySelector(
".arch-panel-extension-combobox-option.has-tooltip.is-tooltip-active"
);
if (activeOption instanceof HTMLElement) {
positionTimeManagementComboboxOptionTooltip(activeOption);
}
}
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);
positionTimeManagementCombobox(target);
}
function positionTimeManagementCombobox(input) {
const combobox = input.closest(".arch-panel-extension-combobox");
const menu = combobox?.querySelector(".arch-panel-extension-combobox-menu");
if (!(combobox instanceof HTMLElement) || !(menu instanceof HTMLElement)) {
return;
}
const inputRect = input.getBoundingClientRect();
const margin = 16;
const gap = 6;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const spaceBelow = viewportHeight - inputRect.bottom - margin - gap;
const spaceAbove = inputRect.top - margin - gap;
const hasServiceRequestTooltips = menu.classList.contains(
"has-service-request-tooltips"
);
const openAbove = spaceBelow < 240 && spaceAbove > spaceBelow;
const availableHeight = Math.max(180, (openAbove ? spaceAbove : spaceBelow) - 12);
const desiredHeight = hasServiceRequestTooltips ? 430 : 260;
const menuWidth = hasServiceRequestTooltips
? Math.min(1120, Math.max(inputRect.width, viewportWidth - inputRect.left - margin))
: inputRect.width;
menu.style.top = openAbove ? "auto" : `calc(100% + ${gap}px)`;
menu.style.bottom = openAbove ? `calc(100% + ${gap}px)` : "auto";
menu.style.maxHeight = `${Math.min(desiredHeight, availableHeight)}px`;
menu.style.width = `${menuWidth}px`;
menu.style.maxWidth = `calc(100vw - ${Math.max(margin * 2, inputRect.left + margin)}px)`;
const activeOption = menu.querySelector(
".arch-panel-extension-combobox-option.has-tooltip.is-tooltip-active, .arch-panel-extension-combobox-option.has-tooltip:hover, .arch-panel-extension-combobox-option.has-tooltip:focus, .arch-panel-extension-combobox-option.has-tooltip.is-active"
);
if (activeOption instanceof HTMLElement) {
setActiveTimeManagementComboboxTooltipOption(activeOption);
}
}
function scrollSelectedTimeManagementComboboxOptionIntoView(input) {
const combobox = input.closest(".arch-panel-extension-combobox");
const menu = combobox?.querySelector(".arch-panel-extension-combobox-menu");
const selectedOption = menu?.querySelector(
".arch-panel-extension-combobox-option.is-selected:not([hidden])"
);
if (!(menu instanceof HTMLElement) || !(selectedOption instanceof HTMLElement)) {
return;
}
const targetScrollTop =
selectedOption.offsetTop -
Math.max(0, (menu.clientHeight - selectedOption.offsetHeight) / 2);
menu.scrollTop = Math.max(0, targetScrollTop);
}
function setActiveTimeManagementComboboxTooltipOption(option) {
const menu = option.closest(".arch-panel-extension-combobox-menu");
if (!(menu instanceof HTMLElement) || option.hidden) {
return;
}
clearTimeManagementComboboxTooltipOptions(menu, option);
option.classList.add("is-tooltip-active");
positionTimeManagementComboboxOptionTooltip(option);
}
function clearTimeManagementComboboxTooltipOptions(root, exceptOption = null) {
if (!(root instanceof Element)) {
return;
}
root
.querySelectorAll(".arch-panel-extension-combobox-option.is-tooltip-active")
.forEach((item) => {
if (item !== exceptOption) {
item.classList.remove("is-tooltip-active");
}
});
}
function positionTimeManagementComboboxOptionTooltip(option) {
const menu = option.closest(".arch-panel-extension-combobox-menu");
const tooltip = option.querySelector(
".arch-panel-extension-service-request-tooltip"
);
if (!(menu instanceof HTMLElement) || !(tooltip instanceof HTMLElement)) {
return;
}
const menuRect = menu.getBoundingClientRect();
const optionRect = option.getBoundingClientRect();
const padding = 10;
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const portal = getTimeManagementServiceRequestTooltipPortal();
if (!(portal instanceof HTMLElement)) {
return;
}
const tooltipWidth = Math.min(
420,
Math.max(320, Math.min(menuRect.width * 0.38, viewportWidth - padding * 2))
);
const reservedTextWidth = tooltipWidth + 28;
const maxTooltipHeight = Math.max(180, Math.min(380, viewportHeight - padding * 2));
const optionCenter = optionRect.top + optionRect.height / 2;
const availableRight = viewportWidth - optionRect.right - padding * 2;
const availableLeft = optionRect.left - padding * 2;
const verticalPadding = 12;
portal.innerHTML = tooltip.innerHTML;
portal.style.width = `${tooltipWidth}px`;
portal.style.maxHeight = `${maxTooltipHeight}px`;
portal.classList.add("is-visible");
const tooltipHeight = Math.min(
maxTooltipHeight,
Math.max(120, portal.getBoundingClientRect().height || portal.scrollHeight)
);
const preferredRight = Math.min(
menuRect.right - tooltipWidth - padding,
viewportWidth - tooltipWidth - padding
);
const preferredLeft = menuRect.left + padding;
const hasRoomOnRight = availableRight >= tooltipWidth;
const hasRoomOnLeft = availableLeft >= tooltipWidth;
const tooltipLeft = hasRoomOnRight
? optionRect.right + padding
: hasRoomOnLeft
? optionRect.left - padding - tooltipWidth
: Math.max(padding, Math.min(preferredRight, preferredLeft));
const tooltipTop = Math.max(
verticalPadding,
Math.min(
optionCenter - tooltipHeight / 2,
viewportHeight - tooltipHeight - verticalPadding
)
);
portal.style.top = `${tooltipTop}px`;
portal.style.left = `${tooltipLeft}px`;
portal.style.right = "auto";
portal.style.bottom = "auto";
portal.style.maxHeight = `${Math.min(maxTooltipHeight, viewportHeight - tooltipTop - verticalPadding)}px`;
menu.style.setProperty(
"--arch-service-request-tooltip-reserved-width",
`${reservedTextWidth}px`
);
}
function getTimeManagementServiceRequestTooltipPortal(createIfMissing = true) {
const overlay = document.getElementById(globalThis.ArchPanelConfig.IDS.overlay);
let portal = overlay?.querySelector(
".arch-panel-extension-service-request-tooltip-portal"
);
if (!portal && createIfMissing && overlay) {
portal = document.createElement("div");
portal.className =
"arch-panel-extension-service-request-tooltip arch-panel-extension-service-request-tooltip-portal";
portal.setAttribute("role", "tooltip");
overlay.appendChild(portal);
}
return portal;
}
function hideTimeManagementServiceRequestTooltipPortal() {
const portal = getTimeManagementServiceRequestTooltipPortal(false);
if (portal instanceof HTMLElement) {
portal.classList.remove("is-visible");
}
}
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,
handleOverlayFocusIn,
handleOverlayPointerOver,
handleOverlayPointerMove,
handleOverlayPointerOut,
handleOverlayScroll,
});
}
globalThis.ArchPanelModalEvents = Object.freeze({
create,
});
})();

328
src/content/shell-view.js Normal file
View File

@@ -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,
});
})();

2818
src/content/styles.css Normal file

File diff suppressed because it is too large Load Diff

15
src/content/templates.js Normal file
View File

@@ -0,0 +1,15 @@
(() => {
function hcmTile(hcmTileId) {
return `
<div class="flat-grid-cell" id="${hcmTileId}">
<div id="c_75628d297d10409585cc1d40bb36f59d" class="app-nav-item" filmstrip="Arch Panel" page="undefined" index="0" type="subcluster" title="Arch Panel" group="groupNode_sales" destinationurl="#" targetframe="_self" isdesturlexist="true" role="presentation">
<svg viewBox="0 0 48 48" style="fill:currentColor" class="svg-nav suiicon svg-bkgd11" data-icon="navi_dashboard" role="presentation" focusable="false"><path class="svg-shortcut" d="M28 42.5l-3 2.7v-1.7c-.4 0-1.4 0-2.5.6-1.3 1-1.5 1.6-1.5 1.6s-.4-1.2.8-2.7c1.2-1.6 2.6-1.7 3.2-1.6v-1.6l3 2.7z"></path><path class="svg-cluster" d="M28.5 41.3c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2zm-4 0c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2c-.7 0-1.2-.5-1.2-1.2s.5-1.2 1.2-1.2zm-4 0c.7 0 1.2.5 1.2 1.2s-.5 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2z"></path><path class="svg-icon18" d="M38 16H10v18.8c0 .7.5 1.2 1 1.2h26c.7 0 1.2-.5 1.2-1.2V16zm-9 3.2c0-.7.5-1.2 1-1.2h5c.5 0 1 .5 1 1.2v5.6c0 .7-.5 1.2-1 1.2h-5c-.5 0-1-.5-1-1.2v-5.6zM21 33c0 .6-.3 1-1 1h-7c-.7 0-1-.5-1-1.2V19.2c0-.7.5-1.2 1.2-1.2h6.6c.7 0 1.2.4 1.2 1v14zm1-13.8c0-.7.5-1.2 1-1.2h4c.5 0 1 .5 1 1.2v5.6c0 .7-.5 1.2-1 1.2h-4c-.5 0-1-.5-1-1.2v-5.6zM33 33H22v-2h11v2zm3-3H22v-2h14v2z"></path><path class="svg-icon14" d="M30 25v-5h1v4h4v1h-5zm4-5h1v3h-1v-3zm-2 1h1v2h-1v-2z"></path><path class="svg-icon03" d="M23 20h4v1h-4v-1zm3 4h-3v-1h3v1z"></path><path class="svg-icon04" d="M17 20c1 0 2 1 2 2h-2v-2zm-1 5c-1 0-2-1.2-2-2 0-1 1-2 2-2v2h2c0 1-1 2-2 2zm3 4h-5v-1h5v1zm0 3h-5v-1h5v1z"></path><path class="svg-icon05" d="M11 12h26c.7 0 1.2.6 1.2 1.2V15H10v-1.8c0-.6.5-1.2 1-1.2z"></path><path class="svg-outline" d="M38.1 35.76H9.92a1.16 1.16 0 0 1-1.15-1.17l.01-18.78h30.47V34.6a1.16 1.16 0 0 1-1.15 1.17zM23.9 19.31h2.53a1.15 1.15 0 0 1 1.13 1.17v3.45a1.15 1.15 0 0 1-1.13 1.17h-2.52a1.15 1.15 0 0 1-1.14-1.17v-3.45a1.15 1.15 0 0 1 1.14-1.17zm8.2.03h2.55a1.15 1.15 0 0 1 1.12 1.17v3.42a1.15 1.15 0 0 1-1.12 1.17H32.1a1.15 1.15 0 0 1-1.13-1.17v-3.42a1.15 1.15 0 0 1 1.13-1.17zm-18.6-.03h4.6a1.04 1.04 0 0 1 1.18 1v12.07a.9.9 0 0 1-1 1h-4.97a1.03 1.03 0 0 1-1-1.17V20.48a1.18 1.18 0 0 1 1.19-1.17z"></path><path class="svg-outline" d="M9.93 12.3H38.1a1.21 1.21 0 0 1 1.16 1.23l.02 2.28H8.78l-.01-2.28a1.21 1.21 0 0 1 1.16-1.23zM36 28.66H22m11 3.46H22"></path></svg>
<a id="c_75628d297d10409585cc1d40bb36f48d_0" class="app-nav-label flat-grid-nav-label" href="#">Arch Panel</a>
</div>
</div>`;
}
globalThis.ArchPanelTemplates = Object.freeze({
hcmTile,
});
})();

File diff suppressed because it is too large Load Diff

216
src/domain/ramp-forecast.js Normal file
View File

@@ -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,
});
})();

View File

@@ -0,0 +1,985 @@
(() => {
function create(dependencies) {
const {
cleanString,
normalizeString,
firstNonEmptyString,
extractArray,
getFieldValue,
flattenDisplayRecord,
toNumber,
parseTimeEntryDate,
getLocalDateKey,
getTimeManagementDayKey,
getTimeManagementWeekdayName,
createTimeManagementRowSignature,
} = dependencies;
const ACTIVITY_REQUEST_DETAIL_FIELDS = [
"SrNumber",
"ProblemDescription",
"AccountPartyUniqueName",
"ServiceNew_c",
"OptyOwner_c",
"OptyName_c",
"OptyNumber_c",
"ReportedByPartyUniqueName",
"StatusCdMeaning",
"CountryText_c",
"AssigneePersonName",
"OptyStatus_c",
];
const ACTIVITY_REQUEST_DETAIL_LABELS = {
SrNumber: "SR Number",
ProblemDescription: "Problem Description",
AccountPartyUniqueName: "Account",
ServiceNew_c: "Service",
OptyOwner_c: "Opportunity Owner",
OptyName_c: "Opportunity",
OptyNumber_c: "Opportunity Number",
ReportedByPartyUniqueName: "Reported By",
StatusCdMeaning: "Status",
CountryText_c: "Country",
AssigneePersonName: "Assignee",
OptyStatus_c: "Opportunity Status",
};
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),
...extractArray(srPayload, ["items", "data", "results", "content"]).map(
(item) => getFieldValue(item, ["SrNumber", "srNumber"])
),
];
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 = getActivityRequestItemsBySr(payload);
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 normalizeActivityRequestDetails(payload, serviceRequests) {
const detailsBySr = {};
const activityItemsBySr = getActivityRequestItemsBySr(payload);
const srNumbers = getActivityRequestDetailSrNumbers(
activityItemsBySr,
serviceRequests
);
for (const srNumber of srNumbers) {
const srKey = normalizeString(srNumber);
const activityItem = activityItemsBySr.get(srKey);
const details = normalizeActivityRequestDetailRows(activityItem);
if (details.length) {
detailsBySr[srNumber] = details;
}
}
return detailsBySr;
}
function getActivityRequestItemsBySr(payload) {
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);
}
}
return activityItemsBySr;
}
function getActivityRequestDetailSrNumbers(activityItemsBySr, serviceRequests) {
const seen = new Set();
return [
...(serviceRequests || []).map((item) => cleanString(item?.srNumber)),
...Array.from(activityItemsBySr.values()).map((item) =>
cleanString(getFieldValue(item, ["SrNumber", "srNumber"]))
),
].filter((srNumber) => {
const key = normalizeString(srNumber);
if (!key || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function normalizeActivityRequestDetailRows(item) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return [];
}
const seen = new Set();
const preferredRows = ACTIVITY_REQUEST_DETAIL_FIELDS.map((fieldName) =>
createActivityRequestDetailRow(fieldName, getActivityRequestDetailValue(item, fieldName))
).filter(Boolean);
return preferredRows.filter((row) => {
const key = normalizeActivityRequestDetailKey(row.key);
if (!key || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function createActivityRequestDetailRow(fieldName, value) {
if (isActivityRequestDetailExcludedKey(fieldName) || isObjectLike(value)) {
return null;
}
const detailValue = cleanString(value);
if (!detailValue) {
return null;
}
return {
key: cleanString(fieldName),
label: formatActivityRequestDetailLabel(fieldName),
value: detailValue,
};
}
function getActivityRequestDetailValue(item, fieldName) {
const lowerFirstFieldName = `${fieldName.charAt(0).toLowerCase()}${fieldName.slice(1)}`;
return getFieldValue(item, [fieldName, lowerFirstFieldName]);
}
function isActivityRequestDetailExcludedKey(fieldName) {
return normalizeActivityRequestDetailKey(fieldName).includes("SRACTIVITIESC");
}
function normalizeActivityRequestDetailKey(fieldName) {
return normalizeString(fieldName).replace(/[^A-Z0-9]/g, "");
}
function isObjectLike(value) {
return value && typeof value === "object";
}
function formatActivityRequestDetailLabel(fieldName) {
const cleanFieldName = cleanString(fieldName);
const directLabel = ACTIVITY_REQUEST_DETAIL_LABELS[cleanFieldName];
if (directLabel) {
return directLabel;
}
return cleanFieldName
.split(".")
.pop()
.replace(/_c$/i, "")
.replace(/_/g, " ")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.trim();
}
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,
normalizeActivityRequestDetails,
normalizeTaskTypeRequest,
getTimeManagementTaskTypeOptions,
getTimeManagementActivityOptions,
findTimeManagementServiceRequest,
findTimeManagementServiceRequestByNumber,
findOptionValue,
findOptionLabel,
normalizeTimeEntriesSummary,
groupTimeEntriesByResourceName,
getTimeEntryTimeSpent,
getTimeEntryActivity,
getTimeEntryTaskType,
};
}
globalThis.ArchPanelTimeManagementData = Object.freeze({
create,
});
})();

View File

@@ -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,
});
})();

View File

@@ -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,
});
})();

View File

@@ -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,
});
})();

View File

@@ -0,0 +1,108 @@
(() => {
function create({
CACHE,
storageService,
normalizeCachedExportPayload,
cleanString,
isJwtExpired,
}) {
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 || isCachedWorklistTokenExpired(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."
);
const token = cleanString(record?.token);
return isCachedWorklistTokenExpired(token) ? "" : token;
}
function isCachedWorklistTokenExpired(token) {
return typeof isJwtExpired === "function" && isJwtExpired(token, 120);
}
return Object.freeze({
readCachedDataset,
writeCachedDataset,
readCachedTaskTypePayload,
writeCachedTaskTypePayload,
writeCachedWorklistToken,
readCachedWorklistToken,
});
}
globalThis.ArchPanelCacheRepository = Object.freeze({
create,
});
})();

View File

@@ -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,
});
})();

View File

@@ -0,0 +1,467 @@
(() => {
function create(dependencies) {
const {
TARGET_ORIGIN,
API_ENDPOINTS,
fetchJson,
extractArray,
cleanString,
firstNonEmptyString,
getFieldValue,
decodeJwtPayload,
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 });
return normalizeCurrentUserPayload(payload, "Worklist user");
}
async function fetchSpaCurrentUser() {
const payload = await fetchJson(API_ENDPOINTS.currentUser);
return normalizeCurrentUserPayload(payload, "SPA current user");
}
function normalizeCurrentUserPayload(payload, sourceLabel) {
const userPayload = normalizeCurrentUserCandidate(payload);
const firstName = cleanString(
getFieldValue(userPayload, [
"FirstName",
"firstName",
"GivenName",
"givenName",
"User.FirstName",
])
);
const lastName = cleanString(
getFieldValue(userPayload, [
"LastName",
"lastName",
"FamilyName",
"familyName",
"User.LastName",
])
);
const userEmail = resolveUserEmail(payload, userPayload);
const displayName = firstNonEmptyString([
`${firstName} ${lastName}`.trim(),
userPayload?.DisplayName,
userPayload?.displayName,
userPayload?.FullName,
userPayload?.fullName,
userPayload?.Name,
userPayload?.name,
userEmail,
]);
if (!userEmail) {
const userKeys =
userPayload && typeof userPayload === "object"
? Object.keys(userPayload).join(", ")
: "";
throw new Error(
`Unable to resolve userEmail from ${sourceLabel} response. Available top-level keys: ${Object.keys(
payload || {}
).join(", ")}${userKeys ? ` | User keys: ${userKeys}` : ""}`
);
}
return {
userEmail,
name: displayName || userEmail,
avatar: resolveUserAvatar(payload, userPayload),
administrator: Boolean(
getFieldValue(userPayload, [
"Administrator",
"administrator",
"User.Administrator",
])
),
};
}
function normalizeCurrentUserCandidate(payload) {
const candidate =
getObjectCandidate(payload?.User) ??
getObjectCandidate(payload?.user) ??
getObjectCandidate(payload?.currentUser) ??
getObjectCandidate(payload?.CurrentUser) ??
getObjectCandidate(payload?.data) ??
getObjectCandidate(payload?.result) ??
getObjectCandidate(payload?.items?.[0]) ??
payload;
if (Array.isArray(candidate)) {
return candidate[0] || {};
}
const nestedUser =
getObjectCandidate(candidate?.User) ?? getObjectCandidate(candidate?.user);
if (nestedUser) {
return normalizeCurrentUserCandidate(nestedUser);
}
return candidate || {};
}
function getObjectCandidate(value) {
return value && typeof value === "object" ? value : null;
}
function resolveUserEmail(payload, userPayload) {
return firstEmailLikeString([
getFieldValue(userPayload, [
"Email",
"email",
"EmailAddress",
"emailAddress",
"User.Email",
"User.email",
"User.EmailAddress",
"UserName",
"userName",
"username",
"mail",
"Mail",
"userEmail",
"UserEmail",
"PrimaryEmail.EmailAddress",
"primaryEmail.emailAddress",
"ResourceEmailAddress",
"resourceEmailAddress",
"id",
"Id",
]),
...getJwtEmailCandidates(payload?.token),
...getJwtEmailCandidates(payload?.accessToken),
...getJwtEmailCandidates(payload?.idToken),
...getJwtEmailCandidates(userPayload?.token),
]);
}
function resolveUserAvatar(payload, userPayload) {
return firstNonEmptyString([
getFieldValue(userPayload, [
"ImageURL",
"imageUrl",
"imageURL",
"User.ImageURL",
"Picture",
"picture",
"avatar",
"Avatar",
"avatarUrl",
"avatarURL",
"photo",
"photoUrl",
"photoURL",
"profileImageUrl",
"profileImageURL",
"thumbnailUrl",
"thumbnailURL",
"image",
"userImage",
"userImageUrl",
"profile.picture",
"profile.avatar",
"profile.photoUrl",
]),
...getJwtAvatarCandidates(payload?.token),
...getJwtAvatarCandidates(payload?.accessToken),
...getJwtAvatarCandidates(payload?.idToken),
...getJwtAvatarCandidates(userPayload?.token),
]);
}
function getJwtAvatarCandidates(token) {
const tokenPayload = decodeTokenPayload(token);
if (!tokenPayload) {
return [];
}
return [
getFieldValue(tokenPayload, [
"picture",
"avatar",
"avatar_url",
"image",
"photo",
"photo_url",
"thumbnail",
"thumbnail_url",
]),
];
}
function getJwtEmailCandidates(token) {
const tokenPayload = decodeTokenPayload(token);
if (!tokenPayload) {
return [];
}
return [
getFieldValue(tokenPayload, [
"email",
"Email",
"sub",
"prn",
"upn",
"user_name",
"username",
"preferred_username",
"unique_name",
"mail",
"userEmail",
"UserEmail",
]),
];
}
function decodeTokenPayload(token) {
const normalizedToken = cleanTokenValue(token);
if (!normalizedToken || typeof decodeJwtPayload !== "function") {
return null;
}
return decodeJwtPayload(normalizedToken);
}
function cleanTokenValue(value) {
const normalized = cleanString(value);
if (!normalized) {
return "";
}
return normalized.replace(/^Bearer\s+/i, "");
}
function firstEmailLikeString(values) {
for (const value of values) {
const normalized = cleanString(value);
if (/@/.test(normalized)) {
return normalized;
}
}
return "";
}
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,
fetchSpaCurrentUser,
fetchAllCustomers,
fetchCustomerWorkloads,
fetchWorkloadActions,
fetchWorkloadServiceRequests,
createWorkloadAction,
updateWorkloadForecast,
};
}
globalThis.ArchPanelWorkbenchRepository = Object.freeze({
create,
});
})();

View File

@@ -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,
});
})();

View File

@@ -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,
});
})();

View File

@@ -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,
});
})();

View File

@@ -0,0 +1,757 @@
(() => {
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 ${redactUrlForError(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 ${redactUrlForError(
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 ${redactUrlForError(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: ${redactUrlForError(debugUrl)}` : "",
debugFrameUrl ? `frame: ${redactUrlForError(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 ${redactUrlForError(
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 ${redactUrlForError(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: ${redactUrlForError(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 ${redactUrlForError(
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 redactUrlForError(value) {
const normalized = cleanString(value);
if (!normalized) {
return "";
}
try {
const parsedUrl = new URL(normalized, window.location.origin);
const sensitiveQueryPattern =
/(token|authorization|auth|jwt|id_token|access_token)/i;
for (const key of Array.from(parsedUrl.searchParams.keys())) {
if (sensitiveQueryPattern.test(key)) {
parsedUrl.searchParams.set(key, "[redacted]");
}
}
const redactedUrl = parsedUrl.toString();
if (
parsedUrl.origin === window.location.origin &&
!/^[a-z][a-z0-9+.-]*:/i.test(normalized)
) {
return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
}
return redactedUrl;
} catch {
return normalized.replace(
/([?&][^=\s|]*(?:token|authorization|auth|jwt|id_token|access_token)[^=\s|]*=)[^&\s|]+/gi,
"$1[redacted]"
);
}
}
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,
});
})();

192
src/shared/config.js Normal file
View File

@@ -0,0 +1,192 @@
(() => {
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_PATHNAMES = Object.freeze([
"/hcmUI/faces/FuseWelcome",
"/fscmUI/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_PATHNAMES,
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,
});
})();

514
src/shared/utils.js Normal file
View File

@@ -0,0 +1,514 @@
(() => {
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 decodeJwtPayload(token) {
const normalizedToken = cleanString(token);
if (
!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(
normalizedToken
)
) {
return null;
}
try {
const payloadPart = normalizedToken.split(".")[1];
const base64 = payloadPart.replace(/-/g, "+").replace(/_/g, "/");
const paddedBase64 = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
const json = decodeURIComponent(
Array.from(atob(paddedBase64), (char) => {
return `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`;
}).join("")
);
return JSON.parse(json);
} catch {
return null;
}
}
function getJwtExpiresAt(token) {
const payload = decodeJwtPayload(token);
const expiresAtSeconds = Number(payload?.exp);
return Number.isFinite(expiresAtSeconds) && expiresAtSeconds > 0
? expiresAtSeconds * 1000
: 0;
}
function isJwtExpired(token, skewSeconds = 60) {
const expiresAt = getJwtExpiresAt(token);
if (!expiresAt) {
return false;
}
const skewMilliseconds = Math.max(Number(skewSeconds) || 0, 0) * 1000;
return expiresAt <= Date.now() + skewMilliseconds;
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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,
decodeJwtPayload,
getJwtExpiresAt,
isJwtExpired,
getErrorMessage,
escapeHtml,
cssEscape,
});
})();

731
src/views/dashboard-view.js Normal file
View File

@@ -0,0 +1,731 @@
(() => {
function create(dependencies) {
const {
appState,
buildWorkloadStartCalendar,
hasRampForecastMismatch,
renderOpportunityLink,
renderWorkloadLink,
escapeHtml,
cleanString,
formatWholeNumber,
formatCurrency,
formatPercent,
formatProgressPercent,
formatDate,
} = dependencies;
function renderMainContent() {
if (appState.status === "loading") {
return `
<main class="arch-panel-extension-main-shell">
<section class="arch-panel-extension-state-card">
<p class="arch-panel-extension-state-eyebrow">Loading data</p>
<h2 class="arch-panel-extension-state-title">Building the workload dataset</h2>
<p class="arch-panel-extension-state-copy">${escapeHtml(appState.loadingMessage)}</p>
<div
class="arch-panel-extension-loading-progress"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="${Math.round(appState.loadingProgress || 0)}"
>
<div class="arch-panel-extension-loading-progress-meta">
<span>Overall progress</span>
</div>
<div class="arch-panel-extension-loading-progress-track">
<div
class="arch-panel-extension-loading-progress-fill"
style="width: 0%;"
></div>
<span class="arch-panel-extension-loading-progress-value">
${formatProgressPercent(appState.loadingProgress || 0)}
</span>
</div>
</div>
</section>
</main>
`;
}
if (appState.status === "error") {
return `
<main class="arch-panel-extension-main-shell">
<section class="arch-panel-extension-state-card is-error">
<p class="arch-panel-extension-state-eyebrow">Request failed</p>
<h2 class="arch-panel-extension-state-title">Unable to assemble the dashboard</h2>
<p class="arch-panel-extension-state-copy">${escapeHtml(appState.errorMessage)}</p>
<div class="arch-panel-extension-state-actions">
<button type="button" class="arch-panel-extension-utility-button" data-action="refresh">
Retry
</button>
</div>
</section>
</main>
`;
}
if (!appState.dataset) {
return `
<main class="arch-panel-extension-main-shell">
<section class="arch-panel-extension-state-card">
<p class="arch-panel-extension-state-eyebrow">Ready</p>
<h2 class="arch-panel-extension-state-title">Start the live data extraction</h2>
<p class="arch-panel-extension-state-copy">
No cached snapshot is available yet. Use Refresh data to fetch the current user, customers, workloads, actions and service requests.
</p>
<div class="arch-panel-extension-state-actions">
<button type="button" class="arch-panel-extension-utility-button" data-action="refresh">
Refresh data
</button>
</div>
</section>
</main>
`;
}
const { user, summary } = appState.dataset;
return `
<aside class="arch-panel-extension-sidebar${appState.sidebarCollapsed ? " is-collapsed" : ""}" aria-label="Dataset summary">
<button
type="button"
class="arch-panel-extension-sidebar-toggle"
data-action="toggle-sidebar"
aria-label="${appState.sidebarCollapsed ? "Show sidebar" : "Hide sidebar"}"
aria-expanded="${appState.sidebarCollapsed ? "false" : "true"}"
title="${appState.sidebarCollapsed ? "Show sidebar" : "Hide sidebar"}"
>
<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false">
${
appState.sidebarCollapsed
? `<path d="M7.22 15.78a.75.75 0 0 1 0-1.06L11.94 10 7.22 5.28a.75.75 0 0 1 1.06-1.06l5.25 5.25a.75.75 0 0 1 0 1.06l-5.25 5.25a.75.75 0 0 1-1.06 0Z"></path>`
: `<path d="M12.78 4.22a.75.75 0 0 1 0 1.06L8.06 10l4.72 4.72a.75.75 0 1 1-1.06 1.06l-5.25-5.25a.75.75 0 0 1 0-1.06l5.25-5.25a.75.75 0 0 1 1.06 0Z"></path>`
}
</svg>
</button>
<div class="arch-panel-extension-sidebar-group">
<p class="arch-panel-extension-sidebar-label">Current user</p>
${renderCurrentUserSidebar(user)}
</div>
<div class="arch-panel-extension-sidebar-group">
<p class="arch-panel-extension-sidebar-label">Exports</p>
<button type="button" class="arch-panel-extension-sidebar-button" data-action="download-json">
Download JSON
</button>
<button type="button" class="arch-panel-extension-sidebar-button" data-action="download-html">
Download HTML
</button>
<button type="button" class="arch-panel-extension-sidebar-button" data-action="refresh">
Refresh data
</button>
</div>
<div class="arch-panel-extension-sidebar-group">
<p class="arch-panel-extension-sidebar-label">Coverage</p>
<div class="arch-panel-extension-sidebar-data">
<span>Customers: ${formatWholeNumber(summary.totalCustomers)}</span>
<span>Workloads: ${formatWholeNumber(summary.totalWorkloads)}</span>
<span>Pending SRs: ${formatWholeNumber(summary.pendingServiceRequests.length)}</span>
</div>
</div>
<div class="arch-panel-extension-sidebar-group">
<p class="arch-panel-extension-sidebar-label">Last refresh</p>
<div class="arch-panel-extension-sidebar-data">
<strong>${escapeHtml(summary.generatedAtLabel || "-")}</strong>
<span>Updated by Refresh data</span>
</div>
</div>
<div class="arch-panel-extension-sidebar-group">
<p class="arch-panel-extension-sidebar-label">Extension version</p>
<div class="arch-panel-extension-sidebar-data">
<span>v${escapeHtml(getExtensionVersion())}</span>
</div>
</div>
</aside>
<main class="arch-panel-extension-main-shell">
<section class="arch-panel-extension-summary-row" aria-label="Overview metrics">
${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")}
</section>
<section class="arch-panel-extension-indicator-grid" aria-label="Opportunity indicators">
${summary.stageIndicators.map((indicator) => renderStageIndicator(indicator)).join("")}
</section>
<section class="arch-panel-extension-status-grid" aria-label="SR and action status indicators">
${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"
)}
</section>
${renderSrHoursAndCalendarSection(summary)}
</main>
`;
}
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 `
<div class="arch-panel-extension-current-user-card">
${
avatar
? `<img
class="arch-panel-extension-current-user-avatar"
src="${escapeHtml(avatar)}"
alt="${escapeHtml(displayName ? `${displayName} avatar` : "User avatar")}"
loading="lazy"
referrerpolicy="no-referrer"
>`
: `<span class="arch-panel-extension-current-user-avatar is-fallback" aria-hidden="true">${escapeHtml(initials)}</span>`
}
<div class="arch-panel-extension-sidebar-data">
<strong>${escapeHtml(displayName || userEmail)}</strong>
<span>${escapeHtml(userEmail)}</span>
</div>
</div>
`;
}
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 `
<article class="arch-panel-extension-summary-card">
<p class="arch-panel-extension-summary-label">${escapeHtml(label)}</p>
<p class="arch-panel-extension-summary-value">${escapeHtml(value)}</p>
<p class="arch-panel-extension-summary-detail">${escapeHtml(detail)}</p>
</article>
`;
}
function renderStageIndicator(indicator) {
return `
<button
type="button"
class="arch-panel-extension-summary-card arch-panel-extension-summary-card-button"
data-action="open-detail"
data-detail-scope="stage"
data-detail-value="${escapeHtml(indicator.label)}"
>
<p class="arch-panel-extension-summary-label">${escapeHtml(indicator.label)}</p>
<p class="arch-panel-extension-summary-value">${formatWholeNumber(indicator.count)}</p>
<p class="arch-panel-extension-summary-detail">Total (ACR): ${formatCurrency(indicator.totalAcr)}</p>
</button>
`;
}
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 `
<article class="arch-panel-extension-panel">
<header class="arch-panel-extension-panel-head">
<div>
<p class="arch-panel-extension-panel-label">${escapeHtml(title)}</p>
</div>
</header>
<div class="arch-panel-extension-progress">
<div class="arch-panel-extension-progress-track">
<span
class="arch-panel-extension-progress-segment is-success"
style="width: ${truePercent}%;"
></span>
<span
class="arch-panel-extension-progress-segment is-danger"
style="width: ${falsePercent}%;"
></span>
</div>
<div class="arch-panel-extension-progress-labels">
<span>${escapeHtml(trueLabel)}: ${formatPercent(truePercent)}</span>
<span>${escapeHtml(falseLabel)}: ${formatPercent(falsePercent)}</span>
</div>
</div>
<div class="arch-panel-extension-boolean-grid">
<button
type="button"
class="arch-panel-extension-summary-card arch-panel-extension-summary-card-button"
data-action="open-detail"
data-detail-scope="${escapeHtml(scope)}"
data-detail-value="true"
>
<p class="arch-panel-extension-summary-label">${escapeHtml(trueLabel)}</p>
<p class="arch-panel-extension-summary-value">${formatWholeNumber(trueCount)}</p>
<p class="arch-panel-extension-summary-detail">${formatPercent(truePercent)}</p>
</button>
<button
type="button"
class="arch-panel-extension-summary-card arch-panel-extension-summary-card-button"
data-action="open-detail"
data-detail-scope="${escapeHtml(scope)}"
data-detail-value="false"
>
<p class="arch-panel-extension-summary-label">${escapeHtml(falseLabel)}</p>
<p class="arch-panel-extension-summary-value">${formatWholeNumber(falseCount)}</p>
<p class="arch-panel-extension-summary-detail">${formatPercent(falsePercent)}</p>
</button>
</div>
</article>
`;
}
function renderSrHoursAndCalendarSection(summary) {
const calendar = buildWorkloadStartCalendar(
summary.allWorkloads,
appState.calendarYear,
appState.calendarMonth,
appState.calendarView,
appState.calendarAnchorDateKey
);
return `
<section class="arch-panel-extension-operations-grid" aria-label="Pending SRs and workload calendar">
${renderSrHoursIndicatorCard(summary)}
${renderWorkloadStartCalendar(calendar)}
</section>
`;
}
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 `
<article class="arch-panel-extension-panel">
<header class="arch-panel-extension-panel-head">
<div>
<p class="arch-panel-extension-panel-label">Horas apontadas</p>
</div>
</header>
<div class="arch-panel-extension-progress">
<div class="arch-panel-extension-progress-track">
<span
class="arch-panel-extension-progress-segment is-success"
style="width: ${zeroToFourPercent}%;"
></span>
<span
class="arch-panel-extension-progress-segment is-danger"
style="width: ${aboveFourPercent}%;"
></span>
</div>
<div class="arch-panel-extension-progress-labels">
<span>SRs 0-4h: ${formatPercent(zeroToFourPercent)}</span>
<span>SRs &gt;4h: ${formatPercent(aboveFourPercent)}</span>
</div>
</div>
<div class="arch-panel-extension-boolean-grid">
${renderSrHoursIndicator(
"SRs 0-4h",
formatPercent(zeroToFourPercent),
zeroToFourCount,
"0-4"
)}
${renderSrHoursIndicator(
"SRs >4h",
formatPercent(aboveFourPercent),
aboveFourCount,
"above-4"
)}
</div>
</article>
`;
}
function renderSrHoursIndicator(label, detail, count, value) {
return `
<button
type="button"
class="arch-panel-extension-summary-card arch-panel-extension-summary-card-button"
data-action="open-detail"
data-detail-scope="sr-hours"
data-detail-value="${escapeHtml(value)}"
aria-label="Open ${escapeHtml(label)} service requests"
>
<p class="arch-panel-extension-summary-label">${escapeHtml(label)}</p>
<p class="arch-panel-extension-summary-value">${formatWholeNumber(count)}</p>
<p class="arch-panel-extension-summary-detail">${escapeHtml(detail)}</p>
</button>
`;
}
function renderWorkloadStartCalendar(calendar) {
return `
<article class="arch-panel-extension-panel arch-panel-extension-calendar-card">
<header class="arch-panel-extension-panel-head">
<div>
<p class="arch-panel-extension-panel-label">Workload start calendar</p>
<h3 class="arch-panel-extension-panel-title">${escapeHtml(calendar.periodLabel)}</h3>
</div>
<div class="arch-panel-extension-calendar-actions">
<div class="arch-panel-extension-calendar-view-toggle" aria-label="Calendar view">
${["month", "week"]
.map(
(view) => `
<button
type="button"
class="arch-panel-extension-calendar-view-button${
calendar.view === view ? " is-active" : ""
}"
data-action="calendar-view"
data-calendar-view="${view}"
aria-pressed="${calendar.view === view ? "true" : "false"}"
>
${view === "month" ? "Month" : "Week"}
</button>
`
)
.join("")}
</div>
<button
type="button"
class="arch-panel-extension-calendar-nav"
data-action="calendar-prev"
aria-label="${calendar.view === "week" ? "Previous week" : "Previous month"}"
>
</button>
<span>${formatWholeNumber(calendar.totalVisibleWorkloads)} starts</span>
<button
type="button"
class="arch-panel-extension-calendar-today"
data-action="calendar-today"
aria-label="${calendar.view === "week" ? "Go to current week" : "Go to current month"}"
>
Today
</button>
<button
type="button"
class="arch-panel-extension-calendar-nav"
data-action="calendar-next"
aria-label="${calendar.view === "week" ? "Next week" : "Next month"}"
>
</button>
</div>
</header>
<div class="arch-panel-extension-calendar-weekdays${
calendar.view === "week" ? " is-week" : ""
}">
${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
.map((day) => `<span>${day}</span>`)
.join("")}
</div>
<div class="arch-panel-extension-calendar-grid${
calendar.view === "week" ? " is-week" : ""
}">
${calendar.days
.map((day) => renderCalendarDay(day, calendar.view))
.join("")}
</div>
</article>
`;
}
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 `
<button
type="button"
class="${className}"
title="${escapeHtml(
day.workloads
.map((workload) => workload.description || workload.name)
.filter(Boolean)
.join(" | ")
)}"
data-action="open-detail"
data-detail-scope="calendar-day"
data-detail-value="${escapeHtml(day.dateKey)}"
${day.workloads.length ? "" : "disabled"}
>
<span class="arch-panel-extension-calendar-date">${day.dayNumber}</span>
${
day.isWeekStart
? `<span class="arch-panel-extension-calendar-week-number">${escapeHtml(
day.operationalWeekLabel
)}</span>`
: ""
}
${
day.workloads.length
? `<span class="arch-panel-extension-calendar-workload">${escapeHtml(
calendarWorkloadLabel
)}</span><span class="arch-panel-extension-calendar-count">${formatWholeNumber(
day.workloads.length
)}</span>`
: ""
}
</button>
`;
}
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 `
<button
type="button"
class="${className}"
title="${escapeHtml(title)}"
data-action="open-detail"
data-detail-scope="calendar-day"
data-detail-value="${escapeHtml(day.dateKey)}"
${day.workloads.length ? "" : "disabled"}
>
<span class="arch-panel-extension-calendar-week-day-head">
<span class="arch-panel-extension-calendar-date">${day.dayNumber}</span>
${
day.isWeekStart
? `<span class="arch-panel-extension-calendar-week-number">${escapeHtml(
day.operationalWeekLabel
)}</span>`
: ""
}
</span>
<span class="arch-panel-extension-calendar-week-workloads">
${
day.workloads.length
? day.workloads.map((workload) => renderCalendarWeekWorkload(workload)).join("")
: `<span class="arch-panel-extension-calendar-week-empty">No starts</span>`
}
</span>
</button>
`;
}
function renderCalendarWeekWorkload(workload) {
const label = workload.description || workload.name || "Workload";
const customer = workload.customerName || "";
return `
<span class="arch-panel-extension-calendar-week-workload">
<span class="arch-panel-extension-calendar-week-workload-title">${escapeHtml(label)}</span>
${
customer
? `<span class="arch-panel-extension-calendar-week-workload-meta">${escapeHtml(
customer
)}</span>`
: ""
}
</span>
`;
}
function renderWorkloadTable(workloads) {
return `
<div class="arch-panel-extension-table-wrap is-main">
<table class="arch-panel-extension-table">
<colgroup>
<col class="arch-panel-extension-col-opportunity">
<col class="arch-panel-extension-col-customer">
<col class="arch-panel-extension-col-workload">
<col class="arch-panel-extension-col-acr">
<col class="arch-panel-extension-col-type">
<col class="arch-panel-extension-col-flag">
<col class="arch-panel-extension-col-flag">
</colgroup>
<thead>
<tr>
<th>Opportunity</th>
<th>Customer</th>
<th>Workload</th>
<th class="is-numeric">ACR</th>
<th>Type</th>
<th>Tem SR?</th>
<th>Tem Action?</th>
</tr>
</thead>
<tbody>
${workloads.map((workload) => renderWorkloadTableRow(workload)).join("")}
</tbody>
</table>
</div>
`;
}
function renderWorkloadTableRow(
workload,
includeConsumptionStartDate = false,
includeRampMonths = false
) {
const hasRampMismatch =
includeRampMonths && hasRampForecastMismatch(workload);
return `
<tr>
<td>${renderOpportunityLink(workload)}</td>
<td>${escapeHtml(workload.customerName || "")}</td>
<td>${renderWorkloadLink(workload, hasRampMismatch)}</td>
${
includeConsumptionStartDate
? `<td>${escapeHtml(formatDate(workload.consumptionStartDate) || "-")}</td>`
: ""
}
${
includeRampMonths
? `<td class="is-numeric">${renderRampMonthsButton(workload)}</td>`
: ""
}
<td class="is-numeric">${formatCurrency(workload.adjustedACR)}</td>
<td>${escapeHtml(workload.opportunityForecastTypeGroup)}</td>
<td>${renderBooleanBadge(workload.hasSR)}</td>
<td>${renderHasActionCell(workload)}</td>
</tr>
`;
}
function renderRampMonthsButton(workload) {
return `
<button
type="button"
class="arch-panel-extension-row-link arch-panel-extension-inline-link-button"
data-action="open-ramp-comparison"
data-workload-id="${escapeHtml(workload.workloadId || "")}"
aria-label="Abrir comparação de rampa para ${escapeHtml(
workload.name || "workload"
)}"
>
${formatWholeNumber(workload.rampMonths || 0)}
</button>
`;
}
function renderBooleanBadge(value) {
const isTrue = Boolean(value);
const variantClass = isTrue ? "is-success" : "is-danger";
const label = isTrue ? "Sim" : "Não";
return `
<span class="arch-panel-extension-badge ${variantClass}">
${label}
</span>
`;
}
function renderHasActionCell(workload) {
if (workload.hasAction) {
return renderBooleanBadge(true);
}
return `
<div class="arch-panel-extension-action-cell">
${renderBooleanBadge(false)}
<button
type="button"
class="arch-panel-extension-add-action-button"
data-action="open-action-form"
data-workload-id="${escapeHtml(workload.workloadId || "")}"
aria-label="Criar action para ${escapeHtml(workload.name || "workload")}"
title="Criar action"
>
</button>
</div>
`;
}
function renderInsightRow(label, value, detail) {
return `
<div class="arch-panel-extension-insight">
<div>
<p class="arch-panel-extension-insight-label">${escapeHtml(label)}</p>
<p class="arch-panel-extension-insight-detail">${escapeHtml(detail)}</p>
</div>
<p class="arch-panel-extension-insight-value">${escapeHtml(value)}</p>
</div>
`;
}
return {
renderMainContent,
renderWorkloadTableRow,
};
}
globalThis.ArchPanelDashboardView = Object.freeze({
create,
});
})();

View File

@@ -0,0 +1,760 @@
(() => {
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 `
<div class="arch-panel-extension-detail-overlay">
<div class="arch-panel-extension-time-management-dialog" role="document">
<header class="arch-panel-extension-detail-head">
<div>
<p class="arch-panel-extension-panel-label">Horas apontadas</p>
<h3 class="arch-panel-extension-panel-title">Manage time</h3>
</div>
</header>
<div class="arch-panel-extension-time-management-toolbar">
<label class="arch-panel-extension-form-field">
<span>Selected date</span>
<span class="arch-panel-extension-date-picker">
<button
type="button"
class="arch-panel-extension-date-step-button"
data-action="time-management-date-week-prev"
aria-label="Previous week"
>
<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false">
<path d="M12.78 4.22a.75.75 0 0 1 0 1.06L8.06 10l4.72 4.72a.75.75 0 1 1-1.06 1.06l-5.25-5.25a.75.75 0 0 1 0-1.06l5.25-5.25a.75.75 0 0 1 1.06 0Z"></path>
</svg>
</button>
<span class="arch-panel-extension-date-input-shell">
<button
type="button"
class="arch-panel-extension-date-picker-button"
data-action="open-time-management-date-picker"
aria-label="Open calendar"
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M7 2a1 1 0 0 1 1 1v1h8V3a1 1 0 1 1 2 0v1h1.25A2.75 2.75 0 0 1 22 6.75v11.5A2.75 2.75 0 0 1 19.25 21H4.75A2.75 2.75 0 0 1 2 18.25V6.75A2.75 2.75 0 0 1 4.75 4H6V3a1 1 0 0 1 1-1ZM4.5 9.5v8.75c0 .69.56 1.25 1.25 1.25h12.5c.69 0 1.25-.56 1.25-1.25V9.5h-15Zm1.25-4c-.69 0-1.25.56-1.25 1.25V8h15V6.75c0-.69-.56-1.25-1.25-1.25H17v.5a1 1 0 1 1-2 0v-.5H9v.5a1 1 0 0 1-2 0v-.5H5.75Z"></path>
</svg>
</button>
<input
type="text"
value="${escapeHtml(selectedDateLabel)}"
aria-label="Selected date"
readonly
>
${renderTimeManagementDatePicker(selectedDate)}
</span>
<button
type="button"
class="arch-panel-extension-date-step-button"
data-action="time-management-date-week-next"
aria-label="Next week"
>
<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false">
<path d="M7.22 15.78a.75.75 0 0 1 0-1.06L11.94 10 7.22 5.28a.75.75 0 0 1 1.06-1.06l5.25 5.25a.75.75 0 0 1 0 1.06l-5.25 5.25a.75.75 0 0 1-1.06 0Z"></path>
</svg>
</button>
</span>
</label>
${renderTimeManagementHourIndicators(modal)}
</div>
${renderTimeManagementBody(modal)}
<div class="arch-panel-extension-detail-actions">
<div class="arch-panel-extension-time-management-footer-actions">
<button
type="button"
class="arch-panel-extension-utility-button"
data-action="time-management-add-sr"
>
Add SR
</button>
<button
type="button"
class="arch-panel-extension-utility-button"
data-action="time-management-add-non-service"
>
Add (Non-service)
</button>
<button
type="button"
class="arch-panel-extension-utility-button is-outline"
data-action="time-management-save"
${canSaveTimeManagement && !isSavingTimeManagement ? "" : "disabled"}
>
${isSavingTimeManagement ? "Gravando..." : "Gravar"}
</button>
</div>
${
modal.saveErrorMessage
? `<p class="arch-panel-extension-time-management-save-error">${escapeHtml(modal.saveErrorMessage)}</p>`
: ""
}
<button
type="button"
class="arch-panel-extension-close"
data-action="close-time-management"
aria-label="Fechar apontamento de horas"
>
Fechar
</button>
</div>
</div>
</div>
`;
}
function renderTimeManagementBody(modal) {
if (modal.status === "loading") {
return renderTimeManagementSkeleton();
}
if (modal.status === "error") {
return `
<div class="arch-panel-extension-time-management-card is-error">
<p class="arch-panel-extension-summary-label">Erro na requisição</p>
<p class="arch-panel-extension-detail-subtitle">${escapeHtml(
modal.errorMessage || "Não foi possível carregar o apontamento."
)}</p>
</div>
`;
}
const rows = getTimeManagementRows(modal);
return `
<div class="arch-panel-extension-time-management-card">
${renderTimeManagementHeaderRow(modal.weekDays || [])}
<div class="arch-panel-extension-time-management-rows">
${
rows.length
? rows.map((row, index) => renderTimeManagementEntryRow(modal, row, index)).join("")
: `<div class="arch-panel-extension-empty-state arch-panel-extension-time-management-empty">
Nenhum apontamento de hora neste período
</div>`
}
</div>
</div>
`;
}
function renderTimeManagementHeaderRow(weekDays) {
return `
<div class="arch-panel-extension-time-management-header-row" aria-hidden="true">
<span class="arch-panel-extension-time-management-column-label">Service Request</span>
<span class="arch-panel-extension-time-management-column-label">Activity</span>
<span class="arch-panel-extension-time-management-column-label">Task Type</span>
${(weekDays || []).map((day) => renderTimeManagementDayHeader(day)).join("")}
<span class="arch-panel-extension-time-management-column-label" aria-label="Actions"></span>
</div>
`;
}
function renderTimeManagementDayHeader(day) {
return `
<span class="arch-panel-extension-time-management-day-head">
<strong>${escapeHtml(day.dayLabel || "-")}</strong>
<span>${escapeHtml(day.monthLabel || day.date || "-")}</span>
</span>
`;
}
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 `
<div class="arch-panel-extension-time-management-entry-row is-compact${isHighlighted ? " is-duplicate-highlight" : ""}" data-row-id="${escapeHtml(rowId)}">
<input type="hidden" name="timeEntryId" value="${escapeHtml(row?.timeEntryId || "")}">
<label class="arch-panel-extension-form-field">
${renderTimeManagementServiceRequestField({
modal,
rowId,
selectedSr,
selectedSrLabel,
isNonServiceRequest,
})}
</label>
<label class="arch-panel-extension-form-field">
${renderSearchableCombobox({
field: "activity",
rowId,
selectedValue: selectedActivityValue,
value: selectedActivityLabel,
placeholder: "Search activity",
options: activityOptions,
valueKey: "value",
labelKey: "label",
metaKey: "value",
disabled: isNonServiceRequest,
})}
</label>
<label class="arch-panel-extension-form-field">
${renderSearchableCombobox({
field: "taskType",
rowId,
selectedValue: selectedTaskTypeValue,
value: selectedTaskTypeLabel,
placeholder: "Search task type",
options: taskTypeOptions,
valueKey: "value",
labelKey: "label",
})}
</label>
${(modal.weekDays || [])
.map((day) =>
renderTimeManagementDayInput(day, rowId, row?.dayHours)
)
.join("")}
<button
type="button"
class="arch-panel-extension-time-management-delete"
data-action="time-management-remove-row"
data-row-id="${escapeHtml(rowId)}"
aria-label="Remover linha de apontamento"
title="Remover linha"
>
<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false">
<path d="M7.5 2.5A1.5 1.5 0 0 1 9 1h2a1.5 1.5 0 0 1 1.5 1.5V3H16a.75.75 0 0 1 0 1.5h-.72l-.67 11.33A2.25 2.25 0 0 1 12.36 18H7.64a2.25 2.25 0 0 1-2.25-2.17L4.72 4.5H4A.75.75 0 0 1 4 3h3.5v-.5ZM9 3h2v-.5H9V3Zm-2.78 1.5.66 11.24c.02.42.36.76.76.76h4.72c.4 0 .74-.34.76-.76l.66-11.24H6.22Zm2.53 2.25c.41 0 .75.34.75.75v6a.75.75 0 0 1-1.5 0v-6c0-.41.34-.75.75-.75Zm3.25.75a.75.75 0 0 0-1.5 0v6a.75.75 0 0 0 1.5 0v-6Z"></path>
</svg>
</button>
</div>
`;
}
function renderTimeManagementServiceRequestField({
modal,
rowId,
selectedSr,
selectedSrLabel,
isNonServiceRequest,
}) {
return renderSearchableCombobox({
field: "sr",
rowId,
selectedValue: selectedSr,
value: selectedSrLabel,
placeholder: "Search SR",
options: modal.serviceRequests || [],
valueKey: "srNumber",
labelKey: "label",
fallbackLabelKey: "srTitle",
disabled: isNonServiceRequest,
optionTooltipHtml: (option) => {
const optionSrNumber = cleanString(option?.srNumber);
const details = getTimeManagementServiceRequestDetails(modal, optionSrNumber);
return details.length
? renderTimeManagementServiceRequestTooltip(
option?.label ||
formatTimeManagementServiceRequestLabel(option) ||
optionSrNumber,
details
)
: "";
},
});
}
function renderTimeManagementServiceRequestTooltip(title, details) {
return `
<span class="arch-panel-extension-service-request-tooltip" role="tooltip">
<span class="arch-panel-extension-service-request-tooltip-title">
${escapeHtml(title || "Service Request")}
</span>
<span class="arch-panel-extension-service-request-tooltip-grid">
${details
.map(
(detail) => `
<span class="arch-panel-extension-service-request-tooltip-label">
${escapeHtml(detail.label || detail.key || "")}
</span>
<span class="arch-panel-extension-service-request-tooltip-value">
${renderTimeManagementServiceRequestTooltipValue(detail)}
</span>
`
)
.join("")}
</span>
</span>
`;
}
function renderTimeManagementServiceRequestTooltipValue(detail) {
const value = cleanString(detail?.value);
if (!isLongProblemDescriptionDetail(detail, value)) {
return escapeHtml(value);
}
return `
<span class="arch-panel-extension-service-request-tooltip-excerpt">
${escapeHtml(value.slice(0, 180).trim())}...
</span>
<details class="arch-panel-extension-service-request-tooltip-more">
<summary>ver mais</summary>
<span>${escapeHtml(value)}</span>
</details>
`;
}
function isLongProblemDescriptionDetail(detail, value) {
const key = normalizeString(detail?.key || detail?.label).replace(/[^A-Z0-9]/g, "");
return key === "PROBLEMDESCRIPTION" && value.length > 220;
}
function getTimeManagementServiceRequestDetails(modal, srNumber) {
const normalizedSrNumber = normalizeString(srNumber);
const matchedSrKey = Object.keys(modal?.activityRequestDetailsBySr || {}).find(
(key) => normalizeString(key) === normalizedSrNumber
);
return (
modal?.activityRequestDetailsBySr?.[srNumber] ||
modal?.activityRequestDetailsBySr?.[matchedSrKey] ||
[]
);
}
function renderTimeManagementSkeleton() {
return `
<div class="arch-panel-extension-time-management-card" aria-busy="true">
<div class="arch-panel-extension-time-management-header-row">
${["Service Request", "Activity", "Task Type"]
.map(
(label) =>
`<span class="arch-panel-extension-time-management-column-label">${label}</span>`
)
.join("")}
${Array.from({ length: 7 }, () => `
<span class="arch-panel-extension-time-management-day-head">
<span class="arch-panel-extension-skeleton arch-panel-extension-skeleton-title"></span>
<span class="arch-panel-extension-skeleton arch-panel-extension-skeleton-text"></span>
</span>
`).join("")}
<span></span>
</div>
<div class="arch-panel-extension-time-management-entry-row">
${Array.from({ length: 3 }, () => `
<div class="arch-panel-extension-form-field">
<span class="arch-panel-extension-skeleton arch-panel-extension-skeleton-control"></span>
</div>
`).join("")}
${Array.from({ length: 7 }, () => `
<div class="arch-panel-extension-time-management-day">
<span class="arch-panel-extension-skeleton arch-panel-extension-skeleton-input"></span>
</div>
`).join("")}
<span></span>
</div>
</div>
`;
}
function renderSearchableCombobox({
field,
rowId,
selectedValue,
value,
placeholder,
options,
valueKey,
labelKey,
fallbackLabelKey,
metaKey,
disabled = false,
optionTooltipHtml = null,
}) {
const safeField = escapeHtml(field);
const safeRowId = escapeHtml(rowId || "");
const isDisabled = Boolean(disabled);
const normalizedSelectedValue = normalizeString(selectedValue);
let hasOptionTooltips = false;
const normalizedOptions = (options || [])
.map((item) => {
const value = cleanString(item?.[valueKey]);
const label =
cleanString(item?.[labelKey]) ||
cleanString(item?.[fallbackLabelKey]) ||
value;
return {
original: item,
value,
label,
meta: cleanString(item?.[metaKey]),
tooltipHtml: "",
};
})
.filter((item) => item.value || item.label)
.map((item) => {
const tooltipHtml =
typeof optionTooltipHtml === "function"
? optionTooltipHtml(item.original || item, item)
: "";
if (tooltipHtml) {
hasOptionTooltips = true;
}
return {
original: item.original,
value: item.value,
label: item.label,
meta: item.meta,
tooltipHtml,
};
});
const menuClassName = [
"arch-panel-extension-combobox-menu",
hasOptionTooltips ? "has-service-request-tooltips" : "",
]
.filter(Boolean)
.join(" ");
return `
<span class="arch-panel-extension-combobox${isDisabled ? " is-disabled" : ""}" data-combobox-field="${safeField}" data-row-id="${safeRowId}">
<span class="arch-panel-extension-combobox-value" title="${escapeHtml(value || "")}">
<span class="arch-panel-extension-combobox-value-text">
${escapeHtml(value || placeholder || "")}
</span>
</span>
<input
type="search"
value=""
placeholder="${escapeHtml(placeholder || "")}"
data-combobox-field="${safeField}"
data-row-id="${safeRowId}"
autocomplete="off"
spellcheck="false"
role="combobox"
aria-autocomplete="list"
aria-expanded="false"
${isDisabled ? "disabled" : ""}
>
<span class="arch-panel-extension-combobox-arrow" aria-hidden="true">
<svg viewBox="0 0 20 20" focusable="false">
<path d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.16l3.71-3.93a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z"></path>
</svg>
</span>
<span class="${menuClassName}" role="listbox">
${
normalizedOptions.length
? normalizedOptions
.map(
(item, index) => {
const isSelected =
normalizedSelectedValue &&
normalizeString(item.value) === normalizedSelectedValue;
const hasTooltip = Boolean(item.tooltipHtml);
const meta =
item.meta && normalizeString(item.meta) !== normalizeString(item.label)
? item.meta
: "";
const optionId = `arch-panel-extension-combobox-${safeField}-${index}`;
return `
<span
id="${optionId}"
class="arch-panel-extension-combobox-option${isSelected ? " is-selected" : ""}${hasTooltip ? " has-tooltip" : ""}"
data-action="time-management-combobox-option"
data-field="${safeField}"
data-row-id="${safeRowId}"
data-value="${escapeHtml(item.value)}"
data-search="${escapeHtml(`${item.label} ${item.value}`.toLowerCase())}"
role="option"
aria-selected="${isSelected ? "true" : "false"}"
tabindex="-1"
>
<span class="arch-panel-extension-combobox-option-main">
${escapeHtml(item.label || item.value)}
</span>
${
meta
? `<span class="arch-panel-extension-combobox-option-meta">${escapeHtml(meta)}</span>`
: ""
}
${item.tooltipHtml}
</span>
`;
}
)
.join("")
: `<span class="arch-panel-extension-combobox-empty">No options</span>`
}
</span>
</span>
`;
}
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 `
<button
type="button"
class="${className}"
data-action="time-management-date-picker-select"
data-date-value="${escapeHtml(dateKey)}"
>
${date.getDate()}
</button>
`;
});
return `
<div class="arch-panel-extension-date-picker-popover">
<div class="arch-panel-extension-date-picker-head">
<button
type="button"
class="arch-panel-extension-calendar-nav"
data-action="time-management-date-picker-prev"
aria-label="Previous month"
>
</button>
<strong>${escapeHtml(monthLabel)}</strong>
<button
type="button"
class="arch-panel-extension-calendar-nav"
data-action="time-management-date-picker-next"
aria-label="Next month"
>
</button>
</div>
<div class="arch-panel-extension-date-picker-weekdays">
${["S", "M", "T", "W", "T", "F", "S"].map((day) => `<span>${day}</span>`).join("")}
</div>
<div class="arch-panel-extension-date-picker-grid">
${days.join("")}
</div>
</div>
`;
}
function renderTimeManagementHourIndicators(modal) {
if (modal?.status === "loading") {
return `
<div class="arch-panel-extension-time-management-hour-indicators" aria-label="Resumo de horas apontadas" aria-busy="true">
${Array.from({ length: 4 }, () => `
<span class="arch-panel-extension-time-management-hour-indicator">
<span class="arch-panel-extension-skeleton arch-panel-extension-skeleton-text"></span>
<strong class="arch-panel-extension-skeleton arch-panel-extension-skeleton-title"></strong>
</span>
`).join("")}
</div>
`;
}
return `
<div class="arch-panel-extension-time-management-hour-indicators" aria-label="Resumo de horas apontadas">
${buildTimeManagementHourIndicators(modal)
.map(
(indicator) => `
<span class="arch-panel-extension-time-management-hour-indicator">
<span>${escapeHtml(indicator.label)}</span>
<strong>${escapeHtml(formatHours(indicator.value))}</strong>
</span>
`
)
.join("")}
</div>
`;
}
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 `
<label class="arch-panel-extension-time-management-day">
<input
type="number"
min="0"
step="1"
value="${escapeHtml(value)}"
placeholder="0"
data-action="time-management-hours-change"
data-row-id="${escapeHtml(rowId || "")}"
data-date-key="${escapeHtml(dateKey)}"
aria-label="Horas apontadas em ${escapeHtml(day.date || "")}"
>
</label>
`;
}
return {
renderTimeManagementModal,
};
}
globalThis.ArchPanelManageTimeView = Object.freeze({
create,
});
})();

213
src/views/render-helpers.js Normal file
View File

@@ -0,0 +1,213 @@
(() => {
function create({
appState,
escapeHtml,
cleanString,
formatWholeNumber,
formatCurrency,
formatDate,
formatHours,
}) {
function renderServiceRequestDetailTable(items) {
return `
<div class="arch-panel-extension-table-wrap is-detail">
<table class="arch-panel-extension-table arch-panel-extension-sr-detail-table">
<thead>
<tr>
<th>Customer</th>
<th>SR Number</th>
<th>Queue</th>
<th>Service</th>
<th>Pillar</th>
<th class="is-numeric">Oppty value</th>
<th class="is-numeric">Horas apontadas</th>
<th>Customer deadline</th>
<th>Oppty Status</th>
</tr>
</thead>
<tbody>
${items
.map(
(serviceRequest) => `
<tr>
<td>${escapeHtml(serviceRequest.customerName || "-")}</td>
<td>${renderServiceRequestLink(serviceRequest)}</td>
<td>${escapeHtml(serviceRequest.queueName || "-")}</td>
<td>${escapeHtml(serviceRequest.serviceName || "-")}</td>
<td>${escapeHtml(serviceRequest.pillarLabel || "-")}</td>
<td class="is-numeric">${formatCurrency(serviceRequest.opportunityValue)}</td>
<td class="is-numeric">${renderReportedHoursLink(serviceRequest)}</td>
<td>${escapeHtml(formatDate(serviceRequest.customerDeadline) || "-")}</td>
<td>${escapeHtml(serviceRequest.opportunityStatusLabel || "-")}</td>
</tr>
`
)
.join("")}
</tbody>
</table>
</div>
`;
}
function renderSortButton(label, sortKey, isNumeric = false) {
const currentKey = appState.detailModal?.sortKey || "";
const currentDirection = appState.detailModal?.sortDirection || "asc";
const indicator =
currentKey === sortKey ? (currentDirection === "asc" ? " ↑" : " ↓") : "";
return `
<button
type="button"
class="arch-panel-extension-sort-button${isNumeric ? " is-numeric" : ""}"
data-action="sort-detail"
data-sort-key="${escapeHtml(sortKey)}"
aria-label="Sort by ${escapeHtml(label)}"
>
<span>${escapeHtml(label)}${indicator}</span>
</button>
`;
}
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 `<a class="arch-panel-extension-row-link" href="${href}" target="_blank" rel="noreferrer">${escapeHtml(label)}</a>`;
}
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 `<a class="arch-panel-extension-row-link" href="${href}" target="_blank" rel="noreferrer">${escapeHtml(srNumber)}</a>`;
}
function renderReportedHoursLink(serviceRequest) {
const srNumber = cleanString(serviceRequest?.srNumber);
const label = formatHours(serviceRequest?.totalHoursWorked);
if (!srNumber) {
return escapeHtml(label);
}
return `
<button
type="button"
class="arch-panel-extension-row-link arch-panel-extension-inline-link-button"
data-action="open-time-entries"
data-sr-number="${escapeHtml(srNumber)}"
aria-label="Abrir apontamentos de horas da SR ${escapeHtml(srNumber)}"
>
${escapeHtml(label)}
</button>
`;
}
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 `
<span class="arch-panel-extension-workload-link-cell">
<a class="arch-panel-extension-row-link" href="${href}" target="_blank" rel="noreferrer">${escapeHtml(label)}</a>
${renderRampAlertButton(workload, showRampAlert)}
</span>
`;
}
function renderRampAlertButton(workload, showRampAlert) {
if (!showRampAlert || !workload.workloadId) {
return "";
}
return `
<button
type="button"
class="arch-panel-extension-ramp-alert-button"
data-action="open-ramp-comparison"
data-workload-id="${escapeHtml(workload.workloadId || "")}"
title="Requer atencao: existe Delta diferente de zero"
aria-label="Abrir comparacao de rampa com inconsistencia para ${escapeHtml(
workload.name || "workload"
)}"
>
!
</button>
`;
}
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,
});
})();

View File

@@ -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 `
<div class="arch-panel-extension-detail-overlay">
<div class="arch-panel-extension-detail-dialog" role="document">
<header class="arch-panel-extension-detail-head">
<div>
<p class="arch-panel-extension-panel-label">${escapeHtml(appState.detailModal.label)}</p>
<h3 class="arch-panel-extension-panel-title">${escapeHtml(appState.detailModal.title)}</h3>
</div>
</header>
<div class="arch-panel-extension-detail-meta">
${
isServiceRequestDetail
? `<span>${formatWholeNumber(detailItems.length)} SRs</span>`
: `<span>${formatWholeNumber(detailItems.length)} workloads</span>
<span class="arch-panel-extension-panel-dot"></span>
<span>Total (ACR): ${formatCurrency(appState.detailModal.totalAcr)}</span>`
}
</div>
${
isServiceRequestDetail
? renderServiceRequestDetailTable(detailItems)
: renderDetailTable(
detailItems,
appState.detailModal.scope === "calendar-day"
)
}
<div class="arch-panel-extension-detail-actions">
${
isServiceRequestDetail
? `<button
type="button"
class="arch-panel-extension-utility-button is-primary"
data-action="open-time-management"
>
⏱️ Manage time
</button>`
: ""
}
<button
type="button"
class="arch-panel-extension-close"
data-action="close-detail"
aria-label="Fechar detalhamento"
>
Fechar
</button>
</div>
</div>
</div>
`;
}
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 `
<div class="arch-panel-extension-detail-overlay">
<div class="arch-panel-extension-action-dialog" role="document">
<header class="arch-panel-extension-detail-head">
<div>
<p class="arch-panel-extension-panel-label">Create workload action</p>
<h3 class="arch-panel-extension-panel-title">Criar action</h3>
</div>
<button
type="button"
class="arch-panel-extension-close"
data-action="close-action-form"
aria-label="Fechar formulário"
>
Fechar
</button>
</header>
<div class="arch-panel-extension-detail-meta">
<span>${renderOpportunityLink(workload)}</span>
<span class="arch-panel-extension-panel-dot"></span>
<span>${escapeHtml(workload.customerName || "")}</span>
<span class="arch-panel-extension-panel-dot"></span>
<span>${escapeHtml(workload.name || "Unnamed workload")}</span>
</div>
${modalError ? `<p class="arch-panel-extension-form-error">${escapeHtml(modalError)}</p>` : ""}
<form class="arch-panel-extension-action-form">
<input type="hidden" name="parentType" value="WORKLOAD">
<input type="hidden" name="parentId" value="${escapeHtml(workload.workloadId || "")}">
<input type="hidden" name="ownerEmail" value="${escapeHtml(userEmail)}">
<input type="hidden" name="role" value="CLOUD_ARCHITECT">
<input type="hidden" name="name" value="SUCCESS_PLAN_CONSUM_RVW">
<input type="hidden" name="status" value="COMPLETE">
<input type="hidden" name="notes" value="[]">
<input type="hidden" name="objective" value="">
<input type="hidden" name="createdBy" value="${escapeHtml(userEmail)}">
<input type="hidden" name="updatedBy" value="${escapeHtml(userEmail)}">
<div class="arch-panel-extension-form-grid">
<label class="arch-panel-extension-form-field">
<span>Team</span>
<select name="team" required ${isSubmitting ? "disabled" : ""}>
<option value="">Selecione</option>
${ACTION_TEAM_OPTIONS.map(
(option) =>
`<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`
).join("")}
</select>
</label>
<label class="arch-panel-extension-form-field">
<span>Data de início</span>
<input type="date" name="startDate" required ${isSubmitting ? "disabled" : ""}>
</label>
<label class="arch-panel-extension-form-field">
<span>Data fim</span>
<input type="date" name="endDate" required ${isSubmitting ? "disabled" : ""}>
</label>
<label class="arch-panel-extension-form-field">
<span>Complexity</span>
<select name="complexity" required ${isSubmitting ? "disabled" : ""}>
<option value="">Selecione</option>
${ACTION_COMPLEXITY_OPTIONS.map(
(option) =>
`<option value="${escapeHtml(option)}">${escapeHtml(option)}</option>`
).join("")}
</select>
</label>
</div>
<div class="arch-panel-extension-detail-actions">
<button
type="button"
class="arch-panel-extension-close"
data-action="close-action-form"
aria-label="Fechar formulário"
${isSubmitting ? "disabled" : ""}
>
Fechar
</button>
<button
type="button"
class="arch-panel-extension-utility-button"
data-action="submit-action-form"
${isSubmitting ? "disabled" : ""}
>
${isSubmitting ? "Criando..." : "Criar action"}
</button>
</div>
</form>
</div>
</div>
`;
}
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 `
<div class="arch-panel-extension-detail-overlay">
<div class="arch-panel-extension-ramp-dialog" role="document">
<header class="arch-panel-extension-detail-head">
<div>
<p class="arch-panel-extension-panel-label">Ramp forecast comparison</p>
<h3 class="arch-panel-extension-panel-title">${escapeHtml(title)}</h3>
</div>
</header>
<div class="arch-panel-extension-detail-meta">
<span>Ramp months: ${formatWholeNumber(modal.workload.rampMonths || 0)}</span>
<span class="arch-panel-extension-panel-dot"></span>
<span>Adjusted ACR: ${formatCurrency(modal.workload.adjustedACR)}</span>
<span class="arch-panel-extension-panel-dot"></span>
<span>Consumption start: ${escapeHtml(
formatDate(modal.workload.consumptionStartDate) || "-"
)}</span>
</div>
${renderRampComparisonPivotTable(rows)}
<div class="arch-panel-extension-detail-actions">
<button
type="button"
class="arch-panel-extension-utility-button is-primary"
data-action="open-forecast-update-confirm"
${rows.length ? "" : "disabled"}
>
Atualizar pelo Calculated ramp
</button>
<button
type="button"
class="arch-panel-extension-close"
data-action="close-ramp-comparison"
aria-label="Fechar comparação de rampa"
>
Fechar
</button>
</div>
</div>
</div>
`;
}
function renderRampComparisonPivotTable(rows) {
if (!rows.length) {
return `<div class="arch-panel-extension-empty-state">Nenhum forecast encontrado para comparar.</div>`;
}
return `
<div class="arch-panel-extension-table-wrap is-ramp-pivot">
<table class="arch-panel-extension-table arch-panel-extension-ramp-pivot-table">
<tbody>
<tr>
<th>Period</th>
${rows.map((row) => `<td>${escapeHtml(row.periodLabel)}</td>`).join("")}
</tr>
<tr>
<th>Current adjusted</th>
${rows
.map(
(row) =>
`<td class="is-numeric">${formatCurrency(row.currentValue)}</td>`
)
.join("")}
</tr>
<tr>
<th>Calculated ramp</th>
${rows
.map(
(row) =>
`<td class="is-numeric">${formatCurrency(row.calculatedValue)}</td>`
)
.join("")}
</tr>
<tr>
<th>Delta</th>
${rows
.map(
(row) =>
`<td class="is-numeric${
isNonZeroAmount(row.delta) ? " is-delta-different" : ""
}">${formatCurrency(row.delta)}</td>`
)
.join("")}
</tr>
</tbody>
</table>
</div>
`;
}
function renderForecastUpdateConfirmModal() {
const modal = appState.forecastUpdateConfirmModal;
if (!modal) {
return "";
}
return `
<div class="arch-panel-extension-detail-overlay is-confirmation">
<div class="arch-panel-extension-confirm-dialog" role="document">
<header class="arch-panel-extension-detail-head">
<div>
<p class="arch-panel-extension-panel-label">Confirmar atualização</p>
<h3 class="arch-panel-extension-panel-title">Atualizar forecast pelo Calculated ramp?</h3>
</div>
</header>
<p class="arch-panel-extension-detail-subtitle">
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.
</p>
${
modal.errorMessage
? `<p class="arch-panel-extension-form-error">${escapeHtml(
modal.errorMessage
)}</p>`
: ""
}
<div class="arch-panel-extension-detail-actions">
<button
type="button"
class="arch-panel-extension-close"
data-action="close-forecast-update-confirm"
${modal.isSubmitting ? "disabled" : ""}
>
Cancelar
</button>
<button
type="button"
class="arch-panel-extension-utility-button is-primary"
data-action="submit-forecast-update"
${modal.isSubmitting ? "disabled" : ""}
>
${modal.isSubmitting ? "Atualizando..." : "Confirmar atualização"}
</button>
</div>
</div>
</div>
`;
}
function renderTimeEntriesDrawer() {
const drawer = appState.timeEntriesDrawer;
if (!drawer) {
return "";
}
const items = Array.isArray(drawer.items) ? drawer.items : [];
return `
<aside class="arch-panel-extension-time-drawer" aria-label="Time entries summary">
<header class="arch-panel-extension-time-drawer-head">
<div>
<p class="arch-panel-extension-panel-label">Horas apontadas</p>
<h3 class="arch-panel-extension-panel-title">SR ${escapeHtml(drawer.srNumber || "-")}</h3>
</div>
</header>
${renderTimeEntriesDrawerBody(drawer, items)}
<footer class="arch-panel-extension-time-drawer-actions">
<button
type="button"
class="arch-panel-extension-close arch-panel-extension-time-drawer-close"
data-action="close-time-entries"
aria-label="Fechar apontamentos de horas"
>
Fechar
</button>
</footer>
</aside>
`;
}
function renderTimeEntriesDrawerBody(drawer, items) {
if (drawer.status === "loading") {
return `
<div class="arch-panel-extension-time-drawer-state">
<p class="arch-panel-extension-summary-label">Carregando</p>
<p class="arch-panel-extension-summary-value">Buscando dados</p>
<p class="arch-panel-extension-summary-detail">Consultando o resumo de time entries na origem COMCIP.</p>
</div>
`;
}
if (drawer.status === "error") {
return `
<div class="arch-panel-extension-time-drawer-state is-error">
<p class="arch-panel-extension-summary-label">Erro na requisição</p>
<p class="arch-panel-extension-detail-subtitle">${escapeHtml(drawer.errorMessage || "Não foi possível carregar os apontamentos.")}</p>
<button type="button" class="arch-panel-extension-utility-button" data-action="retry-time-entries">
Tentar novamente
</button>
</div>
`;
}
const groupedItems = groupTimeEntriesByResourceName(items);
return `
<div class="arch-panel-extension-time-drawer-meta">
<span>${formatWholeNumber(items.length)} registros</span>
<span>${formatWholeNumber(groupedItems.length)} resources</span>
</div>
${
items.length
? groupedItems.map((group) => renderTimeEntryGroup(group)).join("")
: `<div class="arch-panel-extension-empty-state">Nenhum registro retornado para esta SR.</div>`
}
`;
}
function renderTimeEntryGroup(group) {
const totalTimeSpent = group.items.reduce((sum, item) => {
return sum + getTimeEntryTimeSpent(item);
}, 0);
return `
<section class="arch-panel-extension-time-entry-group">
<header class="arch-panel-extension-time-entry-group-head">
<div>
<p class="arch-panel-extension-summary-label">resourceName</p>
<h4>${escapeHtml(group.resourceName)}</h4>
</div>
<span>${formatHours(totalTimeSpent)} total</span>
</header>
<div class="arch-panel-extension-time-entry-table-wrap">
<table class="arch-panel-extension-time-entry-table">
<thead>
<tr>
<th>Activity</th>
<th>Task type</th>
<th class="is-numeric">Time spent</th>
</tr>
</thead>
<tbody>
${group.items.map((item) => renderTimeEntryRow(item)).join("")}
</tbody>
<tfoot>
<tr>
<th colspan="2">Total</th>
<th class="is-numeric">${formatHours(totalTimeSpent)}</th>
</tr>
</tfoot>
</table>
</div>
</section>
`;
}
function renderTimeEntryRow(item) {
return `
<tr>
<td>${escapeHtml(getTimeEntryActivity(item) || "-")}</td>
<td>${escapeHtml(getTimeEntryTaskType(item) || "-")}</td>
<td class="is-numeric">${formatHours(getTimeEntryTimeSpent(item))}</td>
</tr>
`;
}
function renderDetailTable(items, includeConsumptionStartDate = false) {
return `
<div class="arch-panel-extension-table-wrap is-detail">
<table class="arch-panel-extension-table">
<thead>
<tr>
<th>${renderSortButton("Opportunity", DETAIL_SORT_KEYS.opportunityNumber)}</th>
<th>${renderSortButton("Customer", DETAIL_SORT_KEYS.customerName)}</th>
<th>${renderSortButton("Workload", DETAIL_SORT_KEYS.workload)}</th>
${includeConsumptionStartDate ? "<th>consumptionStartDate</th>" : ""}
<th class="is-numeric">${renderSortButton("Ramp months", DETAIL_SORT_KEYS.rampMonths, true)}</th>
<th class="is-numeric">${renderSortButton("ACR", DETAIL_SORT_KEYS.adjustedACR, true)}</th>
<th>${renderSortButton("Type", DETAIL_SORT_KEYS.opportunityForecastTypeGroup)}</th>
<th>${renderSortButton("Tem SR?", DETAIL_SORT_KEYS.hasSR)}</th>
<th>${renderSortButton("Tem Action?", DETAIL_SORT_KEYS.hasAction)}</th>
</tr>
</thead>
<tbody>
${items
.map((workload) =>
renderWorkloadTableRow(workload, includeConsumptionStartDate, true)
)
.join("")}
</tbody>
</table>
</div>
`;
}
return {
renderDetailModal,
renderActionFormModal,
renderRampComparisonModal,
renderForecastUpdateConfirmModal,
renderTimeEntriesDrawer,
};
}
globalThis.ArchPanelWorkbenchModalsView = Object.freeze({
create,
});
})();

View File

@@ -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"