Fully implemented the Manage Time functionality

This commit is contained in:
2026-10-19 10:30:09 -03:00
parent d392e5ddd7
commit 9eef03e5f4
17 changed files with 1429 additions and 139 deletions

View File

@@ -34,7 +34,7 @@ function createTimeDates() {
} }
// Source: src/services/fusion/time-entries.js // Source: src/services/fusion/time-entries.js
function createTimeEntries({ fetchJson, dates, getResourceUser }) { function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
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"; 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) { async function requestWeekDetails(message) {
const debugRequests = []; const debugRequests = [];
@@ -51,6 +51,61 @@ function createTimeEntries({ fetchJson, dates, getResourceUser }) {
throw error; throw error;
} }
} }
async function requestTimeEntries(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$/, "") + `?resourceId=${encodeURIComponent(resource.resource.resourcePartyId)}&sortBy=createdDate&sortOrder=ASC&weekId=${encodeURIComponent(message.weekId)}`;
const items = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestTimeEntries", debugRequests);
if (!Array.isArray(items)) throw new Error("Invalid time entries response.");
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
}
async function requestTimeEntriesDelete(message) {
const debugRequests = [];
try {
if (!/^[1-9]\d*$/.test(String(message.id))) throw new Error("Missing or invalid time entry ID.");
const url = endpoint.replace(/\/weekDetails$/, "") + `/${message.id}`;
const headers = await saveHeaders(url);
const result = await fetchJson(url, { method: "DELETE", credentials: "include", headers },
"requestTimeEntriesDelete", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestTimeEntriesSave(message) {
const debugRequests = [];
try {
if (!["POST", "PUT"].includes(message.method)) throw new Error("Invalid time entry save method.");
if (!Array.isArray(message.items) || !message.items.length) throw new Error("No time entries to save.");
const hours = ["saturdayTaskHours", "sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours"];
for (const item of message.items) {
if (!item || !["SERVICE", "NON_SERVICE"].includes(item.tag) || !item.taskType
|| (item.tag === "SERVICE" && (!/^SR\d+$/.test(item.srNumber) || !item.activityCode))
|| !/^\d+$/.test(String(item.weekId)) || Number(item.weekId) <= 0
|| hours.some(key => typeof item[key] !== "number" || !Number.isFinite(item[key]) || item[key] < 0 || item[key] > 24)
|| !hours.some(key => item[key] > 0)
|| (message.method === "PUT" && (!/^\d+$/.test(String(item.id)) || Number(item.id) <= 0))) throw new Error("Invalid or incomplete time entry.");
}
const resource = await getResourceUser(message);
debugRequests.push(...resource.debugRequests);
const items = message.items.map(item => ({
...(message.method === "PUT" ? { id: item.id } : {}),
srNumber: item.tag === "NON_SERVICE" ? "non-service-sr" : item.srNumber,
activityCode: item.activityCode || "", taskType: item.taskType, deliveryType: item.deliveryType || "",
...Object.fromEntries(hours.map(key => [key, item[key]])), notes: String(item.notes || ""),
weekId: item.weekId, resourceId: String(resource.resource.resourcePartyId), tag: item.tag
}));
const url = endpoint.replace(/\/weekDetails$/, "");
const headers = await saveHeaders(url);
const result = await fetchJson(url, {
method: message.method, credentials: "include",
headers, body: JSON.stringify(items)
}, "requestTimeEntriesSave", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
}
async function requestActiveServiceRequests(message) { async function requestActiveServiceRequests(message) {
const debugRequests = []; const debugRequests = [];
try { try {
@@ -95,7 +150,7 @@ function createTimeEntries({ fetchJson, dates, getResourceUser }) {
return { ok: true, items: result.items, debugRequests }; return { ok: true, items: result.items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; } } catch (error) { error.debugRequests = debugRequests; throw error; }
} }
return { requestWeekDetails, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType }; return { requestWeekDetails, requestTimeEntries, requestTimeEntriesSave, requestTimeEntriesDelete, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType };
} }
// Source: src/shared/messages.js // Source: src/shared/messages.js
@@ -103,6 +158,9 @@ function createTimeEntries({ fetchJson, dates, getResourceUser }) {
function createMessageTypes() { function createMessageTypes() {
return Object.freeze({ return Object.freeze({
requestWeekDetails: "opportunitiesExtension.requestWeekDetails", requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
requestTimeEntries: "opportunitiesExtension.requestTimeEntries",
requestTimeEntriesSave: "opportunitiesExtension.requestTimeEntriesSave",
requestTimeEntriesDelete: "opportunitiesExtension.requestTimeEntriesDelete",
requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType", requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType",
requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand", requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand",
requestResourceUser: "opportunitiesExtension.requestResourceUser", requestResourceUser: "opportunitiesExtension.requestResourceUser",
@@ -518,7 +576,7 @@ function createSrRequestHeaders({ runtimeApi }) {
function observeHeaders(rawUrl, headers) { function observeHeaders(rawUrl, headers) {
let url; let url;
try { url = new URL(rawUrl); } catch { return; } try { url = new URL(rawUrl); } catch { return; }
if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management)\/live(?:\/|;)/.test(url.pathname)) return; if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management|oalset_timeentrymobile)\/live(?:\/|;)/.test(url.pathname)) return;
// Read only the client ID, not cookies, bodies or authorization. // Read only the client ID, not cookies, bodies or authorization.
const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id") const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id")
: headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value; : headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value;
@@ -531,7 +589,7 @@ function createSrRequestHeaders({ runtimeApi }) {
function start() { function start() {
if (started || !runtimeApi?.webRequest) return; if (started || !runtimeApi?.webRequest) return;
started = true; started = true;
const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`], types: ["xmlhttprequest"] }; const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`, `${origin}/ic/builder/rt/oalset_timeentrymobile/*`], types: ["xmlhttprequest"] };
runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => { runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => {
if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders); if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders);
}, filter, ["requestHeaders"]); }, filter, ["requestHeaders"]);
@@ -608,7 +666,29 @@ function createSrRequestHeaders({ runtimeApi }) {
}); });
return headers; return headers;
} }
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, observeResponseHeaders: observeHeaders }; async function timeEntriesHeaders(url) {
const target = new URL(url);
const path = "/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";
const isDelete = target.pathname.startsWith(`${path}/`) && /^[1-9]\d*$/.test(target.pathname.slice(path.length + 1));
if (target.origin !== origin || (target.pathname !== path && !isDelete) || target.search || target.hash) {
throw new Error("Unexpected time entries URL.");
}
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
throw new Error("Reload the extension to enable the time entry header permissions.");
}
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await runtimeApi.declarativeNetRequest.updateSessionRules({
removeRuleIds: [isDelete ? 7205 : 7204],
addRules: [{
id: isDelete ? 7205 : 7204, priority: 1,
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: target.origin }] },
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [new URL(runtimeApi.runtime.getURL("/")).hostname],
requestMethods: isDelete ? ["delete"] : ["post", "put"], resourceTypes: ["xmlhttprequest"] }
}]
});
return sessionHeaders(target.origin, "application/json");
}
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, timeEntriesHeaders, observeResponseHeaders: observeHeaders };
} }
// Source: src/services/spa/accounts.js // Source: src/services/spa/accounts.js
@@ -2425,6 +2505,9 @@ function startBackgroundApplication(dependencies) {
function registerBackgroundMessages({ function registerBackgroundMessages({
messageTypes, messageTypes,
requestWeekDetails, requestWeekDetails,
requestTimeEntries,
requestTimeEntriesSave,
requestTimeEntriesDelete,
requestLookupTaskType, requestLookupTaskType,
requestServiceRequestExpand, requestServiceRequestExpand,
requestResourceUser, requestResourceUser,
@@ -2468,8 +2551,18 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] })); error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true; return true;
} }
if ([messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) { if (message.type === messageTypes.requestTimeEntriesSave) {
const request = message.type === messageTypes.requestServiceRequestExpand ? requestServiceRequestExpand : message.type === messageTypes.requestWeekDetails ? requestWeekDetails : message.type === messageTypes.requestResourceUser ? requestResourceUser : requestActiveServiceRequests; requestTimeEntriesSave(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.requestTimeEntriesDelete) {
requestTimeEntriesDelete(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true;
}
if ([messageTypes.requestTimeEntries, messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
const request = message.type === messageTypes.requestTimeEntries ? requestTimeEntries : 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, request(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] })); error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true; return true;
@@ -2675,7 +2768,7 @@ function createBackgroundApplication(overrides = {}) {
...overrides ...overrides
}; };
const messageTypes = createMessageTypes(); const messageTypes = createMessageTypes();
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message) }); const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) }); const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser(); const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
const contracts = createBackgroundContracts(); const contracts = createBackgroundContracts();
@@ -2816,6 +2909,9 @@ function createBackgroundApplication(overrides = {}) {
startBackgroundApplication({ startBackgroundApplication({
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(), invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
requestWeekDetails: message => timeEntries.requestWeekDetails(message), requestWeekDetails: message => timeEntries.requestWeekDetails(message),
requestTimeEntries: message => timeEntries.requestTimeEntries(message),
requestTimeEntriesSave: message => timeEntries.requestTimeEntriesSave(message),
requestTimeEntriesDelete: message => timeEntries.requestTimeEntriesDelete(message),
requestLookupTaskType: () => timeEntries.requestLookupTaskType(), requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message), requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
requestResourceUser: message => fusionSrCreate.getResourceUser(message), requestResourceUser: message => fusionSrCreate.getResourceUser(message),

File diff suppressed because one or more lines are too long

View File

@@ -34,7 +34,7 @@ function createTimeDates() {
} }
// Source: src/services/fusion/time-entries.js // Source: src/services/fusion/time-entries.js
function createTimeEntries({ fetchJson, dates, getResourceUser }) { function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
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"; 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) { async function requestWeekDetails(message) {
const debugRequests = []; const debugRequests = [];
@@ -51,6 +51,61 @@ function createTimeEntries({ fetchJson, dates, getResourceUser }) {
throw error; throw error;
} }
} }
async function requestTimeEntries(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$/, "") + `?resourceId=${encodeURIComponent(resource.resource.resourcePartyId)}&sortBy=createdDate&sortOrder=ASC&weekId=${encodeURIComponent(message.weekId)}`;
const items = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestTimeEntries", debugRequests);
if (!Array.isArray(items)) throw new Error("Invalid time entries response.");
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
}
async function requestTimeEntriesDelete(message) {
const debugRequests = [];
try {
if (!/^[1-9]\d*$/.test(String(message.id))) throw new Error("Missing or invalid time entry ID.");
const url = endpoint.replace(/\/weekDetails$/, "") + `/${message.id}`;
const headers = await saveHeaders(url);
const result = await fetchJson(url, { method: "DELETE", credentials: "include", headers },
"requestTimeEntriesDelete", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestTimeEntriesSave(message) {
const debugRequests = [];
try {
if (!["POST", "PUT"].includes(message.method)) throw new Error("Invalid time entry save method.");
if (!Array.isArray(message.items) || !message.items.length) throw new Error("No time entries to save.");
const hours = ["saturdayTaskHours", "sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours"];
for (const item of message.items) {
if (!item || !["SERVICE", "NON_SERVICE"].includes(item.tag) || !item.taskType
|| (item.tag === "SERVICE" && (!/^SR\d+$/.test(item.srNumber) || !item.activityCode))
|| !/^\d+$/.test(String(item.weekId)) || Number(item.weekId) <= 0
|| hours.some(key => typeof item[key] !== "number" || !Number.isFinite(item[key]) || item[key] < 0 || item[key] > 24)
|| !hours.some(key => item[key] > 0)
|| (message.method === "PUT" && (!/^\d+$/.test(String(item.id)) || Number(item.id) <= 0))) throw new Error("Invalid or incomplete time entry.");
}
const resource = await getResourceUser(message);
debugRequests.push(...resource.debugRequests);
const items = message.items.map(item => ({
...(message.method === "PUT" ? { id: item.id } : {}),
srNumber: item.tag === "NON_SERVICE" ? "non-service-sr" : item.srNumber,
activityCode: item.activityCode || "", taskType: item.taskType, deliveryType: item.deliveryType || "",
...Object.fromEntries(hours.map(key => [key, item[key]])), notes: String(item.notes || ""),
weekId: item.weekId, resourceId: String(resource.resource.resourcePartyId), tag: item.tag
}));
const url = endpoint.replace(/\/weekDetails$/, "");
const headers = await saveHeaders(url);
const result = await fetchJson(url, {
method: message.method, credentials: "include",
headers, body: JSON.stringify(items)
}, "requestTimeEntriesSave", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
}
async function requestActiveServiceRequests(message) { async function requestActiveServiceRequests(message) {
const debugRequests = []; const debugRequests = [];
try { try {
@@ -95,7 +150,7 @@ function createTimeEntries({ fetchJson, dates, getResourceUser }) {
return { ok: true, items: result.items, debugRequests }; return { ok: true, items: result.items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; } } catch (error) { error.debugRequests = debugRequests; throw error; }
} }
return { requestWeekDetails, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType }; return { requestWeekDetails, requestTimeEntries, requestTimeEntriesSave, requestTimeEntriesDelete, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType };
} }
// Source: src/shared/messages.js // Source: src/shared/messages.js
@@ -103,6 +158,9 @@ function createTimeEntries({ fetchJson, dates, getResourceUser }) {
function createMessageTypes() { function createMessageTypes() {
return Object.freeze({ return Object.freeze({
requestWeekDetails: "opportunitiesExtension.requestWeekDetails", requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
requestTimeEntries: "opportunitiesExtension.requestTimeEntries",
requestTimeEntriesSave: "opportunitiesExtension.requestTimeEntriesSave",
requestTimeEntriesDelete: "opportunitiesExtension.requestTimeEntriesDelete",
requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType", requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType",
requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand", requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand",
requestResourceUser: "opportunitiesExtension.requestResourceUser", requestResourceUser: "opportunitiesExtension.requestResourceUser",
@@ -518,7 +576,7 @@ function createSrRequestHeaders({ runtimeApi }) {
function observeHeaders(rawUrl, headers) { function observeHeaders(rawUrl, headers) {
let url; let url;
try { url = new URL(rawUrl); } catch { return; } try { url = new URL(rawUrl); } catch { return; }
if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management)\/live(?:\/|;)/.test(url.pathname)) return; if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management|oalset_timeentrymobile)\/live(?:\/|;)/.test(url.pathname)) return;
// Read only the client ID, not cookies, bodies or authorization. // Read only the client ID, not cookies, bodies or authorization.
const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id") const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id")
: headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value; : headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value;
@@ -531,7 +589,7 @@ function createSrRequestHeaders({ runtimeApi }) {
function start() { function start() {
if (started || !runtimeApi?.webRequest) return; if (started || !runtimeApi?.webRequest) return;
started = true; started = true;
const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`], types: ["xmlhttprequest"] }; const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`, `${origin}/ic/builder/rt/oalset_timeentrymobile/*`], types: ["xmlhttprequest"] };
runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => { runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => {
if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders); if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders);
}, filter, ["requestHeaders"]); }, filter, ["requestHeaders"]);
@@ -608,7 +666,29 @@ function createSrRequestHeaders({ runtimeApi }) {
}); });
return headers; return headers;
} }
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, observeResponseHeaders: observeHeaders }; async function timeEntriesHeaders(url) {
const target = new URL(url);
const path = "/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";
const isDelete = target.pathname.startsWith(`${path}/`) && /^[1-9]\d*$/.test(target.pathname.slice(path.length + 1));
if (target.origin !== origin || (target.pathname !== path && !isDelete) || target.search || target.hash) {
throw new Error("Unexpected time entries URL.");
}
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
throw new Error("Reload the extension to enable the time entry header permissions.");
}
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await runtimeApi.declarativeNetRequest.updateSessionRules({
removeRuleIds: [isDelete ? 7205 : 7204],
addRules: [{
id: isDelete ? 7205 : 7204, priority: 1,
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: target.origin }] },
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [new URL(runtimeApi.runtime.getURL("/")).hostname],
requestMethods: isDelete ? ["delete"] : ["post", "put"], resourceTypes: ["xmlhttprequest"] }
}]
});
return sessionHeaders(target.origin, "application/json");
}
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, timeEntriesHeaders, observeResponseHeaders: observeHeaders };
} }
// Source: src/services/spa/accounts.js // Source: src/services/spa/accounts.js
@@ -2425,6 +2505,9 @@ function startBackgroundApplication(dependencies) {
function registerBackgroundMessages({ function registerBackgroundMessages({
messageTypes, messageTypes,
requestWeekDetails, requestWeekDetails,
requestTimeEntries,
requestTimeEntriesSave,
requestTimeEntriesDelete,
requestLookupTaskType, requestLookupTaskType,
requestServiceRequestExpand, requestServiceRequestExpand,
requestResourceUser, requestResourceUser,
@@ -2468,8 +2551,18 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] })); error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true; return true;
} }
if ([messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) { if (message.type === messageTypes.requestTimeEntriesSave) {
const request = message.type === messageTypes.requestServiceRequestExpand ? requestServiceRequestExpand : message.type === messageTypes.requestWeekDetails ? requestWeekDetails : message.type === messageTypes.requestResourceUser ? requestResourceUser : requestActiveServiceRequests; requestTimeEntriesSave(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.requestTimeEntriesDelete) {
requestTimeEntriesDelete(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true;
}
if ([messageTypes.requestTimeEntries, messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
const request = message.type === messageTypes.requestTimeEntries ? requestTimeEntries : 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, request(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] })); error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true; return true;
@@ -2675,7 +2768,7 @@ function createBackgroundApplication(overrides = {}) {
...overrides ...overrides
}; };
const messageTypes = createMessageTypes(); const messageTypes = createMessageTypes();
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message) }); const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) }); const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser(); const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
const contracts = createBackgroundContracts(); const contracts = createBackgroundContracts();
@@ -2816,6 +2909,9 @@ function createBackgroundApplication(overrides = {}) {
startBackgroundApplication({ startBackgroundApplication({
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(), invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
requestWeekDetails: message => timeEntries.requestWeekDetails(message), requestWeekDetails: message => timeEntries.requestWeekDetails(message),
requestTimeEntries: message => timeEntries.requestTimeEntries(message),
requestTimeEntriesSave: message => timeEntries.requestTimeEntriesSave(message),
requestTimeEntriesDelete: message => timeEntries.requestTimeEntriesDelete(message),
requestLookupTaskType: () => timeEntries.requestLookupTaskType(), requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message), requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
requestResourceUser: message => fusionSrCreate.getResourceUser(message), requestResourceUser: message => fusionSrCreate.getResourceUser(message),

File diff suppressed because one or more lines are too long

View File

@@ -15,7 +15,7 @@ function createSrRequestHeaders({ runtimeApi }) {
function observeHeaders(rawUrl, headers) { function observeHeaders(rawUrl, headers) {
let url; let url;
try { url = new URL(rawUrl); } catch { return; } try { url = new URL(rawUrl); } catch { return; }
if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management)\/live(?:\/|;)/.test(url.pathname)) return; if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management|oalset_timeentrymobile)\/live(?:\/|;)/.test(url.pathname)) return;
// Read only the client ID, not cookies, bodies or authorization. // Read only the client ID, not cookies, bodies or authorization.
const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id") const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id")
: headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value; : headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value;
@@ -28,7 +28,7 @@ function createSrRequestHeaders({ runtimeApi }) {
function start() { function start() {
if (started || !runtimeApi?.webRequest) return; if (started || !runtimeApi?.webRequest) return;
started = true; started = true;
const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`], types: ["xmlhttprequest"] }; const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`, `${origin}/ic/builder/rt/oalset_timeentrymobile/*`], types: ["xmlhttprequest"] };
runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => { runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => {
if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders); if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders);
}, filter, ["requestHeaders"]); }, filter, ["requestHeaders"]);
@@ -105,5 +105,27 @@ function createSrRequestHeaders({ runtimeApi }) {
}); });
return headers; return headers;
} }
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, observeResponseHeaders: observeHeaders }; async function timeEntriesHeaders(url) {
const target = new URL(url);
const path = "/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";
const isDelete = target.pathname.startsWith(`${path}/`) && /^[1-9]\d*$/.test(target.pathname.slice(path.length + 1));
if (target.origin !== origin || (target.pathname !== path && !isDelete) || target.search || target.hash) {
throw new Error("Unexpected time entries URL.");
}
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
throw new Error("Reload the extension to enable the time entry header permissions.");
}
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await runtimeApi.declarativeNetRequest.updateSessionRules({
removeRuleIds: [isDelete ? 7205 : 7204],
addRules: [{
id: isDelete ? 7205 : 7204, priority: 1,
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: target.origin }] },
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [new URL(runtimeApi.runtime.getURL("/")).hostname],
requestMethods: isDelete ? ["delete"] : ["post", "put"], resourceTypes: ["xmlhttprequest"] }
}]
});
return sessionHeaders(target.origin, "application/json");
}
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, timeEntriesHeaders, observeResponseHeaders: observeHeaders };
} }

View File

@@ -7,7 +7,7 @@ function createBackgroundApplication(overrides = {}) {
...overrides ...overrides
}; };
const messageTypes = createMessageTypes(); const messageTypes = createMessageTypes();
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message) }); const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) }); const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser(); const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
const contracts = createBackgroundContracts(); const contracts = createBackgroundContracts();
@@ -148,6 +148,9 @@ function createBackgroundApplication(overrides = {}) {
startBackgroundApplication({ startBackgroundApplication({
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(), invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
requestWeekDetails: message => timeEntries.requestWeekDetails(message), requestWeekDetails: message => timeEntries.requestWeekDetails(message),
requestTimeEntries: message => timeEntries.requestTimeEntries(message),
requestTimeEntriesSave: message => timeEntries.requestTimeEntriesSave(message),
requestTimeEntriesDelete: message => timeEntries.requestTimeEntriesDelete(message),
requestLookupTaskType: () => timeEntries.requestLookupTaskType(), requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message), requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
requestResourceUser: message => fusionSrCreate.getResourceUser(message), requestResourceUser: message => fusionSrCreate.getResourceUser(message),

View File

@@ -2,6 +2,9 @@
function registerBackgroundMessages({ function registerBackgroundMessages({
messageTypes, messageTypes,
requestWeekDetails, requestWeekDetails,
requestTimeEntries,
requestTimeEntriesSave,
requestTimeEntriesDelete,
requestLookupTaskType, requestLookupTaskType,
requestServiceRequestExpand, requestServiceRequestExpand,
requestResourceUser, requestResourceUser,
@@ -45,8 +48,18 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] })); error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true; return true;
} }
if ([messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) { if (message.type === messageTypes.requestTimeEntriesSave) {
const request = message.type === messageTypes.requestServiceRequestExpand ? requestServiceRequestExpand : message.type === messageTypes.requestWeekDetails ? requestWeekDetails : message.type === messageTypes.requestResourceUser ? requestResourceUser : requestActiveServiceRequests; requestTimeEntriesSave(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.requestTimeEntriesDelete) {
requestTimeEntriesDelete(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true;
}
if ([messageTypes.requestTimeEntries, messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
const request = message.type === messageTypes.requestTimeEntries ? requestTimeEntries : 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, request(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] })); error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
return true; return true;

View File

@@ -137,6 +137,30 @@ function createContentApplication(overrides = {}) {
}); });
const svgPaths = createSvgPaths({ environment }); const svgPaths = createSvgPaths({ environment });
const manageTime = createManageTime({ MODAL_ID: contracts.MODAL_ID, environment, searchableSelect, dates: createTimeDates(), const manageTime = createManageTime({ MODAL_ID: contracts.MODAL_ID, environment, searchableSelect, dates: createTimeDates(),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
async requestDelete(id) {
await srSessionPreparation.ensure();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntriesDelete, id });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to delete time entry.");
return result.result;
},
async requestSave(method, items) {
await srSessionPreparation.ensure();
await currentUser.ensure();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntriesSave, method, items, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to save time entries.");
return result.result;
},
async requestEntries(weekId) {
await srSessionPreparation.ensure();
await currentUser.ensure();
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntries, weekId, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load time entries.");
return result.items;
},
async requestTaskTypes() { async requestTaskTypes() {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestLookupTaskType }); const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestLookupTaskType });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []); debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);

View File

@@ -24,6 +24,10 @@
.oe-time-grid th:nth-child(2), .oe-time-grid th:nth-child(3) { width: 12%; } .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 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 td { padding: 10px 5px; border-bottom: 1px solid var(--time-border); vertical-align: middle; text-align: center; }
.oe-time-duplicate-row { outline: 2px solid #b3261e; outline-offset: -2px; animation: oe-time-duplicate-fade 5.5s linear forwards; }
.opportunities-extension-modal[data-theme="dark"] .oe-time-duplicate-row { --time-duplicate-color: #ffaaa4; }
@keyframes oe-time-duplicate-fade { 0%, 90.909% { outline-color: var(--time-duplicate-color, #b3261e); } 100% { outline-color: transparent; } }
@media (prefers-reduced-motion: reduce) { .oe-time-duplicate-row { animation-timing-function: step-end; } }
.oe-time-grid select { width: 100%; } .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 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-combobox input { width: 100%; text-align: left; text-overflow: ellipsis; }
@@ -45,6 +49,14 @@
.oe-time-footer { flex-wrap: wrap; justify-content: flex-start; margin-top: 18px; } .oe-time-footer { flex-wrap: wrap; justify-content: flex-start; margin-top: 18px; }
.oe-time-footer button { padding: 7px 14px; } .oe-time-footer button { padding: 7px 14px; }
.oe-time-status { padding: 12px 0; } .oe-time-status { padding: 12px 0; }
.oe-time-loading-status { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.oe-time-skeleton { display: inline-block; vertical-align: middle; border-radius: 4px; background: var(--time-border); color: transparent; animation: oe-time-pulse 1.3s ease-in-out infinite alternate; }
.oe-time-skeleton-select { display: block; width: 100%; height: 38px; }
.oe-time-skeleton-control { width: min(100%, 60px); height: 38px; }
.oe-time-skeleton-heading { width: 65%; height: 20px; margin: 17px 0; }
.oe-time-skeleton-summary { min-width: 44px; height: 20px; }
@keyframes oe-time-pulse { from { opacity: .4; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .oe-time-skeleton { animation: none; } }
.oe-time-status[role="alert"] { color: #a12d27; } .oe-time-status[role="alert"] { color: #a12d27; }
.oe-time-dialog [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; } .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-status[role="alert"] { color: #ffd9d5; }

View File

@@ -1,8 +1,23 @@
function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requestWeek, requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) { function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requestWeek, requestSave, requestDelete, notify = () => {}, requestEntries = async () => [], requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) {
const { document } = environment; const { document } = environment;
let active = null; let active = null;
let ready = false; let ready = false;
let launcher = null; let launcher = null;
const referenceDateKey = "opportunities-extension-manage-time-reference-date";
let lastReferenceDate = "";
function savedReferenceDate() {
try {
const saved = environment.localStorage?.getItem(referenceDateKey);
if (dates.parse(saved)) lastReferenceDate = saved;
} catch { /* Keep the in-memory selection when storage is unavailable. */ }
return dates.parse(lastReferenceDate) ? lastReferenceDate : dates.today();
}
function saveReferenceDate(value) {
if (!dates.parse(value)) return;
lastReferenceDate = value;
try { environment.localStorage?.setItem(referenceDateKey, value); }
catch { /* Keep the selection for this page even if persistence is blocked. */ }
}
function setReady(value) { function setReady(value) {
ready = !!value; ready = !!value;
if (launcher) { launcher.disabled = !ready; launcher.title = ready ? "Manage Time" : "Waiting for SR session ready"; } if (launcher) { launcher.disabled = !ready; launcher.title = ready ? "Manage Time" : "Waiting for SR session ready"; }
@@ -54,12 +69,14 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
} }
function close(restoreFocus = true) { function close(restoreFocus = true) {
if (!active) return false; if (!active) return false;
if (active.saving) return true;
const previous = active; active = null; const previous = active; active = null;
previous.modal.remove(); previous.modal.remove();
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus(); if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
return true; return true;
} }
function open(trigger) { function open(trigger) {
if (active?.saving) return;
if (!ready) return; if (!ready) return;
const host = document.getElementById(MODAL_ID); const host = document.getElementById(MODAL_ID);
if (!host || host.hidden) return; if (!host || host.hidden) return;
@@ -76,17 +93,18 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
const body = node("div", "oe-time-body"); const body = node("div", "oe-time-body");
const toolbar = node("div", "oe-time-toolbar"); const toolbar = node("div", "oe-time-toolbar");
const navigation = node("div", "oe-time-navigation"); const navigation = node("div", "oe-time-navigation");
const date = node("input"); date.type = "date"; date.value = dates.today(); date.required = true; const date = node("input"); date.type = "date"; date.value = savedReferenceDate(); date.required = true;
date.setAttribute("aria-label", "Reference date"); date.setAttribute("aria-label", "Reference date");
const ending = node("div", "oe-time-ending"); const ending = node("div", "oe-time-ending");
const endingValue = node("strong", "", "--"); const endingValue = node("strong", "", "--");
ending.append(node("span", "", "Week Ending"), endingValue); ending.append(node("span", "", "Week Ending"), endingValue);
const changeWeek = days => { if (dates.parse(date.value)) { date.value = dates.shift(date.value, days); load(); } }; const changeDate = () => { saveReferenceDate(date.value); load(); };
const changeWeek = days => { if (dates.parse(date.value)) { date.value = dates.shift(date.value, days); changeDate(); } };
navigation.append(button("Previous week", "left", () => changeWeek(-7)), date, button("Next week", "right", () => changeWeek(7))); navigation.append(button("Previous week", "left", () => changeWeek(-7)), date, button("Next week", "right", () => changeWeek(7)));
date.addEventListener("change", load); toolbar.append(navigation, ending); date.addEventListener("change", changeDate); toolbar.append(navigation, ending);
const summary = node("div", "oe-time-summary"), sums = {}; const summary = node("div", "oe-time-summary"), sums = {};
for (const label of ["Service", "Non Service", "Time Off", "Total Hours"]) { for (const label of ["Service", "Non Service", "Time Off", "Total Hours"]) {
const item = node("div"); sums[label] = node("strong", "", "0 hrs"); const item = node("div"); sums[label] = node("strong", "", "0h");
item.append(node("span", "", `${label}: `), sums[label]); summary.append(item); item.append(node("span", "", `${label}: `), sums[label]); summary.append(item);
} }
const status = node("div", "oe-time-status"); status.setAttribute("role", "status"); const status = node("div", "oe-time-status"); status.setAttribute("role", "status");
@@ -100,13 +118,14 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
const footer = node("footer", "opportunities-extension-action-form-footer oe-time-footer"); const footer = node("footer", "opportunities-extension-action-form-footer oe-time-footer");
const add = button("Add", "add", () => addRow("Service"), true); const add = button("Add", "add", () => addRow("Service"), true);
const addNonService = button("Add (Non service)", "add", () => addRow("Non Service"), true); const addNonService = button("Add (Non service)", "add", () => addRow("Non Service"), true);
const save = button("Save", "save", null, true); save.disabled = true; const save = button("Save", "save", saveEntries, true); save.disabled = true;
save.title = "Saving time entries is not available yet."; const saveStatus = node("div", "oe-time-status"); saveStatus.hidden = true; saveStatus.setAttribute("role", "status");
footer.append(add, addNonService, save); footer.append(add, addNonService, save);
body.append(toolbar, summary, status, retry, serviceStatus, serviceRetry, taskStatus, taskRetry, scroll, footer); body.append(toolbar, summary, status, retry, serviceStatus, serviceRetry, taskStatus, taskRetry, scroll, saveStatus, footer);
dialog.append(header, body); modal.append(dialog); host.append(modal); 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() }; const entry = { modal, trigger, version: 0, days: [], rows: [], drafts: new Map(), tbody: null, dayTotals: [], services: [], servicesStarted: false, servicesReady: false, expanded: new Map() };
active = entry; active = entry;
entry.deletions = new Map();
entry.taskTypes = []; entry.taskTypes = [];
entry.taskTypesReady = false; entry.taskTypesReady = false;
let combos = []; let combos = [];
@@ -121,7 +140,88 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.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 blankRow(type) { return { type, hours: {}, invalidHours: {}, notes: "", srNumber: "", activity: "", taskType: "", notesOpen: false }; }
const hourFields = ["sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours", "saturdayTaskHours"];
function rowPayload(row) {
const payload = { srNumber: row.type === "Non Service" ? "non-service-sr" : row.srNumber,
activityCode: row.activity, taskType: row.taskType, deliveryType: row.deliveryType || "",
notes: row.notes, weekId: entry.days[0]?.WeekId, tag: row.type === "Non Service" ? "NON_SERVICE" : "SERVICE" };
for (const day of entry.days) payload[hourFields[dates.fromApi(day.Date).getUTCDay()]] = Number(row.hours[day.Date]) || 0;
return payload;
}
function signature(row) { return JSON.stringify(rowPayload(row)); }
function touched(row) { return row.id || row.persisted || row.srNumber || row.activity || row.taskType || row.notes.trim() || Object.values(row.hours).some(value => value !== "") || Object.values(row.invalidHours).some(Boolean); }
function validRow(row) {
return !!row.taskType && (row.type === "Non Service" || (!!row.srNumber && !!row.activity))
&& !Object.values(row.invalidHours).some(Boolean)
&& Object.values(row.hours).every(value => Number.isFinite(Number(value)) && Number(value) >= 0 && Number(value) <= 24)
&& Object.values(row.hours).some(value => Number(value) > 0);
}
function changedRows() { return entry.rows.filter(row => touched(row) && signature(row) !== row.baseline); }
function pendingDeletes() {
const key = entry.days.map(day => day.Date).join("|");
if (!entry.deletions.has(key)) entry.deletions.set(key, new Set());
return entry.deletions.get(key);
}
function updateSave() {
const rows = changedRows();
const deletions = pendingDeletes();
save.disabled = (rows.length > 0 && !requestSave) || (deletions.size > 0 && !requestDelete) || !entry.tbody || entry.saving || (!rows.length && !deletions.size)
|| entry.rows.some(row => touched(row) && !validRow(row)) || rows.some(row => row.persisted && !row.id);
save.title = save.disabled ? "Complete the required fields and enter hours to save changes." : "Save time entries";
}
async function saveEntries() {
updateSave();
if (save.disabled) return;
const rows = changedRows(), knownIds = new Set(entry.rows.filter(row => row.id).map(row => String(row.id)));
const controls = [];
function lock(node) {
if (["BUTTON", "INPUT", "TEXTAREA", "SELECT"].includes(String(node.tagName || node.tag).toUpperCase())) { controls.push([node, node.disabled]); node.disabled = true; }
Array.from(node.children).forEach(lock);
}
entry.saving = true; lock(modal);
saveStatus.hidden = false; saveStatus.setAttribute("role", "status"); saveStatus.textContent = "Saving time entries...";
let savedCount = 0, deletedCount = 0;
try {
const deletions = pendingDeletes();
for (const id of [...deletions]) {
await requestDelete(id);
deletions.delete(id);
deletedCount++;
}
for (const method of ["PUT", "POST"]) {
const group = rows.filter(row => method === "PUT" ? !!row.id : !row.id);
if (!group.length) continue;
const result = await requestSave(method, group.map(row => ({ ...rowPayload(row), ...(row.id ? { id: row.id } : {}) })));
group.forEach(row => { row.persisted = true; row.baseline = signature(row); });
savedCount += group.length;
if (method === "POST") {
let records = Array.isArray(result) ? result : Array.isArray(result?.items) ? result.items : [];
function assignIds() {
const candidates = rowsFromEntries(records, entry.days);
for (const row of group.filter(row => !row.id)) {
const match = candidates.find(candidate => candidate.id && !knownIds.has(String(candidate.id)) && signature(candidate) === row.baseline);
if (match) { row.id = match.id; knownIds.add(String(match.id)); }
}
}
assignIds();
if (group.some(row => !row.id)) { records = await requestEntries(entry.days[0].WeekId); assignIds(); }
if (group.some(row => !row.id)) throw new Error("Entries saved, but their IDs could not be confirmed. Reopen Manage Time before editing these entries.");
}
}
saveStatus.textContent = [savedCount ? `${savedCount} time ${savedCount === 1 ? "entry" : "entries"} saved.` : "", deletedCount ? `${deletedCount} time ${deletedCount === 1 ? "entry" : "entries"} deleted.` : ""].filter(Boolean).join(" ");
notify(saveStatus.textContent);
saveStatus.hidden = true;
} catch (error) {
saveStatus.setAttribute("role", "alert");
saveStatus.textContent = `${savedCount ? `${savedCount} saved. ` : ""}${deletedCount ? `${deletedCount} deleted. ` : ""}${error.message || "Unable to save time entries."}`;
notify(saveStatus.textContent, true);
} finally {
entry.saving = false;
controls.forEach(([control, disabled]) => { control.disabled = disabled; });
if (active === entry) { renderRows(); updateSave(); }
}
}
function total(value) { return Number(Number(value).toFixed(2)).toString(); } function total(value) { return Number(Number(value).toFixed(2)).toString(); }
function updateTotals() { function updateTotals() {
const groups = { Service: 0, "Non Service": 0, "Time Off": 0, "Total Hours": 0 }; const groups = { Service: 0, "Non Service": 0, "Time Off": 0, "Total Hours": 0 };
@@ -133,18 +233,27 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
}); });
entry.dayTotals[i].textContent = total(sum); entry.dayTotals[i].textContent = total(sum);
}); });
Object.entries(groups).forEach(([label, value]) => { sums[label].textContent = `${total(value)} hrs`; }); Object.entries(groups).forEach(([label, value]) => { sums[label].textContent = `${total(value)}h`; });
updateSave();
} }
function addRow(type, source) { function addRow(type, source) {
if (!entry.tbody || add.disabled) return; if (!entry.tbody || add.disabled) return;
entry.rows.push(source ? { ...source, hours: { ...source.hours } } : blankRow(type)); renderRows(); entry.rows.push(source ? { ...source, id: undefined, persisted: false, baseline: undefined, duplicateHighlightAt: undefined,
...(source.type === "Service" ? { srNumber: "", activity: "" } : {}),
invalidHours: { ...source.invalidHours }, hours: { ...source.hours } } : blankRow(type)); renderRows();
} }
function renderRows() { function renderRows() {
if (entry.saving) return;
const focusedId = document.activeElement?.id; const focusedId = document.activeElement?.id;
combos = []; combos = [];
entry.tbody.replaceChildren(); entry.tbody.replaceChildren();
entry.rows.forEach((row, index) => { entry.rows.forEach((row, index) => {
const tr = node("tr"); const tr = node("tr");
if (row.duplicateHighlightAt && Date.now() - row.duplicateHighlightAt < 5500) {
tr.className = "oe-time-duplicate-row";
tr.setAttribute("style", `animation-delay: -${Math.max(0, Date.now() - row.duplicateHighlightAt)}ms`);
tr.addEventListener("animationend", () => { tr.className = ""; row.duplicateHighlightAt = undefined; });
}
for (const label of [row.type === "Service" ? "Service Request" : "Non Service", "Activity", "Task Type"]) { for (const label of [row.type === "Service" ? "Service Request" : "Non Service", "Activity", "Task Type"]) {
const td = node("td"), select = node("select"); const td = node("td"), select = node("select");
select.id = `oe-time-${index}-${label.toLowerCase().replaceAll(" ", "-")}`; select.id = `oe-time-${index}-${label.toLowerCase().replaceAll(" ", "-")}`;
@@ -156,34 +265,51 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
const option = node("option", "", sr.srTitle ? `${sr.srTitle} (${sr.srNumber})` : sr.srNumber); const option = node("option", "", sr.srTitle ? `${sr.srTitle} (${sr.srNumber})` : sr.srNumber);
option.value = sr.srNumber; option.title = option.textContent; select.append(option); option.value = sr.srNumber; option.title = option.textContent; select.append(option);
} }
if (row.srNumber && !entry.services.some(sr => sr.srNumber === row.srNumber)) {
const option = node("option", "", row.srNumber); option.value = row.srNumber; select.append(option);
}
select.value = row.srNumber; select.value = row.srNumber;
select.disabled = !entry.servicesReady; select.disabled = !entry.servicesReady;
select.title = entry.servicesReady ? "Service Request" : "Loading Service Requests"; select.title = entry.servicesReady ? "Service Request" : "Loading Service Requests";
change = () => { row.srNumber = select.value; row.activity = ""; renderRows(); }; change = () => {
const duplicate = select.value && entry.rows.find(other => other !== row && other.type === "Service" && other.srNumber === select.value);
if (duplicate) {
duplicate.duplicateHighlightAt = Date.now();
select.value = row.srNumber;
notify("This Service Request is already selected in another row.", true);
renderRows(); return;
}
row.srNumber = select.value; row.activity = ""; renderRows();
};
} else if (label === "Activity") { } else if (label === "Activity") {
const source = entry.expanded.get(row.type === "Service" ? row.srNumber : "non-service-sr"); const source = entry.expanded.get(row.type === "Service" ? row.srNumber : "non-service-sr");
const raw = String(source?.SRActivities_c || ""); const raw = String(source?.SRActivities_c || "");
const activities = [...new Set(raw.split(raw.includes(";") ? ";" : ",").map(value => value.trim()).filter(Boolean))]; const activities = [...new Set(raw.split(raw.includes(";") ? ";" : ",").map(value => value.trim()).filter(Boolean))];
if (row.activity && !activities.includes(row.activity)) activities.push(row.activity);
for (const activity of activities) { const option = node("option", "", activity); option.value = activity; select.append(option); } 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.value = row.activity; select.disabled = !activities.length;
select.title = activities.length ? "Activity" : "No activities available for this Service Request"; select.title = activities.length ? "Activity" : "No activities available for this Service Request";
change = () => { row.activity = select.value; }; change = () => { row.activity = select.value; updateSave(); };
} else if (label === "Task Type") { } else if (label === "Task Type") {
const tag = row.type === "Service" ? "SERVICE" : "NON_SERVICE"; const tag = row.type === "Service" ? "SERVICE" : "NON_SERVICE";
const taskTypes = entry.taskTypes.filter(item => item.tags_c === tag); const taskTypes = entry.taskTypes.filter(item => item.tags_c === tag);
for (const item of taskTypes) { for (const item of taskTypes) {
const option = node("option", "", item.lookUpMeaning_c); option.value = item.lookUpCode_c; select.append(option); 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 = ""; if (row.taskType && !taskTypes.some(item => item.lookUpCode_c === row.taskType)) {
const option = node("option", "", row.taskType); option.value = row.taskType; select.append(option);
}
select.value = row.taskType; select.value = row.taskType;
select.disabled = !entry.taskTypesReady || !taskTypes.length; select.disabled = !entry.taskTypesReady || (!taskTypes.length && !row.taskType);
select.title = !entry.taskTypesReady ? "Loading Task Types" : taskTypes.length ? "Task Type" : "No Task Types available for this row type"; select.title = !entry.taskTypesReady ? "Loading Task Types" : taskTypes.length ? "Task Type" : "No Task Types available for this row type";
change = () => { row.taskType = select.value; }; change = () => { row.taskType = select.value; updateSave(); };
} else { select.disabled = true; select.title = "Options are not available yet."; } } else { select.disabled = true; select.title = "Options are not available yet."; }
td.append(select); tr.append(td); td.append(select); tr.append(td);
select.addEventListener("change", change); select.addEventListener("change", change);
const combo = searchableSelect(select, td, change, { const combo = searchableSelect(select, td, change, {
floating: true, floating: true,
listWidth: label === "Service Request" ? 640 : 0,
listMaxHeight: label === "Service Request" ? 360 : 180,
tooltipContent: label === "Service Request" ? serviceRequestTooltip : undefined tooltipContent: label === "Service Request" ? serviceRequestTooltip : undefined
}); });
combo.input.setAttribute("aria-label", `${label}, row ${index + 1}`); combo.input.setAttribute("aria-label", `${label}, row ${index + 1}`);
@@ -193,10 +319,17 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
const td = node("td"), input = node("input"); const td = node("td"), input = node("input");
input.type = "number"; input.min = "0"; input.max = "24"; input.step = "1"; input.type = "number"; input.min = "0"; input.max = "24"; input.step = "1";
input.value = row.hours[day.Date] ?? ""; input.value = row.hours[day.Date] ?? "";
input.setAttribute("aria-invalid", String(!!row.invalidHours[day.Date]));
input.setAttribute("aria-label", `Hours ${day.Date}, row ${index + 1}`); input.setAttribute("aria-label", `Hours ${day.Date}, row ${index + 1}`);
input.addEventListener("input", () => { input.addEventListener("input", () => {
const valid = input.value === "" || (Number.isFinite(input.valueAsNumber) && input.valueAsNumber >= 0 && input.valueAsNumber <= 24); const valid = !input.validity?.badInput && (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(); if (!valid) {
input.value = row.hours[day.Date] ?? "";
notify("Enter hours between 0 and 24.", true);
return;
}
row.invalidHours[day.Date] = !valid;
input.setAttribute("aria-invalid", String(!valid)); row.hours[day.Date] = input.value; updateTotals();
}); td.append(input); tr.append(td); }); td.append(input); tr.append(td);
} }
const notesCell = node("td"), notesRow = node("tr", "oe-time-notes"); notesRow.hidden = !row.notesOpen; const notesCell = node("td"), notesRow = node("tr", "oe-time-notes"); notesRow.hidden = !row.notesOpen;
@@ -205,7 +338,7 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
const remaining = node("span", "oe-time-notes-count", `${2000 - row.notes.length} characters left`); 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; const notes = node("textarea"); notes.rows = 6; notes.maxLength = 2000; notes.value = row.notes;
notes.setAttribute("aria-label", `Notes, row ${index + 1}`); 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`; }); notes.addEventListener("input", () => { row.notes = notes.value.slice(0, 2000); remaining.textContent = `${2000 - row.notes.length} characters left`; updateSave(); });
const collapse = button(`Collapse notes, row ${index + 1}`, "up", () => { const collapse = button(`Collapse notes, row ${index + 1}`, "up", () => {
row.notesOpen = false; notesRow.hidden = true; notesButton.setAttribute("aria-expanded", "false"); notesButton.focus(); row.notesOpen = false; notesRow.hidden = true; notesButton.setAttribute("aria-expanded", "false"); notesButton.focus();
}); });
@@ -216,7 +349,17 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
if (!notesRow.hidden) notes.focus(); if (!notesRow.hidden) notes.focus();
}); notesButton.setAttribute("aria-expanded", String(row.notesOpen)); notesCell.append(notesButton); tr.append(notesCell); }); 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 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); const remove = node("td"); remove.append(button(`Delete row ${index + 1}`, "trash", () => {
if (entry.saving) return;
if (row.persisted && !row.id) {
saveStatus.hidden = false; saveStatus.setAttribute("role", "alert");
saveStatus.textContent = "Reopen Manage Time to obtain the saved entry ID before deleting it."; return;
}
if (row.id) pendingDeletes().add(String(row.id));
entry.rows.splice(index, 1); renderRows();
saveStatus.hidden = false; saveStatus.setAttribute("role", "status");
saveStatus.textContent = pendingDeletes().size ? `${pendingDeletes().size} pending ${pendingDeletes().size === 1 ? "deletion" : "deletions"}. Click Save to apply.` : "Unsaved row removed.";
})); tr.append(remove);
entry.tbody.append(tr, notesRow); entry.tbody.append(tr, notesRow);
}); updateTotals(); }); updateTotals();
combos.find(combo => combo.input.id === focusedId)?.input.focus(); combos.find(combo => combo.input.id === focusedId)?.input.focus();
@@ -240,6 +383,7 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
return details; return details;
} }
async function loadTaskTypes() { async function loadTaskTypes() {
taskStatus.className = "oe-time-status oe-time-loading-status";
taskStatus.hidden = false; taskStatus.textContent = "Loading Task Types..."; taskStatus.hidden = false; taskStatus.textContent = "Loading Task Types...";
taskStatus.setAttribute("role", "status"); taskRetry.hidden = true; taskStatus.setAttribute("role", "status"); taskRetry.hidden = true;
try { try {
@@ -248,10 +392,12 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
entry.taskTypes = items.filter(item => typeof item?.lookUpCode_c === "string" && item.lookUpCode_c.trim() entry.taskTypes = items.filter(item => typeof item?.lookUpCode_c === "string" && item.lookUpCode_c.trim()
&& typeof item.lookUpMeaning_c === "string" && item.lookUpMeaning_c.trim()); && typeof item.lookUpMeaning_c === "string" && item.lookUpMeaning_c.trim());
entry.taskTypesReady = true; entry.taskTypesReady = true;
taskStatus.className = "oe-time-status";
taskStatus.textContent = "No Task Types available."; taskStatus.hidden = entry.taskTypes.length > 0; taskStatus.textContent = "No Task Types available."; taskStatus.hidden = entry.taskTypes.length > 0;
if (entry.tbody) renderRows(); if (entry.tbody) renderRows();
} catch (error) { } catch (error) {
if (active !== entry) return; if (active !== entry) return;
taskStatus.className = "oe-time-status";
taskStatus.textContent = error.message || "Unable to load Task Types."; taskStatus.textContent = error.message || "Unable to load Task Types.";
taskStatus.setAttribute("role", "alert"); taskRetry.hidden = false; taskStatus.setAttribute("role", "alert"); taskRetry.hidden = false;
} }
@@ -260,6 +406,7 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
if (entry.servicesStarted && !retrying) return; if (entry.servicesStarted && !retrying) return;
entry.servicesStarted = true; entry.initialWeekId = weekId; entry.servicesStarted = true; entry.initialWeekId = weekId;
serviceRetry.hidden = true; serviceStatus.hidden = false; serviceRetry.hidden = true; serviceStatus.hidden = false;
serviceStatus.className = "oe-time-status oe-time-loading-status";
serviceStatus.setAttribute("role", "status"); serviceStatus.textContent = "Loading Service Requests..."; serviceStatus.setAttribute("role", "status"); serviceStatus.textContent = "Loading Service Requests...";
try { try {
const services = entry.servicesReady ? entry.services : await requestServices(weekId); const services = entry.servicesReady ? entry.services : await requestServices(weekId);
@@ -276,14 +423,32 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
if (entry.tbody) renderRows(); if (entry.tbody) renderRows();
} catch (error) { } catch (error) {
if (active !== entry) return; if (active !== entry) return;
serviceStatus.className = "oe-time-status";
serviceStatus.textContent = error.message || "Unable to load Service Requests."; serviceStatus.textContent = error.message || "Unable to load Service Requests.";
serviceStatus.setAttribute("role", "alert"); serviceRetry.hidden = false; serviceStatus.setAttribute("role", "alert"); serviceRetry.hidden = false;
} }
} }
function renderWeek(items) { function rowsFromEntries(records, days) {
return records.map(record => {
const row = blankRow(record.tag === "NON_SERVICE" || record.srNumber === "non-service-sr" ? "Non Service" : "Service");
row.id = record.id;
row.deliveryType = record.deliveryType || "";
row.srNumber = record.srNumber || "";
row.activity = record.activityCode || "";
row.taskType = record.taskType || "";
row.notes = typeof record.notes === "string" ? record.notes : "";
for (const day of days) {
const value = record[hourFields[dates.fromApi(day.Date).getUTCDay()]];
row.hours[day.Date] = value == null ? "" : String(value);
}
row.baseline = signature(row);
return row;
});
}
function renderWeek(items, records) {
entry.days = items; entry.days = items;
const key = items.map(day => day.Date).join("|"); const key = items.map(day => day.Date).join("|");
if (!entry.drafts.has(key)) entry.drafts.set(key, Array.from({ length: 3 }, () => blankRow("Service"))); if (!entry.drafts.has(key)) entry.drafts.set(key, records.length ? rowsFromEntries(records, items) : Array.from({ length: 3 }, () => blankRow("Service")));
entry.rows = entry.drafts.get(key); entry.rows = entry.drafts.get(key);
const head = node("thead"), row = node("tr"); const head = node("thead"), row = node("tr");
for (const label of ["Service Request", "Activity", "Task Type"]) row.append(node("th", "", label)); for (const label of ["Service Request", "Activity", "Task Type"]) row.append(node("th", "", label));
@@ -299,20 +464,55 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
head.append(row); entry.tbody = node("tbody"); table.replaceChildren(head, entry.tbody); 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(); endingValue.textContent = dates.fromApi(items[0].EndDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); renderRows();
} }
function renderSkeleton() {
const head = node("thead"), heading = node("tr"), skeletonBody = node("tbody");
for (let column = 0; column < 13; column++) {
const cell = node("th");
cell.append(node("span", "oe-time-skeleton oe-time-skeleton-heading")); heading.append(cell);
}
head.append(heading);
for (let row = 0; row < 3; row++) {
const tr = node("tr", "oe-time-skeleton-row");
for (let column = 0; column < 13; column++) {
const cell = node("td");
cell.append(node("span", `oe-time-skeleton ${column < 3 ? "oe-time-skeleton-select" : "oe-time-skeleton-control"}`)); tr.append(cell);
}
skeletonBody.append(tr);
}
table.setAttribute("aria-hidden", "true"); table.replaceChildren(head, skeletonBody);
}
function finishSkeleton() {
table.setAttribute("aria-hidden", "false");
endingValue.className = "";
Object.values(sums).forEach(value => { value.className = ""; });
status.className = "oe-time-status";
}
async function load() { async function load() {
if (entry.saving) return;
finishSkeleton();
const version = ++entry.version; entry.tbody = null; table.replaceChildren(); endingValue.textContent = "--"; const version = ++entry.version; entry.tbody = null; table.replaceChildren(); endingValue.textContent = "--";
save.disabled = true; saveStatus.hidden = true;
Object.values(sums).forEach(value => { value.textContent = "--"; }); Object.values(sums).forEach(value => { value.textContent = "--"; });
add.disabled = addNonService.disabled = true; retry.hidden = true; status.hidden = false; add.disabled = addNonService.disabled = true; retry.hidden = true; status.hidden = false;
status.setAttribute("role", "status"); scroll.setAttribute("aria-busy", "false"); status.setAttribute("role", "status"); scroll.setAttribute("aria-busy", "false");
if (!dates.parse(date.value)) { status.textContent = "Select a valid reference date."; return; } if (!dates.parse(date.value)) { status.textContent = "Select a valid reference date."; return; }
status.className = "oe-time-status oe-time-loading-status";
endingValue.className = "oe-time-skeleton oe-time-skeleton-summary";
Object.values(sums).forEach(value => { value.className = "oe-time-skeleton oe-time-skeleton-summary"; });
renderSkeleton();
status.textContent = "Loading week details..."; scroll.setAttribute("aria-busy", "true"); status.textContent = "Loading week details..."; scroll.setAttribute("aria-busy", "true");
try { try {
const items = await requestWeek(date.value); const items = await requestWeek(date.value);
if (active !== entry || entry.version !== version) return; if (active !== entry || entry.version !== version) return;
renderWeek(items); add.disabled = addNonService.disabled = false; status.hidden = true; status.textContent = "Loading time entries...";
const key = items.map(day => day.Date).join("|");
const records = entry.drafts.has(key) ? [] : await requestEntries(items[0].WeekId);
if (active !== entry || entry.version !== version) return;
finishSkeleton(); renderWeek(items, records); add.disabled = addNonService.disabled = false; status.hidden = true;
loadServices(items[0].WeekId); loadServices(items[0].WeekId);
} catch (error) { } catch (error) {
if (active !== entry || entry.version !== version) return; if (active !== entry || entry.version !== version) return;
finishSkeleton(); table.replaceChildren();
status.textContent = error.message || "Unable to load week details."; status.textContent = error.message || "Unable to load week details.";
status.setAttribute("role", "alert"); retry.hidden = false; status.setAttribute("role", "alert"); retry.hidden = false;
} finally { } finally {

View File

@@ -6,7 +6,7 @@ function createSearchableSelect({ environment }) {
if (text !== undefined) node.textContent = text; if (text !== undefined) node.textContent = text;
return node; return node;
} }
function searchableSelect(select, field, onChange, { floating = false, tooltipContent } = {}) { function searchableSelect(select, field, onChange, { floating = false, tooltipContent, listWidth = 0, listMaxHeight = 180 } = {}) {
const box = element("div", "opportunities-extension-action-combobox"); const box = element("div", "opportunities-extension-action-combobox");
const input = element("input"); const input = element("input");
input.type = "text"; input.type = "text";
@@ -79,11 +79,11 @@ function createSearchableSelect({ environment }) {
input.value = options().find(item => item.value === select.value)?.textContent || ""; input.value = options().find(item => item.value === select.value)?.textContent || "";
input.title = tooltipContent ? "" : input.value; input.title = tooltipContent ? "" : input.value;
} }
function highlight() { function highlight(withTooltip = true) {
Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index))); Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index)));
input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : ""); input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : "");
list.children[index]?.scrollIntoView?.({ block: "nearest" }); list.children[index]?.scrollIntoView?.({ block: "nearest" });
if (matches[index]) showTooltip(matches[index].value, list.children[index]); if (withTooltip && matches[index]) showTooltip(matches[index].value, list.children[index]);
} }
function choose(i) { function choose(i) {
if (!matches[i]) return; if (!matches[i]) return;
@@ -93,18 +93,22 @@ function createSearchableSelect({ environment }) {
if (changed) onChange?.(); if (changed) onChange?.();
} }
function positionList() { function positionList() {
const bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect(); let bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
const anchor = input.getBoundingClientRect?.(); const anchor = input.getBoundingClientRect?.();
if (!bounds || !anchor) return; if (!bounds || !anchor) return;
if (floating && listWidth && document.documentElement?.clientWidth) {
bounds = { top: 0, left: 0, right: document.documentElement.clientWidth, bottom: document.documentElement.clientHeight };
}
const below = Math.max(0, bounds.bottom - anchor.bottom - 8); const below = Math.max(0, bounds.bottom - anchor.bottom - 8);
const above = Math.max(0, anchor.top - bounds.top - 8); const above = Math.max(0, anchor.top - bounds.top - 8);
const opensAbove = below < 180 && above > below; const opensAbove = below < listMaxHeight && above > below;
list.setAttribute("data-placement", opensAbove ? "above" : "below"); list.setAttribute("data-placement", opensAbove ? "above" : "below");
list.style.maxHeight = `${Math.min(180, opensAbove ? above : below)}px`; list.style.maxHeight = `${Math.min(listMaxHeight, opensAbove ? above : below)}px`;
if (floating) { if (floating) {
const width = listWidth ? Math.min(Math.max(anchor.width, listWidth), bounds.right - bounds.left - 16) : anchor.width;
list.style.position = "fixed"; list.style.position = "fixed";
list.style.left = `${anchor.left}px`; list.style.left = `${listWidth ? Math.max(bounds.left + 8, Math.min(anchor.left, bounds.right - width - 8)) : anchor.left}px`;
list.style.width = `${anchor.width}px`; list.style.width = `${width}px`;
list.style.right = "auto"; list.style.right = "auto";
list.style.bottom = "auto"; list.style.bottom = "auto";
list.style.top = `${opensAbove ? anchor.top - 4 : anchor.bottom + 4}px`; list.style.top = `${opensAbove ? anchor.top - 4 : anchor.bottom + 4}px`;
@@ -115,7 +119,7 @@ function createSearchableSelect({ environment }) {
if (input.disabled) return; if (input.disabled) return;
hideTooltip(); hideTooltip();
matches = options().filter(item => normalize(`${item.textContent} ${item.getAttribute?.("data-description") || ""} ${item.value}`).includes(normalize(query))); matches = options().filter(item => normalize(`${item.textContent} ${item.getAttribute?.("data-description") || ""} ${item.value}`).includes(normalize(query)));
index = -1; index = matches.findIndex(item => item.value === select.value);
list.textContent = ""; list.textContent = "";
matches.forEach((item, i) => { matches.forEach((item, i) => {
const row = element("div", "opportunities-extension-action-option", item.textContent); const row = element("div", "opportunities-extension-action-option", item.textContent);
@@ -140,7 +144,7 @@ function createSearchableSelect({ environment }) {
positionList(); positionList();
list.hidden = false; list.hidden = false;
input.setAttribute("aria-expanded", "true"); input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-activedescendant", ""); highlight(false);
} }
input.addEventListener("click", () => { render(); input.select?.(); }); input.addEventListener("click", () => { render(); input.select?.(); });
input.addEventListener("input", () => render(input.value)); input.addEventListener("input", () => render(input.value));

View File

@@ -4,11 +4,11 @@ function createFormRequestCache({ getHours, getUserId }) {
let generation = 0; let generation = 0;
let owner = ""; let owner = "";
const queries = new Set([ const queries = new Set([
"requestWeekDetails", "requestActiveServiceRequests", "requestServiceRequestExpand", "requestLookupTaskType", "requestWeekDetails", "requestTimeEntries", "requestActiveServiceRequests", "requestServiceRequestExpand", "requestLookupTaskType",
"requestActionTeamMembers", "requestActionFindLookup", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestActionTeamMembers", "requestActionFindLookup", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage",
"requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestLookupTypePlatform", "requestInternalSrServices" "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestLookupTypePlatform", "requestInternalSrServices"
]); ]);
const writes = new Set(["requestMgmtAction", "requestPostInternalServiceRequest", "requestPostServiceRequest"]); const writes = new Set(["requestTimeEntriesDelete", "requestTimeEntriesSave", "requestMgmtAction", "requestPostInternalServiceRequest", "requestPostServiceRequest"]);
function invalidate() { generation++; entries.clear(); pending.clear(); } function invalidate() { generation++; entries.clear(); pending.clear(); }
function maxAge() { function maxAge() {
const hours = Number(getHours()); const hours = Number(getHours());

View File

@@ -1,4 +1,4 @@
function createTimeEntries({ fetchJson, dates, getResourceUser }) { function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
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"; 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) { async function requestWeekDetails(message) {
const debugRequests = []; const debugRequests = [];
@@ -15,6 +15,61 @@ function createTimeEntries({ fetchJson, dates, getResourceUser }) {
throw error; throw error;
} }
} }
async function requestTimeEntries(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$/, "") + `?resourceId=${encodeURIComponent(resource.resource.resourcePartyId)}&sortBy=createdDate&sortOrder=ASC&weekId=${encodeURIComponent(message.weekId)}`;
const items = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestTimeEntries", debugRequests);
if (!Array.isArray(items)) throw new Error("Invalid time entries response.");
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
}
async function requestTimeEntriesDelete(message) {
const debugRequests = [];
try {
if (!/^[1-9]\d*$/.test(String(message.id))) throw new Error("Missing or invalid time entry ID.");
const url = endpoint.replace(/\/weekDetails$/, "") + `/${message.id}`;
const headers = await saveHeaders(url);
const result = await fetchJson(url, { method: "DELETE", credentials: "include", headers },
"requestTimeEntriesDelete", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestTimeEntriesSave(message) {
const debugRequests = [];
try {
if (!["POST", "PUT"].includes(message.method)) throw new Error("Invalid time entry save method.");
if (!Array.isArray(message.items) || !message.items.length) throw new Error("No time entries to save.");
const hours = ["saturdayTaskHours", "sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours"];
for (const item of message.items) {
if (!item || !["SERVICE", "NON_SERVICE"].includes(item.tag) || !item.taskType
|| (item.tag === "SERVICE" && (!/^SR\d+$/.test(item.srNumber) || !item.activityCode))
|| !/^\d+$/.test(String(item.weekId)) || Number(item.weekId) <= 0
|| hours.some(key => typeof item[key] !== "number" || !Number.isFinite(item[key]) || item[key] < 0 || item[key] > 24)
|| !hours.some(key => item[key] > 0)
|| (message.method === "PUT" && (!/^\d+$/.test(String(item.id)) || Number(item.id) <= 0))) throw new Error("Invalid or incomplete time entry.");
}
const resource = await getResourceUser(message);
debugRequests.push(...resource.debugRequests);
const items = message.items.map(item => ({
...(message.method === "PUT" ? { id: item.id } : {}),
srNumber: item.tag === "NON_SERVICE" ? "non-service-sr" : item.srNumber,
activityCode: item.activityCode || "", taskType: item.taskType, deliveryType: item.deliveryType || "",
...Object.fromEntries(hours.map(key => [key, item[key]])), notes: String(item.notes || ""),
weekId: item.weekId, resourceId: String(resource.resource.resourcePartyId), tag: item.tag
}));
const url = endpoint.replace(/\/weekDetails$/, "");
const headers = await saveHeaders(url);
const result = await fetchJson(url, {
method: message.method, credentials: "include",
headers, body: JSON.stringify(items)
}, "requestTimeEntriesSave", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
}
async function requestActiveServiceRequests(message) { async function requestActiveServiceRequests(message) {
const debugRequests = []; const debugRequests = [];
try { try {
@@ -59,5 +114,5 @@ function createTimeEntries({ fetchJson, dates, getResourceUser }) {
return { ok: true, items: result.items, debugRequests }; return { ok: true, items: result.items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; } } catch (error) { error.debugRequests = debugRequests; throw error; }
} }
return { requestWeekDetails, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType }; return { requestWeekDetails, requestTimeEntries, requestTimeEntriesSave, requestTimeEntriesDelete, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType };
} }

View File

@@ -2,6 +2,9 @@
function createMessageTypes() { function createMessageTypes() {
return Object.freeze({ return Object.freeze({
requestWeekDetails: "opportunitiesExtension.requestWeekDetails", requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
requestTimeEntries: "opportunitiesExtension.requestTimeEntries",
requestTimeEntriesSave: "opportunitiesExtension.requestTimeEntriesSave",
requestTimeEntriesDelete: "opportunitiesExtension.requestTimeEntriesDelete",
requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType", requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType",
requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand", requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand",
requestResourceUser: "opportunitiesExtension.requestResourceUser", requestResourceUser: "opportunitiesExtension.requestResourceUser",

View File

@@ -30,6 +30,7 @@
onMessage: { addListener: listener => listeners.push(listener) }, onMessage: { addListener: listener => listeners.push(listener) },
async sendMessage(message) { async sendMessage(message) {
const operation = message.type.split(".").at(-1); const operation = message.type.split(".").at(-1);
if (operation === "requestTimeEntries") return { ok: true, items: [], debugRequests: [] };
if (operation === "requestLookupTaskType") return { ok: true, items: [{ lookUpCode_c: "SCTA_SERVICE_CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }, { lookUpCode_c: "SCTA_SERVICE_STANDARD", lookUpMeaning_c: "Standard", tags_c: "SERVICE" }, { lookUpCode_c: "SCTA_NON_SERVICE_TRAINING", lookUpMeaning_c: "Training", tags_c: "NON_SERVICE" }], debugRequests: [] }; if (operation === "requestLookupTaskType") return { ok: true, items: [{ lookUpCode_c: "SCTA_SERVICE_CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }, { lookUpCode_c: "SCTA_SERVICE_STANDARD", lookUpMeaning_c: "Standard", tags_c: "SERVICE" }, { lookUpCode_c: "SCTA_NON_SERVICE_TRAINING", lookUpMeaning_c: "Training", tags_c: "NON_SERVICE" }], debugRequests: [] };
if (operation === "requestServiceRequestExpand") return { ok: true, items: [...message.srNumbers, "non-service-sr"].map(SrNumber => ({ SrNumber, SRActivities_c: "Account Plan;Customer Research, Assessment and Recommendation;Success Plan" })), debugRequests: [] }; if (operation === "requestServiceRequestExpand") return { ok: true, items: [...message.srNumbers, "non-service-sr"].map(SrNumber => ({ SrNumber, SRActivities_c: "Account Plan;Customer Research, Assessment and Recommendation;Success Plan" })), debugRequests: [] };
if (operation === "requestResourceUser") return { ok: true, resource: { resourcePartyId: 123 }, debugRequests: [] }; if (operation === "requestResourceUser") return { ok: true, resource: { resourcePartyId: 123 }, debugRequests: [] };

View File

@@ -34,7 +34,7 @@ test("failed results are never cached and identity changes discard previous resp
h.user(""); await h.cache.send(msg, send); await h.cache.send(msg, send); assert.equal(calls, 5); h.user(""); await h.cache.send(msg, send); await h.cache.send(msg, send); assert.equal(calls, 5);
}); });
test("writes and token requests always execute; successful saves and Refresh all invalidate queries", async () => { test("writes and token requests always execute; successful saves and Refresh all invalidate queries", async () => {
for (const name of ["requestMgmtAction", "requestPostServiceRequest", "requestPostInternalServiceRequest", "invalidateProductsCache"]) { for (const name of ["requestTimeEntriesSave", "requestTimeEntriesDelete", "requestMgmtAction", "requestPostServiceRequest", "requestPostInternalServiceRequest", "invalidateProductsCache"]) {
const h = setup(); let queries = 0, writes = 0; const h = setup(); let queries = 0, writes = 0;
const query = message("requestActionFindLookup"); const query = message("requestActionFindLookup");
const queryTransport = async () => { queries++; return { ok: true }; }; const queryTransport = async () => { queries++; return { ok: true }; };

View File

@@ -37,7 +37,7 @@ test("week errors return status and debug logs without masking failure", async (
const result = await app.dispatch({ type: "opportunitiesExtension.requestWeekDetails", dateReference: "2026-10-16" }); const result = await app.dispatch({ type: "opportunitiesExtension.requestWeekDetails", dateReference: "2026-10-16" });
assert.equal(result.ok, false); assert.equal(result.httpStatus, 401); assert.equal(result.debugRequests.length, 1); assert.equal(result.ok, false); assert.equal(result.httpStatus, 401); assert.equal(result.debugRequests.length, 1);
}); });
function setup(requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => []) { function setup(requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [], requestEntries = async () => [], localStorage, requestSave, requestDelete) {
let document; let document;
class Element { class Element {
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; } constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; }
@@ -53,16 +53,314 @@ function setup(requestServices = async () => [], requestExpand = async () => [],
} }
const host = new Element("host"); const host = new Element("host");
document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag), getElementById: () => host }; document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag), getElementById: () => host };
const calls = []; const calls = [], notifications = [];
const searchableSelect = factory("src/features/opportunities/searchable-select.js", "createSearchableSelect")({ environment: { document } }); const searchableSelect = factory("src/features/opportunities/searchable-select.js", "createSearchableSelect")({ environment: { document } });
const app = factory("src/features/manage-time/view.js", "createManageTime")({ MODAL_ID: "host", environment: { document }, searchableSelect, dates, requestServices, requestExpand, requestTaskTypes, const app = factory("src/features/manage-time/view.js", "createManageTime")({ MODAL_ID: "host", environment: { document, localStorage }, searchableSelect, dates, requestServices, requestExpand, requestTaskTypes, requestEntries, requestSave, requestDelete,
notify: (message, isError = false) => notifications.push({ message, isError }),
requestWeek: date => new Promise((resolve, reject) => calls.push({ date, resolve, reject })) }); requestWeek: date => new Promise((resolve, reject) => calls.push({ date, resolve, reject })) });
app.setReady(true); app.setReady(true);
const walk = el => [el, ...el.children.flatMap(walk)]; const walk = el => [el, ...el.children.flatMap(walk)];
const nodes = () => walk(host); const nodes = () => walk(host);
const find = label => nodes().find(el => el.attributes["aria-label"] === label); const find = label => nodes().find(el => el.attributes["aria-label"] === label);
return { app, calls, nodes, find, host }; return { app, calls, nodes, find, host, notifications };
} }
test("persisted deletions wait for Save, stay scoped to their week and allow a deletion-only save", async () => {
const deleted = [];
const h = setup(undefined, undefined, undefined, async () => [{ id: 7, srNumber: "SR001", activityCode: "Design", taskType: "PREP", mondayTaskHours: 2, tag: "SERVICE" }], undefined, undefined, async id => deleted.push(id));
h.app.open(); h.calls[0].resolve(week()); await flush();
h.find("Delete row 1").click(); assert.deepEqual(deleted, []); assert.equal(h.find("Save").disabled, false);
h.find("Next week").click();
const otherWeek = week().map(day => ({ ...day, Date: dates.toApi(dates.shift(dates.fromApi(day.Date).toISOString().slice(0, 10), 7)), EndDate: "23-OCT-2026", WeekId: 420 }));
h.calls[1].resolve(otherWeek); await flush(); assert.equal(h.find("Save").disabled, true);
h.find("Previous week").click(); h.calls[2].resolve(week()); await flush();
assert.equal(h.find("Service Request, row 1"), undefined);
h.find("Save").click(); await flush();
assert.deepEqual(deleted, ["7"]); assert.equal(h.find("Save").disabled, true);
assert.deepEqual(h.notifications, [{ message: "1 time entry deleted.", isError: false }]);
});
test("deleting new rows is local; failed deletions retain only unfinished IDs", async () => {
const deleted = []; let fail = true;
const h = setup(undefined, undefined, undefined, async () => [7, 8].map(id => ({ id, srNumber: "SR001", activityCode: "Design", taskType: "PREP", mondayTaskHours: 2, tag: "SERVICE" })), undefined, undefined, async id => {
deleted.push(id); if (id === "8" && fail) throw new Error("HTTP 403");
});
h.app.open(); h.calls[0].resolve(week()); await flush();
h.find("Add").click(); h.find("Delete row 3").click();
assert.equal(h.find("Save").disabled, true); assert.deepEqual(deleted, []);
h.find("Delete row 1").click(); h.find("Delete row 1").click();
h.find("Save").click(); h.find("Save").click(); await flush();
assert.deepEqual(deleted, ["7", "8"]); assert.equal(h.find("Save").disabled, false);
assert.deepEqual(h.notifications, [{ message: "1 deleted. HTTP 403", isError: true }]);
fail = false; h.find("Save").click(); await flush();
assert.deepEqual(deleted, ["7", "8", "8"]); assert.equal(h.find("Save").disabled, true);
});
for (const family of ["chrome", "firefox"]) test(`${family}: DELETE uses the saved ID, identical write headers and a narrow Origin rule`, async () => {
const origin = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com";
const calls = [];
const app = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options }); return new Response(null, { status: 204 });
} });
app.responseHeaderListeners[0].listener({ url: `${origin}/ic/builder/rt/oalset_timeentrymobile/live/test`, incognito: false,
responseHeaders: [{ name: "x-appbuilder-client-id", value: "delete-client" }] });
const result = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntriesDelete", id: 9339403 });
assert.equal(result.ok, true); assert.equal(calls.length, 1);
assert.match(calls[0].url, /\/mgmt\/timeEntries\/9339403$/);
assert.equal(calls[0].options.method, "DELETE"); assert.equal(calls[0].options.body, undefined);
assert.deepEqual({ ...calls[0].options.headers }, { Accept: "application/json", "Content-Type": "application/json", Authorization: "Session", Origin: origin, "x-appbuilder-client-id": "delete-client" });
const rule = app.headerRules[0].addRules[0];
assert.equal(rule.id, 7205); assert.deepEqual(rule.condition.requestMethods, ["delete"]);
assert.equal(new RegExp(rule.condition.regexFilter).test(calls[0].url), true);
assert.equal(new RegExp(rule.condition.regexFilter).test(calls[0].url + "0"), false);
const invalid = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntriesDelete", id: "../other" });
assert.equal(invalid.ok, false); assert.equal(calls.length, 1);
});
test("Save validates service rows, posts once, then puts changes with the returned id", async () => {
const calls = []; let finish;
const h = setup(undefined, undefined, undefined, undefined, undefined, (method, items) => {
calls.push({ method, items }); return new Promise(resolve => { finish = () => resolve(items.map(item => ({ ...item, id: 123 }))); });
});
h.app.open(); h.calls[0].resolve(week()); await flush();
assert.equal(h.find("Save").disabled, true);
for (const [label, value] of [["Service Request", "SR001"], ["Activity", "Solution Design"], ["Task Type", "PREP"]]) {
const input = h.find(`${label}, row 1`); input.value = value; input.listeners.change();
assert.equal(h.find("Save").disabled, true);
}
const hours = h.find("Hours 12-OCT-2026, row 1"); hours.value = "2"; hours.valueAsNumber = 2; hours.listeners.input();
assert.equal(h.find("Save").disabled, false);
h.find("Save").click(); h.find("Save").click();
assert.equal(calls.length, 1); assert.equal(calls[0].method, "POST");
assert.equal(calls[0].items[0].mondayTaskHours, 2); assert.equal(calls[0].items[0].saturdayTaskHours, 0);
assert.equal(calls[0].items[0].id, undefined); assert.equal(calls[0].items[0].weekId, 419);
finish(); await flush(); assert.equal(h.find("Save").disabled, true);
assert.deepEqual(h.notifications, [{ message: "1 time entry saved.", isError: false }]);
const notes = h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 1");
notes.value = "Updated"; notes.listeners.input(); h.find("Save").click();
assert.equal(calls[1].method, "PUT"); assert.equal(calls[1].items[0].id, 123);
finish(); await flush(); assert.equal(h.find("Save").disabled, true);
assert.equal(h.notifications.length, 2); assert.equal(h.notifications[1].isError, false);
});
test("mixed saves retain successful PUTs when POST fails and never repost a saved row without an id", async () => {
const calls = [];
const h = setup(undefined, undefined, undefined, async () => [{ id: 7, srNumber: "SR001", activityCode: "Design", taskType: "PREP", tag: "SERVICE", mondayTaskHours: 2 }], undefined,
async (method, items) => { calls.push({ method, items }); if (method === "POST" && calls.length === 2) throw new Error("Post failed"); return null; });
h.app.open(); h.calls[0].resolve(week()); await flush();
const notes = h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 1");
notes.value = "Changed"; notes.listeners.input();
h.find("Copy row 1").click();
const copiedSr = h.find("Service Request, row 2"); copiedSr.value = "SR002"; copiedSr.listeners.change();
const copiedActivity = h.find("Activity, row 2"); copiedActivity.value = "Design"; copiedActivity.listeners.change();
h.find("Save").click(); await flush();
assert.deepEqual(calls.map(call => call.method), ["PUT", "POST"]);
assert.equal(h.find("Save").disabled, false);
h.find("Save").click(); await flush();
assert.deepEqual(calls.map(call => call.method), ["PUT", "POST", "POST"]);
const copyNotes = h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 2");
copyNotes.value = "Further edit"; copyNotes.listeners.input();
assert.equal(h.find("Save").disabled, true);
assert.ok(h.nodes().some(node => node.attributes.role === "alert" && node.textContent.includes("IDs could not be confirmed")));
});
test("Non Service saves without activity and failed saves keep edited values", async () => {
const calls = [];
const h = setup(undefined, undefined, undefined, undefined, undefined, async (method, items) => { calls.push({ method, items }); throw new Error("HTTP 403"); });
h.app.open(); h.calls[0].resolve(week()); await flush(); h.find("Add (Non service)").click();
const type = h.find("Task Type, row 4"); type.value = "TRAIN"; type.listeners.change();
const hours = h.find("Hours 10-OCT-2026, row 4"); hours.value = "3"; hours.valueAsNumber = 3; hours.listeners.input();
assert.equal(h.find("Save").disabled, false);
hours.value = "25"; hours.valueAsNumber = 25; hours.listeners.input();
assert.equal(hours.value, "3"); assert.equal(h.find("Save").disabled, false);
assert.deepEqual(h.notifications.at(-1), { message: "Enter hours between 0 and 24.", isError: true });
hours.value = "3"; hours.valueAsNumber = 3; hours.listeners.input(); h.find("Save").click(); await flush();
assert.equal(calls[0].items[0].tag, "NON_SERVICE"); assert.equal(calls[0].items[0].srNumber, "non-service-sr");
assert.equal(calls[0].items[0].activityCode, "");
assert.equal(h.find("Hours 10-OCT-2026, row 4").value, "3");
assert.equal(h.find("Save").disabled, false);
assert.ok(h.nodes().some(node => node.attributes.role === "alert" && node.textContent.includes("403")));
});
for (const method of ["POST", "PUT"]) test(`time entry ${method} uses Session and the current resource with a JSON array`, async () => {
let posted;
const app = loadBackground({ fetch: async (url, options) => {
if (url.includes("resourceUsers?")) return new Response(JSON.stringify({ resourcePartyId: 123 }), { headers: { "x-appbuilder-client-id": "time-save-client" } });
assert.match(url, /\/mgmt\/timeEntries$/); assert.equal(options.method, method);
assert.equal(options.headers.Authorization, "Session"); assert.equal(options.headers["Content-Type"], "application/json");
assert.equal(options.headers.Origin, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com");
assert.equal(options.headers["x-appbuilder-client-id"], "time-save-client");
posted = JSON.parse(options.body); return new Response(null, { status: 204 });
} });
const item = { id: 7, srNumber: "SR001", activityCode: "Design", taskType: "PREP", weekId: 419, tag: "SERVICE", mondayTaskHours: 2,
tuesdayTaskHours: 0, wednesdayTaskHours: 0, thursdayTaskHours: 0, fridayTaskHours: 0, saturdayTaskHours: 0, sundayTaskHours: 0 };
const result = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntriesSave", method, items: [item], emailAddress: "test@oracle.com" });
assert.equal(result.ok, true); assert.equal(posted[0].resourceId, "123"); assert.equal(posted[0].id, method === "PUT" ? 7 : undefined);
const rule = app.headerRules.find(update => update.addRules.some(rule => rule.id === 7204)).addRules[0];
assert.deepEqual(rule.condition.requestMethods, ["post", "put"]);
assert.equal(rule.condition.initiatorDomains.length, 1);
assert.equal(rule.action.requestHeaders[0].value, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com");
assert.equal(new RegExp(rule.condition.regexFilter).test("https://example.com/mgmt/timeEntries"), false);
});
test("time entry headers reuse IDs captured from Time Entry responses and session storage", async () => {
const stored = {};
const runtimeApi = { runtime: { getURL: () => "chrome-extension://test/" },
storage: { session: { set: async values => Object.assign(stored, values), get: async () => stored } },
declarativeNetRequest: { updateSessionRules: async () => {} } };
const createHeaders = new vm.Script(fs.readFileSync("src/auth/sr-request-headers.js", "utf8") + "\ncreateSrRequestHeaders;").runInNewContext({ URL });
const headers = createHeaders({ runtimeApi });
const url = "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";
headers.observeResponseHeaders(`${url}/weekDetails?dateValue=16-OCT-2026`, [{ name: "x-appbuilder-client-id", value: "time-entry-client" }]);
assert.equal((await headers.timeEntriesHeaders(url))["x-appbuilder-client-id"], "time-entry-client");
const restarted = createHeaders({ runtimeApi });
assert.equal((await restarted.timeEntriesHeaders(url))["x-appbuilder-client-id"], "time-entry-client");
});
test("hour inputs accept inclusive 0..24 and reject out-of-range or nonnumeric values", async () => {
const h = setup(); h.app.open(); h.calls[0].resolve(week()); await flush();
const input = h.find("Hours 10-OCT-2026, row 1");
for (const value of [0, 24]) {
input.value = String(value); input.valueAsNumber = value; input.listeners.input();
assert.equal(input.value, String(value));
}
for (const value of [-1, 25, 100, Infinity, NaN]) {
input.value = String(value); input.valueAsNumber = value; input.listeners.input();
assert.equal(input.value, "24"); assert.equal(h.notifications.at(-1).isError, true);
}
input.value = ""; input.valueAsNumber = NaN; input.listeners.input();
assert.equal(input.value, "");
});
test("time entry writes reject out-of-range hours before making a network request", async () => {
let calls = 0;
const app = loadBackground({ fetch: async () => { calls++; throw new Error("Unexpected fetch"); } });
for (const value of [-1, 25]) {
const result = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntriesSave", method: "POST", items: [{
srNumber: "SR001", activityCode: "Design", taskType: "PREP", tag: "SERVICE", weekId: 419,
saturdayTaskHours: value, sundayTaskHours: 0, mondayTaskHours: 1, tuesdayTaskHours: 0, wednesdayTaskHours: 0, thursdayTaskHours: 0, fridayTaskHours: 0
}] });
assert.equal(result.ok, false);
}
assert.equal(calls, 0);
});
test("duplicate SR selection restores the previous selection and highlights the existing row", async () => {
const h = setup(async () => [{ srNumber: "SR001", srTitle: "One" }, { srNumber: "SR002", srTitle: "Two" }]);
h.app.open(); h.calls[0].resolve(week()); await flush();
function select(row, value) { const input = h.find(`Service Request, row ${row}`); input.value = value; input.listeners.change(); }
select(1, "SR001"); select(2, "SR002"); select(2, "SR001");
assert.equal(h.find("Service Request, row 2").value, "SR002");
const highlighted = h.nodes().filter(el => el.className === "oe-time-duplicate-row");
assert.equal(highlighted.length, 1);
assert.equal(h.find("Service Request, row 1").parent.parent, highlighted[0]);
assert.equal(h.notifications.at(-1).isError, true);
highlighted[0].listeners.animationend();
assert.equal(highlighted[0].className, "");
h.find("Delete row 1").click(); select(1, "SR001");
assert.equal(h.find("Service Request, row 1").value, "SR001");
const css = fs.readFileSync("src/features/manage-time/styles.css", "utf8");
assert.match(css, /oe-time-duplicate-fade 5\.5s/);
assert.match(css, /90\.909%/);
});
test("reference date persists manual and weekly changes across modal and page reopening", () => {
const saved = new Map();
const storage = { getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value) };
const h = setup(undefined, undefined, undefined, undefined, storage);
h.app.open();
const date = h.find("Reference date"); date.value = "2026-10-09"; date.listeners.change();
h.app.close(); h.app.open();
assert.equal(h.find("Reference date").value, "2026-10-09");
h.find("Next week").click();
const next = setup(undefined, undefined, undefined, undefined, storage); next.app.open();
assert.equal(next.calls[0].date, "2026-10-16");
const invalid = next.find("Reference date"); invalid.value = ""; invalid.listeners.change();
next.app.close(); next.app.open();
assert.equal(next.find("Reference date").value, "2026-10-16");
});
test("invalid stored date falls back to today and unavailable storage retains the in-memory date", () => {
const h = setup(undefined, undefined, undefined, undefined, { getItem: () => "invalid", setItem() { throw new Error("Blocked"); } });
h.app.open(); assert.equal(h.calls[0].date, dates.today());
const date = h.find("Reference date"); date.value = "2026-10-09"; date.listeners.change();
h.app.close(); h.app.open();
assert.equal(h.find("Reference date").value, "2026-10-09");
});
test("loading renders three skeleton rows until entries resolve, then restores empty input rows", async () => {
let resolveEntries;
const h = setup(undefined, undefined, undefined, () => new Promise(resolve => { resolveEntries = resolve; }));
h.app.open();
const skeletons = () => h.nodes().filter(el => el.className === "oe-time-skeleton-row");
assert.equal(skeletons().length, 3);
assert.ok(skeletons().every(row => row.children.length === 13));
assert.equal(h.find("Weekly time entries").attributes["aria-hidden"], "true");
assert.equal(h.find("Add").disabled, true);
h.calls[0].resolve(week()); await flush();
assert.equal(skeletons().length, 3);
resolveEntries([]); await flush();
assert.equal(skeletons().length, 0);
assert.equal(h.find("Weekly time entries").attributes["aria-hidden"], "false");
assert.ok(h.find("Service Request, row 3"));
assert.equal(h.find("Add").disabled, false);
});
test("time entry errors allow retry and stale weeks cannot replace current entries", async () => {
const pending = [];
const h = setup(undefined, undefined, undefined, weekId => new Promise((resolve, reject) => pending.push({ weekId, resolve, reject })));
h.app.open(); h.calls[0].resolve(week()); await flush();
pending[0].reject(new Error("Time entries unavailable")); await flush();
assert.equal(h.find("Retry").hidden, false);
assert.equal(h.find("Add").disabled, true);
h.find("Retry").click(); h.calls[1].resolve(week()); await flush();
h.find("Next week").click(); h.calls[2].resolve(week().map(day => ({ ...day, WeekId: 420 }))); await flush();
pending[2].resolve([{ srNumber: "SR002", activityCode: "Current", tag: "SERVICE" }]); await flush();
pending[1].resolve([{ srNumber: "SR001", activityCode: "Stale", tag: "SERVICE" }]); await flush();
assert.equal(h.find("Service Request, row 1").value, "SR002");
assert.equal(h.find("Activity, row 1").value, "Current");
});
test("saved entries hydrate selections, weekday hours and notes, preserving drafts", async () => {
const calls = [];
const h = setup(undefined, undefined, async () => [{ lookUpCode_c: "PREP", lookUpMeaning_c: "Preparation", tags_c: "SERVICE" }], async weekId => {
calls.push(weekId);
return [{ id: 9339403, srNumber: "SR0001502709", taskType: "PREP", activityCode: "Solution Design", tag: "SERVICE", mondayTaskHours: 2, saturdayTaskHours: 3, sundayTaskHours: 0, notes: "Saved notes" },
{ id: 2, srNumber: "non-service-sr", taskType: "TRAIN", activityCode: "Training", tag: "NON_SERVICE", fridayTaskHours: 4 }];
});
h.app.open(); h.calls[0].resolve(week()); await flush();
assert.deepEqual(calls, [419]);
assert.equal(h.find("Service Request, row 1").value, "SR0001502709");
assert.equal(h.find("Activity, row 1").value, "Solution Design");
assert.equal(h.find("Task Type, row 1").value, "PREP");
assert.equal(h.find("Hours 12-OCT-2026, row 1").value, "2");
assert.equal(h.find("Hours 10-OCT-2026, row 1").value, "3");
assert.equal(h.find("Hours 11-OCT-2026, row 1").value, "0");
assert.equal(h.find("Hours 16-OCT-2026, row 2").value, "4");
assert.ok(h.find("Non Service, row 2"));
const notes = h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 1");
assert.equal(notes.value, "Saved notes");
notes.value = "Edited notes"; notes.listeners.input();
h.find("Next week").click(); h.calls[1].resolve(week()); await flush();
assert.equal(calls.length, 1);
assert.equal(h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 1").value, "Edited notes");
});
for (const family of ["chrome", "firefox"]) test(`${family}: time entries use the resource party, selected week, Session and ascending created date`, async () => {
const calls = [];
const app = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options });
return new Response(JSON.stringify(url.includes("resourceUsers?") ? { resourcePartyId: 123 } : [{ id: 7, srNumber: "SR001" }]));
} });
const result = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntries", weekId: 418, emailAddress: "test@oracle.com" });
assert.equal(result.ok, true);
const call = calls.find(call => call.url.includes("timeEntries?"));
assert.match(call.url, /timeEntries\?resourceId=123&sortBy=createdDate&sortOrder=ASC&weekId=418$/);
assert.equal(call.options.headers.Authorization, "Session");
assert.equal(call.options.credentials, "include");
assert.equal(result.items[0].id, 7);
});
test("autocomplete reopening highlights its saved option and filtering does not select another value", async () => {
const h = setup(undefined, undefined, async () => [
{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" },
{ lookUpCode_c: "PREP", lookUpMeaning_c: "Preparation", tags_c: "SERVICE" }
], async () => [{ srNumber: "SR001", taskType: "PREP", tag: "SERVICE" }]);
h.app.open(); h.calls[0].resolve(week()); await flush();
const input = h.nodes().find(el => el.attributes.role === "combobox" && el.attributes["aria-label"] === "Task Type, row 1");
input.click();
const list = input.parent.children[1];
assert.equal(list.children[0].attributes["aria-selected"], "false");
assert.equal(list.children[1].attributes["aria-selected"], "true");
assert.equal(input.attributes["aria-activedescendant"], list.children[1].id);
input.value = "Config"; input.listeners.input();
assert.equal(list.children[0].attributes["aria-selected"], "false");
assert.equal(h.find("Task Type, row 1").value, "PREP");
input.listeners.blur(); input.click();
assert.equal(list.children[1].attributes["aria-selected"], "true");
});
test("Service Request tooltips use expanded fields in order and keep description separate", async () => { test("Service Request tooltips use expanded fields in order and keep description separate", async () => {
const keys = ["SrNumber", "AccountPartyUniqueName", "OptyName_c", "OptyNumber_c", "OptyOwner_c", "OptyStatus_c", "ServiceNew_c", "ReportedByPartyUniqueName", "CountryText_c", "StatusCdMeaning", "AssigneePersonName", "ProblemDescription"]; const keys = ["SrNumber", "AccountPartyUniqueName", "OptyName_c", "OptyNumber_c", "OptyOwner_c", "OptyStatus_c", "ServiceNew_c", "ReportedByPartyUniqueName", "CountryText_c", "StatusCdMeaning", "AssigneePersonName", "ProblemDescription"];
const record = Object.fromEntries(keys.map(key => [key, key === "SrNumber" ? "SR001" : `Value ${key}`])); const record = Object.fromEntries(keys.map(key => [key, key === "SrNumber" ? "SR001" : `Value ${key}`]));
@@ -108,7 +406,8 @@ test("Manage Time autocompletes search and select SR, dependent Activity and fil
choose("Task Type, row 1", "config"); choose("Task Type, row 1", "config");
assert.equal(h.find("Task Type, row 1").value, "CONFIG"); assert.equal(h.find("Task Type, row 1").value, "CONFIG");
h.find("Copy row 1").click(); h.find("Copy row 1").click();
assert.equal(combo("Activity, row 4").value, "Technical Support"); assert.equal(combo("Activity, row 4").value, "");
assert.equal(combo("Service Request, row 4").value, "");
h.find("Add (Non service)").click(); h.find("Add (Non service)").click();
choose("Task Type, row 5", "train"); choose("Task Type, row 5", "train");
const ids = h.nodes().filter(el => el.attributes.role === "combobox").map(el => el.id); const ids = h.nodes().filter(el => el.attributes.role === "combobox").map(el => el.id);
@@ -181,7 +480,7 @@ test("modal loads today, navigates seven days, ignores stale results and preserv
assert.equal(h.find("Save").disabled, true); assert.equal(h.find("Save").disabled, true);
const hours = h.find("Hours 10-OCT-2026, row 1"); hours.value = "2"; hours.valueAsNumber = 2; hours.listeners.input(); const hours = h.find("Hours 10-OCT-2026, row 1"); hours.value = "2"; hours.valueAsNumber = 2; hours.listeners.input();
assert.equal(hours.step, "1"); assert.equal(hours.step, "1");
assert.ok(h.nodes().some(el => el.tag === "strong" && el.textContent === "2 hrs")); assert.ok(h.nodes().some(el => el.tag === "strong" && el.textContent === "2h"));
h.find("Copy row 1").click(); assert.equal(h.nodes().filter(el => el.type === "number").length, 28); h.find("Copy row 1").click(); assert.equal(h.nodes().filter(el => el.type === "number").length, 28);
h.find("Next week").click(); assert.equal(h.calls[2].date, "2026-10-23"); h.find("Next week").click(); assert.equal(h.calls[2].date, "2026-10-23");
h.find("Previous week").click(); assert.equal(h.calls[3].date, "2026-10-16"); h.find("Previous week").click(); assert.equal(h.calls[3].date, "2026-10-16");
@@ -246,7 +545,7 @@ test("active SRs load once per modal and notes preserve text, character count an
assert.ok(h.host.textContent.includes("1995 characters left")); assert.ok(h.host.textContent.includes("1995 characters left"));
h.find("Collapse notes, row 1").click(); h.find("Collapse notes, row 1").click();
assert.equal(h.find("Notes, row 1").attributes["aria-expanded"], "false"); assert.equal(h.find("Notes, row 1").attributes["aria-expanded"], "false");
h.find("Copy row 1").click(); assert.equal(h.find("Service Request, row 4").value, "SR1"); h.find("Copy row 1").click(); assert.equal(h.find("Service Request, row 4").value, "");
h.find("Next week").click(); h.calls[1].resolve(week()); await flush(); h.find("Next week").click(); h.calls[1].resolve(week()); await flush();
assert.deepEqual(calls, [419]); assert.deepEqual(calls, [419]);
h.app.close(); h.app.open(); h.calls[2].resolve(week()); await flush(); h.app.close(); h.app.open(); h.calls[2].resolve(week()); await flush();