Partially implemented the Manage Time functionality

This commit is contained in:
2026-10-16 19:50:39 -03:00
parent 2ae057cc90
commit d392e5ddd7
28 changed files with 2528 additions and 64 deletions

View File

@@ -7,6 +7,7 @@ function createBackgroundApplication(overrides = {}) {
...overrides
};
const messageTypes = createMessageTypes();
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message) });
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
const contracts = createBackgroundContracts();
@@ -127,6 +128,7 @@ function createBackgroundApplication(overrides = {}) {
const runtimeApi = environment.browser || environment.chrome;
const srRequestHeaders = createSrRequestHeaders({ runtimeApi });
const fusionSrCreate = createFusionSrCreate({
runtimeApi,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
tokenHeaders: (...args) => srRequestHeaders.tokenHeaders(...args),
internalServiceRequestHeaders: url => srRequestHeaders.internalServiceRequestHeaders(url),
@@ -144,6 +146,12 @@ function createBackgroundApplication(overrides = {}) {
function start() {
srRequestHeaders.start();
startBackgroundApplication({
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
requestResourceUser: message => fusionSrCreate.getResourceUser(message),
requestActiveServiceRequests: message => timeEntries.requestActiveServiceRequests(message),
requestLookupTypePlatform: () => fusionSrDetails.requestLookupTypePlatform(),
requestPostInternalServiceRequest: message => fusionSrCreate.requestPostInternalServiceRequest(message),
requestInternalSrServices: message => fusionSrDetails.requestInternalSrServices(message),

View File

@@ -1,7 +1,13 @@
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
messageTypes,
requestWeekDetails,
requestLookupTaskType,
requestServiceRequestExpand,
requestResourceUser,
requestActiveServiceRequests,
invalidateProductsCache,
invalidateResourceUser = async () => {},
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
@@ -34,6 +40,17 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) {
return false;
}
if (message.type === messageTypes.requestLookupTaskType) {
requestLookupTaskType().then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true;
}
if ([messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
const request = message.type === messageTypes.requestServiceRequestExpand ? requestServiceRequestExpand : message.type === messageTypes.requestWeekDetails ? requestWeekDetails : message.type === messageTypes.requestResourceUser ? requestResourceUser : requestActiveServiceRequests;
request(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.requestPostInternalServiceRequest) {
requestPostInternalServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
@@ -64,7 +81,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
}
if (message.type === messageTypes.invalidateProductsCache) {
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
invalidateProductsCache().then(() => invalidateResourceUser()).then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
return true;
}
if (message.type === messageTypes.getXsrfToken) {

View File

@@ -56,8 +56,9 @@ function createContentApplication(overrides = {}) {
environment
});
const contentModal = createContentModal({
createManageTimeButton: () => manageTime.createButton(),
prepareSrSession: () => srSessionPreparation.ensure(),
closeActionForm: (...args) => internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
closeActionForm: (...args) => manageTime.close(...args) || internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
mountOpportunityViews: (...args) => opportunityCalendar.mount(...args),
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
closeCrmSrPopover: () => crmSrPopover.close(),
@@ -135,6 +136,34 @@ function createContentApplication(overrides = {}) {
environment
});
const svgPaths = createSvgPaths({ environment });
const manageTime = createManageTime({ MODAL_ID: contracts.MODAL_ID, environment, searchableSelect, dates: createTimeDates(),
async requestTaskTypes() {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestLookupTaskType });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load Task Types.");
return result.items;
},
async requestExpand(srNumbers) {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestServiceRequestExpand, srNumbers });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load activities.");
return result.items;
},
async requestServices(weekId) {
await srSessionPreparation.ensure();
await currentUser.ensure();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestActiveServiceRequests, weekId, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load Service Requests.");
return result.items;
},
async requestWeek(dateReference) {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestWeekDetails, dateReference });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load week details.");
return result.items;
}
});
const contentStyles = createContentStyles({
STYLE_ID: contracts.STYLE_ID,
redwoodStyles,
@@ -173,6 +202,7 @@ function createContentApplication(overrides = {}) {
environment
});
const platformContentMessages = createPlatformContentMessages({
requestCache: createFormRequestCache({ getHours: () => preferencesState.opptyProductsCacheHours, getUserId: () => currentUser.getUserId() }),
messageTypes,
environment
});
@@ -434,10 +464,15 @@ function createContentApplication(overrides = {}) {
});
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment });
const srSessionPreparation = createSrSessionPreparation({
async onReady() {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestResourceUser, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load resource user.");
},
ensureCurrentUser: () => currentUser.ensure(),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
sendRequest: () => platformContentMessages.sendRuntimeMessage({ type: messageTypes.prepareSrSession }),
reportProgress: state => activityProgress.update("session", state),
reportProgress: state => { activityProgress.update("session", state); manageTime.setReady(state === "complete"); },
appendLog: entries => debugContentLog.appendBackgroundRequestLog(entries)
});
const activityFilters = createActivityFilters({

View File

@@ -34,6 +34,7 @@ function createContentModal({
closePreferencesModal,
createSettingsIcon,
createRefreshAllButton,
createManageTimeButton,
createActivityFilterControls,
mountOpportunityViews,
debugState,
@@ -176,7 +177,7 @@ function createContentModal({
const headerActions = document.createElement("div");
headerActions.className = "opportunities-extension-header-actions";
headerActions.append(themeButton, createRefreshAllButton(() => select.value), preferencesButton, closeButton);
headerActions.append(themeButton, createRefreshAllButton(() => select.value), createManageTimeButton(), preferencesButton, closeButton);
header.append(titleBlock, headerActions);

View File

@@ -0,0 +1,52 @@
.oe-time-dialog { --time-bg: #fff; --time-input-bg: #fff; --time-muted: #faf9f8; --time-border: #dedbd7; --time-input-border: #8f8983; --time-accent: #00758f; width: min(1600px, 100%); max-height: calc(100dvh - 24px); display: flex; flex-direction: column; overflow: hidden; font-size: 13px; line-height: 20px; }
.opportunities-extension-modal[data-theme="dark"] .oe-time-dialog { --time-bg: #292725; --time-input-bg: #211f1d; --time-muted: #35322f; --time-border: #514c47; --time-input-border: #918a82; --time-accent: #80d6e8; }
.oe-time-modal { grid-template-columns: minmax(0, 1fr); }
.oe-time-dialog, .oe-time-body { min-width: 0; }
.oe-time-dialog * { box-sizing: border-box; }
.oe-time-dialog [hidden] { display: none !important; }
.oe-time-header { flex-shrink: 0; }
.oe-time-body { padding: 18px; overflow: auto; min-height: 0; }
.oe-time-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.oe-time-navigation { display: flex; align-items: center; gap: 8px; }
.oe-time-ending { display: grid; gap: 4px; text-align: right; }
.oe-time-summary { display: grid; grid-template-columns: repeat(4, 1fr); padding: 12px 0; gap: 12px; text-align: center; border-block: 1px solid var(--time-border); margin-bottom: 18px; }
.oe-time-body button { min-width: 38px; min-height: 38px; display: inline-flex; gap: 8px; align-items: center; justify-content: center; color: inherit; background: transparent; border: 1px solid var(--time-input-border); border-radius: 4px; padding: 7px; cursor: pointer; font: inherit; }
.oe-time-dialog button:hover:not(:disabled) { background: var(--time-muted); border-color: var(--time-accent); }
.oe-time-dialog svg { width: 17px; height: 17px; flex-shrink: 0; }
.oe-time-dialog input, .oe-time-dialog select, .oe-time-dialog textarea { font: inherit; line-height: 20px; color: inherit; background: var(--time-input-bg); border: 1px solid var(--time-input-border); border-radius: 4px; padding: 8px; min-height: 38px; }
.oe-time-dialog select { height: 38px; }
.oe-time-dialog :focus-visible { outline: 2px solid var(--time-accent); outline-offset: 2px; }
.oe-time-dialog :disabled { opacity: .55; cursor: not-allowed; }
.oe-time-grid { overflow-x: auto; }
.oe-time-grid table { width: 100%; min-width: 1060px; border-collapse: collapse; table-layout: fixed; }
.oe-time-grid th { background: var(--time-muted); padding: 10px 5px; font-weight: 600; text-align: center; border-bottom: 1px solid var(--time-border); }
.oe-time-grid th:first-child { width: 23%; }
.oe-time-grid th:nth-child(2), .oe-time-grid th:nth-child(3) { width: 12%; }
.oe-time-grid th:nth-last-child(-n+3) { width: 46px; }
.oe-time-grid td { padding: 10px 5px; border-bottom: 1px solid var(--time-border); vertical-align: middle; text-align: center; }
.oe-time-grid select { width: 100%; }
.oe-time-grid input[type="number"] { width: min(100%, 60px); padding: 6px 3px; text-align: center; }
.oe-time-grid .opportunities-extension-action-combobox input { width: 100%; text-align: left; text-overflow: ellipsis; }
.oe-time-grid .opportunities-extension-action-options { text-align: left; }
.oe-time-dialog .opportunities-extension-select-tooltip { position: fixed; z-index: 100; width: min(560px, calc(100vw - 40px)); max-height: min(440px, calc(100dvh - 48px)); overflow: auto; padding: 14px; border: 1px solid var(--time-input-border); border-radius: 4px; background: var(--time-input-bg); color: inherit; box-shadow: 0 4px 16px #0003; text-align: left; }
.oe-time-sr-details { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 18px; margin: 0; }
.oe-time-sr-details > div { min-width: 0; overflow-wrap: anywhere; }
.oe-time-sr-details dt { font-size: 11px; opacity: .75; margin-bottom: 2px; }
.oe-time-sr-details dd { margin: 0; white-space: pre-wrap; }
.oe-time-sr-details .oe-time-sr-description { grid-column: 1 / -1; }
.oe-time-day > span { display: block; }
.oe-time-day .oe-time-day-label { display: inline-flex; align-items: center; gap: 4px; margin-bottom: 9px; }
.oe-time-day-total { font-size: 10px; background: var(--time-accent); color: var(--time-bg); min-width: 16px; border-radius: 8px; padding: 2px 4px; }
.oe-time-notes textarea { width: 100%; resize: vertical; }
.oe-time-notes-box { position: relative; text-align: left; background: var(--time-input-bg); border: 1px solid var(--time-input-border); border-radius: 4px; padding: 14px 12px 42px; }
.oe-time-notes-count { display: block; font-weight: 600; opacity: .8; margin-bottom: 8px; }
.oe-time-notes-box textarea { border: 0; min-height: 128px; padding: 0; }
.oe-time-notes-box .oe-time-notes-collapse { position: absolute; right: 0; bottom: 0; background: var(--time-muted); }
.oe-time-footer { flex-wrap: wrap; justify-content: flex-start; margin-top: 18px; }
.oe-time-footer button { padding: 7px 14px; }
.oe-time-status { padding: 12px 0; }
.oe-time-status[role="alert"] { color: #a12d27; }
.oe-time-dialog [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
.opportunities-extension-modal[data-theme="dark"] .oe-time-status[role="alert"] { color: #ffd9d5; }
.opportunities-extension-modal[data-theme="dark"] .oe-time-dialog [aria-invalid="true"] { border-color: #ffaaa4; outline-color: #ffaaa4; }
@media (max-width: 700px) { .oe-time-modal { padding: 8px; } .oe-time-dialog { max-height: calc(100dvh - 16px); } .oe-time-body { padding: 12px; } .oe-time-summary { grid-template-columns: repeat(2, 1fr); text-align: left; } .oe-time-toolbar { flex-wrap: wrap; } .oe-time-ending { text-align: left; } }

View File

@@ -0,0 +1,325 @@
function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requestWeek, requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) {
const { document } = environment;
let active = null;
let ready = false;
let launcher = null;
function setReady(value) {
ready = !!value;
if (launcher) { launcher.disabled = !ready; launcher.title = ready ? "Manage Time" : "Waiting for SR session ready"; }
}
function node(tag, className = "", text) {
const el = document.createElement(tag);
el.className = className;
if (text !== undefined) el.textContent = text;
return el;
}
function icon(name) {
const paths = {
clock: "M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0ZM12 6v6l4 2",
left: "m15 18-6-6 6-6", right: "m9 18 6-6-6-6", close: "m6 6 12 12M18 6 6 18",
up: "m6 15 6-6 6 6",
add: "M12 5v14M5 12h14", copy: "M9 9h12v12H9ZM5 15H3V3h12v2",
notes: "M4 3h16v18H4ZM8 7h8M8 11h8M8 15h5",
trash: "M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7",
save: "M5 3h12l4 4v14H3V3ZM7 3v6h10V3M7 21v-8h10v8"
};
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", paths[name]);
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.8");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
svg.append(path);
return svg;
}
function button(label, symbol, onClick, text = false) {
const el = node("button");
el.type = "button"; el.title = label;
el.setAttribute("aria-label", label);
el.append(icon(symbol));
if (text) el.append(node("span", "", label));
if (onClick) el.addEventListener("click", onClick);
return el;
}
function createButton() {
const trigger = button("Manage Time", "clock", () => open(trigger));
trigger.className = "opportunities-extension-icon-button";
launcher = trigger; setReady(ready);
return trigger;
}
function close(restoreFocus = true) {
if (!active) return false;
const previous = active; active = null;
previous.modal.remove();
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function open(trigger) {
if (!ready) return;
const host = document.getElementById(MODAL_ID);
if (!host || host.hidden) return;
close(false);
const modal = node("section", "opportunities-extension-action-form-modal oe-time-modal");
modal.setAttribute("role", "dialog"); modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "oe-time-title");
const dialog = node("div", "opportunities-extension-action-form-dialog oe-time-dialog");
const header = node("header", "opportunities-extension-account-plans-header oe-time-header");
const title = node("h2", "", "Manage Time"); title.id = "oe-time-title";
const closeButton = button("Close Manage Time", "close", () => close());
closeButton.className = "opportunities-extension-icon-button";
header.append(title, closeButton);
const body = node("div", "oe-time-body");
const toolbar = node("div", "oe-time-toolbar");
const navigation = node("div", "oe-time-navigation");
const date = node("input"); date.type = "date"; date.value = dates.today(); date.required = true;
date.setAttribute("aria-label", "Reference date");
const ending = node("div", "oe-time-ending");
const endingValue = node("strong", "", "--");
ending.append(node("span", "", "Week Ending"), endingValue);
const changeWeek = days => { if (dates.parse(date.value)) { date.value = dates.shift(date.value, days); load(); } };
navigation.append(button("Previous week", "left", () => changeWeek(-7)), date, button("Next week", "right", () => changeWeek(7)));
date.addEventListener("change", load); toolbar.append(navigation, ending);
const summary = node("div", "oe-time-summary"), sums = {};
for (const label of ["Service", "Non Service", "Time Off", "Total Hours"]) {
const item = node("div"); sums[label] = node("strong", "", "0 hrs");
item.append(node("span", "", `${label}: `), sums[label]); summary.append(item);
}
const status = node("div", "oe-time-status"); status.setAttribute("role", "status");
const retry = button("Retry", "clock", load, true); retry.hidden = true;
const serviceStatus = node("div", "oe-time-status"); serviceStatus.setAttribute("role", "status"); serviceStatus.hidden = true;
const serviceRetry = button("Retry Service Requests", "clock", () => loadServices(entry.initialWeekId, true), true); serviceRetry.hidden = true;
const taskStatus = node("div", "oe-time-status"); taskStatus.setAttribute("role", "status");
const taskRetry = button("Retry Task Types", "clock", loadTaskTypes, true); taskRetry.hidden = true;
const scroll = node("div", "oe-time-grid"), table = node("table");
table.setAttribute("aria-label", "Weekly time entries"); scroll.append(table);
const footer = node("footer", "opportunities-extension-action-form-footer oe-time-footer");
const add = button("Add", "add", () => addRow("Service"), true);
const addNonService = button("Add (Non service)", "add", () => addRow("Non Service"), true);
const save = button("Save", "save", null, true); save.disabled = true;
save.title = "Saving time entries is not available yet.";
footer.append(add, addNonService, save);
body.append(toolbar, summary, status, retry, serviceStatus, serviceRetry, taskStatus, taskRetry, scroll, footer);
dialog.append(header, body); modal.append(dialog); host.append(modal);
const entry = { modal, trigger, version: 0, days: [], rows: [], drafts: new Map(), tbody: null, dayTotals: [], services: [], servicesStarted: false, servicesReady: false, expanded: new Map() };
active = entry;
entry.taskTypes = [];
entry.taskTypesReady = false;
let combos = [];
modal.addEventListener("scroll", event => {
if (event.target === body || event.target === scroll) combos.forEach(combo => combo.sync());
}, true);
modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key !== "Tab") return;
const elements = [...dialog.querySelectorAll("button:not(:disabled), input:not(:disabled), textarea:not(:disabled)")].filter(el => !el.hidden && el.getClientRects().length);
const first = elements[0], last = elements[elements.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
});
function blankRow(type) { return { type, hours: {}, notes: "", srNumber: "", activity: "", taskType: "", notesOpen: false }; }
function total(value) { return Number(Number(value).toFixed(2)).toString(); }
function updateTotals() {
const groups = { Service: 0, "Non Service": 0, "Time Off": 0, "Total Hours": 0 };
entry.days.forEach((day, i) => {
let sum = 0;
entry.rows.forEach(row => {
const hours = Number(row.hours[day.Date]) || 0;
sum += hours; groups[row.type] += hours; groups["Total Hours"] += hours;
});
entry.dayTotals[i].textContent = total(sum);
});
Object.entries(groups).forEach(([label, value]) => { sums[label].textContent = `${total(value)} hrs`; });
}
function addRow(type, source) {
if (!entry.tbody || add.disabled) return;
entry.rows.push(source ? { ...source, hours: { ...source.hours } } : blankRow(type)); renderRows();
}
function renderRows() {
const focusedId = document.activeElement?.id;
combos = [];
entry.tbody.replaceChildren();
entry.rows.forEach((row, index) => {
const tr = node("tr");
for (const label of [row.type === "Service" ? "Service Request" : "Non Service", "Activity", "Task Type"]) {
const td = node("td"), select = node("select");
select.id = `oe-time-${index}-${label.toLowerCase().replaceAll(" ", "-")}`;
let change = () => {};
select.setAttribute("aria-label", `${label}, row ${index + 1}`);
const placeholder = node("option", "", label); placeholder.value = ""; select.append(placeholder);
if (label === "Service Request") {
for (const sr of entry.services) {
const option = node("option", "", sr.srTitle ? `${sr.srTitle} (${sr.srNumber})` : sr.srNumber);
option.value = sr.srNumber; option.title = option.textContent; select.append(option);
}
select.value = row.srNumber;
select.disabled = !entry.servicesReady;
select.title = entry.servicesReady ? "Service Request" : "Loading Service Requests";
change = () => { row.srNumber = select.value; row.activity = ""; renderRows(); };
} else if (label === "Activity") {
const source = entry.expanded.get(row.type === "Service" ? row.srNumber : "non-service-sr");
const raw = String(source?.SRActivities_c || "");
const activities = [...new Set(raw.split(raw.includes(";") ? ";" : ",").map(value => value.trim()).filter(Boolean))];
for (const activity of activities) { const option = node("option", "", activity); option.value = activity; select.append(option); }
select.value = row.activity; select.disabled = !activities.length;
select.title = activities.length ? "Activity" : "No activities available for this Service Request";
change = () => { row.activity = select.value; };
} else if (label === "Task Type") {
const tag = row.type === "Service" ? "SERVICE" : "NON_SERVICE";
const taskTypes = entry.taskTypes.filter(item => item.tags_c === tag);
for (const item of taskTypes) {
const option = node("option", "", item.lookUpMeaning_c); option.value = item.lookUpCode_c; select.append(option);
}
if (entry.taskTypesReady && !taskTypes.some(item => item.lookUpCode_c === row.taskType)) row.taskType = "";
select.value = row.taskType;
select.disabled = !entry.taskTypesReady || !taskTypes.length;
select.title = !entry.taskTypesReady ? "Loading Task Types" : taskTypes.length ? "Task Type" : "No Task Types available for this row type";
change = () => { row.taskType = select.value; };
} else { select.disabled = true; select.title = "Options are not available yet."; }
td.append(select); tr.append(td);
select.addEventListener("change", change);
const combo = searchableSelect(select, td, change, {
floating: true,
tooltipContent: label === "Service Request" ? serviceRequestTooltip : undefined
});
combo.input.setAttribute("aria-label", `${label}, row ${index + 1}`);
combos.push(combo);
}
for (const day of entry.days) {
const td = node("td"), input = node("input");
input.type = "number"; input.min = "0"; input.max = "24"; input.step = "1";
input.value = row.hours[day.Date] ?? "";
input.setAttribute("aria-label", `Hours ${day.Date}, row ${index + 1}`);
input.addEventListener("input", () => {
const valid = input.value === "" || (Number.isFinite(input.valueAsNumber) && input.valueAsNumber >= 0 && input.valueAsNumber <= 24);
input.setAttribute("aria-invalid", String(!valid)); row.hours[day.Date] = valid ? input.value : ""; updateTotals();
}); td.append(input); tr.append(td);
}
const notesCell = node("td"), notesRow = node("tr", "oe-time-notes"); notesRow.hidden = !row.notesOpen;
const noteCell = node("td"); noteCell.colSpan = entry.days.length + 6;
const notesBox = node("div", "oe-time-notes-box");
const remaining = node("span", "oe-time-notes-count", `${2000 - row.notes.length} characters left`);
const notes = node("textarea"); notes.rows = 6; notes.maxLength = 2000; notes.value = row.notes;
notes.setAttribute("aria-label", `Notes, row ${index + 1}`);
notes.addEventListener("input", () => { row.notes = notes.value.slice(0, 2000); remaining.textContent = `${2000 - row.notes.length} characters left`; });
const collapse = button(`Collapse notes, row ${index + 1}`, "up", () => {
row.notesOpen = false; notesRow.hidden = true; notesButton.setAttribute("aria-expanded", "false"); notesButton.focus();
});
collapse.className = "oe-time-notes-collapse";
notesBox.append(remaining, notes, collapse); noteCell.append(notesBox); notesRow.append(noteCell);
const notesButton = button(`Notes, row ${index + 1}`, "notes", () => {
row.notesOpen = !row.notesOpen; notesRow.hidden = !row.notesOpen; notesButton.setAttribute("aria-expanded", String(row.notesOpen));
if (!notesRow.hidden) notes.focus();
}); notesButton.setAttribute("aria-expanded", String(row.notesOpen)); notesCell.append(notesButton); tr.append(notesCell);
const copy = node("td"); copy.append(button(`Copy row ${index + 1}`, "copy", () => addRow(row.type, row))); tr.append(copy);
const remove = node("td"); remove.append(button(`Delete row ${index + 1}`, "trash", () => { entry.rows.splice(index, 1); renderRows(); })); tr.append(remove);
entry.tbody.append(tr, notesRow);
}); updateTotals();
combos.find(combo => combo.input.id === focusedId)?.input.focus();
}
function serviceRequestTooltip(srNumber) {
const sr = entry.expanded.get(srNumber);
if (!sr) return null;
const details = node("dl", "oe-time-sr-details");
for (const [key, label] of [
["SrNumber", "SR number"], ["AccountPartyUniqueName", "Account"],
["OptyName_c", "Opportunity"], ["OptyNumber_c", "Opty number"],
["OptyOwner_c", "Opty owner"], ["OptyStatus_c", "Opty status"],
["ServiceNew_c", "Service"], ["ReportedByPartyUniqueName", "Requested by"],
["CountryText_c", "Country"], ["StatusCdMeaning", "Status"],
["AssigneePersonName", "Service lead"], ["ProblemDescription", "Description"]
]) {
const field = node("div", key === "ProblemDescription" ? "oe-time-sr-description" : "");
field.append(node("dt", "", label), node("dd", "", String(sr[key] ?? "-")));
details.append(field);
}
return details;
}
async function loadTaskTypes() {
taskStatus.hidden = false; taskStatus.textContent = "Loading Task Types...";
taskStatus.setAttribute("role", "status"); taskRetry.hidden = true;
try {
const items = await requestTaskTypes();
if (active !== entry) return;
entry.taskTypes = items.filter(item => typeof item?.lookUpCode_c === "string" && item.lookUpCode_c.trim()
&& typeof item.lookUpMeaning_c === "string" && item.lookUpMeaning_c.trim());
entry.taskTypesReady = true;
taskStatus.textContent = "No Task Types available."; taskStatus.hidden = entry.taskTypes.length > 0;
if (entry.tbody) renderRows();
} catch (error) {
if (active !== entry) return;
taskStatus.textContent = error.message || "Unable to load Task Types.";
taskStatus.setAttribute("role", "alert"); taskRetry.hidden = false;
}
}
async function loadServices(weekId, retrying = false) {
if (entry.servicesStarted && !retrying) return;
entry.servicesStarted = true; entry.initialWeekId = weekId;
serviceRetry.hidden = true; serviceStatus.hidden = false;
serviceStatus.setAttribute("role", "status"); serviceStatus.textContent = "Loading Service Requests...";
try {
const services = entry.servicesReady ? entry.services : await requestServices(weekId);
if (active !== entry) return;
entry.services = services; entry.servicesReady = true;
serviceStatus.textContent = services.length ? "" : "No active Service Requests.";
serviceStatus.hidden = services.length > 0;
if (entry.tbody) renderRows();
serviceStatus.hidden = false; serviceStatus.textContent = "Loading activities...";
const expanded = await requestExpand(services.map(sr => sr.srNumber));
if (active !== entry) return;
entry.expanded = new Map(expanded.map(sr => [sr.SrNumber, sr]));
serviceStatus.hidden = true;
if (entry.tbody) renderRows();
} catch (error) {
if (active !== entry) return;
serviceStatus.textContent = error.message || "Unable to load Service Requests.";
serviceStatus.setAttribute("role", "alert"); serviceRetry.hidden = false;
}
}
function renderWeek(items) {
entry.days = items;
const key = items.map(day => day.Date).join("|");
if (!entry.drafts.has(key)) entry.drafts.set(key, Array.from({ length: 3 }, () => blankRow("Service")));
entry.rows = entry.drafts.get(key);
const head = node("thead"), row = node("tr");
for (const label of ["Service Request", "Activity", "Task Type"]) row.append(node("th", "", label));
entry.dayTotals = [];
for (const day of items) {
const parsed = dates.fromApi(day.Date), th = node("th", "oe-time-day");
const dayLabel = node("span", "oe-time-day-label", ["Su", "M", "T", "W", "Th", "F", "Sa"][parsed.getUTCDay()]);
const count = node("span", "oe-time-day-total", "0"); entry.dayTotals.push(count); dayLabel.append(count);
th.append(dayLabel, node("span", "", parsed.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }))); row.append(th);
}
for (const label of ["Notes", "Copy", ""]) row.append(node("th", "", label));
[...row.children].forEach(th => th.setAttribute("scope", "col"));
head.append(row); entry.tbody = node("tbody"); table.replaceChildren(head, entry.tbody);
endingValue.textContent = dates.fromApi(items[0].EndDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); renderRows();
}
async function load() {
const version = ++entry.version; entry.tbody = null; table.replaceChildren(); endingValue.textContent = "--";
Object.values(sums).forEach(value => { value.textContent = "--"; });
add.disabled = addNonService.disabled = true; retry.hidden = true; status.hidden = false;
status.setAttribute("role", "status"); scroll.setAttribute("aria-busy", "false");
if (!dates.parse(date.value)) { status.textContent = "Select a valid reference date."; return; }
status.textContent = "Loading week details..."; scroll.setAttribute("aria-busy", "true");
try {
const items = await requestWeek(date.value);
if (active !== entry || entry.version !== version) return;
renderWeek(items); add.disabled = addNonService.disabled = false; status.hidden = true;
loadServices(items[0].WeekId);
} catch (error) {
if (active !== entry || entry.version !== version) return;
status.textContent = error.message || "Unable to load week details.";
status.setAttribute("role", "alert"); retry.hidden = false;
} finally {
if (active === entry && entry.version === version) scroll.setAttribute("aria-busy", "false");
}
}
date.focus(); load(); loadTaskTypes();
}
return { createButton, open, close, setReady };
}

View File

@@ -6,7 +6,7 @@ function createSearchableSelect({ environment }) {
if (text !== undefined) node.textContent = text;
return node;
}
function searchableSelect(select, field, onChange) {
function searchableSelect(select, field, onChange, { floating = false, tooltipContent } = {}) {
const box = element("div", "opportunities-extension-action-combobox");
const input = element("input");
input.type = "text";
@@ -27,10 +27,47 @@ function createSearchableSelect({ environment }) {
select.tabIndex = -1;
box.append(input, list);
field.append(box);
const tooltip = tooltipContent ? element("div", "opportunities-extension-select-tooltip") : null;
if (tooltip) {
tooltip.id = `${select.id}-tooltip`;
tooltip.setAttribute("role", "tooltip");
tooltip.hidden = true;
box.append(tooltip);
}
let tooltipAnchor = null;
function hideTooltip() {
if (!tooltip) return;
tooltip.hidden = true;
tooltipAnchor?.removeAttribute?.("aria-describedby");
tooltipAnchor = null;
}
function showTooltip(value, anchor) {
hideTooltip();
if (!tooltip || !value) return;
const content = tooltipContent(value);
if (!content) return;
tooltip.replaceChildren(content);
tooltip.hidden = false;
tooltipAnchor = anchor;
anchor.setAttribute("aria-describedby", tooltip.id);
const rect = anchor.getBoundingClientRect?.();
const bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
if (!rect || !bounds) return;
const size = tooltip.getBoundingClientRect();
const left = rect.right + 8 + size.width <= bounds.right ? rect.right + 8 : Math.max(bounds.left + 8, rect.left - size.width - 8);
tooltip.style.left = `${left}px`;
tooltip.style.top = `${Math.max(bounds.top + 8, Math.min(rect.top, bounds.bottom - size.height - 8))}px`;
}
function leaveTooltip(event) {
if (!tooltip?.contains?.(event.relatedTarget)) hideTooltip();
}
tooltip?.addEventListener("mouseleave", hideTooltip);
list.addEventListener("scroll", hideTooltip);
let matches = [], index = -1;
const options = () => Array.from(select.children).filter(item => item.value);
const normalize = value => String(value).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
function closeList() {
hideTooltip();
list.hidden = true;
input.setAttribute("aria-expanded", "false");
input.setAttribute("aria-activedescendant", "");
@@ -40,12 +77,13 @@ function createSearchableSelect({ environment }) {
input.disabled = !!select.disabled;
input.placeholder = select.children[0]?.textContent || "Select";
input.value = options().find(item => item.value === select.value)?.textContent || "";
input.title = input.value;
input.title = tooltipContent ? "" : input.value;
}
function highlight() {
Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index)));
input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : "");
list.children[index]?.scrollIntoView?.({ block: "nearest" });
if (matches[index]) showTooltip(matches[index].value, list.children[index]);
}
function choose(i) {
if (!matches[i]) return;
@@ -63,9 +101,19 @@ function createSearchableSelect({ environment }) {
const opensAbove = below < 180 && above > below;
list.setAttribute("data-placement", opensAbove ? "above" : "below");
list.style.maxHeight = `${Math.min(180, opensAbove ? above : below)}px`;
if (floating) {
list.style.position = "fixed";
list.style.left = `${anchor.left}px`;
list.style.width = `${anchor.width}px`;
list.style.right = "auto";
list.style.bottom = "auto";
list.style.top = `${opensAbove ? anchor.top - 4 : anchor.bottom + 4}px`;
list.style.transform = opensAbove ? "translateY(-100%)" : "none";
}
}
function render(query = "") {
if (input.disabled) return;
hideTooltip();
matches = options().filter(item => normalize(`${item.textContent} ${item.getAttribute?.("data-description") || ""} ${item.value}`).includes(normalize(query)));
index = -1;
list.textContent = "";
@@ -78,6 +126,10 @@ function createSearchableSelect({ environment }) {
row.setAttribute("aria-selected", "false");
row.addEventListener("mousedown", event => event.preventDefault());
row.addEventListener("click", event => { event.preventDefault(); choose(i); });
if (tooltip) {
row.addEventListener("mouseenter", () => showTooltip(item.value, row));
row.addEventListener("mouseleave", leaveTooltip);
}
list.append(row);
});
if (!matches.length) {
@@ -93,6 +145,10 @@ function createSearchableSelect({ environment }) {
input.addEventListener("click", () => { render(); input.select?.(); });
input.addEventListener("input", () => render(input.value));
input.addEventListener("blur", sync);
if (tooltip) {
input.addEventListener("mouseenter", () => { if (list.hidden) showTooltip(select.value, input); });
input.addEventListener("mouseleave", leaveTooltip);
}
input.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
@@ -102,7 +158,7 @@ function createSearchableSelect({ environment }) {
} else if (event.key === "Enter" && !list.hidden) {
event.preventDefault();
choose(index < 0 && matches.length === 1 ? 0 : index);
} else if (event.key === "Escape" && !list.hidden) {
} else if (event.key === "Escape" && (!list.hidden || (tooltip && !tooltip.hidden))) {
event.preventDefault();
event.stopPropagation();
sync();

View File

@@ -1,4 +1,4 @@
function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, sendRequest, reportProgress, appendLog }) {
function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, sendRequest, reportProgress, appendLog, onReady = async () => {} }) {
let pending = null;
function ensure() {
if (pending) return pending;
@@ -11,6 +11,7 @@ function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, se
appendLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Session initialization failed.");
reportProgress("complete");
await onReady();
} catch {
reportProgress("error");
}

View File

@@ -0,0 +1,61 @@
function createFormRequestCache({ getHours, getUserId }) {
const entries = new Map();
const pending = new Map();
let generation = 0;
let owner = "";
const queries = new Set([
"requestWeekDetails", "requestActiveServiceRequests", "requestServiceRequestExpand", "requestLookupTaskType",
"requestActionTeamMembers", "requestActionFindLookup", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage",
"requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestLookupTypePlatform", "requestInternalSrServices"
]);
const writes = new Set(["requestMgmtAction", "requestPostInternalServiceRequest", "requestPostServiceRequest"]);
function invalidate() { generation++; entries.clear(); pending.clear(); }
function maxAge() {
const hours = Number(getHours());
return (Number.isFinite(hours) && hours > 0 ? Math.min(hours, 168) : 24) * 3600000;
}
function copy(value) { return JSON.parse(JSON.stringify(value)); }
function stable(value) {
if (Array.isArray(value)) return value.map(stable);
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
return value;
}
async function send(message, transport) {
const userId = String(getUserId() || "").trim().toLowerCase();
if (userId !== owner) { invalidate(); owner = userId; }
const operation = String(message.type || "").split(".").pop();
const duration = maxAge();
const request = queries.has(operation) || writes.has(operation) || operation === "requestResourceUser"
? { ...message, cacheMaxAgeMs: duration } : message;
if (operation === "invalidateProductsCache") invalidate();
// Only explicitly known read operations are reusable. Writes and token generation always execute.
if (!queries.has(operation) || !userId) {
const result = await transport(request);
if (result?.ok && writes.has(operation) && !(operation === "requestPostServiceRequest" && message.phase === "prepare")) invalidate();
return result;
}
const key = JSON.stringify(stable(message));
const hit = entries.get(key);
if (hit && Date.now() >= hit.fetchedAt && Date.now() - hit.fetchedAt < duration) {
return { ...copy(hit.result), debugRequests: [], fromCache: true, cachedAt: hit.fetchedAt };
}
entries.delete(key);
if (pending.has(key)) {
const result = await pending.get(key);
return { ...copy(result), debugRequests: [] };
}
const version = generation;
const promise = transport(request).then(result => {
if (result?.ok && version === generation) {
// Keep this per-page cache bounded and avoid persisting credentials or customer data to disk.
if (entries.size >= 500) entries.delete(entries.keys().next().value);
entries.set(key, { result: copy({ ...result, debugRequests: [] }), fetchedAt: Date.now() });
}
return result;
});
pending.set(key, promise);
try { return await promise; }
finally { if (pending.get(key) === promise) pending.delete(key); }
}
return { send, invalidate };
}

View File

@@ -102,11 +102,11 @@ function createPreferencesView({
cacheField.className = "opportunities-extension-preferences-cache-field";
const cacheLabel = document.createElement("span");
cacheLabel.textContent = "Product cache duration";
cacheLabel.textContent = "Cache duration";
const cacheSelect = document.createElement("select");
cacheSelect.name = "opptyProductsCacheHours";
cacheSelect.setAttribute("aria-label", "Product cache duration");
cacheSelect.setAttribute("aria-label", "Cache duration");
OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => {
const optionElement = document.createElement("option");
optionElement.value = String(option.value);

View File

@@ -1,9 +1,13 @@
function createPlatformContentMessages({
messageTypes,
requestCache,
environment
}) {
const { browser, chrome } = environment;
function sendRuntimeMessage(message) {
return requestCache ? requestCache.send(message, sendUncached) : sendUncached(message);
}
function sendUncached(message) {
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {

View File

@@ -1,4 +1,7 @@
function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders }) {
function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, runtimeApi }) {
const resourceRequests = new Map();
let resourceGeneration = 0;
const resourceCacheKey = "opportunitiesExtension.resourceUsers.v1";
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/";
const catalog = base + "crmRestApiResources/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/11.13.18.05/Catalogue_c";
const resourceUsers = base + "oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/resourceUsers";
@@ -24,12 +27,45 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
dateOnly(payload.CustomerDeadline_c, "Customer deadline");
return { ...payload, SvcQuestionsCollection_c: [], SourceCd: "SVC_OPPORTUNITY_SCREEN", SeverityCd: "ORA_SVC_SEV2", ChannelTypeCd: "ORA_SVC_WEB" };
}
async function requestResourceUser(email, debugRequests) {
const emailAddress = text(email, "current user email");
const resource = await fetchJson(`${resourceUsers}?emailAddress=${encodeURIComponent(emailAddress)}`, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestResourceUser", debugRequests);
return numericId(resource?.resourcePartyId, "resourcePartyId");
async function requestResourceUser(message, debugRequests) {
try {
const result = await getResourceUser(message);
debugRequests.push(...result.debugRequests);
return numericId(result.resource.resourcePartyId, "resourcePartyId");
} catch (error) { debugRequests.push(...(error.debugRequests || [])); throw error; }
}
async function getResourceUser(message) {
const emailAddress = text(message.emailAddress, "current user email").toLowerCase();
const duration = Number(message.cacheMaxAgeMs) > 0 ? Math.min(Number(message.cacheMaxAgeMs), 168 * 3600000) : 24 * 3600000;
const fresh = timestamp => Number.isFinite(timestamp) && Date.now() >= timestamp && Date.now() - timestamp < duration;
const current = resourceRequests.get(emailAddress);
if (current && (current.fetchedAt === null || fresh(current.fetchedAt))) return { ok: true, resource: await current.promise, debugRequests: [] };
const generation = resourceGeneration;
const record = { promise: null, fetchedAt: null };
const debugRequests = [];
const pending = (async () => {
try {
const stored = await runtimeApi?.storage?.session?.get(resourceCacheKey);
const cached = stored?.[resourceCacheKey];
if (cached?.emailAddress === emailAddress && cached.resource?.resourcePartyId && fresh(cached.fetchedAt)) {
record.fetchedAt = cached.fetchedAt;
return cached.resource;
}
} catch { /* Session storage is optional; retain the in-memory cache. */ }
const resource = await fetchJson(`${resourceUsers}?emailAddress=${encodeURIComponent(emailAddress)}`, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestResourceUser", debugRequests);
numericId(resource?.resourcePartyId, "resourcePartyId");
record.fetchedAt = Date.now();
if (generation === resourceGeneration) {
try { await runtimeApi?.storage?.session?.set({ [resourceCacheKey]: { emailAddress, resource, fetchedAt: record.fetchedAt } }); } catch { /* Keep the successful response in memory. */ }
}
return resource;
})();
record.promise = pending;
resourceRequests.set(emailAddress, record);
try { return { ok: true, resource: await pending, debugRequests }; }
catch (error) { if (resourceRequests.get(emailAddress) === record) resourceRequests.delete(emailAddress); error.debugRequests = debugRequests; throw error; }
}
async function requestPostInternalServiceRequest(message) {
const debugRequests = [];
@@ -50,7 +86,7 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
for (const name of ["AccountPartyId", "OpportunityName_Id_c"]) payload[name] = numericId(parent[name], name);
for (const name of ["OpportunityName_c", "RegionText_c", "SvcHealth_c", "Language_c", "CountryText_c", "PrimaryPillarNew_c"]) payload[name] = text(parent[name], name);
payload.SecondaryPillarNew_c = parent.SecondaryPillarNew_c == null ? null : text(parent.SecondaryPillarNew_c, "SecondaryPillarNew_c");
payload.ReportedByPartyId = await requestResourceUser(message.emailAddress, debugRequests);
payload.ReportedByPartyId = await requestResourceUser(message, debugRequests);
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPICloudAccount/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
const result = await fetchJson(url, { method: "POST", credentials: "include",
headers: await internalServiceRequestHeaders(url),
@@ -80,7 +116,7 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
if (message.phase !== "prepare" && message.phase !== "confirm") throw new Error("Service Request confirmation is required.");
const payload = payloadFor(message.payload);
if (message.phase === "prepare") {
payload.ReportedByPartyId = await requestResourceUser(message.emailAddress, debugRequests);
payload.ReportedByPartyId = await requestResourceUser(message, debugRequests);
const sharedHeaders = await previewTokenHeaders(catalog);
return { ok: true, payload, debugRequests, headers: {
requestSrTokenCatalog: sharedHeaders,
@@ -136,5 +172,10 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
}
}
}
return { requestPostServiceRequest, requestPostInternalServiceRequest };
async function invalidateResourceUser() {
resourceGeneration++;
resourceRequests.clear();
await runtimeApi?.storage?.session?.set({ [resourceCacheKey]: null });
}
return { requestPostServiceRequest, requestPostInternalServiceRequest, getResourceUser, invalidateResourceUser };
}

View File

@@ -0,0 +1,63 @@
function createTimeEntries({ fetchJson, dates, getResourceUser }) {
const endpoint = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries/weekDetails";
async function requestWeekDetails(message) {
const debugRequests = [];
try {
const date = dates.toApi(message.dateReference);
const items = await fetchJson(`${endpoint}?dateValue=${encodeURIComponent(date)}`, {
method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" }
}, "requestWeekDetails", debugRequests);
if (!Array.isArray(items) || items.length !== 7 || items.some(item => !dates.fromApi(item?.Date) || !dates.fromApi(item?.EndDate))
|| new Set(items.map(item => item.Date)).size !== 7) throw new Error("Invalid week details response.");
return { ok: true, items, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
}
}
async function requestActiveServiceRequests(message) {
const debugRequests = [];
try {
if (!/^\d+$/.test(String(message.weekId)) || Number(message.weekId) <= 0) throw new Error("Missing or invalid WeekId.");
const resource = await getResourceUser(message);
debugRequests.push(...resource.debugRequests);
const url = endpoint.replace(/weekDetails$/, "activeServiceRequests") + `?resourceId=${encodeURIComponent(resource.resource.resourcePartyId)}&weekId=${encodeURIComponent(message.weekId)}`;
const items = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestActiveServiceRequests", debugRequests);
if (!Array.isArray(items)) throw new Error("Invalid active Service Requests response.");
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
}
async function requestServiceRequestExpand(message) {
const debugRequests = [];
try {
if (!Array.isArray(message.srNumbers)) throw new Error("Missing Service Request numbers.");
const numbers = [...new Set(message.srNumbers.map(value => String(value || "").trim()).filter(Boolean))].filter(value => value !== "non-service-sr");
if (numbers.some(value => !/^SR\d+$/.test(value))) throw new Error("Invalid Service Request number.");
const batches = [];
for (let index = 0; index < numbers.length; index += 50) batches.push(numbers.slice(index, index + 50));
if (!batches.length || batches[batches.length - 1].length === 50) batches.push([]);
batches[batches.length - 1].push("non-service-sr");
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/crmRestApiSearchResourcesLatestCustomActions/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
const fields = "SrNumber,SRActivities_c,ProblemDescription,AccountPartyUniqueName,ServiceNew_c,OptyOwner_c,OptyName_c,OptyNumber_c,ReportedByPartyUniqueName,StatusCdMeaning,CountryText_c,AssigneePersonName,OptyStatus_c";
const items = [];
for (const batch of batches) {
const query = `SrNumber in (${batch.map(value => `'${value}'`).join(",")})`;
const url = `${base}?expand=ServiceRequest_SRToInternalSR_Tgt&fields=${encodeURIComponent(fields)}&limit=200&onlyData=true&q=${encodeURIComponent(query).replace(/'/g, "%27")}`;
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json", "vb-proxy-header-rest-framework-version": "3" } }, "requestServiceRequestExpand", debugRequests);
if (!Array.isArray(result?.items)) throw new Error("Invalid Service Request expansion response.");
items.push(...result.items);
}
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestLookupTaskType() {
const debugRequests = [];
try {
const url = endpoint.replace(/mgmt\/timeEntries\/weekDetails$/, "crm/lookups?lookupType=SCTA_TASK_TYPE&type=CUSTOM");
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestLookupTaskType", debugRequests);
if (!Array.isArray(result?.items)) throw new Error("Invalid Task Type lookup response.");
return { ok: true, items: result.items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
return { requestWeekDetails, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType };
}

View File

@@ -1,6 +1,11 @@
/** Stable wire names. All payloads remain plain, serializable objects. */
function createMessageTypes() {
return Object.freeze({
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType",
requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand",
requestResourceUser: "opportunitiesExtension.requestResourceUser",
requestActiveServiceRequests: "opportunitiesExtension.requestActiveServiceRequests",
getXsrfToken: "opportunitiesExtension.getXsrfToken",
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",

31
src/shared/time-dates.js Normal file
View File

@@ -0,0 +1,31 @@
function createTimeDates() {
const months = "JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC".split(" ");
function parse(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value));
if (!match) return null;
const date = new Date(`${value}T00:00:00.000Z`);
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value ? date : null;
}
function fromApi(value) {
const match = /^(\d{2})-([A-Z]{3})-(\d{4})$/.exec(String(value));
if (!match) return null;
const month = months.indexOf(match[2]) + 1;
return parse(`${match[3]}-${String(month).padStart(2, "0")}-${match[1]}`);
}
function toApi(value) {
const date = parse(value);
if (!date) throw new Error("Select a valid reference date.");
return `${String(date.getUTCDate()).padStart(2, "0")}-${months[date.getUTCMonth()]}-${date.getUTCFullYear()}`;
}
function shift(value, days) {
const date = parse(value);
if (!date) return value;
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
function today() {
const date = new Date();
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
return { parse, fromApi, toApi, shift, today };
}