Reestruturação dos arquivos e pastas do código da extensão
This commit is contained in:
653
src/content/export-html.js
Normal file
653
src/content/export-html.js
Normal file
@@ -0,0 +1,653 @@
|
||||
(() => {
|
||||
function escapeJsonForInlineScript(json) {
|
||||
return String(json || "")
|
||||
.replace(/</g, "\\u003c")
|
||||
.replace(/>/g, "\\u003e")
|
||||
.replace(/&/g, "\\u0026")
|
||||
.replace(/\u2028/g, "\\u2028")
|
||||
.replace(/\u2029/g, "\\u2029");
|
||||
}
|
||||
|
||||
function createExportHtml(dataset) {
|
||||
const json = escapeJsonForInlineScript(JSON.stringify(dataset.exportPayload));
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Arch Panel Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f141b;
|
||||
--panel: #171e27;
|
||||
--panel-strong: #202a36;
|
||||
--border: #33404f;
|
||||
--text: #f4f7fb;
|
||||
--text-muted: #a8b4c2;
|
||||
--accent: #7dc6e7;
|
||||
--accent-soft: rgba(125, 198, 231, 0.14);
|
||||
--success: #61bf81;
|
||||
--warning: #ef8d67;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: linear-gradient(180deg, #0d1218, #121923 45%, #10161e 100%);
|
||||
color: var(--text);
|
||||
font: 14px/1.45 "Segoe UI", Arial, sans-serif;
|
||||
}
|
||||
.shell {
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
}
|
||||
.hero, .panel {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
border-radius: 18px;
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
h1 { font-size: 34px; line-height: 1.1; }
|
||||
.subtitle { margin-top: 12px; color: var(--text-muted); max-width: 720px; }
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
font-weight: 600;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.metrics { grid-template-columns: repeat(4, minmax(0,1fr)); margin-bottom: 18px; }
|
||||
.metric, .boolean-card {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
}
|
||||
.metric-label { color: var(--text-muted); font-size: 12px; }
|
||||
.metric-value { margin-top: 8px; font-size: 28px; font-weight: 700; }
|
||||
.metric-detail { margin-top: 6px; color: var(--text-muted); font-size: 12px; }
|
||||
.indicator-grid { grid-template-columns: repeat(4, minmax(0,1fr)); margin-bottom: 18px; }
|
||||
.status-grid { grid-template-columns: repeat(2, minmax(0,1fr)); margin-bottom: 18px; }
|
||||
.boolean-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; margin-top: 14px; }
|
||||
.progress { display: grid; gap: 8px; margin-bottom: 12px; }
|
||||
.progress-track {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.progress-segment { height: 100%; }
|
||||
.progress-segment.is-success { background: linear-gradient(90deg, #7fd29a, #3fa35c); }
|
||||
.progress-segment.is-danger { background: linear-gradient(90deg, #f2a28d, #d94f2b); }
|
||||
.progress-labels {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.boolean-card {
|
||||
appearance: none;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.boolean-card strong { display: block; margin-top: 8px; font-size: 28px; }
|
||||
.layout { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }
|
||||
.panel { padding: 18px; }
|
||||
.panel-head { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
|
||||
.panel-title { margin-top: 6px; font-size: 18px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
.main-table { table-layout: fixed; }
|
||||
.main-table col.col-opportunity { width: 14%; }
|
||||
.main-table col.col-customer { width: 16%; }
|
||||
.main-table col.col-workload { width: auto; }
|
||||
.main-table col.col-acr { width: 11%; }
|
||||
.main-table col.col-type { width: 11%; }
|
||||
.main-table col.col-flag { width: 9%; }
|
||||
th, td { padding: 12px 14px; border-bottom: 1px solid var(--border); font-size: 12px; white-space: nowrap; }
|
||||
th { color: var(--text-muted); text-align: left; background: var(--panel-strong); }
|
||||
td { color: var(--text); }
|
||||
td.is-numeric, th.is-numeric { text-align: right; }
|
||||
.main-table th:nth-child(3),
|
||||
.main-table td:nth-child(3) {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.main-table td:nth-child(3) a {
|
||||
display: block;
|
||||
}
|
||||
.main-table th:nth-child(2),
|
||||
.main-table td:nth-child(2) {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
tr:hover td { background: rgba(255,255,255,.02); }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.status.true { color: #d9f9e4; background: rgba(97,191,129,.2); }
|
||||
.status.false { color: #ffd8cc; background: rgba(239,141,103,.18); }
|
||||
.row-stack { display: grid; gap: 12px; }
|
||||
.row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.row:first-child { border-top: 0; padding-top: 0; }
|
||||
.row-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.row-label { font-weight: 700; }
|
||||
.row-sub { color: var(--text-muted); font-size: 12px; }
|
||||
.bar { position: relative; height: 8px; border-radius: 999px; overflow: hidden; background: rgba(255,255,255,.08); }
|
||||
.bar > span { position: absolute; inset: 0 auto 0 0; border-radius: inherit; background: linear-gradient(90deg, var(--accent), #4b8ce2); }
|
||||
.sr-table td { white-space: normal; vertical-align: top; }
|
||||
.sr-main { width: 78%; }
|
||||
.sr-side { width: 22%; text-align: right; }
|
||||
.sr-line { display: flex; flex-wrap: wrap; gap: 10px 14px; margin-top: 6px; }
|
||||
.sr-line.head { margin-top: 0; }
|
||||
.sr-customer { color: var(--text); font-size: 13px; font-weight: 700; }
|
||||
.sr-moment { color: var(--text); font-size: 18px; font-weight: 700; }
|
||||
.sr-exact, .sr-hours { margin-top: 6px; color: var(--text-muted); font-size: 12px; }
|
||||
.empty {
|
||||
padding: 18px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(8, 12, 18, .78);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.overlay.open { display: flex; }
|
||||
.detail {
|
||||
width: min(1480px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
}
|
||||
.detail-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.close {
|
||||
appearance: none;
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-strong);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
background: linear-gradient(180deg, rgba(23, 30, 39, 0), var(--panel) 35%);
|
||||
}
|
||||
.detail table { table-layout: fixed; }
|
||||
.detail th, .detail td {
|
||||
white-space: normal;
|
||||
vertical-align: top;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.sort-button {
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sort-button.is-numeric {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
.clickable {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.metrics, .indicator-grid, .status-grid, .layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="detailOverlay" class="overlay"></div>
|
||||
<script>
|
||||
const payload = ${json};
|
||||
const allowedTypes = ["SQL", "Pipeline", "Upside", "Forecast"];
|
||||
const detailSortKeys = {
|
||||
opportunityNumber: "opportunityNumber",
|
||||
customerName: "customerName",
|
||||
workload: "workload",
|
||||
adjustedACR: "adjustedACR",
|
||||
opportunityForecastTypeGroup: "opportunityForecastTypeGroup",
|
||||
hasSR: "hasSR",
|
||||
hasAction: "hasAction"
|
||||
};
|
||||
let detailState = null;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function normalizeForecastType(value) {
|
||||
const text = String(value || "").trim();
|
||||
return text ? text.split(" -")[0].trim() || "Unknown" : "Unknown";
|
||||
}
|
||||
|
||||
function formatWholeNumber(value) {
|
||||
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value);
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0
|
||||
}).format(value || 0);
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 1
|
||||
}).format(value || 0) + "%";
|
||||
}
|
||||
|
||||
function formatRelativeMoment(value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const target = new Date(value);
|
||||
|
||||
if (Number.isNaN(target.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
let remaining = Math.max(Date.now() - target.getTime(), 0);
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
const hour = 60 * 60 * 1000;
|
||||
const minute = 60 * 1000;
|
||||
const second = 1000;
|
||||
const days = Math.floor(remaining / day);
|
||||
remaining -= days * day;
|
||||
const hours = Math.floor(remaining / hour);
|
||||
remaining -= hours * hour;
|
||||
const minutes = Math.floor(remaining / minute);
|
||||
remaining -= minutes * minute;
|
||||
const seconds = Math.floor(remaining / second);
|
||||
|
||||
return days + "d " + hours + "h " + minutes + "m " + seconds + "s";
|
||||
}
|
||||
|
||||
function flattenWorkloads() {
|
||||
return (payload.customers || []).flatMap((customer) =>
|
||||
(customer.workloads || []).map((workload) => ({
|
||||
...workload,
|
||||
customerName: customer.name
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
function buildSummary(workloads) {
|
||||
const eligible = workloads
|
||||
.map((workload) => ({
|
||||
...workload,
|
||||
opportunityForecastTypeGroup: normalizeForecastType(workload.opportunityForecastTypeGroup || workload.opportunityForecastType)
|
||||
}))
|
||||
.filter((workload) => allowedTypes.includes(workload.opportunityForecastTypeGroup))
|
||||
.sort((a, b) => (b.adjustedACR || 0) - (a.adjustedACR || 0));
|
||||
|
||||
return {
|
||||
eligible,
|
||||
stageIndicators: allowedTypes.map((label) => {
|
||||
const items = eligible.filter((workload) => workload.opportunityForecastTypeGroup === label);
|
||||
return {
|
||||
label,
|
||||
count: items.length,
|
||||
totalAcr: items.reduce((sum, item) => sum + (item.adjustedACR || 0), 0)
|
||||
};
|
||||
}),
|
||||
sr: {
|
||||
trueCount: eligible.filter((item) => item.hasSR).length,
|
||||
falseCount: eligible.filter((item) => !item.hasSR).length
|
||||
},
|
||||
action: {
|
||||
trueCount: eligible.filter((item) => item.hasAction).length,
|
||||
falseCount: eligible.filter((item) => !item.hasAction).length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function opportunityLink(workload) {
|
||||
if (!workload.opportunityId || !workload.opportunityNumber) {
|
||||
return escapeHtml(workload.opportunityNumber || "Opportunity");
|
||||
}
|
||||
|
||||
const href = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?id=" +
|
||||
encodeURIComponent(workload.opportunityId) +
|
||||
"&puid=" +
|
||||
encodeURIComponent(workload.opportunityNumber);
|
||||
|
||||
return '<a href="' + href + '" target="_blank" rel="noreferrer">' + escapeHtml(workload.opportunityNumber) + '</a>';
|
||||
}
|
||||
|
||||
function workloadLink(workload) {
|
||||
const label = (workload.name || "Unnamed workload") + (workload.description ? " | " + workload.description : "");
|
||||
|
||||
if (!workload.workloadId) {
|
||||
return escapeHtml(label);
|
||||
}
|
||||
|
||||
const href = "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html?ojr=workload_workbench/workload_details;workloadId=" +
|
||||
encodeURIComponent(workload.workloadId);
|
||||
|
||||
return '<a href="' + href + '" target="_blank" rel="noreferrer">' + escapeHtml(label) + '</a>';
|
||||
}
|
||||
|
||||
function statusToken(value) {
|
||||
const label = value === "true" ? "Sim" : "Não";
|
||||
return '<span class="status ' + value + '">' + label + '</span>';
|
||||
}
|
||||
|
||||
function renderTable(items) {
|
||||
return '<table class="main-table"><colgroup><col class="col-opportunity"><col class="col-customer"><col class="col-workload"><col class="col-acr"><col class="col-type"><col class="col-flag"><col class="col-flag"></colgroup><thead><tr><th>Opportunity</th><th>Customer</th><th>Workload</th><th class="is-numeric">ACR</th><th>Type</th><th>Tem SR?</th><th>Tem Action?</th></tr></thead><tbody>' +
|
||||
items.map((workload) => '<tr><td>' + opportunityLink(workload) + '</td><td>' + escapeHtml(workload.customerName || '') + '</td><td>' + workloadLink(workload) + '</td><td class="is-numeric">' + formatCurrency(workload.adjustedACR) + '</td><td>' + escapeHtml(workload.opportunityForecastTypeGroup) + '</td><td>' + statusToken(String(Boolean(workload.hasSR))) + '</td><td>' + statusToken(String(Boolean(workload.hasAction))) + '</td></tr>').join('') +
|
||||
'</tbody></table>';
|
||||
}
|
||||
|
||||
function getSortableValue(workload, sortKey) {
|
||||
if (sortKey === detailSortKeys.opportunityNumber) {
|
||||
return String(workload.opportunityNumber || "");
|
||||
}
|
||||
|
||||
if (sortKey === detailSortKeys.customerName) {
|
||||
return String(workload.customerName || "");
|
||||
}
|
||||
|
||||
if (sortKey === detailSortKeys.workload) {
|
||||
return String((workload.name || "") + "|" + (workload.description || ""));
|
||||
}
|
||||
|
||||
if (sortKey === detailSortKeys.adjustedACR) {
|
||||
return Number(workload.adjustedACR || 0);
|
||||
}
|
||||
|
||||
if (sortKey === detailSortKeys.opportunityForecastTypeGroup) {
|
||||
return String(workload.opportunityForecastTypeGroup || "");
|
||||
}
|
||||
|
||||
if (sortKey === detailSortKeys.hasSR) {
|
||||
return Boolean(workload.hasSR);
|
||||
}
|
||||
|
||||
if (sortKey === detailSortKeys.hasAction) {
|
||||
return Boolean(workload.hasAction);
|
||||
}
|
||||
|
||||
return String(workload.name || "");
|
||||
}
|
||||
|
||||
function compareSortableValues(left, right) {
|
||||
if (typeof left === "number" || typeof right === "number") {
|
||||
return Number(left || 0) - Number(right || 0);
|
||||
}
|
||||
|
||||
if (typeof left === "boolean" || typeof right === "boolean") {
|
||||
return Number(Boolean(left)) - Number(Boolean(right));
|
||||
}
|
||||
|
||||
return String(left || "").localeCompare(String(right || ""), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base"
|
||||
});
|
||||
}
|
||||
|
||||
function sortItems(items, sortKey, sortDirection) {
|
||||
const factor = sortDirection === "desc" ? -1 : 1;
|
||||
|
||||
return items.slice().sort((left, right) => {
|
||||
const comparison = compareSortableValues(
|
||||
getSortableValue(left, sortKey),
|
||||
getSortableValue(right, sortKey)
|
||||
);
|
||||
|
||||
if (comparison !== 0) {
|
||||
return comparison * factor;
|
||||
}
|
||||
|
||||
return compareSortableValues(
|
||||
getSortableValue(left, detailSortKeys.workload),
|
||||
getSortableValue(right, detailSortKeys.workload)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSortButton(label, sortKey, isNumeric) {
|
||||
const direction = detailState && detailState.sortKey === sortKey
|
||||
? (detailState.sortDirection === "asc" ? " ↑" : " ↓")
|
||||
: "";
|
||||
|
||||
return '<button class="sort-button' + (isNumeric ? ' is-numeric' : '') + '" data-sort-key="' + sortKey + '">' + label + direction + '</button>';
|
||||
}
|
||||
|
||||
function renderDetailTable(items) {
|
||||
return '<table><thead><tr><th>' + renderSortButton('Opportunity', detailSortKeys.opportunityNumber, false) + '</th><th>' + renderSortButton('Customer', detailSortKeys.customerName, false) + '</th><th>' + renderSortButton('Workload', detailSortKeys.workload, false) + '</th><th class="is-numeric">' + renderSortButton('ACR', detailSortKeys.adjustedACR, true) + '</th><th>' + renderSortButton('Type', detailSortKeys.opportunityForecastTypeGroup, false) + '</th><th>' + renderSortButton('HasSR', detailSortKeys.hasSR, false) + '</th><th>' + renderSortButton('HasAction', detailSortKeys.hasAction, false) + '</th></tr></thead><tbody>' +
|
||||
items.map((workload) => '<tr><td>' + opportunityLink(workload) + '</td><td>' + escapeHtml(workload.customerName || '') + '</td><td>' + workloadLink(workload) + '</td><td class="is-numeric">' + formatCurrency(workload.adjustedACR) + '</td><td>' + escapeHtml(workload.opportunityForecastTypeGroup) + '</td><td>' + statusToken(String(Boolean(workload.hasSR))) + '</td><td>' + statusToken(String(Boolean(workload.hasAction))) + '</td></tr>').join('') +
|
||||
'</tbody></table>';
|
||||
}
|
||||
|
||||
function openDetail(title, label, items) {
|
||||
const totalAcr = items.reduce((sum, item) => sum + (item.adjustedACR || 0), 0);
|
||||
const overlay = document.getElementById("detailOverlay");
|
||||
|
||||
overlay.innerHTML = '<div class="detail"><div class="detail-head"><div><p class="eyebrow">' + label + '</p><h2>' + title + '</h2></div><button class="close" id="closeDetail">Close</button></div><p class="subtitle">' + formatWholeNumber(items.length) + ' workloads · Total (ACR): ' + formatCurrency(totalAcr) + '</p><div style="margin-top:16px">' + renderTable(items) + '</div></div>';
|
||||
overlay.classList.add("open");
|
||||
document.getElementById("closeDetail").addEventListener("click", () => overlay.classList.remove("open"));
|
||||
overlay.onclick = (event) => {
|
||||
if (event.target === overlay) {
|
||||
overlay.classList.remove("open");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderSortButtonStateful(label, sortKey, isNumeric) {
|
||||
const direction = detailState && detailState.sortKey === sortKey
|
||||
? (detailState.sortDirection === "asc" ? " ^" : " v")
|
||||
: "";
|
||||
|
||||
return '<button class="sort-button' + (isNumeric ? ' is-numeric' : '') + '" data-sort-key="' + sortKey + '">' + label + direction + '</button>';
|
||||
}
|
||||
|
||||
function renderDetailTableStateful(items) {
|
||||
return '<table><thead><tr><th>' + renderSortButtonStateful('Opportunity', detailSortKeys.opportunityNumber, false) + '</th><th>' + renderSortButtonStateful('Customer', detailSortKeys.customerName, false) + '</th><th>' + renderSortButtonStateful('Workload', detailSortKeys.workload, false) + '</th><th class="is-numeric">' + renderSortButtonStateful('ACR', detailSortKeys.adjustedACR, true) + '</th><th>' + renderSortButtonStateful('Type', detailSortKeys.opportunityForecastTypeGroup, false) + '</th><th>' + renderSortButtonStateful('Tem SR?', detailSortKeys.hasSR, false) + '</th><th>' + renderSortButtonStateful('Tem Action?', detailSortKeys.hasAction, false) + '</th></tr></thead><tbody>' +
|
||||
items.map((workload) => '<tr><td>' + opportunityLink(workload) + '</td><td>' + escapeHtml(workload.customerName || '') + '</td><td>' + workloadLink(workload) + '</td><td class="is-numeric">' + formatCurrency(workload.adjustedACR) + '</td><td>' + escapeHtml(workload.opportunityForecastTypeGroup) + '</td><td>' + statusToken(String(Boolean(workload.hasSR))) + '</td><td>' + statusToken(String(Boolean(workload.hasAction))) + '</td></tr>').join('') +
|
||||
'</tbody></table>';
|
||||
}
|
||||
|
||||
function openDetailStateful(title, label, items) {
|
||||
detailState = {
|
||||
title: title,
|
||||
label: label,
|
||||
items: items.slice(),
|
||||
sortKey: detailSortKeys.adjustedACR,
|
||||
sortDirection: "desc"
|
||||
};
|
||||
renderDetailStateful();
|
||||
}
|
||||
|
||||
function renderDetailStateful() {
|
||||
if (!detailState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalAcr = detailState.items.reduce((sum, item) => sum + (item.adjustedACR || 0), 0);
|
||||
const sortedItems = sortItems(detailState.items, detailState.sortKey, detailState.sortDirection);
|
||||
const overlay = document.getElementById("detailOverlay");
|
||||
|
||||
overlay.innerHTML = '<div class="detail"><div class="detail-head"><div><p class="eyebrow">' + detailState.label + '</p><h2>' + detailState.title + '</h2></div><button class="close" id="closeDetail">Fechar</button></div><p class="subtitle">' + formatWholeNumber(sortedItems.length) + ' workloads · Total (ACR): ' + formatCurrency(totalAcr) + '</p><div style="margin-top:16px">' + renderDetailTableStateful(sortedItems) + '</div><div class="detail-actions"><button class="close" id="closeDetailFooter">Fechar</button></div></div>';
|
||||
overlay.classList.add("open");
|
||||
|
||||
const close = () => {
|
||||
overlay.classList.remove("open");
|
||||
detailState = null;
|
||||
};
|
||||
|
||||
document.getElementById("closeDetail").addEventListener("click", close);
|
||||
document.getElementById("closeDetailFooter").addEventListener("click", close);
|
||||
overlay.querySelectorAll("[data-sort-key]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const sortKey = button.getAttribute("data-sort-key");
|
||||
const nextDirection = detailState.sortKey === sortKey && detailState.sortDirection === "asc"
|
||||
? "desc"
|
||||
: "asc";
|
||||
detailState.sortKey = sortKey;
|
||||
detailState.sortDirection = nextDirection;
|
||||
renderDetailStateful();
|
||||
});
|
||||
});
|
||||
overlay.onclick = (event) => {
|
||||
if (event.target === overlay) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
const workloads = flattenWorkloads();
|
||||
const summary = buildSummary(workloads);
|
||||
const app = document.getElementById("app");
|
||||
|
||||
app.innerHTML = '<div class="shell">' +
|
||||
'<section class="hero"><div><p class="eyebrow">Live Oracle dataset</p><h1>Arch panel export</h1><p class="subtitle">Generated from user, customer, workload, action and service request APIs.</p></div><div><span class="pill">' + formatCurrency(summary.eligible.reduce((sum, item) => sum + (item.adjustedACR || 0), 0)) + ' total eligible ACR</span></div></section>' +
|
||||
'<section class="grid metrics">' +
|
||||
'<article class="metric"><p class="metric-label">Total clients</p><p class="metric-value">' + formatWholeNumber(payload.customers.length) + '</p><p class="metric-detail">Loaded from listSummary</p></article>' +
|
||||
'<article class="metric"><p class="metric-label">Total workloads</p><p class="metric-value">' + formatWholeNumber(workloads.length) + '</p><p class="metric-detail">Won workloads excluded upstream</p></article>' +
|
||||
'<article class="metric"><p class="metric-label">Current user</p><p class="metric-value" style="font-size:18px">' + escapeHtml(payload.user.name || payload.user.userEmail) + '</p><p class="metric-detail">' + escapeHtml(payload.user.userEmail) + '</p></article>' +
|
||||
'</section>' +
|
||||
'<section class="grid indicator-grid">' +
|
||||
summary.stageIndicators.map((indicator) => '<button class="metric clickable" data-kind="stage" data-value="' + indicator.label + '"><p class="metric-label">' + indicator.label + '</p><p class="metric-value">' + formatWholeNumber(indicator.count) + '</p><p class="metric-detail">Total (ACR): ' + formatCurrency(indicator.totalAcr) + '</p></button>').join('') +
|
||||
'</section>' +
|
||||
'<section class="grid status-grid">' +
|
||||
'<article class="panel"><p class="eyebrow">Status de SRs</p><div class="progress"><div class="progress-track"><span class="progress-segment is-success" style="width:' + ((summary.sr.trueCount + summary.sr.falseCount) > 0 ? (summary.sr.trueCount / (summary.sr.trueCount + summary.sr.falseCount)) * 100 : 0) + '%"></span><span class="progress-segment is-danger" style="width:' + ((summary.sr.trueCount + summary.sr.falseCount) > 0 ? (summary.sr.falseCount / (summary.sr.trueCount + summary.sr.falseCount)) * 100 : 0) + '%"></span></div><div class="progress-labels"><span>Workloads com SR: ' + formatPercent((summary.sr.trueCount + summary.sr.falseCount) > 0 ? (summary.sr.trueCount / (summary.sr.trueCount + summary.sr.falseCount)) * 100 : 0) + '</span><span>Workloads sem SR: ' + formatPercent((summary.sr.trueCount + summary.sr.falseCount) > 0 ? (summary.sr.falseCount / (summary.sr.trueCount + summary.sr.falseCount)) * 100 : 0) + '</span></div></div><div class="boolean-grid"><button class="boolean-card" data-kind="sr" data-value="true"><span>Workloads com SR</span><strong>' + formatWholeNumber(summary.sr.trueCount) + '</strong></button><button class="boolean-card" data-kind="sr" data-value="false"><span>Workloads sem SR</span><strong>' + formatWholeNumber(summary.sr.falseCount) + '</strong></button></div></article>' +
|
||||
'<article class="panel"><p class="eyebrow">Status de Consumption Plan/action</p><div class="progress"><div class="progress-track"><span class="progress-segment is-success" style="width:' + ((summary.action.trueCount + summary.action.falseCount) > 0 ? (summary.action.trueCount / (summary.action.trueCount + summary.action.falseCount)) * 100 : 0) + '%"></span><span class="progress-segment is-danger" style="width:' + ((summary.action.trueCount + summary.action.falseCount) > 0 ? (summary.action.falseCount / (summary.action.trueCount + summary.action.falseCount)) * 100 : 0) + '%"></span></div><div class="progress-labels"><span>Workloads com action: ' + formatPercent((summary.action.trueCount + summary.action.falseCount) > 0 ? (summary.action.trueCount / (summary.action.trueCount + summary.action.falseCount)) * 100 : 0) + '</span><span>Workloads sem action: ' + formatPercent((summary.action.trueCount + summary.action.falseCount) > 0 ? (summary.action.falseCount / (summary.action.trueCount + summary.action.falseCount)) * 100 : 0) + '</span></div></div><div class="boolean-grid"><button class="boolean-card" data-kind="action" data-value="true"><span>Workloads com action</span><strong>' + formatWholeNumber(summary.action.trueCount) + '</strong></button><button class="boolean-card" data-kind="action" data-value="false"><span>Workloads sem action</span><strong>' + formatWholeNumber(summary.action.falseCount) + '</strong></button></div></article>' +
|
||||
'</section>' +
|
||||
'</div>';
|
||||
|
||||
app.querySelectorAll("[data-kind]").forEach((element) => {
|
||||
element.addEventListener("click", () => {
|
||||
const kind = element.getAttribute("data-kind");
|
||||
const value = element.getAttribute("data-value");
|
||||
let items = [];
|
||||
let title = "";
|
||||
let label = "";
|
||||
|
||||
if (kind === "stage") {
|
||||
items = summary.eligible.filter((item) => item.opportunityForecastTypeGroup === value);
|
||||
label = "Opportunity type: " + value;
|
||||
title = value + " workloads";
|
||||
} else if (kind === "sr") {
|
||||
const flag = value === "true";
|
||||
items = summary.eligible.filter((item) => Boolean(item.hasSR) === flag);
|
||||
label = "Status de SRs";
|
||||
title = flag ? "Workloads com SR" : "Workloads sem SR";
|
||||
} else if (kind === "action") {
|
||||
const flag = value === "true";
|
||||
items = summary.eligible.filter((item) => Boolean(item.hasAction) === flag);
|
||||
label = "Status de Consumption Plan/action";
|
||||
title = flag ? "Workloads com action" : "Workloads sem action";
|
||||
}
|
||||
|
||||
openDetailStateful(title, label, items);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
|
||||
globalThis.ArchPanelExportHtml = Object.freeze({
|
||||
createExportHtml,
|
||||
});
|
||||
})();
|
||||
118
src/content/hcm-page.js
Normal file
118
src/content/hcm-page.js
Normal file
@@ -0,0 +1,118 @@
|
||||
(() => {
|
||||
function create({
|
||||
HCM_MY_INFORMATION_APPS_GROUP_ID,
|
||||
WORKLIST_ORIGIN,
|
||||
WORKLIST_SAASUI_PATHNAME,
|
||||
IDS,
|
||||
cleanString,
|
||||
hcmTileTemplate,
|
||||
onOpenArchPanel,
|
||||
writeCachedWorklistToken,
|
||||
}) {
|
||||
let lastPersistedWorklistIframeUrl = "";
|
||||
|
||||
function syncHcmTile() {
|
||||
const appsGroup = document.getElementById(HCM_MY_INFORMATION_APPS_GROUP_ID);
|
||||
const addTile = appsGroup?.querySelector(
|
||||
".flat-grid-cell.flat-grid-cell-addicon"
|
||||
);
|
||||
|
||||
if (!addTile?.parentElement) {
|
||||
document.getElementById(IDS.hcmTile)?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const tile = document.getElementById(IDS.hcmTile) || createHcmTile();
|
||||
|
||||
if (
|
||||
tile.parentElement !== addTile.parentElement ||
|
||||
tile.nextElementSibling !== addTile
|
||||
) {
|
||||
addTile.parentElement.insertBefore(tile, addTile);
|
||||
}
|
||||
}
|
||||
|
||||
function syncWorklistIframeToken(options = {}) {
|
||||
for (const frame of document.querySelectorAll("iframe")) {
|
||||
const tokenPayload = extractWorklistIframeToken(frame);
|
||||
|
||||
if (tokenPayload?.token) {
|
||||
persistWorklistIframeToken(tokenPayload, options);
|
||||
return tokenPayload;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractWorklistIframeToken(frame) {
|
||||
const rawSrc = cleanString(frame?.getAttribute?.("src") || frame?.src);
|
||||
|
||||
if (!rawSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(rawSrc, window.location.href);
|
||||
const token = cleanString(url.searchParams.get("token"));
|
||||
|
||||
if (
|
||||
url.origin !== WORKLIST_ORIGIN ||
|
||||
url.pathname !== WORKLIST_SAASUI_PATHNAME ||
|
||||
!token
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
url: url.toString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistWorklistIframeToken(tokenPayload, options = {}) {
|
||||
if (!options.force && tokenPayload.url === lastPersistedWorklistIframeUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastPersistedWorklistIframeUrl = tokenPayload.url;
|
||||
void writeCachedWorklistToken(tokenPayload);
|
||||
}
|
||||
|
||||
function createHcmTile() {
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = hcmTileTemplate(IDS.hcmTile);
|
||||
const tile = template.content.firstElementChild;
|
||||
|
||||
tile.addEventListener("click", openHcmArchPanelTile);
|
||||
tile.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
openHcmArchPanelTile(event);
|
||||
}
|
||||
});
|
||||
tile.tabIndex = 0;
|
||||
tile.setAttribute("role", "button");
|
||||
tile.setAttribute("aria-label", "Open Arch Panel");
|
||||
|
||||
return tile;
|
||||
}
|
||||
|
||||
function openHcmArchPanelTile(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onOpenArchPanel();
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
syncHcmTile,
|
||||
syncWorklistIframeToken,
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.ArchPanelHcmPage = Object.freeze({
|
||||
create,
|
||||
});
|
||||
})();
|
||||
944
src/content/manage-time-controller.js
Normal file
944
src/content/manage-time-controller.js
Normal file
@@ -0,0 +1,944 @@
|
||||
(() => {
|
||||
function create(dependencies) {
|
||||
const {
|
||||
appState,
|
||||
COMCIP_REQUEST,
|
||||
comcipRepository,
|
||||
readCachedTaskTypePayload,
|
||||
writeCachedTaskTypePayload,
|
||||
getStoredTimeManagementSelectedDate,
|
||||
setStoredTimeManagementSelectedDate,
|
||||
renderModal,
|
||||
cleanString,
|
||||
normalizeString,
|
||||
getDateInputValue,
|
||||
parseDatePreservingDateOnly,
|
||||
getFieldValue,
|
||||
extractArray,
|
||||
getErrorMessage,
|
||||
cssEscape,
|
||||
getTimeManagementRows,
|
||||
isTimeManagementSaveEnabled,
|
||||
getTimeManagementPendingChanges,
|
||||
createTimeManagementSavePayload,
|
||||
isNonServiceRequestSrNumber,
|
||||
normalizeInfoWeekResponse,
|
||||
normalizeTimeManagementServiceRequests,
|
||||
mergeTimeManagementServiceRequests,
|
||||
normalizeTimeManagementEntryRows,
|
||||
normalizeTimeManagementEntryRowSelection,
|
||||
getActivityRequestSrNumbers,
|
||||
normalizeActivityRequest,
|
||||
normalizeTaskTypeRequest,
|
||||
getTimeManagementTaskTypeOptions,
|
||||
getTimeManagementActivityOptions,
|
||||
findTimeManagementServiceRequest,
|
||||
findOptionValue,
|
||||
} = dependencies;
|
||||
|
||||
let requestCounter = 0;
|
||||
|
||||
function toggleTimeManagementDatePicker() {
|
||||
const selectedDate = getTimeManagementSelectedDate();
|
||||
const pickerDate = parseDatePreservingDateOnly(selectedDate);
|
||||
const fallback = Number.isNaN(pickerDate.getTime()) ? new Date() : pickerDate;
|
||||
|
||||
appState.timeManagementDatePicker = {
|
||||
isOpen: !appState.timeManagementDatePicker.isOpen,
|
||||
year: fallback.getFullYear(),
|
||||
month: fallback.getMonth(),
|
||||
};
|
||||
renderModal();
|
||||
}
|
||||
|
||||
function shiftTimeManagementDatePickerMonth(delta) {
|
||||
const picker = appState.timeManagementDatePicker;
|
||||
const nextDate = new Date(picker.year, picker.month + delta, 1);
|
||||
|
||||
appState.timeManagementDatePicker = {
|
||||
isOpen: true,
|
||||
year: nextDate.getFullYear(),
|
||||
month: nextDate.getMonth(),
|
||||
};
|
||||
renderModal();
|
||||
}
|
||||
|
||||
async function selectTimeManagementDate(selectedDate) {
|
||||
appState.timeManagementDatePicker = {
|
||||
...appState.timeManagementDatePicker,
|
||||
isOpen: false,
|
||||
};
|
||||
await loadTimeManagementData(selectedDate);
|
||||
}
|
||||
|
||||
async function shiftTimeManagementDateByWeeks(delta) {
|
||||
const selectedDate = getTimeManagementSelectedDate();
|
||||
const parsedDate = parseDatePreservingDateOnly(selectedDate);
|
||||
const baseDate = Number.isNaN(parsedDate.getTime())
|
||||
? new Date()
|
||||
: parsedDate;
|
||||
|
||||
baseDate.setDate(baseDate.getDate() + (Number(delta) || 0) * 7);
|
||||
appState.timeManagementDatePicker = {
|
||||
...appState.timeManagementDatePicker,
|
||||
isOpen: false,
|
||||
};
|
||||
await loadTimeManagementData(getDateInputValue(baseDate));
|
||||
}
|
||||
|
||||
function getTimeManagementSelectedDate() {
|
||||
return appState.timeManagementModal?.selectedDate || getDateInputValue(new Date());
|
||||
}
|
||||
|
||||
async function openTimeManagementModal() {
|
||||
const selectedDate = getStoredTimeManagementSelectedDate();
|
||||
|
||||
appState.timeManagementModal = createTimeManagementLoadingState(selectedDate);
|
||||
renderModal();
|
||||
await loadTimeManagementData(selectedDate);
|
||||
}
|
||||
|
||||
async function loadTimeManagementData(selectedDate) {
|
||||
const normalizedDate = cleanString(selectedDate) || getDateInputValue(new Date());
|
||||
const requestId = `${Date.now()}-${++requestCounter}`;
|
||||
|
||||
setStoredTimeManagementSelectedDate(normalizedDate);
|
||||
appState.timeManagementDatePicker = {
|
||||
...appState.timeManagementDatePicker,
|
||||
isOpen: false,
|
||||
};
|
||||
appState.timeManagementModal = {
|
||||
...(appState.timeManagementModal || {}),
|
||||
requestId,
|
||||
selectedDate: normalizedDate,
|
||||
status: "loading",
|
||||
errorMessage: "",
|
||||
};
|
||||
renderModal();
|
||||
|
||||
try {
|
||||
const resourcePartyId = getCurrentUserResourcePartyId();
|
||||
const taskTypePromise = fetchTaskTypeRequest();
|
||||
|
||||
if (!resourcePartyId) {
|
||||
throw new Error("resourceCurrentUser.resourcePartyId nao esta disponivel.");
|
||||
}
|
||||
|
||||
const weekInfoPayload = await comcipRepository.fetchInfoWeekRequest(
|
||||
normalizedDate
|
||||
);
|
||||
const weekDays = normalizeInfoWeekResponse(weekInfoPayload);
|
||||
const weekId = cleanString(weekDays[0]?.weekId);
|
||||
|
||||
if (!weekId) {
|
||||
throw new Error("WeekId nao encontrado no retorno de infoWeekRequest.");
|
||||
}
|
||||
|
||||
const timeEntryWeekUrl = comcipRepository.buildTimeEntryWeekUrl(
|
||||
resourcePartyId,
|
||||
weekId
|
||||
);
|
||||
const [srPayload, taskTypePayload, timeEntryWeekPayload] = await Promise.all([
|
||||
comcipRepository.fetchActiveServiceRequests(resourcePartyId, weekId),
|
||||
taskTypePromise,
|
||||
comcipRepository.fetchTimeEntryWeek(timeEntryWeekUrl),
|
||||
]);
|
||||
const timeEntryRows = normalizeTimeManagementEntryRows(
|
||||
timeEntryWeekPayload,
|
||||
weekDays
|
||||
);
|
||||
const serviceRequests = mergeTimeManagementServiceRequests(
|
||||
normalizeTimeManagementServiceRequests(srPayload),
|
||||
timeEntryRows
|
||||
);
|
||||
const activitySrNumbers = getActivityRequestSrNumbers(
|
||||
srPayload,
|
||||
serviceRequests
|
||||
);
|
||||
const activityRequest = await fetchCachedActivityRequest(activitySrNumbers);
|
||||
const activityRequestUrl = activityRequest.url;
|
||||
const activityPayload = activityRequest.payload;
|
||||
const activityOptionsBySr = normalizeActivityRequest(
|
||||
activityPayload,
|
||||
serviceRequests
|
||||
);
|
||||
const taskTypes = normalizeTaskTypeRequest(taskTypePayload);
|
||||
const rows = timeEntryRows;
|
||||
const selectedSrNumber = rows[0]?.selectedSrNumber || "";
|
||||
const normalizedRows = rows.map((row, index) =>
|
||||
normalizeTimeManagementEntryRowSelection(
|
||||
row,
|
||||
index,
|
||||
activityOptionsBySr,
|
||||
taskTypes
|
||||
)
|
||||
);
|
||||
|
||||
if (appState.timeManagementModal?.requestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
appState.timeManagementModal = {
|
||||
requestId,
|
||||
selectedDate: normalizedDate,
|
||||
status: "ready",
|
||||
errorMessage: "",
|
||||
weekId,
|
||||
weekDays,
|
||||
serviceRequests,
|
||||
selectedSrNumber,
|
||||
activityOptionsBySr,
|
||||
activityRequestUrl,
|
||||
activityRequestPayload: activityPayload,
|
||||
timeEntryRows: normalizedRows,
|
||||
selectedActivityValue: normalizedRows[0]?.selectedActivityValue || "",
|
||||
taskTypes,
|
||||
selectedTaskTypeValue: normalizedRows[0]?.selectedTaskTypeValue || "",
|
||||
highlightRowId: "",
|
||||
deletedTimeEntryIds: [],
|
||||
isSaving: false,
|
||||
saveErrorMessage: "",
|
||||
};
|
||||
renderModal();
|
||||
} catch (error) {
|
||||
if (appState.timeManagementModal?.requestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
appState.timeManagementModal = {
|
||||
...(appState.timeManagementModal || {}),
|
||||
requestId,
|
||||
selectedDate: normalizedDate,
|
||||
status: "error",
|
||||
errorMessage: getErrorMessage(error),
|
||||
};
|
||||
renderModal();
|
||||
}
|
||||
}
|
||||
|
||||
function createTimeManagementLoadingState(selectedDate) {
|
||||
return {
|
||||
requestId: "",
|
||||
selectedDate,
|
||||
status: "loading",
|
||||
errorMessage: "",
|
||||
weekId: "",
|
||||
weekDays: [],
|
||||
serviceRequests: [],
|
||||
selectedSrNumber: "",
|
||||
activityOptionsBySr: {},
|
||||
activityRequestUrl: "",
|
||||
activityRequestPayload: null,
|
||||
selectedActivityValue: "",
|
||||
taskTypes: [],
|
||||
selectedTaskTypeValue: "",
|
||||
timeEntryRows: [],
|
||||
highlightRowId: "",
|
||||
deletedTimeEntryIds: [],
|
||||
isSaving: false,
|
||||
saveErrorMessage: "",
|
||||
};
|
||||
}
|
||||
|
||||
function createBlankTimeManagementEntryRow(srNumber = "", source = "blank") {
|
||||
return {
|
||||
id: `row-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
timeEntryId: "",
|
||||
selectedSrNumber: cleanString(srNumber),
|
||||
selectedActivityValue: "",
|
||||
selectedTaskTypeValue: "",
|
||||
dayHours: {},
|
||||
source,
|
||||
originalSignature: "",
|
||||
};
|
||||
}
|
||||
|
||||
function addTimeManagementEntryRow(type) {
|
||||
if (!appState.timeManagementModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingDuplicateRow = findExistingTimeManagementDuplicateRow(
|
||||
appState.timeManagementModal,
|
||||
type
|
||||
);
|
||||
|
||||
if (existingDuplicateRow) {
|
||||
highlightTimeManagementDuplicateRow(existingDuplicateRow.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const srNumber = type === "non-service" ? "non-service-sr" : "";
|
||||
const nextRow = createBlankTimeManagementEntryRow(srNumber, "manual");
|
||||
const nextRows = [
|
||||
...getTimeManagementRows(appState.timeManagementModal),
|
||||
nextRow,
|
||||
];
|
||||
|
||||
appState.timeManagementModal = {
|
||||
...appState.timeManagementModal,
|
||||
timeEntryRows: nextRows,
|
||||
highlightRowId: "",
|
||||
saveErrorMessage: "",
|
||||
};
|
||||
appState.pendingFocusSelector = getTimeManagementComboboxFocusSelector(
|
||||
nextRow.id,
|
||||
type === "non-service" ? "taskType" : "sr"
|
||||
);
|
||||
renderModal();
|
||||
}
|
||||
|
||||
function removeTimeManagementEntryRow(rowId) {
|
||||
if (!appState.timeManagementModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedRowId = cleanString(rowId);
|
||||
const rows = getTimeManagementRows(appState.timeManagementModal);
|
||||
const removedRow = rows.find(
|
||||
(row) => cleanString(row?.id) === normalizedRowId
|
||||
);
|
||||
const removedTimeEntryId = cleanString(removedRow?.timeEntryId);
|
||||
const nextRows = rows.filter(
|
||||
(row) => cleanString(row?.id) !== normalizedRowId
|
||||
);
|
||||
const deletedTimeEntryIds = removedTimeEntryId
|
||||
? Array.from(
|
||||
new Set([
|
||||
...(appState.timeManagementModal.deletedTimeEntryIds || []),
|
||||
removedTimeEntryId,
|
||||
])
|
||||
)
|
||||
: appState.timeManagementModal.deletedTimeEntryIds || [];
|
||||
|
||||
appState.timeManagementModal = {
|
||||
...appState.timeManagementModal,
|
||||
timeEntryRows: nextRows,
|
||||
selectedSrNumber: nextRows[0]?.selectedSrNumber || "",
|
||||
selectedActivityValue: nextRows[0]?.selectedActivityValue || "",
|
||||
selectedTaskTypeValue: nextRows[0]?.selectedTaskTypeValue || "",
|
||||
highlightRowId: "",
|
||||
deletedTimeEntryIds,
|
||||
saveErrorMessage: "",
|
||||
};
|
||||
appState.pendingFocusSelector = getTimeManagementPostDeleteFocusSelector(
|
||||
nextRows,
|
||||
rows,
|
||||
normalizedRowId
|
||||
);
|
||||
renderModal();
|
||||
}
|
||||
|
||||
function getTimeManagementComboboxFocusSelector(rowId, field) {
|
||||
return `.arch-panel-extension-combobox input[data-row-id="${cssEscape(rowId)}"][data-combobox-field="${cssEscape(field)}"]`;
|
||||
}
|
||||
|
||||
function getTimeManagementPostDeleteFocusSelector(
|
||||
nextRows,
|
||||
previousRows,
|
||||
removedRowId
|
||||
) {
|
||||
if (!nextRows.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const removedIndex = previousRows.findIndex(
|
||||
(row) => cleanString(row?.id) === cleanString(removedRowId)
|
||||
);
|
||||
const focusIndex = Math.min(Math.max(removedIndex, 0), nextRows.length - 1);
|
||||
const focusRow = nextRows[focusIndex];
|
||||
|
||||
return focusRow?.id
|
||||
? `.arch-panel-extension-time-management-delete[data-row-id="${cssEscape(focusRow.id)}"]`
|
||||
: "";
|
||||
}
|
||||
|
||||
function getTimeManagementRowById(modal, rowId) {
|
||||
const rows = getTimeManagementRows(modal);
|
||||
const normalizedRowId = cleanString(rowId);
|
||||
|
||||
return (
|
||||
rows.find((row) => cleanString(row?.id) === normalizedRowId) ||
|
||||
rows[0] ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function updateTimeManagementRows(modal, rowId, nextRow) {
|
||||
const rows = getTimeManagementRows(modal);
|
||||
const normalizedRowId = cleanString(rowId || nextRow?.id);
|
||||
|
||||
return rows.map((row, index) => {
|
||||
const isTarget = normalizedRowId
|
||||
? cleanString(row?.id) === normalizedRowId
|
||||
: index === 0;
|
||||
|
||||
return isTarget
|
||||
? {
|
||||
...row,
|
||||
...nextRow,
|
||||
id: row?.id || nextRow?.id || `row-${index + 1}`,
|
||||
}
|
||||
: row;
|
||||
});
|
||||
}
|
||||
|
||||
function updateTimeManagementSelectedSr(srNumber, rowId = "") {
|
||||
if (!appState.timeManagementModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRow = getTimeManagementRowById(appState.timeManagementModal, rowId);
|
||||
const selectedServiceRequest = findTimeManagementServiceRequest(
|
||||
appState.timeManagementModal,
|
||||
srNumber
|
||||
);
|
||||
const selectedSrNumber =
|
||||
selectedServiceRequest?.srNumber || cleanString(srNumber);
|
||||
const duplicateRow = findDuplicateTimeManagementSrRow(
|
||||
appState.timeManagementModal,
|
||||
selectedSrNumber,
|
||||
rowId
|
||||
);
|
||||
|
||||
if (duplicateRow) {
|
||||
highlightTimeManagementDuplicateRow(duplicateRow.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRow = {
|
||||
...(targetRow || createBlankTimeManagementEntryRow()),
|
||||
selectedSrNumber,
|
||||
selectedActivityValue: "",
|
||||
selectedTaskTypeValue: "",
|
||||
};
|
||||
const nextRows = updateTimeManagementRows(
|
||||
appState.timeManagementModal,
|
||||
rowId,
|
||||
nextRow
|
||||
);
|
||||
|
||||
appState.timeManagementModal = {
|
||||
...appState.timeManagementModal,
|
||||
timeEntryRows: nextRows,
|
||||
selectedSrNumber: nextRows[0]?.selectedSrNumber || selectedSrNumber,
|
||||
selectedActivityValue: nextRows[0]?.selectedActivityValue || "",
|
||||
selectedTaskTypeValue: nextRows[0]?.selectedTaskTypeValue || "",
|
||||
highlightRowId: "",
|
||||
saveErrorMessage: "",
|
||||
};
|
||||
renderModal();
|
||||
}
|
||||
|
||||
function updateTimeManagementField(fieldName, inputValue, rowId = "") {
|
||||
if (!appState.timeManagementModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRow = getTimeManagementRowById(appState.timeManagementModal, rowId);
|
||||
const normalizedInput = cleanString(inputValue);
|
||||
let value = normalizedInput;
|
||||
|
||||
if (fieldName === "selectedActivityValue") {
|
||||
const options = getTimeManagementActivityOptions(
|
||||
appState.timeManagementModal,
|
||||
targetRow?.selectedSrNumber || appState.timeManagementModal.selectedSrNumber
|
||||
);
|
||||
value = findOptionValue(options, normalizedInput) || normalizedInput;
|
||||
}
|
||||
|
||||
if (fieldName === "selectedTaskTypeValue") {
|
||||
const options = getTimeManagementTaskTypeOptions(
|
||||
appState.timeManagementModal,
|
||||
targetRow?.selectedSrNumber || appState.timeManagementModal.selectedSrNumber
|
||||
);
|
||||
value = findOptionValue(options, normalizedInput) || normalizedInput;
|
||||
|
||||
const duplicateRow = findDuplicateTimeManagementNonServiceTaskTypeRow(
|
||||
appState.timeManagementModal,
|
||||
value,
|
||||
rowId
|
||||
);
|
||||
|
||||
if (duplicateRow) {
|
||||
highlightTimeManagementDuplicateRow(duplicateRow.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const nextRows = updateTimeManagementRows(appState.timeManagementModal, rowId, {
|
||||
...(targetRow || createBlankTimeManagementEntryRow()),
|
||||
[fieldName]: value,
|
||||
});
|
||||
|
||||
appState.timeManagementModal = {
|
||||
...appState.timeManagementModal,
|
||||
timeEntryRows: nextRows,
|
||||
[fieldName]: value,
|
||||
highlightRowId: "",
|
||||
saveErrorMessage: "",
|
||||
};
|
||||
}
|
||||
|
||||
function findDuplicateTimeManagementSrRow(modal, srNumber, currentRowId = "") {
|
||||
const normalizedSrNumber = normalizeString(srNumber);
|
||||
const normalizedCurrentRowId = cleanString(currentRowId);
|
||||
|
||||
if (!normalizedSrNumber || isNonServiceRequestSrNumber(srNumber)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
getTimeManagementRows(modal).find((row) => {
|
||||
return (
|
||||
cleanString(row?.id) !== normalizedCurrentRowId &&
|
||||
normalizeString(row?.selectedSrNumber) === normalizedSrNumber
|
||||
);
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
function findExistingTimeManagementDuplicateRow(modal, type) {
|
||||
const seen = new Map();
|
||||
|
||||
for (const row of getTimeManagementRows(modal)) {
|
||||
const rowId = cleanString(row?.id);
|
||||
let key = "";
|
||||
|
||||
if (type === "non-service") {
|
||||
if (!isNonServiceRequestSrNumber(row?.selectedSrNumber)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
key = normalizeString(row?.selectedTaskTypeValue);
|
||||
} else {
|
||||
if (isNonServiceRequestSrNumber(row?.selectedSrNumber)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
key = normalizeString(row?.selectedSrNumber);
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(key)) {
|
||||
return (
|
||||
getTimeManagementRows(modal).find(
|
||||
(candidate) => cleanString(candidate?.id) === seen.get(key)
|
||||
) || row
|
||||
);
|
||||
}
|
||||
|
||||
seen.set(key, rowId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findDuplicateTimeManagementNonServiceTaskTypeRow(
|
||||
modal,
|
||||
taskTypeValue,
|
||||
currentRowId = ""
|
||||
) {
|
||||
const normalizedTaskTypeValue = normalizeString(taskTypeValue);
|
||||
const normalizedCurrentRowId = cleanString(currentRowId);
|
||||
|
||||
if (!normalizedTaskTypeValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentRow = getTimeManagementRowById(modal, currentRowId);
|
||||
|
||||
if (!isNonServiceRequestSrNumber(currentRow?.selectedSrNumber)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
getTimeManagementRows(modal).find((row) => {
|
||||
return (
|
||||
cleanString(row?.id) !== normalizedCurrentRowId &&
|
||||
isNonServiceRequestSrNumber(row?.selectedSrNumber) &&
|
||||
normalizeString(row?.selectedTaskTypeValue) === normalizedTaskTypeValue
|
||||
);
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
function highlightTimeManagementDuplicateRow(rowId) {
|
||||
const normalizedRowId = cleanString(rowId);
|
||||
|
||||
if (!appState.timeManagementModal || !normalizedRowId) {
|
||||
return;
|
||||
}
|
||||
|
||||
appState.pendingTimeManagementHighlightRowId = normalizedRowId;
|
||||
appState.timeManagementModal = {
|
||||
...appState.timeManagementModal,
|
||||
highlightRowId: normalizedRowId,
|
||||
};
|
||||
renderModal();
|
||||
}
|
||||
|
||||
function updateTimeManagementDayHours(rowId, dateKey, inputValue) {
|
||||
if (!appState.timeManagementModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRow = getTimeManagementRowById(appState.timeManagementModal, rowId);
|
||||
const nextRow = {
|
||||
...(targetRow || createBlankTimeManagementEntryRow()),
|
||||
dayHours: {
|
||||
...(targetRow?.dayHours || {}),
|
||||
[cleanString(dateKey)]: cleanString(inputValue),
|
||||
},
|
||||
};
|
||||
|
||||
appState.timeManagementModal = {
|
||||
...appState.timeManagementModal,
|
||||
timeEntryRows: updateTimeManagementRows(
|
||||
appState.timeManagementModal,
|
||||
rowId,
|
||||
nextRow
|
||||
),
|
||||
highlightRowId: "",
|
||||
saveErrorMessage: "",
|
||||
};
|
||||
renderModal();
|
||||
}
|
||||
|
||||
async function saveTimeManagementChanges() {
|
||||
const modal = appState.timeManagementModal;
|
||||
|
||||
if (!modal || modal.isSaving || !isTimeManagementSaveEnabled(modal)) {
|
||||
return;
|
||||
}
|
||||
|
||||
appState.timeManagementModal = {
|
||||
...modal,
|
||||
isSaving: true,
|
||||
saveErrorMessage: "",
|
||||
};
|
||||
renderModal();
|
||||
|
||||
try {
|
||||
const resourceId = getCurrentUserResourcePartyId();
|
||||
const weekId = cleanString(modal.weekId);
|
||||
|
||||
if (!resourceId) {
|
||||
throw new Error("resourceCurrentUser.resourcePartyId nao esta disponivel.");
|
||||
}
|
||||
|
||||
if (!weekId) {
|
||||
throw new Error("WeekId nao encontrado para gravacao do apontamento.");
|
||||
}
|
||||
|
||||
const changes = getTimeManagementPendingChanges(modal);
|
||||
const baseUrl = COMCIP_REQUEST.timeEntriesUrl;
|
||||
const updatedPayload = changes.updatedRows.map((row) =>
|
||||
createTimeManagementSavePayload(row, modal.weekDays, weekId, resourceId, {
|
||||
includeId: true,
|
||||
})
|
||||
);
|
||||
const newPayload = changes.newRows.map((row) =>
|
||||
createTimeManagementSavePayload(row, modal.weekDays, weekId, resourceId)
|
||||
);
|
||||
|
||||
for (const timeEntryId of changes.deletedIds) {
|
||||
await comcipRepository.mutateComcipPayload(
|
||||
"DELETE",
|
||||
`${baseUrl}/${encodeURIComponent(timeEntryId)}`,
|
||||
null,
|
||||
"timeEntryDelete"
|
||||
);
|
||||
}
|
||||
|
||||
if (updatedPayload.length) {
|
||||
await comcipRepository.mutateComcipPayload(
|
||||
"PUT",
|
||||
baseUrl,
|
||||
updatedPayload,
|
||||
"timeEntryUpdate"
|
||||
);
|
||||
}
|
||||
|
||||
if (newPayload.length) {
|
||||
await comcipRepository.mutateComcipPayload(
|
||||
"POST",
|
||||
baseUrl,
|
||||
newPayload,
|
||||
"timeEntryCreate"
|
||||
);
|
||||
}
|
||||
|
||||
await loadTimeManagementData(
|
||||
modal.selectedDate || getDateInputValue(new Date())
|
||||
);
|
||||
} catch (error) {
|
||||
appState.timeManagementModal = {
|
||||
...(appState.timeManagementModal || modal),
|
||||
isSaving: false,
|
||||
saveErrorMessage: getErrorMessage(error),
|
||||
};
|
||||
renderModal();
|
||||
}
|
||||
}
|
||||
|
||||
function selectTimeManagementComboboxOption(field, value, rowId = "") {
|
||||
if (field === "sr") {
|
||||
updateTimeManagementSelectedSr(value || "", rowId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (field === "activity") {
|
||||
updateTimeManagementField("selectedActivityValue", value || "", rowId);
|
||||
renderModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (field === "taskType") {
|
||||
updateTimeManagementField("selectedTaskTypeValue", value || "", rowId);
|
||||
renderModal();
|
||||
}
|
||||
}
|
||||
|
||||
function filterTimeManagementCombobox(input) {
|
||||
const query = normalizeString(input.value);
|
||||
const combobox = input.closest(".arch-panel-extension-combobox");
|
||||
|
||||
if (!combobox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = Array.from(
|
||||
combobox.querySelectorAll(".arch-panel-extension-combobox-option")
|
||||
);
|
||||
|
||||
for (const option of options) {
|
||||
const searchText = normalizeString(option.getAttribute("data-search") || "");
|
||||
option.hidden = Boolean(query) && !searchText.includes(query);
|
||||
}
|
||||
|
||||
setActiveTimeManagementComboboxOption(
|
||||
combobox,
|
||||
getVisibleTimeManagementComboboxOptions(combobox).find((option) =>
|
||||
option.classList.contains("is-selected")
|
||||
) || getVisibleTimeManagementComboboxOptions(combobox)[0]
|
||||
);
|
||||
}
|
||||
|
||||
function handleTimeManagementComboboxKeydown(event, input) {
|
||||
const combobox = input.closest(".arch-panel-extension-combobox");
|
||||
|
||||
if (!combobox) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
clearActiveTimeManagementComboboxOption(combobox);
|
||||
input.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleOptions = getVisibleTimeManagementComboboxOptions(combobox);
|
||||
|
||||
if (!visibleOptions.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (event.key === "Enter") {
|
||||
const activeOption =
|
||||
visibleOptions.find((option) => option.classList.contains("is-active")) ||
|
||||
visibleOptions[0];
|
||||
activeOption.click();
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIndex = visibleOptions.findIndex((option) =>
|
||||
option.classList.contains("is-active")
|
||||
);
|
||||
const selectedIndex = visibleOptions.findIndex((option) =>
|
||||
option.classList.contains("is-selected")
|
||||
);
|
||||
const baseIndex = activeIndex >= 0 ? activeIndex : selectedIndex;
|
||||
let nextIndex = 0;
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
nextIndex = baseIndex >= 0 ? baseIndex + 1 : 0;
|
||||
} else if (event.key === "ArrowUp") {
|
||||
nextIndex = baseIndex >= 0 ? baseIndex - 1 : visibleOptions.length - 1;
|
||||
} else if (event.key === "Home") {
|
||||
nextIndex = 0;
|
||||
} else if (event.key === "End") {
|
||||
nextIndex = visibleOptions.length - 1;
|
||||
}
|
||||
|
||||
if (nextIndex < 0) {
|
||||
nextIndex = visibleOptions.length - 1;
|
||||
} else if (nextIndex >= visibleOptions.length) {
|
||||
nextIndex = 0;
|
||||
}
|
||||
|
||||
setActiveTimeManagementComboboxOption(combobox, visibleOptions[nextIndex]);
|
||||
}
|
||||
|
||||
function getVisibleTimeManagementComboboxOptions(combobox) {
|
||||
return Array.from(
|
||||
combobox.querySelectorAll(".arch-panel-extension-combobox-option")
|
||||
).filter((option) => !option.hidden);
|
||||
}
|
||||
|
||||
function setActiveTimeManagementComboboxOption(combobox, option) {
|
||||
clearActiveTimeManagementComboboxOption(combobox);
|
||||
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
|
||||
option.classList.add("is-active");
|
||||
combobox
|
||||
.querySelector("input[data-combobox-field]")
|
||||
?.setAttribute("aria-activedescendant", option.id || "");
|
||||
option.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function clearActiveTimeManagementComboboxOption(combobox) {
|
||||
combobox
|
||||
.querySelectorAll(".arch-panel-extension-combobox-option.is-active")
|
||||
.forEach((option) => option.classList.remove("is-active"));
|
||||
combobox
|
||||
.querySelector("input[data-combobox-field]")
|
||||
?.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
|
||||
async function fetchCachedActivityRequest(srNumbers) {
|
||||
const cacheKey = getActivityRequestCacheKey(srNumbers);
|
||||
|
||||
if (!cacheKey) {
|
||||
return {
|
||||
url: "",
|
||||
payload: null,
|
||||
};
|
||||
}
|
||||
|
||||
const cachedRequest = appState.timeManagementActivityCache.get(cacheKey);
|
||||
|
||||
if (cachedRequest?.payload) {
|
||||
return cachedRequest;
|
||||
}
|
||||
|
||||
if (cachedRequest?.promise) {
|
||||
return cachedRequest.promise;
|
||||
}
|
||||
|
||||
const requestPromise = comcipRepository
|
||||
.fetchActivityRequest(srNumbers)
|
||||
.then((result) => {
|
||||
appState.timeManagementActivityCache.set(cacheKey, {
|
||||
url: result.url,
|
||||
payload: result.payload,
|
||||
});
|
||||
|
||||
return result;
|
||||
})
|
||||
.catch((error) => {
|
||||
appState.timeManagementActivityCache.delete(cacheKey);
|
||||
throw error;
|
||||
});
|
||||
|
||||
appState.timeManagementActivityCache.set(cacheKey, {
|
||||
promise: requestPromise,
|
||||
});
|
||||
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
function getActivityRequestCacheKey(srNumbers) {
|
||||
const normalizedSrNumbers = (srNumbers || [])
|
||||
.map((srNumber) => cleanString(srNumber).toUpperCase())
|
||||
.filter((srNumber) => /^SR\d+$/i.test(srNumber))
|
||||
.sort();
|
||||
|
||||
return normalizedSrNumbers.join("|");
|
||||
}
|
||||
|
||||
async function fetchTaskTypeRequest() {
|
||||
if (appState.taskTypeCache.promise) {
|
||||
return appState.taskTypeCache.promise;
|
||||
}
|
||||
|
||||
appState.taskTypeCache.promise = (async () => {
|
||||
return readCachedTaskTypePayload().catch(() => null);
|
||||
})().finally(() => {
|
||||
appState.taskTypeCache.promise = null;
|
||||
});
|
||||
|
||||
return appState.taskTypeCache.promise;
|
||||
}
|
||||
|
||||
async function refreshTaskTypeCache() {
|
||||
const payload = await comcipRepository.fetchTaskTypePayload();
|
||||
await writeCachedTaskTypePayload(payload);
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function getCurrentUserResourcePartyId() {
|
||||
const resourceCurrentUser = appState.dataset?.resourceCurrentUser;
|
||||
const directResourcePartyId = cleanString(
|
||||
getFieldValue(resourceCurrentUser, [
|
||||
"resourcePartyId",
|
||||
"ResourcePartyId",
|
||||
"ResourcePartyID",
|
||||
"partyId",
|
||||
"PartyId",
|
||||
])
|
||||
);
|
||||
|
||||
if (directResourcePartyId) {
|
||||
return directResourcePartyId;
|
||||
}
|
||||
|
||||
const candidates = Array.isArray(resourceCurrentUser)
|
||||
? resourceCurrentUser
|
||||
: extractArray(resourceCurrentUser, ["items", "data", "results", "content"]);
|
||||
const source = candidates[0] || resourceCurrentUser;
|
||||
|
||||
return cleanString(
|
||||
getFieldValue(source, [
|
||||
"resourcePartyId",
|
||||
"ResourcePartyId",
|
||||
"ResourcePartyID",
|
||||
"partyId",
|
||||
"PartyId",
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
toggleTimeManagementDatePicker,
|
||||
shiftTimeManagementDatePickerMonth,
|
||||
selectTimeManagementDate,
|
||||
shiftTimeManagementDateByWeeks,
|
||||
openTimeManagementModal,
|
||||
loadTimeManagementData,
|
||||
addTimeManagementEntryRow,
|
||||
removeTimeManagementEntryRow,
|
||||
updateTimeManagementSelectedSr,
|
||||
updateTimeManagementField,
|
||||
updateTimeManagementDayHours,
|
||||
saveTimeManagementChanges,
|
||||
selectTimeManagementComboboxOption,
|
||||
filterTimeManagementCombobox,
|
||||
handleTimeManagementComboboxKeydown,
|
||||
refreshTaskTypeCache,
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.ArchPanelManageTimeController = Object.freeze({
|
||||
create,
|
||||
});
|
||||
})();
|
||||
533
src/content/modal-events.js
Normal file
533
src/content/modal-events.js
Normal file
@@ -0,0 +1,533 @@
|
||||
(() => {
|
||||
function create(dependencies) {
|
||||
const {
|
||||
appState,
|
||||
renderModal,
|
||||
closeModal,
|
||||
closeDetailModal,
|
||||
toggleTheme,
|
||||
toggleSidebar,
|
||||
refreshData,
|
||||
shiftCalendarPeriod,
|
||||
goToCurrentCalendarPeriod,
|
||||
setCalendarView,
|
||||
downloadFile,
|
||||
createExportHtml,
|
||||
openDetailModal,
|
||||
openActionFormModal,
|
||||
openRampComparisonModal,
|
||||
openForecastUpdateConfirmModal,
|
||||
submitForecastUpdate,
|
||||
openTimeEntriesDrawer,
|
||||
openTimeManagementModal,
|
||||
addTimeManagementEntryRow,
|
||||
saveTimeManagementChanges,
|
||||
removeTimeManagementEntryRow,
|
||||
toggleTimeManagementDatePicker,
|
||||
shiftTimeManagementDateByWeeks,
|
||||
shiftTimeManagementDatePickerMonth,
|
||||
selectTimeManagementDate,
|
||||
selectTimeManagementComboboxOption,
|
||||
submitActionForm,
|
||||
toggleDetailSort,
|
||||
loadTimeManagementData,
|
||||
updateTimeManagementSelectedSr,
|
||||
updateTimeManagementField,
|
||||
updateTimeManagementDayHours,
|
||||
filterTimeManagementCombobox,
|
||||
handleTimeManagementComboboxKeydown,
|
||||
} = dependencies;
|
||||
|
||||
function handleOverlayClick(event) {
|
||||
const overlay = document.getElementById(
|
||||
globalThis.ArchPanelConfig.IDS.overlay
|
||||
);
|
||||
|
||||
if (!overlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target === overlay) {
|
||||
if (appState.actionFormModal) {
|
||||
appState.actionFormModal = null;
|
||||
renderModal();
|
||||
} else if (appState.timeManagementModal) {
|
||||
appState.timeManagementModal = null;
|
||||
renderModal();
|
||||
} else if (appState.forecastUpdateConfirmModal) {
|
||||
appState.forecastUpdateConfirmModal = null;
|
||||
renderModal();
|
||||
} else if (appState.rampComparisonModal) {
|
||||
appState.rampComparisonModal = null;
|
||||
renderModal();
|
||||
} else if (appState.detailModal) {
|
||||
closeDetailModal();
|
||||
renderModal();
|
||||
} else {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const actionElement = event.target.closest("[data-action]");
|
||||
|
||||
if (!actionElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = actionElement.getAttribute("data-action");
|
||||
|
||||
if (
|
||||
action === "time-management-date-change" ||
|
||||
action === "time-management-sr-change" ||
|
||||
action === "time-management-activity-change" ||
|
||||
action === "time-management-task-type-change"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (action === "close") {
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "toggle-theme") {
|
||||
toggleTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "toggle-sidebar") {
|
||||
toggleSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "refresh") {
|
||||
void refreshData();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "calendar-prev") {
|
||||
shiftCalendarPeriod(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "calendar-next") {
|
||||
shiftCalendarPeriod(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "calendar-today") {
|
||||
goToCurrentCalendarPeriod();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "calendar-view") {
|
||||
const calendarView = actionElement.getAttribute("data-calendar-view");
|
||||
|
||||
if (calendarView) {
|
||||
setCalendarView(calendarView);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "download-json") {
|
||||
if (appState.dataset) {
|
||||
downloadFile(
|
||||
"arch-panel-data.json",
|
||||
JSON.stringify(appState.dataset.exportPayload, null, 2),
|
||||
"application/json"
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "download-html") {
|
||||
if (appState.dataset) {
|
||||
downloadFile(
|
||||
"arch-panel-dashboard.html",
|
||||
createExportHtml(appState.dataset),
|
||||
"text/html"
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "open-detail") {
|
||||
const detailScope = actionElement.getAttribute("data-detail-scope");
|
||||
const detailValue = actionElement.getAttribute("data-detail-value");
|
||||
|
||||
openDetailModal(detailScope, detailValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "open-action-form") {
|
||||
const workloadId = actionElement.getAttribute("data-workload-id");
|
||||
|
||||
if (workloadId) {
|
||||
openActionFormModal(workloadId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "open-ramp-comparison") {
|
||||
const workloadId = actionElement.getAttribute("data-workload-id");
|
||||
|
||||
if (workloadId) {
|
||||
openRampComparisonModal(workloadId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "close-ramp-comparison") {
|
||||
appState.forecastUpdateConfirmModal = null;
|
||||
appState.rampComparisonModal = null;
|
||||
renderModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "open-forecast-update-confirm") {
|
||||
openForecastUpdateConfirmModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "close-forecast-update-confirm") {
|
||||
appState.forecastUpdateConfirmModal = null;
|
||||
renderModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "submit-forecast-update") {
|
||||
void submitForecastUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "close-action-form") {
|
||||
appState.actionFormModal = null;
|
||||
renderModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "open-time-entries") {
|
||||
const srNumber = actionElement.getAttribute("data-sr-number");
|
||||
|
||||
if (srNumber) {
|
||||
void openTimeEntriesDrawer(srNumber);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "close-time-entries") {
|
||||
appState.timeEntriesDrawer = null;
|
||||
renderModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "retry-time-entries") {
|
||||
const srNumber = appState.timeEntriesDrawer?.srNumber;
|
||||
|
||||
if (srNumber) {
|
||||
void openTimeEntriesDrawer(srNumber);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "open-time-management") {
|
||||
void openTimeManagementModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "close-time-management") {
|
||||
appState.timeManagementModal = null;
|
||||
renderModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "time-management-add-sr") {
|
||||
addTimeManagementEntryRow("sr");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "time-management-add-non-service") {
|
||||
addTimeManagementEntryRow("non-service");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "time-management-save") {
|
||||
void saveTimeManagementChanges();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "time-management-remove-row") {
|
||||
removeTimeManagementEntryRow(actionElement.getAttribute("data-row-id"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "open-time-management-date-picker") {
|
||||
if (!actionElement.matches("button.arch-panel-extension-date-picker-button")) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggleTimeManagementDatePicker();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "time-management-date-week-prev") {
|
||||
void shiftTimeManagementDateByWeeks(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "time-management-date-week-next") {
|
||||
void shiftTimeManagementDateByWeeks(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "time-management-date-picker-prev") {
|
||||
shiftTimeManagementDatePickerMonth(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "time-management-date-picker-next") {
|
||||
shiftTimeManagementDatePickerMonth(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "time-management-date-picker-select") {
|
||||
const selectedDate = actionElement.getAttribute("data-date-value");
|
||||
|
||||
if (selectedDate) {
|
||||
void selectTimeManagementDate(selectedDate);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "time-management-combobox-option") {
|
||||
selectTimeManagementComboboxOption(
|
||||
actionElement.getAttribute("data-field"),
|
||||
actionElement.getAttribute("data-value"),
|
||||
actionElement.getAttribute("data-row-id")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "submit-action-form") {
|
||||
const form = actionElement.closest("form");
|
||||
|
||||
if (form instanceof HTMLFormElement) {
|
||||
void submitActionForm(form);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "close-detail") {
|
||||
closeDetailModal();
|
||||
renderModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "sort-detail") {
|
||||
const sortKey = actionElement.getAttribute("data-sort-key");
|
||||
|
||||
if (sortKey) {
|
||||
toggleDetailSort(sortKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOverlayChange(event) {
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof HTMLInputElement || target instanceof HTMLSelectElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = target.getAttribute("data-action");
|
||||
|
||||
if (action === "time-management-date-change") {
|
||||
void loadTimeManagementData(target.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "time-management-sr-change") {
|
||||
updateTimeManagementSelectedSr(
|
||||
target.value,
|
||||
target.getAttribute("data-row-id")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "time-management-activity-change") {
|
||||
updateTimeManagementField(
|
||||
"selectedActivityValue",
|
||||
target.value,
|
||||
target.getAttribute("data-row-id")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "time-management-task-type-change") {
|
||||
updateTimeManagementField(
|
||||
"selectedTaskTypeValue",
|
||||
target.value,
|
||||
target.getAttribute("data-row-id")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "time-management-hours-change") {
|
||||
updateTimeManagementDayHours(
|
||||
target.getAttribute("data-row-id"),
|
||||
target.getAttribute("data-date-key"),
|
||||
target.value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOverlayInput(event) {
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const field = target.getAttribute("data-combobox-field");
|
||||
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
|
||||
filterTimeManagementCombobox(target);
|
||||
}
|
||||
|
||||
function handleOverlayKeydown(event) {
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.getAttribute("data-action") === "time-management-hours-change") {
|
||||
handleTimeManagementHourKeydown(event, target);
|
||||
return;
|
||||
}
|
||||
|
||||
const field = target.getAttribute("data-combobox-field");
|
||||
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!["ArrowDown", "ArrowUp", "Home", "End", "Enter", "Escape"].includes(
|
||||
event.key
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleTimeManagementComboboxKeydown(event, target);
|
||||
}
|
||||
|
||||
function handleTimeManagementHourKeydown(event, input) {
|
||||
if (
|
||||
![
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"Home",
|
||||
"End",
|
||||
"Enter",
|
||||
].includes(event.key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextInput = getNextTimeManagementHourInput(input, event);
|
||||
|
||||
if (!nextInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
nextInput.focus();
|
||||
nextInput.select();
|
||||
}
|
||||
|
||||
function getNextTimeManagementHourInput(currentInput, event) {
|
||||
const rows = Array.from(
|
||||
document.querySelectorAll(
|
||||
".arch-panel-extension-time-management-entry-row[data-row-id]"
|
||||
)
|
||||
);
|
||||
const currentRow = currentInput.closest(
|
||||
".arch-panel-extension-time-management-entry-row"
|
||||
);
|
||||
const rowIndex = rows.indexOf(currentRow);
|
||||
|
||||
if (rowIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentInputs = getTimeManagementHourInputs(rows[rowIndex]);
|
||||
const columnIndex = currentInputs.indexOf(currentInput);
|
||||
|
||||
if (columnIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let nextRowIndex = rowIndex;
|
||||
let nextColumnIndex = columnIndex;
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
nextColumnIndex -= 1;
|
||||
} else if (event.key === "ArrowRight") {
|
||||
nextColumnIndex += 1;
|
||||
} else if (event.key === "ArrowUp") {
|
||||
nextRowIndex -= 1;
|
||||
} else if (event.key === "ArrowDown" || event.key === "Enter") {
|
||||
nextRowIndex += event.shiftKey ? -1 : 1;
|
||||
} else if (event.key === "Home") {
|
||||
nextColumnIndex = 0;
|
||||
} else if (event.key === "End") {
|
||||
nextColumnIndex = currentInputs.length - 1;
|
||||
}
|
||||
|
||||
const nextRow = rows[nextRowIndex];
|
||||
|
||||
if (!nextRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextInputs = getTimeManagementHourInputs(nextRow);
|
||||
|
||||
return nextInputs[nextColumnIndex] || null;
|
||||
}
|
||||
|
||||
function getTimeManagementHourInputs(row) {
|
||||
return Array.from(
|
||||
row.querySelectorAll('input[data-action="time-management-hours-change"]')
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
handleOverlayClick,
|
||||
handleOverlayChange,
|
||||
handleOverlayInput,
|
||||
handleOverlayKeydown,
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.ArchPanelModalEvents = Object.freeze({
|
||||
create,
|
||||
});
|
||||
})();
|
||||
328
src/content/shell-view.js
Normal file
328
src/content/shell-view.js
Normal file
@@ -0,0 +1,328 @@
|
||||
(() => {
|
||||
function create({
|
||||
IDS,
|
||||
CLASSES,
|
||||
USER_BUTTON_LABEL,
|
||||
normalizeText,
|
||||
isVisible,
|
||||
}) {
|
||||
function applyDialogTheme(overlay, theme) {
|
||||
const dialog = overlay?.querySelector(`#${IDS.dialog}`);
|
||||
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
dialog.setAttribute("data-theme", theme);
|
||||
}
|
||||
|
||||
function ensureStyles() {
|
||||
// Styles are loaded by manifest.json. This function remains as a stable callsite.
|
||||
}
|
||||
|
||||
function applyTheme(header, functionsContainer, anchor, mount, overlay) {
|
||||
const headerStyle = window.getComputedStyle(header);
|
||||
const functionsStyle = window.getComputedStyle(functionsContainer);
|
||||
const sourceBackground = resolveBackgroundColor(header, headerStyle);
|
||||
const sourceForeground = resolveForegroundColor(
|
||||
header,
|
||||
functionsContainer,
|
||||
anchor,
|
||||
headerStyle,
|
||||
functionsStyle
|
||||
);
|
||||
const workbenchHeader = mix(
|
||||
sourceBackground,
|
||||
{ r: 75, g: 51, b: 93, a: 1 },
|
||||
0.52
|
||||
);
|
||||
const workbenchAccent = mix(
|
||||
workbenchHeader,
|
||||
{ r: 108, g: 77, b: 128, a: 1 },
|
||||
0.38
|
||||
);
|
||||
const focusColor = mix(sourceForeground, workbenchHeader, 0.28);
|
||||
const themeTargets = [mount, overlay].filter(Boolean);
|
||||
|
||||
for (const target of themeTargets) {
|
||||
target.style.setProperty(
|
||||
"--arch-panel-bg",
|
||||
toColor(mix(sourceBackground, sourceForeground, 0.12))
|
||||
);
|
||||
target.style.setProperty(
|
||||
"--arch-panel-bg-hover",
|
||||
toColor(mix(sourceBackground, sourceForeground, 0.22))
|
||||
);
|
||||
target.style.setProperty(
|
||||
"--arch-panel-border",
|
||||
toColor(mix(sourceBackground, sourceForeground, 0.32))
|
||||
);
|
||||
target.style.setProperty("--arch-panel-fg", toColor(sourceForeground, 1));
|
||||
target.style.setProperty("--arch-panel-focus", toColor(focusColor, 0.94));
|
||||
target.style.setProperty(
|
||||
"--arch-panel-font",
|
||||
functionsStyle.fontFamily || headerStyle.fontFamily || "inherit"
|
||||
);
|
||||
target.style.setProperty(
|
||||
"--arch-workbench-header",
|
||||
toColor(workbenchHeader, 1)
|
||||
);
|
||||
target.style.setProperty(
|
||||
"--arch-workbench-accent",
|
||||
toColor(workbenchAccent, 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveForegroundColor(
|
||||
header,
|
||||
functionsContainer,
|
||||
anchor,
|
||||
headerStyle,
|
||||
functionsStyle
|
||||
) {
|
||||
const salesPlanningElement = findTextElement(header, "Sales Planning");
|
||||
const salesPlanningColor = salesPlanningElement
|
||||
? parseColor(window.getComputedStyle(salesPlanningElement).color)
|
||||
: null;
|
||||
const functionsItemColor = findVisibleChildColor(functionsContainer);
|
||||
const userColor = anchor
|
||||
? parseColor(window.getComputedStyle(anchor).color)
|
||||
: null;
|
||||
|
||||
return (
|
||||
userColor ||
|
||||
functionsItemColor ||
|
||||
parseColor(functionsStyle.color) ||
|
||||
salesPlanningColor ||
|
||||
parseColor(headerStyle.color) || {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
a: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBackgroundColor(element, style) {
|
||||
const directBackground = parseColor(style.backgroundColor);
|
||||
|
||||
if (directBackground && directBackground.a > 0) {
|
||||
return directBackground;
|
||||
}
|
||||
|
||||
let current = element.parentElement;
|
||||
|
||||
while (current) {
|
||||
const currentBackground = parseColor(
|
||||
window.getComputedStyle(current).backgroundColor
|
||||
);
|
||||
|
||||
if (currentBackground && currentBackground.a > 0) {
|
||||
return currentBackground;
|
||||
}
|
||||
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return { r: 75, g: 51, b: 93, a: 1 };
|
||||
}
|
||||
|
||||
function parseColor(value) {
|
||||
if (!value || value === "transparent") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rgbMatch = value.match(
|
||||
/rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})(?:[\s,/]+([.\d]+))?\s*\)/i
|
||||
);
|
||||
|
||||
if (rgbMatch) {
|
||||
return {
|
||||
r: Number.parseInt(rgbMatch[1], 10),
|
||||
g: Number.parseInt(rgbMatch[2], 10),
|
||||
b: Number.parseInt(rgbMatch[3], 10),
|
||||
a: rgbMatch[4] === undefined ? 1 : Number.parseFloat(rgbMatch[4]),
|
||||
};
|
||||
}
|
||||
|
||||
const hexMatch = value.match(/^#([\da-f]{3,8})$/i);
|
||||
|
||||
if (!hexMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hexValue = hexMatch[1];
|
||||
|
||||
if (hexValue.length === 3 || hexValue.length === 4) {
|
||||
const [r, g, b, a = "f"] = hexValue.split("");
|
||||
|
||||
return {
|
||||
r: Number.parseInt(r + r, 16),
|
||||
g: Number.parseInt(g + g, 16),
|
||||
b: Number.parseInt(b + b, 16),
|
||||
a: Number.parseInt(a + a, 16) / 255,
|
||||
};
|
||||
}
|
||||
|
||||
if (hexValue.length === 6 || hexValue.length === 8) {
|
||||
return {
|
||||
r: Number.parseInt(hexValue.slice(0, 2), 16),
|
||||
g: Number.parseInt(hexValue.slice(2, 4), 16),
|
||||
b: Number.parseInt(hexValue.slice(4, 6), 16),
|
||||
a:
|
||||
hexValue.length === 8
|
||||
? Number.parseInt(hexValue.slice(6, 8), 16) / 255
|
||||
: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function mix(base, tint, amount) {
|
||||
const ratio = clamp(amount, 0, 1);
|
||||
|
||||
return {
|
||||
r: Math.round(base.r + (tint.r - base.r) * ratio),
|
||||
g: Math.round(base.g + (tint.g - base.g) * ratio),
|
||||
b: Math.round(base.b + (tint.b - base.b) * ratio),
|
||||
a: base.a + (tint.a - base.a) * ratio,
|
||||
};
|
||||
}
|
||||
|
||||
function toColor(color, alphaOverride) {
|
||||
const alpha = alphaOverride ?? color.a ?? 1;
|
||||
|
||||
return `rgba(${color.r}, ${color.g}, ${color.b}, ${clamp(alpha, 0, 1)})`;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function findUserAnchor(container) {
|
||||
const selector = [
|
||||
`button[label="${USER_BUTTON_LABEL}"]`,
|
||||
`[role="button"][label="${USER_BUTTON_LABEL}"]`,
|
||||
`oj-button[label="${USER_BUTTON_LABEL}"]`,
|
||||
`button[aria-label="${USER_BUTTON_LABEL}"]`,
|
||||
`[role="button"][aria-label="${USER_BUTTON_LABEL}"]`,
|
||||
`[label="${USER_BUTTON_LABEL}"]`,
|
||||
`[aria-label="${USER_BUTTON_LABEL}"]`,
|
||||
].join(", ");
|
||||
|
||||
const directMatch = container.querySelector(selector);
|
||||
|
||||
if (directMatch && isVisible(directMatch)) {
|
||||
return directMatch;
|
||||
}
|
||||
|
||||
const fallbackMatch = Array.from(container.querySelectorAll("*")).find(
|
||||
(element) => {
|
||||
if (!isVisible(element) || element.closest(`#${IDS.mount}`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
element.getAttribute("label") === USER_BUTTON_LABEL ||
|
||||
element.getAttribute("aria-label") === USER_BUTTON_LABEL
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return fallbackMatch || null;
|
||||
}
|
||||
|
||||
function findTextElement(container, expectedText) {
|
||||
const normalizedExpected = normalizeText(expectedText);
|
||||
const elements = Array.from(container.querySelectorAll("*"));
|
||||
|
||||
return (
|
||||
elements.find((element) => {
|
||||
const text = normalizeText(element.textContent || "");
|
||||
|
||||
return text && text.includes(normalizedExpected);
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInsertionTarget(container, anchor, mount) {
|
||||
const directAnchor = anchor ? getDirectChild(anchor, container) : null;
|
||||
const fallbackTarget = findFirstInsertionTarget(container, mount);
|
||||
const candidate = directAnchor || fallbackTarget;
|
||||
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidate === mount) {
|
||||
return findNextValidSibling(container, mount);
|
||||
}
|
||||
|
||||
return candidate.parentElement === container ? candidate : null;
|
||||
}
|
||||
|
||||
function getDirectChild(element, container) {
|
||||
let current = element;
|
||||
|
||||
while (current && current.parentElement !== container) {
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return current && current.parentElement === container ? current : null;
|
||||
}
|
||||
|
||||
function findFirstInsertionTarget(container, mount) {
|
||||
return Array.from(container.children).find((child) => child !== mount) || null;
|
||||
}
|
||||
|
||||
function findNextValidSibling(container, mount) {
|
||||
let sibling = mount.nextElementSibling;
|
||||
|
||||
while (sibling) {
|
||||
if (sibling.parentElement === container) {
|
||||
return sibling;
|
||||
}
|
||||
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findVisibleChildColor(container) {
|
||||
const visibleChild = Array.from(container.querySelectorAll("*")).find(
|
||||
(element) => isVisible(element) && !element.closest(`#${IDS.mount}`)
|
||||
);
|
||||
|
||||
return visibleChild
|
||||
? parseColor(window.getComputedStyle(visibleChild).color)
|
||||
: null;
|
||||
}
|
||||
|
||||
function lockScroll() {
|
||||
document.documentElement.classList.add(CLASSES.modalOpen);
|
||||
document.body?.classList.add(CLASSES.modalOpen);
|
||||
}
|
||||
|
||||
function unlockScroll() {
|
||||
document.documentElement.classList.remove(CLASSES.modalOpen);
|
||||
document.body?.classList.remove(CLASSES.modalOpen);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
applyDialogTheme,
|
||||
ensureStyles,
|
||||
applyTheme,
|
||||
findUserAnchor,
|
||||
resolveInsertionTarget,
|
||||
lockScroll,
|
||||
unlockScroll,
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.ArchPanelShellView = Object.freeze({
|
||||
create,
|
||||
});
|
||||
})();
|
||||
2530
src/content/styles.css
Normal file
2530
src/content/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
15
src/content/templates.js
Normal file
15
src/content/templates.js
Normal file
@@ -0,0 +1,15 @@
|
||||
(() => {
|
||||
function hcmTile(hcmTileId) {
|
||||
return `
|
||||
<div class="flat-grid-cell" id="${hcmTileId}">
|
||||
<div id="c_75628d297d10409585cc1d40bb36f59d" class="app-nav-item" filmstrip="Arch Panel" page="undefined" index="0" type="subcluster" title="Arch Panel" group="groupNode_sales" destinationurl="#" targetframe="_self" isdesturlexist="true" role="presentation">
|
||||
<svg viewBox="0 0 48 48" style="fill:currentColor" class="svg-nav suiicon svg-bkgd11" data-icon="navi_dashboard" role="presentation" focusable="false"><path class="svg-shortcut" d="M28 42.5l-3 2.7v-1.7c-.4 0-1.4 0-2.5.6-1.3 1-1.5 1.6-1.5 1.6s-.4-1.2.8-2.7c1.2-1.6 2.6-1.7 3.2-1.6v-1.6l3 2.7z"></path><path class="svg-cluster" d="M28.5 41.3c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2zm-4 0c.6 0 1.2.5 1.2 1.2s-.6 1.2-1.2 1.2c-.7 0-1.2-.5-1.2-1.2s.5-1.2 1.2-1.2zm-4 0c.7 0 1.2.5 1.2 1.2s-.5 1.2-1.2 1.2-1.2-.5-1.2-1.2.5-1.2 1.2-1.2z"></path><path class="svg-icon18" d="M38 16H10v18.8c0 .7.5 1.2 1 1.2h26c.7 0 1.2-.5 1.2-1.2V16zm-9 3.2c0-.7.5-1.2 1-1.2h5c.5 0 1 .5 1 1.2v5.6c0 .7-.5 1.2-1 1.2h-5c-.5 0-1-.5-1-1.2v-5.6zM21 33c0 .6-.3 1-1 1h-7c-.7 0-1-.5-1-1.2V19.2c0-.7.5-1.2 1.2-1.2h6.6c.7 0 1.2.4 1.2 1v14zm1-13.8c0-.7.5-1.2 1-1.2h4c.5 0 1 .5 1 1.2v5.6c0 .7-.5 1.2-1 1.2h-4c-.5 0-1-.5-1-1.2v-5.6zM33 33H22v-2h11v2zm3-3H22v-2h14v2z"></path><path class="svg-icon14" d="M30 25v-5h1v4h4v1h-5zm4-5h1v3h-1v-3zm-2 1h1v2h-1v-2z"></path><path class="svg-icon03" d="M23 20h4v1h-4v-1zm3 4h-3v-1h3v1z"></path><path class="svg-icon04" d="M17 20c1 0 2 1 2 2h-2v-2zm-1 5c-1 0-2-1.2-2-2 0-1 1-2 2-2v2h2c0 1-1 2-2 2zm3 4h-5v-1h5v1zm0 3h-5v-1h5v1z"></path><path class="svg-icon05" d="M11 12h26c.7 0 1.2.6 1.2 1.2V15H10v-1.8c0-.6.5-1.2 1-1.2z"></path><path class="svg-outline" d="M38.1 35.76H9.92a1.16 1.16 0 0 1-1.15-1.17l.01-18.78h30.47V34.6a1.16 1.16 0 0 1-1.15 1.17zM23.9 19.31h2.53a1.15 1.15 0 0 1 1.13 1.17v3.45a1.15 1.15 0 0 1-1.13 1.17h-2.52a1.15 1.15 0 0 1-1.14-1.17v-3.45a1.15 1.15 0 0 1 1.14-1.17zm8.2.03h2.55a1.15 1.15 0 0 1 1.12 1.17v3.42a1.15 1.15 0 0 1-1.12 1.17H32.1a1.15 1.15 0 0 1-1.13-1.17v-3.42a1.15 1.15 0 0 1 1.13-1.17zm-18.6-.03h4.6a1.04 1.04 0 0 1 1.18 1v12.07a.9.9 0 0 1-1 1h-4.97a1.03 1.03 0 0 1-1-1.17V20.48a1.18 1.18 0 0 1 1.19-1.17z"></path><path class="svg-outline" d="M9.93 12.3H38.1a1.21 1.21 0 0 1 1.16 1.23l.02 2.28H8.78l-.01-2.28a1.21 1.21 0 0 1 1.16-1.23zM36 28.66H22m11 3.46H22"></path></svg>
|
||||
<a id="c_75628d297d10409585cc1d40bb36f48d_0" class="app-nav-label flat-grid-nav-label" href="#">Arch Panel</a>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
globalThis.ArchPanelTemplates = Object.freeze({
|
||||
hcmTile,
|
||||
});
|
||||
})();
|
||||
966
src/content/workbench-controller.js
Normal file
966
src/content/workbench-controller.js
Normal file
@@ -0,0 +1,966 @@
|
||||
(() => {
|
||||
function create({
|
||||
appState,
|
||||
IDS,
|
||||
DETAIL_SORT_KEYS,
|
||||
hcmPage,
|
||||
workbenchRepository,
|
||||
comcipRepository,
|
||||
readCachedDataset,
|
||||
writeCachedDataset,
|
||||
readCachedWorklistToken,
|
||||
setStoredCalendarView,
|
||||
refreshTaskTypeCache,
|
||||
renderModal,
|
||||
cleanString,
|
||||
normalizeString,
|
||||
toNumber,
|
||||
toIsoDateString,
|
||||
mapWithConcurrency,
|
||||
getLocalDateKey,
|
||||
getErrorMessage,
|
||||
formatWholeNumber,
|
||||
formatProgressPercent,
|
||||
formatDate,
|
||||
teamContainsUser,
|
||||
stripTransientFields,
|
||||
createExportPayload,
|
||||
createDatasetSnapshot,
|
||||
buildRampForecastComparisonRows,
|
||||
parseForecastMonthNumber,
|
||||
createForecastUpdatePayload,
|
||||
normalizeTimeEntriesSummary,
|
||||
getCalendarAnchorDate,
|
||||
}) {
|
||||
let cacheHydrationPromise = null;
|
||||
|
||||
async function refreshData() {
|
||||
if (appState.status === "loading") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
appState.status = "loading";
|
||||
appState.errorMessage = "";
|
||||
updateLoadingProgress("Requesting current user", 4);
|
||||
|
||||
const user = await fetchCurrentUser();
|
||||
|
||||
updateLoadingProgress("Loading current resource user", 8);
|
||||
|
||||
const resourceCurrentUser = await comcipRepository
|
||||
.fetchResourceCurrentUser(user.userEmail)
|
||||
.catch(() => null);
|
||||
|
||||
updateLoadingProgress("Loading customer summary pages", 12);
|
||||
|
||||
const customers = await workbenchRepository.fetchAllCustomers(
|
||||
user.userEmail
|
||||
);
|
||||
|
||||
updateLoadingProgress(
|
||||
`Loading workloads for ${formatWholeNumber(
|
||||
customers.length
|
||||
)} customers`,
|
||||
24
|
||||
);
|
||||
|
||||
let completedCustomers = 0;
|
||||
const customersWithWorkloads = await mapWithConcurrency(
|
||||
customers,
|
||||
4,
|
||||
async (customer, index) => {
|
||||
updateLoadingProgress(
|
||||
`Loading workloads ${index + 1}/${customers.length}: ${customer.name}`,
|
||||
getSegmentProgress(24, 52, completedCustomers, customers.length)
|
||||
);
|
||||
|
||||
const workloads = await workbenchRepository.fetchCustomerWorkloads(
|
||||
customer
|
||||
);
|
||||
completedCustomers += 1;
|
||||
updateLoadingProgress(
|
||||
`Loaded workloads ${completedCustomers}/${customers.length}: ${customer.name}`,
|
||||
getSegmentProgress(24, 52, completedCustomers, customers.length)
|
||||
);
|
||||
|
||||
return {
|
||||
customerId: customer.customerId,
|
||||
name: customer.name,
|
||||
workloads,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const allWorkloads = customersWithWorkloads.flatMap((customer) => {
|
||||
return customer.workloads.map((workload) => ({
|
||||
...workload,
|
||||
customerId: customer.customerId,
|
||||
customerName: customer.name,
|
||||
}));
|
||||
});
|
||||
|
||||
updateLoadingProgress(
|
||||
`Loading actions and service requests for ${formatWholeNumber(
|
||||
allWorkloads.length
|
||||
)} workloads`,
|
||||
56
|
||||
);
|
||||
|
||||
let completedWorkloads = 0;
|
||||
const enrichedWorkloads = await mapWithConcurrency(
|
||||
allWorkloads,
|
||||
6,
|
||||
async (workload, index) => {
|
||||
updateLoadingProgress(
|
||||
`Loading actions and SRs ${index + 1}/${allWorkloads.length}: ${workload.name}`,
|
||||
getSegmentProgress(56, 86, completedWorkloads, allWorkloads.length)
|
||||
);
|
||||
|
||||
const [actions, serviceRequests] = await Promise.all([
|
||||
workbenchRepository.fetchWorkloadActions(workload.workloadId),
|
||||
workbenchRepository.fetchWorkloadServiceRequests(workload.workloadId),
|
||||
]);
|
||||
completedWorkloads += 1;
|
||||
updateLoadingProgress(
|
||||
`Loaded actions and SRs ${completedWorkloads}/${allWorkloads.length}: ${workload.name}`,
|
||||
getSegmentProgress(56, 86, completedWorkloads, allWorkloads.length)
|
||||
);
|
||||
|
||||
const hasSR = serviceRequests.some((serviceRequest) => {
|
||||
return (
|
||||
normalizeString(serviceRequest.status) ===
|
||||
"SVC_PENDING_DELIVERY" &&
|
||||
teamContainsUser(serviceRequest.team, user.userEmail)
|
||||
);
|
||||
});
|
||||
|
||||
const hasAction = actions.some((action) => {
|
||||
return (
|
||||
normalizeString(action.owner) === normalizeString(user.userEmail)
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
...workload,
|
||||
actions,
|
||||
serviceRequests,
|
||||
hasSR,
|
||||
hasAction,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const customersMap = new Map(
|
||||
customersWithWorkloads.map((customer) => [
|
||||
customer.customerId,
|
||||
{
|
||||
customerId: customer.customerId,
|
||||
name: customer.name,
|
||||
workloads: [],
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
for (const workload of enrichedWorkloads) {
|
||||
const customer = customersMap.get(workload.customerId);
|
||||
|
||||
if (customer) {
|
||||
customer.workloads.push(stripTransientFields(workload));
|
||||
}
|
||||
}
|
||||
|
||||
updateLoadingProgress("Loading pending delivery service requests", 90);
|
||||
|
||||
const pendingServiceRequests = await comcipRepository
|
||||
.fetchPendingServiceRequests()
|
||||
.catch(() => []);
|
||||
updateLoadingProgress("Refreshing time management task types", 94);
|
||||
await refreshTaskTypeCache().catch(() => null);
|
||||
|
||||
updateLoadingProgress("Preparing dashboard cache", 96);
|
||||
const finalCustomers = Array.from(customersMap.values());
|
||||
const exportPayload = createExportPayload(
|
||||
user,
|
||||
finalCustomers,
|
||||
pendingServiceRequests,
|
||||
resourceCurrentUser
|
||||
);
|
||||
const dataset = createDatasetSnapshot(exportPayload);
|
||||
|
||||
await writeCachedDataset(exportPayload);
|
||||
updateLoadingProgress("Dashboard ready", 100);
|
||||
|
||||
appState.dataset = dataset;
|
||||
appState.status = "ready";
|
||||
appState.loadingMessage = "";
|
||||
appState.loadingProgress = 0;
|
||||
appState.detailModal = null;
|
||||
renderModal();
|
||||
} catch (error) {
|
||||
appState.status = "error";
|
||||
appState.errorMessage = getErrorMessage(error);
|
||||
appState.loadingMessage = "";
|
||||
appState.loadingProgress = 0;
|
||||
renderModal();
|
||||
}
|
||||
}
|
||||
|
||||
function updateLoadingProgress(message, progress) {
|
||||
appState.loadingMessage = message;
|
||||
appState.loadingProgress = Math.max(
|
||||
0,
|
||||
Math.min(Number(progress) || 0, 100)
|
||||
);
|
||||
|
||||
if (!updateLoadingProgressDom()) {
|
||||
renderModal();
|
||||
window.requestAnimationFrame(() => {
|
||||
updateLoadingProgressDom();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateLoadingProgressDom() {
|
||||
const overlay = document.getElementById(IDS.overlay);
|
||||
const progress = Math.max(0, Math.min(appState.loadingProgress || 0, 100));
|
||||
const stateCopy = overlay?.querySelector(
|
||||
".arch-panel-extension-state-copy"
|
||||
);
|
||||
const progressRoot = overlay?.querySelector(
|
||||
".arch-panel-extension-loading-progress"
|
||||
);
|
||||
const progressBar = overlay?.querySelector(
|
||||
".arch-panel-extension-loading-progress-fill"
|
||||
);
|
||||
const progressTrackValue = overlay?.querySelector(
|
||||
".arch-panel-extension-loading-progress-value"
|
||||
);
|
||||
const shellNote = overlay?.querySelector(
|
||||
".arch-panel-extension-shell-note"
|
||||
);
|
||||
|
||||
if (!stateCopy || !progressRoot || !progressBar) {
|
||||
return false;
|
||||
}
|
||||
|
||||
stateCopy.textContent = appState.loadingMessage;
|
||||
progressRoot.setAttribute("aria-valuenow", String(Math.round(progress)));
|
||||
progressBar.style.width = `${progress}%`;
|
||||
|
||||
if (progressTrackValue) {
|
||||
progressTrackValue.textContent = formatProgressPercent(progress);
|
||||
}
|
||||
|
||||
if (shellNote) {
|
||||
shellNote.textContent = appState.loadingMessage || "Loading";
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getSegmentProgress(start, end, current, total) {
|
||||
const denominator = Math.max(Number(total) || 0, 1);
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min((Number(current) || 0) / denominator, 1)
|
||||
);
|
||||
|
||||
return start + (end - start) * ratio;
|
||||
}
|
||||
|
||||
async function hydrateDatasetFromCache() {
|
||||
if (appState.dataset || cacheHydrationPromise) {
|
||||
return cacheHydrationPromise;
|
||||
}
|
||||
|
||||
appState.status = "loading";
|
||||
appState.errorMessage = "";
|
||||
appState.loadingMessage = "Loading cached snapshot";
|
||||
appState.loadingProgress = 18;
|
||||
renderModal();
|
||||
|
||||
cacheHydrationPromise = (async () => {
|
||||
try {
|
||||
const exportPayload = await readCachedDataset();
|
||||
appState.loadingProgress = exportPayload ? 100 : 0;
|
||||
|
||||
if (exportPayload) {
|
||||
appState.dataset = createDatasetSnapshot(exportPayload);
|
||||
appState.status = "ready";
|
||||
} else {
|
||||
appState.status = "idle";
|
||||
}
|
||||
} catch (error) {
|
||||
appState.status = "error";
|
||||
appState.errorMessage = getErrorMessage(error);
|
||||
} finally {
|
||||
appState.loadingMessage = "";
|
||||
appState.loadingProgress = 0;
|
||||
cacheHydrationPromise = null;
|
||||
renderModal();
|
||||
}
|
||||
})();
|
||||
|
||||
return cacheHydrationPromise;
|
||||
}
|
||||
|
||||
async function fetchCurrentUser() {
|
||||
const currentFrameToken = hcmPage.syncWorklistIframeToken({
|
||||
force: true,
|
||||
})?.token;
|
||||
|
||||
const worklistToken = currentFrameToken || (await readCachedWorklistToken());
|
||||
|
||||
if (!worklistToken) {
|
||||
throw new Error(
|
||||
"Unable to resolve Worklist token. Reload the FuseWelcome page so the Worklist iframe token can be captured before refreshing data."
|
||||
);
|
||||
}
|
||||
|
||||
appState.sessionAuthHeaders = {};
|
||||
|
||||
const user = await workbenchRepository.fetchWorklistCurrentUser(
|
||||
worklistToken
|
||||
);
|
||||
|
||||
appState.sessionAuthHeaders = {};
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function findWorkloadById(workloadId) {
|
||||
if (!appState.dataset || !workloadId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const customer of appState.dataset.customers || []) {
|
||||
for (const workload of customer.workloads || []) {
|
||||
if (workload.workloadId === workloadId) {
|
||||
return {
|
||||
...workload,
|
||||
customerId: workload.customerId || customer.customerId,
|
||||
customerName: workload.customerName || customer.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyForecastUpdateToDataset(workloadId, payload) {
|
||||
if (!appState.dataset || !workloadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousDetailModal = appState.detailModal
|
||||
? {
|
||||
scope: appState.detailModal.scope,
|
||||
value: appState.detailModal.value,
|
||||
sortKey: appState.detailModal.sortKey,
|
||||
sortDirection: appState.detailModal.sortDirection,
|
||||
type: appState.detailModal.type,
|
||||
}
|
||||
: null;
|
||||
const updatesByPeriod = new Map(
|
||||
(payload || []).map((item) => [
|
||||
`${toNumber(item.year)}-${toNumber(item.month)}`,
|
||||
toNumber(item.amount),
|
||||
])
|
||||
);
|
||||
|
||||
function updateForecast(workload) {
|
||||
if (!workload || workload.workloadId !== workloadId) {
|
||||
return workload;
|
||||
}
|
||||
|
||||
workload.forecast = (workload.forecast || []).map((forecastItem) => {
|
||||
const month = parseForecastMonthNumber(forecastItem.month);
|
||||
const year = toNumber(forecastItem.year);
|
||||
const key = `${year}-${month}`;
|
||||
|
||||
if (!updatesByPeriod.has(key)) {
|
||||
return forecastItem;
|
||||
}
|
||||
|
||||
return {
|
||||
...forecastItem,
|
||||
adjustedConsumptionAmount: updatesByPeriod.get(key),
|
||||
updatedBy: appState.dataset.user?.userEmail || forecastItem.updatedBy,
|
||||
updatedDate: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
return workload;
|
||||
}
|
||||
|
||||
for (const customer of appState.dataset.customers || []) {
|
||||
customer.workloads = (customer.workloads || []).map((workload) =>
|
||||
updateForecast(workload)
|
||||
);
|
||||
}
|
||||
|
||||
for (const customer of appState.dataset.exportPayload?.customers || []) {
|
||||
customer.workloads = (customer.workloads || []).map((workload) =>
|
||||
updateForecast(workload)
|
||||
);
|
||||
}
|
||||
|
||||
appState.dataset = createDatasetSnapshot(appState.dataset.exportPayload);
|
||||
|
||||
if (
|
||||
previousDetailModal?.scope &&
|
||||
previousDetailModal.type !== "serviceRequests"
|
||||
) {
|
||||
restoreDetailModal(previousDetailModal);
|
||||
}
|
||||
|
||||
void writeCachedDataset(appState.dataset.exportPayload);
|
||||
}
|
||||
|
||||
function openDetailModal(scope, value) {
|
||||
if (!appState.dataset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eligibleWorkloads = appState.dataset.summary.eligibleWorkloads;
|
||||
let items = [];
|
||||
let label = "";
|
||||
let title = "";
|
||||
|
||||
if (scope === "stage") {
|
||||
items = eligibleWorkloads.filter(
|
||||
(workload) => workload.opportunityForecastTypeGroup === value
|
||||
);
|
||||
label = `Opportunity type: ${value}`;
|
||||
title = `${value} workloads`;
|
||||
} else if (scope === "sr") {
|
||||
const flag = value === "true";
|
||||
items = eligibleWorkloads.filter((workload) => workload.hasSR === flag);
|
||||
label = "Status de SRs";
|
||||
title = flag ? "Workloads com SR" : "Workloads sem SR";
|
||||
} else if (scope === "action") {
|
||||
const flag = value === "true";
|
||||
items = eligibleWorkloads.filter((workload) => workload.hasAction === flag);
|
||||
label = "Status de Consumption Plan/action";
|
||||
title = flag ? "Workloads com action" : "Workloads sem action";
|
||||
} else if (scope === "sr-hours") {
|
||||
const pendingServiceRequests =
|
||||
appState.dataset.summary.pendingServiceRequests || [];
|
||||
const isAboveFour = value === "above-4";
|
||||
items = pendingServiceRequests.filter((serviceRequest) => {
|
||||
const hours = toNumber(serviceRequest.totalHoursWorked);
|
||||
return isAboveFour ? hours > 4 : hours >= 0 && hours <= 4;
|
||||
});
|
||||
items.sort((left, right) => {
|
||||
return toNumber(left.totalHoursWorked) - toNumber(right.totalHoursWorked);
|
||||
});
|
||||
label = "SRs por horas reportadas";
|
||||
title = isAboveFour ? "SRs acima de 4h" : "SRs entre 0 e 4h";
|
||||
} else if (scope === "calendar-day") {
|
||||
const allWorkloads = appState.dataset.summary.allWorkloads || [];
|
||||
items = allWorkloads.filter((workload) => {
|
||||
return getLocalDateKey(workload.consumptionStartDate) === value;
|
||||
});
|
||||
label = "Workload start calendar";
|
||||
title = `Workloads starting ${formatDate(value) || value}`;
|
||||
}
|
||||
|
||||
appState.detailModal = {
|
||||
label,
|
||||
title,
|
||||
items,
|
||||
totalAcr:
|
||||
scope === "sr-hours"
|
||||
? 0
|
||||
: items.reduce((sum, workload) => sum + workload.adjustedACR, 0),
|
||||
sortKey: DETAIL_SORT_KEYS.adjustedACR,
|
||||
sortDirection: "desc",
|
||||
scope,
|
||||
value,
|
||||
type: scope === "sr-hours" ? "serviceRequests" : "workloads",
|
||||
};
|
||||
|
||||
renderModal();
|
||||
}
|
||||
|
||||
function openActionFormModal(workloadId) {
|
||||
appState.actionFormModal = {
|
||||
workloadId,
|
||||
errorMessage: "",
|
||||
isSubmitting: false,
|
||||
};
|
||||
renderModal();
|
||||
}
|
||||
|
||||
function openRampComparisonModal(workloadId) {
|
||||
const workload = findWorkloadById(workloadId);
|
||||
|
||||
if (!workload) {
|
||||
return;
|
||||
}
|
||||
|
||||
appState.rampComparisonModal = {
|
||||
workload,
|
||||
rows: buildRampForecastComparisonRows([workload]),
|
||||
};
|
||||
renderModal();
|
||||
}
|
||||
|
||||
function openForecastUpdateConfirmModal() {
|
||||
if (!appState.rampComparisonModal || !appState.dataset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = createForecastUpdatePayload(
|
||||
appState.rampComparisonModal.rows || [],
|
||||
appState.dataset?.user?.userEmail || ""
|
||||
);
|
||||
|
||||
appState.forecastUpdateConfirmModal = {
|
||||
workloadId: appState.rampComparisonModal.workload.workloadId,
|
||||
payload,
|
||||
isSubmitting: false,
|
||||
errorMessage: "",
|
||||
};
|
||||
renderModal();
|
||||
}
|
||||
|
||||
async function submitForecastUpdate() {
|
||||
const modal = appState.forecastUpdateConfirmModal;
|
||||
|
||||
if (!modal || modal.isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
appState.forecastUpdateConfirmModal = {
|
||||
...modal,
|
||||
isSubmitting: true,
|
||||
errorMessage: "",
|
||||
};
|
||||
renderModal();
|
||||
|
||||
try {
|
||||
const workloadId = cleanString(modal.workloadId);
|
||||
await workbenchRepository.updateWorkloadForecast(
|
||||
workloadId,
|
||||
modal.payload
|
||||
);
|
||||
|
||||
applyForecastUpdateToDataset(workloadId, modal.payload);
|
||||
const updatedWorkload = findWorkloadById(workloadId);
|
||||
|
||||
appState.forecastUpdateConfirmModal = null;
|
||||
|
||||
if (updatedWorkload) {
|
||||
appState.rampComparisonModal = {
|
||||
workload: updatedWorkload,
|
||||
rows: buildRampForecastComparisonRows([updatedWorkload]),
|
||||
};
|
||||
}
|
||||
|
||||
renderModal();
|
||||
} catch (error) {
|
||||
appState.forecastUpdateConfirmModal = {
|
||||
...modal,
|
||||
isSubmitting: false,
|
||||
errorMessage: getErrorMessage(error),
|
||||
};
|
||||
renderModal();
|
||||
}
|
||||
}
|
||||
|
||||
async function openTimeEntriesDrawer(srNumber) {
|
||||
const normalizedSrNumber = cleanString(srNumber);
|
||||
|
||||
if (!normalizedSrNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
appState.timeEntriesDrawer = {
|
||||
srNumber: normalizedSrNumber,
|
||||
status: "loading",
|
||||
errorMessage: "",
|
||||
payload: null,
|
||||
items: [],
|
||||
};
|
||||
renderModal();
|
||||
|
||||
try {
|
||||
const response = await comcipRepository.fetchTimeEntriesSummary(
|
||||
normalizedSrNumber
|
||||
);
|
||||
|
||||
appState.timeEntriesDrawer = {
|
||||
srNumber: normalizedSrNumber,
|
||||
status: "ready",
|
||||
errorMessage: "",
|
||||
payload: response.payload,
|
||||
items: normalizeTimeEntriesSummary(response.payload),
|
||||
};
|
||||
} catch (error) {
|
||||
appState.timeEntriesDrawer = {
|
||||
srNumber: normalizedSrNumber,
|
||||
status: "error",
|
||||
errorMessage: getErrorMessage(error),
|
||||
payload: null,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
renderModal();
|
||||
}
|
||||
|
||||
function shiftCalendarPeriod(delta) {
|
||||
const nextDate =
|
||||
appState.calendarView === "week"
|
||||
? getShiftedCalendarWeekDate(delta)
|
||||
: new Date(appState.calendarYear, appState.calendarMonth + delta, 1);
|
||||
|
||||
appState.calendarYear = nextDate.getFullYear();
|
||||
appState.calendarMonth = nextDate.getMonth();
|
||||
appState.calendarAnchorDateKey = getLocalDateKey(nextDate);
|
||||
renderModal();
|
||||
}
|
||||
|
||||
function goToCurrentCalendarPeriod() {
|
||||
const today = new Date();
|
||||
appState.calendarYear = today.getFullYear();
|
||||
appState.calendarMonth = today.getMonth();
|
||||
appState.calendarAnchorDateKey = getLocalDateKey(today);
|
||||
renderModal();
|
||||
}
|
||||
|
||||
function setCalendarView(view) {
|
||||
const normalizedView = view === "week" ? "week" : "month";
|
||||
|
||||
if (appState.calendarView === normalizedView) {
|
||||
return;
|
||||
}
|
||||
|
||||
appState.calendarView = normalizedView;
|
||||
setStoredCalendarView(normalizedView);
|
||||
|
||||
if (normalizedView === "week") {
|
||||
const today = new Date();
|
||||
const anchorDate =
|
||||
today.getFullYear() === appState.calendarYear &&
|
||||
today.getMonth() === appState.calendarMonth
|
||||
? today
|
||||
: new Date(appState.calendarYear, appState.calendarMonth, 1);
|
||||
|
||||
appState.calendarAnchorDateKey = getLocalDateKey(anchorDate);
|
||||
appState.calendarYear = anchorDate.getFullYear();
|
||||
appState.calendarMonth = anchorDate.getMonth();
|
||||
} else {
|
||||
const anchorDate = getCalendarAnchorDate(
|
||||
appState.calendarYear,
|
||||
appState.calendarMonth,
|
||||
appState.calendarAnchorDateKey
|
||||
);
|
||||
|
||||
appState.calendarYear = anchorDate.getFullYear();
|
||||
appState.calendarMonth = anchorDate.getMonth();
|
||||
}
|
||||
|
||||
renderModal();
|
||||
}
|
||||
|
||||
function shiftCalendarMonth(delta) {
|
||||
shiftCalendarPeriod(delta);
|
||||
}
|
||||
|
||||
function goToCurrentCalendarMonth() {
|
||||
goToCurrentCalendarPeriod();
|
||||
}
|
||||
|
||||
function getShiftedCalendarWeekDate(delta) {
|
||||
const anchorDate = getCalendarAnchorDate(
|
||||
appState.calendarYear,
|
||||
appState.calendarMonth,
|
||||
appState.calendarAnchorDateKey
|
||||
);
|
||||
|
||||
anchorDate.setDate(anchorDate.getDate() + delta * 7);
|
||||
return anchorDate;
|
||||
}
|
||||
|
||||
async function submitActionForm(form) {
|
||||
if (!appState.dataset || !appState.actionFormModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.reportValidity()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
const parentId = cleanString(formData.get("parentId"));
|
||||
const ownerEmail = cleanString(formData.get("ownerEmail"));
|
||||
const startDate = toIsoDateString(formData.get("startDate"));
|
||||
const endDate = toIsoDateString(formData.get("endDate"));
|
||||
const payload = {
|
||||
parentType: "WORKLOAD",
|
||||
parentId,
|
||||
action: {
|
||||
ownerEmail,
|
||||
role: "CLOUD_ARCHITECT",
|
||||
name: "SUCCESS_PLAN_CONSUM_RVW",
|
||||
team: cleanString(formData.get("team")),
|
||||
tags: "",
|
||||
startDate,
|
||||
endDate,
|
||||
status: "COMPLETE",
|
||||
complexity: cleanString(formData.get("complexity")),
|
||||
notes: [],
|
||||
objective: "",
|
||||
createdBy: cleanString(formData.get("createdBy")),
|
||||
updatedBy: cleanString(formData.get("updatedBy")),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
appState.actionFormModal = {
|
||||
...appState.actionFormModal,
|
||||
isSubmitting: true,
|
||||
errorMessage: "",
|
||||
};
|
||||
renderModal();
|
||||
|
||||
await workbenchRepository.createWorkloadAction(payload);
|
||||
|
||||
await applyCreatedActionToDataset(payload);
|
||||
appState.actionFormModal = null;
|
||||
renderModal();
|
||||
} catch (error) {
|
||||
appState.actionFormModal = {
|
||||
...appState.actionFormModal,
|
||||
isSubmitting: false,
|
||||
errorMessage: getErrorMessage(error),
|
||||
};
|
||||
renderModal();
|
||||
}
|
||||
}
|
||||
|
||||
async function applyCreatedActionToDataset(payload) {
|
||||
if (!appState.dataset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedCustomers = (appState.dataset.customers || []).map(
|
||||
(customer) => ({
|
||||
...customer,
|
||||
workloads: (customer.workloads || []).map((workload) => {
|
||||
if (workload.workloadId !== payload.parentId) {
|
||||
return workload;
|
||||
}
|
||||
|
||||
return {
|
||||
...workload,
|
||||
hasAction: true,
|
||||
actions: [
|
||||
...(workload.actions || []),
|
||||
{
|
||||
owner: payload.action.ownerEmail,
|
||||
role: payload.action.role,
|
||||
name: payload.action.name,
|
||||
team: payload.action.team,
|
||||
startDate: payload.action.startDate,
|
||||
endDate: payload.action.endDate,
|
||||
complexity: payload.action.complexity,
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
})
|
||||
);
|
||||
const exportPayload = createExportPayload(
|
||||
appState.dataset.user,
|
||||
updatedCustomers,
|
||||
appState.dataset.exportPayload?.pendingServiceRequests || [],
|
||||
appState.dataset.exportPayload?.resourceCurrentUser || null
|
||||
);
|
||||
const nextDataset = createDatasetSnapshot(exportPayload);
|
||||
const previousDetailModal = appState.detailModal
|
||||
? {
|
||||
scope: appState.detailModal.scope,
|
||||
value: appState.detailModal.value,
|
||||
sortKey: appState.detailModal.sortKey,
|
||||
sortDirection: appState.detailModal.sortDirection,
|
||||
}
|
||||
: null;
|
||||
|
||||
appState.dataset = nextDataset;
|
||||
await writeCachedDataset(exportPayload);
|
||||
|
||||
if (previousDetailModal?.scope) {
|
||||
restoreDetailModal(previousDetailModal);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDetailModal(detailState) {
|
||||
const eligibleWorkloads =
|
||||
appState.dataset?.summary?.eligibleWorkloads || [];
|
||||
let items = [];
|
||||
let label = "";
|
||||
let title = "";
|
||||
|
||||
if (detailState.scope === "stage") {
|
||||
items = eligibleWorkloads.filter(
|
||||
(workload) =>
|
||||
workload.opportunityForecastTypeGroup === detailState.value
|
||||
);
|
||||
label = `Opportunity type: ${detailState.value}`;
|
||||
title = `${detailState.value} workloads`;
|
||||
} else if (detailState.scope === "sr") {
|
||||
const flag = detailState.value === "true";
|
||||
items = eligibleWorkloads.filter((workload) => workload.hasSR === flag);
|
||||
label = "Status de SRs";
|
||||
title = flag ? "Workloads com SR" : "Workloads sem SR";
|
||||
} else if (detailState.scope === "action") {
|
||||
const flag = detailState.value === "true";
|
||||
items = eligibleWorkloads.filter((workload) => workload.hasAction === flag);
|
||||
label = "Status de Consumption Plan/action";
|
||||
title = flag ? "Workloads com action" : "Workloads sem action";
|
||||
} else if (detailState.scope === "calendar-day") {
|
||||
const allWorkloads = appState.dataset?.summary?.allWorkloads || [];
|
||||
items = allWorkloads.filter((workload) => {
|
||||
return getLocalDateKey(workload.consumptionStartDate) === detailState.value;
|
||||
});
|
||||
label = "Workload start calendar";
|
||||
title = `Workloads starting ${
|
||||
formatDate(detailState.value) || detailState.value
|
||||
}`;
|
||||
}
|
||||
|
||||
appState.detailModal = {
|
||||
label,
|
||||
title,
|
||||
items,
|
||||
totalAcr: items.reduce((sum, workload) => sum + workload.adjustedACR, 0),
|
||||
sortKey: detailState.sortKey || DETAIL_SORT_KEYS.adjustedACR,
|
||||
sortDirection: detailState.sortDirection || "desc",
|
||||
scope: detailState.scope,
|
||||
value: detailState.value,
|
||||
type: "workloads",
|
||||
};
|
||||
}
|
||||
|
||||
function toggleDetailSort(sortKey) {
|
||||
if (!appState.detailModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextDirection =
|
||||
appState.detailModal.sortKey === sortKey &&
|
||||
appState.detailModal.sortDirection === "asc"
|
||||
? "desc"
|
||||
: "asc";
|
||||
|
||||
appState.detailModal = {
|
||||
...appState.detailModal,
|
||||
sortKey,
|
||||
sortDirection: nextDirection,
|
||||
};
|
||||
|
||||
renderModal();
|
||||
}
|
||||
|
||||
function getSortedDetailItems() {
|
||||
if (!appState.detailModal) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return sortWorkloads(
|
||||
appState.detailModal.items,
|
||||
appState.detailModal.sortKey,
|
||||
appState.detailModal.sortDirection
|
||||
);
|
||||
}
|
||||
|
||||
function sortWorkloads(items, sortKey, sortDirection = "asc") {
|
||||
const factor = sortDirection === "desc" ? -1 : 1;
|
||||
|
||||
return [...items].sort((left, right) => {
|
||||
const comparison = compareWorkloadValues(
|
||||
getSortableWorkloadValue(left, sortKey),
|
||||
getSortableWorkloadValue(right, sortKey)
|
||||
);
|
||||
|
||||
if (comparison !== 0) {
|
||||
return comparison * factor;
|
||||
}
|
||||
|
||||
return compareWorkloadValues(
|
||||
getSortableWorkloadValue(left, DETAIL_SORT_KEYS.workload),
|
||||
getSortableWorkloadValue(right, DETAIL_SORT_KEYS.workload)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getSortableWorkloadValue(workload, sortKey) {
|
||||
switch (sortKey) {
|
||||
case DETAIL_SORT_KEYS.opportunityNumber:
|
||||
return cleanString(workload.opportunityNumber);
|
||||
case DETAIL_SORT_KEYS.customerName:
|
||||
return cleanString(workload.customerName);
|
||||
case DETAIL_SORT_KEYS.workload:
|
||||
return `${cleanString(workload.name)}|${cleanString(
|
||||
workload.description
|
||||
)}`;
|
||||
case DETAIL_SORT_KEYS.rampMonths:
|
||||
return toNumber(workload.rampMonths);
|
||||
case DETAIL_SORT_KEYS.adjustedACR:
|
||||
return toNumber(workload.adjustedACR);
|
||||
case DETAIL_SORT_KEYS.opportunityForecastTypeGroup:
|
||||
return cleanString(workload.opportunityForecastTypeGroup);
|
||||
case DETAIL_SORT_KEYS.hasSR:
|
||||
return Boolean(workload.hasSR);
|
||||
case DETAIL_SORT_KEYS.hasAction:
|
||||
return Boolean(workload.hasAction);
|
||||
default:
|
||||
return cleanString(workload.name);
|
||||
}
|
||||
}
|
||||
|
||||
function compareWorkloadValues(left, right) {
|
||||
if (typeof left === "number" || typeof right === "number") {
|
||||
return toNumber(left) - toNumber(right);
|
||||
}
|
||||
|
||||
if (typeof left === "boolean" || typeof right === "boolean") {
|
||||
return Number(Boolean(left)) - Number(Boolean(right));
|
||||
}
|
||||
|
||||
return String(left || "").localeCompare(String(right || ""), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
refreshData,
|
||||
hydrateDatasetFromCache,
|
||||
findWorkloadById,
|
||||
openDetailModal,
|
||||
openActionFormModal,
|
||||
openRampComparisonModal,
|
||||
openForecastUpdateConfirmModal,
|
||||
submitForecastUpdate,
|
||||
openTimeEntriesDrawer,
|
||||
shiftCalendarPeriod,
|
||||
goToCurrentCalendarPeriod,
|
||||
setCalendarView,
|
||||
shiftCalendarMonth,
|
||||
goToCurrentCalendarMonth,
|
||||
submitActionForm,
|
||||
toggleDetailSort,
|
||||
getSortedDetailItems,
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.ArchPanelWorkbenchController = Object.freeze({
|
||||
create,
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user