Partially implemented the Manage Time functionality
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
52
src/features/manage-time/styles.css
Normal file
52
src/features/manage-time/styles.css
Normal 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; } }
|
||||
325
src/features/manage-time/view.js
Normal file
325
src/features/manage-time/view.js
Normal 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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
61
src/features/preferences/request-cache.js
Normal file
61
src/features/preferences/request-cache.js
Normal 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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
63
src/services/fusion/time-entries.js
Normal file
63
src/services/fusion/time-entries.js
Normal 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 };
|
||||
}
|
||||
@@ -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
31
src/shared/time-dates.js
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user