Table sorting preferences will be saved and reused based on the user’s selections

This commit is contained in:
2026-10-25 00:39:55 -03:00
parent 19edee4e11
commit 0f1f399d1a
40 changed files with 896 additions and 230 deletions

View File

@@ -29,6 +29,7 @@ function createContentApplication(overrides = {}) {
const authSessionState = createAuthSessionState(contracts);
const authSalesCommonState = createAuthSalesCommonState();
const opportunitiesQueryState = createOpportunitiesQueryState();
const tableSorting = createTableSorting({ environment });
const opportunitiesTableState = createOpportunitiesTableState();
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
const preferencesState = createPreferencesState(contracts);
@@ -137,7 +138,7 @@ function createContentApplication(overrides = {}) {
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, tableSorting, searchableSelect, dates: createTimeDates(),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
async requestDelete(id) {
await srSessionPreparation.ensure();
@@ -363,6 +364,7 @@ function createContentApplication(overrides = {}) {
closeSrPopover: () => crmSrPopover.close()
});
const opportunitiesTable = createOpportunitiesTable({
tableSorting,
renderCalendar: (...args) => opportunityCalendar.render(...args),
renderActivityFilters: () => activityFilters.render(),
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
@@ -607,6 +609,7 @@ function createContentApplication(overrides = {}) {
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
});
const rampProjectionView = createRampProjectionView({
tableSorting,
saveEstimates: product => workloadForecastUpdate.save(product),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
MODAL_ID: contracts.MODAL_ID,
@@ -630,6 +633,7 @@ function createContentApplication(overrides = {}) {
});
const customerTableSearch = createCustomerTableSearch({ environment });
const installBaseView = createInstallBaseView({
tableSorting,
createSearch: (...args) => customerTableSearch.createSearch(...args),
MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
@@ -645,6 +649,7 @@ function createContentApplication(overrides = {}) {
environment
});
const serviceRequestsView = createServiceRequestsView({
tableSorting,
createSearch: (...args) => customerTableSearch.createSearch(...args),
MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
@@ -690,6 +695,7 @@ function createContentApplication(overrides = {}) {
environment
});
const ratecardView = createRatecardView({
tableSorting,
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
columns: ratecardModel.columns,
formatValue: (...args) => ratecardModel.formatValue(...args),

View File

@@ -99,6 +99,7 @@ function createContentModal({
}
const overlay = document.createElement("div");
overlay.setAttribute("lang", "en");
debugState.requestLog = [];
authSessionState.tokenRelayRequest = null;
authSessionState.consumerTokenServiceRequest = null;
@@ -154,7 +155,7 @@ function createContentModal({
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Fechar");
closeButton.setAttribute("aria-label", "Close");
closeButton.textContent = "X";
closeButton.addEventListener("click", closeOpportunitiesModal);
@@ -170,7 +171,7 @@ function createContentModal({
const preferencesButton = document.createElement("button");
preferencesButton.type = "button";
preferencesButton.className = "opportunities-extension-icon-button";
preferencesButton.setAttribute("aria-label", "Abrir preferências");
preferencesButton.setAttribute("aria-label", "Open preferences");
preferencesButton.title = "Preferences";
preferencesButton.append(createSettingsIcon());
preferencesButton.addEventListener("click", openPreferencesModal);

View File

@@ -218,7 +218,7 @@ function createDebugContentView({
const startedTime = document.createElement("time");
startedTime.className = "opportunities-extension-debug-time";
startedTime.textContent = formatDebugTime(entry.startedAt);
startedTime.title = `Started: ${entry.startedAt.toLocaleString()}`;
startedTime.title = `Started: ${entry.startedAt.toLocaleString("en-GB")}`;
const method = document.createElement("span");
method.className = "opportunities-extension-debug-method";
method.textContent = entry.method || "GET";
@@ -228,7 +228,7 @@ function createDebugContentView({
const hasDuration = typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && entry.durationMs >= 0;
duration.textContent = hasDuration
? entry.durationMs < 1000 ? `${Math.round(entry.durationMs)} ms` : `${(entry.durationMs / 1000).toFixed(2)} s`
: entry.status === "pending" ? "In progress" : "N/D";
: entry.status === "pending" ? "In progress" : "N/A";
duration.title = hasDuration ? `Request duration: ${entry.durationMs} ms` : "Request duration not yet available";
summaryMeta.append(startedTime, method, httpStatus, status, duration);
@@ -349,7 +349,7 @@ function createDebugContentView({
}
function formatDebugTime(date) {
return date.toLocaleTimeString(undefined, {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"

View File

@@ -78,3 +78,5 @@
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-install-base-table .opportunities-extension-account-plans-skeleton-bar { animation: none; }
}
.opportunities-extension-install-base-table .opportunities-extension-sort-button { min-height: 28px; padding: 4px 16px 4px 0; overflow-wrap: anywhere; }
.opportunities-extension-install-base-table .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }

View File

@@ -1,4 +1,4 @@
function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, environment }) {
function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, tableSorting, environment }) {
const { document } = environment;
let active = null;
const columns = [
@@ -33,7 +33,8 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
}
return String(value).trim();
}
function createTable(items, loading = false) {
const sortColumns = columns.map(([key, , type]) => ({ key, type: ["amount", "quantity"].includes(type) ? "number" : type }));
function createTable(items, loading = false, sorting = tableSorting.create("installed-base", sortColumns), onSort) {
const table = element("table", "opportunities-extension-install-base-table");
table.setAttribute("aria-label", "Installed base items");
const head = element("thead");
@@ -43,13 +44,14 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
const col = element("col");
col.className = `install-base-col-${key}`;
group.append(col);
const th = element("th", "", label);
const th = element("th");
th.scope = "col";
sorting.header(th, key, label, onSort, loading);
header.append(th);
});
head.append(header);
const body = element("tbody");
(loading ? Array.from({ length: 8 }, () => ({})) : items).forEach(item => {
(loading ? Array.from({ length: 8 }, () => ({})) : sorting.select(items)).forEach(item => {
const row = element("tr");
columns.forEach(([key, , type]) => {
const cell = element("td");
@@ -103,6 +105,7 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
const status = element("p", "opportunities-extension-install-base-message", "Loading installed base...");
status.setAttribute("role", "status");
let loadedItems = [], loaded = false, failure = "";
const sorting = tableSorting.create("installed-base", sortColumns);
const search = createSearch({ label: "Search installed base", onSearch: renderResults,
getValues: item => columns.flatMap(([key, , type]) => [item[key], formatValue(item[key], type)]) });
const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
@@ -115,7 +118,7 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
modal.addEventListener("keydown", event => {
if (event.key === "Tab") {
event.preventDefault();
const controls = [close, search.input, content];
const controls = [close, search.input, content, ...content.querySelectorAll("button")].filter(control => !control.disabled);
const index = controls.indexOf(document.activeElement);
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus();
}
@@ -130,7 +133,10 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
: "No installed base items found for this customer.");
status.setAttribute("role", failure ? "alert" : "status");
content.append(status);
if (!failure && items.length) content.append(createTable(items));
if (!failure && items.length) content.append(createTable(items, false, sorting, key => {
renderResults();
content.querySelector(`[data-sort-key="${key}"]`)?.focus();
}));
content.scrollTop = 0;
}
return { render(items, error = "") {

View File

@@ -20,6 +20,9 @@
.oe-time-grid { overflow-x: auto; }
.oe-time-grid table { width: 100%; min-width: 1060px; border-collapse: collapse; table-layout: fixed; }
.oe-time-grid th { background: var(--time-muted); padding: 10px 5px; font-weight: 600; text-align: center; border-bottom: 1px solid var(--time-border); }
.oe-time-grid .opportunities-extension-sort-button { min-width: 0; min-height: 28px; padding: 4px 12px 4px 0; border: 0; border-radius: 0; gap: 0; }
.oe-time-grid .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }
.oe-time-grid .oe-time-day-label { margin-bottom: 0; }
.oe-time-grid th:first-child { width: 23%; }
.oe-time-grid th:nth-child(2), .oe-time-grid th:nth-child(3) { width: 12%; }
.oe-time-grid th:nth-last-child(-n+3) { width: 46px; }

View File

@@ -1,4 +1,4 @@
function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requestWeek, requestSave, requestDelete, notify = () => {}, requestEntries = async () => [], requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) {
function createManageTime({ MODAL_ID, environment, tableSorting, searchableSelect, dates, requestWeek, requestSave, requestDelete, notify = () => {}, requestEntries = async () => [], requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) {
const { document } = environment;
let active = null;
let ready = false;
@@ -142,6 +142,16 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
});
function blankRow(type) { return { type, hours: {}, invalidHours: {}, notes: "", srNumber: "", activity: "", taskType: "", notesOpen: false }; }
const hourFields = ["sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours", "saturdayTaskHours"];
const sorting = tableSorting.create("manage-time", [
{ key: "service", value: row => row.type === "Non Service" ? "Non Service" : `${entry.services.find(sr => sr.srNumber === row.srNumber)?.srTitle || ""} ${row.srNumber}` },
{ key: "activity" },
{ key: "taskType", value: row => entry.taskTypes.find(item => item.lookUpCode_c === row.taskType)?.lookUpMeaning_c || row.taskType },
{ key: "notes" },
...hourFields.map((key, weekday) => ({ key, type: "number", value: row => {
const day = entry.days.find(item => dates.fromApi(item.Date).getUTCDay() === weekday);
return day ? row.hours[day.Date] : null;
} }))
]);
function rowPayload(row) {
const payload = { srNumber: row.type === "Non Service" ? "non-service-sr" : row.srNumber,
activityCode: row.activity, taskType: row.taskType, deliveryType: row.deliveryType || "",
@@ -247,7 +257,8 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
const focusedId = document.activeElement?.id;
combos = [];
entry.tbody.replaceChildren();
entry.rows.forEach((row, index) => {
sorting.select(entry.rows).forEach(row => {
const index = entry.rows.indexOf(row);
const tr = node("tr");
if (row.duplicateHighlightAt && Date.now() - row.duplicateHighlightAt < 5500) {
tr.className = "oe-time-duplicate-row";
@@ -451,15 +462,19 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
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);
const head = node("thead"), row = node("tr");
for (const label of ["Service Request", "Activity", "Task Type"]) row.append(node("th", "", label));
for (const [key, label] of [["service", "Service Request"], ["activity", "Activity"], ["taskType", "Task Type"]]) {
const th = node("th"); sorting.header(th, key, label, renderRows); row.append(th);
}
entry.dayTotals = [];
for (const day of items) {
const parsed = dates.fromApi(day.Date), th = node("th", "oe-time-day");
const dayLabel = node("span", "oe-time-day-label", ["Su", "M", "T", "W", "Th", "F", "Sa"][parsed.getUTCDay()]);
const dayButton = sorting.header(th, hourFields[parsed.getUTCDay()], ["Su", "M", "T", "W", "Th", "F", "Sa"][parsed.getUTCDay()], renderRows);
const dayLabel = dayButton.children[0]; dayLabel.className += " oe-time-day-label";
const count = node("span", "oe-time-day-total", "0"); entry.dayTotals.push(count); dayLabel.append(count);
th.append(dayLabel, node("span", "", parsed.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }))); row.append(th);
th.append(node("span", "", parsed.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }))); row.append(th);
}
for (const label of ["Notes", "Copy", ""]) row.append(node("th", "", label));
const notesHeading = node("th"); sorting.header(notesHeading, "notes", "Notes", renderRows); row.append(notesHeading);
for (const label of ["Copy", ""]) row.append(node("th", "", label));
[...row.children].forEach(th => th.setAttribute("scope", "col"));
head.append(row); entry.tbody = node("tbody"); table.replaceChildren(head, entry.tbody);
endingValue.textContent = dates.fromApi(items[0].EndDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); renderRows();

View File

@@ -60,7 +60,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
const count = dayCounts.get(key) || 0;
badge.hidden = count === 0;
badge.textContent = String(count);
badge.title = `${count} oportunidade${count === 1 ? "" : "s"}`;
badge.title = `${count} ${count === 1 ? "opportunity" : "opportunities"}`;
badge.setAttribute("aria-label", badge.title);
badge.setAttribute("data-empty", String(count === 0));
}
@@ -137,7 +137,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
if (copy) identity.append(copy);
const summary = node("dl", "opportunities-extension-action-form-context");
for (const [label, value] of [["Customer", item.CustomerAccount?.PartyUniqueName], ["Owner", getOwnerName(item)],
["Close Date", closeDate(item.EffectiveDate) ? formatDate(item.EffectiveDate) : "N/D"], ["Amount", formatRevenue(item)]]) {
["Close Date", closeDate(item.EffectiveDate) ? formatDate(item.EffectiveDate) : "N/A"], ["Amount", formatRevenue(item)]]) {
summary.append(node("dt", "", label), node("dd", "", String(value || "-")));
}
const badges = node("div", "opportunities-calendar-detail-badges");
@@ -154,13 +154,13 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
for (const event of events.values()) {
const item = event.extendedProps.item;
const button = node("button", "opportunities-calendar-day-item"); button.type = "button";
const summary = `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/D"} | ${formatRevenue(item)}`;
const summary = `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/A"} | ${formatRevenue(item)}`;
button.append(node("strong", "", event.title), node("span", "", summary));
button.title = `${event.title}\n${summary}`;
button.addEventListener("click", () => showDetails(item, trigger));
controls.push(button); content.append(button);
}
showDialog(`${formatDate(date.toISOString().slice(0, 10))} | ${events.size} oportunidades`, content, controls, trigger);
showDialog(`${formatDate(date.toISOString().slice(0, 10))} | ${events.size} ${events.size === 1 ? "opportunity" : "opportunities"}`, content, controls, trigger);
// A truthy non-string result prevents FullCalendar from opening its measured popover.
return true;
}
@@ -173,14 +173,14 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
closeDetails(false);
const workspace = node("section", "opportunities-extension-views");
const tablist = node("div", "opportunities-extension-view-tabs");
tablist.setAttribute("role", "tablist"); tablist.setAttribute("aria-label", "Visualiza\u00e7\u00e3o das oportunidades");
tablist.setAttribute("role", "tablist"); tablist.setAttribute("aria-label", "Opportunity views");
const tabs = {};
const surface = node("section", "opportunities-extension-calendar");
const message = node("p", "opportunities-calendar-message"); message.setAttribute("role", "status");
const calendarHost = node("div", "opportunities-calendar-grid");
const undated = node("details", "opportunities-calendar-undated");
surface.append(message, calendarHost, undated);
for (const [key, label, panel] of [["overview", "Vis\u00e3o geral", tableSurface], ["calendar", "Calend\u00e1rio", surface]]) {
for (const [key, label, panel] of [["overview", "Overview", tableSurface], ["calendar", "Calendar", surface]]) {
const tab = node("button", ""); tab.type = "button"; tab.id = `opportunities-view-${key}-tab`;
tab.append(createViewIcon(key), node("span", "", label));
panel.id = `opportunities-view-${key}`; panel.setAttribute("role", "tabpanel"); panel.setAttribute("aria-labelledby", tab.id);
@@ -234,12 +234,12 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
calendar = new library.Calendar(calendarHost, {
initialView: "dayGridMonth", initialDate: anchor, height: "100%", expandRows: true, fixedWeekCount: false,
datesSet: scheduleLayout,
locale: "pt-br", firstDay: 0, editable: false, selectable: false, eventInteractive: true,
locale: "en", firstDay: 0, editable: false, selectable: false, eventInteractive: true,
headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,dayGridWeek" },
buttonText: { today: "Hoje", month: "M\u00eas", week: "Semana" },
buttonHints: { prev: "Anterior", next: "Pr\u00f3ximo", today: "Hoje", dayGridMonth: "M\u00eas", dayGridWeek: "Semana" },
buttonText: { today: "Today", month: "Month", week: "Week" },
buttonHints: { prev: "Previous", next: "Next", today: "Today", dayGridMonth: "Month", dayGridWeek: "Week" },
dayMaxEvents: 2, moreLinkText: count => `... +${count}`, displayEventTime: false,
moreLinkHint: count => `Ver mais ${count} oportunidades`,
moreLinkHint: count => `View ${count} more ${count === 1 ? "opportunity" : "opportunities"}`,
moreLinkClick: showDayOpportunities,
weekNumbers: true,
weekNumberContent: ({ date }) => ({ domNodes: [node("span", "opportunities-calendar-fiscal-week", fiscalWeekLabel(date))] }),
@@ -255,7 +255,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
eventContent: ({ event }) => {
const item = event.extendedProps.item;
const card = node("div", "opportunities-calendar-event-content");
const summary = node("span", "", `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/D"} | ${formatRevenue(item)}`);
const summary = node("span", "", `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/A"} | ${formatRevenue(item)}`);
summary.title = summary.textContent;
card.append(node("strong", "", event.title), summary);
return { domNodes: [card] };
@@ -280,7 +280,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
const missing = items.filter(item => !closeDate(item.EffectiveDate));
undated.hidden = !missing.length; undated.textContent = "";
if (missing.length) {
undated.append(node("summary", "", `Sem Close Date v\u00e1lido (${missing.length})`));
undated.append(node("summary", "", `No valid Close Date (${missing.length})`));
for (const item of missing) {
const button = node("button", "", `${item.OptyNumber || "-"} | ${item.Name || "-"}`); button.type = "button";
button.addEventListener("click", () => showDetails(item, button)); undated.append(button);

View File

@@ -72,15 +72,15 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
const summary = getSummary(optyNumber);
const reportedHours = summary.reportedHours !== null
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
: "N/D";
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/D" ? "" : "h"}` : "0h");
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
: "N/A";
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/A" ? "" : "h"}` : "0h");
const display = value => value === null || value === undefined || value === "" ? "N/A" : String(value);
const descriptions = items.map(item => [
`Title: ${display(item.Title)}`,
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
`Reported Hours: ${display(item.ReportedHours_c)}`,
`Customer Deadline: ${display(formatDate(item.CustomerDeadline_c))}`,
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/D"}`,
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/A"}`,
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
].join("\n"));

View File

@@ -81,7 +81,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
heading.className = "opportunities-extension-sr-number";
const number = item.SrNumber;
const link = document.createElement(number ? "a" : "span");
link.textContent = number ? String(number) : "N/D";
link.textContent = number ? String(number) : "N/A";
if (number) {
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(number))}`;
link.target = "_blank";
@@ -137,7 +137,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
const label = document.createElement("span");
label.textContent = "Internal SR Number:";
const link = document.createElement(record.SrNumber ? "a" : "span");
link.textContent = record.SrNumber ? String(record.SrNumber) : "N/D";
link.textContent = record.SrNumber ? String(record.SrNumber) : "N/A";
if (record.SrNumber) {
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(record.SrNumber))}&srId=${encodeURIComponent(String(record.SrId ?? ""))}`;
link.target = "_blank";
@@ -149,7 +149,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
body.className = "opportunities-extension-sr-details";
body.textContent = ["Title", "OpportunityName_c", "StatusCd", "ServiceNew_c", "AccountPartyName",
"LastUpdateDate", "SvcHealth_c", "AssigneePersonName", "OpenDate"].map(key =>
`${key === "ServiceNew_c" ? "Service Queue" : key}: ${record[key] == null || record[key] === "" ? "N/D" : String(key === "LastUpdateDate" || key === "OpenDate" ? formatDate(record[key]) : record[key])}`).join("\n");
`${key === "ServiceNew_c" ? "Service Queue" : key}: ${record[key] == null || record[key] === "" ? "N/A" : String(key === "LastUpdateDate" || key === "OpenDate" ? formatDate(record[key]) : record[key])}`).join("\n");
child.append(row, body);
children.append(child);
}

View File

@@ -28,7 +28,7 @@ function createOpportunitiesDashboard({
totalValue,
"TOTAL",
items.length,
(amount) => `${Math.round(amount).toLocaleString()}/${totalCount.toLocaleString()}`
(amount) => `${Math.round(amount).toLocaleString("en-US")}/${totalCount.toLocaleString("en-US")}`
);
totalCard.append(totalLabel, totalValue);
dashboard.append(totalCard);
@@ -81,7 +81,7 @@ function createOpportunitiesDashboard({
}
}
return (amount) => amount.toLocaleString(undefined, {
return (amount) => amount.toLocaleString("en-US", {
maximumFractionDigits: 0
});
}

View File

@@ -144,7 +144,7 @@ function createOpportunitiesFormatters({
}
if (!currency) {
return number.toLocaleString();
return number.toLocaleString("en-US");
}
try {
@@ -153,7 +153,7 @@ function createOpportunitiesFormatters({
currency
}).format(number);
} catch (error) {
return `${currency} ${number.toLocaleString()}`;
return `${currency} ${number.toLocaleString("en-US")}`;
}
}

View File

@@ -33,7 +33,7 @@ function createInternalSrForm({ MODAL_ID, searchableSelect, formatDate, getOppor
const context = node("dl", "opportunities-extension-action-form-context");
const opportunity = getOpportunity(parent.OpportunityName_Id_c?.OptyNumber);
for (const [label, value] of [["SR Number", parent.SrNumber], ["Opportunity", opportunity?.Name], ["Title", parent.Title]]) {
context.append(node("dt", "", label), node("dd", "", value == null || value === "" ? "N/D" : String(value)));
context.append(node("dt", "", label), node("dd", "", value == null || value === "" ? "N/A" : String(value)));
}
heading.append(title, context);
header.append(heading);
@@ -60,7 +60,7 @@ function createInternalSrForm({ MODAL_ID, searchableSelect, formatDate, getOppor
const titleInput = field("title", "Title", "text", true).input;
const deadline = field("customerDeadline", "Customer Deadline", "text").input;
deadline.readOnly = true;
deadline.value = formatDate(parent.CustomerDeadline_c) || "N/D";
deadline.value = formatDate(parent.CustomerDeadline_c) || "N/A";
field("internalDeadline", "Internal Deadline", "date");
const platform = field("platform", "Platform", "select", true);
field("description", "Description", "textarea", true);

View File

@@ -412,6 +412,14 @@
table-layout: fixed;
}
.opportunities-extension-table-skeleton-row { height: 76px; }
.opportunities-extension-table-skeleton-text { width: 85%; max-width: 100%; }
.opportunities-extension-table-skeleton-row:nth-of-type(even) .opportunities-extension-table-skeleton-text { width: 70%; }
.opportunities-extension-table-skeleton-badge { flex: 0 1 30px; width: 30px; min-width: 0; height: 16px; }
.opportunities-extension-table-skeleton-row > td[data-column="customer"] .opportunities-extension-table-skeleton-badge { flex-basis: 18px; width: 18px; }
.opportunities-extension-table-skeleton-row > td[data-column="status"] .opportunities-extension-table-skeleton-text,
.opportunities-extension-table-skeleton-row > td[data-column="stage"] .opportunities-extension-table-skeleton-text { height: 20px; max-width: 66px; border-radius: 8px; }
/* Fixed table layout shares leftover width only between the two auto columns. */
.opportunities-extension-table > thead > tr > th[data-column="name"] { width: auto; }
.opportunities-extension-table > thead > tr > th[data-column="customer"] { width: auto; }

View File

@@ -1,4 +1,5 @@
function createOpportunitiesTable({
tableSorting,
ACCOUNT_DETAIL_URL,
OPPORTUNITIES_COLUMNS,
OPPORTUNITIES_TABLE_ID,
@@ -41,8 +42,8 @@ function createOpportunitiesTable({
opportunitiesTableState.opportunitiesTableState = {
...opportunitiesTableState.opportunitiesTableState,
...nextState,
sortKey: nextState.items ? "" : opportunitiesTableState.opportunitiesTableState.sortKey,
sortDirection: nextState.items ? "ascending" : opportunitiesTableState.opportunitiesTableState.sortDirection
sortKey: tableSorting.read("overview", OPPORTUNITIES_COLUMNS).key,
sortDirection: tableSorting.read("overview", OPPORTUNITIES_COLUMNS).direction
};
renderOpportunitiesTable();
renderCustomerFilter();
@@ -64,7 +65,7 @@ function createOpportunitiesTable({
const caption = document.createElement("caption");
caption.className = "opportunities-extension-visually-hidden";
caption.textContent = "Opportunities";
caption.textContent = opportunitiesTableState.opportunitiesTableState.status === "loading" ? "Loading opportunities..." : "Opportunities";
const tableHead = document.createElement("thead");
const headerRow = document.createElement("tr");
@@ -85,6 +86,7 @@ function createOpportunitiesTable({
const sortButton = document.createElement("button");
sortButton.type = "button";
sortButton.disabled = opportunitiesTableState.opportunitiesTableState.status === "loading";
sortButton.className = "opportunities-extension-sort-button";
sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`);
@@ -106,7 +108,32 @@ function createOpportunitiesTable({
renderStageDashboard(items, opportunitiesTableState.opportunitiesTableState.items.length);
renderCalendar?.(items, opportunitiesTableState.opportunitiesTableState);
if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
if (opportunitiesTableState.opportunitiesTableState.status === "loading") {
for (let index = 0; index < 8; index++) {
const row = document.createElement("tr");
row.className = "opportunities-extension-table-skeleton-row";
row.setAttribute("aria-hidden", "true");
OPPORTUNITIES_COLUMNS.forEach(column => {
const cell = document.createElement("td");
cell.setAttribute("data-column", column.key);
const bar = document.createElement("span");
bar.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-table-skeleton-text";
cell.append(bar);
if (column.key === "name" || column.key === "customer") {
const actions = document.createElement("span");
actions.className = "opportunities-extension-cell-actions";
for (let action = 0; action < (column.key === "name" ? 4 : 3); action++) {
const badge = document.createElement("span");
badge.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-table-skeleton-badge";
actions.append(badge);
}
cell.append(actions);
}
row.append(cell);
});
tableBody.append(row);
}
} else if (opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.className = "opportunities-extension-table-status";
@@ -193,11 +220,13 @@ function createOpportunitiesTable({
}
function sortOpportunitiesBy(key) {
if (!OPPORTUNITIES_COLUMNS.some(column => column.key === key && column.sortable !== false)) return;
const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key;
opportunitiesTableState.opportunitiesTableState.sortKey = key;
opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending"
? "descending"
: "ascending";
tableSorting.save("overview", { key, direction: opportunitiesTableState.opportunitiesTableState.sortDirection });
renderOpportunitiesTable();
}

View File

@@ -155,9 +155,9 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
badge.setAttribute("data-state", state);
setBadgeContent(badge, "actions", state === "matched" || state === "missing" ? actions.length : state === "loading" ? "..." : "?");
badge.title = actions.length ? actions.map(action => [
`Action: ${action.name ?? "N/D"}`, `ID: ${action.id ?? "N/D"}`, `Status: ${action.status ?? "N/D"}`,
`Owner: ${action.owner || "N/D"}`, `Email: ${action.ownerEmail || "N/D"}`, `Workload: ${action.workloadName || "N/D"}`,
`Description: ${action.workloadDescription || "N/D"}`, `Start: ${action.startDate || "N/D"}`, `End: ${action.endDate || "N/D"}`
`Action: ${action.name ?? "N/A"}`, `ID: ${action.id ?? "N/A"}`, `Status: ${action.status ?? "N/A"}`,
`Owner: ${action.owner || "N/A"}`, `Email: ${action.ownerEmail || "N/A"}`, `Workload: ${action.workloadName || "N/A"}`,
`Description: ${action.workloadDescription || "N/A"}`, `Start: ${action.startDate || "N/A"}`, `End: ${action.endDate || "N/A"}`
].join("\n")).join("\n\n") : state === "missing" ? "No actions found for this opportunity's products."
: state === "loading" ? "Loading workload actions..." : "Unable to confirm workload actions. Check product RevnId and Debug Requests.";
if (records.some(record => record?.hasNext)) badge.title += "\nAdditional actions exist; pagination is not enabled.";

View File

@@ -0,0 +1,75 @@
function createTableSorting({ environment }) {
const memory = new Map();
const storageKey = id => `opportunities-extension-table-sort-v1-${id}`;
function read(id, columns) {
let saved = memory.get(id);
if (!saved) {
try { saved = JSON.parse(environment.localStorage?.getItem(storageKey(id)) || "null"); } catch { /* Storage is optional. */ }
}
return saved && columns.some(column => column.key === saved.key && column.sortable !== false)
&& ["ascending", "descending"].includes(saved.direction)
? { key: saved.key, direction: saved.direction } : { key: "", direction: "ascending" };
}
function save(id, state) {
memory.set(id, { ...state });
try { environment.localStorage?.setItem(storageKey(id), JSON.stringify(state)); } catch { /* Retain the page-session preference. */ }
}
function value(raw, type) {
if (raw == null || String(raw).trim() === "") return null;
if (type === "number") return Number.isFinite(Number(raw)) ? Number(raw) : null;
if (type === "date") {
const timestamp = Date.parse(raw);
return Number.isFinite(timestamp) ? timestamp : null;
}
return String(raw);
}
function create(id, columns) {
let state;
const headers = new Map();
const get = () => state || (state = read(id, columns));
function toggle(key) {
if (!columns.some(column => column.key === key && column.sortable !== false)) return;
const previous = get();
state = { key, direction: previous.key === key && previous.direction === "ascending" ? "descending" : "ascending" };
save(id, state);
headers.forEach(update);
}
function select(items) {
const selected = get(), column = columns.find(column => column.key === selected.key);
if (!column) return [...items];
const direction = selected.direction === "descending" ? -1 : 1;
return items.map((item, index) => ({ item, index, value: value(column.value ? column.value(item) : item[column.key], column.type) }))
.sort((a, b) => {
if (a.value === null || b.value === null) return a.value === b.value ? a.index - b.index : a.value === null ? 1 : -1;
const result = typeof a.value === "number" ? a.value - b.value : a.value.localeCompare(b.value, undefined, { numeric: true, sensitivity: "base" });
return result * direction || a.index - b.index;
}).map(record => record.item);
}
function update({ th, button, key, label }) {
const selected = get();
const direction = selected.key === key ? selected.direction : "none";
th.setAttribute("aria-sort", direction);
button.setAttribute("data-sort-direction", direction);
button.setAttribute("aria-label", `Sort by ${label}${direction === "none" ? "" : `, ${direction}`}`);
}
function header(th, key, label, onChange, disabled = false) {
const button = environment.document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-sort-button";
button.disabled = disabled;
button.title = `Sort by ${label}`;
button.setAttribute("data-sort-key", key);
const text = environment.document.createElement("span");
text.className = "opportunities-extension-sort-label";
text.textContent = label;
button.append(text);
const record = { th, button, key, label };
headers.set(key, record); update(record);
th.append(button);
button.addEventListener("click", () => { toggle(key); onChange?.(key); });
return button;
}
return { get, toggle, select, header };
}
return { read, save, create };
}

View File

@@ -60,7 +60,7 @@ function createPreferencesView({
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Fechar preferências");
closeButton.setAttribute("aria-label", "Close preferences");
closeButton.title = "Close";
closeButton.append(createCloseIcon());
closeButton.addEventListener("click", closePreferencesModal);

View File

@@ -189,7 +189,7 @@ function createProductsContentJobs({
timeStyle: "short"
}).format(new Date(timestamp));
} catch (error) {
return new Date(timestamp).toLocaleString();
return new Date(timestamp).toLocaleString("en-GB");
}
}

View File

@@ -13,12 +13,12 @@ function createProductsDetails({
function createProductsRampBadge(productsResult) {
const values = (Array.isArray(productsResult.items) ? productsResult.items : [])
.map(item => item && item.RampMonths_c)
.map(value => value === null || value === undefined || String(value).trim() === "" ? "N/D" : String(value).trim())
.filter((value, index, items) => value !== "N/D" || items.indexOf(value) === index);
.map(value => value === null || value === undefined || String(value).trim() === "" ? "N/A" : String(value).trim())
.filter((value, index, items) => value !== "N/A" || items.indexOf(value) === index);
if (!values.length) return null;
const badge = document.createElement("span");
badge.className = "opportunities-extension-products-ramp";
badge.setAttribute("data-missing", String(values.includes("N/D")));
badge.setAttribute("data-missing", String(values.includes("N/A")));
setBadgeContent(badge, "ramp", values.join(" | "));
badge.title = `Ramp Months: ${values.join(", ")}`;
badge.setAttribute("aria-label", badge.title);
@@ -114,7 +114,7 @@ function createProductsDetails({
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
{ label: "Ramp Months", value: (item) => item.RampMonths_c === null || item.RampMonths_c === undefined || String(item.RampMonths_c).trim() === ""
? "N/D" : formatProductNumber(item.RampMonths_c) },
? "N/A" : formatProductNumber(item.RampMonths_c) },
{ label: "Projection", render: (item) => createProjectionIndicator(item, opportunity) }
];

View File

@@ -1,4 +1,4 @@
function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCloseIcon, expandProducts, saveEstimates, notify, formatAmount, formatDate, environment }) {
function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCloseIcon, expandProducts, saveEstimates, notify, formatAmount, formatDate, environment, tableSorting }) {
const { document } = environment;
let active = null;
function node(tag, className, text) {
@@ -96,8 +96,15 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
const table = node("table", "opportunities-extension-projection-table");
const caption = node("caption", "opportunities-extension-visually-hidden", `Monthly consumption comparison (${currency}). Difference is forecast minus calculated.`);
const head = node("thead"), headRow = node("tr"), tbody = node("tbody");
for (const label of ["Month", "Forecast", "Calculated", "Difference"]) {
const th = node("th", "", label); th.scope = "col"; headRow.append(th);
const sorting = tableSorting.create("consumption-projection", [
{ key: "month", type: "number", value: item => item.year * 12 + item.month },
...["actual", "calculated", "difference"].map(key => ({ key, type: "number" }))
]);
const rowNodes = new Map();
const renderRows = () => tbody.replaceChildren(...sorting.select(result.rows).map(item => rowNodes.get(item)));
for (const [key, label] of [["month", "Month"], ["actual", "Forecast"], ["calculated", "Calculated"], ["difference", "Difference"]]) {
const th = node("th"); th.scope = "col";
sorting.header(th, key, label, renderRows); headRow.append(th);
}
head.append(headRow);
for (const item of result.rows) {
@@ -108,8 +115,9 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
if (item.prorated) month.append(node("span", "opportunities-extension-projection-prorated", " (Prorated)"));
tr.append(month);
for (const value of [item.actual, item.calculated, item.difference]) tr.append(node("td", "", value === null ? "-" : formatAmount(value, currency)));
tbody.append(tr);
rowNodes.set(item, tr);
}
renderRows();
table.append(caption, head, tbody);
viewport.append(table);
body.append(viewport);
@@ -203,7 +211,7 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key === "Tab") {
const controls = [dismiss, viewport, updateButton].filter(control => !control.disabled);
const controls = [dismiss, viewport, ...table.querySelectorAll("button"), updateButton].filter(control => !control.disabled);
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();

View File

@@ -18,6 +18,9 @@
.opportunities-extension-projection-table th, .opportunities-extension-projection-table td { padding: 9px 12px; border-bottom: 1px solid #dedbd7; text-align: right; white-space: nowrap; }
.opportunities-extension-projection-table th:first-child { text-align: left; }
.opportunities-extension-projection-table thead th { background: #f5f4f2; font-weight: 600; }
.opportunities-extension-projection-table .opportunities-extension-sort-button { min-height: 28px; padding: 4px 18px 4px 0; justify-content: flex-end; }
.opportunities-extension-projection-table th:first-child .opportunities-extension-sort-button { justify-content: flex-start; }
.opportunities-extension-projection-table .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }
.opportunities-extension-projection-table tbody th { font-weight: 400; }
.opportunities-extension-projection-table tr[data-divergent="true"] { background: #fff7e5; }
.opportunities-extension-projection-table tr[data-divergent="true"] td:last-child { color: #815400; font-weight: 700; }

View File

@@ -1,4 +1,4 @@
function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, selectItems, createConsumptionHeader, createPlanDetails, environment }) {
function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, selectItems, createConsumptionHeader, createPlanDetails, tableSorting, environment }) {
const { document, setTimeout, clearTimeout, requestAnimationFrame, cancelAnimationFrame } = environment;
let active = null;
let closing = null;
@@ -78,6 +78,9 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
const headRow = element("tr");
const sortHeaders = [];
const current = { root, background, wasInert: background.inert, trigger, items: [], status: "loading", sortKey: "", direction: "asc", openFrame: null };
const savedSort = tableSorting.read("rate-card", columns);
current.sortKey = savedSort.key;
current.direction = savedSort.direction === "descending" ? "desc" : "asc";
active = current;
background.inert = true;
const alive = () => active === current && root.isConnected && !parent.hidden;
@@ -98,6 +101,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
sort.addEventListener("click", () => {
current.direction = current.sortKey === column.key && current.direction === "asc" ? "desc" : "asc";
current.sortKey = column.key;
tableSorting.save("rate-card", { key: current.sortKey, direction: current.direction === "desc" ? "descending" : "ascending" });
updateSortHeaders();
if (current.status === "ready") renderRows();
});
@@ -106,6 +110,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
headRow.append(th);
});
head.append(headRow);
updateSortHeaders();
const body = element("tbody");
table.append(caption, colgroup, head, body);
viewport.append(table);

View File

@@ -38,3 +38,5 @@
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-service-requests-table .opportunities-extension-account-plans-skeleton-bar { animation: none; }
}
.opportunities-extension-service-requests-table .opportunities-extension-sort-button { min-height: 28px; padding: 4px 16px 4px 0; overflow-wrap: anywhere; }
.opportunities-extension-service-requests-table .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }

View File

@@ -1,4 +1,4 @@
function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, environment }) {
function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, tableSorting, environment }) {
const { document } = environment;
let active = null;
const columns = [
@@ -31,7 +31,8 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
const text = value === null || value === undefined || value === "" ? "-" : String(value);
return text !== "-" && (key === "created" || key === "lastUpdated") ? formatOracleResponseDate(value) : text;
}
function createTable(items, loading = false) {
const sortColumns = columns.map(([key, , value]) => ({ key, value, type: ["created", "lastUpdated"].includes(key) ? "date" : "text" }));
function createTable(items, loading = false, sorting = tableSorting.create("service-requests", sortColumns), onSort) {
const table = element("table", "opportunities-extension-service-requests-table");
table.setAttribute("aria-label", "Customer service requests");
const group = element("colgroup");
@@ -39,13 +40,14 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
const header = element("tr");
columns.forEach(([key, label]) => {
group.append(element("col", `service-requests-col-${key}`));
const th = element("th", "", label);
const th = element("th");
th.scope = "col";
sorting.header(th, key, label, onSort, loading);
header.append(th);
});
head.append(header);
const body = element("tbody");
(loading ? Array.from({ length: 8 }, () => ({})) : items).forEach(item => {
(loading ? Array.from({ length: 8 }, () => ({})) : sorting.select(items)).forEach(item => {
const row = element("tr");
columns.forEach(([key, , getValue]) => {
const cell = element("td");
@@ -99,6 +101,7 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
const status = element("p", "opportunities-extension-service-requests-message", "Loading service requests...");
status.setAttribute("role", "status");
let loadedItems = [], loaded = false, failure = "";
const sorting = tableSorting.create("service-requests", sortColumns);
const search = createSearch({ label: "Search service requests", onSearch: renderResults,
getValues: item => columns.flatMap(([key, , getValue]) => [getValue(item), formatValue(getValue(item), key)]) });
const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
@@ -111,7 +114,7 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
modal.addEventListener("keydown", event => {
if (event.key === "Tab") {
event.preventDefault();
const controls = [close, search.input, content];
const controls = [close, search.input, content, ...content.querySelectorAll("button")].filter(control => !control.disabled);
const index = controls.indexOf(document.activeElement);
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus();
}
@@ -126,7 +129,10 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
: "No open service requests found for this customer.");
status.setAttribute("role", failure ? "alert" : "status");
content.append(status);
if (!failure && items.length) content.append(createTable(items));
if (!failure && items.length) content.append(createTable(items, false, sorting, key => {
renderResults();
content.querySelector(`[data-sort-key="${key}"]`)?.focus();
}));
content.scrollTop = 0;
}
return { render(items, error = "") {