12274 lines
364 KiB
JavaScript
12274 lines
364 KiB
JavaScript
(() => {
|
||
const TARGET_ORIGIN = "https://spa.oracle.com";
|
||
const TARGET_PATHNAME =
|
||
"/oalcrm/web/api/g2m-consumer-application/ui/index.html";
|
||
const TARGET_PARAM = "ojr";
|
||
const TARGET_PARAM_VALUE = "workload_workbench";
|
||
const TARGET_APP_URL =
|
||
"https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=workload_workbench";
|
||
const HCM_ORIGIN = "https://eeho.fa.us2.oraclecloud.com";
|
||
const HCM_WELCOME_PATHNAME = "/hcmUI/faces/FuseWelcome";
|
||
const HCM_MY_INFORMATION_APPS_GROUP_ID = "yourapps_groupNode_my_information";
|
||
const WORKLIST_ORIGIN = "https://worklist.oracle.com";
|
||
const WORKLIST_SAASUI_PATHNAME = "/oalapp/pub/worklist/saasui/index.html";
|
||
const USER_BUTTON_LABEL = "[[displayUser().fullName]]";
|
||
const ALLOWED_STAGE_TYPES = ["SQL", "Pipeline", "Upside", "Forecast"];
|
||
|
||
const SELECTORS = {
|
||
header: ".oj-oal-ux-global-header.oj-oal-ux-global-header-pillar-cx",
|
||
functions: ".oj-flex.oj-oal-ux-global-header-functions",
|
||
};
|
||
|
||
const IDS = {
|
||
mount: "arch-panel-extension-mount",
|
||
button: "arch-panel-extension-button",
|
||
style: "arch-panel-extension-style",
|
||
overlay: "arch-panel-extension-overlay",
|
||
dialog: "arch-panel-extension-dialog",
|
||
hcmTile: "arch-panel-extension-hcm-tile",
|
||
spaFrame: "arch-panel-extension-spa-frame",
|
||
spaFrameHost: "arch-panel-extension-spa-frame-host",
|
||
};
|
||
|
||
const CLASSES = {
|
||
modalOpen: "arch-panel-extension-modal-open",
|
||
overlayOpen: "is-open",
|
||
};
|
||
|
||
const STORAGE_KEYS = {
|
||
theme: "arch-panel-extension-theme",
|
||
sidebarCollapsed: "arch-panel-extension-sidebar-collapsed",
|
||
calendarView: "arch-panel-extension-calendar-view",
|
||
timeManagementSelectedDate: "arch-panel-extension-time-management-selected-date",
|
||
};
|
||
|
||
const CACHE = {
|
||
dbName: "arch-panel-extension-db",
|
||
dbVersion: 1,
|
||
storeName: "datasets",
|
||
datasetKey: "workload-dashboard",
|
||
taskTypeKey: "time-management-task-types",
|
||
worklistTokenKey: "worklist-saasui-token",
|
||
};
|
||
|
||
const DETAIL_SORT_KEYS = {
|
||
opportunityNumber: "opportunityNumber",
|
||
customerName: "customerName",
|
||
workload: "workload",
|
||
rampMonths: "rampMonths",
|
||
adjustedACR: "adjustedACR",
|
||
opportunityForecastTypeGroup: "opportunityForecastTypeGroup",
|
||
hasSR: "hasSR",
|
||
hasAction: "hasAction",
|
||
};
|
||
|
||
const BRIDGE = {
|
||
source: "arch-panel-extension",
|
||
fetchType: "ARCH_PANEL_FETCH",
|
||
resultType: "ARCH_PANEL_FETCH_RESULT",
|
||
headersType: "ARCH_PANEL_HEADERS",
|
||
headersResultType: "ARCH_PANEL_HEADERS_RESULT",
|
||
workbenchAuthHeadersType: "ARCH_PANEL_WORKBENCH_AUTH_HEADERS",
|
||
readyType: "ARCH_PANEL_BRIDGE_READY",
|
||
scriptId: "arch-panel-extension-page-bridge",
|
||
};
|
||
|
||
const API_ENDPOINTS = {
|
||
currentUser:
|
||
"/oalcrm/web/api/identity-management/users/current",
|
||
worklistCurrentUser:
|
||
"https://worklist.oracle.com/oalapp/pub/wlp/svc/user-service/user",
|
||
customerSummary:
|
||
"/oalcrm/web/api/provider-proxy/wwb-provider/service/elastic/customers/listSummary",
|
||
customerWorkloads:
|
||
"/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/customers",
|
||
workloadActions:
|
||
"/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions",
|
||
workloadServiceRequests:
|
||
"/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads",
|
||
};
|
||
|
||
const COMCIP_REQUEST = {
|
||
url:
|
||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_semc/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPIElastic/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries",
|
||
timeEntriesSummaryUrl:
|
||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_semc/live;profile=PROD/services/auth/1.1/proxy/oalsetSeaaSOKECustomRestAPI/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries/summary",
|
||
resourceCurrentUserUrl:
|
||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/resourceUsers",
|
||
timeEntryWeekDetailsUrl:
|
||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries/weekDetails",
|
||
activeServiceRequestsUrl:
|
||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries/activeServiceRequests",
|
||
timeEntriesUrl:
|
||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries",
|
||
activityServiceRequestsUrl:
|
||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/crmRestApiSearchResourcesLatestCustomActions/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests",
|
||
taskTypeUrl:
|
||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/lookups?lookupType=SCTA_TASK_TYPE&type=CUSTOM",
|
||
timeEntryAppUrl:
|
||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live/webApps/timeentry/",
|
||
appVersion: "version_1754044416761",
|
||
};
|
||
const COMCIP_TIME_ENTRY_FRAME_ID = "arch-panel-extension-comcip-timeentry-frame";
|
||
const ACTIVITY_REQUEST_REFERENCE_SR_NUMBERS = [
|
||
"SR0001440593",
|
||
"SR0001440475",
|
||
"SR0001434642",
|
||
"SR0001434671",
|
||
"SR0001387564",
|
||
"SR0001439267",
|
||
"SR0001411935",
|
||
"SR0001428065",
|
||
"SR0001433200",
|
||
"SR0001433217",
|
||
"SR0001433030",
|
||
"SR0001430407",
|
||
"SR0001432386",
|
||
"SR0001431923",
|
||
"SR0001418256",
|
||
"SR0001398520",
|
||
"SR0001377462",
|
||
"SR0001367044",
|
||
"SR0001419998",
|
||
"SR0001399117",
|
||
"SR0001413684",
|
||
"SR0001422541",
|
||
"SR0001415397",
|
||
"SR0001418535",
|
||
"SR0001421040",
|
||
"SR0001421074",
|
||
"SR0001388866",
|
||
"SR0001335148",
|
||
"SR0001416756",
|
||
"SR0001380511",
|
||
"SR0001413965",
|
||
"SR0001413598",
|
||
"SR0001409121",
|
||
"SR0001404828",
|
||
"SR0001404549",
|
||
"SR0001402376",
|
||
"SR0001399121",
|
||
"SR0001294405",
|
||
"SR0001308472",
|
||
];
|
||
|
||
const ACTION_TEAM_OPTIONS = [
|
||
{
|
||
value: "DM_AUTONOMOUS",
|
||
label: "Data Platform - Autonomous Database",
|
||
},
|
||
{
|
||
value: "CI_COMPUTE",
|
||
label: "Cloud Infrastructure - Compute including HPC",
|
||
},
|
||
{
|
||
value: "APP_DEV_AI_CLOUD_SERVICES",
|
||
label: "App Dev - AI Cloud Services",
|
||
},
|
||
{
|
||
value: "DEV_CLOUD_NATIVE_APP",
|
||
label: "App Dev - Cloud Native",
|
||
},
|
||
{
|
||
value: "CI_BIG_COMPUTE_HPC",
|
||
label: "Cloud Infrastructure - AI Infra/GPU",
|
||
},
|
||
{
|
||
value: "CI_MULTI_CLOUD",
|
||
label: "Cloud Infrastructure - Multi-Cloud",
|
||
},
|
||
{
|
||
value: "CI_STORAGE",
|
||
label: "Cloud Infrastructure - Storage",
|
||
},
|
||
{
|
||
value: "CI_VMWARE",
|
||
label: "Cloud Infrastructure - VMware",
|
||
},
|
||
{
|
||
value: "DALH_DESIGN",
|
||
label: "Data Platform - Analytical Data Platform/Lakehouse",
|
||
},
|
||
{
|
||
value: "DATA_INTEGRATION",
|
||
label: "Data Platform - Data Integration",
|
||
},
|
||
{
|
||
value: "DM_UPGRADE",
|
||
label: "Data Platform - Modernise",
|
||
},
|
||
];
|
||
|
||
const ACTION_COMPLEXITY_OPTIONS = ["LOW", "MEDIUM", "HIGH", "VERY_HIGH"];
|
||
|
||
const appState = {
|
||
theme: getStoredTheme(),
|
||
sidebarCollapsed: getStoredSidebarCollapsed(),
|
||
calendarYear: new Date().getFullYear(),
|
||
calendarMonth: new Date().getMonth(),
|
||
calendarView: getStoredCalendarView(),
|
||
calendarAnchorDateKey: getLocalDateKey(new Date()),
|
||
status: "idle",
|
||
loadingMessage: "",
|
||
loadingProgress: 0,
|
||
errorMessage: "",
|
||
dataset: null,
|
||
sessionAuthHeaders: {},
|
||
detailModal: null,
|
||
actionFormModal: null,
|
||
rampComparisonModal: null,
|
||
forecastUpdateConfirmModal: null,
|
||
timeEntriesDrawer: null,
|
||
timeManagementModal: null,
|
||
taskTypeCache: {
|
||
promise: null,
|
||
},
|
||
timeManagementActivityCache: new Map(),
|
||
pendingTimeManagementHighlightRowId: "",
|
||
timeManagementDatePicker: {
|
||
isOpen: false,
|
||
year: new Date().getFullYear(),
|
||
month: new Date().getMonth(),
|
||
},
|
||
pendingFocusSelector: "",
|
||
};
|
||
|
||
let syncFrame = 0;
|
||
let restoreFocusTarget = null;
|
||
let closeTimer = 0;
|
||
let requestCounter = 0;
|
||
let bridgeReadyPromise = null;
|
||
let cacheHydrationPromise = null;
|
||
let lastPersistedWorklistIframeUrl = "";
|
||
const pendingBridgeRequests = new Map();
|
||
|
||
function isTargetPage() {
|
||
const currentUrl = new URL(window.location.href);
|
||
|
||
return (
|
||
currentUrl.origin === TARGET_ORIGIN &&
|
||
currentUrl.pathname === TARGET_PATHNAME &&
|
||
currentUrl.searchParams.get(TARGET_PARAM) === TARGET_PARAM_VALUE
|
||
);
|
||
}
|
||
|
||
function isHcmWelcomePage() {
|
||
const currentUrl = new URL(window.location.href);
|
||
|
||
return (
|
||
currentUrl.origin === HCM_ORIGIN &&
|
||
currentUrl.pathname === HCM_WELCOME_PATHNAME
|
||
);
|
||
}
|
||
|
||
function scheduleSync() {
|
||
if (syncFrame) {
|
||
return;
|
||
}
|
||
|
||
syncFrame = window.requestAnimationFrame(() => {
|
||
syncFrame = 0;
|
||
syncButton();
|
||
});
|
||
}
|
||
|
||
function syncButton() {
|
||
const mount = document.getElementById(IDS.mount);
|
||
const hcmTile = document.getElementById(IDS.hcmTile);
|
||
|
||
if (isHcmWelcomePage()) {
|
||
mount?.remove();
|
||
ensureStyles();
|
||
ensureModal();
|
||
syncHcmTile();
|
||
ensureSpaFrame();
|
||
syncWorklistIframeToken();
|
||
return;
|
||
}
|
||
|
||
if (!isTargetPage()) {
|
||
mount?.remove();
|
||
hcmTile?.remove();
|
||
removeModal();
|
||
unlockScroll();
|
||
return;
|
||
}
|
||
|
||
hcmTile?.remove();
|
||
|
||
const header = document.querySelector(SELECTORS.header);
|
||
const functionsContainer = header?.querySelector(SELECTORS.functions);
|
||
const anchor = functionsContainer ? findUserAnchor(functionsContainer) : null;
|
||
|
||
if (!header || !functionsContainer) {
|
||
return;
|
||
}
|
||
|
||
ensureStyles();
|
||
|
||
const themedMount = mount ?? createMount();
|
||
const overlay = ensureModal();
|
||
|
||
applyTheme(header, functionsContainer, anchor, themedMount, overlay);
|
||
|
||
const targetSibling = resolveInsertionTarget(
|
||
functionsContainer,
|
||
anchor,
|
||
themedMount
|
||
);
|
||
|
||
if (
|
||
themedMount.parentElement !== functionsContainer ||
|
||
themedMount.nextElementSibling !== targetSibling
|
||
) {
|
||
const verifiedTarget =
|
||
targetSibling && targetSibling.parentElement === functionsContainer
|
||
? targetSibling
|
||
: null;
|
||
|
||
if (verifiedTarget) {
|
||
functionsContainer.insertBefore(themedMount, verifiedTarget);
|
||
} else {
|
||
functionsContainer.appendChild(themedMount);
|
||
}
|
||
}
|
||
}
|
||
|
||
function syncHcmTile() {
|
||
const appsGroup = document.getElementById(HCM_MY_INFORMATION_APPS_GROUP_ID);
|
||
const addTile = appsGroup?.querySelector(".flat-grid-cell.flat-grid-cell-addicon");
|
||
|
||
if (!addTile?.parentElement) {
|
||
document.getElementById(IDS.hcmTile)?.remove();
|
||
return;
|
||
}
|
||
|
||
const tile = document.getElementById(IDS.hcmTile) || createHcmTile();
|
||
|
||
if (tile.parentElement !== addTile.parentElement || tile.nextElementSibling !== addTile) {
|
||
addTile.parentElement.insertBefore(tile, addTile);
|
||
}
|
||
}
|
||
|
||
function syncWorklistIframeToken(options = {}) {
|
||
if (!isHcmWelcomePage()) {
|
||
return null;
|
||
}
|
||
|
||
for (const frame of document.querySelectorAll("iframe")) {
|
||
const tokenPayload = extractWorklistIframeToken(frame);
|
||
|
||
if (tokenPayload?.token) {
|
||
persistWorklistIframeToken(tokenPayload, options);
|
||
return tokenPayload;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function extractWorklistIframeToken(frame) {
|
||
const rawSrc = cleanString(frame?.getAttribute?.("src") || frame?.src);
|
||
|
||
if (!rawSrc) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const url = new URL(rawSrc, window.location.href);
|
||
const token = cleanString(url.searchParams.get("token"));
|
||
|
||
if (
|
||
url.origin !== WORKLIST_ORIGIN ||
|
||
url.pathname !== WORKLIST_SAASUI_PATHNAME ||
|
||
!token
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
token,
|
||
url: url.toString(),
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function persistWorklistIframeToken(tokenPayload, options = {}) {
|
||
if (!options.force && tokenPayload.url === lastPersistedWorklistIframeUrl) {
|
||
return;
|
||
}
|
||
|
||
lastPersistedWorklistIframeUrl = tokenPayload.url;
|
||
void writeCachedWorklistToken(tokenPayload);
|
||
}
|
||
|
||
function createHcmTile() {
|
||
const template = document.createElement("template");
|
||
template.innerHTML = `
|
||
<div class="flat-grid-cell" id="${IDS.hcmTile}">
|
||
<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>
|
||
`;
|
||
const tile = template.content.firstElementChild;
|
||
|
||
tile.addEventListener("click", openHcmArchPanelTile);
|
||
tile.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
openHcmArchPanelTile(event);
|
||
}
|
||
});
|
||
tile.tabIndex = 0;
|
||
tile.setAttribute("role", "button");
|
||
tile.setAttribute("aria-label", "Open Arch Panel");
|
||
|
||
return tile;
|
||
}
|
||
|
||
function openHcmArchPanelTile(event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
openModal();
|
||
}
|
||
|
||
function createMount() {
|
||
const mount = document.createElement("div");
|
||
mount.id = IDS.mount;
|
||
mount.className = "arch-panel-extension-entry";
|
||
|
||
const button = document.createElement("button");
|
||
button.id = IDS.button;
|
||
button.type = "button";
|
||
button.className = "arch-panel-extension-button";
|
||
button.textContent = "Arch panel";
|
||
button.setAttribute("aria-label", "Open Arch panel");
|
||
button.addEventListener("click", openModal);
|
||
|
||
mount.appendChild(button);
|
||
|
||
return mount;
|
||
}
|
||
|
||
function ensurePageBridge() {
|
||
if (bridgeReadyPromise) {
|
||
return bridgeReadyPromise;
|
||
}
|
||
|
||
bridgeReadyPromise = new Promise((resolve) => {
|
||
const existingScript = document.getElementById(BRIDGE.scriptId);
|
||
|
||
if (existingScript) {
|
||
resolve();
|
||
return;
|
||
}
|
||
|
||
const script = document.createElement("script");
|
||
script.id = BRIDGE.scriptId;
|
||
script.src = chrome.runtime.getURL("page-bridge.js");
|
||
script.async = false;
|
||
script.onload = () => {
|
||
script.remove();
|
||
resolve();
|
||
};
|
||
script.onerror = () => {
|
||
resolve();
|
||
};
|
||
|
||
(document.head || document.documentElement).appendChild(script);
|
||
});
|
||
|
||
return bridgeReadyPromise;
|
||
}
|
||
|
||
function ensureModal() {
|
||
const existingOverlay = document.getElementById(IDS.overlay);
|
||
|
||
if (existingOverlay) {
|
||
return existingOverlay;
|
||
}
|
||
|
||
const overlay = document.createElement("div");
|
||
overlay.id = IDS.overlay;
|
||
overlay.className = "arch-panel-extension-overlay";
|
||
overlay.hidden = true;
|
||
overlay.setAttribute("aria-hidden", "true");
|
||
overlay.addEventListener("click", handleOverlayClick);
|
||
overlay.addEventListener("change", handleOverlayChange);
|
||
overlay.addEventListener("input", handleOverlayInput);
|
||
overlay.addEventListener("keydown", handleOverlayKeydown);
|
||
|
||
document.body.appendChild(overlay);
|
||
renderModal();
|
||
|
||
return overlay;
|
||
}
|
||
|
||
function handleOverlayClick(event) {
|
||
const overlay = document.getElementById(IDS.overlay);
|
||
|
||
if (!overlay) {
|
||
return;
|
||
}
|
||
|
||
if (event.target === overlay) {
|
||
if (appState.actionFormModal) {
|
||
appState.actionFormModal = null;
|
||
renderModal();
|
||
} else if (appState.timeManagementModal) {
|
||
appState.timeManagementModal = null;
|
||
renderModal();
|
||
} else if (appState.forecastUpdateConfirmModal) {
|
||
appState.forecastUpdateConfirmModal = null;
|
||
renderModal();
|
||
} else if (appState.rampComparisonModal) {
|
||
appState.rampComparisonModal = null;
|
||
renderModal();
|
||
} else if (appState.detailModal) {
|
||
closeDetailModal();
|
||
renderModal();
|
||
} else {
|
||
closeModal();
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
const actionElement = event.target.closest("[data-action]");
|
||
|
||
if (!actionElement) {
|
||
return;
|
||
}
|
||
|
||
const action = actionElement.getAttribute("data-action");
|
||
|
||
if (
|
||
action === "time-management-date-change" ||
|
||
action === "time-management-sr-change" ||
|
||
action === "time-management-activity-change" ||
|
||
action === "time-management-task-type-change"
|
||
) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
|
||
if (action === "close") {
|
||
closeModal();
|
||
return;
|
||
}
|
||
|
||
if (action === "toggle-theme") {
|
||
toggleTheme();
|
||
return;
|
||
}
|
||
|
||
if (action === "toggle-sidebar") {
|
||
toggleSidebar();
|
||
return;
|
||
}
|
||
|
||
if (action === "refresh") {
|
||
void refreshData();
|
||
return;
|
||
}
|
||
|
||
if (action === "calendar-prev") {
|
||
shiftCalendarPeriod(-1);
|
||
return;
|
||
}
|
||
|
||
if (action === "calendar-next") {
|
||
shiftCalendarPeriod(1);
|
||
return;
|
||
}
|
||
|
||
if (action === "calendar-today") {
|
||
goToCurrentCalendarPeriod();
|
||
return;
|
||
}
|
||
|
||
if (action === "calendar-view") {
|
||
const calendarView = actionElement.getAttribute("data-calendar-view");
|
||
|
||
if (calendarView) {
|
||
setCalendarView(calendarView);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (action === "download-json") {
|
||
if (appState.dataset) {
|
||
downloadFile(
|
||
"arch-panel-data.json",
|
||
JSON.stringify(appState.dataset.exportPayload, null, 2),
|
||
"application/json"
|
||
);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (action === "download-html") {
|
||
if (appState.dataset) {
|
||
downloadFile(
|
||
"arch-panel-dashboard.html",
|
||
createExportHtml(appState.dataset),
|
||
"text/html"
|
||
);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (action === "open-detail") {
|
||
const detailScope = actionElement.getAttribute("data-detail-scope");
|
||
const detailValue = actionElement.getAttribute("data-detail-value");
|
||
|
||
openDetailModal(detailScope, detailValue);
|
||
return;
|
||
}
|
||
|
||
if (action === "open-action-form") {
|
||
const workloadId = actionElement.getAttribute("data-workload-id");
|
||
|
||
if (workloadId) {
|
||
openActionFormModal(workloadId);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (action === "open-ramp-comparison") {
|
||
const workloadId = actionElement.getAttribute("data-workload-id");
|
||
|
||
if (workloadId) {
|
||
openRampComparisonModal(workloadId);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (action === "close-ramp-comparison") {
|
||
appState.forecastUpdateConfirmModal = null;
|
||
appState.rampComparisonModal = null;
|
||
renderModal();
|
||
return;
|
||
}
|
||
|
||
if (action === "open-forecast-update-confirm") {
|
||
openForecastUpdateConfirmModal();
|
||
return;
|
||
}
|
||
|
||
if (action === "close-forecast-update-confirm") {
|
||
appState.forecastUpdateConfirmModal = null;
|
||
renderModal();
|
||
return;
|
||
}
|
||
|
||
if (action === "submit-forecast-update") {
|
||
void submitForecastUpdate();
|
||
return;
|
||
}
|
||
|
||
if (action === "close-action-form") {
|
||
appState.actionFormModal = null;
|
||
renderModal();
|
||
return;
|
||
}
|
||
|
||
if (action === "open-time-entries") {
|
||
const srNumber = actionElement.getAttribute("data-sr-number");
|
||
|
||
if (srNumber) {
|
||
void openTimeEntriesDrawer(srNumber);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (action === "close-time-entries") {
|
||
appState.timeEntriesDrawer = null;
|
||
renderModal();
|
||
return;
|
||
}
|
||
|
||
if (action === "retry-time-entries") {
|
||
const srNumber = appState.timeEntriesDrawer?.srNumber;
|
||
|
||
if (srNumber) {
|
||
void openTimeEntriesDrawer(srNumber);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (action === "open-time-management") {
|
||
void openTimeManagementModal();
|
||
return;
|
||
}
|
||
|
||
if (action === "close-time-management") {
|
||
appState.timeManagementModal = null;
|
||
renderModal();
|
||
return;
|
||
}
|
||
|
||
if (action === "time-management-add-sr") {
|
||
addTimeManagementEntryRow("sr");
|
||
return;
|
||
}
|
||
|
||
if (action === "time-management-add-non-service") {
|
||
addTimeManagementEntryRow("non-service");
|
||
return;
|
||
}
|
||
|
||
if (action === "time-management-save") {
|
||
void saveTimeManagementChanges();
|
||
return;
|
||
}
|
||
|
||
if (action === "time-management-remove-row") {
|
||
removeTimeManagementEntryRow(actionElement.getAttribute("data-row-id"));
|
||
return;
|
||
}
|
||
|
||
if (action === "open-time-management-date-picker") {
|
||
if (!actionElement.matches("button.arch-panel-extension-date-picker-button")) {
|
||
return;
|
||
}
|
||
|
||
toggleTimeManagementDatePicker();
|
||
return;
|
||
}
|
||
|
||
if (action === "time-management-date-week-prev") {
|
||
void shiftTimeManagementDateByWeeks(-1);
|
||
return;
|
||
}
|
||
|
||
if (action === "time-management-date-week-next") {
|
||
void shiftTimeManagementDateByWeeks(1);
|
||
return;
|
||
}
|
||
|
||
if (action === "time-management-date-picker-prev") {
|
||
shiftTimeManagementDatePickerMonth(-1);
|
||
return;
|
||
}
|
||
|
||
if (action === "time-management-date-picker-next") {
|
||
shiftTimeManagementDatePickerMonth(1);
|
||
return;
|
||
}
|
||
|
||
if (action === "time-management-date-picker-select") {
|
||
const selectedDate = actionElement.getAttribute("data-date-value");
|
||
|
||
if (selectedDate) {
|
||
void selectTimeManagementDate(selectedDate);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (action === "time-management-combobox-option") {
|
||
selectTimeManagementComboboxOption(
|
||
actionElement.getAttribute("data-field"),
|
||
actionElement.getAttribute("data-value"),
|
||
actionElement.getAttribute("data-row-id")
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (action === "submit-action-form") {
|
||
const form = actionElement.closest("form");
|
||
|
||
if (form instanceof HTMLFormElement) {
|
||
void submitActionForm(form);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (action === "close-detail") {
|
||
closeDetailModal();
|
||
renderModal();
|
||
return;
|
||
}
|
||
|
||
if (action === "sort-detail") {
|
||
const sortKey = actionElement.getAttribute("data-sort-key");
|
||
|
||
if (sortKey) {
|
||
toggleDetailSort(sortKey);
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleOverlayChange(event) {
|
||
const target = event.target;
|
||
|
||
if (!(target instanceof HTMLInputElement || target instanceof HTMLSelectElement)) {
|
||
return;
|
||
}
|
||
|
||
const action = target.getAttribute("data-action");
|
||
|
||
if (action === "time-management-date-change") {
|
||
void loadTimeManagementData(target.value);
|
||
return;
|
||
}
|
||
|
||
if (action === "time-management-sr-change") {
|
||
updateTimeManagementSelectedSr(target.value, target.getAttribute("data-row-id"));
|
||
return;
|
||
}
|
||
|
||
if (action === "time-management-activity-change") {
|
||
updateTimeManagementField(
|
||
"selectedActivityValue",
|
||
target.value,
|
||
target.getAttribute("data-row-id")
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (action === "time-management-task-type-change") {
|
||
updateTimeManagementField(
|
||
"selectedTaskTypeValue",
|
||
target.value,
|
||
target.getAttribute("data-row-id")
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (action === "time-management-hours-change") {
|
||
updateTimeManagementDayHours(
|
||
target.getAttribute("data-row-id"),
|
||
target.getAttribute("data-date-key"),
|
||
target.value
|
||
);
|
||
}
|
||
}
|
||
|
||
function handleOverlayInput(event) {
|
||
const target = event.target;
|
||
|
||
if (!(target instanceof HTMLInputElement)) {
|
||
return;
|
||
}
|
||
|
||
const field = target.getAttribute("data-combobox-field");
|
||
|
||
if (!field) {
|
||
return;
|
||
}
|
||
|
||
filterTimeManagementCombobox(target);
|
||
}
|
||
|
||
function handleOverlayKeydown(event) {
|
||
const target = event.target;
|
||
|
||
if (!(target instanceof HTMLInputElement)) {
|
||
return;
|
||
}
|
||
|
||
if (target.getAttribute("data-action") === "time-management-hours-change") {
|
||
handleTimeManagementHourKeydown(event, target);
|
||
return;
|
||
}
|
||
|
||
const field = target.getAttribute("data-combobox-field");
|
||
|
||
if (!field) {
|
||
return;
|
||
}
|
||
|
||
if (![
|
||
"ArrowDown",
|
||
"ArrowUp",
|
||
"Home",
|
||
"End",
|
||
"Enter",
|
||
"Escape",
|
||
].includes(event.key)) {
|
||
return;
|
||
}
|
||
|
||
handleTimeManagementComboboxKeydown(event, target);
|
||
}
|
||
|
||
function handleTimeManagementHourKeydown(event, input) {
|
||
if (
|
||
![
|
||
"ArrowLeft",
|
||
"ArrowRight",
|
||
"ArrowUp",
|
||
"ArrowDown",
|
||
"Home",
|
||
"End",
|
||
"Enter",
|
||
].includes(event.key)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const nextInput = getNextTimeManagementHourInput(input, event);
|
||
|
||
if (!nextInput) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
nextInput.focus();
|
||
nextInput.select();
|
||
}
|
||
|
||
function getNextTimeManagementHourInput(currentInput, event) {
|
||
const rows = Array.from(
|
||
document.querySelectorAll(
|
||
".arch-panel-extension-time-management-entry-row[data-row-id]"
|
||
)
|
||
);
|
||
const currentRow = currentInput.closest(
|
||
".arch-panel-extension-time-management-entry-row"
|
||
);
|
||
const rowIndex = rows.indexOf(currentRow);
|
||
|
||
if (rowIndex < 0) {
|
||
return null;
|
||
}
|
||
|
||
const currentInputs = getTimeManagementHourInputs(rows[rowIndex]);
|
||
const columnIndex = currentInputs.indexOf(currentInput);
|
||
|
||
if (columnIndex < 0) {
|
||
return null;
|
||
}
|
||
|
||
let nextRowIndex = rowIndex;
|
||
let nextColumnIndex = columnIndex;
|
||
|
||
if (event.key === "ArrowLeft") {
|
||
nextColumnIndex -= 1;
|
||
} else if (event.key === "ArrowRight") {
|
||
nextColumnIndex += 1;
|
||
} else if (event.key === "ArrowUp") {
|
||
nextRowIndex -= 1;
|
||
} else if (event.key === "ArrowDown" || event.key === "Enter") {
|
||
nextRowIndex += event.shiftKey ? -1 : 1;
|
||
} else if (event.key === "Home") {
|
||
nextColumnIndex = 0;
|
||
} else if (event.key === "End") {
|
||
nextColumnIndex = currentInputs.length - 1;
|
||
}
|
||
|
||
const nextRow = rows[nextRowIndex];
|
||
|
||
if (!nextRow) {
|
||
return null;
|
||
}
|
||
|
||
const nextInputs = getTimeManagementHourInputs(nextRow);
|
||
|
||
return nextInputs[nextColumnIndex] || null;
|
||
}
|
||
|
||
function getTimeManagementHourInputs(row) {
|
||
return Array.from(
|
||
row.querySelectorAll('input[data-action="time-management-hours-change"]')
|
||
);
|
||
}
|
||
|
||
function toggleTimeManagementDatePicker() {
|
||
const selectedDate = getTimeManagementSelectedDate();
|
||
const pickerDate = parseDatePreservingDateOnly(selectedDate);
|
||
const fallback = Number.isNaN(pickerDate.getTime()) ? new Date() : pickerDate;
|
||
|
||
appState.timeManagementDatePicker = {
|
||
isOpen: !appState.timeManagementDatePicker.isOpen,
|
||
year: fallback.getFullYear(),
|
||
month: fallback.getMonth(),
|
||
};
|
||
renderModal();
|
||
}
|
||
|
||
function shiftTimeManagementDatePickerMonth(delta) {
|
||
const picker = appState.timeManagementDatePicker;
|
||
const nextDate = new Date(picker.year, picker.month + delta, 1);
|
||
|
||
appState.timeManagementDatePicker = {
|
||
isOpen: true,
|
||
year: nextDate.getFullYear(),
|
||
month: nextDate.getMonth(),
|
||
};
|
||
renderModal();
|
||
}
|
||
|
||
async function selectTimeManagementDate(selectedDate) {
|
||
appState.timeManagementDatePicker = {
|
||
...appState.timeManagementDatePicker,
|
||
isOpen: false,
|
||
};
|
||
await loadTimeManagementData(selectedDate);
|
||
}
|
||
|
||
async function shiftTimeManagementDateByWeeks(delta) {
|
||
const selectedDate = getTimeManagementSelectedDate();
|
||
const parsedDate = parseDatePreservingDateOnly(selectedDate);
|
||
const baseDate = Number.isNaN(parsedDate.getTime()) ? new Date() : parsedDate;
|
||
|
||
baseDate.setDate(baseDate.getDate() + (Number(delta) || 0) * 7);
|
||
appState.timeManagementDatePicker = {
|
||
...appState.timeManagementDatePicker,
|
||
isOpen: false,
|
||
};
|
||
await loadTimeManagementData(getDateInputValue(baseDate));
|
||
}
|
||
|
||
function getTimeManagementSelectedDate() {
|
||
return appState.timeManagementModal?.selectedDate || getDateInputValue(new Date());
|
||
}
|
||
|
||
function renderModal() {
|
||
const overlay = document.getElementById(IDS.overlay);
|
||
|
||
if (!overlay) {
|
||
return;
|
||
}
|
||
|
||
const previousScrollState = captureModalScrollState(overlay);
|
||
const activeFocusSelector = getRestorableFocusSelector(document.activeElement);
|
||
const requestedFocusSelector = appState.pendingFocusSelector || activeFocusSelector;
|
||
|
||
overlay.innerHTML = createModalMarkup();
|
||
applyDialogTheme(overlay);
|
||
|
||
restoreModalScrollState(overlay, previousScrollState);
|
||
|
||
window.requestAnimationFrame(() => {
|
||
if (
|
||
!appState.detailModal &&
|
||
!appState.actionFormModal &&
|
||
!appState.rampComparisonModal &&
|
||
!appState.forecastUpdateConfirmModal &&
|
||
!appState.timeManagementModal &&
|
||
requestedFocusSelector
|
||
) {
|
||
const focusTarget = overlay.querySelector(requestedFocusSelector);
|
||
|
||
if (focusTarget instanceof HTMLElement) {
|
||
focusTarget.focus({ preventScroll: true });
|
||
}
|
||
}
|
||
|
||
appState.pendingFocusSelector = "";
|
||
scrollPendingTimeManagementHighlightIntoView(overlay);
|
||
});
|
||
}
|
||
|
||
function scrollPendingTimeManagementHighlightIntoView(overlay) {
|
||
const rowId = cleanString(appState.pendingTimeManagementHighlightRowId);
|
||
|
||
if (!rowId) {
|
||
return;
|
||
}
|
||
|
||
appState.pendingTimeManagementHighlightRowId = "";
|
||
const row = overlay.querySelector(
|
||
`.arch-panel-extension-time-management-entry-row[data-row-id="${cssEscape(rowId)}"]`
|
||
);
|
||
|
||
if (row instanceof HTMLElement) {
|
||
row.scrollIntoView({
|
||
behavior: "smooth",
|
||
block: "center",
|
||
inline: "nearest",
|
||
});
|
||
}
|
||
}
|
||
|
||
function captureModalScrollState(overlay) {
|
||
const mainShell = overlay.querySelector(".arch-panel-extension-main-shell");
|
||
const shellBody = overlay.querySelector(".arch-panel-extension-shell-body");
|
||
const dialog = overlay.querySelector(".arch-panel-extension-dialog");
|
||
const detailTable = overlay.querySelector(
|
||
".arch-panel-extension-table-wrap.is-detail"
|
||
);
|
||
|
||
return {
|
||
windowX: window.scrollX,
|
||
windowY: window.scrollY,
|
||
documentTop: document.documentElement.scrollTop,
|
||
bodyTop: document.body.scrollTop,
|
||
mainShellTop: mainShell ? mainShell.scrollTop : 0,
|
||
shellBodyTop: shellBody ? shellBody.scrollTop : 0,
|
||
dialogTop: dialog ? dialog.scrollTop : 0,
|
||
detailTableTop: detailTable ? detailTable.scrollTop : 0,
|
||
detailTableLeft: detailTable ? detailTable.scrollLeft : 0,
|
||
};
|
||
}
|
||
|
||
function restoreModalScrollState(overlay, scrollState) {
|
||
const restore = () => {
|
||
const mainShell = overlay.querySelector(".arch-panel-extension-main-shell");
|
||
const shellBody = overlay.querySelector(".arch-panel-extension-shell-body");
|
||
const dialog = overlay.querySelector(".arch-panel-extension-dialog");
|
||
const detailTable = overlay.querySelector(
|
||
".arch-panel-extension-table-wrap.is-detail"
|
||
);
|
||
|
||
if (mainShell) {
|
||
mainShell.scrollTop = scrollState.mainShellTop;
|
||
}
|
||
|
||
if (shellBody) {
|
||
shellBody.scrollTop = scrollState.shellBodyTop;
|
||
}
|
||
|
||
if (dialog) {
|
||
dialog.scrollTop = scrollState.dialogTop;
|
||
}
|
||
|
||
if (detailTable) {
|
||
detailTable.scrollTop = scrollState.detailTableTop;
|
||
detailTable.scrollLeft = scrollState.detailTableLeft;
|
||
}
|
||
|
||
document.documentElement.scrollTop = scrollState.documentTop;
|
||
document.body.scrollTop = scrollState.bodyTop;
|
||
window.scrollTo(scrollState.windowX, scrollState.windowY);
|
||
};
|
||
|
||
window.requestAnimationFrame(() => {
|
||
restore();
|
||
window.requestAnimationFrame(restore);
|
||
});
|
||
}
|
||
|
||
function getRestorableFocusSelector(element) {
|
||
if (!(element instanceof HTMLElement)) {
|
||
return "";
|
||
}
|
||
|
||
const action = element.getAttribute("data-action");
|
||
|
||
if (
|
||
action === "calendar-prev" ||
|
||
action === "calendar-next" ||
|
||
action === "calendar-today"
|
||
) {
|
||
return `[data-action="${action}"]`;
|
||
}
|
||
|
||
if (action === "calendar-view") {
|
||
const calendarView = element.getAttribute("data-calendar-view");
|
||
|
||
return calendarView
|
||
? `[data-action="calendar-view"][data-calendar-view="${calendarView}"]`
|
||
: `[data-action="${action}"]`;
|
||
}
|
||
|
||
const detailScope = element.getAttribute("data-detail-scope");
|
||
const detailValue = element.getAttribute("data-detail-value");
|
||
|
||
if (detailScope === "calendar-day" && detailValue) {
|
||
return getCalendarDayFocusSelector(detailValue);
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
function getCalendarDayFocusSelector(dateKey) {
|
||
return `[data-action="open-detail"][data-detail-scope="calendar-day"][data-detail-value="${dateKey}"]`;
|
||
}
|
||
|
||
function createModalMarkup() {
|
||
return `
|
||
<div
|
||
id="${IDS.dialog}"
|
||
class="arch-panel-extension-dialog"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="arch-panel-extension-title"
|
||
tabindex="-1"
|
||
>
|
||
<header class="arch-panel-extension-shell-header">
|
||
<div class="arch-panel-extension-brand">
|
||
<span class="arch-panel-extension-brand-mark">Arch</span>
|
||
<div>
|
||
<p class="arch-panel-extension-brand-label">Enterprise workload intelligence</p>
|
||
<h1 id="arch-panel-extension-title" class="arch-panel-extension-title">
|
||
Arch panel
|
||
</h1>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="arch-panel-extension-shell-actions">
|
||
<span class="arch-panel-extension-shell-note">
|
||
${getHeaderSummary()}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
class="arch-panel-extension-theme-toggle"
|
||
data-action="toggle-sidebar"
|
||
aria-label="Toggle sidebar"
|
||
>
|
||
${appState.sidebarCollapsed ? "Show sidebar" : "Hide sidebar"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="arch-panel-extension-theme-toggle"
|
||
data-action="toggle-theme"
|
||
aria-label="Toggle dashboard theme"
|
||
>
|
||
${appState.theme === "dark" ? "Light mode" : "Dark mode"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="arch-panel-extension-close"
|
||
data-action="close"
|
||
aria-label="Close Arch panel"
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="arch-panel-extension-shell-body${appState.sidebarCollapsed ? " is-sidebar-collapsed" : ""}">
|
||
${renderMainContent()}
|
||
${renderDetailModal()}
|
||
${renderActionFormModal()}
|
||
${renderRampComparisonModal()}
|
||
${renderForecastUpdateConfirmModal()}
|
||
${renderTimeEntriesDrawer()}
|
||
${renderTimeManagementModal()}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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">
|
||
<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>
|
||
`;
|
||
}
|
||
|
||
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>
|
||
`;
|
||
}
|
||
|
||
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">
|
||
${renderSearchableCombobox({
|
||
field: "sr",
|
||
rowId,
|
||
selectedValue: selectedSr,
|
||
value: selectedSrLabel,
|
||
placeholder: "Search SR",
|
||
options: modal.serviceRequests || [],
|
||
valueKey: "srNumber",
|
||
labelKey: "label",
|
||
fallbackLabelKey: "srTitle",
|
||
disabled: 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 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,
|
||
}) {
|
||
const safeField = escapeHtml(field);
|
||
const safeRowId = escapeHtml(rowId || "");
|
||
const isDisabled = Boolean(disabled);
|
||
const normalizedSelectedValue = normalizeString(selectedValue);
|
||
const normalizedOptions = (options || [])
|
||
.map((item) => ({
|
||
value: cleanString(item?.[valueKey]),
|
||
label:
|
||
cleanString(item?.[labelKey]) ||
|
||
cleanString(item?.[fallbackLabelKey]) ||
|
||
cleanString(item?.[valueKey]),
|
||
meta: cleanString(item?.[metaKey]),
|
||
}))
|
||
.filter((item) => item.value || item.label);
|
||
|
||
return `
|
||
<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 || "")}">
|
||
${escapeHtml(value || placeholder || "")}
|
||
</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="arch-panel-extension-combobox-menu" role="listbox">
|
||
${
|
||
normalizedOptions.length
|
||
? normalizedOptions
|
||
.map(
|
||
(item, index) => {
|
||
const isSelected =
|
||
normalizedSelectedValue &&
|
||
normalizeString(item.value) === normalizedSelectedValue;
|
||
const meta =
|
||
item.meta && normalizeString(item.meta) !== normalizeString(item.label)
|
||
? item.meta
|
||
: "";
|
||
const optionId = `arch-panel-extension-combobox-${safeField}-${index}`;
|
||
|
||
return `
|
||
<button
|
||
id="${optionId}"
|
||
type="button"
|
||
class="arch-panel-extension-combobox-option${isSelected ? " is-selected" : ""}"
|
||
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"}"
|
||
>
|
||
<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>`
|
||
: ""
|
||
}
|
||
</button>
|
||
`;
|
||
}
|
||
)
|
||
.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>
|
||
`;
|
||
}
|
||
|
||
function getTimeManagementDayKey(day) {
|
||
const parsedDate = parseTimeEntryDate(day?.date);
|
||
|
||
if (!Number.isNaN(parsedDate.getTime())) {
|
||
return getLocalDateKey(parsedDate);
|
||
}
|
||
|
||
return cleanString(day?.date);
|
||
}
|
||
|
||
function renderSelectOptions(items, selectedValue, valueKey, labelKey, emptyLabel) {
|
||
const options = [];
|
||
|
||
if (!items.length) {
|
||
options.push(`<option value="">${escapeHtml(emptyLabel)}</option>`);
|
||
}
|
||
|
||
return options
|
||
.concat(
|
||
items.map((item) => {
|
||
const value = cleanString(item?.[valueKey]);
|
||
const label = cleanString(item?.[labelKey]) || value || emptyLabel;
|
||
|
||
return `
|
||
<option value="${escapeHtml(value)}" ${
|
||
value === selectedValue ? "selected" : ""
|
||
}>${escapeHtml(label)}</option>
|
||
`;
|
||
})
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
function buildRampForecastComparisonRows(workloads) {
|
||
return (workloads || [])
|
||
.flatMap((workload) => {
|
||
const startDate = parseDatePreservingDateOnly(workload.consumptionStartDate);
|
||
|
||
if (Number.isNaN(startDate.getTime())) {
|
||
return [];
|
||
}
|
||
|
||
const startMonth = startDate.getMonth() + 1;
|
||
const startYear = startDate.getFullYear();
|
||
const workloadLabel = `${workload.name || "Unnamed workload"}${
|
||
workload.description ? ` | ${workload.description}` : ""
|
||
}`;
|
||
|
||
return (workload.forecast || [])
|
||
.map((forecastItem) => {
|
||
const forecastMonth = parseForecastMonthNumber(forecastItem.month);
|
||
const forecastYear = toNumber(forecastItem.year);
|
||
|
||
if (!forecastMonth || !forecastYear) {
|
||
return null;
|
||
}
|
||
|
||
const monthIndex =
|
||
(forecastYear - startYear) * 12 + (forecastMonth - startMonth) + 1;
|
||
|
||
const currentValue = toNumber(forecastItem.adjustedConsumptionAmount);
|
||
const calculatedValue =
|
||
monthIndex >= 1 && monthIndex <= 12
|
||
? calculateRampForecastAmount({
|
||
adjustedACR: workload.adjustedACR,
|
||
rampMonths: workload.rampMonths,
|
||
monthIndex,
|
||
consumptionStartDate: startDate,
|
||
})
|
||
: 0;
|
||
|
||
return {
|
||
workloadLabel,
|
||
periodLabel: formatForecastPeriodLabel(forecastYear, forecastMonth),
|
||
currentValue,
|
||
calculatedValue,
|
||
delta: currentValue - calculatedValue,
|
||
monthIndex,
|
||
year: forecastYear,
|
||
month: forecastMonth,
|
||
sortKey: `${workloadLabel}|${String(forecastYear).padStart(
|
||
4,
|
||
"0"
|
||
)}-${String(forecastMonth).padStart(2, "0")}`,
|
||
};
|
||
})
|
||
.filter(Boolean);
|
||
})
|
||
.sort((left, right) => left.sortKey.localeCompare(right.sortKey));
|
||
}
|
||
|
||
function hasRampForecastMismatch(workload) {
|
||
return buildRampForecastComparisonRows([workload]).some((row) =>
|
||
isNonZeroAmount(row.delta)
|
||
);
|
||
}
|
||
|
||
function calculateRampForecastAmount({
|
||
adjustedACR,
|
||
rampMonths,
|
||
monthIndex,
|
||
consumptionStartDate,
|
||
}) {
|
||
const annualValue = toNumber(adjustedACR);
|
||
const ramp = Math.max(0, toNumber(rampMonths));
|
||
const month = Math.max(1, toNumber(monthIndex));
|
||
|
||
if (!annualValue) {
|
||
return 0;
|
||
}
|
||
|
||
if (ramp <= 0) {
|
||
return Math.round(
|
||
(annualValue / 12) *
|
||
getStartMonthProrationFactor(month, consumptionStartDate)
|
||
);
|
||
}
|
||
|
||
const steadyStateMonthlyAmount = (2 * annualValue) / (24 - ramp);
|
||
const rampStepCount = ramp + 1;
|
||
const calculatedAmount =
|
||
month <= rampStepCount
|
||
? steadyStateMonthlyAmount * (month / rampStepCount)
|
||
: steadyStateMonthlyAmount;
|
||
|
||
return Math.round(
|
||
calculatedAmount * getStartMonthProrationFactor(month, consumptionStartDate)
|
||
);
|
||
}
|
||
|
||
function getStartMonthProrationFactor(monthIndex, consumptionStartDate) {
|
||
if (toNumber(monthIndex) !== 1) {
|
||
return 1;
|
||
}
|
||
|
||
const startDate = parseDatePreservingDateOnly(consumptionStartDate);
|
||
|
||
if (Number.isNaN(startDate.getTime())) {
|
||
return 1;
|
||
}
|
||
|
||
const daysInMonth = new Date(
|
||
startDate.getFullYear(),
|
||
startDate.getMonth() + 1,
|
||
0
|
||
).getDate();
|
||
const remainingDays = daysInMonth - startDate.getDate() + 1;
|
||
|
||
return Math.max(0, Math.min(1, remainingDays / daysInMonth));
|
||
}
|
||
|
||
function isNonZeroAmount(value) {
|
||
return Math.abs(toNumber(value)) > 0.000001;
|
||
}
|
||
|
||
function parseForecastMonthNumber(value) {
|
||
if (typeof value === "number") {
|
||
return value >= 1 && value <= 12 ? value : 0;
|
||
}
|
||
|
||
const normalized = normalizeString(value);
|
||
const numericMonth = Number.parseInt(normalized, 10);
|
||
|
||
if (numericMonth >= 1 && numericMonth <= 12) {
|
||
return numericMonth;
|
||
}
|
||
|
||
const monthMap = {
|
||
JAN: 1,
|
||
JANUARY: 1,
|
||
FEB: 2,
|
||
FEBRUARY: 2,
|
||
MAR: 3,
|
||
MARCH: 3,
|
||
APR: 4,
|
||
APRIL: 4,
|
||
MAY: 5,
|
||
JUN: 6,
|
||
JUNE: 6,
|
||
JUL: 7,
|
||
JULY: 7,
|
||
AUG: 8,
|
||
AUGUST: 8,
|
||
SEP: 9,
|
||
SEPT: 9,
|
||
SEPTEMBER: 9,
|
||
OCT: 10,
|
||
OCTOBER: 10,
|
||
NOV: 11,
|
||
NOVEMBER: 11,
|
||
DEC: 12,
|
||
DECEMBER: 12,
|
||
};
|
||
|
||
return monthMap[normalized] || 0;
|
||
}
|
||
|
||
function formatForecastPeriodLabel(year, month) {
|
||
const fiscalYear = month >= 6 ? year + 1 : year;
|
||
const fiscalYearLabel = formatWeekNumber(fiscalYear % 100);
|
||
const monthLabel = new Intl.DateTimeFormat("en-US", {
|
||
month: "short",
|
||
}).format(new Date(year, month - 1, 1));
|
||
|
||
return `FY${fiscalYearLabel} ${monthLabel}`;
|
||
}
|
||
|
||
function renderServiceRequestDetailTable(items) {
|
||
return `
|
||
<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 atenção: existe Delta diferente de zero"
|
||
aria-label="Abrir comparação de rampa com inconsistência 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";
|
||
}
|
||
|
||
function openModal() {
|
||
const overlay = ensureModal();
|
||
|
||
if (!overlay) {
|
||
return;
|
||
}
|
||
|
||
window.clearTimeout(closeTimer);
|
||
restoreFocusTarget =
|
||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||
|
||
overlay.hidden = false;
|
||
overlay.setAttribute("aria-hidden", "false");
|
||
lockScroll();
|
||
|
||
window.requestAnimationFrame(() => {
|
||
overlay.classList.add(CLASSES.overlayOpen);
|
||
const primaryAction = overlay.querySelector("[data-action='close']");
|
||
primaryAction?.focus();
|
||
});
|
||
|
||
if (!appState.dataset && appState.status !== "loading") {
|
||
void hydrateDatasetFromCache();
|
||
}
|
||
}
|
||
|
||
function closeModal() {
|
||
const overlay = document.getElementById(IDS.overlay);
|
||
|
||
if (!overlay || overlay.hidden) {
|
||
return;
|
||
}
|
||
|
||
overlay.classList.remove(CLASSES.overlayOpen);
|
||
overlay.setAttribute("aria-hidden", "true");
|
||
unlockScroll();
|
||
appState.detailModal = null;
|
||
appState.actionFormModal = null;
|
||
appState.rampComparisonModal = null;
|
||
appState.forecastUpdateConfirmModal = null;
|
||
appState.timeEntriesDrawer = null;
|
||
appState.timeManagementModal = null;
|
||
|
||
if (restoreFocusTarget && restoreFocusTarget.isConnected) {
|
||
restoreFocusTarget.focus();
|
||
}
|
||
|
||
restoreFocusTarget = null;
|
||
|
||
window.clearTimeout(closeTimer);
|
||
closeTimer = window.setTimeout(() => {
|
||
overlay.hidden = true;
|
||
}, 160);
|
||
}
|
||
|
||
async function refreshData() {
|
||
if (appState.status === "loading") {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
appState.status = "loading";
|
||
appState.errorMessage = "";
|
||
updateLoadingProgress("Requesting current user", 4);
|
||
|
||
const user = await fetchCurrentUser();
|
||
|
||
updateLoadingProgress("Loading current resource user", 8);
|
||
|
||
const resourceCurrentUser = await fetchResourceCurrentUser(user.userEmail).catch(
|
||
() => null
|
||
);
|
||
|
||
updateLoadingProgress("Loading customer summary pages", 12);
|
||
|
||
const customers = await fetchAllCustomers(user.userEmail);
|
||
|
||
updateLoadingProgress(
|
||
`Loading workloads for ${formatWholeNumber(customers.length)} customers`,
|
||
24
|
||
);
|
||
|
||
let completedCustomers = 0;
|
||
const customersWithWorkloads = await mapWithConcurrency(
|
||
customers,
|
||
4,
|
||
async (customer, index) => {
|
||
updateLoadingProgress(
|
||
`Loading workloads ${index + 1}/${customers.length}: ${customer.name}`,
|
||
getSegmentProgress(24, 52, completedCustomers, customers.length)
|
||
);
|
||
|
||
const workloads = await fetchCustomerWorkloads(customer);
|
||
completedCustomers += 1;
|
||
updateLoadingProgress(
|
||
`Loaded workloads ${completedCustomers}/${customers.length}: ${customer.name}`,
|
||
getSegmentProgress(24, 52, completedCustomers, customers.length)
|
||
);
|
||
|
||
return {
|
||
customerId: customer.customerId,
|
||
name: customer.name,
|
||
workloads,
|
||
};
|
||
}
|
||
);
|
||
|
||
const allWorkloads = customersWithWorkloads.flatMap((customer) => {
|
||
return customer.workloads.map((workload) => ({
|
||
...workload,
|
||
customerId: customer.customerId,
|
||
customerName: customer.name,
|
||
}));
|
||
});
|
||
|
||
updateLoadingProgress(
|
||
`Loading actions and service requests for ${formatWholeNumber(
|
||
allWorkloads.length
|
||
)} workloads`,
|
||
56
|
||
);
|
||
|
||
let completedWorkloads = 0;
|
||
const enrichedWorkloads = await mapWithConcurrency(
|
||
allWorkloads,
|
||
6,
|
||
async (workload, index) => {
|
||
updateLoadingProgress(
|
||
`Loading actions and SRs ${index + 1}/${allWorkloads.length}: ${workload.name}`,
|
||
getSegmentProgress(56, 86, completedWorkloads, allWorkloads.length)
|
||
);
|
||
|
||
const [actions, serviceRequests] = await Promise.all([
|
||
fetchWorkloadActions(workload.workloadId),
|
||
fetchWorkloadServiceRequests(workload.workloadId),
|
||
]);
|
||
completedWorkloads += 1;
|
||
updateLoadingProgress(
|
||
`Loaded actions and SRs ${completedWorkloads}/${allWorkloads.length}: ${workload.name}`,
|
||
getSegmentProgress(56, 86, completedWorkloads, allWorkloads.length)
|
||
);
|
||
|
||
const hasSR = serviceRequests.some((serviceRequest) => {
|
||
return (
|
||
normalizeString(serviceRequest.status) === "SVC_PENDING_DELIVERY" &&
|
||
teamContainsUser(serviceRequest.team, user.userEmail)
|
||
);
|
||
});
|
||
|
||
const hasAction = actions.some((action) => {
|
||
return normalizeString(action.owner) === normalizeString(user.userEmail);
|
||
});
|
||
|
||
return {
|
||
...workload,
|
||
actions,
|
||
serviceRequests,
|
||
hasSR,
|
||
hasAction,
|
||
};
|
||
}
|
||
);
|
||
|
||
const customersMap = new Map(
|
||
customersWithWorkloads.map((customer) => [
|
||
customer.customerId,
|
||
{
|
||
customerId: customer.customerId,
|
||
name: customer.name,
|
||
workloads: [],
|
||
},
|
||
])
|
||
);
|
||
|
||
for (const workload of enrichedWorkloads) {
|
||
const customer = customersMap.get(workload.customerId);
|
||
|
||
if (customer) {
|
||
customer.workloads.push(stripTransientFields(workload));
|
||
}
|
||
}
|
||
|
||
updateLoadingProgress("Loading pending delivery service requests", 90);
|
||
|
||
const pendingServiceRequests = await fetchPendingServiceRequests().catch(
|
||
() => []
|
||
);
|
||
updateLoadingProgress("Refreshing time management task types", 94);
|
||
await refreshTaskTypeCache().catch(() => null);
|
||
|
||
updateLoadingProgress("Preparing dashboard cache", 96);
|
||
const finalCustomers = Array.from(customersMap.values());
|
||
const exportPayload = createExportPayload(
|
||
user,
|
||
finalCustomers,
|
||
pendingServiceRequests,
|
||
resourceCurrentUser
|
||
);
|
||
const dataset = createDatasetSnapshot(exportPayload);
|
||
|
||
await writeCachedDataset(exportPayload);
|
||
updateLoadingProgress("Dashboard ready", 100);
|
||
|
||
appState.dataset = dataset;
|
||
appState.status = "ready";
|
||
appState.loadingMessage = "";
|
||
appState.loadingProgress = 0;
|
||
appState.detailModal = null;
|
||
renderModal();
|
||
} catch (error) {
|
||
appState.status = "error";
|
||
appState.errorMessage = getErrorMessage(error);
|
||
appState.loadingMessage = "";
|
||
appState.loadingProgress = 0;
|
||
renderModal();
|
||
}
|
||
}
|
||
|
||
function updateLoadingProgress(message, progress) {
|
||
appState.loadingMessage = message;
|
||
appState.loadingProgress = Math.max(0, Math.min(Number(progress) || 0, 100));
|
||
|
||
if (!updateLoadingProgressDom()) {
|
||
renderModal();
|
||
window.requestAnimationFrame(() => {
|
||
updateLoadingProgressDom();
|
||
});
|
||
}
|
||
}
|
||
|
||
function updateLoadingProgressDom() {
|
||
const overlay = document.getElementById(IDS.overlay);
|
||
const progress = Math.max(0, Math.min(appState.loadingProgress || 0, 100));
|
||
const stateCopy = overlay?.querySelector(".arch-panel-extension-state-copy");
|
||
const progressRoot = overlay?.querySelector(
|
||
".arch-panel-extension-loading-progress"
|
||
);
|
||
const progressBar = overlay?.querySelector(
|
||
".arch-panel-extension-loading-progress-fill"
|
||
);
|
||
const progressTrackValue = overlay?.querySelector(
|
||
".arch-panel-extension-loading-progress-value"
|
||
);
|
||
const shellNote = overlay?.querySelector(".arch-panel-extension-shell-note");
|
||
|
||
if (!stateCopy || !progressRoot || !progressBar) {
|
||
return false;
|
||
}
|
||
|
||
stateCopy.textContent = appState.loadingMessage;
|
||
progressRoot.setAttribute("aria-valuenow", String(Math.round(progress)));
|
||
progressBar.style.width = `${progress}%`;
|
||
|
||
if (progressTrackValue) {
|
||
progressTrackValue.textContent = formatProgressPercent(progress);
|
||
}
|
||
|
||
if (shellNote) {
|
||
shellNote.textContent = appState.loadingMessage || "Loading";
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function getSegmentProgress(start, end, current, total) {
|
||
const denominator = Math.max(Number(total) || 0, 1);
|
||
const ratio = Math.max(0, Math.min((Number(current) || 0) / denominator, 1));
|
||
|
||
return start + (end - start) * ratio;
|
||
}
|
||
|
||
async function fetchPendingServiceRequests() {
|
||
const response = await sendComcipPostMessage();
|
||
|
||
if (!response?.ok) {
|
||
const payloadMessage =
|
||
typeof response?.payload === "string"
|
||
? cleanString(response.payload)
|
||
: firstNonEmptyString([
|
||
response?.payload?.message,
|
||
response?.payload?.error,
|
||
response?.payload?.title,
|
||
response?.payload?.detail,
|
||
]);
|
||
|
||
throw new Error(
|
||
payloadMessage ||
|
||
response?.error ||
|
||
`COMCIP request failed (${response?.status || 0}).`
|
||
);
|
||
}
|
||
|
||
return normalizePendingServiceRequests(response.payload);
|
||
}
|
||
|
||
function sendComcipPostMessage() {
|
||
return sendComcipRequestMessage({
|
||
method: "POST",
|
||
url: COMCIP_REQUEST.url,
|
||
payload: createComcipServiceRequestPayload(),
|
||
timeoutMessage: "Timed out while requesting COMCIP data.",
|
||
});
|
||
}
|
||
|
||
function sendComcipGetMessage(url) {
|
||
return sendComcipRequestMessage({
|
||
method: "GET",
|
||
url,
|
||
payload: null,
|
||
timeoutMessage: "Timed out while requesting COMCIP data.",
|
||
});
|
||
}
|
||
|
||
async function sendComcipRequestMessage({ method, url, payload, timeoutMessage }) {
|
||
const useTimeEntryFrame = isComcipTimeEntryUrl(url);
|
||
|
||
if (useTimeEntryFrame) {
|
||
await ensureComcipTimeEntryFrame();
|
||
}
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const timeout = window.setTimeout(() => {
|
||
reject(new Error(timeoutMessage || "Timed out while requesting COMCIP data."));
|
||
}, 130000);
|
||
|
||
chrome.runtime.sendMessage(
|
||
{
|
||
type: "ARCH_PANEL_COMCIP_REQUEST",
|
||
method,
|
||
url,
|
||
payload,
|
||
useTimeEntryFrame,
|
||
headers: {
|
||
"accept-language": navigator.language || "pt-BR",
|
||
"x-vb-application-version": COMCIP_REQUEST.appVersion,
|
||
},
|
||
},
|
||
(response) => {
|
||
window.clearTimeout(timeout);
|
||
const runtimeError = chrome.runtime.lastError;
|
||
|
||
if (runtimeError) {
|
||
reject(new Error(runtimeError.message));
|
||
return;
|
||
}
|
||
|
||
resolve(response || null);
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
function isComcipTimeEntryUrl(url) {
|
||
return String(url || "").includes("/ic/builder/rt/oalset_timeentrymobile/live");
|
||
}
|
||
|
||
function ensureComcipTimeEntryFrame() {
|
||
const existingFrame = document.getElementById(COMCIP_TIME_ENTRY_FRAME_ID);
|
||
|
||
if (existingFrame instanceof HTMLIFrameElement) {
|
||
existingFrame.name = COMCIP_TIME_ENTRY_FRAME_ID;
|
||
return Promise.resolve(existingFrame);
|
||
}
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const frame = document.createElement("iframe");
|
||
const timeout = window.setTimeout(() => {
|
||
reject(new Error("Timed out while preparing COMCIP Time Entry session frame."));
|
||
}, 45000);
|
||
|
||
frame.id = COMCIP_TIME_ENTRY_FRAME_ID;
|
||
frame.name = COMCIP_TIME_ENTRY_FRAME_ID;
|
||
frame.title = "COMCIP Time Entry session";
|
||
frame.src = COMCIP_REQUEST.timeEntryAppUrl;
|
||
frame.setAttribute("aria-hidden", "true");
|
||
frame.tabIndex = -1;
|
||
frame.style.position = "fixed";
|
||
frame.style.width = "1px";
|
||
frame.style.height = "1px";
|
||
frame.style.opacity = "0";
|
||
frame.style.pointerEvents = "none";
|
||
frame.style.border = "0";
|
||
frame.style.left = "-10000px";
|
||
frame.style.top = "0";
|
||
|
||
frame.addEventListener(
|
||
"load",
|
||
() => {
|
||
window.clearTimeout(timeout);
|
||
resolve(frame);
|
||
},
|
||
{ once: true }
|
||
);
|
||
frame.addEventListener(
|
||
"error",
|
||
() => {
|
||
window.clearTimeout(timeout);
|
||
frame.remove();
|
||
reject(new Error("Unable to load COMCIP Time Entry session frame."));
|
||
},
|
||
{ once: true }
|
||
);
|
||
|
||
document.documentElement.appendChild(frame);
|
||
});
|
||
}
|
||
|
||
function createComcipServiceRequestPayload() {
|
||
return {
|
||
entity: "CRMServiceRequest",
|
||
offset: 0,
|
||
limit: 100,
|
||
onlyData: true,
|
||
language: "US",
|
||
fields: [
|
||
"SrNumber",
|
||
"Title",
|
||
"LastUpdateDate",
|
||
"CreationDate",
|
||
"PrimaryPillarNew_c",
|
||
"PrimaryPillarNew_c_localizedValue",
|
||
"CountryText_c",
|
||
"OpportunityName_Id_c.OptyId",
|
||
"OpportunityName_Id_c.Name",
|
||
"OpportunityName_Id_c.OptyNumber",
|
||
"OpportunityName_Id_c.OpportunityType_c",
|
||
"OpportunityName_Id_c.StatusCode",
|
||
"OpportunityName_Id_c.EffectiveDate",
|
||
"OpportunityName_Id_c.StatusCode_localizedValue",
|
||
"OpportunityName_Id_c.PrimaryRevenue.WinProb",
|
||
"OpportunityName_Id_c.PrimaryRevenue.RevnAmount",
|
||
"OpportunityName_Id_c.PrimaryRevenue.CrmConversionRate",
|
||
"OpportunityName_Id_c.WorkloadAmountUSDWorkspace_c",
|
||
"CustomerAccount.PartyId",
|
||
"CustomerAccount.PartyUniqueName",
|
||
"CustomerAccount.PartyNumber",
|
||
"CustomerAccount.NamedFlag",
|
||
"StatusCd",
|
||
"StatusCd_localizedValue",
|
||
"ServiceNew_Id_c",
|
||
"CustomerDeadline_c",
|
||
"Owner.PartyId",
|
||
"Owner.PartyName",
|
||
"Owner.PrimaryEmail.EmailAddress",
|
||
"Owner.ResourceEmailAddress",
|
||
"ReportedByPartyId.PrimaryEmail.EmailAddress",
|
||
"Queue.QueueId",
|
||
"Queue.QueueName",
|
||
"ServiceRequest_Id_SRToInternalSR",
|
||
"ReportedHours_c",
|
||
"ProblemDescription",
|
||
"AccountRegistryId_c",
|
||
],
|
||
sort: [
|
||
{
|
||
attribute: "LastUpdateDate",
|
||
direction: "descending",
|
||
},
|
||
],
|
||
q: {
|
||
op: "$and",
|
||
criteria: [
|
||
{
|
||
op: "$eq",
|
||
attribute: "RecordSet",
|
||
value: "ORA_SVC_MY_SUBORD_SR_TEAM_SRS",
|
||
},
|
||
{
|
||
op: "$in",
|
||
attribute: "StatusCd",
|
||
values: ["SVC_PENDING_DELIVERY"],
|
||
},
|
||
],
|
||
},
|
||
aggregations: {
|
||
StatusCd: {
|
||
ignore: true,
|
||
terms: {
|
||
attribute: "StatusCd",
|
||
includeTerms: [
|
||
"SVC_PENDING_ACCEPTANCE",
|
||
"SVC_PENDING_DELIVERY",
|
||
"SVC_PENDING_RESOURCING",
|
||
"SVC_SE_MGR_CLOSED",
|
||
"SVC_REJECT",
|
||
],
|
||
excludeTerms: ["SVC_PENDING_DISPATCHER", "ORA_SVC_NEW", "SVC_REJECT"],
|
||
minBucketCount: 1,
|
||
maxNumberOfBuckets: 5,
|
||
other: false,
|
||
missing: false,
|
||
localize: true,
|
||
},
|
||
},
|
||
Queue: createTermsAggregation("Queue", 10),
|
||
CustomerAccount: createTermsAggregation("CustomerAccount", 5),
|
||
OpportunityStatus: createTermsAggregation(
|
||
"OpportunityName_Id_c.StatusCode",
|
||
6
|
||
),
|
||
Service: createTermsAggregation("ServiceNew_Id_c", 5),
|
||
Country: createTermsAggregation("CountryText_c", 5),
|
||
Pillar: createTermsAggregation("PrimaryPillarNew_c", 5),
|
||
WinProb: {
|
||
ignore: true,
|
||
range: {
|
||
attribute: "OpportunityName_Id_c.PrimaryRevenue.WinProb",
|
||
ranges: [
|
||
{ key: "0-10 % SQL", from: 0, to: 11 },
|
||
{ key: "20-30 % Pipeline", from: 20, to: 31 },
|
||
{ key: "40-50 % Upside", from: 40, to: 51 },
|
||
{ key: "60-90 % Forecast", from: 60, to: 91 },
|
||
{ key: "100 % Won", from: 100, to: 101 },
|
||
],
|
||
},
|
||
},
|
||
Amount: {
|
||
ignore: true,
|
||
range: {
|
||
attribute: "OpportunityName_Id_c.PrimaryRevenue.RevnAmount",
|
||
ranges: [
|
||
{ key: "0-10,000", from: 0, to: 10000 },
|
||
{ key: "10,000-50,000", from: 10000, to: 50000 },
|
||
{ key: "50,000-100,000", from: 50000, to: 100000 },
|
||
{ key: ">=100,000", from: 100000 },
|
||
],
|
||
},
|
||
},
|
||
OpportunityName_Id_c: createTermsAggregation("OpportunityName_Id_c", 5),
|
||
},
|
||
};
|
||
}
|
||
|
||
function createTermsAggregation(attribute, maxNumberOfBuckets) {
|
||
return {
|
||
ignore: true,
|
||
terms: {
|
||
attribute,
|
||
includeTerms: [],
|
||
minBucketCount: 1,
|
||
maxNumberOfBuckets,
|
||
other: false,
|
||
missing: false,
|
||
localize: true,
|
||
},
|
||
};
|
||
}
|
||
|
||
function normalizePendingServiceRequests(payload) {
|
||
const items = Array.isArray(payload)
|
||
? payload
|
||
: extractArray(payload, ["items", "data", "results", "content"]);
|
||
|
||
return items
|
||
.map((item) => normalizePendingServiceRequest(item))
|
||
.filter((item) => item.srNumber || item.customerName || item.lastUpdateDate)
|
||
.sort((left, right) => {
|
||
return (
|
||
new Date(right.lastUpdateDate || 0).getTime() -
|
||
new Date(left.lastUpdateDate || 0).getTime()
|
||
);
|
||
});
|
||
}
|
||
|
||
function normalizePendingServiceRequest(item) {
|
||
return {
|
||
customerName: cleanString(
|
||
getFieldValue(item, [
|
||
"CustomerAccount.PartyUniqueName",
|
||
"CustomerAccount.PartyName",
|
||
"CustomerAccount.Name",
|
||
"customerName",
|
||
])
|
||
),
|
||
lastUpdateDate: cleanString(getFieldValue(item, ["LastUpdateDate", "lastUpdateDate"])),
|
||
srNumber: cleanString(getFieldValue(item, ["SrNumber", "srNumber"])),
|
||
queueName: cleanString(
|
||
getFieldValue(item, ["Queue.QueueName", "QueueName", "queueName"])
|
||
),
|
||
requestedBy: cleanString(
|
||
getFieldValue(item, [
|
||
"ReportedByPartyId.PrimaryEmail.EmailAddress",
|
||
"ReportedByPartyId.EmailAddress",
|
||
"requestedBy",
|
||
])
|
||
),
|
||
creationDate: cleanString(getFieldValue(item, ["CreationDate", "creationDate"])),
|
||
opportunityNumber: cleanString(
|
||
getFieldValue(item, [
|
||
"OpportunityName_Id_c.OptyNumber",
|
||
"OptyNumber",
|
||
"opportunityNumber",
|
||
])
|
||
),
|
||
srStatusLabel: cleanString(
|
||
getFieldValue(item, ["StatusCd_localizedValue", "StatusCd", "srStatusLabel"])
|
||
),
|
||
opportunityStatusLabel: cleanString(
|
||
getFieldValue(item, [
|
||
"OpportunityName_Id_c.StatusCode_localizedValue",
|
||
"OpportunityName_Id_c.StatusCode",
|
||
"opportunityStatusLabel",
|
||
])
|
||
),
|
||
serviceName: cleanString(
|
||
getFieldValue(item, [
|
||
"ServiceNew_Id_c.RecordName",
|
||
"ServiceNew_Id_c",
|
||
"serviceName",
|
||
])
|
||
),
|
||
country: cleanString(getFieldValue(item, ["CountryText_c", "country"])),
|
||
totalHoursWorked: toNumber(
|
||
getFieldValue(item, ["ReportedHours_c", "totalHoursWorked"])
|
||
),
|
||
customerDeadline: cleanString(
|
||
getFieldValue(item, ["CustomerDeadline_c", "customerDeadline"])
|
||
),
|
||
opportunityValue: toNumber(
|
||
getFieldValue(item, [
|
||
"WorkloadAmountUSDWorkspace_c",
|
||
"OpportunityName_Id_c.WorkloadAmountUSDWorkspace_c",
|
||
"OpportunityName_Id_c.PrimaryRevenue.RevnAmount",
|
||
"opportunityValue",
|
||
])
|
||
),
|
||
pillarLabel: cleanString(
|
||
getFieldValue(item, [
|
||
"PrimaryPillarNew_c_localizedValue",
|
||
"PrimaryPillarNew_c",
|
||
"pillarLabel",
|
||
])
|
||
),
|
||
};
|
||
}
|
||
|
||
function getFieldValue(source, paths) {
|
||
if (!source || typeof source !== "object") {
|
||
return "";
|
||
}
|
||
|
||
for (const path of paths) {
|
||
if (Object.prototype.hasOwnProperty.call(source, path)) {
|
||
return source[path];
|
||
}
|
||
|
||
const parts = String(path).split(".");
|
||
let current = source;
|
||
|
||
for (const part of parts) {
|
||
if (!current || typeof current !== "object") {
|
||
current = "";
|
||
break;
|
||
}
|
||
|
||
current = current[part];
|
||
}
|
||
|
||
if (current !== undefined && current !== null && current !== "") {
|
||
return current;
|
||
}
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
async function hydrateDatasetFromCache() {
|
||
if (appState.dataset || cacheHydrationPromise) {
|
||
return cacheHydrationPromise;
|
||
}
|
||
|
||
appState.status = "loading";
|
||
appState.errorMessage = "";
|
||
appState.loadingMessage = "Loading cached snapshot";
|
||
appState.loadingProgress = 18;
|
||
renderModal();
|
||
|
||
cacheHydrationPromise = (async () => {
|
||
try {
|
||
const exportPayload = await readCachedDataset();
|
||
appState.loadingProgress = exportPayload ? 100 : 0;
|
||
|
||
if (exportPayload) {
|
||
appState.dataset = createDatasetSnapshot(exportPayload);
|
||
appState.status = "ready";
|
||
} else {
|
||
appState.status = "idle";
|
||
}
|
||
} catch (error) {
|
||
appState.status = "error";
|
||
appState.errorMessage = getErrorMessage(error);
|
||
} finally {
|
||
appState.loadingMessage = "";
|
||
appState.loadingProgress = 0;
|
||
cacheHydrationPromise = null;
|
||
renderModal();
|
||
}
|
||
})();
|
||
|
||
return cacheHydrationPromise;
|
||
}
|
||
|
||
async function fetchCurrentUser() {
|
||
const currentFrameToken = syncWorklistIframeToken({ force: true })?.token;
|
||
|
||
const worklistToken = currentFrameToken || (await readCachedWorklistToken());
|
||
|
||
if (!worklistToken) {
|
||
throw new Error(
|
||
"Unable to resolve Worklist token. Reload the FuseWelcome page so the Worklist iframe token can be captured before refreshing data."
|
||
);
|
||
}
|
||
|
||
const url = new URL(API_ENDPOINTS.worklistCurrentUser);
|
||
url.search = new URLSearchParams({
|
||
source: "saasui",
|
||
_: String(Date.now()),
|
||
token: worklistToken,
|
||
}).toString();
|
||
|
||
appState.sessionAuthHeaders = {};
|
||
|
||
const payload = await fetchJson(url.toString(), { forceExtension: true });
|
||
const userPayload = normalizeWorklistUserPayload(payload);
|
||
const firstName = cleanString(
|
||
getFieldValue(userPayload, ["FirstName", "firstName", "User.FirstName"])
|
||
);
|
||
const lastName = cleanString(
|
||
getFieldValue(userPayload, ["LastName", "lastName", "User.LastName"])
|
||
);
|
||
const userEmail = cleanString(
|
||
getFieldValue(userPayload, [
|
||
"Email",
|
||
"email",
|
||
"EmailAddress",
|
||
"emailAddress",
|
||
"User.Email",
|
||
"User.email",
|
||
"User.EmailAddress",
|
||
])
|
||
);
|
||
const displayName = firstNonEmptyString([
|
||
`${firstName} ${lastName}`.trim(),
|
||
userPayload?.DisplayName,
|
||
userPayload?.displayName,
|
||
userEmail,
|
||
]);
|
||
|
||
if (!userEmail) {
|
||
const userKeys =
|
||
userPayload && typeof userPayload === "object"
|
||
? Object.keys(userPayload).join(", ")
|
||
: "";
|
||
throw new Error(
|
||
`Unable to resolve userEmail from Worklist user response. Available top-level keys: ${Object.keys(
|
||
payload || {}
|
||
).join(", ")}${userKeys ? ` | User keys: ${userKeys}` : ""}`
|
||
);
|
||
}
|
||
|
||
appState.sessionAuthHeaders = {};
|
||
|
||
return {
|
||
userEmail,
|
||
name: displayName || userEmail,
|
||
avatar: cleanString(getFieldValue(userPayload, ["ImageURL", "imageUrl", "User.ImageURL"])),
|
||
administrator: Boolean(
|
||
getFieldValue(userPayload, ["Administrator", "administrator", "User.Administrator"])
|
||
),
|
||
};
|
||
}
|
||
|
||
function normalizeWorklistUserPayload(payload) {
|
||
const candidate = payload?.User ?? payload?.user ?? payload;
|
||
|
||
if (Array.isArray(candidate)) {
|
||
return candidate[0] || {};
|
||
}
|
||
|
||
if (candidate?.User) {
|
||
return normalizeWorklistUserPayload(candidate);
|
||
}
|
||
|
||
return candidate || {};
|
||
}
|
||
|
||
async function fetchResourceCurrentUser(userEmail) {
|
||
const normalizedUserEmail = cleanString(userEmail);
|
||
|
||
if (!normalizedUserEmail) {
|
||
throw new Error("Unable to request resource current user without userEmail.");
|
||
}
|
||
|
||
const url = `${COMCIP_REQUEST.resourceCurrentUserUrl}?emailAddress=${encodeURIComponent(
|
||
normalizedUserEmail
|
||
)}`;
|
||
const response = await sendComcipGetMessage(url);
|
||
|
||
if (!response?.ok) {
|
||
const payloadMessage =
|
||
typeof response?.payload === "string"
|
||
? cleanString(response.payload)
|
||
: firstNonEmptyString([
|
||
response?.payload?.message,
|
||
response?.payload?.error,
|
||
response?.payload?.title,
|
||
response?.payload?.detail,
|
||
]);
|
||
|
||
throw new Error(
|
||
payloadMessage ||
|
||
response?.error ||
|
||
`COMCIP resource user request failed (${response?.status || 0}).`
|
||
);
|
||
}
|
||
|
||
return response.payload;
|
||
}
|
||
|
||
|
||
async function fetchAllCustomers(userEmail) {
|
||
const limit = 49;
|
||
let offset = 0;
|
||
const customers = [];
|
||
const seen = new Set();
|
||
|
||
while (true) {
|
||
const url = new URL(API_ENDPOINTS.customerSummary, TARGET_ORIGIN);
|
||
url.search = new URLSearchParams({
|
||
emailAddress: userEmail,
|
||
projectionPeriod: "ALL",
|
||
recordSet: "IAM_ON_SR_TEAM",
|
||
growth: "All",
|
||
projectedGrowth: "All",
|
||
attainmentBand: "All",
|
||
projectedConsumption: "All",
|
||
customerStatus: "All",
|
||
workloadStatus: "All",
|
||
lastProjectionUpdate: "All",
|
||
revenueForecastType: "All",
|
||
customerProgramCode: "All",
|
||
planType: "All",
|
||
workLoadLivePeriod: "All",
|
||
activeConsumptionOnly: "false",
|
||
favorite: "false",
|
||
workloadDeleted: "false",
|
||
awsFlag: "N",
|
||
googleFlag: "N",
|
||
azureFlag: "N",
|
||
excludePayAsYouGoOnly: "false",
|
||
projectionReview: "N",
|
||
pillarFilter: "dpOci",
|
||
sortBy: "CUSTOMER_NAME",
|
||
sortOrder: "ASC",
|
||
limit: String(limit),
|
||
offset: String(offset),
|
||
}).toString();
|
||
|
||
const payload = await fetchJson(url.toString());
|
||
const items = extractArray(payload, [
|
||
"items",
|
||
"customers",
|
||
"content",
|
||
"results",
|
||
"records",
|
||
"data",
|
||
]);
|
||
|
||
const mapped = items
|
||
.map((item) => ({
|
||
customerId: cleanString(item?.id),
|
||
name: item?.name || "",
|
||
}))
|
||
.filter((item) => item.customerId);
|
||
|
||
for (const item of mapped) {
|
||
if (!seen.has(item.customerId)) {
|
||
seen.add(item.customerId);
|
||
customers.push(item);
|
||
}
|
||
}
|
||
|
||
if (mapped.length < limit) {
|
||
break;
|
||
}
|
||
|
||
offset += limit;
|
||
}
|
||
|
||
return customers;
|
||
}
|
||
|
||
async function fetchCustomerWorkloads(customer) {
|
||
const url = new URL(
|
||
`${API_ENDPOINTS.customerWorkloads}/${encodeURIComponent(
|
||
customer.customerId
|
||
)}/workloads`,
|
||
TARGET_ORIGIN
|
||
);
|
||
url.search = new URLSearchParams({
|
||
workloadStatus: "Pipeline,Implementation,Live",
|
||
opportunityForecastType: "All",
|
||
includeForEstimates: "ALL",
|
||
financialYear: "ROLLING_4_QTRS",
|
||
}).toString();
|
||
|
||
const payload = await fetchJson(url.toString());
|
||
const items = extractArray(payload, [
|
||
"items",
|
||
"workloads",
|
||
"content",
|
||
"results",
|
||
"data",
|
||
]);
|
||
|
||
return items
|
||
.map((item) => normalizeWorkload(item))
|
||
.filter(Boolean)
|
||
.filter((workload) => !isWonWorkload(workload));
|
||
}
|
||
|
||
async function fetchWorkloadActions(workloadId) {
|
||
const url = new URL(API_ENDPOINTS.workloadActions, TARGET_ORIGIN);
|
||
url.search = new URLSearchParams({
|
||
parentType: "WORKLOAD",
|
||
parentId: workloadId,
|
||
}).toString();
|
||
|
||
const payload = await fetchJson(url.toString());
|
||
const items = extractArray(payload, ["items", "actions", "content", "results", "data"]);
|
||
|
||
return items.map((item) => ({
|
||
owner: item?.owner || "",
|
||
role: item?.role || "",
|
||
name: item?.name || "",
|
||
team: normalizeTeamValue(item?.team),
|
||
startDate: item?.startDate || "",
|
||
endDate: item?.endDate || "",
|
||
complexity: item?.complexity || "",
|
||
}));
|
||
}
|
||
|
||
async function fetchWorkloadServiceRequests(workloadId) {
|
||
const url = new URL(
|
||
`${API_ENDPOINTS.workloadServiceRequests}/${encodeURIComponent(
|
||
workloadId
|
||
)}/serviceRequests`,
|
||
TARGET_ORIGIN
|
||
);
|
||
|
||
const payload = await fetchJson(url.toString());
|
||
const items = extractArray(payload, [
|
||
"items",
|
||
"serviceRequests",
|
||
"content",
|
||
"results",
|
||
"data",
|
||
]);
|
||
|
||
return items.map((item) => ({
|
||
srNumber: item?.srNumber || "",
|
||
status: item?.status || "",
|
||
team: normalizeTeamValue(item?.team),
|
||
}));
|
||
}
|
||
|
||
async function fetchJson(url, options = {}) {
|
||
if (options.forceExtension) {
|
||
return fetchJsonViaExtension(url, options);
|
||
}
|
||
|
||
if (shouldUseSpaFrameFetch(url) && !options.forceExtension) {
|
||
return fetchJsonViaSpaFrame(url, options);
|
||
}
|
||
|
||
if (shouldUseExtensionFetch(url) && !options.forcePageBridge) {
|
||
return fetchJsonViaExtension(url, options);
|
||
}
|
||
|
||
await ensurePageBridge();
|
||
|
||
const requestId = `${Date.now()}-${++requestCounter}`;
|
||
const requestUrl = toBridgeRequestUrl(url);
|
||
|
||
const response = await new Promise((resolve, reject) => {
|
||
const timeout = window.setTimeout(() => {
|
||
pendingBridgeRequests.delete(requestId);
|
||
reject(new Error(`Timed out while requesting ${requestUrl}`));
|
||
}, 60000);
|
||
|
||
pendingBridgeRequests.set(requestId, {
|
||
resolve,
|
||
reject,
|
||
timeout,
|
||
});
|
||
|
||
window.postMessage(
|
||
{
|
||
source: BRIDGE.source,
|
||
type: BRIDGE.fetchType,
|
||
requestId,
|
||
url: requestUrl,
|
||
options: {
|
||
method: options.method || "GET",
|
||
transport: options.transport || "fetch",
|
||
referrer: options.referrer,
|
||
headers: {
|
||
accept: "application/json, text/plain, */*",
|
||
...(options.headers || {}),
|
||
},
|
||
body: options.body,
|
||
},
|
||
},
|
||
window.location.origin
|
||
);
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const payloadMessage =
|
||
typeof response.payload === "string"
|
||
? cleanString(response.payload)
|
||
: firstNonEmptyString([
|
||
response.payload?.message,
|
||
response.payload?.error,
|
||
response.payload?.title,
|
||
response.payload?.detail,
|
||
]);
|
||
const debugHeaderKeys = Array.isArray(response.debug?.headerKeys)
|
||
? response.debug.headerKeys.join(", ")
|
||
: "";
|
||
const debugScopeKeys = Array.isArray(response.debug?.scopeKeys)
|
||
? response.debug.scopeKeys.join(", ")
|
||
: "";
|
||
const debugParts = [
|
||
debugHeaderKeys ? `captured headers: ${debugHeaderKeys}` : "",
|
||
debugScopeKeys ? `scopes: ${debugScopeKeys}` : "",
|
||
].filter(Boolean);
|
||
const debugSuffix = debugParts.length > 0
|
||
? ` | ${debugParts.join(" | ")}`
|
||
: "";
|
||
|
||
throw new Error(
|
||
`${payloadMessage || response.error || `Request failed (${response.status}) for ${requestUrl}`}${debugSuffix}`
|
||
);
|
||
}
|
||
|
||
return response.payload;
|
||
}
|
||
|
||
function shouldUseExtensionFetch(url) {
|
||
const normalized = resolveRequestUrl(url);
|
||
|
||
if (!normalized) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
const parsedUrl = new URL(normalized, window.location.origin);
|
||
return parsedUrl.origin !== window.location.origin;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function shouldUseSpaFrameFetch(url) {
|
||
if (!isHcmWelcomePage()) {
|
||
return false;
|
||
}
|
||
|
||
const normalized = resolveRequestUrl(url);
|
||
|
||
if (!normalized) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
return new URL(normalized).origin === TARGET_ORIGIN;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function fetchJsonViaSpaFrame(url, options = {}) {
|
||
const requestUrl = resolveRequestUrl(url);
|
||
const bridgeHeaders = await requestBridgeHeaders({
|
||
url: requestUrl,
|
||
headers: options.headers,
|
||
body: options.body,
|
||
includeAllAuth: true,
|
||
}).catch(() => ({}));
|
||
|
||
await ensureSpaFrame();
|
||
|
||
const response = await new Promise((resolve, reject) => {
|
||
chrome.runtime.sendMessage(
|
||
{
|
||
type: "ARCH_PANEL_SPA_FRAME_FETCH",
|
||
url: requestUrl,
|
||
options: {
|
||
method: options.method || "GET",
|
||
referrer: options.referrer,
|
||
headers: buildExtensionFetchHeaders(
|
||
requestUrl,
|
||
options,
|
||
bridgeHeaders || {}
|
||
),
|
||
body: options.body,
|
||
},
|
||
},
|
||
(result) => {
|
||
const runtimeError = chrome.runtime.lastError;
|
||
|
||
if (runtimeError) {
|
||
reject(new Error(runtimeError.message));
|
||
return;
|
||
}
|
||
|
||
resolve(result || null);
|
||
}
|
||
);
|
||
});
|
||
|
||
if (!response || typeof response !== "object") {
|
||
throw new Error(`No response received for ${requestUrl}`);
|
||
}
|
||
|
||
if (
|
||
!response.ok &&
|
||
response.status === 0 &&
|
||
/SPA Workbench frame did not return a result/i.test(cleanString(response.error))
|
||
) {
|
||
throw new Error(response.error || "SPA Workbench iframe did not return a result.");
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const payloadMessage =
|
||
typeof response.payload === "string"
|
||
? cleanString(response.payload)
|
||
: firstNonEmptyString([
|
||
response.payload?.message,
|
||
response.payload?.error,
|
||
response.payload?.title,
|
||
response.payload?.detail,
|
||
]);
|
||
const debugHeaderKeys = Array.isArray(response.debug?.headerKeys)
|
||
? response.debug.headerKeys.join(", ")
|
||
: "";
|
||
const debugFrameUrl = cleanString(response.debug?.frameUrl);
|
||
const debugUrl = cleanString(response.debug?.url);
|
||
const debugParts = [
|
||
debugUrl ? `url: ${debugUrl}` : "",
|
||
debugFrameUrl ? `frame: ${debugFrameUrl}` : "",
|
||
debugHeaderKeys ? `captured headers: ${debugHeaderKeys}` : "",
|
||
].filter(Boolean);
|
||
const debugSuffix = debugParts.length > 0
|
||
? ` | ${debugParts.join(" | ")}`
|
||
: "";
|
||
|
||
throw new Error(
|
||
`${
|
||
payloadMessage ||
|
||
response.error ||
|
||
`Request failed (${response.status}) for ${requestUrl}`
|
||
}${debugSuffix}`
|
||
);
|
||
}
|
||
|
||
return response.payload;
|
||
}
|
||
|
||
function ensureSpaFrame() {
|
||
const frameHost = ensureSpaFrameHost();
|
||
const existingFrame = document.getElementById(IDS.spaFrame);
|
||
const frameUrl = TARGET_APP_URL;
|
||
|
||
if (existingFrame instanceof HTMLIFrameElement) {
|
||
if (existingFrame.parentElement !== frameHost) {
|
||
frameHost.appendChild(existingFrame);
|
||
}
|
||
|
||
if (shouldNavigateSpaFrame(existingFrame, frameUrl)) {
|
||
navigateSpaFrame(existingFrame, frameUrl);
|
||
}
|
||
|
||
return waitForFrameLoad(existingFrame);
|
||
}
|
||
|
||
const frame = document.createElement("iframe");
|
||
frame.id = IDS.spaFrame;
|
||
frame.name = IDS.spaFrame;
|
||
frame.dataset.archPanelFrame = "spa-workbench";
|
||
frame.setAttribute("aria-hidden", "true");
|
||
frame.setAttribute("referrerpolicy", "no-referrer-when-downgrade");
|
||
frame.tabIndex = -1;
|
||
Object.assign(frame.style, {
|
||
position: "absolute",
|
||
width: "1440px",
|
||
height: "1000px",
|
||
opacity: "0",
|
||
pointerEvents: "none",
|
||
border: "0",
|
||
left: "0",
|
||
top: "0",
|
||
});
|
||
|
||
frameHost.appendChild(frame);
|
||
navigateSpaFrame(frame, frameUrl);
|
||
|
||
return waitForFrameLoad(frame);
|
||
}
|
||
|
||
function ensureSpaFrameHost() {
|
||
const existingHost = document.getElementById(IDS.spaFrameHost);
|
||
|
||
if (existingHost) {
|
||
const desiredParent = document.body || document.documentElement;
|
||
|
||
if (desiredParent && existingHost.parentElement !== desiredParent) {
|
||
desiredParent.appendChild(existingHost);
|
||
}
|
||
|
||
Object.assign(existingHost.style, {
|
||
position: "fixed",
|
||
width: "1440px",
|
||
height: "1000px",
|
||
overflow: "hidden",
|
||
opacity: "0",
|
||
pointerEvents: "none",
|
||
border: "0",
|
||
left: "-20000px",
|
||
top: "0",
|
||
zIndex: "0",
|
||
});
|
||
|
||
return existingHost;
|
||
}
|
||
|
||
const host = document.createElement("div");
|
||
host.id = IDS.spaFrameHost;
|
||
host.setAttribute("aria-hidden", "true");
|
||
Object.assign(host.style, {
|
||
position: "fixed",
|
||
width: "1440px",
|
||
height: "1000px",
|
||
overflow: "hidden",
|
||
opacity: "0",
|
||
pointerEvents: "none",
|
||
border: "0",
|
||
left: "-20000px",
|
||
top: "0",
|
||
zIndex: "0",
|
||
});
|
||
|
||
(document.body || document.documentElement).appendChild(host);
|
||
|
||
return host;
|
||
}
|
||
|
||
function shouldNavigateSpaFrame(frame, frameUrl) {
|
||
const currentSrc = cleanString(frame.getAttribute("src") || frame.src);
|
||
|
||
if (!currentSrc || currentSrc === "about:blank") {
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
return new URL(currentSrc, window.location.href).toString() !== frameUrl;
|
||
} catch {
|
||
return currentSrc !== frameUrl;
|
||
}
|
||
}
|
||
|
||
function navigateSpaFrame(frame, frameUrl) {
|
||
frame.dataset.archPanelLoaded = "false";
|
||
frame.src = frameUrl;
|
||
}
|
||
|
||
function waitForFrameLoad(frame) {
|
||
if (frame.dataset.archPanelLoaded === "true") {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
const timeout = window.setTimeout(() => {
|
||
resolve();
|
||
}, 12000);
|
||
|
||
frame.addEventListener(
|
||
"load",
|
||
() => {
|
||
frame.dataset.archPanelLoaded = "true";
|
||
window.clearTimeout(timeout);
|
||
window.setTimeout(resolve, 1200);
|
||
},
|
||
{ once: true }
|
||
);
|
||
});
|
||
}
|
||
|
||
async function fetchJsonViaExtension(url, options = {}) {
|
||
const requestUrl = resolveRequestUrl(url);
|
||
const bridgeHeaders = await requestBridgeHeaders({
|
||
url: requestUrl,
|
||
headers: options.headers,
|
||
body: options.body,
|
||
includeAllAuth: true,
|
||
});
|
||
const response = await new Promise((resolve, reject) => {
|
||
chrome.runtime.sendMessage(
|
||
{
|
||
type: "ARCH_PANEL_EXTENSION_FETCH",
|
||
url: requestUrl,
|
||
options: {
|
||
method: options.method || "GET",
|
||
referrer: options.referrer,
|
||
headers: buildExtensionFetchHeaders(
|
||
requestUrl,
|
||
options,
|
||
bridgeHeaders || {}
|
||
),
|
||
body: options.body,
|
||
},
|
||
},
|
||
(result) => {
|
||
const runtimeError = chrome.runtime.lastError;
|
||
|
||
if (runtimeError) {
|
||
reject(new Error(runtimeError.message));
|
||
return;
|
||
}
|
||
|
||
resolve(result || null);
|
||
}
|
||
);
|
||
});
|
||
|
||
if (!response || typeof response !== "object") {
|
||
throw new Error(`No response received for ${requestUrl}`);
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const payloadMessage =
|
||
typeof response.payload === "string"
|
||
? cleanString(response.payload)
|
||
: firstNonEmptyString([
|
||
response.payload?.message,
|
||
response.payload?.error,
|
||
response.payload?.title,
|
||
response.payload?.detail,
|
||
]);
|
||
const debugHeaderKeys = Array.isArray(response.debug?.headerKeys)
|
||
? response.debug.headerKeys.join(", ")
|
||
: "";
|
||
const debugUrl = cleanString(response.debug?.url);
|
||
const debugParts = [
|
||
debugUrl ? `url: ${debugUrl}` : "",
|
||
debugHeaderKeys ? `captured headers: ${debugHeaderKeys}` : "",
|
||
].filter(Boolean);
|
||
const debugSuffix = debugParts.length > 0
|
||
? ` | ${debugParts.join(" | ")}`
|
||
: "";
|
||
|
||
throw new Error(
|
||
`${
|
||
payloadMessage ||
|
||
response.error ||
|
||
`Request failed (${response.status}) for ${requestUrl}`
|
||
}${debugSuffix}`
|
||
);
|
||
}
|
||
|
||
return response.payload;
|
||
}
|
||
|
||
function buildExtensionFetchHeaders(requestUrl, options = {}, bridgeHeaders = {}) {
|
||
const combinedHeaders = {
|
||
...(bridgeHeaders || {}),
|
||
...(appState.sessionAuthHeaders || {}),
|
||
accept: "application/json, text/plain, */*",
|
||
...(options.headers || {}),
|
||
};
|
||
|
||
return options.omitAuthHeaders ? stripAuthHeaders(combinedHeaders) : combinedHeaders;
|
||
}
|
||
|
||
function stripAuthHeaders(headers = {}) {
|
||
const sanitizedHeaders = {};
|
||
|
||
for (const [key, value] of Object.entries(headers || {})) {
|
||
const normalizedKey = cleanString(key).toLowerCase();
|
||
|
||
if (
|
||
normalizedKey === "authorization" ||
|
||
normalizedKey === "x-id-token" ||
|
||
normalizedKey === "spa-ts-authorization" ||
|
||
normalizedKey.endsWith("-authorization")
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
sanitizedHeaders[key] = value;
|
||
}
|
||
|
||
return sanitizedHeaders;
|
||
}
|
||
|
||
async function requestBridgeHeaders(options = {}) {
|
||
await ensurePageBridge();
|
||
|
||
const requestId = `${Date.now()}-${++requestCounter}`;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const timeout = window.setTimeout(() => {
|
||
pendingBridgeRequests.delete(requestId);
|
||
reject(new Error("Timed out while requesting bridge headers"));
|
||
}, 30000);
|
||
|
||
pendingBridgeRequests.set(requestId, {
|
||
resolve,
|
||
reject,
|
||
timeout,
|
||
});
|
||
|
||
window.postMessage(
|
||
{
|
||
source: BRIDGE.source,
|
||
type: BRIDGE.headersType,
|
||
requestId,
|
||
options,
|
||
},
|
||
window.location.origin
|
||
);
|
||
});
|
||
}
|
||
|
||
function normalizeWorkload(item) {
|
||
const workloadId = cleanString(item?.workloadId || item?.id);
|
||
|
||
if (!workloadId) {
|
||
return null;
|
||
}
|
||
|
||
const opportunityForecastType = item?.opportunityForecastType || "";
|
||
const opportunityForecastTypeGroup = normalizeForecastType(
|
||
opportunityForecastType
|
||
);
|
||
|
||
return {
|
||
workloadId,
|
||
name: item?.name || "",
|
||
description: item?.description || "",
|
||
consumptionStartDate: item?.consumptionStartDate || "",
|
||
workloadStatus: item?.workloadStatus || "",
|
||
opportunityForecastType,
|
||
opportunityForecastTypeGroup,
|
||
opportunityId: cleanString(item?.opportunityId),
|
||
opportunityNumber: item?.opportunityNumber || "",
|
||
adjustedACR: toNumber(item?.adjustedACR),
|
||
rampMonths: toNumber(item?.rampMonths),
|
||
forecast: extractArray(item?.forecast, ["items", "content", "results", "data"]).map(
|
||
(forecastItem) => ({
|
||
month: forecastItem?.month || "",
|
||
year: toNumber(forecastItem?.year),
|
||
projectedConsumptionAmount: toNumber(
|
||
forecastItem?.projectedConsumptionAmount
|
||
),
|
||
adjustedConsumptionAmount: toNumber(
|
||
forecastItem?.adjustedConsumptionAmount
|
||
),
|
||
createdBy: forecastItem?.createdBy || "",
|
||
createdDate: forecastItem?.createdDate || "",
|
||
updatedBy: forecastItem?.updatedBy || "",
|
||
updatedDate: forecastItem?.updatedDate || "",
|
||
})
|
||
),
|
||
};
|
||
}
|
||
|
||
function isWonWorkload(workload) {
|
||
const forecastType = normalizeString(workload.opportunityForecastTypeGroup);
|
||
const workloadStatus = normalizeString(workload.workloadStatus);
|
||
|
||
return forecastType === "WON" || workloadStatus === "WON";
|
||
}
|
||
|
||
function normalizeForecastType(value) {
|
||
const text = String(value || "").trim();
|
||
|
||
if (!text) {
|
||
return "Unknown";
|
||
}
|
||
|
||
const [head] = text.split(" -");
|
||
return head.trim() || "Unknown";
|
||
}
|
||
|
||
function normalizeTeamValue(value) {
|
||
if (Array.isArray(value)) {
|
||
return value.map((item) => normalizeTeamValue(item)).join(" | ");
|
||
}
|
||
|
||
if (value && typeof value === "object") {
|
||
return Object.values(value).map((item) => normalizeTeamValue(item)).join(" | ");
|
||
}
|
||
|
||
return String(value || "");
|
||
}
|
||
|
||
function teamContainsUser(team, userEmail) {
|
||
return normalizeString(team).includes(normalizeString(userEmail));
|
||
}
|
||
|
||
function stripTransientFields(workload) {
|
||
return {
|
||
workloadId: workload.workloadId,
|
||
name: workload.name,
|
||
description: workload.description,
|
||
consumptionStartDate: workload.consumptionStartDate,
|
||
workloadStatus: workload.workloadStatus,
|
||
opportunityForecastType: workload.opportunityForecastType,
|
||
opportunityForecastTypeGroup: workload.opportunityForecastTypeGroup,
|
||
opportunityId: workload.opportunityId,
|
||
opportunityNumber: workload.opportunityNumber,
|
||
adjustedACR: workload.adjustedACR,
|
||
rampMonths: workload.rampMonths,
|
||
forecast: workload.forecast,
|
||
customerId: workload.customerId,
|
||
customerName: workload.customerName,
|
||
actions: workload.actions,
|
||
serviceRequests: workload.serviceRequests,
|
||
hasSR: workload.hasSR,
|
||
hasAction: workload.hasAction,
|
||
};
|
||
}
|
||
|
||
function createExportPayload(
|
||
user,
|
||
customers,
|
||
pendingServiceRequests = [],
|
||
resourceCurrentUser = null
|
||
) {
|
||
return {
|
||
generatedAt: new Date().toISOString(),
|
||
user,
|
||
resourceCurrentUser,
|
||
customers,
|
||
pendingServiceRequests,
|
||
};
|
||
}
|
||
|
||
function createDatasetSnapshot(exportPayload) {
|
||
const normalizedPayload = normalizeCachedExportPayload(exportPayload);
|
||
|
||
return {
|
||
user: normalizedPayload.user,
|
||
resourceCurrentUser: normalizedPayload.resourceCurrentUser,
|
||
customers: normalizedPayload.customers,
|
||
exportPayload: normalizedPayload,
|
||
summary: buildSummary(normalizedPayload),
|
||
};
|
||
}
|
||
|
||
function findWorkloadById(workloadId) {
|
||
if (!appState.dataset || !workloadId) {
|
||
return null;
|
||
}
|
||
|
||
for (const customer of appState.dataset.customers || []) {
|
||
for (const workload of customer.workloads || []) {
|
||
if (workload.workloadId === workloadId) {
|
||
return {
|
||
...workload,
|
||
customerId: workload.customerId || customer.customerId,
|
||
customerName: workload.customerName || customer.name,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function applyForecastUpdateToDataset(workloadId, payload) {
|
||
if (!appState.dataset || !workloadId) {
|
||
return;
|
||
}
|
||
|
||
const previousDetailModal = appState.detailModal
|
||
? {
|
||
scope: appState.detailModal.scope,
|
||
value: appState.detailModal.value,
|
||
sortKey: appState.detailModal.sortKey,
|
||
sortDirection: appState.detailModal.sortDirection,
|
||
type: appState.detailModal.type,
|
||
}
|
||
: null;
|
||
const updatesByPeriod = new Map(
|
||
(payload || []).map((item) => [
|
||
`${toNumber(item.year)}-${toNumber(item.month)}`,
|
||
toNumber(item.amount),
|
||
])
|
||
);
|
||
|
||
function updateForecast(workload) {
|
||
if (!workload || workload.workloadId !== workloadId) {
|
||
return workload;
|
||
}
|
||
|
||
workload.forecast = (workload.forecast || []).map((forecastItem) => {
|
||
const month = parseForecastMonthNumber(forecastItem.month);
|
||
const year = toNumber(forecastItem.year);
|
||
const key = `${year}-${month}`;
|
||
|
||
if (!updatesByPeriod.has(key)) {
|
||
return forecastItem;
|
||
}
|
||
|
||
return {
|
||
...forecastItem,
|
||
adjustedConsumptionAmount: updatesByPeriod.get(key),
|
||
updatedBy: appState.dataset.user?.userEmail || forecastItem.updatedBy,
|
||
updatedDate: new Date().toISOString(),
|
||
};
|
||
});
|
||
|
||
return workload;
|
||
}
|
||
|
||
for (const customer of appState.dataset.customers || []) {
|
||
customer.workloads = (customer.workloads || []).map((workload) =>
|
||
updateForecast(workload)
|
||
);
|
||
}
|
||
|
||
for (const customer of appState.dataset.exportPayload?.customers || []) {
|
||
customer.workloads = (customer.workloads || []).map((workload) =>
|
||
updateForecast(workload)
|
||
);
|
||
}
|
||
|
||
appState.dataset = createDatasetSnapshot(appState.dataset.exportPayload);
|
||
|
||
if (previousDetailModal?.scope && previousDetailModal.type !== "serviceRequests") {
|
||
restoreDetailModal(previousDetailModal);
|
||
}
|
||
|
||
void writeCachedDataset(appState.dataset.exportPayload);
|
||
}
|
||
|
||
function normalizeCachedExportPayload(exportPayload) {
|
||
const normalizedUser = {
|
||
userEmail: cleanString(exportPayload?.user?.userEmail),
|
||
name:
|
||
firstNonEmptyString([
|
||
exportPayload?.user?.name,
|
||
exportPayload?.user?.displayName,
|
||
exportPayload?.user?.fullName,
|
||
exportPayload?.user?.profile?.name,
|
||
exportPayload?.user?.userEmail,
|
||
]) || "",
|
||
avatar: cleanString(exportPayload?.user?.avatar || exportPayload?.user?.imageUrl),
|
||
administrator: Boolean(exportPayload?.user?.administrator),
|
||
};
|
||
const resourceCurrentUser = exportPayload?.resourceCurrentUser ?? null;
|
||
const normalizedCustomers = extractArray(exportPayload?.customers, ["customers"])
|
||
.map((customer) => ({
|
||
customerId: cleanString(customer?.customerId || customer?.id),
|
||
name: cleanString(customer?.name),
|
||
workloads: extractArray(customer?.workloads, ["workloads"])
|
||
.map((workload) => stripTransientFields(normalizeWorkloadForCache(workload)))
|
||
.filter((workload) => workload.workloadId),
|
||
}))
|
||
.filter((customer) => customer.customerId);
|
||
const pendingServiceRequests = normalizePendingServiceRequests(
|
||
extractArray(exportPayload?.pendingServiceRequests, [
|
||
"pendingServiceRequests",
|
||
"items",
|
||
"data",
|
||
"results",
|
||
])
|
||
);
|
||
|
||
return {
|
||
generatedAt:
|
||
firstNonEmptyString([exportPayload?.generatedAt]) || new Date().toISOString(),
|
||
user: normalizedUser,
|
||
resourceCurrentUser,
|
||
customers: normalizedCustomers,
|
||
pendingServiceRequests,
|
||
};
|
||
}
|
||
|
||
function normalizeWorkloadForCache(workload) {
|
||
const normalized = {
|
||
workloadId: cleanString(workload?.workloadId),
|
||
name: cleanString(workload?.name),
|
||
description: cleanString(workload?.description),
|
||
consumptionStartDate: workload?.consumptionStartDate || "",
|
||
workloadStatus: cleanString(workload?.workloadStatus),
|
||
opportunityForecastType: cleanString(workload?.opportunityForecastType),
|
||
opportunityForecastTypeGroup:
|
||
cleanString(workload?.opportunityForecastTypeGroup) ||
|
||
normalizeOpportunityForecastType(workload?.opportunityForecastType),
|
||
opportunityId: cleanString(workload?.opportunityId),
|
||
opportunityNumber: cleanString(workload?.opportunityNumber),
|
||
adjustedACR: toNumber(workload?.adjustedACR),
|
||
rampMonths: toNumber(workload?.rampMonths),
|
||
forecast: extractArray(workload?.forecast, ["forecast"]).map((entry) => ({
|
||
month: entry?.month || "",
|
||
year: entry?.year || "",
|
||
projectedConsumptionAmount: toNumber(entry?.projectedConsumptionAmount),
|
||
adjustedConsumptionAmount: toNumber(entry?.adjustedConsumptionAmount),
|
||
createdBy: entry?.createdBy || "",
|
||
createdDate: entry?.createdDate || "",
|
||
updatedBy: entry?.updatedBy || "",
|
||
updatedDate: entry?.updatedDate || "",
|
||
})),
|
||
customerId: cleanString(workload?.customerId),
|
||
customerName: cleanString(workload?.customerName),
|
||
actions: extractArray(workload?.actions, ["actions"]).map((action) => ({
|
||
owner: cleanString(action?.owner),
|
||
role: cleanString(action?.role),
|
||
name: cleanString(action?.name),
|
||
team: normalizeTeamValue(action?.team),
|
||
startDate: action?.startDate || "",
|
||
endDate: action?.endDate || "",
|
||
complexity: cleanString(action?.complexity),
|
||
})),
|
||
serviceRequests: extractArray(workload?.serviceRequests, [
|
||
"serviceRequests",
|
||
]).map((serviceRequest) => ({
|
||
srNumber: cleanString(serviceRequest?.srNumber),
|
||
status: cleanString(serviceRequest?.status),
|
||
team: normalizeTeamValue(serviceRequest?.team),
|
||
})),
|
||
hasSR: Boolean(workload?.hasSR),
|
||
hasAction: Boolean(workload?.hasAction),
|
||
};
|
||
|
||
return normalized;
|
||
}
|
||
|
||
function openCacheDatabase() {
|
||
return new Promise((resolve, reject) => {
|
||
if (!window.indexedDB) {
|
||
reject(new Error("IndexedDB is not available in this browser context."));
|
||
return;
|
||
}
|
||
|
||
const request = window.indexedDB.open(CACHE.dbName, CACHE.dbVersion);
|
||
|
||
request.onupgradeneeded = () => {
|
||
const database = request.result;
|
||
|
||
if (!database.objectStoreNames.contains(CACHE.storeName)) {
|
||
database.createObjectStore(CACHE.storeName, { keyPath: "key" });
|
||
}
|
||
};
|
||
|
||
request.onsuccess = () => {
|
||
resolve(request.result);
|
||
};
|
||
|
||
request.onerror = () => {
|
||
reject(request.error || new Error("Unable to open IndexedDB cache."));
|
||
};
|
||
});
|
||
}
|
||
|
||
async function withCacheStore(mode, callback) {
|
||
const database = await openCacheDatabase();
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const transaction = database.transaction(CACHE.storeName, mode);
|
||
const store = transaction.objectStore(CACHE.storeName);
|
||
|
||
transaction.oncomplete = () => {
|
||
database.close();
|
||
};
|
||
|
||
transaction.onerror = () => {
|
||
const error =
|
||
transaction.error || new Error("IndexedDB transaction failed.");
|
||
database.close();
|
||
reject(error);
|
||
};
|
||
|
||
transaction.onabort = () => {
|
||
const error =
|
||
transaction.error || new Error("IndexedDB transaction was aborted.");
|
||
database.close();
|
||
reject(error);
|
||
};
|
||
|
||
callback(store, resolve, reject);
|
||
});
|
||
}
|
||
|
||
async function readCachedDataset() {
|
||
const record = await withCacheStore("readonly", (store, resolve, reject) => {
|
||
const request = store.get(CACHE.datasetKey);
|
||
|
||
request.onsuccess = () => {
|
||
const payload =
|
||
request.result?.exportPayload ||
|
||
request.result?.payload ||
|
||
request.result?.dataset?.exportPayload ||
|
||
request.result?.dataset ||
|
||
null;
|
||
|
||
resolve(payload);
|
||
};
|
||
|
||
request.onerror = () => {
|
||
reject(request.error || new Error("Unable to read the cached dataset."));
|
||
};
|
||
});
|
||
|
||
return record ? normalizeCachedExportPayload(record) : null;
|
||
}
|
||
|
||
async function writeCachedDataset(exportPayload) {
|
||
const normalizedPayload = normalizeCachedExportPayload(exportPayload);
|
||
|
||
await withCacheStore("readwrite", (store, resolve, reject) => {
|
||
const request = store.put({
|
||
key: CACHE.datasetKey,
|
||
updatedAt: new Date().toISOString(),
|
||
exportPayload: normalizedPayload,
|
||
});
|
||
|
||
request.onsuccess = () => {
|
||
resolve();
|
||
};
|
||
|
||
request.onerror = () => {
|
||
reject(request.error || new Error("Unable to write the cached dataset."));
|
||
};
|
||
});
|
||
}
|
||
|
||
async function readCachedTaskTypePayload() {
|
||
return withCacheStore("readonly", (store, resolve, reject) => {
|
||
const request = store.get(CACHE.taskTypeKey);
|
||
|
||
request.onsuccess = () => {
|
||
resolve(request.result?.payload || null);
|
||
};
|
||
|
||
request.onerror = () => {
|
||
reject(request.error || new Error("Unable to read the cached task types."));
|
||
};
|
||
});
|
||
}
|
||
|
||
async function writeCachedTaskTypePayload(payload) {
|
||
await withCacheStore("readwrite", (store, resolve, reject) => {
|
||
const request = store.put({
|
||
key: CACHE.taskTypeKey,
|
||
updatedAt: new Date().toISOString(),
|
||
payload,
|
||
});
|
||
|
||
request.onsuccess = () => {
|
||
resolve();
|
||
};
|
||
|
||
request.onerror = () => {
|
||
reject(request.error || new Error("Unable to write the cached task types."));
|
||
};
|
||
});
|
||
}
|
||
|
||
async function writeCachedWorklistToken(tokenPayload) {
|
||
const token = cleanString(tokenPayload?.token);
|
||
|
||
if (!token) {
|
||
return;
|
||
}
|
||
|
||
await withCacheStore("readwrite", (store, resolve, reject) => {
|
||
const request = store.put({
|
||
key: CACHE.worklistTokenKey,
|
||
updatedAt: new Date().toISOString(),
|
||
token,
|
||
sourceUrl: cleanString(tokenPayload?.url),
|
||
});
|
||
|
||
request.onsuccess = () => {
|
||
resolve();
|
||
};
|
||
|
||
request.onerror = () => {
|
||
reject(request.error || new Error("Unable to write the cached Worklist token."));
|
||
};
|
||
});
|
||
}
|
||
|
||
async function readCachedWorklistToken() {
|
||
const record = await withCacheStore("readonly", (store, resolve, reject) => {
|
||
const request = store.get(CACHE.worklistTokenKey);
|
||
|
||
request.onsuccess = () => {
|
||
resolve(request.result || null);
|
||
};
|
||
|
||
request.onerror = () => {
|
||
reject(request.error || new Error("Unable to read the cached Worklist token."));
|
||
};
|
||
});
|
||
|
||
return cleanString(record?.token);
|
||
}
|
||
|
||
function buildSummary(exportPayload) {
|
||
const customers = exportPayload.customers || [];
|
||
const allWorkloads = customers.flatMap((customer) => customer.workloads || []);
|
||
const eligibleWorkloads = allWorkloads
|
||
.filter((workload) =>
|
||
ALLOWED_STAGE_TYPES.includes(workload.opportunityForecastTypeGroup)
|
||
)
|
||
.sort((left, right) => right.adjustedACR - left.adjustedACR);
|
||
const totalEligibleAcr = eligibleWorkloads.reduce((sum, workload) => {
|
||
return sum + workload.adjustedACR;
|
||
}, 0);
|
||
const stageIndicators = ALLOWED_STAGE_TYPES.map((label) => {
|
||
const items = eligibleWorkloads.filter(
|
||
(workload) => workload.opportunityForecastTypeGroup === label
|
||
);
|
||
|
||
return {
|
||
label,
|
||
count: items.length,
|
||
totalAcr: items.reduce((sum, workload) => sum + workload.adjustedACR, 0),
|
||
};
|
||
});
|
||
const srStatus = {
|
||
trueCount: eligibleWorkloads.filter((workload) => workload.hasSR).length,
|
||
falseCount: eligibleWorkloads.filter((workload) => !workload.hasSR).length,
|
||
};
|
||
const actionStatus = {
|
||
trueCount: eligibleWorkloads.filter((workload) => workload.hasAction).length,
|
||
falseCount: eligibleWorkloads.filter((workload) => !workload.hasAction).length,
|
||
};
|
||
const pendingServiceRequests = exportPayload.pendingServiceRequests || [];
|
||
const srHours = {
|
||
betweenZeroAndFour: pendingServiceRequests.filter((serviceRequest) => {
|
||
const hours = toNumber(serviceRequest.totalHoursWorked);
|
||
return hours >= 0 && hours <= 4;
|
||
}),
|
||
aboveFour: pendingServiceRequests.filter((serviceRequest) => {
|
||
return toNumber(serviceRequest.totalHoursWorked) > 4;
|
||
}),
|
||
};
|
||
|
||
return {
|
||
generatedAtLabel: formatDateTime(exportPayload.generatedAt),
|
||
totalCustomers: customers.length,
|
||
totalWorkloads: allWorkloads.length,
|
||
allWorkloads,
|
||
eligibleWorkloads,
|
||
totalEligibleAcr,
|
||
stageIndicators,
|
||
srStatus,
|
||
actionStatus,
|
||
pendingServiceRequests,
|
||
srHours,
|
||
};
|
||
}
|
||
|
||
function buildWorkloadStartCalendar(
|
||
workloads,
|
||
year,
|
||
month,
|
||
view = "month",
|
||
anchorDateKey = ""
|
||
) {
|
||
const today = new Date();
|
||
const normalizedView = view === "week" ? "week" : "month";
|
||
const monthStart = new Date(year, month, 1);
|
||
const anchorDate = getCalendarAnchorDate(year, month, anchorDateKey);
|
||
const calendarStart =
|
||
normalizedView === "week"
|
||
? getWeekStartDate(anchorDate)
|
||
: getWeekStartDate(monthStart);
|
||
const dayCount = normalizedView === "week" ? 7 : 42;
|
||
const workloadMap = new Map();
|
||
|
||
for (const workload of workloads || []) {
|
||
const dateKey = getLocalDateKey(workload.consumptionStartDate);
|
||
|
||
if (!dateKey) {
|
||
continue;
|
||
}
|
||
|
||
const bucket = workloadMap.get(dateKey) || [];
|
||
bucket.push(workload);
|
||
workloadMap.set(dateKey, bucket);
|
||
}
|
||
|
||
const days = Array.from({ length: dayCount }, (_, index) => {
|
||
const date = new Date(calendarStart);
|
||
date.setDate(calendarStart.getDate() + index);
|
||
const dateKey = getLocalDateKey(date);
|
||
const workloadsForDay = workloadMap.get(dateKey) || [];
|
||
|
||
return {
|
||
dateKey,
|
||
dayNumber: date.getDate(),
|
||
isWeekStart: index % 7 === 0,
|
||
operationalWeekLabel: formatOperationalWeekLabel(
|
||
getOperationalWeekInfo(getCalendarRowWeekDate(date))
|
||
),
|
||
isCurrentMonth: normalizedView === "week" || date.getMonth() === month,
|
||
isToday: dateKey === getLocalDateKey(today),
|
||
workloads: workloadsForDay,
|
||
};
|
||
});
|
||
const weekEnd = new Date(calendarStart);
|
||
weekEnd.setDate(calendarStart.getDate() + 6);
|
||
const monthLabel = new Intl.DateTimeFormat("en-US", {
|
||
month: "long",
|
||
year: "numeric",
|
||
}).format(monthStart);
|
||
const weekLabel = `${formatDate(calendarStart)} - ${formatDate(weekEnd)}`;
|
||
|
||
return {
|
||
view: normalizedView,
|
||
periodLabel: normalizedView === "week" ? weekLabel : monthLabel,
|
||
days,
|
||
totalVisibleWorkloads: days.reduce(
|
||
(sum, day) => sum + day.workloads.length,
|
||
0
|
||
),
|
||
};
|
||
}
|
||
|
||
function getCalendarAnchorDate(year, month, anchorDateKey) {
|
||
const anchorDate = parseDatePreservingDateOnly(anchorDateKey);
|
||
|
||
if (!Number.isNaN(anchorDate.getTime())) {
|
||
return anchorDate;
|
||
}
|
||
|
||
return new Date(year, month, 1);
|
||
}
|
||
|
||
function getWeekStartDate(value) {
|
||
const date = parseDatePreservingDateOnly(value);
|
||
const weekStart = Number.isNaN(date.getTime()) ? new Date() : new Date(date);
|
||
|
||
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
||
return weekStart;
|
||
}
|
||
|
||
function openDetailModal(scope, value) {
|
||
if (!appState.dataset) {
|
||
return;
|
||
}
|
||
|
||
const eligibleWorkloads = appState.dataset.summary.eligibleWorkloads;
|
||
let items = [];
|
||
let label = "";
|
||
let title = "";
|
||
|
||
if (scope === "stage") {
|
||
items = eligibleWorkloads.filter(
|
||
(workload) => workload.opportunityForecastTypeGroup === value
|
||
);
|
||
label = `Opportunity type: ${value}`;
|
||
title = `${value} workloads`;
|
||
} else if (scope === "sr") {
|
||
const flag = value === "true";
|
||
items = eligibleWorkloads.filter((workload) => workload.hasSR === flag);
|
||
label = "Status de SRs";
|
||
title = flag ? "Workloads com SR" : "Workloads sem SR";
|
||
} else if (scope === "action") {
|
||
const flag = value === "true";
|
||
items = eligibleWorkloads.filter((workload) => workload.hasAction === flag);
|
||
label = "Status de Consumption Plan/action";
|
||
title = flag ? "Workloads com action" : "Workloads sem action";
|
||
} else if (scope === "sr-hours") {
|
||
const pendingServiceRequests = appState.dataset.summary.pendingServiceRequests || [];
|
||
const isAboveFour = value === "above-4";
|
||
items = pendingServiceRequests.filter((serviceRequest) => {
|
||
const hours = toNumber(serviceRequest.totalHoursWorked);
|
||
return isAboveFour ? hours > 4 : hours >= 0 && hours <= 4;
|
||
});
|
||
items.sort((left, right) => {
|
||
return toNumber(left.totalHoursWorked) - toNumber(right.totalHoursWorked);
|
||
});
|
||
label = "SRs por horas reportadas";
|
||
title = isAboveFour ? "SRs acima de 4h" : "SRs entre 0 e 4h";
|
||
} else if (scope === "calendar-day") {
|
||
const allWorkloads = appState.dataset.summary.allWorkloads || [];
|
||
items = allWorkloads.filter((workload) => {
|
||
return getLocalDateKey(workload.consumptionStartDate) === value;
|
||
});
|
||
label = "Workload start calendar";
|
||
title = `Workloads starting ${formatDate(value) || value}`;
|
||
}
|
||
|
||
appState.detailModal = {
|
||
label,
|
||
title,
|
||
items,
|
||
totalAcr:
|
||
scope === "sr-hours"
|
||
? 0
|
||
: items.reduce((sum, workload) => sum + workload.adjustedACR, 0),
|
||
sortKey: DETAIL_SORT_KEYS.adjustedACR,
|
||
sortDirection: "desc",
|
||
scope,
|
||
value,
|
||
type: scope === "sr-hours" ? "serviceRequests" : "workloads",
|
||
};
|
||
|
||
renderModal();
|
||
}
|
||
|
||
function openActionFormModal(workloadId) {
|
||
appState.actionFormModal = {
|
||
workloadId,
|
||
errorMessage: "",
|
||
isSubmitting: false,
|
||
};
|
||
renderModal();
|
||
}
|
||
|
||
function openRampComparisonModal(workloadId) {
|
||
const workload = findWorkloadById(workloadId);
|
||
|
||
if (!workload) {
|
||
return;
|
||
}
|
||
|
||
appState.rampComparisonModal = {
|
||
workload,
|
||
rows: buildRampForecastComparisonRows([workload]),
|
||
};
|
||
renderModal();
|
||
}
|
||
|
||
function openForecastUpdateConfirmModal() {
|
||
if (!appState.rampComparisonModal || !appState.dataset) {
|
||
return;
|
||
}
|
||
|
||
const payload = createForecastUpdatePayload(
|
||
appState.rampComparisonModal.rows || []
|
||
);
|
||
|
||
appState.forecastUpdateConfirmModal = {
|
||
workloadId: appState.rampComparisonModal.workload.workloadId,
|
||
payload,
|
||
isSubmitting: false,
|
||
errorMessage: "",
|
||
};
|
||
renderModal();
|
||
}
|
||
|
||
async function submitForecastUpdate() {
|
||
const modal = appState.forecastUpdateConfirmModal;
|
||
|
||
if (!modal || modal.isSubmitting) {
|
||
return;
|
||
}
|
||
|
||
appState.forecastUpdateConfirmModal = {
|
||
...modal,
|
||
isSubmitting: true,
|
||
errorMessage: "",
|
||
};
|
||
renderModal();
|
||
|
||
try {
|
||
const workloadId = cleanString(modal.workloadId);
|
||
const url = `${API_ENDPOINTS.workloadServiceRequests}/${encodeURIComponent(
|
||
workloadId
|
||
)}/forecast`;
|
||
const headers = {
|
||
accept: "*/*",
|
||
"content-type": "application/json",
|
||
};
|
||
|
||
await fetchJson(url, {
|
||
method: "PATCH",
|
||
omitAuthHeaders: true,
|
||
headers,
|
||
body: JSON.stringify(modal.payload),
|
||
});
|
||
|
||
applyForecastUpdateToDataset(workloadId, modal.payload);
|
||
const updatedWorkload = findWorkloadById(workloadId);
|
||
|
||
appState.forecastUpdateConfirmModal = null;
|
||
|
||
if (updatedWorkload) {
|
||
appState.rampComparisonModal = {
|
||
workload: updatedWorkload,
|
||
rows: buildRampForecastComparisonRows([updatedWorkload]),
|
||
};
|
||
}
|
||
|
||
renderModal();
|
||
} catch (error) {
|
||
appState.forecastUpdateConfirmModal = {
|
||
...modal,
|
||
isSubmitting: false,
|
||
errorMessage: getErrorMessage(error),
|
||
};
|
||
renderModal();
|
||
}
|
||
}
|
||
|
||
function createForecastUpdatePayload(rows) {
|
||
const userEmail = appState.dataset?.user?.userEmail || "";
|
||
|
||
return (rows || [])
|
||
.filter((row) => row.year && row.month)
|
||
.map((row) => ({
|
||
year: toNumber(row.year),
|
||
month: toNumber(row.month),
|
||
amount: toNumber(row.calculatedValue),
|
||
updatedBy: userEmail,
|
||
}));
|
||
}
|
||
|
||
async function openTimeEntriesDrawer(srNumber) {
|
||
const normalizedSrNumber = cleanString(srNumber);
|
||
|
||
if (!normalizedSrNumber) {
|
||
return;
|
||
}
|
||
|
||
appState.timeEntriesDrawer = {
|
||
srNumber: normalizedSrNumber,
|
||
status: "loading",
|
||
errorMessage: "",
|
||
payload: null,
|
||
items: [],
|
||
};
|
||
renderModal();
|
||
|
||
try {
|
||
const response = await fetchTimeEntriesSummary(normalizedSrNumber);
|
||
|
||
appState.timeEntriesDrawer = {
|
||
srNumber: normalizedSrNumber,
|
||
status: "ready",
|
||
errorMessage: "",
|
||
payload: response.payload,
|
||
items: normalizeTimeEntriesSummary(response.payload),
|
||
};
|
||
} catch (error) {
|
||
appState.timeEntriesDrawer = {
|
||
srNumber: normalizedSrNumber,
|
||
status: "error",
|
||
errorMessage: getErrorMessage(error),
|
||
payload: null,
|
||
items: [],
|
||
};
|
||
}
|
||
|
||
renderModal();
|
||
}
|
||
|
||
async function fetchTimeEntriesSummary(srNumber) {
|
||
const url = `${COMCIP_REQUEST.timeEntriesSummaryUrl}?srNumber=${encodeURIComponent(
|
||
srNumber
|
||
)}`;
|
||
const response = await sendComcipGetMessage(url);
|
||
|
||
if (!response?.ok) {
|
||
const payloadMessage =
|
||
typeof response?.payload === "string"
|
||
? cleanString(response.payload)
|
||
: firstNonEmptyString([
|
||
response?.payload?.message,
|
||
response?.payload?.error,
|
||
response?.payload?.title,
|
||
response?.payload?.detail,
|
||
]);
|
||
|
||
throw new Error(
|
||
payloadMessage ||
|
||
response?.error ||
|
||
`COMCIP request failed (${response?.status || 0}).`
|
||
);
|
||
}
|
||
|
||
return response;
|
||
}
|
||
|
||
async function openTimeManagementModal() {
|
||
const selectedDate = getStoredTimeManagementSelectedDate();
|
||
|
||
appState.timeManagementModal = createTimeManagementLoadingState(selectedDate);
|
||
renderModal();
|
||
await loadTimeManagementData(selectedDate);
|
||
}
|
||
|
||
async function loadTimeManagementData(selectedDate) {
|
||
const normalizedDate = cleanString(selectedDate) || getDateInputValue(new Date());
|
||
const requestId = `${Date.now()}-${++requestCounter}`;
|
||
|
||
setStoredTimeManagementSelectedDate(normalizedDate);
|
||
appState.timeManagementDatePicker = {
|
||
...appState.timeManagementDatePicker,
|
||
isOpen: false,
|
||
};
|
||
appState.timeManagementModal = {
|
||
...(appState.timeManagementModal || {}),
|
||
requestId,
|
||
selectedDate: normalizedDate,
|
||
status: "loading",
|
||
errorMessage: "",
|
||
};
|
||
renderModal();
|
||
|
||
try {
|
||
const resourcePartyId = getCurrentUserResourcePartyId();
|
||
const taskTypePromise = fetchTaskTypeRequest();
|
||
|
||
if (!resourcePartyId) {
|
||
throw new Error("resourceCurrentUser.resourcePartyId não está disponÃvel.");
|
||
}
|
||
|
||
const weekInfoPayload = await fetchInfoWeekRequest(normalizedDate);
|
||
const weekDays = normalizeInfoWeekResponse(weekInfoPayload);
|
||
const weekId = cleanString(weekDays[0]?.weekId);
|
||
|
||
if (!weekId) {
|
||
throw new Error("WeekId não encontrado no retorno de infoWeekRequest.");
|
||
}
|
||
|
||
const timeEntryWeekUrl = buildTimeEntryWeekUrl(resourcePartyId, weekId);
|
||
const [srPayload, taskTypePayload, timeEntryWeekPayload] = await Promise.all([
|
||
fetchActiveServiceRequests(resourcePartyId, weekId),
|
||
taskTypePromise,
|
||
fetchTimeEntryWeek(timeEntryWeekUrl),
|
||
]);
|
||
const timeEntryRows = normalizeTimeManagementEntryRows(
|
||
timeEntryWeekPayload,
|
||
weekDays
|
||
);
|
||
const serviceRequests = mergeTimeManagementServiceRequests(
|
||
normalizeTimeManagementServiceRequests(srPayload),
|
||
timeEntryRows
|
||
);
|
||
const activitySrNumbers = getActivityRequestSrNumbers(srPayload, serviceRequests);
|
||
const activityRequest = await fetchCachedActivityRequest(activitySrNumbers);
|
||
const activityRequestUrl = activityRequest.url;
|
||
const activityPayload = activityRequest.payload;
|
||
|
||
const activityOptionsBySr = normalizeActivityRequest(
|
||
activityPayload,
|
||
serviceRequests
|
||
);
|
||
const taskTypes = normalizeTaskTypeRequest(taskTypePayload);
|
||
const rows = timeEntryRows;
|
||
const selectedSrNumber = rows[0]?.selectedSrNumber || "";
|
||
const normalizedRows = rows.map((row, index) =>
|
||
normalizeTimeManagementEntryRowSelection(
|
||
row,
|
||
index,
|
||
activityOptionsBySr,
|
||
taskTypes
|
||
)
|
||
);
|
||
|
||
if (appState.timeManagementModal?.requestId !== requestId) {
|
||
return;
|
||
}
|
||
|
||
appState.timeManagementModal = {
|
||
requestId,
|
||
selectedDate: normalizedDate,
|
||
status: "ready",
|
||
errorMessage: "",
|
||
weekId,
|
||
weekDays,
|
||
serviceRequests,
|
||
selectedSrNumber,
|
||
activityOptionsBySr,
|
||
activityRequestUrl,
|
||
activityRequestPayload: activityPayload,
|
||
timeEntryRows: normalizedRows,
|
||
selectedActivityValue:
|
||
normalizedRows[0]?.selectedActivityValue || "",
|
||
taskTypes,
|
||
selectedTaskTypeValue: normalizedRows[0]?.selectedTaskTypeValue || "",
|
||
highlightRowId: "",
|
||
deletedTimeEntryIds: [],
|
||
isSaving: false,
|
||
saveErrorMessage: "",
|
||
};
|
||
renderModal();
|
||
} catch (error) {
|
||
if (appState.timeManagementModal?.requestId !== requestId) {
|
||
return;
|
||
}
|
||
|
||
appState.timeManagementModal = {
|
||
...(appState.timeManagementModal || {}),
|
||
requestId,
|
||
selectedDate: normalizedDate,
|
||
status: "error",
|
||
errorMessage: getErrorMessage(error),
|
||
};
|
||
renderModal();
|
||
}
|
||
}
|
||
|
||
function createTimeManagementLoadingState(selectedDate) {
|
||
return {
|
||
requestId: "",
|
||
selectedDate,
|
||
status: "loading",
|
||
errorMessage: "",
|
||
weekId: "",
|
||
weekDays: [],
|
||
serviceRequests: [],
|
||
selectedSrNumber: "",
|
||
activityOptionsBySr: {},
|
||
activityRequestUrl: "",
|
||
activityRequestPayload: null,
|
||
selectedActivityValue: "",
|
||
taskTypes: [],
|
||
selectedTaskTypeValue: "",
|
||
timeEntryRows: [],
|
||
highlightRowId: "",
|
||
deletedTimeEntryIds: [],
|
||
isSaving: false,
|
||
saveErrorMessage: "",
|
||
};
|
||
}
|
||
|
||
function getTimeManagementRows(modal) {
|
||
return modal.timeEntryRows || [];
|
||
}
|
||
|
||
function isTimeManagementSaveEnabled(modal) {
|
||
const pendingChanges = getTimeManagementPendingChanges(modal);
|
||
const changedRows = [
|
||
...pendingChanges.newRows,
|
||
...pendingChanges.updatedRows,
|
||
];
|
||
|
||
if (
|
||
!pendingChanges.newRows.length &&
|
||
!pendingChanges.updatedRows.length &&
|
||
!pendingChanges.deletedIds.length
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
return changedRows.every((row) => isTimeManagementRowReadyToSave(row));
|
||
}
|
||
|
||
function isTimeManagementRowReadyToSave(row) {
|
||
const selectedSrNumber = cleanString(row?.selectedSrNumber);
|
||
|
||
if (!selectedSrNumber) {
|
||
return false;
|
||
}
|
||
|
||
if (!hasPositiveTimeManagementHours(row)) {
|
||
return false;
|
||
}
|
||
|
||
if (isNonServiceRequestSrNumber(selectedSrNumber)) {
|
||
return Boolean(cleanString(row?.selectedTaskTypeValue));
|
||
}
|
||
|
||
return Boolean(
|
||
cleanString(row?.selectedActivityValue) &&
|
||
cleanString(row?.selectedTaskTypeValue)
|
||
);
|
||
}
|
||
|
||
function hasPositiveTimeManagementHours(row) {
|
||
return Object.values(row?.dayHours || {}).some((value) => {
|
||
const normalizedValue = cleanString(value);
|
||
|
||
if (!normalizedValue) {
|
||
return false;
|
||
}
|
||
|
||
const numericValue = Number(normalizedValue.replace(",", "."));
|
||
|
||
return Number.isFinite(numericValue) && numericValue > 0;
|
||
});
|
||
}
|
||
|
||
function createBlankTimeManagementEntryRow(srNumber = "", source = "blank") {
|
||
return {
|
||
id: `row-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||
timeEntryId: "",
|
||
selectedSrNumber: cleanString(srNumber),
|
||
selectedActivityValue: "",
|
||
selectedTaskTypeValue: "",
|
||
dayHours: {},
|
||
source,
|
||
originalSignature: "",
|
||
};
|
||
}
|
||
|
||
function addTimeManagementEntryRow(type) {
|
||
if (!appState.timeManagementModal) {
|
||
return;
|
||
}
|
||
|
||
const existingDuplicateRow = findExistingTimeManagementDuplicateRow(
|
||
appState.timeManagementModal,
|
||
type
|
||
);
|
||
|
||
if (existingDuplicateRow) {
|
||
highlightTimeManagementDuplicateRow(existingDuplicateRow.id);
|
||
return;
|
||
}
|
||
|
||
const srNumber = type === "non-service" ? "non-service-sr" : "";
|
||
const nextRows = [
|
||
...getTimeManagementRows(appState.timeManagementModal),
|
||
createBlankTimeManagementEntryRow(srNumber, "manual"),
|
||
];
|
||
|
||
appState.timeManagementModal = {
|
||
...appState.timeManagementModal,
|
||
timeEntryRows: nextRows,
|
||
highlightRowId: "",
|
||
saveErrorMessage: "",
|
||
};
|
||
renderModal();
|
||
}
|
||
|
||
function removeTimeManagementEntryRow(rowId) {
|
||
if (!appState.timeManagementModal) {
|
||
return;
|
||
}
|
||
|
||
const normalizedRowId = cleanString(rowId);
|
||
const rows = getTimeManagementRows(appState.timeManagementModal);
|
||
const removedRow = rows.find((row) => cleanString(row?.id) === normalizedRowId);
|
||
const removedTimeEntryId = cleanString(removedRow?.timeEntryId);
|
||
const remainingRows = rows.filter(
|
||
(row) => cleanString(row?.id) !== normalizedRowId
|
||
);
|
||
const nextRows = remainingRows;
|
||
const deletedTimeEntryIds = removedTimeEntryId
|
||
? Array.from(
|
||
new Set([
|
||
...(appState.timeManagementModal.deletedTimeEntryIds || []),
|
||
removedTimeEntryId,
|
||
])
|
||
)
|
||
: appState.timeManagementModal.deletedTimeEntryIds || [];
|
||
|
||
appState.timeManagementModal = {
|
||
...appState.timeManagementModal,
|
||
timeEntryRows: nextRows,
|
||
selectedSrNumber: nextRows[0]?.selectedSrNumber || "",
|
||
selectedActivityValue: nextRows[0]?.selectedActivityValue || "",
|
||
selectedTaskTypeValue: nextRows[0]?.selectedTaskTypeValue || "",
|
||
highlightRowId: "",
|
||
deletedTimeEntryIds,
|
||
saveErrorMessage: "",
|
||
};
|
||
renderModal();
|
||
}
|
||
|
||
function getTimeManagementRowById(modal, rowId) {
|
||
const rows = getTimeManagementRows(modal);
|
||
const normalizedRowId = cleanString(rowId);
|
||
|
||
return (
|
||
rows.find((row) => cleanString(row?.id) === normalizedRowId) ||
|
||
rows[0] ||
|
||
null
|
||
);
|
||
}
|
||
|
||
function updateTimeManagementRows(modal, rowId, nextRow) {
|
||
const rows = getTimeManagementRows(modal);
|
||
const normalizedRowId = cleanString(rowId || nextRow?.id);
|
||
|
||
return rows.map((row, index) => {
|
||
const isTarget = normalizedRowId
|
||
? cleanString(row?.id) === normalizedRowId
|
||
: index === 0;
|
||
|
||
return isTarget
|
||
? {
|
||
...row,
|
||
...nextRow,
|
||
id: row?.id || nextRow?.id || `row-${index + 1}`,
|
||
}
|
||
: row;
|
||
});
|
||
}
|
||
|
||
function updateTimeManagementSelectedSr(srNumber, rowId = "") {
|
||
if (!appState.timeManagementModal) {
|
||
return;
|
||
}
|
||
|
||
const targetRow = getTimeManagementRowById(appState.timeManagementModal, rowId);
|
||
const selectedServiceRequest = findTimeManagementServiceRequest(
|
||
appState.timeManagementModal,
|
||
srNumber
|
||
);
|
||
const selectedSrNumber = selectedServiceRequest?.srNumber || cleanString(srNumber);
|
||
const duplicateRow = findDuplicateTimeManagementSrRow(
|
||
appState.timeManagementModal,
|
||
selectedSrNumber,
|
||
rowId
|
||
);
|
||
|
||
if (duplicateRow) {
|
||
highlightTimeManagementDuplicateRow(duplicateRow.id);
|
||
return;
|
||
}
|
||
|
||
const nextRow = {
|
||
...(targetRow || createBlankTimeManagementEntryRow()),
|
||
selectedSrNumber,
|
||
selectedActivityValue: "",
|
||
selectedTaskTypeValue: "",
|
||
};
|
||
const nextRows = updateTimeManagementRows(
|
||
appState.timeManagementModal,
|
||
rowId,
|
||
nextRow
|
||
);
|
||
|
||
appState.timeManagementModal = {
|
||
...appState.timeManagementModal,
|
||
timeEntryRows: nextRows,
|
||
selectedSrNumber: nextRows[0]?.selectedSrNumber || selectedSrNumber,
|
||
selectedActivityValue: nextRows[0]?.selectedActivityValue || "",
|
||
selectedTaskTypeValue: nextRows[0]?.selectedTaskTypeValue || "",
|
||
highlightRowId: "",
|
||
saveErrorMessage: "",
|
||
};
|
||
renderModal();
|
||
}
|
||
|
||
function updateTimeManagementField(fieldName, inputValue, rowId = "") {
|
||
if (!appState.timeManagementModal) {
|
||
return;
|
||
}
|
||
|
||
const targetRow = getTimeManagementRowById(appState.timeManagementModal, rowId);
|
||
const normalizedInput = cleanString(inputValue);
|
||
let value = normalizedInput;
|
||
|
||
if (fieldName === "selectedActivityValue") {
|
||
const options = getTimeManagementActivityOptions(
|
||
appState.timeManagementModal,
|
||
targetRow?.selectedSrNumber || appState.timeManagementModal.selectedSrNumber
|
||
);
|
||
value = findOptionValue(options, normalizedInput) || normalizedInput;
|
||
}
|
||
|
||
if (fieldName === "selectedTaskTypeValue") {
|
||
const options = getTimeManagementTaskTypeOptions(
|
||
appState.timeManagementModal,
|
||
targetRow?.selectedSrNumber || appState.timeManagementModal.selectedSrNumber
|
||
);
|
||
value =
|
||
findOptionValue(options, normalizedInput) || normalizedInput;
|
||
|
||
const duplicateRow = findDuplicateTimeManagementNonServiceTaskTypeRow(
|
||
appState.timeManagementModal,
|
||
value,
|
||
rowId
|
||
);
|
||
|
||
if (duplicateRow) {
|
||
highlightTimeManagementDuplicateRow(duplicateRow.id);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const nextRows = updateTimeManagementRows(appState.timeManagementModal, rowId, {
|
||
...(targetRow || createBlankTimeManagementEntryRow()),
|
||
[fieldName]: value,
|
||
});
|
||
|
||
appState.timeManagementModal = {
|
||
...appState.timeManagementModal,
|
||
timeEntryRows: nextRows,
|
||
[fieldName]: value,
|
||
highlightRowId: "",
|
||
saveErrorMessage: "",
|
||
};
|
||
}
|
||
|
||
function findDuplicateTimeManagementSrRow(modal, srNumber, currentRowId = "") {
|
||
const normalizedSrNumber = normalizeString(srNumber);
|
||
const normalizedCurrentRowId = cleanString(currentRowId);
|
||
|
||
if (!normalizedSrNumber || isNonServiceRequestSrNumber(srNumber)) {
|
||
return null;
|
||
}
|
||
|
||
return getTimeManagementRows(modal).find((row) => {
|
||
return (
|
||
cleanString(row?.id) !== normalizedCurrentRowId &&
|
||
normalizeString(row?.selectedSrNumber) === normalizedSrNumber
|
||
);
|
||
}) || null;
|
||
}
|
||
|
||
function findExistingTimeManagementDuplicateRow(modal, type) {
|
||
const seen = new Map();
|
||
|
||
for (const row of getTimeManagementRows(modal)) {
|
||
const rowId = cleanString(row?.id);
|
||
let key = "";
|
||
|
||
if (type === "non-service") {
|
||
if (!isNonServiceRequestSrNumber(row?.selectedSrNumber)) {
|
||
continue;
|
||
}
|
||
|
||
key = normalizeString(row?.selectedTaskTypeValue);
|
||
} else {
|
||
if (isNonServiceRequestSrNumber(row?.selectedSrNumber)) {
|
||
continue;
|
||
}
|
||
|
||
key = normalizeString(row?.selectedSrNumber);
|
||
}
|
||
|
||
if (!key) {
|
||
continue;
|
||
}
|
||
|
||
if (seen.has(key)) {
|
||
return getTimeManagementRows(modal).find(
|
||
(candidate) => cleanString(candidate?.id) === seen.get(key)
|
||
) || row;
|
||
}
|
||
|
||
seen.set(key, rowId);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function findDuplicateTimeManagementNonServiceTaskTypeRow(
|
||
modal,
|
||
taskTypeValue,
|
||
currentRowId = ""
|
||
) {
|
||
const normalizedTaskTypeValue = normalizeString(taskTypeValue);
|
||
const normalizedCurrentRowId = cleanString(currentRowId);
|
||
|
||
if (!normalizedTaskTypeValue) {
|
||
return null;
|
||
}
|
||
|
||
const currentRow = getTimeManagementRowById(modal, currentRowId);
|
||
|
||
if (!isNonServiceRequestSrNumber(currentRow?.selectedSrNumber)) {
|
||
return null;
|
||
}
|
||
|
||
return getTimeManagementRows(modal).find((row) => {
|
||
return (
|
||
cleanString(row?.id) !== normalizedCurrentRowId &&
|
||
isNonServiceRequestSrNumber(row?.selectedSrNumber) &&
|
||
normalizeString(row?.selectedTaskTypeValue) === normalizedTaskTypeValue
|
||
);
|
||
}) || null;
|
||
}
|
||
|
||
function highlightTimeManagementDuplicateRow(rowId) {
|
||
const normalizedRowId = cleanString(rowId);
|
||
|
||
if (!appState.timeManagementModal || !normalizedRowId) {
|
||
return;
|
||
}
|
||
|
||
appState.pendingTimeManagementHighlightRowId = normalizedRowId;
|
||
appState.timeManagementModal = {
|
||
...appState.timeManagementModal,
|
||
highlightRowId: normalizedRowId,
|
||
};
|
||
renderModal();
|
||
}
|
||
|
||
function updateTimeManagementDayHours(rowId, dateKey, inputValue) {
|
||
if (!appState.timeManagementModal) {
|
||
return;
|
||
}
|
||
|
||
const targetRow = getTimeManagementRowById(appState.timeManagementModal, rowId);
|
||
const nextRow = {
|
||
...(targetRow || createBlankTimeManagementEntryRow()),
|
||
dayHours: {
|
||
...(targetRow?.dayHours || {}),
|
||
[cleanString(dateKey)]: cleanString(inputValue),
|
||
},
|
||
};
|
||
|
||
appState.timeManagementModal = {
|
||
...appState.timeManagementModal,
|
||
timeEntryRows: updateTimeManagementRows(
|
||
appState.timeManagementModal,
|
||
rowId,
|
||
nextRow
|
||
),
|
||
highlightRowId: "",
|
||
saveErrorMessage: "",
|
||
};
|
||
renderModal();
|
||
}
|
||
|
||
async function saveTimeManagementChanges() {
|
||
const modal = appState.timeManagementModal;
|
||
|
||
if (!modal || modal.isSaving || !isTimeManagementSaveEnabled(modal)) {
|
||
return;
|
||
}
|
||
|
||
appState.timeManagementModal = {
|
||
...modal,
|
||
isSaving: true,
|
||
saveErrorMessage: "",
|
||
};
|
||
renderModal();
|
||
|
||
try {
|
||
const resourceId = getCurrentUserResourcePartyId();
|
||
const weekId = cleanString(modal.weekId);
|
||
|
||
if (!resourceId) {
|
||
throw new Error("resourceCurrentUser.resourcePartyId não está disponÃvel.");
|
||
}
|
||
|
||
if (!weekId) {
|
||
throw new Error("WeekId não encontrado para gravação do apontamento.");
|
||
}
|
||
|
||
const changes = getTimeManagementPendingChanges(modal);
|
||
const baseUrl = COMCIP_REQUEST.timeEntriesUrl;
|
||
const deletedIds = changes.deletedIds;
|
||
const updatedPayload = changes.updatedRows.map((row) =>
|
||
createTimeManagementSavePayload(row, modal.weekDays, weekId, resourceId, {
|
||
includeId: true,
|
||
})
|
||
);
|
||
const newPayload = changes.newRows.map((row) =>
|
||
createTimeManagementSavePayload(row, modal.weekDays, weekId, resourceId)
|
||
);
|
||
|
||
for (const timeEntryId of deletedIds) {
|
||
await mutateComcipPayload(
|
||
"DELETE",
|
||
`${baseUrl}/${encodeURIComponent(timeEntryId)}`,
|
||
null,
|
||
"timeEntryDelete"
|
||
);
|
||
}
|
||
|
||
if (updatedPayload.length) {
|
||
await mutateComcipPayload(
|
||
"PUT",
|
||
baseUrl,
|
||
updatedPayload,
|
||
"timeEntryUpdate"
|
||
);
|
||
}
|
||
|
||
if (newPayload.length) {
|
||
await mutateComcipPayload("POST", baseUrl, newPayload, "timeEntryCreate");
|
||
}
|
||
|
||
await loadTimeManagementData(modal.selectedDate || getDateInputValue(new Date()));
|
||
} catch (error) {
|
||
appState.timeManagementModal = {
|
||
...(appState.timeManagementModal || modal),
|
||
isSaving: false,
|
||
saveErrorMessage: getErrorMessage(error),
|
||
};
|
||
renderModal();
|
||
}
|
||
}
|
||
|
||
function getTimeManagementPendingChanges(modal) {
|
||
const rows = getTimeManagementRows(modal);
|
||
|
||
return {
|
||
newRows: rows.filter((row) => isNewTimeManagementRow(row)),
|
||
updatedRows: rows.filter((row) => isUpdatedTimeManagementRow(row)),
|
||
deletedIds: (modal?.deletedTimeEntryIds || [])
|
||
.map((id) => cleanString(id))
|
||
.filter(Boolean),
|
||
};
|
||
}
|
||
|
||
function isNewTimeManagementRow(row) {
|
||
return cleanString(row?.source) === "manual" && !cleanString(row?.timeEntryId);
|
||
}
|
||
|
||
function isUpdatedTimeManagementRow(row) {
|
||
const timeEntryId = cleanString(row?.timeEntryId);
|
||
const originalSignature = cleanString(row?.originalSignature);
|
||
|
||
return Boolean(
|
||
timeEntryId &&
|
||
originalSignature &&
|
||
originalSignature !== createTimeManagementRowSignature(row)
|
||
);
|
||
}
|
||
|
||
function createTimeManagementRowSignature(row) {
|
||
return JSON.stringify({
|
||
srNumber: cleanString(row?.selectedSrNumber),
|
||
activityCode: cleanString(row?.selectedActivityValue),
|
||
taskType: cleanString(row?.selectedTaskTypeValue),
|
||
dayHours: normalizeTimeManagementDayHours(row?.dayHours || {}),
|
||
});
|
||
}
|
||
|
||
function normalizeTimeManagementDayHours(dayHours) {
|
||
return Object.keys(dayHours || {})
|
||
.sort()
|
||
.reduce((normalized, key) => {
|
||
normalized[key] = formatTimeManagementHourPayloadValue(dayHours[key]);
|
||
return normalized;
|
||
}, {});
|
||
}
|
||
|
||
function createTimeManagementSavePayload(
|
||
row,
|
||
weekDays,
|
||
weekId,
|
||
resourceId,
|
||
options = {}
|
||
) {
|
||
const selectedSrNumber = cleanString(row?.selectedSrNumber);
|
||
const payload = {
|
||
srNumber: selectedSrNumber,
|
||
activityCode: isNonServiceRequestSrNumber(selectedSrNumber)
|
||
? ""
|
||
: cleanString(row?.selectedActivityValue),
|
||
taskType: cleanString(row?.selectedTaskTypeValue),
|
||
deliveryType: "",
|
||
saturdayTaskHours: 0,
|
||
sundayTaskHours: 0,
|
||
mondayTaskHours: 0,
|
||
tuesdayTaskHours: 0,
|
||
wednesdayTaskHours: 0,
|
||
thursdayTaskHours: 0,
|
||
fridayTaskHours: 0,
|
||
notes: "",
|
||
weekId: toNumber(weekId) || weekId,
|
||
resourceId: cleanString(resourceId),
|
||
tag: isNonServiceRequestSrNumber(selectedSrNumber)
|
||
? "NON_SERVICE"
|
||
: "SERVICE",
|
||
};
|
||
|
||
if (options.includeId) {
|
||
const id = cleanString(row?.timeEntryId);
|
||
payload.id = toNumber(id) || id;
|
||
}
|
||
|
||
for (const day of weekDays || []) {
|
||
const weekdayName = getTimeManagementWeekdayName(day);
|
||
const dateKey = getTimeManagementDayKey(day);
|
||
const payloadKey = `${weekdayName}TaskHours`;
|
||
|
||
if (payloadKey in payload) {
|
||
payload[payloadKey] = formatTimeManagementHourPayloadValue(
|
||
row?.dayHours?.[dateKey]
|
||
);
|
||
}
|
||
}
|
||
|
||
return payload;
|
||
}
|
||
|
||
function formatTimeManagementHourPayloadValue(value) {
|
||
const normalizedValue = cleanString(value);
|
||
|
||
if (!normalizedValue) {
|
||
return 0;
|
||
}
|
||
|
||
const numericValue = Number(normalizedValue.replace(",", "."));
|
||
|
||
return Number.isFinite(numericValue) ? numericValue : 0;
|
||
}
|
||
|
||
function selectTimeManagementComboboxOption(field, value, rowId = "") {
|
||
if (field === "sr") {
|
||
updateTimeManagementSelectedSr(value || "", rowId);
|
||
return;
|
||
}
|
||
|
||
if (field === "activity") {
|
||
updateTimeManagementField("selectedActivityValue", value || "", rowId);
|
||
renderModal();
|
||
return;
|
||
}
|
||
|
||
if (field === "taskType") {
|
||
updateTimeManagementField("selectedTaskTypeValue", value || "", rowId);
|
||
renderModal();
|
||
}
|
||
}
|
||
|
||
function filterTimeManagementCombobox(input) {
|
||
const query = normalizeString(input.value);
|
||
const combobox = input.closest(".arch-panel-extension-combobox");
|
||
|
||
if (!combobox) {
|
||
return;
|
||
}
|
||
|
||
const options = Array.from(
|
||
combobox.querySelectorAll(".arch-panel-extension-combobox-option")
|
||
);
|
||
|
||
for (const option of options) {
|
||
const searchText = normalizeString(option.getAttribute("data-search") || "");
|
||
option.hidden = Boolean(query) && !searchText.includes(query);
|
||
}
|
||
|
||
setActiveTimeManagementComboboxOption(
|
||
combobox,
|
||
getVisibleTimeManagementComboboxOptions(combobox).find((option) =>
|
||
option.classList.contains("is-selected")
|
||
) || getVisibleTimeManagementComboboxOptions(combobox)[0]
|
||
);
|
||
}
|
||
|
||
function handleTimeManagementComboboxKeydown(event, input) {
|
||
const combobox = input.closest(".arch-panel-extension-combobox");
|
||
|
||
if (!combobox) {
|
||
return;
|
||
}
|
||
|
||
if (event.key === "Escape") {
|
||
clearActiveTimeManagementComboboxOption(combobox);
|
||
input.blur();
|
||
return;
|
||
}
|
||
|
||
const visibleOptions = getVisibleTimeManagementComboboxOptions(combobox);
|
||
|
||
if (!visibleOptions.length) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
|
||
if (event.key === "Enter") {
|
||
const activeOption =
|
||
visibleOptions.find((option) => option.classList.contains("is-active")) ||
|
||
visibleOptions[0];
|
||
activeOption.click();
|
||
return;
|
||
}
|
||
|
||
const activeIndex = visibleOptions.findIndex((option) =>
|
||
option.classList.contains("is-active")
|
||
);
|
||
const selectedIndex = visibleOptions.findIndex((option) =>
|
||
option.classList.contains("is-selected")
|
||
);
|
||
const baseIndex = activeIndex >= 0 ? activeIndex : selectedIndex;
|
||
let nextIndex = 0;
|
||
|
||
if (event.key === "ArrowDown") {
|
||
nextIndex = baseIndex >= 0 ? baseIndex + 1 : 0;
|
||
} else if (event.key === "ArrowUp") {
|
||
nextIndex = baseIndex >= 0 ? baseIndex - 1 : visibleOptions.length - 1;
|
||
} else if (event.key === "Home") {
|
||
nextIndex = 0;
|
||
} else if (event.key === "End") {
|
||
nextIndex = visibleOptions.length - 1;
|
||
}
|
||
|
||
if (nextIndex < 0) {
|
||
nextIndex = visibleOptions.length - 1;
|
||
} else if (nextIndex >= visibleOptions.length) {
|
||
nextIndex = 0;
|
||
}
|
||
|
||
setActiveTimeManagementComboboxOption(combobox, visibleOptions[nextIndex]);
|
||
}
|
||
|
||
function getVisibleTimeManagementComboboxOptions(combobox) {
|
||
return Array.from(
|
||
combobox.querySelectorAll(".arch-panel-extension-combobox-option")
|
||
).filter((option) => !option.hidden);
|
||
}
|
||
|
||
function setActiveTimeManagementComboboxOption(combobox, option) {
|
||
clearActiveTimeManagementComboboxOption(combobox);
|
||
|
||
if (!option) {
|
||
return;
|
||
}
|
||
|
||
option.classList.add("is-active");
|
||
combobox
|
||
.querySelector("input[data-combobox-field]")
|
||
?.setAttribute("aria-activedescendant", option.id || "");
|
||
option.scrollIntoView({ block: "nearest" });
|
||
}
|
||
|
||
function clearActiveTimeManagementComboboxOption(combobox) {
|
||
combobox
|
||
.querySelectorAll(".arch-panel-extension-combobox-option.is-active")
|
||
.forEach((option) => option.classList.remove("is-active"));
|
||
combobox
|
||
.querySelector("input[data-combobox-field]")
|
||
?.removeAttribute("aria-activedescendant");
|
||
}
|
||
|
||
async function fetchInfoWeekRequest(selectedDate) {
|
||
const dateValue = formatDateForTimeEntryRequest(selectedDate);
|
||
const url = `${COMCIP_REQUEST.timeEntryWeekDetailsUrl}?dateValue=${encodeURIComponent(
|
||
dateValue
|
||
)}`;
|
||
|
||
return fetchComcipPayload(url, "infoWeekRequest");
|
||
}
|
||
|
||
async function fetchActiveServiceRequests(resourceId, weekId) {
|
||
const url = `${COMCIP_REQUEST.activeServiceRequestsUrl}?resourceId=${encodeURIComponent(
|
||
resourceId
|
||
)}&weekId=${encodeURIComponent(weekId)}`;
|
||
|
||
return fetchComcipPayload(url, "sRRequest");
|
||
}
|
||
|
||
function buildTimeEntryWeekUrl(resourceId, weekId) {
|
||
return `${COMCIP_REQUEST.timeEntriesUrl}?resourceId=${encodeURIComponent(
|
||
resourceId
|
||
)}&sortBy=createdDate&sortOrder=ASC&weekId=${encodeURIComponent(weekId)}`;
|
||
}
|
||
|
||
async function fetchTimeEntryWeek(url) {
|
||
return fetchComcipPayload(url, "timeEntryWeek");
|
||
}
|
||
|
||
async function fetchActivityRequest(srNumbers) {
|
||
return fetchComcipPayload(buildActivityRequestUrl(srNumbers), "activityRequest");
|
||
}
|
||
|
||
async function fetchCachedActivityRequest(srNumbers) {
|
||
const cacheKey = getActivityRequestCacheKey(srNumbers);
|
||
|
||
if (!cacheKey) {
|
||
return {
|
||
url: "",
|
||
payload: null,
|
||
};
|
||
}
|
||
|
||
const cachedRequest = appState.timeManagementActivityCache.get(cacheKey);
|
||
|
||
if (cachedRequest?.payload) {
|
||
return cachedRequest;
|
||
}
|
||
|
||
if (cachedRequest?.promise) {
|
||
return cachedRequest.promise;
|
||
}
|
||
|
||
const requestPromise = fetchActivityRequestWithFallback(srNumbers).then((result) => {
|
||
appState.timeManagementActivityCache.set(cacheKey, {
|
||
url: result.url,
|
||
payload: result.payload,
|
||
});
|
||
|
||
return result;
|
||
}).catch((error) => {
|
||
appState.timeManagementActivityCache.delete(cacheKey);
|
||
throw error;
|
||
});
|
||
|
||
appState.timeManagementActivityCache.set(cacheKey, {
|
||
promise: requestPromise,
|
||
});
|
||
|
||
return requestPromise;
|
||
}
|
||
|
||
async function fetchActivityRequestWithFallback(srNumbers) {
|
||
let activityRequestUrl = buildActivityRequestUrl(srNumbers);
|
||
let activityPayload = await fetchComcipPayload(
|
||
activityRequestUrl,
|
||
"activityRequest"
|
||
);
|
||
|
||
if (activityRequestUrl && !hasActivityRequestItems(activityPayload)) {
|
||
activityRequestUrl = buildActivityRequestUrl(
|
||
ACTIVITY_REQUEST_REFERENCE_SR_NUMBERS
|
||
);
|
||
activityPayload = await fetchComcipPayload(
|
||
activityRequestUrl,
|
||
"activityRequest"
|
||
);
|
||
}
|
||
|
||
return {
|
||
url: activityRequestUrl,
|
||
payload: activityPayload,
|
||
};
|
||
}
|
||
|
||
function getActivityRequestCacheKey(srNumbers) {
|
||
const normalizedSrNumbers = (srNumbers || [])
|
||
.map((srNumber) => cleanString(srNumber).toUpperCase())
|
||
.filter((srNumber) => /^SR\d+$/i.test(srNumber))
|
||
.sort();
|
||
|
||
return normalizedSrNumbers.join("|");
|
||
}
|
||
|
||
function buildActivityRequestUrl(srNumbers) {
|
||
const query = buildServiceRequestActivityQuery(srNumbers);
|
||
const fields =
|
||
"SrNumber,SRActivities_c,ProblemDescription,AccountPartyUniqueName,ServiceNew_c,OptyOwner_c,OptyName_c,OptyNumber_c,ReportedByPartyUniqueName,StatusCdMeaning,CountryText_c,AssigneePersonName,OptyStatus_c";
|
||
|
||
return [
|
||
COMCIP_REQUEST.activityServiceRequestsUrl,
|
||
"?expand=ServiceRequest_SRToInternalSR_Tgt",
|
||
`&fields=${encodeActivityRequestParam(fields)}`,
|
||
"&limit=200",
|
||
"&onlyData=true",
|
||
`&q=${encodeActivityRequestParam(query)}`,
|
||
].join("");
|
||
}
|
||
|
||
function encodeActivityRequestParam(value) {
|
||
return encodeURIComponent(String(value || ""))
|
||
.replace(/'/g, "%27")
|
||
.replace(/\(/g, "%28")
|
||
.replace(/\)/g, "%29");
|
||
}
|
||
|
||
async function fetchTaskTypeRequest() {
|
||
if (appState.taskTypeCache.promise) {
|
||
return appState.taskTypeCache.promise;
|
||
}
|
||
|
||
appState.taskTypeCache.promise = (async () => {
|
||
return readCachedTaskTypePayload().catch(() => null);
|
||
})().finally(() => {
|
||
appState.taskTypeCache.promise = null;
|
||
});
|
||
|
||
return appState.taskTypeCache.promise;
|
||
}
|
||
|
||
async function refreshTaskTypeCache() {
|
||
const payload = await fetchComcipPayload(
|
||
COMCIP_REQUEST.taskTypeUrl,
|
||
"taskTypeRequest"
|
||
);
|
||
await writeCachedTaskTypePayload(payload);
|
||
|
||
return payload;
|
||
}
|
||
|
||
async function fetchComcipPayload(url, requestName) {
|
||
const response = await sendComcipGetMessage(url);
|
||
|
||
if (!response?.ok) {
|
||
const payloadMessage =
|
||
typeof response?.payload === "string"
|
||
? cleanString(response.payload)
|
||
: firstNonEmptyString([
|
||
response?.payload?.message,
|
||
response?.payload?.error,
|
||
response?.payload?.title,
|
||
response?.payload?.detail,
|
||
]);
|
||
|
||
throw new Error(
|
||
`${requestName}: ${
|
||
payloadMessage ||
|
||
response?.error ||
|
||
`COMCIP request failed (${response?.status || 0}).`
|
||
}`
|
||
);
|
||
}
|
||
|
||
return response.payload;
|
||
}
|
||
|
||
async function mutateComcipPayload(method, url, payload, requestName) {
|
||
const response = await sendComcipRequestMessage({
|
||
method,
|
||
url,
|
||
payload,
|
||
timeoutMessage: `Timed out while executing ${requestName}.`,
|
||
});
|
||
|
||
if (!response?.ok) {
|
||
const payloadMessage =
|
||
typeof response?.payload === "string"
|
||
? cleanString(response.payload)
|
||
: firstNonEmptyString([
|
||
response?.payload?.message,
|
||
response?.payload?.error,
|
||
response?.payload?.title,
|
||
response?.payload?.detail,
|
||
]);
|
||
|
||
throw new Error(
|
||
`${requestName}: ${
|
||
payloadMessage ||
|
||
response?.error ||
|
||
`COMCIP request failed (${response?.status || 0}).`
|
||
}`
|
||
);
|
||
}
|
||
|
||
return response.payload;
|
||
}
|
||
|
||
function hasActivityRequestItems(payload) {
|
||
const count = Number(payload?.count);
|
||
|
||
if (Number.isFinite(count) && count > 0) {
|
||
return true;
|
||
}
|
||
|
||
return extractArray(payload, ["items"]).length > 0;
|
||
}
|
||
|
||
function getCurrentUserResourcePartyId() {
|
||
const resourceCurrentUser = appState.dataset?.resourceCurrentUser;
|
||
const directResourcePartyId = cleanString(
|
||
getFieldValue(resourceCurrentUser, [
|
||
"resourcePartyId",
|
||
"ResourcePartyId",
|
||
"ResourcePartyID",
|
||
"partyId",
|
||
"PartyId",
|
||
])
|
||
);
|
||
|
||
if (directResourcePartyId) {
|
||
return directResourcePartyId;
|
||
}
|
||
|
||
const candidates = Array.isArray(resourceCurrentUser)
|
||
? resourceCurrentUser
|
||
: extractArray(resourceCurrentUser, ["items", "data", "results", "content"]);
|
||
const source = candidates[0] || resourceCurrentUser;
|
||
|
||
return cleanString(
|
||
getFieldValue(source, [
|
||
"resourcePartyId",
|
||
"ResourcePartyId",
|
||
"ResourcePartyID",
|
||
"partyId",
|
||
"PartyId",
|
||
])
|
||
);
|
||
}
|
||
|
||
function normalizeInfoWeekResponse(payload) {
|
||
return extractArray(payload, ["items", "data", "results", "content"])
|
||
.map((item) => {
|
||
const rawDate = cleanString(getFieldValue(item, ["Date", "date"]));
|
||
const parsedDate = parseTimeEntryDate(rawDate);
|
||
|
||
return {
|
||
date: rawDate,
|
||
weekId: cleanString(getFieldValue(item, ["WeekId", "weekId"])),
|
||
dayLabel: Number.isNaN(parsedDate.getTime())
|
||
? rawDate
|
||
: new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(parsedDate),
|
||
monthLabel: Number.isNaN(parsedDate.getTime())
|
||
? rawDate
|
||
: new Intl.DateTimeFormat("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
}).format(parsedDate),
|
||
};
|
||
})
|
||
.filter((item) => item.date || item.weekId);
|
||
}
|
||
|
||
function normalizeTimeManagementServiceRequests(payload) {
|
||
return extractArray(payload, ["items", "data", "results", "content"])
|
||
.map((item) => {
|
||
const srNumber = cleanString(
|
||
getFieldValue(item, [
|
||
"srNumber",
|
||
"SrNumber",
|
||
"SRNumber",
|
||
"serviceRequestNumber",
|
||
"ServiceRequestNumber",
|
||
"serviceRequest",
|
||
"ServiceRequest",
|
||
])
|
||
);
|
||
const srTitle = cleanString(
|
||
getFieldValue(item, ["srTitle", "SrTitle", "SRTitle", "title", "Title"])
|
||
);
|
||
const fallbackTitle = cleanString(
|
||
getFieldValue(item, ["problemDescription", "ProblemDescription"])
|
||
);
|
||
|
||
return {
|
||
srNumber,
|
||
srTitle,
|
||
label: formatTimeManagementServiceRequestLabel({
|
||
srNumber,
|
||
srTitle: srTitle || fallbackTitle,
|
||
}),
|
||
};
|
||
})
|
||
.filter((item) => item.srNumber);
|
||
}
|
||
|
||
function formatTimeManagementServiceRequestLabel(serviceRequest) {
|
||
const srNumber = cleanString(serviceRequest?.srNumber);
|
||
const srTitle = cleanString(serviceRequest?.srTitle || serviceRequest?.label);
|
||
|
||
if (srTitle && srNumber) {
|
||
return `${srTitle} (${srNumber})`;
|
||
}
|
||
|
||
return srTitle || srNumber;
|
||
}
|
||
|
||
function mergeTimeManagementServiceRequests(serviceRequests, timeEntryRows) {
|
||
const merged = [...(serviceRequests || [])];
|
||
const seen = new Set(merged.map((item) => normalizeString(item.srNumber)));
|
||
|
||
for (const row of timeEntryRows || []) {
|
||
const srNumber = cleanString(row?.selectedSrNumber);
|
||
const key = normalizeString(srNumber);
|
||
|
||
if (!srNumber || seen.has(key)) {
|
||
continue;
|
||
}
|
||
|
||
merged.push({
|
||
srNumber,
|
||
srTitle: srNumber === "non-service-sr" ? "Non-service request" : srNumber,
|
||
label: formatTimeManagementServiceRequestLabel({
|
||
srNumber,
|
||
srTitle: srNumber === "non-service-sr" ? "Non-service request" : srNumber,
|
||
}),
|
||
});
|
||
seen.add(key);
|
||
}
|
||
|
||
return merged;
|
||
}
|
||
|
||
function normalizeTimeManagementEntryRows(payload, weekDays) {
|
||
return extractArray(payload, ["items", "data", "results", "content"]).map(
|
||
(item, index) => {
|
||
const timeEntryId = getTimeEntryId(item);
|
||
const srNumber = getTimeEntrySrNumber(item) || "non-service-sr";
|
||
const activityValue = getTimeEntryActivity(item);
|
||
const taskTypeValue = getTimeEntryTaskType(item);
|
||
|
||
return {
|
||
id: `entry-${index + 1}`,
|
||
timeEntryId,
|
||
selectedSrNumber: srNumber,
|
||
selectedActivityValue: activityValue,
|
||
selectedTaskTypeValue: taskTypeValue,
|
||
dayHours: getTimeEntryWeekDayHours(item, weekDays),
|
||
source: "timeEntryWeek",
|
||
originalSignature: "",
|
||
};
|
||
}
|
||
);
|
||
}
|
||
|
||
function getTimeEntryWeekDayHours(item, weekDays = []) {
|
||
const dayHours = {};
|
||
|
||
for (const day of weekDays || []) {
|
||
const dateKey = getTimeManagementDayKey(day);
|
||
const weekdayName = getTimeManagementWeekdayName(day);
|
||
const value = getTimeEntryWeekdayTaskHours(item, weekdayName);
|
||
|
||
if (dateKey) {
|
||
dayHours[dateKey] = formatTimeManagementHourValue(value);
|
||
}
|
||
}
|
||
|
||
return dayHours;
|
||
}
|
||
|
||
function getTimeManagementWeekdayName(day) {
|
||
const parsedDate = parseTimeEntryDate(day?.date);
|
||
|
||
if (!Number.isNaN(parsedDate.getTime())) {
|
||
return new Intl.DateTimeFormat("en-US", { weekday: "long" })
|
||
.format(parsedDate)
|
||
.toLowerCase();
|
||
}
|
||
|
||
const label = cleanString(day?.dayLabel || day?.date).toLowerCase();
|
||
const aliases = {
|
||
sun: "sunday",
|
||
sunday: "sunday",
|
||
mon: "monday",
|
||
monday: "monday",
|
||
tue: "tuesday",
|
||
tuesday: "tuesday",
|
||
wed: "wednesday",
|
||
wednesday: "wednesday",
|
||
thu: "thursday",
|
||
thursday: "thursday",
|
||
fri: "friday",
|
||
friday: "friday",
|
||
sat: "saturday",
|
||
saturday: "saturday",
|
||
};
|
||
|
||
return aliases[label.slice(0, 3)] || aliases[label] || "";
|
||
}
|
||
|
||
function getTimeEntryWeekdayTaskHours(item, weekdayName) {
|
||
if (!weekdayName) {
|
||
return "";
|
||
}
|
||
|
||
const titleCaseWeekday =
|
||
weekdayName.charAt(0).toUpperCase() + weekdayName.slice(1);
|
||
const compactKey = `${weekdayName}TaskHours`;
|
||
const titleKey = `${titleCaseWeekday}TaskHours`;
|
||
const snakeKey = `${weekdayName}_task_hours`;
|
||
|
||
return getTimeEntryField(item, [
|
||
compactKey,
|
||
titleKey,
|
||
snakeKey,
|
||
`${weekdayName}.taskHours`,
|
||
`${titleCaseWeekday}.TaskHours`,
|
||
]);
|
||
}
|
||
|
||
function formatTimeManagementHourValue(value) {
|
||
if (value === undefined || value === null || value === "") {
|
||
return "";
|
||
}
|
||
|
||
return cleanString(value);
|
||
}
|
||
|
||
function normalizeTimeManagementEntryRowSelection(
|
||
row,
|
||
index,
|
||
activityOptionsBySr,
|
||
taskTypes
|
||
) {
|
||
const srNumber = cleanString(row?.selectedSrNumber);
|
||
const activitySrKey = Object.keys(activityOptionsBySr || {}).find(
|
||
(key) => normalizeString(key) === normalizeString(srNumber)
|
||
);
|
||
const activityOptions =
|
||
activityOptionsBySr?.[srNumber] || activityOptionsBySr?.[activitySrKey] || [];
|
||
const taskTypeOptions = filterTaskTypesForServiceRequest(taskTypes || [], srNumber);
|
||
|
||
return {
|
||
...row,
|
||
id: cleanString(row?.id) || `entry-${index + 1}`,
|
||
timeEntryId: cleanString(row?.timeEntryId),
|
||
selectedSrNumber: srNumber,
|
||
selectedActivityValue:
|
||
findOptionValue(activityOptions, row?.selectedActivityValue) ||
|
||
cleanString(row?.selectedActivityValue) ||
|
||
"",
|
||
selectedTaskTypeValue:
|
||
findOptionValue(taskTypeOptions, row?.selectedTaskTypeValue) ||
|
||
cleanString(row?.selectedTaskTypeValue) ||
|
||
"",
|
||
dayHours: row?.dayHours || {},
|
||
originalSignature:
|
||
cleanString(row?.originalSignature) ||
|
||
(cleanString(row?.source) === "timeEntryWeek"
|
||
? createTimeManagementRowSignature({
|
||
...row,
|
||
selectedSrNumber: srNumber,
|
||
selectedActivityValue:
|
||
findOptionValue(activityOptions, row?.selectedActivityValue) ||
|
||
cleanString(row?.selectedActivityValue) ||
|
||
"",
|
||
selectedTaskTypeValue:
|
||
findOptionValue(taskTypeOptions, row?.selectedTaskTypeValue) ||
|
||
cleanString(row?.selectedTaskTypeValue) ||
|
||
"",
|
||
dayHours: row?.dayHours || {},
|
||
})
|
||
: ""),
|
||
};
|
||
}
|
||
|
||
function getActivityRequestSrNumbers(srPayload, serviceRequests) {
|
||
const normalizedSrNumbers = [
|
||
...(serviceRequests || []).map((item) => item.srNumber),
|
||
...collectSrNumbers(srPayload),
|
||
];
|
||
const seen = new Set();
|
||
|
||
return normalizedSrNumbers
|
||
.map((srNumber) => cleanString(srNumber).toUpperCase())
|
||
.filter((srNumber) => /^SR\d+$/i.test(srNumber))
|
||
.filter((srNumber) => {
|
||
const key = normalizeString(srNumber);
|
||
|
||
if (seen.has(key)) {
|
||
return false;
|
||
}
|
||
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function collectSrNumbers(value, seen = new WeakSet()) {
|
||
if (!value) {
|
||
return [];
|
||
}
|
||
|
||
if (typeof value === "string" || typeof value === "number") {
|
||
return Array.from(String(value).matchAll(/\bSR\d+\b/gi), (match) => match[0]);
|
||
}
|
||
|
||
if (typeof value !== "object") {
|
||
return [];
|
||
}
|
||
|
||
if (seen.has(value)) {
|
||
return [];
|
||
}
|
||
|
||
seen.add(value);
|
||
|
||
if (Array.isArray(value)) {
|
||
return value.flatMap((item) => collectSrNumbers(item, seen));
|
||
}
|
||
|
||
return Object.values(value).flatMap((nestedValue) =>
|
||
collectSrNumbers(nestedValue, seen)
|
||
);
|
||
}
|
||
|
||
function normalizeActivityRequest(payload, serviceRequests) {
|
||
const activityOptionsBySr = {};
|
||
const activityItemsBySr = new Map();
|
||
|
||
for (const item of extractArray(payload, ["items", "data", "results", "content"])) {
|
||
const srNumber = cleanString(getFieldValue(item, ["SrNumber", "srNumber"]));
|
||
const srKey = normalizeString(srNumber);
|
||
|
||
if (srKey && !activityItemsBySr.has(srKey)) {
|
||
activityItemsBySr.set(srKey, item);
|
||
}
|
||
}
|
||
|
||
for (const serviceRequest of serviceRequests || []) {
|
||
const srNumber = cleanString(serviceRequest?.srNumber);
|
||
const srKey = normalizeString(srNumber);
|
||
const activityItem = activityItemsBySr.get(srKey);
|
||
const options = activityItem ? normalizeSrActivityFieldOptions(activityItem) : [];
|
||
|
||
if (srNumber) {
|
||
activityOptionsBySr[srNumber] = options;
|
||
}
|
||
}
|
||
|
||
return activityOptionsBySr;
|
||
}
|
||
|
||
function normalizeSrActivityFieldOptions(item) {
|
||
const activityText = firstNonEmptyString([
|
||
getFieldValue(item, ["SRActivities_c", "srActivities_c"]),
|
||
getFieldValue(item, [
|
||
"ServiceRequest_SRToInternalSR_Tgt.SRActivities_c",
|
||
"ServiceRequest_SRToInternalSR_Tgt.srActivities_c",
|
||
]),
|
||
]);
|
||
|
||
if (activityText) {
|
||
return parseSemicolonActivityOptions(activityText);
|
||
}
|
||
|
||
return normalizeActivityOptions(item);
|
||
}
|
||
|
||
function normalizeActivityOptions(value) {
|
||
const recursiveActivityTextValues = collectActivityTextValues(value);
|
||
|
||
if (recursiveActivityTextValues.length) {
|
||
return parseSemicolonActivityOptions(recursiveActivityTextValues.join(";"));
|
||
}
|
||
|
||
const directActivityValue =
|
||
value && typeof value === "object" && !Array.isArray(value)
|
||
? getFieldValue(value, ["SRActivities_c", "srActivities_c", "activities"])
|
||
: "";
|
||
const nestedActivityValue =
|
||
value && typeof value === "object" && !Array.isArray(value)
|
||
? getFieldValue(value, [
|
||
"ServiceRequest_SRToInternalSR_Tgt.SRActivities_c",
|
||
"ServiceRequest_SRToInternalSR_Tgt.srActivities_c",
|
||
])
|
||
: "";
|
||
const nestedActivityObject =
|
||
value && typeof value === "object" && !Array.isArray(value)
|
||
? getFieldValue(value, ["ServiceRequest_SRToInternalSR_Tgt"])
|
||
: "";
|
||
const explicitActivityText = firstNonEmptyString([
|
||
directActivityValue && typeof directActivityValue !== "object"
|
||
? directActivityValue
|
||
: "",
|
||
nestedActivityValue && typeof nestedActivityValue !== "object"
|
||
? nestedActivityValue
|
||
: "",
|
||
]);
|
||
|
||
if (explicitActivityText) {
|
||
return parseSemicolonActivityOptions(explicitActivityText);
|
||
}
|
||
|
||
const activityValue =
|
||
directActivityValue ||
|
||
nestedActivityValue ||
|
||
nestedActivityObject ||
|
||
value;
|
||
const rawItems = Array.isArray(value)
|
||
? value
|
||
: activityValue && typeof activityValue === "object"
|
||
? extractArray(activityValue, [
|
||
"items",
|
||
"data",
|
||
"results",
|
||
"content",
|
||
"activities",
|
||
"ServiceRequest_SRToInternalSR_Tgt",
|
||
])
|
||
: [];
|
||
const textValue =
|
||
activityValue && typeof activityValue === "object"
|
||
? ""
|
||
: cleanString(activityValue);
|
||
const parsedTextItems = parseActivityTextOptions(textValue);
|
||
const items = rawItems.length
|
||
? rawItems
|
||
: parsedTextItems;
|
||
const seen = new Set();
|
||
|
||
return items
|
||
.map((item) => {
|
||
const optionValue =
|
||
item && typeof item === "object"
|
||
? firstNonEmptyString([
|
||
item.activityCode,
|
||
item.ActivityCode,
|
||
item.code,
|
||
item.value,
|
||
item.name,
|
||
item.label,
|
||
])
|
||
: cleanString(item);
|
||
const optionLabel =
|
||
item && typeof item === "object"
|
||
? firstNonEmptyString([
|
||
item.activityName,
|
||
item.ActivityName,
|
||
item.meaning,
|
||
item.label,
|
||
item.name,
|
||
optionValue,
|
||
])
|
||
: optionValue;
|
||
const normalizedValue = cleanString(optionValue);
|
||
const normalizedLabel = cleanString(optionLabel);
|
||
|
||
if (!normalizedValue || seen.has(normalizedValue)) {
|
||
return null;
|
||
}
|
||
|
||
seen.add(normalizedValue);
|
||
return {
|
||
value: normalizedValue,
|
||
label: normalizedLabel || normalizedValue,
|
||
};
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function collectActivityTextValues(value, seen = new WeakSet()) {
|
||
if (!value || typeof value !== "object") {
|
||
return [];
|
||
}
|
||
|
||
if (seen.has(value)) {
|
||
return [];
|
||
}
|
||
|
||
seen.add(value);
|
||
|
||
if (Array.isArray(value)) {
|
||
return value.flatMap((item) => collectActivityTextValues(item, seen));
|
||
}
|
||
|
||
const values = [];
|
||
const activityFieldNames = new Set([
|
||
normalizeString("SRActivities_c"),
|
||
normalizeString("srActivities_c"),
|
||
normalizeString("activities"),
|
||
]);
|
||
|
||
for (const [key, nestedValue] of Object.entries(value)) {
|
||
if (
|
||
activityFieldNames.has(normalizeString(key)) &&
|
||
typeof nestedValue !== "object"
|
||
) {
|
||
const text = cleanString(nestedValue);
|
||
|
||
if (text) {
|
||
values.push(text);
|
||
}
|
||
} else if (nestedValue && typeof nestedValue === "object") {
|
||
values.push(...collectActivityTextValues(nestedValue, seen));
|
||
}
|
||
}
|
||
|
||
return values;
|
||
}
|
||
|
||
function parseSemicolonActivityOptions(value) {
|
||
const seen = new Set();
|
||
|
||
return cleanString(value)
|
||
.split(";")
|
||
.map((item) => cleanString(item))
|
||
.filter(Boolean)
|
||
.map((item) => {
|
||
const normalizedItem = normalizeString(item);
|
||
|
||
if (seen.has(normalizedItem)) {
|
||
return null;
|
||
}
|
||
|
||
seen.add(normalizedItem);
|
||
return {
|
||
value: item,
|
||
label: item,
|
||
};
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function parseActivityTextOptions(value) {
|
||
if (!value) {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
|
||
if (Array.isArray(parsed)) {
|
||
return parsed;
|
||
}
|
||
|
||
if (parsed && typeof parsed === "object") {
|
||
return extractArray(parsed, ["items", "data", "results", "content", "activities"]);
|
||
}
|
||
} catch {
|
||
// Continue with delimiter based parsing.
|
||
}
|
||
|
||
return value
|
||
.split(/[;|\n]/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function normalizeTaskTypeRequest(payload) {
|
||
return extractArray(payload, ["items", "data", "results", "content"])
|
||
.map((item) => {
|
||
const value = cleanString(
|
||
getFieldValue(item, ["lookUpCode_c", "LookUpCode_c", "lookupCode"])
|
||
);
|
||
const label = cleanString(
|
||
getFieldValue(item, [
|
||
"lookUpMeaning_c",
|
||
"LookUpMeaning_c",
|
||
"lookupMeaning",
|
||
"meaning",
|
||
])
|
||
);
|
||
const tags = cleanString(
|
||
getFieldValue(item, ["tags_c", "Tags_c", "TAGS_C", "tags", "Tags"])
|
||
);
|
||
|
||
return {
|
||
value,
|
||
label: label || value,
|
||
tags,
|
||
};
|
||
})
|
||
.filter((item) => item.value);
|
||
}
|
||
|
||
function getTimeManagementTaskTypeOptions(modal, srNumber) {
|
||
const options = filterTaskTypesForServiceRequest(modal.taskTypes || [], srNumber);
|
||
|
||
return options.length
|
||
? options
|
||
: [{ value: "", label: "No task types available", tags: "" }];
|
||
}
|
||
|
||
function filterTaskTypesForServiceRequest(taskTypes, srNumber) {
|
||
const expectedTag = isServiceRequestSrNumber(srNumber) ? "SERVICE" : "NON_SERVICE";
|
||
return (taskTypes || []).filter((item) => {
|
||
const tags = normalizeString(item?.tags);
|
||
|
||
return tags === normalizeString(expectedTag);
|
||
});
|
||
}
|
||
|
||
function isServiceRequestSrNumber(srNumber) {
|
||
return /^SR\d+$/i.test(cleanString(srNumber));
|
||
}
|
||
|
||
function isNonServiceRequestSrNumber(srNumber) {
|
||
return normalizeString(srNumber) === normalizeString("non-service-sr");
|
||
}
|
||
|
||
function getTimeManagementActivityOptions(modal, srNumber) {
|
||
const normalizedSrNumber = normalizeString(srNumber);
|
||
const matchedSrKey = Object.keys(modal.activityOptionsBySr || {}).find(
|
||
(key) => normalizeString(key) === normalizedSrNumber
|
||
);
|
||
const options =
|
||
modal.activityOptionsBySr?.[srNumber] ||
|
||
modal.activityOptionsBySr?.[matchedSrKey] ||
|
||
[];
|
||
|
||
return options.length
|
||
? options
|
||
: [{ value: "", label: "No activities available" }];
|
||
}
|
||
|
||
function findTimeManagementServiceRequest(modal, inputValue) {
|
||
const normalizedInput = normalizeString(inputValue);
|
||
|
||
return (modal.serviceRequests || []).find((item) => {
|
||
return (
|
||
normalizeString(item.srNumber) === normalizedInput ||
|
||
normalizeString(item.label) === normalizedInput ||
|
||
normalizeString(item.srTitle) === normalizedInput
|
||
);
|
||
});
|
||
}
|
||
|
||
function findTimeManagementServiceRequestByNumber(modal, srNumber) {
|
||
const normalizedSrNumber = normalizeString(srNumber);
|
||
|
||
return (modal.serviceRequests || []).find(
|
||
(item) => normalizeString(item.srNumber) === normalizedSrNumber
|
||
);
|
||
}
|
||
|
||
function findOptionValue(options, inputValue) {
|
||
const normalizedInput = normalizeString(inputValue);
|
||
const option = (options || []).find((item) => {
|
||
return (
|
||
normalizeString(item.value) === normalizedInput ||
|
||
normalizeString(item.label) === normalizedInput
|
||
);
|
||
});
|
||
|
||
return option?.value || "";
|
||
}
|
||
|
||
function findOptionLabel(options, value) {
|
||
const normalizedValue = normalizeString(value);
|
||
const option = (options || []).find(
|
||
(item) => normalizeString(item.value) === normalizedValue
|
||
);
|
||
|
||
return option?.label || "";
|
||
}
|
||
|
||
function buildServiceRequestActivityQuery(srNumbers) {
|
||
const values = [...(srNumbers || [])]
|
||
.map((srNumber) => cleanString(srNumber))
|
||
.filter(Boolean)
|
||
.map((srNumber) => `'${srNumber.replace(/'/g, "''")}'`)
|
||
.concat("'non-service-sr'")
|
||
.join(",");
|
||
|
||
return `SrNumber in (${values})`;
|
||
}
|
||
|
||
function closeDetailModal() {
|
||
if (appState.detailModal?.scope === "calendar-day" && appState.detailModal.value) {
|
||
appState.pendingFocusSelector = getCalendarDayFocusSelector(
|
||
appState.detailModal.value
|
||
);
|
||
}
|
||
|
||
appState.detailModal = null;
|
||
appState.timeEntriesDrawer = null;
|
||
appState.timeManagementModal = null;
|
||
appState.rampComparisonModal = null;
|
||
appState.forecastUpdateConfirmModal = null;
|
||
}
|
||
|
||
function shiftCalendarPeriod(delta) {
|
||
const nextDate =
|
||
appState.calendarView === "week"
|
||
? getShiftedCalendarWeekDate(delta)
|
||
: new Date(appState.calendarYear, appState.calendarMonth + delta, 1);
|
||
|
||
appState.calendarYear = nextDate.getFullYear();
|
||
appState.calendarMonth = nextDate.getMonth();
|
||
appState.calendarAnchorDateKey = getLocalDateKey(nextDate);
|
||
renderModal();
|
||
}
|
||
|
||
function goToCurrentCalendarPeriod() {
|
||
const today = new Date();
|
||
appState.calendarYear = today.getFullYear();
|
||
appState.calendarMonth = today.getMonth();
|
||
appState.calendarAnchorDateKey = getLocalDateKey(today);
|
||
renderModal();
|
||
}
|
||
|
||
function setCalendarView(view) {
|
||
const normalizedView = view === "week" ? "week" : "month";
|
||
|
||
if (appState.calendarView === normalizedView) {
|
||
return;
|
||
}
|
||
|
||
appState.calendarView = normalizedView;
|
||
setStoredCalendarView(normalizedView);
|
||
|
||
if (normalizedView === "week") {
|
||
const today = new Date();
|
||
const anchorDate =
|
||
today.getFullYear() === appState.calendarYear &&
|
||
today.getMonth() === appState.calendarMonth
|
||
? today
|
||
: new Date(appState.calendarYear, appState.calendarMonth, 1);
|
||
|
||
appState.calendarAnchorDateKey = getLocalDateKey(anchorDate);
|
||
appState.calendarYear = anchorDate.getFullYear();
|
||
appState.calendarMonth = anchorDate.getMonth();
|
||
} else {
|
||
const anchorDate = getCalendarAnchorDate(
|
||
appState.calendarYear,
|
||
appState.calendarMonth,
|
||
appState.calendarAnchorDateKey
|
||
);
|
||
|
||
appState.calendarYear = anchorDate.getFullYear();
|
||
appState.calendarMonth = anchorDate.getMonth();
|
||
}
|
||
|
||
renderModal();
|
||
}
|
||
|
||
function getShiftedCalendarWeekDate(delta) {
|
||
const anchorDate = getCalendarAnchorDate(
|
||
appState.calendarYear,
|
||
appState.calendarMonth,
|
||
appState.calendarAnchorDateKey
|
||
);
|
||
|
||
anchorDate.setDate(anchorDate.getDate() + delta * 7);
|
||
return anchorDate;
|
||
}
|
||
|
||
function shiftCalendarMonth(delta) {
|
||
shiftCalendarPeriod(delta);
|
||
}
|
||
|
||
function goToCurrentCalendarMonth() {
|
||
goToCurrentCalendarPeriod();
|
||
}
|
||
|
||
async function submitActionForm(form) {
|
||
if (!appState.dataset || !appState.actionFormModal) {
|
||
return;
|
||
}
|
||
|
||
if (!form.reportValidity()) {
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData(form);
|
||
const parentId = cleanString(formData.get("parentId"));
|
||
const ownerEmail = cleanString(formData.get("ownerEmail"));
|
||
const startDate = toIsoDateString(formData.get("startDate"));
|
||
const endDate = toIsoDateString(formData.get("endDate"));
|
||
const payload = {
|
||
parentType: "WORKLOAD",
|
||
parentId,
|
||
action: {
|
||
ownerEmail,
|
||
role: "CLOUD_ARCHITECT",
|
||
name: "SUCCESS_PLAN_CONSUM_RVW",
|
||
team: cleanString(formData.get("team")),
|
||
tags: "",
|
||
startDate,
|
||
endDate,
|
||
status: "COMPLETE",
|
||
complexity: cleanString(formData.get("complexity")),
|
||
notes: [],
|
||
objective: "",
|
||
createdBy: cleanString(formData.get("createdBy")),
|
||
updatedBy: cleanString(formData.get("updatedBy")),
|
||
},
|
||
};
|
||
|
||
try {
|
||
appState.actionFormModal = {
|
||
...appState.actionFormModal,
|
||
isSubmitting: true,
|
||
errorMessage: "",
|
||
};
|
||
renderModal();
|
||
|
||
await fetchJson(API_ENDPOINTS.workloadActions, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json; charset=UTF-8",
|
||
},
|
||
body: JSON.stringify(payload),
|
||
});
|
||
|
||
await applyCreatedActionToDataset(payload);
|
||
appState.actionFormModal = null;
|
||
renderModal();
|
||
} catch (error) {
|
||
appState.actionFormModal = {
|
||
...appState.actionFormModal,
|
||
isSubmitting: false,
|
||
errorMessage: getErrorMessage(error),
|
||
};
|
||
renderModal();
|
||
}
|
||
}
|
||
|
||
async function applyCreatedActionToDataset(payload) {
|
||
if (!appState.dataset) {
|
||
return;
|
||
}
|
||
|
||
const updatedCustomers = (appState.dataset.customers || []).map((customer) => ({
|
||
...customer,
|
||
workloads: (customer.workloads || []).map((workload) => {
|
||
if (workload.workloadId !== payload.parentId) {
|
||
return workload;
|
||
}
|
||
|
||
return {
|
||
...workload,
|
||
hasAction: true,
|
||
actions: [
|
||
...(workload.actions || []),
|
||
{
|
||
owner: payload.action.ownerEmail,
|
||
role: payload.action.role,
|
||
name: payload.action.name,
|
||
team: payload.action.team,
|
||
startDate: payload.action.startDate,
|
||
endDate: payload.action.endDate,
|
||
complexity: payload.action.complexity,
|
||
},
|
||
],
|
||
};
|
||
}),
|
||
}));
|
||
const exportPayload = createExportPayload(
|
||
appState.dataset.user,
|
||
updatedCustomers,
|
||
appState.dataset.exportPayload?.pendingServiceRequests || [],
|
||
appState.dataset.exportPayload?.resourceCurrentUser || null
|
||
);
|
||
const nextDataset = createDatasetSnapshot(exportPayload);
|
||
const previousDetailModal = appState.detailModal
|
||
? {
|
||
scope: appState.detailModal.scope,
|
||
value: appState.detailModal.value,
|
||
sortKey: appState.detailModal.sortKey,
|
||
sortDirection: appState.detailModal.sortDirection,
|
||
}
|
||
: null;
|
||
|
||
appState.dataset = nextDataset;
|
||
await writeCachedDataset(exportPayload);
|
||
|
||
if (previousDetailModal?.scope) {
|
||
restoreDetailModal(previousDetailModal);
|
||
}
|
||
}
|
||
|
||
function restoreDetailModal(detailState) {
|
||
const eligibleWorkloads = appState.dataset?.summary?.eligibleWorkloads || [];
|
||
let items = [];
|
||
let label = "";
|
||
let title = "";
|
||
|
||
if (detailState.scope === "stage") {
|
||
items = eligibleWorkloads.filter(
|
||
(workload) => workload.opportunityForecastTypeGroup === detailState.value
|
||
);
|
||
label = `Opportunity type: ${detailState.value}`;
|
||
title = `${detailState.value} workloads`;
|
||
} else if (detailState.scope === "sr") {
|
||
const flag = detailState.value === "true";
|
||
items = eligibleWorkloads.filter((workload) => workload.hasSR === flag);
|
||
label = "Status de SRs";
|
||
title = flag ? "Workloads com SR" : "Workloads sem SR";
|
||
} else if (detailState.scope === "action") {
|
||
const flag = detailState.value === "true";
|
||
items = eligibleWorkloads.filter((workload) => workload.hasAction === flag);
|
||
label = "Status de Consumption Plan/action";
|
||
title = flag ? "Workloads com action" : "Workloads sem action";
|
||
} else if (detailState.scope === "calendar-day") {
|
||
const allWorkloads = appState.dataset?.summary?.allWorkloads || [];
|
||
items = allWorkloads.filter((workload) => {
|
||
return getLocalDateKey(workload.consumptionStartDate) === detailState.value;
|
||
});
|
||
label = "Workload start calendar";
|
||
title = `Workloads starting ${formatDate(detailState.value) || detailState.value}`;
|
||
}
|
||
|
||
appState.detailModal = {
|
||
label,
|
||
title,
|
||
items,
|
||
totalAcr: items.reduce((sum, workload) => sum + workload.adjustedACR, 0),
|
||
sortKey: detailState.sortKey || DETAIL_SORT_KEYS.adjustedACR,
|
||
sortDirection: detailState.sortDirection || "desc",
|
||
scope: detailState.scope,
|
||
value: detailState.value,
|
||
type: "workloads",
|
||
};
|
||
}
|
||
|
||
function toggleDetailSort(sortKey) {
|
||
if (!appState.detailModal) {
|
||
return;
|
||
}
|
||
|
||
const nextDirection =
|
||
appState.detailModal.sortKey === sortKey &&
|
||
appState.detailModal.sortDirection === "asc"
|
||
? "desc"
|
||
: "asc";
|
||
|
||
appState.detailModal = {
|
||
...appState.detailModal,
|
||
sortKey,
|
||
sortDirection: nextDirection,
|
||
};
|
||
|
||
renderModal();
|
||
}
|
||
|
||
function getSortedDetailItems() {
|
||
if (!appState.detailModal) {
|
||
return [];
|
||
}
|
||
|
||
return sortWorkloads(
|
||
appState.detailModal.items,
|
||
appState.detailModal.sortKey,
|
||
appState.detailModal.sortDirection
|
||
);
|
||
}
|
||
|
||
function sortWorkloads(items, sortKey, sortDirection = "asc") {
|
||
const factor = sortDirection === "desc" ? -1 : 1;
|
||
|
||
return [...items].sort((left, right) => {
|
||
const comparison = compareWorkloadValues(
|
||
getSortableWorkloadValue(left, sortKey),
|
||
getSortableWorkloadValue(right, sortKey)
|
||
);
|
||
|
||
if (comparison !== 0) {
|
||
return comparison * factor;
|
||
}
|
||
|
||
return compareWorkloadValues(
|
||
getSortableWorkloadValue(left, DETAIL_SORT_KEYS.workload),
|
||
getSortableWorkloadValue(right, DETAIL_SORT_KEYS.workload)
|
||
);
|
||
});
|
||
}
|
||
|
||
function getSortableWorkloadValue(workload, sortKey) {
|
||
switch (sortKey) {
|
||
case DETAIL_SORT_KEYS.opportunityNumber:
|
||
return cleanString(workload.opportunityNumber);
|
||
case DETAIL_SORT_KEYS.customerName:
|
||
return cleanString(workload.customerName);
|
||
case DETAIL_SORT_KEYS.workload:
|
||
return `${cleanString(workload.name)}|${cleanString(workload.description)}`;
|
||
case DETAIL_SORT_KEYS.rampMonths:
|
||
return toNumber(workload.rampMonths);
|
||
case DETAIL_SORT_KEYS.adjustedACR:
|
||
return toNumber(workload.adjustedACR);
|
||
case DETAIL_SORT_KEYS.opportunityForecastTypeGroup:
|
||
return cleanString(workload.opportunityForecastTypeGroup);
|
||
case DETAIL_SORT_KEYS.hasSR:
|
||
return Boolean(workload.hasSR);
|
||
case DETAIL_SORT_KEYS.hasAction:
|
||
return Boolean(workload.hasAction);
|
||
default:
|
||
return cleanString(workload.name);
|
||
}
|
||
}
|
||
|
||
function compareWorkloadValues(left, right) {
|
||
if (typeof left === "number" || typeof right === "number") {
|
||
return toNumber(left) - toNumber(right);
|
||
}
|
||
|
||
if (typeof left === "boolean" || typeof right === "boolean") {
|
||
return Number(Boolean(left)) - Number(Boolean(right));
|
||
}
|
||
|
||
return String(left || "").localeCompare(String(right || ""), undefined, {
|
||
numeric: true,
|
||
sensitivity: "base",
|
||
});
|
||
}
|
||
|
||
function downloadFile(filename, content, contentType) {
|
||
const blob = new Blob([content], { type: contentType });
|
||
const url = URL.createObjectURL(blob);
|
||
const anchor = document.createElement("a");
|
||
anchor.href = url;
|
||
anchor.download = filename;
|
||
anchor.click();
|
||
window.setTimeout(() => {
|
||
URL.revokeObjectURL(url);
|
||
}, 1000);
|
||
}
|
||
|
||
function createExportHtml(dataset) {
|
||
const json = JSON.stringify(dataset.exportPayload);
|
||
|
||
return `<!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 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 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">' + workload.opportunityNumber + '</a>';
|
||
}
|
||
|
||
function workloadLink(workload) {
|
||
const label = (workload.name || "Unnamed workload") + (workload.description ? " | " + workload.description : "");
|
||
|
||
if (!workload.workloadId) {
|
||
return 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">' + 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>' + (workload.customerName || '') + '</td><td>' + workloadLink(workload) + '</td><td class="is-numeric">' + formatCurrency(workload.adjustedACR) + '</td><td>' + 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>' + (workload.customerName || '') + '</td><td>' + workloadLink(workload) + '</td><td class="is-numeric">' + formatCurrency(workload.adjustedACR) + '</td><td>' + 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>' + (workload.customerName || '') + '</td><td>' + workloadLink(workload) + '</td><td class="is-numeric">' + formatCurrency(workload.adjustedACR) + '</td><td>' + 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">' + (payload.user.name || payload.user.userEmail) + '</p><p class="metric-detail">' + 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>`;
|
||
}
|
||
|
||
function toggleTheme() {
|
||
appState.theme = appState.theme === "dark" ? "light" : "dark";
|
||
setStoredTheme(appState.theme);
|
||
renderModal();
|
||
}
|
||
|
||
function toggleSidebar() {
|
||
appState.sidebarCollapsed = !appState.sidebarCollapsed;
|
||
setStoredSidebarCollapsed(appState.sidebarCollapsed);
|
||
renderModal();
|
||
}
|
||
|
||
function applyDialogTheme(overlay) {
|
||
const dialog = overlay?.querySelector(`#${IDS.dialog}`);
|
||
|
||
if (!dialog) {
|
||
return;
|
||
}
|
||
|
||
dialog.setAttribute("data-theme", appState.theme);
|
||
}
|
||
|
||
function ensureStyles() {
|
||
if (document.getElementById(IDS.style)) {
|
||
return;
|
||
}
|
||
|
||
const style = document.createElement("style");
|
||
style.id = IDS.style;
|
||
style.textContent = `
|
||
html.${CLASSES.modalOpen},
|
||
body.${CLASSES.modalOpen} {
|
||
overflow: hidden !important;
|
||
}
|
||
|
||
.arch-panel-extension-entry {
|
||
--arch-panel-bg: rgba(255, 255, 255, 0.12);
|
||
--arch-panel-bg-hover: rgba(255, 255, 255, 0.2);
|
||
--arch-panel-border: rgba(255, 255, 255, 0.26);
|
||
--arch-panel-fg: #ffffff;
|
||
--arch-panel-focus: rgba(255, 255, 255, 0.72);
|
||
--arch-panel-font: inherit;
|
||
--arch-workbench-header: #4b335d;
|
||
--arch-workbench-accent: #6c4d80;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
margin-inline-end: 12px;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.arch-panel-extension-button {
|
||
appearance: none;
|
||
border: 1px solid var(--arch-panel-border);
|
||
background: var(--arch-panel-bg);
|
||
color: var(--arch-panel-fg);
|
||
border-radius: 999px;
|
||
min-height: 36px;
|
||
padding: 0 14px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-family: var(--arch-panel-font);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
line-height: 1;
|
||
letter-spacing: 0.01em;
|
||
cursor: pointer;
|
||
transition:
|
||
background-color 160ms ease,
|
||
border-color 160ms ease,
|
||
transform 160ms ease;
|
||
}
|
||
|
||
.arch-panel-extension-button:hover {
|
||
background: var(--arch-panel-bg-hover);
|
||
}
|
||
|
||
.arch-panel-extension-button:active {
|
||
transform: translateY(1px);
|
||
}
|
||
|
||
.arch-panel-extension-button:focus-visible,
|
||
.arch-panel-extension-close:focus-visible,
|
||
.arch-panel-extension-theme-toggle:focus-visible,
|
||
.arch-panel-extension-sidebar-button:focus-visible,
|
||
.arch-panel-extension-utility-button:focus-visible,
|
||
.arch-panel-extension-summary-card-button:focus-visible,
|
||
.arch-panel-extension-sort-button:focus-visible,
|
||
.arch-panel-extension-indicator-card:focus-visible,
|
||
.arch-panel-extension-row-link:focus-visible {
|
||
outline: 2px solid var(--arch-panel-focus);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
#${IDS.hcmTile} {
|
||
cursor: pointer;
|
||
}
|
||
|
||
#${IDS.hcmTile}:focus-visible {
|
||
outline: 2px solid #0b5cab;
|
||
outline-offset: 3px;
|
||
}
|
||
|
||
.arch-panel-extension-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 2147483647;
|
||
display: flex;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
background: rgba(13, 17, 24, 0.56);
|
||
backdrop-filter: blur(8px);
|
||
transition: opacity 160ms ease;
|
||
}
|
||
|
||
.arch-panel-extension-overlay.${CLASSES.overlayOpen} {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.arch-panel-extension-dialog {
|
||
width: 100vw;
|
||
height: 100vh;
|
||
display: grid;
|
||
grid-template-rows: auto 1fr;
|
||
--wb-app-bg: #f5f4f3;
|
||
--wb-panel: #ffffff;
|
||
--wb-panel-soft: #fafafa;
|
||
--wb-sidebar: #f3f2f1;
|
||
--wb-border: #d9d9d9;
|
||
--wb-border-strong: #c8c8c8;
|
||
--wb-row-divider: #e6e6e6;
|
||
--wb-divider: #ececec;
|
||
--wb-row-hover: #f9fbfc;
|
||
--wb-text: #1f1f1f;
|
||
--wb-text-secondary: #5f6670;
|
||
--wb-text-muted: #7b8189;
|
||
--wb-link: #006b9a;
|
||
--wb-info: #dceef7;
|
||
--wb-success: #3fa35c;
|
||
--wb-warning: #d94f2b;
|
||
--wb-shadow: 0 1px 2px rgba(31, 31, 31, 0.04);
|
||
--arch-workbench-header-fallback: #3f2d4f;
|
||
--arch-workbench-accent-fallback: #684b78;
|
||
--arch-shell-header-text: #ffffff;
|
||
--arch-shell-header-muted: rgba(255, 255, 255, 0.82);
|
||
--arch-shell-header-subtle: rgba(255, 255, 255, 0.68);
|
||
background: var(--wb-app-bg);
|
||
color: var(--wb-text);
|
||
font-family: "Oracle Sans", "Segoe UI", Arial, sans-serif;
|
||
}
|
||
|
||
.arch-panel-extension-dialog[data-theme="dark"] {
|
||
--wb-app-bg: #141b24;
|
||
--wb-panel: #1f2833;
|
||
--wb-panel-soft: #273240;
|
||
--wb-sidebar: #19222c;
|
||
--wb-border: #364150;
|
||
--wb-border-strong: #566171;
|
||
--wb-row-divider: #333d4b;
|
||
--wb-divider: #303a46;
|
||
--wb-row-hover: #273240;
|
||
--wb-text: #f4f7fb;
|
||
--wb-text-secondary: #c6d0da;
|
||
--wb-text-muted: #9ca8b5;
|
||
--wb-link: #84c7e3;
|
||
--wb-info: #233847;
|
||
--wb-success: #5ec280;
|
||
--wb-warning: #f0946c;
|
||
--wb-shadow: none;
|
||
--arch-workbench-header-fallback: #202a36;
|
||
--arch-workbench-accent-fallback: #39495b;
|
||
}
|
||
|
||
.arch-panel-extension-dialog * {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.arch-panel-extension-shell-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 24px;
|
||
min-height: 64px;
|
||
padding: 12px 24px;
|
||
background:
|
||
linear-gradient(
|
||
135deg,
|
||
var(--arch-workbench-header, var(--arch-workbench-header-fallback)) 0%,
|
||
var(--arch-workbench-accent, var(--arch-workbench-accent-fallback)) 100%
|
||
);
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.16);
|
||
box-shadow: 0 8px 24px rgba(31, 31, 31, 0.16);
|
||
color: var(--arch-shell-header-text);
|
||
}
|
||
|
||
.arch-panel-extension-brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.arch-panel-extension-brand-mark {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 40px;
|
||
height: 40px;
|
||
border-radius: 6px;
|
||
background: rgba(255, 255, 255, 0.18);
|
||
color: var(--arch-shell-header-text);
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
|
||
}
|
||
|
||
.arch-panel-extension-brand-label {
|
||
margin: 0 0 2px;
|
||
color: var(--arch-shell-header-muted);
|
||
font-size: 11px;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.arch-panel-extension-title {
|
||
margin: 0;
|
||
color: var(--arch-shell-header-text);
|
||
font-size: 24px;
|
||
line-height: 1.2;
|
||
font-weight: 600;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
|
||
.arch-panel-extension-shell-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.arch-panel-extension-shell-note {
|
||
max-width: 36vw;
|
||
overflow: hidden;
|
||
color: var(--arch-shell-header-muted);
|
||
font-size: 12px;
|
||
line-height: 1.35;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.arch-panel-extension-theme-toggle {
|
||
appearance: none;
|
||
min-height: 34px;
|
||
padding: 0 14px;
|
||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||
border-radius: 4px;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
color: var(--arch-shell-header-text);
|
||
font: inherit;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: background-color 160ms ease, border-color 160ms ease, transform 160ms ease;
|
||
}
|
||
|
||
.arch-panel-extension-shell-header .arch-panel-extension-theme-toggle:hover {
|
||
border-color: rgba(255, 255, 255, 0.44);
|
||
background: rgba(255, 255, 255, 0.2);
|
||
}
|
||
|
||
.arch-panel-extension-close {
|
||
appearance: none;
|
||
min-height: 34px;
|
||
padding: 0 14px;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 4px;
|
||
background: var(--wb-panel-soft);
|
||
color: var(--wb-text);
|
||
font: inherit;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.arch-panel-extension-close:hover {
|
||
border-color: var(--wb-border-strong);
|
||
background: var(--wb-row-hover);
|
||
}
|
||
|
||
.arch-panel-extension-shell-header .arch-panel-extension-close {
|
||
border-color: rgba(255, 255, 255, 0.28);
|
||
background: rgba(255, 255, 255, 0.12);
|
||
color: var(--arch-shell-header-text);
|
||
}
|
||
|
||
.arch-panel-extension-shell-header .arch-panel-extension-close:hover {
|
||
border-color: rgba(255, 255, 255, 0.42);
|
||
background: rgba(255, 255, 255, 0.16);
|
||
}
|
||
|
||
.arch-panel-extension-detail-dialog .arch-panel-extension-close {
|
||
border-color: var(--wb-border);
|
||
background: var(--wb-panel-soft);
|
||
color: var(--wb-text);
|
||
box-shadow: none;
|
||
}
|
||
|
||
.arch-panel-extension-sidebar-button,
|
||
.arch-panel-extension-utility-button {
|
||
appearance: none;
|
||
min-height: 34px;
|
||
padding: 0 14px;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 4px;
|
||
background: var(--wb-panel);
|
||
color: var(--wb-text);
|
||
font: inherit;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.arch-panel-extension-utility-button.is-primary {
|
||
border-color: #0b5cab;
|
||
background: #0b5cab;
|
||
color: #ffffff;
|
||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.16);
|
||
box-shadow: 0 8px 18px rgba(11, 92, 171, 0.24);
|
||
}
|
||
|
||
.arch-panel-extension-utility-button.is-primary:hover {
|
||
border-color: #084a8a;
|
||
background: #084a8a;
|
||
}
|
||
|
||
.arch-panel-extension-utility-button.is-primary:disabled {
|
||
border-color: var(--wb-border);
|
||
background: var(--wb-panel-soft);
|
||
color: var(--wb-text-muted);
|
||
box-shadow: none;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.arch-panel-extension-utility-button.is-outline {
|
||
border-color: #0b5cab;
|
||
background: transparent;
|
||
color: #0b5cab;
|
||
}
|
||
|
||
.arch-panel-extension-utility-button.is-outline:hover:not(:disabled) {
|
||
background: rgba(11, 92, 171, 0.08);
|
||
}
|
||
|
||
.arch-panel-extension-utility-button.is-outline:disabled {
|
||
border-color: var(--wb-border);
|
||
background: transparent;
|
||
color: var(--wb-text-muted);
|
||
cursor: not-allowed;
|
||
opacity: 0.62;
|
||
}
|
||
|
||
.arch-panel-extension-shell-body {
|
||
display: grid;
|
||
grid-template-columns: 260px minmax(0, 1fr);
|
||
min-height: 0;
|
||
position: relative;
|
||
}
|
||
|
||
.arch-panel-extension-shell-body.is-sidebar-collapsed {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
|
||
.arch-panel-extension-sidebar {
|
||
background: var(--wb-sidebar);
|
||
border-right: 1px solid var(--wb-border);
|
||
padding: 20px 14px;
|
||
}
|
||
|
||
.arch-panel-extension-sidebar.is-collapsed {
|
||
display: none;
|
||
}
|
||
|
||
.arch-panel-extension-sidebar-group + .arch-panel-extension-sidebar-group {
|
||
margin-top: 18px;
|
||
padding-top: 18px;
|
||
border-top: 1px solid var(--wb-border);
|
||
}
|
||
|
||
.arch-panel-extension-sidebar-label,
|
||
.arch-panel-extension-page-eyebrow,
|
||
.arch-panel-extension-panel-label,
|
||
.arch-panel-extension-state-eyebrow {
|
||
margin: 0 0 8px;
|
||
color: var(--wb-text-muted);
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.arch-panel-extension-sidebar-data {
|
||
display: grid;
|
||
gap: 6px;
|
||
color: var(--wb-text-secondary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.arch-panel-extension-sidebar-data strong {
|
||
color: var(--wb-text);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.arch-panel-extension-current-user-card {
|
||
display: grid;
|
||
justify-items: start;
|
||
gap: 10px;
|
||
}
|
||
|
||
.arch-panel-extension-current-user-avatar {
|
||
width: 48px;
|
||
height: 48px;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 999px;
|
||
background: var(--wb-panel);
|
||
box-shadow: var(--wb-shadow);
|
||
color: var(--wb-text);
|
||
object-fit: cover;
|
||
}
|
||
|
||
.arch-panel-extension-current-user-avatar.is-fallback {
|
||
display: inline-grid;
|
||
place-items: center;
|
||
background: var(--arch-workbench-header, var(--arch-workbench-header-fallback));
|
||
color: #ffffff;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.arch-panel-extension-sidebar-button {
|
||
width: 100%;
|
||
justify-content: center;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
border-color: var(--wb-border);
|
||
background: var(--wb-panel);
|
||
color: var(--wb-text);
|
||
}
|
||
|
||
.arch-panel-extension-sidebar-button + .arch-panel-extension-sidebar-button {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.arch-panel-extension-main-shell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
overflow: auto;
|
||
overflow-anchor: none;
|
||
background: var(--wb-app-bg);
|
||
padding: 20px 24px 24px;
|
||
}
|
||
|
||
.arch-panel-extension-main-shell:has(.arch-panel-extension-state-card) {
|
||
grid-column: 1 / -1;
|
||
align-items: stretch;
|
||
justify-content: center;
|
||
min-height: min(720px, calc(100vh - 120px));
|
||
}
|
||
|
||
.arch-panel-extension-page-hero {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 24px;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.arch-panel-extension-page-title,
|
||
.arch-panel-extension-state-title {
|
||
margin: 0;
|
||
color: var(--wb-text);
|
||
font-size: 34px;
|
||
line-height: 1.15;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.arch-panel-extension-page-subtitle,
|
||
.arch-panel-extension-state-copy {
|
||
margin: 10px 0 0;
|
||
max-width: 760px;
|
||
color: var(--wb-text-secondary);
|
||
font-size: 13px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.arch-panel-extension-hero-aside {
|
||
display: grid;
|
||
gap: 8px;
|
||
justify-items: end;
|
||
}
|
||
|
||
.arch-panel-extension-hero-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
min-height: 28px;
|
||
padding: 0 10px;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 14px;
|
||
background: var(--wb-info);
|
||
color: var(--wb-text);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.arch-panel-extension-hero-note {
|
||
color: var(--wb-text-secondary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.arch-panel-extension-summary-row,
|
||
.arch-panel-extension-indicator-grid,
|
||
.arch-panel-extension-status-grid,
|
||
.arch-panel-extension-content-grid,
|
||
.arch-panel-extension-boolean-grid {
|
||
display: grid;
|
||
gap: 14px;
|
||
}
|
||
|
||
.arch-panel-extension-summary-row {
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.arch-panel-extension-indicator-grid {
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.arch-panel-extension-status-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.arch-panel-extension-content-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.arch-panel-extension-summary-card,
|
||
.arch-panel-extension-panel,
|
||
.arch-panel-extension-indicator-card,
|
||
.arch-panel-extension-state-card {
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 8px;
|
||
background: var(--wb-panel);
|
||
box-shadow: var(--wb-shadow);
|
||
}
|
||
|
||
.arch-panel-extension-summary-card,
|
||
.arch-panel-extension-state-card,
|
||
.arch-panel-extension-indicator-card {
|
||
padding: 16px;
|
||
}
|
||
|
||
.arch-panel-extension-state-card {
|
||
width: 100%;
|
||
min-height: 320px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
padding: 34px 40px;
|
||
}
|
||
|
||
.arch-panel-extension-loading-progress {
|
||
display: grid;
|
||
gap: 10px;
|
||
width: 100%;
|
||
margin-top: 30px;
|
||
}
|
||
|
||
.arch-panel-extension-loading-progress-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
gap: 16px;
|
||
color: var(--wb-text-secondary);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.arch-panel-extension-loading-progress-track {
|
||
position: relative;
|
||
height: 30px;
|
||
overflow: hidden;
|
||
border: 1px solid color-mix(in srgb, var(--wb-accent) 50%, var(--wb-border-strong));
|
||
border-radius: 8px;
|
||
background: color-mix(in srgb, var(--wb-panel-soft) 68%, #000000);
|
||
}
|
||
|
||
.arch-panel-extension-loading-progress-fill {
|
||
position: absolute;
|
||
inset: 0 auto 0 0;
|
||
width: 0;
|
||
min-width: 0;
|
||
border-radius: inherit;
|
||
background: linear-gradient(90deg, #1f7a4d, #2fbf71);
|
||
transition: width 520ms ease;
|
||
}
|
||
|
||
.arch-panel-extension-loading-progress-value {
|
||
position: absolute;
|
||
inset: 0 auto 0 50%;
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 54px;
|
||
margin: 4px 0;
|
||
padding: 0 8px;
|
||
border: 1px solid rgba(255, 255, 255, 0.26);
|
||
border-radius: 999px;
|
||
background: rgba(7, 12, 18, 0.72);
|
||
color: #ffffff;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
line-height: 1;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
|
||
text-shadow: none;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.arch-panel-extension-summary-card-button {
|
||
appearance: none;
|
||
width: 100%;
|
||
text-align: left;
|
||
color: inherit;
|
||
font: inherit;
|
||
line-height: inherit;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.arch-panel-extension-panel {
|
||
padding: 16px;
|
||
}
|
||
|
||
.arch-panel-extension-summary-label,
|
||
.arch-panel-extension-summary-detail,
|
||
.arch-panel-extension-indicator-label,
|
||
.arch-panel-extension-indicator-subtitle,
|
||
.arch-panel-extension-indicator-total,
|
||
.arch-panel-extension-insight-detail,
|
||
.arch-panel-extension-stage-detail {
|
||
margin: 0;
|
||
}
|
||
|
||
.arch-panel-extension-summary-label,
|
||
.arch-panel-extension-indicator-label {
|
||
color: var(--wb-text-secondary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.arch-panel-extension-summary-value,
|
||
.arch-panel-extension-indicator-value {
|
||
margin: 8px 0 4px;
|
||
color: var(--wb-text);
|
||
font-size: 26px;
|
||
line-height: 1.2;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.arch-panel-extension-summary-detail,
|
||
.arch-panel-extension-indicator-subtitle,
|
||
.arch-panel-extension-indicator-total {
|
||
color: var(--wb-text-muted);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.arch-panel-extension-indicator-card {
|
||
appearance: none;
|
||
text-align: left;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.arch-panel-extension-boolean-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.arch-panel-extension-progress {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.arch-panel-extension-progress-track {
|
||
display: flex;
|
||
width: 100%;
|
||
height: 10px;
|
||
overflow: hidden;
|
||
border-radius: 999px;
|
||
background: var(--wb-panel-soft);
|
||
border: 1px solid var(--wb-border);
|
||
}
|
||
|
||
.arch-panel-extension-progress-segment {
|
||
height: 100%;
|
||
}
|
||
|
||
.arch-panel-extension-progress-segment.is-success {
|
||
background: linear-gradient(90deg, #7fd29a, #3fa35c);
|
||
}
|
||
|
||
.arch-panel-extension-progress-segment.is-danger {
|
||
background: linear-gradient(90deg, #f2a28d, #d94f2b);
|
||
}
|
||
|
||
.arch-panel-extension-progress-labels {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
color: var(--wb-text-secondary);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.arch-panel-extension-panel-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.arch-panel-extension-panel-title {
|
||
margin: 0;
|
||
color: var(--wb-text);
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.arch-panel-extension-panel-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: var(--wb-text-muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.arch-panel-extension-operations-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 14px;
|
||
}
|
||
|
||
.arch-panel-extension-sr-hour-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 14px;
|
||
align-content: start;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-card {
|
||
min-width: 0;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-actions {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: var(--wb-text-muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-view-toggle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
overflow: hidden;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 4px;
|
||
background: var(--wb-panel-soft);
|
||
}
|
||
|
||
.arch-panel-extension-calendar-view-button {
|
||
appearance: none;
|
||
min-height: 28px;
|
||
padding: 0 9px;
|
||
border: 0;
|
||
border-right: 1px solid var(--wb-border);
|
||
background: transparent;
|
||
color: var(--wb-text-secondary);
|
||
font: inherit;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-view-button:last-child {
|
||
border-right: 0;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-view-button:hover {
|
||
background: var(--wb-row-hover);
|
||
color: var(--wb-text);
|
||
}
|
||
|
||
.arch-panel-extension-calendar-view-button.is-active {
|
||
background: #0b5cab;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-nav {
|
||
appearance: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 4px;
|
||
background: var(--wb-panel-soft);
|
||
color: var(--wb-text);
|
||
font-size: 18px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-nav:hover {
|
||
border-color: var(--wb-accent);
|
||
color: var(--wb-accent);
|
||
}
|
||
|
||
.arch-panel-extension-calendar-today {
|
||
appearance: none;
|
||
min-height: 28px;
|
||
padding: 0 10px;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 4px;
|
||
background: var(--wb-panel-soft);
|
||
color: var(--wb-text);
|
||
font: inherit;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-today:hover {
|
||
border-color: var(--wb-accent);
|
||
color: var(--wb-accent);
|
||
}
|
||
|
||
.arch-panel-extension-calendar-weekdays,
|
||
.arch-panel-extension-calendar-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||
}
|
||
|
||
.arch-panel-extension-calendar-weekdays {
|
||
gap: 6px;
|
||
margin-bottom: 6px;
|
||
color: var(--wb-text-muted);
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-weekdays span {
|
||
padding: 0 6px;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-grid {
|
||
gap: 6px;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-grid.is-week {
|
||
min-height: 300px;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-day {
|
||
appearance: none;
|
||
position: relative;
|
||
display: block;
|
||
width: 100%;
|
||
text-align: left;
|
||
min-height: 74px;
|
||
overflow: hidden;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 6px;
|
||
background: var(--wb-panel-soft);
|
||
padding: 7px;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-day.is-week-day {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 300px;
|
||
padding: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-week-day-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
min-height: 38px;
|
||
padding: 8px 9px;
|
||
border-bottom: 1px solid var(--wb-border);
|
||
background: color-mix(in srgb, var(--wb-panel) 72%, var(--wb-panel-soft));
|
||
}
|
||
|
||
.arch-panel-extension-calendar-day.is-week-day .arch-panel-extension-calendar-week-number {
|
||
position: static;
|
||
font-size: 9px;
|
||
opacity: 0.76;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-week-workloads {
|
||
display: flex;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
gap: 7px;
|
||
padding: 8px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-week-workload {
|
||
display: block;
|
||
border-left: 3px solid #d94f2b;
|
||
border-radius: 5px;
|
||
background: rgba(217, 79, 43, 0.14);
|
||
padding: 7px 8px;
|
||
color: var(--wb-text);
|
||
text-align: left;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-week-workload-title,
|
||
.arch-panel-extension-calendar-week-workload-meta {
|
||
display: block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-week-workload-title {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-week-workload-meta {
|
||
margin-top: 3px;
|
||
color: var(--wb-text-secondary);
|
||
font-size: 10px;
|
||
line-height: 1.2;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-week-empty {
|
||
margin: auto;
|
||
color: var(--wb-text-muted);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-day:disabled {
|
||
cursor: default;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-day.has-workload:hover {
|
||
border-color: #f26c4f;
|
||
background: rgba(217, 79, 43, 0.22);
|
||
}
|
||
|
||
.arch-panel-extension-calendar-day.is-muted {
|
||
opacity: 0.42;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-day.is-today {
|
||
border-color: var(--wb-accent);
|
||
box-shadow: inset 0 0 0 1px var(--wb-accent);
|
||
}
|
||
|
||
.arch-panel-extension-calendar-day.has-workload {
|
||
border-color: rgba(217, 79, 43, 0.82);
|
||
background: rgba(217, 79, 43, 0.14);
|
||
}
|
||
|
||
.arch-panel-extension-calendar-date {
|
||
color: var(--wb-text);
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-week-number {
|
||
position: absolute;
|
||
right: 7px;
|
||
bottom: 6px;
|
||
color: var(--wb-text-muted);
|
||
font-size: 9px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.04em;
|
||
opacity: 0.72;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-workload {
|
||
display: block;
|
||
margin-top: 8px;
|
||
color: var(--wb-text);
|
||
font-size: 11px;
|
||
line-height: 1.25;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
}
|
||
|
||
.arch-panel-extension-calendar-count {
|
||
position: absolute;
|
||
top: 6px;
|
||
right: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 20px;
|
||
height: 20px;
|
||
padding: 0 6px;
|
||
border-radius: 999px;
|
||
background: #d94f2b;
|
||
color: #ffffff;
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.arch-panel-extension-empty-state {
|
||
border: 1px dashed var(--wb-border);
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
color: var(--wb-text-muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.arch-panel-extension-panel-dot {
|
||
width: 4px;
|
||
height: 4px;
|
||
border-radius: 50%;
|
||
background: var(--wb-border-strong);
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap {
|
||
overflow: auto;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-detail {
|
||
max-height: calc(100vh - 220px);
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-sr-list {
|
||
overflow: auto;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-ramp-pivot {
|
||
max-height: min(460px, calc(100vh - 260px));
|
||
}
|
||
|
||
.arch-panel-extension-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
background: var(--wb-panel);
|
||
}
|
||
|
||
.arch-panel-extension-table th,
|
||
.arch-panel-extension-table td {
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid var(--wb-row-divider);
|
||
font-size: 12px;
|
||
text-align: left;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.arch-panel-extension-table th {
|
||
background: var(--wb-panel-soft);
|
||
color: var(--wb-text-secondary);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.arch-panel-extension-table td {
|
||
color: var(--wb-text);
|
||
}
|
||
|
||
.arch-panel-extension-ramp-pivot-table th:first-child {
|
||
min-width: 150px;
|
||
position: sticky;
|
||
left: 0;
|
||
z-index: 1;
|
||
}
|
||
|
||
.arch-panel-extension-ramp-pivot-table tbody th {
|
||
background: var(--wb-panel-soft);
|
||
color: var(--wb-text-secondary);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.arch-panel-extension-ramp-pivot-table .is-delta-different {
|
||
color: #d94f2b;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.arch-panel-extension-sr-table td {
|
||
white-space: normal;
|
||
vertical-align: top;
|
||
}
|
||
|
||
.arch-panel-extension-sr-main {
|
||
width: 78%;
|
||
}
|
||
|
||
.arch-panel-extension-sr-side {
|
||
width: 22%;
|
||
text-align: right;
|
||
}
|
||
|
||
.arch-panel-extension-sr-line {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px 14px;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.arch-panel-extension-sr-line.is-head {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.arch-panel-extension-sr-customer {
|
||
color: var(--wb-text);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.arch-panel-extension-sr-moment {
|
||
color: var(--wb-text);
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.arch-panel-extension-sr-exact,
|
||
.arch-panel-extension-sr-hours {
|
||
margin-top: 6px;
|
||
color: var(--wb-text-secondary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.arch-panel-extension-table-empty {
|
||
padding: 18px;
|
||
border: 1px dashed var(--wb-border);
|
||
border-radius: 8px;
|
||
color: var(--wb-text-secondary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-main .arch-panel-extension-table {
|
||
table-layout: fixed;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-main
|
||
.arch-panel-extension-col-opportunity {
|
||
width: 14%;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-main
|
||
.arch-panel-extension-col-customer {
|
||
width: 16%;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-main
|
||
.arch-panel-extension-col-workload {
|
||
width: auto;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-main
|
||
.arch-panel-extension-col-acr {
|
||
width: 11%;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-main
|
||
.arch-panel-extension-col-type {
|
||
width: 11%;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-main
|
||
.arch-panel-extension-col-flag {
|
||
width: 9%;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-main
|
||
.arch-panel-extension-table
|
||
th:nth-child(3),
|
||
.arch-panel-extension-table-wrap.is-main
|
||
.arch-panel-extension-table
|
||
td:nth-child(3) {
|
||
white-space: normal;
|
||
overflow-wrap: anywhere;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-main
|
||
.arch-panel-extension-table
|
||
td:nth-child(3)
|
||
.arch-panel-extension-row-link {
|
||
display: block;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-main
|
||
.arch-panel-extension-table
|
||
th:nth-child(2),
|
||
.arch-panel-extension-table-wrap.is-main
|
||
.arch-panel-extension-table
|
||
td:nth-child(2) {
|
||
white-space: normal;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.arch-panel-extension-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 54px;
|
||
min-height: 24px;
|
||
padding: 0 10px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.arch-panel-extension-badge.is-success {
|
||
background: #dcfce7;
|
||
border: 1px solid #86efac;
|
||
color: #166534;
|
||
}
|
||
|
||
.arch-panel-extension-badge.is-danger {
|
||
background: #fee2e2;
|
||
border: 1px solid #fca5a5;
|
||
color: #991b1b;
|
||
}
|
||
|
||
.arch-panel-extension-action-cell {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.arch-panel-extension-add-action-button {
|
||
appearance: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 26px;
|
||
height: 26px;
|
||
padding: 0;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 999px;
|
||
background: var(--wb-panel-soft);
|
||
color: var(--wb-text);
|
||
font: inherit;
|
||
font-size: 18px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.arch-panel-extension-table tbody tr:hover {
|
||
background: var(--wb-row-hover);
|
||
}
|
||
|
||
.arch-panel-extension-table th.is-numeric,
|
||
.arch-panel-extension-table td.is-numeric {
|
||
text-align: right;
|
||
}
|
||
|
||
.arch-panel-extension-row-link {
|
||
appearance: none;
|
||
padding: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--wb-link);
|
||
font: inherit;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.arch-panel-extension-workload-link-cell {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
}
|
||
|
||
.arch-panel-extension-ramp-alert-button {
|
||
appearance: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 18px;
|
||
height: 18px;
|
||
padding: 0;
|
||
border: 1px solid #d94f2b;
|
||
border-radius: 999px;
|
||
background: #fee2e2;
|
||
color: #991b1b;
|
||
font: inherit;
|
||
font-size: 12px;
|
||
font-weight: 900;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.arch-panel-extension-ramp-alert-button:hover {
|
||
background: #fecaca;
|
||
}
|
||
|
||
.arch-panel-extension-insight-list,
|
||
.arch-panel-extension-stage-list,
|
||
.arch-panel-extension-side-column {
|
||
display: grid;
|
||
gap: 14px;
|
||
}
|
||
|
||
.arch-panel-extension-insight {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid var(--wb-divider);
|
||
}
|
||
|
||
.arch-panel-extension-insight:first-child {
|
||
padding-top: 0;
|
||
border-top: 0;
|
||
}
|
||
|
||
.arch-panel-extension-insight-label,
|
||
.arch-panel-extension-stage-label {
|
||
margin: 0 0 4px;
|
||
color: var(--wb-text);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.arch-panel-extension-insight-detail,
|
||
.arch-panel-extension-stage-detail {
|
||
color: var(--wb-text-muted);
|
||
font-size: 11px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.arch-panel-extension-insight-value {
|
||
margin: 0;
|
||
color: var(--wb-text);
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
text-align: right;
|
||
}
|
||
|
||
.arch-panel-extension-stage-row {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
|
||
.arch-panel-extension-stage-copy {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.arch-panel-extension-stage-values {
|
||
display: grid;
|
||
justify-items: end;
|
||
gap: 2px;
|
||
color: var(--wb-text-secondary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.arch-panel-extension-stage-detail-row {
|
||
color: var(--wb-text-secondary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.arch-panel-extension-stage-bar {
|
||
position: relative;
|
||
height: 8px;
|
||
border-radius: 999px;
|
||
overflow: hidden;
|
||
background: color-mix(in srgb, var(--wb-text-secondary) 14%, transparent);
|
||
}
|
||
|
||
.arch-panel-extension-stage-bar span {
|
||
position: absolute;
|
||
inset: 0 auto 0 0;
|
||
border-radius: inherit;
|
||
background: linear-gradient(90deg, var(--wb-accent), color-mix(in srgb, var(--wb-accent) 52%, #ffffff));
|
||
}
|
||
|
||
.arch-panel-extension-detail-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 16px;
|
||
background: rgba(10, 14, 20, 0.68);
|
||
backdrop-filter: blur(6px);
|
||
}
|
||
|
||
.arch-panel-extension-time-drawer {
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 2;
|
||
width: min(560px, calc(100vw - 32px));
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
padding: 18px;
|
||
border-left: 1px solid var(--wb-border);
|
||
background: var(--wb-panel);
|
||
box-shadow: -18px 0 42px rgba(0, 0, 0, 0.28);
|
||
overflow: auto;
|
||
animation: arch-panel-extension-slide-in 180ms ease-out;
|
||
}
|
||
|
||
.arch-panel-extension-time-drawer-head {
|
||
position: sticky;
|
||
top: -18px;
|
||
z-index: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 14px;
|
||
padding: 0 0 12px;
|
||
border-bottom: 1px solid var(--wb-border);
|
||
background: var(--wb-panel);
|
||
}
|
||
|
||
.arch-panel-extension-time-drawer-close {
|
||
border-color: var(--wb-border);
|
||
background: var(--wb-panel-soft);
|
||
color: var(--wb-text);
|
||
box-shadow: none;
|
||
}
|
||
|
||
.arch-panel-extension-time-drawer-close:hover {
|
||
border-color: var(--wb-border-strong);
|
||
background: var(--wb-row-hover);
|
||
}
|
||
|
||
.arch-panel-extension-time-drawer-actions {
|
||
position: sticky;
|
||
bottom: -18px;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin: auto -18px -18px;
|
||
padding: 12px 18px 18px;
|
||
border-top: 1px solid var(--wb-border);
|
||
background: linear-gradient(180deg, rgba(23, 30, 39, 0), var(--wb-panel) 30%);
|
||
}
|
||
|
||
.arch-panel-extension-time-drawer-meta {
|
||
display: flex;
|
||
gap: 8px;
|
||
color: var(--wb-text-muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.arch-panel-extension-time-drawer-state,
|
||
.arch-panel-extension-time-drawer-json {
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 8px;
|
||
background: var(--wb-panel-soft);
|
||
padding: 14px;
|
||
}
|
||
|
||
.arch-panel-extension-time-drawer-state.is-error {
|
||
border-color: rgba(217, 79, 43, 0.42);
|
||
}
|
||
|
||
.arch-panel-extension-time-entry-group {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.arch-panel-extension-time-entry-group + .arch-panel-extension-time-entry-group {
|
||
padding-top: 12px;
|
||
border-top: 1px solid var(--wb-border);
|
||
}
|
||
|
||
.arch-panel-extension-time-entry-group-head {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.arch-panel-extension-time-entry-group-head h4 {
|
||
margin: 3px 0 0;
|
||
color: var(--wb-text);
|
||
font-size: 16px;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.arch-panel-extension-time-entry-group-head span {
|
||
color: var(--wb-text-muted);
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.arch-panel-extension-time-entry-table-wrap {
|
||
overflow: auto;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.arch-panel-extension-time-entry-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
background: var(--wb-panel);
|
||
}
|
||
|
||
.arch-panel-extension-time-entry-table th,
|
||
.arch-panel-extension-time-entry-table td {
|
||
padding: 10px 12px;
|
||
border-bottom: 1px solid var(--wb-row-divider);
|
||
color: var(--wb-text);
|
||
font-size: 12px;
|
||
text-align: left;
|
||
vertical-align: top;
|
||
}
|
||
|
||
.arch-panel-extension-time-entry-table th {
|
||
background: var(--wb-panel-soft);
|
||
color: var(--wb-text-secondary);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.arch-panel-extension-time-entry-table tfoot th {
|
||
border-bottom: 0;
|
||
color: var(--wb-text);
|
||
}
|
||
|
||
.arch-panel-extension-time-entry-table .is-numeric {
|
||
text-align: right;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.arch-panel-extension-time-drawer-json summary {
|
||
color: var(--wb-link);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.arch-panel-extension-time-drawer-json pre {
|
||
max-height: 320px;
|
||
margin: 12px 0 0;
|
||
overflow: auto;
|
||
color: var(--wb-text);
|
||
font-size: 11px;
|
||
line-height: 1.5;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
@keyframes arch-panel-extension-slide-in {
|
||
from {
|
||
transform: translateX(100%);
|
||
}
|
||
to {
|
||
transform: translateX(0);
|
||
}
|
||
}
|
||
|
||
.arch-panel-extension-detail-dialog {
|
||
width: min(1520px, calc(100vw - 32px));
|
||
max-height: calc(100vh - 88px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 10px;
|
||
background: var(--wb-panel);
|
||
box-shadow: var(--wb-shadow);
|
||
padding: 18px;
|
||
}
|
||
|
||
.arch-panel-extension-ramp-dialog {
|
||
width: min(1320px, calc(100vw - 48px));
|
||
max-height: calc(100vh - 120px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 10px;
|
||
background: var(--wb-panel);
|
||
box-shadow: var(--wb-shadow);
|
||
padding: 18px;
|
||
}
|
||
|
||
.arch-panel-extension-action-dialog {
|
||
width: min(980px, calc(100vw - 32px));
|
||
max-height: calc(100vh - 88px);
|
||
overflow: auto;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 10px;
|
||
background: var(--wb-panel);
|
||
box-shadow: var(--wb-shadow);
|
||
padding: 18px;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-dialog {
|
||
width: min(1760px, calc(100vw - 20px));
|
||
max-height: calc(100vh - 88px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
overflow: visible;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 10px;
|
||
background: var(--wb-panel);
|
||
box-shadow: var(--wb-shadow);
|
||
padding: 18px;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-toolbar,
|
||
.arch-panel-extension-time-management-card {
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 8px;
|
||
background: var(--wb-panel-soft);
|
||
padding: 14px;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-card {
|
||
display: grid;
|
||
--arch-time-management-scrollbar-gutter: 16px;
|
||
gap: 4px;
|
||
min-height: 0;
|
||
overflow: visible;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-card.is-error {
|
||
border-color: rgba(217, 79, 43, 0.42);
|
||
}
|
||
|
||
.arch-panel-extension-time-management-toolbar {
|
||
display: flex;
|
||
align-items: end;
|
||
gap: 14px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-hour-indicators {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(92px, 1fr));
|
||
gap: 8px;
|
||
flex: 1 1 520px;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-hour-indicator {
|
||
display: grid;
|
||
gap: 3px;
|
||
min-height: 48px;
|
||
align-content: center;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 8px;
|
||
background: var(--wb-panel);
|
||
padding: 7px 10px;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-hour-indicator span {
|
||
color: var(--wb-text-muted);
|
||
font-size: 10px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-hour-indicator strong {
|
||
color: var(--wb-text);
|
||
font-size: 16px;
|
||
font-weight: 800;
|
||
line-height: 1.1;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-hour-indicator .arch-panel-extension-skeleton-text {
|
||
width: 72%;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-hour-indicator .arch-panel-extension-skeleton-title {
|
||
width: 42%;
|
||
height: 16px;
|
||
}
|
||
|
||
.arch-panel-extension-date-picker {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
width: auto;
|
||
}
|
||
|
||
.arch-panel-extension-date-input-shell {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.arch-panel-extension-date-picker-button {
|
||
position: absolute;
|
||
top: 50%;
|
||
right: 11px;
|
||
z-index: 1;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 20px;
|
||
height: 20px;
|
||
border: 0;
|
||
border-radius: 5px;
|
||
background: transparent;
|
||
color: var(--wb-text-muted);
|
||
cursor: pointer;
|
||
font-size: 0;
|
||
line-height: 1;
|
||
padding: 0;
|
||
transform: translateY(-50%);
|
||
}
|
||
|
||
.arch-panel-extension-date-step-button {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 26px;
|
||
height: 32px;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 7px;
|
||
background: var(--wb-panel);
|
||
color: var(--wb-text-muted);
|
||
cursor: pointer;
|
||
padding: 0;
|
||
}
|
||
|
||
.arch-panel-extension-date-step-button svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
fill: currentColor;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.arch-panel-extension-date-step-button:hover {
|
||
border-color: var(--wb-border-strong);
|
||
background: var(--wb-row-hover);
|
||
color: var(--wb-text);
|
||
}
|
||
|
||
.arch-panel-extension-date-picker-button svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
fill: currentColor;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.arch-panel-extension-date-picker-button:hover {
|
||
background: var(--wb-row-hover);
|
||
color: var(--wb-text);
|
||
}
|
||
|
||
.arch-panel-extension-date-picker-icon {
|
||
display: none;
|
||
position: absolute;
|
||
left: 11px;
|
||
z-index: 1;
|
||
color: var(--wb-text-muted);
|
||
font-size: 14px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.arch-panel-extension-date-picker input {
|
||
width: 118px;
|
||
min-height: 32px;
|
||
padding-left: 12px;
|
||
padding-right: 38px;
|
||
}
|
||
|
||
.arch-panel-extension-form-field .arch-panel-extension-date-picker input {
|
||
min-height: 32px;
|
||
padding: 0 38px 0 12px;
|
||
}
|
||
|
||
.arch-panel-extension-date-picker input[readonly] {
|
||
cursor: default;
|
||
}
|
||
|
||
.arch-panel-extension-date-picker-popover {
|
||
position: absolute;
|
||
top: calc(100% + 8px);
|
||
left: 0;
|
||
z-index: 6;
|
||
width: 292px;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 10px;
|
||
background: var(--wb-panel);
|
||
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.22);
|
||
padding: 12px;
|
||
}
|
||
|
||
.arch-panel-extension-date-picker-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.arch-panel-extension-date-picker-head strong {
|
||
color: var(--wb-text);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.arch-panel-extension-date-picker-weekdays,
|
||
.arch-panel-extension-date-picker-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||
gap: 4px;
|
||
}
|
||
|
||
.arch-panel-extension-date-picker-weekdays {
|
||
margin-bottom: 5px;
|
||
color: var(--wb-text-muted);
|
||
font-size: 10px;
|
||
font-weight: 800;
|
||
text-align: center;
|
||
}
|
||
|
||
.arch-panel-extension-date-picker-day {
|
||
appearance: none;
|
||
min-width: 0;
|
||
height: 32px;
|
||
border: 1px solid transparent;
|
||
border-radius: 7px;
|
||
background: transparent;
|
||
color: var(--wb-text);
|
||
font: inherit;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.arch-panel-extension-date-picker-day:hover {
|
||
border-color: var(--wb-border-strong);
|
||
background: var(--wb-row-hover);
|
||
}
|
||
|
||
.arch-panel-extension-date-picker-day.is-muted {
|
||
color: var(--wb-text-muted);
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.arch-panel-extension-date-picker-day.is-today {
|
||
border-color: #0b5cab;
|
||
}
|
||
|
||
.arch-panel-extension-date-picker-day.is-selected {
|
||
border-color: #0b5cab;
|
||
background: #0b5cab;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-select-grid {
|
||
display: grid;
|
||
grid-template-columns: minmax(240px, 1.15fr) minmax(220px, 1fr) minmax(180px, 0.8fr);
|
||
gap: 12px;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-rows {
|
||
display: grid;
|
||
gap: 10px;
|
||
max-height: min(52vh, 520px);
|
||
min-height: 0;
|
||
overflow-x: hidden;
|
||
overflow-y: auto;
|
||
padding: 4px 0;
|
||
scrollbar-gutter: stable;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-rows:focus-within {
|
||
overflow: visible;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-empty {
|
||
grid-column: 1 / -1;
|
||
text-align: center;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-header-row {
|
||
display: grid;
|
||
grid-template-columns:
|
||
minmax(420px, 3.8fr)
|
||
minmax(150px, 0.95fr)
|
||
minmax(138px, 0.82fr)
|
||
repeat(7, minmax(52px, 0.28fr))
|
||
34px;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding-right: var(--arch-time-management-scrollbar-gutter);
|
||
}
|
||
|
||
.arch-panel-extension-time-management-column-label {
|
||
color: var(--wb-text-secondary);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-entry-row {
|
||
display: grid;
|
||
grid-template-columns:
|
||
minmax(420px, 3.8fr)
|
||
minmax(150px, 0.95fr)
|
||
minmax(138px, 0.82fr)
|
||
repeat(7, minmax(52px, 0.28fr))
|
||
34px;
|
||
align-items: start;
|
||
gap: 6px;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-entry-row.is-compact {
|
||
align-items: center;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-entry-row.is-duplicate-highlight {
|
||
border-radius: 10px;
|
||
animation: arch-panel-extension-duplicate-pulse 1100ms ease-in-out 2;
|
||
box-shadow:
|
||
inset 0 0 0 3px rgba(217, 79, 43, 0.9),
|
||
0 0 0 2px rgba(217, 79, 43, 0.12);
|
||
padding: 4px;
|
||
margin: -4px 0;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-entry-row.is-compact .arch-panel-extension-form-field {
|
||
gap: 0;
|
||
}
|
||
|
||
@keyframes arch-panel-extension-duplicate-pulse {
|
||
0% {
|
||
box-shadow:
|
||
inset 0 0 0 2px rgba(217, 79, 43, 0.35),
|
||
0 0 0 0 rgba(217, 79, 43, 0.22);
|
||
}
|
||
45% {
|
||
box-shadow:
|
||
inset 0 0 0 3px rgba(217, 79, 43, 0.95),
|
||
0 0 0 6px rgba(217, 79, 43, 0.14);
|
||
}
|
||
100% {
|
||
box-shadow:
|
||
inset 0 0 0 3px rgba(217, 79, 43, 0.72),
|
||
0 0 0 0 rgba(217, 79, 43, 0);
|
||
}
|
||
}
|
||
|
||
.arch-panel-extension-combobox {
|
||
position: relative;
|
||
display: block;
|
||
}
|
||
|
||
.arch-panel-extension-combobox input {
|
||
min-height: 40px;
|
||
padding-left: 12px;
|
||
padding-right: 38px;
|
||
border-radius: 8px;
|
||
background: color-mix(in srgb, var(--wb-panel-soft) 86%, var(--wb-panel));
|
||
color: transparent;
|
||
transition:
|
||
border-color 140ms ease,
|
||
box-shadow 140ms ease,
|
||
background 140ms ease;
|
||
}
|
||
|
||
.arch-panel-extension-combobox input::placeholder {
|
||
color: transparent;
|
||
}
|
||
|
||
.arch-panel-extension-combobox:focus-within input::placeholder {
|
||
color: var(--wb-text-muted);
|
||
}
|
||
|
||
.arch-panel-extension-combobox-value {
|
||
position: absolute;
|
||
top: 50%;
|
||
right: 38px;
|
||
left: 12px;
|
||
z-index: 1;
|
||
overflow: hidden;
|
||
color: var(--wb-text);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
line-height: 1.25;
|
||
pointer-events: none;
|
||
text-overflow: ellipsis;
|
||
transform: translateY(-50%);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.arch-panel-extension-combobox:focus-within .arch-panel-extension-combobox-value {
|
||
display: none;
|
||
}
|
||
|
||
.arch-panel-extension-combobox.is-disabled {
|
||
opacity: 0.72;
|
||
}
|
||
|
||
.arch-panel-extension-combobox.is-disabled .arch-panel-extension-combobox-value,
|
||
.arch-panel-extension-combobox.is-disabled .arch-panel-extension-combobox-arrow {
|
||
color: var(--wb-text-muted);
|
||
}
|
||
|
||
.arch-panel-extension-combobox.is-disabled input {
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.arch-panel-extension-combobox.is-disabled:focus-within .arch-panel-extension-combobox-menu {
|
||
display: none;
|
||
}
|
||
|
||
.arch-panel-extension-combobox input:focus {
|
||
border-color: #0b5cab;
|
||
background: var(--wb-panel);
|
||
box-shadow: 0 0 0 3px rgba(11, 92, 171, 0.16);
|
||
color: var(--wb-text);
|
||
outline: none;
|
||
}
|
||
|
||
.arch-panel-extension-combobox-arrow {
|
||
position: absolute;
|
||
top: 50%;
|
||
right: 12px;
|
||
z-index: 1;
|
||
display: inline-flex;
|
||
width: 16px;
|
||
height: 16px;
|
||
color: var(--wb-text-muted);
|
||
pointer-events: none;
|
||
transform: translateY(-50%);
|
||
}
|
||
|
||
.arch-panel-extension-combobox-arrow svg {
|
||
width: 100%;
|
||
height: 100%;
|
||
fill: currentColor;
|
||
}
|
||
|
||
.arch-panel-extension-combobox-menu {
|
||
position: absolute;
|
||
top: calc(100% + 6px);
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 10000;
|
||
display: none;
|
||
max-height: 260px;
|
||
overflow: auto;
|
||
border: 1px solid var(--wb-border-strong);
|
||
border-radius: 10px;
|
||
background: var(--wb-panel);
|
||
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.22);
|
||
padding: 6px;
|
||
}
|
||
|
||
.arch-panel-extension-combobox:focus-within .arch-panel-extension-combobox-menu {
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
|
||
.arch-panel-extension-combobox-option {
|
||
appearance: none;
|
||
display: grid;
|
||
gap: 2px;
|
||
position: relative;
|
||
width: 100%;
|
||
min-height: 38px;
|
||
border: 0;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
color: var(--wb-text);
|
||
cursor: pointer;
|
||
font: inherit;
|
||
line-height: 1.25;
|
||
padding: 8px 10px;
|
||
text-align: left;
|
||
}
|
||
|
||
.arch-panel-extension-combobox-option:hover,
|
||
.arch-panel-extension-combobox-option:focus,
|
||
.arch-panel-extension-combobox-option.is-active {
|
||
outline: none;
|
||
background: var(--wb-row-hover);
|
||
}
|
||
|
||
.arch-panel-extension-combobox-option.is-active {
|
||
box-shadow: inset 3px 0 0 #0b5cab;
|
||
}
|
||
|
||
.arch-panel-extension-combobox-option.is-selected {
|
||
background: rgba(11, 92, 171, 0.12);
|
||
color: var(--wb-text);
|
||
padding-right: 32px;
|
||
}
|
||
|
||
.arch-panel-extension-combobox-option.is-selected::after {
|
||
content: "✓";
|
||
position: absolute;
|
||
top: 50%;
|
||
right: 11px;
|
||
color: #0b5cab;
|
||
font-size: 14px;
|
||
font-weight: 800;
|
||
transform: translateY(-50%);
|
||
}
|
||
|
||
.arch-panel-extension-dialog[data-theme="dark"] .arch-panel-extension-combobox-option.is-selected::after {
|
||
color: #9ed4f0;
|
||
}
|
||
|
||
.arch-panel-extension-combobox-option-main {
|
||
color: var(--wb-text);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.arch-panel-extension-combobox-option-meta {
|
||
color: var(--wb-text-muted);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.arch-panel-extension-combobox-option[hidden] {
|
||
display: none;
|
||
}
|
||
|
||
.arch-panel-extension-combobox-empty {
|
||
color: var(--wb-text-muted);
|
||
font-size: 12px;
|
||
padding: 8px 9px;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-days {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, minmax(110px, 1fr));
|
||
gap: 8px;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-day {
|
||
display: grid;
|
||
gap: 6px;
|
||
padding: 7px;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 8px;
|
||
background: var(--wb-panel);
|
||
}
|
||
|
||
.arch-panel-extension-time-management-day-head {
|
||
display: grid;
|
||
align-content: center;
|
||
justify-items: center;
|
||
gap: 1px;
|
||
min-height: 22px;
|
||
padding: 0;
|
||
color: var(--wb-text-secondary);
|
||
line-height: 1.15;
|
||
text-align: center;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-day-head strong {
|
||
color: var(--wb-text-secondary);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-day-head span {
|
||
color: var(--wb-text-muted);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
text-transform: none;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-day input {
|
||
width: 100%;
|
||
min-height: 30px;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 6px;
|
||
background: var(--wb-panel-soft);
|
||
color: var(--wb-text);
|
||
font: inherit;
|
||
font-size: 12px;
|
||
padding: 0 6px;
|
||
text-align: right;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-delete {
|
||
appearance: none;
|
||
align-self: center;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 7px;
|
||
background: var(--wb-panel);
|
||
color: var(--wb-text-muted);
|
||
cursor: pointer;
|
||
padding: 0;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-delete:hover,
|
||
.arch-panel-extension-time-management-delete:focus-visible {
|
||
border-color: rgba(217, 79, 43, 0.52);
|
||
background: rgba(217, 79, 43, 0.12);
|
||
color: #d94f2b;
|
||
outline: none;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-delete svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
fill: currentColor;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-request {
|
||
border-top: 1px solid var(--wb-border);
|
||
padding-top: 10px;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-request summary {
|
||
color: var(--wb-link);
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-request p {
|
||
margin: 10px 0 4px;
|
||
color: var(--wb-text-secondary);
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.02em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-request pre {
|
||
max-height: 220px;
|
||
margin: 8px 0 0;
|
||
overflow: auto;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 7px;
|
||
background: var(--wb-panel);
|
||
color: var(--wb-text-secondary);
|
||
font-size: 11px;
|
||
line-height: 1.45;
|
||
padding: 10px;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.arch-panel-extension-skeleton {
|
||
display: block;
|
||
overflow: hidden;
|
||
border-radius: 6px;
|
||
background: linear-gradient(
|
||
90deg,
|
||
color-mix(in srgb, var(--wb-border) 70%, transparent) 0%,
|
||
color-mix(in srgb, var(--wb-panel) 70%, #ffffff) 48%,
|
||
color-mix(in srgb, var(--wb-border) 70%, transparent) 100%
|
||
);
|
||
background-size: 220% 100%;
|
||
animation: arch-panel-extension-skeleton 1.15s ease-in-out infinite;
|
||
}
|
||
|
||
.arch-panel-extension-skeleton-control {
|
||
height: 38px;
|
||
}
|
||
|
||
.arch-panel-extension-skeleton-title {
|
||
width: 62%;
|
||
height: 14px;
|
||
}
|
||
|
||
.arch-panel-extension-skeleton-text {
|
||
width: 46%;
|
||
height: 11px;
|
||
}
|
||
|
||
.arch-panel-extension-skeleton-input {
|
||
height: 34px;
|
||
}
|
||
|
||
@keyframes arch-panel-extension-skeleton {
|
||
from {
|
||
background-position: 120% 0;
|
||
}
|
||
to {
|
||
background-position: -120% 0;
|
||
}
|
||
}
|
||
|
||
.arch-panel-extension-confirm-dialog {
|
||
width: min(560px, calc(100vw - 32px));
|
||
max-height: calc(100vh - 120px);
|
||
overflow: auto;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 10px;
|
||
background: var(--wb-panel);
|
||
box-shadow: var(--wb-shadow);
|
||
padding: 18px;
|
||
}
|
||
|
||
.arch-panel-extension-detail-title {
|
||
margin: 0;
|
||
color: var(--wb-text);
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.arch-panel-extension-detail-subtitle {
|
||
margin: 0 0 14px;
|
||
color: var(--wb-text-muted);
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.arch-panel-extension-detail-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.arch-panel-extension-detail-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-bottom: 14px;
|
||
color: var(--wb-text-muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.arch-panel-extension-detail-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
position: sticky;
|
||
bottom: 0;
|
||
margin-top: 16px;
|
||
padding-top: 12px;
|
||
background: linear-gradient(180deg, rgba(23, 30, 39, 0), var(--wb-panel) 35%);
|
||
}
|
||
|
||
.arch-panel-extension-time-management-footer-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-right: auto;
|
||
}
|
||
|
||
.arch-panel-extension-time-management-save-error {
|
||
margin: 0 auto 0 0;
|
||
color: var(--wb-warning);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.arch-panel-extension-form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 14px;
|
||
margin-top: 18px;
|
||
}
|
||
|
||
.arch-panel-extension-form-field {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
|
||
.arch-panel-extension-form-field span {
|
||
color: var(--wb-text-secondary);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.arch-panel-extension-form-field input,
|
||
.arch-panel-extension-form-field select {
|
||
width: 100%;
|
||
min-height: 38px;
|
||
padding: 0 12px;
|
||
border: 1px solid var(--wb-border);
|
||
border-radius: 6px;
|
||
background: var(--wb-panel-soft);
|
||
color: var(--wb-text);
|
||
font: inherit;
|
||
}
|
||
|
||
.arch-panel-extension-form-field .arch-panel-extension-combobox input {
|
||
min-height: 40px;
|
||
padding: 0 38px 0 12px;
|
||
border-radius: 8px;
|
||
background: color-mix(in srgb, var(--wb-panel-soft) 86%, var(--wb-panel));
|
||
color: transparent;
|
||
}
|
||
|
||
.arch-panel-extension-form-field .arch-panel-extension-combobox input:focus {
|
||
border-color: #0b5cab;
|
||
background: var(--wb-panel);
|
||
box-shadow: 0 0 0 3px rgba(11, 92, 171, 0.16);
|
||
color: var(--wb-text);
|
||
outline: none;
|
||
}
|
||
|
||
.arch-panel-extension-form-error {
|
||
margin: 12px 0 0;
|
||
color: #d94f2b;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.arch-panel-extension-sort-button {
|
||
appearance: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
color: inherit;
|
||
font: inherit;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.arch-panel-extension-sort-button.is-numeric {
|
||
justify-content: flex-end;
|
||
width: 100%;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-detail {
|
||
flex: 1 1 auto;
|
||
min-height: 0;
|
||
max-height: calc(100vh - 270px);
|
||
overflow: auto;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-detail .arch-panel-extension-table th {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 3;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-detail .arch-panel-extension-table thead {
|
||
position: relative;
|
||
z-index: 3;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-detail .arch-panel-extension-table {
|
||
border-collapse: separate;
|
||
border-spacing: 0;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-detail .arch-panel-extension-table {
|
||
table-layout: fixed;
|
||
}
|
||
|
||
.arch-panel-extension-table-wrap.is-detail .arch-panel-extension-table th,
|
||
.arch-panel-extension-table-wrap.is-detail .arch-panel-extension-table td {
|
||
white-space: normal;
|
||
vertical-align: top;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.arch-panel-extension-state-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-top: 18px;
|
||
}
|
||
|
||
.arch-panel-extension-state-card.is-error {
|
||
border-color: rgba(217, 79, 43, 0.42);
|
||
}
|
||
|
||
@media (max-width: 1280px) {
|
||
.arch-panel-extension-summary-row,
|
||
.arch-panel-extension-indicator-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.arch-panel-extension-shell-body,
|
||
.arch-panel-extension-content-grid,
|
||
.arch-panel-extension-status-grid,
|
||
.arch-panel-extension-operations-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.arch-panel-extension-sidebar {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 780px) {
|
||
.arch-panel-extension-shell-header,
|
||
.arch-panel-extension-page-hero {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.arch-panel-extension-summary-row,
|
||
.arch-panel-extension-indicator-grid,
|
||
.arch-panel-extension-boolean-grid,
|
||
.arch-panel-extension-sr-hour-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.arch-panel-extension-state-card {
|
||
min-height: 260px;
|
||
padding: 26px 24px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 980px) {
|
||
.arch-panel-extension-action-dialog {
|
||
width: min(720px, calc(100vw - 24px));
|
||
}
|
||
|
||
.arch-panel-extension-form-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
`;
|
||
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
function applyTheme(header, functionsContainer, anchor, mount, overlay) {
|
||
const headerStyle = window.getComputedStyle(header);
|
||
const functionsStyle = window.getComputedStyle(functionsContainer);
|
||
const sourceBackground = resolveBackgroundColor(header, headerStyle);
|
||
const sourceForeground = resolveForegroundColor(
|
||
header,
|
||
functionsContainer,
|
||
anchor,
|
||
headerStyle,
|
||
functionsStyle
|
||
);
|
||
const workbenchHeader = mix(sourceBackground, { r: 75, g: 51, b: 93, a: 1 }, 0.52);
|
||
const workbenchAccent = mix(workbenchHeader, { r: 108, g: 77, b: 128, a: 1 }, 0.38);
|
||
const focusColor = mix(sourceForeground, workbenchHeader, 0.28);
|
||
const themeTargets = [mount, overlay].filter(Boolean);
|
||
|
||
for (const target of themeTargets) {
|
||
target.style.setProperty(
|
||
"--arch-panel-bg",
|
||
toColor(mix(sourceBackground, sourceForeground, 0.12))
|
||
);
|
||
target.style.setProperty(
|
||
"--arch-panel-bg-hover",
|
||
toColor(mix(sourceBackground, sourceForeground, 0.22))
|
||
);
|
||
target.style.setProperty(
|
||
"--arch-panel-border",
|
||
toColor(mix(sourceBackground, sourceForeground, 0.32))
|
||
);
|
||
target.style.setProperty("--arch-panel-fg", toColor(sourceForeground, 1));
|
||
target.style.setProperty("--arch-panel-focus", toColor(focusColor, 0.94));
|
||
target.style.setProperty(
|
||
"--arch-panel-font",
|
||
functionsStyle.fontFamily || headerStyle.fontFamily || "inherit"
|
||
);
|
||
target.style.setProperty("--arch-workbench-header", toColor(workbenchHeader, 1));
|
||
target.style.setProperty("--arch-workbench-accent", toColor(workbenchAccent, 1));
|
||
}
|
||
}
|
||
|
||
function resolveForegroundColor(
|
||
header,
|
||
functionsContainer,
|
||
anchor,
|
||
headerStyle,
|
||
functionsStyle
|
||
) {
|
||
const salesPlanningElement = findTextElement(header, "Sales Planning");
|
||
const salesPlanningColor = salesPlanningElement
|
||
? parseColor(window.getComputedStyle(salesPlanningElement).color)
|
||
: null;
|
||
const functionsItemColor = findVisibleChildColor(functionsContainer);
|
||
const userColor = anchor
|
||
? parseColor(window.getComputedStyle(anchor).color)
|
||
: null;
|
||
|
||
return (
|
||
userColor ||
|
||
functionsItemColor ||
|
||
parseColor(functionsStyle.color) ||
|
||
salesPlanningColor ||
|
||
parseColor(headerStyle.color) || {
|
||
r: 255,
|
||
g: 255,
|
||
b: 255,
|
||
a: 1,
|
||
}
|
||
);
|
||
}
|
||
|
||
function resolveBackgroundColor(element, style) {
|
||
const directBackground = parseColor(style.backgroundColor);
|
||
|
||
if (directBackground && directBackground.a > 0) {
|
||
return directBackground;
|
||
}
|
||
|
||
let current = element.parentElement;
|
||
|
||
while (current) {
|
||
const currentBackground = parseColor(
|
||
window.getComputedStyle(current).backgroundColor
|
||
);
|
||
|
||
if (currentBackground && currentBackground.a > 0) {
|
||
return currentBackground;
|
||
}
|
||
|
||
current = current.parentElement;
|
||
}
|
||
|
||
return { r: 75, g: 51, b: 93, a: 1 };
|
||
}
|
||
|
||
function parseColor(value) {
|
||
if (!value || value === "transparent") {
|
||
return null;
|
||
}
|
||
|
||
const rgbMatch = value.match(
|
||
/rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})(?:[\s,/]+([.\d]+))?\s*\)/i
|
||
);
|
||
|
||
if (rgbMatch) {
|
||
return {
|
||
r: Number.parseInt(rgbMatch[1], 10),
|
||
g: Number.parseInt(rgbMatch[2], 10),
|
||
b: Number.parseInt(rgbMatch[3], 10),
|
||
a: rgbMatch[4] === undefined ? 1 : Number.parseFloat(rgbMatch[4]),
|
||
};
|
||
}
|
||
|
||
const hexMatch = value.match(/^#([\da-f]{3,8})$/i);
|
||
|
||
if (!hexMatch) {
|
||
return null;
|
||
}
|
||
|
||
const hexValue = hexMatch[1];
|
||
|
||
if (hexValue.length === 3 || hexValue.length === 4) {
|
||
const [r, g, b, a = "f"] = hexValue.split("");
|
||
|
||
return {
|
||
r: Number.parseInt(r + r, 16),
|
||
g: Number.parseInt(g + g, 16),
|
||
b: Number.parseInt(b + b, 16),
|
||
a: Number.parseInt(a + a, 16) / 255,
|
||
};
|
||
}
|
||
|
||
if (hexValue.length === 6 || hexValue.length === 8) {
|
||
return {
|
||
r: Number.parseInt(hexValue.slice(0, 2), 16),
|
||
g: Number.parseInt(hexValue.slice(2, 4), 16),
|
||
b: Number.parseInt(hexValue.slice(4, 6), 16),
|
||
a:
|
||
hexValue.length === 8
|
||
? Number.parseInt(hexValue.slice(6, 8), 16) / 255
|
||
: 1,
|
||
};
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function mix(base, tint, amount) {
|
||
const ratio = clamp(amount, 0, 1);
|
||
|
||
return {
|
||
r: Math.round(base.r + (tint.r - base.r) * ratio),
|
||
g: Math.round(base.g + (tint.g - base.g) * ratio),
|
||
b: Math.round(base.b + (tint.b - base.b) * ratio),
|
||
a: base.a + (tint.a - base.a) * ratio,
|
||
};
|
||
}
|
||
|
||
function toColor(color, alphaOverride) {
|
||
const alpha = alphaOverride ?? color.a ?? 1;
|
||
|
||
return `rgba(${color.r}, ${color.g}, ${color.b}, ${clamp(alpha, 0, 1)})`;
|
||
}
|
||
|
||
function clamp(value, min, max) {
|
||
return Math.min(Math.max(value, min), max);
|
||
}
|
||
|
||
function findUserAnchor(container) {
|
||
const selector = [
|
||
`button[label="${USER_BUTTON_LABEL}"]`,
|
||
`[role="button"][label="${USER_BUTTON_LABEL}"]`,
|
||
`oj-button[label="${USER_BUTTON_LABEL}"]`,
|
||
`button[aria-label="${USER_BUTTON_LABEL}"]`,
|
||
`[role="button"][aria-label="${USER_BUTTON_LABEL}"]`,
|
||
`[label="${USER_BUTTON_LABEL}"]`,
|
||
`[aria-label="${USER_BUTTON_LABEL}"]`,
|
||
].join(", ");
|
||
|
||
const directMatch = container.querySelector(selector);
|
||
|
||
if (directMatch && isVisible(directMatch)) {
|
||
return directMatch;
|
||
}
|
||
|
||
const fallbackMatch = Array.from(container.querySelectorAll("*")).find(
|
||
(element) => {
|
||
if (!isVisible(element) || element.closest(`#${IDS.mount}`)) {
|
||
return false;
|
||
}
|
||
|
||
return (
|
||
element.getAttribute("label") === USER_BUTTON_LABEL ||
|
||
element.getAttribute("aria-label") === USER_BUTTON_LABEL
|
||
);
|
||
}
|
||
);
|
||
|
||
return fallbackMatch || null;
|
||
}
|
||
|
||
function findTextElement(container, expectedText) {
|
||
const normalizedExpected = normalizeText(expectedText);
|
||
const elements = Array.from(container.querySelectorAll("*"));
|
||
|
||
return (
|
||
elements.find((element) => {
|
||
const text = normalizeText(element.textContent || "");
|
||
|
||
return text && text.includes(normalizedExpected);
|
||
}) || null
|
||
);
|
||
}
|
||
|
||
function resolveInsertionTarget(container, anchor, mount) {
|
||
const directAnchor = anchor ? getDirectChild(anchor, container) : null;
|
||
const fallbackTarget = findFirstInsertionTarget(container, mount);
|
||
const candidate = directAnchor || fallbackTarget;
|
||
|
||
if (!candidate) {
|
||
return null;
|
||
}
|
||
|
||
if (candidate === mount) {
|
||
return findNextValidSibling(container, mount);
|
||
}
|
||
|
||
return candidate.parentElement === container ? candidate : null;
|
||
}
|
||
|
||
function getDirectChild(element, container) {
|
||
let current = element;
|
||
|
||
while (current && current.parentElement !== container) {
|
||
current = current.parentElement;
|
||
}
|
||
|
||
return current && current.parentElement === container ? current : null;
|
||
}
|
||
|
||
function findFirstInsertionTarget(container, mount) {
|
||
return Array.from(container.children).find((child) => child !== mount) || null;
|
||
}
|
||
|
||
function findNextValidSibling(container, mount) {
|
||
let sibling = mount.nextElementSibling;
|
||
|
||
while (sibling) {
|
||
if (sibling.parentElement === container) {
|
||
return sibling;
|
||
}
|
||
|
||
sibling = sibling.nextElementSibling;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function findVisibleChildColor(container) {
|
||
const visibleChild = Array.from(container.querySelectorAll("*")).find(
|
||
(element) => isVisible(element) && !element.closest(`#${IDS.mount}`)
|
||
);
|
||
|
||
return visibleChild
|
||
? parseColor(window.getComputedStyle(visibleChild).color)
|
||
: null;
|
||
}
|
||
|
||
function extractArray(value, candidateKeys = []) {
|
||
if (Array.isArray(value)) {
|
||
return value;
|
||
}
|
||
|
||
if (!value || typeof value !== "object") {
|
||
return [];
|
||
}
|
||
|
||
for (const key of candidateKeys) {
|
||
if (Array.isArray(value[key])) {
|
||
return value[key];
|
||
}
|
||
}
|
||
|
||
for (const nestedValue of Object.values(value)) {
|
||
if (Array.isArray(nestedValue)) {
|
||
return nestedValue;
|
||
}
|
||
|
||
if (nestedValue && typeof nestedValue === "object") {
|
||
const nestedArray = extractArray(nestedValue, candidateKeys);
|
||
|
||
if (nestedArray.length > 0) {
|
||
return nestedArray;
|
||
}
|
||
}
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
async function mapWithConcurrency(items, concurrency, mapper) {
|
||
const results = new Array(items.length);
|
||
let nextIndex = 0;
|
||
|
||
async function worker() {
|
||
while (nextIndex < items.length) {
|
||
const currentIndex = nextIndex;
|
||
nextIndex += 1;
|
||
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
||
}
|
||
}
|
||
|
||
const workerCount = Math.min(concurrency, items.length || 1);
|
||
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
||
|
||
return results;
|
||
}
|
||
|
||
function getStoredTheme() {
|
||
try {
|
||
const storedTheme = window.localStorage.getItem(STORAGE_KEYS.theme);
|
||
|
||
return storedTheme === "dark" ? "dark" : "light";
|
||
} catch {
|
||
return "light";
|
||
}
|
||
}
|
||
|
||
function setStoredTheme(theme) {
|
||
try {
|
||
window.localStorage.setItem(STORAGE_KEYS.theme, theme);
|
||
} catch {
|
||
// Ignore storage issues and keep the in-memory theme.
|
||
}
|
||
}
|
||
|
||
function getStoredSidebarCollapsed() {
|
||
try {
|
||
return window.localStorage.getItem(STORAGE_KEYS.sidebarCollapsed) === "true";
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function setStoredSidebarCollapsed(value) {
|
||
try {
|
||
window.localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, String(Boolean(value)));
|
||
} catch {
|
||
// Ignore storage issues and keep the in-memory state.
|
||
}
|
||
}
|
||
|
||
function getStoredCalendarView() {
|
||
try {
|
||
return window.localStorage.getItem(STORAGE_KEYS.calendarView) === "week"
|
||
? "week"
|
||
: "month";
|
||
} catch {
|
||
return "month";
|
||
}
|
||
}
|
||
|
||
function setStoredCalendarView(value) {
|
||
try {
|
||
window.localStorage.setItem(
|
||
STORAGE_KEYS.calendarView,
|
||
value === "week" ? "week" : "month"
|
||
);
|
||
} catch {
|
||
// Ignore storage issues and keep the in-memory calendar view.
|
||
}
|
||
}
|
||
|
||
function getStoredTimeManagementSelectedDate() {
|
||
try {
|
||
const storedDate = window.localStorage.getItem(
|
||
STORAGE_KEYS.timeManagementSelectedDate
|
||
);
|
||
|
||
return isValidDateInputValue(storedDate)
|
||
? storedDate
|
||
: getDateInputValue(new Date());
|
||
} catch {
|
||
return getDateInputValue(new Date());
|
||
}
|
||
}
|
||
|
||
function setStoredTimeManagementSelectedDate(value) {
|
||
if (!isValidDateInputValue(value)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
window.localStorage.setItem(
|
||
STORAGE_KEYS.timeManagementSelectedDate,
|
||
value
|
||
);
|
||
} catch {
|
||
// Ignore storage issues and keep the in-memory selected date.
|
||
}
|
||
}
|
||
|
||
function isValidDateInputValue(value) {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || ""))) {
|
||
return false;
|
||
}
|
||
|
||
const parsedDate = parseDatePreservingDateOnly(value);
|
||
|
||
return !Number.isNaN(parsedDate.getTime());
|
||
}
|
||
|
||
function normalizeText(value) {
|
||
return String(value || "").replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
function cleanString(value) {
|
||
return normalizeText(value);
|
||
}
|
||
|
||
function firstNonEmptyString(values) {
|
||
for (const value of values) {
|
||
const normalized = cleanString(value);
|
||
|
||
if (normalized) {
|
||
return normalized;
|
||
}
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
function extractAuthHeaders(tokenPayload) {
|
||
if (!tokenPayload) {
|
||
return {};
|
||
}
|
||
|
||
if (typeof tokenPayload === "string") {
|
||
return {
|
||
authorization: `Bearer ${tokenPayload}`,
|
||
};
|
||
}
|
||
|
||
const tokenSources = [
|
||
tokenPayload,
|
||
tokenPayload?.token,
|
||
tokenPayload?.tokens,
|
||
tokenPayload?.oauth,
|
||
tokenPayload?.oauthToken,
|
||
tokenPayload?.authentication,
|
||
tokenPayload?.auth,
|
||
].filter(Boolean);
|
||
const headers = {};
|
||
const explicitHeaderSources = [
|
||
tokenPayload?.headers,
|
||
tokenPayload?.requestHeaders,
|
||
tokenPayload?.authHeaders,
|
||
].filter((value) => value && typeof value === "object");
|
||
|
||
for (const explicitHeaderSource of explicitHeaderSources) {
|
||
for (const [key, value] of Object.entries(explicitHeaderSource)) {
|
||
const normalizedKey = cleanString(key).toLowerCase();
|
||
const normalizedValue = cleanString(value);
|
||
|
||
if (!normalizedKey || !normalizedValue) {
|
||
continue;
|
||
}
|
||
|
||
headers[normalizedKey] = normalizedValue;
|
||
}
|
||
}
|
||
|
||
for (const tokenSource of tokenSources) {
|
||
const tokenType =
|
||
firstNonEmptyString([
|
||
tokenSource?.tokenType,
|
||
tokenSource?.type,
|
||
tokenSource?.token_type,
|
||
]) || "Bearer";
|
||
const accessToken = firstNonEmptyString([
|
||
tokenSource?.accessToken,
|
||
tokenSource?.access_token,
|
||
tokenSource?.token,
|
||
tokenSource?.jwt,
|
||
]);
|
||
const idToken = firstNonEmptyString([
|
||
tokenSource?.idToken,
|
||
tokenSource?.id_token,
|
||
]);
|
||
|
||
if (accessToken && !headers.authorization) {
|
||
headers.authorization = `${tokenType} ${accessToken}`;
|
||
}
|
||
|
||
if (idToken && !headers["x-id-token"]) {
|
||
headers["x-id-token"] = idToken;
|
||
}
|
||
}
|
||
|
||
return headers;
|
||
}
|
||
|
||
function normalizeString(value) {
|
||
return normalizeText(value).toUpperCase();
|
||
}
|
||
|
||
function toBridgeRequestUrl(url) {
|
||
const normalized = resolveRequestUrl(url);
|
||
|
||
if (!normalized) {
|
||
return normalized;
|
||
}
|
||
|
||
try {
|
||
const parsedUrl = new URL(normalized, window.location.origin);
|
||
|
||
if (parsedUrl.origin === window.location.origin) {
|
||
return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
|
||
}
|
||
|
||
return parsedUrl.toString();
|
||
} catch {
|
||
return normalized;
|
||
}
|
||
}
|
||
|
||
function resolveRequestUrl(url) {
|
||
const normalized = cleanString(url);
|
||
|
||
if (!normalized) {
|
||
return "";
|
||
}
|
||
|
||
try {
|
||
const baseOrigin =
|
||
isHcmWelcomePage() && normalized.startsWith("/")
|
||
? TARGET_ORIGIN
|
||
: window.location.origin;
|
||
|
||
return new URL(normalized, baseOrigin).toString();
|
||
} catch {
|
||
return normalized;
|
||
}
|
||
}
|
||
|
||
function toNumber(value) {
|
||
if (typeof value === "number" && Number.isFinite(value)) {
|
||
return value;
|
||
}
|
||
|
||
const numeric = Number.parseFloat(String(value ?? "").replace(/,/g, ""));
|
||
return Number.isFinite(numeric) ? numeric : 0;
|
||
}
|
||
|
||
function toIsoDateString(value) {
|
||
const normalized = cleanString(value);
|
||
|
||
if (!normalized) {
|
||
return "";
|
||
}
|
||
|
||
const date = new Date(`${normalized}T12:00:00.000Z`);
|
||
|
||
return Number.isNaN(date.getTime()) ? normalized : date.toISOString();
|
||
}
|
||
|
||
function isVisible(element) {
|
||
const style = window.getComputedStyle(element);
|
||
const rect = element.getBoundingClientRect();
|
||
|
||
return (
|
||
style.display !== "none" &&
|
||
style.visibility !== "hidden" &&
|
||
Number.parseFloat(style.opacity || "1") > 0 &&
|
||
rect.width > 0 &&
|
||
rect.height > 0
|
||
);
|
||
}
|
||
|
||
function formatWholeNumber(value) {
|
||
return new Intl.NumberFormat("en-US", {
|
||
maximumFractionDigits: 0,
|
||
}).format(value);
|
||
}
|
||
|
||
function formatCurrency(value) {
|
||
return new Intl.NumberFormat("en-US", {
|
||
style: "currency",
|
||
currency: "USD",
|
||
maximumFractionDigits: 0,
|
||
}).format(value || 0);
|
||
}
|
||
|
||
function formatHours(value) {
|
||
return new Intl.NumberFormat("pt-BR", {
|
||
maximumFractionDigits: 2,
|
||
}).format(toNumber(value));
|
||
}
|
||
|
||
function formatWeekNumber(value) {
|
||
return String(toNumber(value)).padStart(2, "0");
|
||
}
|
||
|
||
function formatOperationalWeekLabel(weekInfo) {
|
||
if (!weekInfo) {
|
||
return "";
|
||
}
|
||
|
||
return `FY${formatWeekNumber(weekInfo.fiscalYear % 100)}-Q${
|
||
weekInfo.quarter
|
||
}-W${formatWeekNumber(weekInfo.weekNumber)}`;
|
||
}
|
||
|
||
function formatPercent(value) {
|
||
return new Intl.NumberFormat("en-US", {
|
||
maximumFractionDigits: 1,
|
||
}).format(value || 0) + "%";
|
||
}
|
||
|
||
function formatProgressPercent(value) {
|
||
return new Intl.NumberFormat("en-US", {
|
||
maximumFractionDigits: 0,
|
||
}).format(value || 0) + "%";
|
||
}
|
||
|
||
function formatRelativeMoment(value) {
|
||
if (!value) {
|
||
return "-";
|
||
}
|
||
|
||
const target = new Date(value);
|
||
|
||
if (Number.isNaN(target.getTime())) {
|
||
return String(value);
|
||
}
|
||
|
||
let remaining = Math.max(Date.now() - target.getTime(), 0);
|
||
const day = 24 * 60 * 60 * 1000;
|
||
const hour = 60 * 60 * 1000;
|
||
const minute = 60 * 1000;
|
||
const second = 1000;
|
||
const days = Math.floor(remaining / day);
|
||
remaining -= days * day;
|
||
const hours = Math.floor(remaining / hour);
|
||
remaining -= hours * hour;
|
||
const minutes = Math.floor(remaining / minute);
|
||
remaining -= minutes * minute;
|
||
const seconds = Math.floor(remaining / second);
|
||
|
||
return `${days}d ${hours}h ${minutes}m ${seconds}s`;
|
||
}
|
||
|
||
function formatDate(value) {
|
||
if (!value) {
|
||
return "";
|
||
}
|
||
|
||
const date = parseDatePreservingDateOnly(value);
|
||
|
||
if (Number.isNaN(date.getTime())) {
|
||
return String(value);
|
||
}
|
||
|
||
return new Intl.DateTimeFormat("en-US", {
|
||
dateStyle: "medium",
|
||
}).format(date);
|
||
}
|
||
|
||
function getDateInputValue(value) {
|
||
const date = parseDatePreservingDateOnly(value);
|
||
|
||
if (Number.isNaN(date.getTime())) {
|
||
return "";
|
||
}
|
||
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||
const day = String(date.getDate()).padStart(2, "0");
|
||
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
function formatDateInputDisplay(value) {
|
||
const date = parseDatePreservingDateOnly(value);
|
||
|
||
if (Number.isNaN(date.getTime())) {
|
||
return cleanString(value);
|
||
}
|
||
|
||
const day = String(date.getDate()).padStart(2, "0");
|
||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||
const year = date.getFullYear();
|
||
|
||
return `${day}/${month}/${year}`;
|
||
}
|
||
|
||
function formatDateForTimeEntryRequest(value) {
|
||
const date = parseDatePreservingDateOnly(value);
|
||
|
||
if (Number.isNaN(date.getTime())) {
|
||
return "";
|
||
}
|
||
|
||
const day = String(date.getDate()).padStart(2, "0");
|
||
const month = new Intl.DateTimeFormat("en-US", { month: "short" })
|
||
.format(date)
|
||
.toUpperCase();
|
||
const year = date.getFullYear();
|
||
|
||
return `${day}-${month}-${year}`;
|
||
}
|
||
|
||
function parseTimeEntryDate(value) {
|
||
const text = cleanString(value);
|
||
const match = text.match(/^(\d{1,2})-([A-Z]{3})-(\d{4})$/i);
|
||
|
||
if (!match) {
|
||
return parseDatePreservingDateOnly(text);
|
||
}
|
||
|
||
const monthIndex = [
|
||
"JAN",
|
||
"FEB",
|
||
"MAR",
|
||
"APR",
|
||
"MAY",
|
||
"JUN",
|
||
"JUL",
|
||
"AUG",
|
||
"SEP",
|
||
"OCT",
|
||
"NOV",
|
||
"DEC",
|
||
].indexOf(match[2].toUpperCase());
|
||
|
||
if (monthIndex < 0) {
|
||
return new Date(Number.NaN);
|
||
}
|
||
|
||
return new Date(Number(match[3]), monthIndex, Number(match[1]));
|
||
}
|
||
|
||
function formatDateTime(value) {
|
||
if (!value) {
|
||
return "";
|
||
}
|
||
|
||
const date = new Date(value);
|
||
|
||
if (Number.isNaN(date.getTime())) {
|
||
return String(value);
|
||
}
|
||
|
||
return new Intl.DateTimeFormat("en-US", {
|
||
dateStyle: "medium",
|
||
timeStyle: "short",
|
||
}).format(date);
|
||
}
|
||
|
||
function getLocalDateKey(value) {
|
||
if (!value) {
|
||
return "";
|
||
}
|
||
|
||
const date = parseDatePreservingDateOnly(value);
|
||
|
||
if (Number.isNaN(date.getTime())) {
|
||
return "";
|
||
}
|
||
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||
const day = String(date.getDate()).padStart(2, "0");
|
||
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
function getOperationalWeekInfo(value) {
|
||
const date = parseDatePreservingDateOnly(value);
|
||
|
||
if (Number.isNaN(date.getTime())) {
|
||
return null;
|
||
}
|
||
|
||
const operationalYearStart = new Date(date.getFullYear(), 5, 1);
|
||
|
||
if (date < operationalYearStart) {
|
||
operationalYearStart.setFullYear(operationalYearStart.getFullYear() - 1);
|
||
}
|
||
|
||
const normalizedDate = new Date(
|
||
date.getFullYear(),
|
||
date.getMonth(),
|
||
date.getDate()
|
||
);
|
||
const normalizedStart = new Date(
|
||
operationalYearStart.getFullYear(),
|
||
operationalYearStart.getMonth(),
|
||
operationalYearStart.getDate()
|
||
);
|
||
const daysSinceStart = Math.floor(
|
||
(normalizedDate.getTime() - normalizedStart.getTime()) / 86400000
|
||
);
|
||
const monthsSinceStart =
|
||
(normalizedDate.getFullYear() - normalizedStart.getFullYear()) * 12 +
|
||
normalizedDate.getMonth() -
|
||
normalizedStart.getMonth();
|
||
|
||
return {
|
||
fiscalYear: operationalYearStart.getFullYear() + 1,
|
||
quarter: Math.floor(monthsSinceStart / 3) + 1,
|
||
weekNumber: Math.floor(daysSinceStart / 7) + 1,
|
||
};
|
||
}
|
||
|
||
function getCalendarRowWeekDate(rowStartDate) {
|
||
const weekDate = new Date(rowStartDate);
|
||
|
||
// The calendar renders rows from Sunday, but ISO week numbering starts on Monday.
|
||
if (weekDate.getDay() === 0) {
|
||
weekDate.setDate(weekDate.getDate() + 1);
|
||
}
|
||
|
||
return weekDate;
|
||
}
|
||
|
||
function normalizeTimeEntriesSummary(payload) {
|
||
if (Array.isArray(payload)) {
|
||
return payload;
|
||
}
|
||
|
||
const items = extractArray(payload, [
|
||
"items",
|
||
"data",
|
||
"results",
|
||
"content",
|
||
"entries",
|
||
"timeEntries",
|
||
"summary",
|
||
"summaries",
|
||
]);
|
||
|
||
if (items.length) {
|
||
return items;
|
||
}
|
||
|
||
if (payload && typeof payload === "object") {
|
||
return [payload];
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
function groupTimeEntriesByResourceName(items) {
|
||
const groups = new Map();
|
||
|
||
for (const item of items) {
|
||
const resourceName =
|
||
getTimeEntryResourceName(item) || "Sem resourceName";
|
||
const group = groups.get(resourceName) || {
|
||
resourceName,
|
||
items: [],
|
||
};
|
||
|
||
group.items.push(item);
|
||
groups.set(resourceName, group);
|
||
}
|
||
|
||
return Array.from(groups.values()).sort((left, right) => {
|
||
return left.resourceName.localeCompare(right.resourceName);
|
||
});
|
||
}
|
||
|
||
function getTimeEntryResourceName(item) {
|
||
const directValue = getFieldValue(item, [
|
||
"resourceName",
|
||
"ResourceName",
|
||
"resource_name",
|
||
"resource.name",
|
||
"Resource.Name",
|
||
"resource.displayName",
|
||
"Resource.DisplayName",
|
||
"resource.display_name",
|
||
"employeeName",
|
||
"EmployeeName",
|
||
"personName",
|
||
"PersonName",
|
||
]);
|
||
|
||
if (directValue) {
|
||
return cleanString(directValue);
|
||
}
|
||
|
||
const flattenedMatch = flattenDisplayRecord(item).find(([key, value]) => {
|
||
return key.toLowerCase().endsWith("resourcename") && cleanString(value);
|
||
});
|
||
|
||
return flattenedMatch ? cleanString(flattenedMatch[1]) : "";
|
||
}
|
||
|
||
function getTimeEntrySrNumber(item) {
|
||
return cleanString(
|
||
getTimeEntryField(item, [
|
||
"srNumber",
|
||
"SrNumber",
|
||
"SRNumber",
|
||
"serviceRequestNumber",
|
||
"ServiceRequestNumber",
|
||
"serviceRequest.srNumber",
|
||
"ServiceRequest.SrNumber",
|
||
])
|
||
);
|
||
}
|
||
|
||
function getTimeEntryId(item) {
|
||
return cleanString(
|
||
getTimeEntryField(item, [
|
||
"id",
|
||
"Id",
|
||
"ID",
|
||
"timeEntryId",
|
||
"TimeEntryId",
|
||
"timeEntryID",
|
||
"TimeEntryID",
|
||
])
|
||
);
|
||
}
|
||
|
||
function getTimeEntryActivity(item) {
|
||
return cleanString(
|
||
getTimeEntryField(item, [
|
||
"activityCode",
|
||
"ActivityCode",
|
||
"activity.code",
|
||
"Activity.Code",
|
||
])
|
||
);
|
||
}
|
||
|
||
function getTimeEntryTaskType(item) {
|
||
return cleanString(
|
||
getTimeEntryField(item, [
|
||
"TaskType",
|
||
"taskType",
|
||
"task_type",
|
||
"task.type",
|
||
"Task.Type",
|
||
])
|
||
);
|
||
}
|
||
|
||
function getTimeEntryTimeSpent(item) {
|
||
return toNumber(
|
||
getTimeEntryField(item, [
|
||
"timeSpent",
|
||
"TimeSpent",
|
||
"time_spent",
|
||
"time.spent",
|
||
"Time.Spent",
|
||
])
|
||
);
|
||
}
|
||
|
||
function getTimeEntryDateKey(item, weekDays = []) {
|
||
const rawDate = cleanString(
|
||
getTimeEntryField(item, [
|
||
"Date",
|
||
"date",
|
||
"dateValue",
|
||
"DateValue",
|
||
"timeEntryDate",
|
||
"TimeEntryDate",
|
||
"entryDate",
|
||
"EntryDate",
|
||
])
|
||
);
|
||
|
||
if (!rawDate) {
|
||
return weekDays.length === 1 ? getTimeManagementDayKey(weekDays[0]) : "";
|
||
}
|
||
|
||
const parsedDate = parseTimeEntryDate(rawDate);
|
||
|
||
if (!Number.isNaN(parsedDate.getTime())) {
|
||
return getLocalDateKey(parsedDate);
|
||
}
|
||
|
||
const matchingDay = (weekDays || []).find((day) => {
|
||
return normalizeString(day?.date) === normalizeString(rawDate);
|
||
});
|
||
|
||
return matchingDay ? getTimeManagementDayKey(matchingDay) : "";
|
||
}
|
||
|
||
function getTimeEntryField(item, keys) {
|
||
const directValue = getFieldValue(item, keys);
|
||
|
||
if (directValue !== undefined && directValue !== null && directValue !== "") {
|
||
return directValue;
|
||
}
|
||
|
||
const normalizedKeys = keys.map((key) => key.toLowerCase().replace(/[^a-z0-9]/g, ""));
|
||
const flattenedMatch = flattenDisplayRecord(item).find(([key, value]) => {
|
||
const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||
|
||
return normalizedKeys.includes(normalizedKey) && cleanString(value);
|
||
});
|
||
|
||
return flattenedMatch ? flattenedMatch[1] : "";
|
||
}
|
||
|
||
function flattenDisplayRecord(value, prefix = "", output = []) {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||
if (prefix) {
|
||
output.push([prefix, value]);
|
||
}
|
||
|
||
return output;
|
||
}
|
||
|
||
for (const [key, nestedValue] of Object.entries(value)) {
|
||
const nextKey = prefix ? `${prefix}.${key}` : key;
|
||
|
||
if (
|
||
nestedValue &&
|
||
typeof nestedValue === "object" &&
|
||
!Array.isArray(nestedValue)
|
||
) {
|
||
flattenDisplayRecord(nestedValue, nextKey, output);
|
||
} else {
|
||
output.push([nextKey, nestedValue]);
|
||
}
|
||
}
|
||
|
||
return output;
|
||
}
|
||
|
||
function formatDisplayValue(value) {
|
||
if (value === null || value === undefined || value === "") {
|
||
return "-";
|
||
}
|
||
|
||
if (Array.isArray(value) || typeof value === "object") {
|
||
return JSON.stringify(value);
|
||
}
|
||
|
||
return String(value);
|
||
}
|
||
|
||
function parseDatePreservingDateOnly(value) {
|
||
if (value instanceof Date) {
|
||
return value;
|
||
}
|
||
|
||
if (typeof value === "string") {
|
||
const dateOnlyMatch = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
|
||
if (dateOnlyMatch) {
|
||
return new Date(
|
||
Number(dateOnlyMatch[1]),
|
||
Number(dateOnlyMatch[2]) - 1,
|
||
Number(dateOnlyMatch[3])
|
||
);
|
||
}
|
||
}
|
||
|
||
return new Date(value);
|
||
}
|
||
|
||
function getErrorMessage(error) {
|
||
if (error instanceof Error) {
|
||
return error.message;
|
||
}
|
||
|
||
return "Unexpected error while fetching live data.";
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function cssEscape(value) {
|
||
if (window.CSS?.escape) {
|
||
return window.CSS.escape(String(value || ""));
|
||
}
|
||
|
||
return String(value || "").replace(/["\\]/g, "\\$&");
|
||
}
|
||
|
||
function lockScroll() {
|
||
document.documentElement.classList.add(CLASSES.modalOpen);
|
||
document.body.classList.add(CLASSES.modalOpen);
|
||
}
|
||
|
||
function unlockScroll() {
|
||
document.documentElement.classList.remove(CLASSES.modalOpen);
|
||
document.body.classList.remove(CLASSES.modalOpen);
|
||
}
|
||
|
||
function removeModal() {
|
||
const overlay = document.getElementById(IDS.overlay);
|
||
|
||
window.clearTimeout(closeTimer);
|
||
restoreFocusTarget = null;
|
||
|
||
if (overlay) {
|
||
overlay.remove();
|
||
}
|
||
}
|
||
|
||
function handleGlobalKeydown(event) {
|
||
if (event.key !== "Escape") {
|
||
return;
|
||
}
|
||
|
||
if (appState.actionFormModal) {
|
||
appState.actionFormModal = null;
|
||
renderModal();
|
||
return;
|
||
}
|
||
|
||
if (appState.timeManagementModal) {
|
||
appState.timeManagementModal = null;
|
||
renderModal();
|
||
return;
|
||
}
|
||
|
||
if (appState.forecastUpdateConfirmModal) {
|
||
appState.forecastUpdateConfirmModal = null;
|
||
renderModal();
|
||
return;
|
||
}
|
||
|
||
if (appState.rampComparisonModal) {
|
||
appState.rampComparisonModal = null;
|
||
renderModal();
|
||
return;
|
||
}
|
||
|
||
if (appState.timeEntriesDrawer) {
|
||
appState.timeEntriesDrawer = null;
|
||
renderModal();
|
||
return;
|
||
}
|
||
|
||
if (appState.detailModal) {
|
||
closeDetailModal();
|
||
renderModal();
|
||
return;
|
||
}
|
||
|
||
closeModal();
|
||
}
|
||
|
||
function handleBridgeMessage(event) {
|
||
const message = event.data;
|
||
|
||
if (!message || message.source !== BRIDGE.source) {
|
||
return;
|
||
}
|
||
|
||
if (message.type === BRIDGE.workbenchAuthHeadersType) {
|
||
rememberWorkbenchAuthHeaders(message.headers);
|
||
return;
|
||
}
|
||
|
||
if (event.source !== window) {
|
||
return;
|
||
}
|
||
|
||
if (message.type === BRIDGE.readyType) {
|
||
return;
|
||
}
|
||
|
||
if (
|
||
message.type !== BRIDGE.resultType &&
|
||
message.type !== BRIDGE.headersResultType
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const pending = pendingBridgeRequests.get(message.requestId);
|
||
|
||
if (!pending) {
|
||
return;
|
||
}
|
||
|
||
pendingBridgeRequests.delete(message.requestId);
|
||
window.clearTimeout(pending.timeout);
|
||
pending.resolve(
|
||
message.type === BRIDGE.headersResultType ? message.headers || {} : message
|
||
);
|
||
}
|
||
|
||
function rememberWorkbenchAuthHeaders(headers) {
|
||
if (!headers || typeof headers !== "object") {
|
||
return;
|
||
}
|
||
|
||
const nextHeaders = {
|
||
...(appState.sessionAuthHeaders || {}),
|
||
};
|
||
|
||
for (const [key, value] of Object.entries(headers)) {
|
||
const normalizedKey = cleanString(key).toLowerCase();
|
||
const normalizedValue = cleanString(value);
|
||
|
||
if (!normalizedKey || !normalizedValue) {
|
||
continue;
|
||
}
|
||
|
||
nextHeaders[normalizedKey] = normalizedValue;
|
||
}
|
||
|
||
appState.sessionAuthHeaders = nextHeaders;
|
||
}
|
||
|
||
function observePage() {
|
||
const observer = new MutationObserver(() => {
|
||
scheduleSync();
|
||
});
|
||
|
||
observer.observe(document.documentElement, {
|
||
childList: true,
|
||
subtree: true,
|
||
attributes: true,
|
||
attributeFilter: ["src"],
|
||
});
|
||
}
|
||
|
||
function observeHistory() {
|
||
const { pushState, replaceState } = window.history;
|
||
|
||
window.history.pushState = function pushStatePatched(...args) {
|
||
const result = pushState.apply(this, args);
|
||
scheduleSync();
|
||
return result;
|
||
};
|
||
|
||
window.history.replaceState = function replaceStatePatched(...args) {
|
||
const result = replaceState.apply(this, args);
|
||
scheduleSync();
|
||
return result;
|
||
};
|
||
|
||
window.addEventListener("popstate", scheduleSync);
|
||
window.addEventListener("hashchange", scheduleSync);
|
||
}
|
||
|
||
function observeWorklistPageLoadToken() {
|
||
if (!isHcmWelcomePage()) {
|
||
return;
|
||
}
|
||
|
||
const syncLoadedToken = () => {
|
||
syncWorklistIframeToken({ force: true });
|
||
};
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", syncLoadedToken, { once: true });
|
||
} else {
|
||
syncLoadedToken();
|
||
}
|
||
|
||
window.addEventListener("load", syncLoadedToken);
|
||
window.addEventListener("pageshow", syncLoadedToken);
|
||
}
|
||
|
||
function observeSpaFrameLifecycle() {
|
||
if (!isHcmWelcomePage()) {
|
||
return;
|
||
}
|
||
|
||
const syncFrame = () => {
|
||
ensureSpaFrame();
|
||
};
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", syncFrame, { once: true });
|
||
} else {
|
||
syncFrame();
|
||
}
|
||
|
||
window.addEventListener("load", syncFrame);
|
||
window.addEventListener("pageshow", syncFrame);
|
||
window.setInterval(syncFrame, 5000);
|
||
}
|
||
|
||
window.addEventListener("message", handleBridgeMessage);
|
||
document.addEventListener("keydown", handleGlobalKeydown);
|
||
void ensurePageBridge();
|
||
observePage();
|
||
observeHistory();
|
||
observeWorklistPageLoadToken();
|
||
observeSpaFrameLifecycle();
|
||
scheduleSync();
|
||
})();
|