Compare commits
10 Commits
88787a054a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 086cad88f5 | |||
|
|
904e210ac8 | ||
|
|
945da04db0 | ||
|
|
cbf9550f0b | ||
|
|
29a461ea63 | ||
|
|
946fab12ca | ||
|
|
f38bae4479 | ||
|
|
c989088ab1 | ||
|
|
f196067b21 | ||
|
|
f574ba936c |
161
README.md
161
README.md
@@ -1,27 +1,156 @@
|
|||||||
# Arch Panel Injector
|
# Arch Panel
|
||||||
|
|
||||||
Extensao Chrome Manifest V3 que adiciona o botao `Arch panel` ao header da pagina Oracle:
|
Extensao Chrome Manifest V3 que injeta o Arch Panel em paginas Oracle usadas pelo time de arquitetura, incluindo Workload Workbench e HCM FuseWelcome.
|
||||||
|
|
||||||
`https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=workload_workbench`
|
## Entry points
|
||||||
|
|
||||||
## O que ela faz
|
- `manifest.json`: fonte oficial da versao, permissoes e content scripts.
|
||||||
|
- `src/shared/config.js`: configuracao centralizada, constantes, IDs, endpoints e chaves de cache.
|
||||||
|
- `src/shared/utils.js`: helpers compartilhados de strings, numeros, datas, DOM, escaping e extração de payloads.
|
||||||
|
- `src/background/config.js`: configuracao especifica do service worker e URLs usadas pelos proxies de background.
|
||||||
|
- `src/content/styles.css`: estilos da UI injetados pelo Manifest V3.
|
||||||
|
- `src/content/shell-view.js`: shell visual injetado na pagina, incluindo tema, cores, alvo de insercao e scroll lock.
|
||||||
|
- `src/content/templates.js`: templates estaticos de DOM usados pelo content script.
|
||||||
|
- `src/content/hcm-page.js`: integracao com FuseWelcome, injecao do tile HCM e captura do token Worklist via iframe.
|
||||||
|
- `src/content/modal-events.js`: controller de eventos delegados da modal, incluindo clicks, inputs e navegacao por teclado.
|
||||||
|
- `src/content/workbench-controller.js`: controller de Workbench, dataset, refresh, detalhes, actions, forecast e calendario.
|
||||||
|
- `src/content/manage-time-controller.js`: controller da modal Manage Time, incluindo carregamento, diff, foco, validacao e gravacao.
|
||||||
|
- `src/content/export-html.js`: geracao do relatorio HTML standalone baixado pelo usuario.
|
||||||
|
- `src/repositories/workbench-repository.js`: acesso e normalizacao de dados do Workload Workbench e Worklist user API.
|
||||||
|
- `src/repositories/comcip-repository.js`: acesso e normalizacao de dados COMCIP, Manage Time e Service Requests.
|
||||||
|
- `src/repositories/cache-repository.js`: persistencia dos payloads de dashboard, task types e token Worklist no IndexedDB.
|
||||||
|
- `src/domain/ramp-forecast.js`: regras puras de calculo de ramp, delta de forecast e payload de atualizacao de forecast.
|
||||||
|
- `src/domain/workload-dataset.js`: regras puras de normalizacao de workloads, snapshots, export payloads e indicadores do dashboard.
|
||||||
|
- `src/domain/workload-calendar.js`: regras puras para calendario mensal/semanal de inicio dos workloads.
|
||||||
|
- `src/domain/time-management.js`: regras puras de diff, assinatura, payload, validacao e datas do Manage time.
|
||||||
|
- `src/domain/time-management-data.js`: normalizacao de payloads, options e DTOs usados pela modal Manage time.
|
||||||
|
- `src/services/storage-service.js`: infraestrutura generica de IndexedDB.
|
||||||
|
- `src/services/chrome-service.js`: wrappers Promise-based para APIs do Chrome usadas pelo content script.
|
||||||
|
- `src/services/preferences-service.js`: persistencia local de preferencias visuais e ultima data selecionada no Manage Time.
|
||||||
|
- `src/services/transport-service.js`: camada de transporte, page bridge, iframes autenticados e requests via background.
|
||||||
|
- `src/views/render-helpers.js`: helpers reutilizaveis de apresentacao para tabelas, links e resumo do header.
|
||||||
|
- `content-script.js`: entrypoint principal da UI e orquestracao de estado, eventos e requests.
|
||||||
|
- `page-bridge.js`: bridge executada no contexto da pagina SPA para capturar e reutilizar headers autorizados.
|
||||||
|
- `background.js`: service worker responsavel por proxy de requests, descoberta de frames e execucao em frames autenticados.
|
||||||
|
- `tools/bump-extension-version.ps1`: script obrigatorio para atualizar a versao da extensao.
|
||||||
|
|
||||||
- roda apenas na pagina alvo do Oracle Workload Workbench
|
## Estrutura atual
|
||||||
- injeta o botao imediatamente a esquerda de `.oj-oal-ux-global-header-content`
|
|
||||||
- reaproveita as cores e a tipografia do proprio header para manter compatibilidade visual
|
|
||||||
- observa mudancas da SPA para recolocar o botao se o layout for remontado
|
|
||||||
|
|
||||||
## Como instalar no Chrome
|
```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`
|
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.
|
||||||
2. Ative `Developer mode`
|
|
||||||
3. Clique em `Load unpacked`
|
|
||||||
4. Selecione a pasta `C:\Codex\Projects\arch-central-extension-browser`
|
|
||||||
|
|
||||||
## 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
|
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.
|
||||||
- `content-script.js`: valida a URL, encontra o header e injeta o botao
|
|
||||||
|
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
|
## Controle de versao
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
const COMCIP_ORIGIN = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
importScripts("src/background/config.js");
|
||||||
const COMCIP_APP_URL =
|
|
||||||
`${COMCIP_ORIGIN}/ic/builder/rt/oalset_semc/live/webApps/Dashboard/?page=shell&shell=main&main=service-requests-detailed-view`;
|
const {
|
||||||
const COMCIP_QUERY_URL =
|
COMCIP_ORIGIN,
|
||||||
`${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`;
|
COMCIP_APP_URL,
|
||||||
const COMCIP_CLIENT_ID_PROBE_URL =
|
COMCIP_QUERY_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`;
|
COMCIP_CLIENT_ID_PROBE_URL,
|
||||||
const COMCIP_TIME_ENTRY_APP_URL =
|
COMCIP_TIME_ENTRY_APP_URL,
|
||||||
`${COMCIP_ORIGIN}/ic/builder/rt/oalset_timeentrymobile/live/webApps/timeentry/`;
|
COMCIP_TIME_ENTRY_CLIENT_ID_PROBE_URL,
|
||||||
const COMCIP_TIME_ENTRY_CLIENT_ID_PROBE_URL =
|
COMCIP_TIME_ENTRY_APP_VERSION,
|
||||||
`${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`;
|
COMCIP_TAB_URL_PATTERN,
|
||||||
const COMCIP_TIME_ENTRY_APP_VERSION = "version_1774016034953";
|
COMCIP_TIME_ENTRY_TAB_URL_PATTERN,
|
||||||
const COMCIP_TAB_URL_PATTERN =
|
SPA_WORKBENCH_FRAME_URL,
|
||||||
`${COMCIP_ORIGIN}/ic/builder/rt/oalset_semc/live*`;
|
SPA_WORKBENCH_FRAME_ID,
|
||||||
const COMCIP_TIME_ENTRY_TAB_URL_PATTERN =
|
SPA_WORKBENCH_FRAME_HOST_ID,
|
||||||
`${COMCIP_ORIGIN}/ic/builder/rt/oalset_timeentrymobile/live*`;
|
SPA_FRAME_DNR_RULE_ID,
|
||||||
const SPA_WORKBENCH_FRAME_URL =
|
} = globalThis.ArchPanelBackgroundConfig;
|
||||||
"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;
|
|
||||||
|
|
||||||
void installSpaFrameHeaderRules();
|
void installSpaFrameHeaderRules();
|
||||||
|
|
||||||
@@ -63,7 +59,6 @@ async function installSpaFrameHeaderRules() {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
condition: {
|
condition: {
|
||||||
initiatorDomains: ["eeho.fa.us2.oraclecloud.com"],
|
|
||||||
requestDomains: ["spa.oracle.com"],
|
requestDomains: ["spa.oracle.com"],
|
||||||
resourceTypes: ["sub_frame"],
|
resourceTypes: ["sub_frame"],
|
||||||
urlFilter: "||spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html",
|
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);
|
return executeSpaFrameScriptFetch(senderTabId, url, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureSpaFrameElementInTab(tabId) {
|
async function ensureSpaFrameElementInTab(tabId, options = {}) {
|
||||||
await chrome.scripting.executeScript({
|
await chrome.scripting.executeScript({
|
||||||
target: { tabId, frameIds: [0] },
|
target: { tabId, frameIds: [0] },
|
||||||
world: "MAIN",
|
world: "MAIN",
|
||||||
@@ -189,8 +184,9 @@ async function ensureSpaFrameElementInTab(tabId) {
|
|||||||
SPA_WORKBENCH_FRAME_HOST_ID,
|
SPA_WORKBENCH_FRAME_HOST_ID,
|
||||||
SPA_WORKBENCH_FRAME_ID,
|
SPA_WORKBENCH_FRAME_ID,
|
||||||
SPA_WORKBENCH_FRAME_URL,
|
SPA_WORKBENCH_FRAME_URL,
|
||||||
|
Boolean(options.forceReload),
|
||||||
],
|
],
|
||||||
func: (hostId, frameId, frameUrl) => {
|
func: (hostId, frameId, frameUrl, forceReload) => {
|
||||||
const desiredParent = document.body || document.documentElement;
|
const desiredParent = document.body || document.documentElement;
|
||||||
|
|
||||||
if (!desiredParent) {
|
if (!desiredParent) {
|
||||||
@@ -250,7 +246,16 @@ async function ensureSpaFrameElementInTab(tabId) {
|
|||||||
host.appendChild(frame);
|
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;
|
frame.src = frameUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,8 +267,23 @@ async function ensureSpaFrameElementInTab(tabId) {
|
|||||||
async function waitForSpaFramePresent(tabId) {
|
async function waitForSpaFramePresent(tabId) {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let lastFrames = "";
|
let lastFrames = "";
|
||||||
|
let lastEnsureAt = 0;
|
||||||
|
let forceReloaded = false;
|
||||||
|
|
||||||
while (Date.now() - startedAt < 45000) {
|
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 frames = await getFrameHints(tabId).catch(() => []);
|
||||||
const spaFrame = frames.find((frame) => isSpaFrameHint(frame));
|
const spaFrame = frames.find((frame) => isSpaFrameHint(frame));
|
||||||
|
|
||||||
|
|||||||
12302
content-script.js
12302
content-script.js
File diff suppressed because it is too large
Load Diff
164
manifest.json
164
manifest.json
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Arch Panel Injector",
|
"name": "Arch Panel",
|
||||||
"version": "1.0.2",
|
"version": "1.0.10",
|
||||||
"description": "Insere o botao Arch panel no header do Oracle Workload Workbench.",
|
"description": "Insere o botao Arch panel no header do Oracle Workload Workbench.",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"cookies",
|
|
||||||
"declarativeNetRequestWithHostAccess",
|
"declarativeNetRequestWithHostAccess",
|
||||||
"scripting",
|
"scripting",
|
||||||
"tabs"
|
"tabs"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*",
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*",
|
||||||
|
"https://*.oraclecloud.com/*",
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
"https://spa.oracle.com/*",
|
"https://spa.oracle.com/*",
|
||||||
"https://worklist.oracle.com/*"
|
"https://worklist.oracle.com/*"
|
||||||
@@ -32,10 +32,38 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matches": [
|
"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": [
|
"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"
|
"content-script.js"
|
||||||
],
|
],
|
||||||
"run_at": "document_start"
|
"run_at": "document_start"
|
||||||
@@ -47,132 +75,10 @@
|
|||||||
"page-bridge.js"
|
"page-bridge.js"
|
||||||
],
|
],
|
||||||
"matches": [
|
"matches": [
|
||||||
|
"https://*.oraclecloud.com/*",
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
"https://spa.oracle.com/*"
|
"https://spa.oracle.com/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
const EXTRA_CAPTURE_ORIGINS = new Set([
|
const EXTRA_CAPTURE_ORIGINS = new Set([
|
||||||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com",
|
"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([
|
const SAFE_HEADER_BLOCKLIST = new Set([
|
||||||
"accept-encoding",
|
"accept-encoding",
|
||||||
"accept-language",
|
"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 nativeFetch = window.fetch.bind(window);
|
||||||
const originalOpen = window.XMLHttpRequest.prototype.open;
|
const originalOpen = window.XMLHttpRequest.prototype.open;
|
||||||
const originalSetRequestHeader = window.XMLHttpRequest.prototype.setRequestHeader;
|
const originalSetRequestHeader = window.XMLHttpRequest.prototype.setRequestHeader;
|
||||||
@@ -215,15 +285,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
safePostMessage(
|
safePostToTop(
|
||||||
{
|
{
|
||||||
source: MESSAGE_SOURCE,
|
source: MESSAGE_SOURCE,
|
||||||
type: "ARCH_PANEL_WORKBENCH_AUTH_HEADERS",
|
type: "ARCH_PANEL_WORKBENCH_AUTH_HEADERS",
|
||||||
url: normalizeUrl(url),
|
url: normalizeUrl(url),
|
||||||
headers: authHeaders,
|
headers: authHeaders,
|
||||||
},
|
}
|
||||||
window.top,
|
|
||||||
"*"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +424,7 @@
|
|||||||
const key = storage.key(index) || "";
|
const key = storage.key(index) || "";
|
||||||
const value = storage.getItem(key) || "";
|
const value = storage.getItem(key) || "";
|
||||||
|
|
||||||
if (key || value) {
|
if (isAuthStorageCandidate(key, value)) {
|
||||||
candidates.push([key, value]);
|
candidates.push([key, value]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,6 +440,25 @@
|
|||||||
return headers;
|
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) {
|
function collectAuthFromStorageEntry(headers, key, value) {
|
||||||
const normalizedKey = String(key || "").toLowerCase();
|
const normalizedKey = String(key || "").toLowerCase();
|
||||||
const normalizedValue = String(value || "").trim();
|
const normalizedValue = String(value || "").trim();
|
||||||
|
|||||||
39
src/background/config.js
Normal file
39
src/background/config.js
Normal 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
653
src/content/export-html.js
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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
146
src/content/hcm-page.js
Normal 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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
1145
src/content/manage-time-controller.js
Normal file
1145
src/content/manage-time-controller.js
Normal file
File diff suppressed because it is too large
Load Diff
866
src/content/modal-events.js
Normal file
866
src/content/modal-events.js
Normal 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
328
src/content/shell-view.js
Normal 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
2818
src/content/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
15
src/content/templates.js
Normal file
15
src/content/templates.js
Normal 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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
1037
src/content/workbench-controller.js
Normal file
1037
src/content/workbench-controller.js
Normal file
File diff suppressed because it is too large
Load Diff
216
src/domain/ramp-forecast.js
Normal file
216
src/domain/ramp-forecast.js
Normal 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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
985
src/domain/time-management-data.js
Normal file
985
src/domain/time-management-data.js
Normal 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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
240
src/domain/time-management.js
Normal file
240
src/domain/time-management.js
Normal 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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
107
src/domain/workload-calendar.js
Normal file
107
src/domain/workload-calendar.js
Normal 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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
324
src/domain/workload-dataset.js
Normal file
324
src/domain/workload-dataset.js
Normal 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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
108
src/repositories/cache-repository.js
Normal file
108
src/repositories/cache-repository.js
Normal 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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
438
src/repositories/comcip-repository.js
Normal file
438
src/repositories/comcip-repository.js
Normal 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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
467
src/repositories/workbench-repository.js
Normal file
467
src/repositories/workbench-repository.js
Normal 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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
25
src/services/chrome-service.js
Normal file
25
src/services/chrome-service.js
Normal 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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
122
src/services/preferences-service.js
Normal file
122
src/services/preferences-service.js
Normal 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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
95
src/services/storage-service.js
Normal file
95
src/services/storage-service.js
Normal 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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
757
src/services/transport-service.js
Normal file
757
src/services/transport-service.js
Normal 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
192
src/shared/config.js
Normal 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
514
src/shared/utils.js
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssEscape(value) {
|
||||||
|
if (window.CSS?.escape) {
|
||||||
|
return window.CSS.escape(String(value || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value || "").replace(/["\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.ArchPanelUtils = Object.freeze({
|
||||||
|
normalizeText,
|
||||||
|
cleanString,
|
||||||
|
firstNonEmptyString,
|
||||||
|
normalizeString,
|
||||||
|
toNumber,
|
||||||
|
toIsoDateString,
|
||||||
|
extractArray,
|
||||||
|
mapWithConcurrency,
|
||||||
|
getFieldValue,
|
||||||
|
flattenDisplayRecord,
|
||||||
|
isVisible,
|
||||||
|
formatWholeNumber,
|
||||||
|
formatCurrency,
|
||||||
|
formatHours,
|
||||||
|
formatWeekNumber,
|
||||||
|
formatOperationalWeekLabel,
|
||||||
|
formatPercent,
|
||||||
|
formatProgressPercent,
|
||||||
|
formatDate,
|
||||||
|
getDateInputValue,
|
||||||
|
formatDateInputDisplay,
|
||||||
|
formatDateForTimeEntryRequest,
|
||||||
|
parseTimeEntryDate,
|
||||||
|
formatDateTime,
|
||||||
|
getLocalDateKey,
|
||||||
|
getOperationalWeekInfo,
|
||||||
|
getCalendarRowWeekDate,
|
||||||
|
parseDatePreservingDateOnly,
|
||||||
|
decodeJwtPayload,
|
||||||
|
getJwtExpiresAt,
|
||||||
|
isJwtExpired,
|
||||||
|
getErrorMessage,
|
||||||
|
escapeHtml,
|
||||||
|
cssEscape,
|
||||||
|
});
|
||||||
|
})();
|
||||||
731
src/views/dashboard-view.js
Normal file
731
src/views/dashboard-view.js
Normal 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 >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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
760
src/views/manage-time-view.js
Normal file
760
src/views/manage-time-view.js
Normal 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
213
src/views/render-helpers.js
Normal 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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
518
src/views/workbench-modals-view.js
Normal file
518
src/views/workbench-modals-view.js
Normal 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,
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -61,5 +61,6 @@ $updatedManifest = [regex]::Replace(
|
|||||||
1
|
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"
|
Write-Output "Extension version updated: $currentVersion -> $nextVersion"
|
||||||
|
|||||||
Reference in New Issue
Block a user