Updated the calendar to improve opportunity display and navigation
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
function createActivityProgress({ MODAL_ID, environment }) {
|
||||
function createActivityProgress({ MODAL_ID, environment, retrySession = () => {} }) {
|
||||
const { document, setTimeout, clearTimeout, matchMedia } = environment;
|
||||
const entries = new Map();
|
||||
function update(key, state, completed = 0, total = null) {
|
||||
@@ -29,6 +29,15 @@ function createActivityProgress({ MODAL_ID, environment }) {
|
||||
: state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`;
|
||||
if (key === "session") entry.badge.textContent = state === "complete" ? "SR session ready" : state === "error" ? "SR session unavailable" : "SR session loading";
|
||||
entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Product workloads checked for Actions in the selected Reference, regardless of display filters";
|
||||
if (key === "session" && state === "error") {
|
||||
const retry = document.createElement("button");
|
||||
retry.type = "button";
|
||||
retry.className = "opportunities-extension-session-retry";
|
||||
retry.textContent = "Retry";
|
||||
retry.setAttribute("aria-label", "Retry Service Request session initialization");
|
||||
retry.addEventListener("click", () => { retry.disabled = true; retrySession(); });
|
||||
entry.badge.append(retry);
|
||||
}
|
||||
if (state === "complete") entry.timer = setTimeout(() => {
|
||||
entry.badge.setAttribute("data-fading", "true");
|
||||
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
@@ -16,6 +16,22 @@
|
||||
border: 1px solid var(--fc-border-color); border-radius: 4px; font: 13px/1.4 Arial, sans-serif;
|
||||
}
|
||||
.opportunities-calendar-grid { flex: 1 1 0; min-height: 0; min-width: 0; }
|
||||
.opportunities-calendar-day-cards { position: absolute; inset: 27px 0 0; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; padding-bottom: 4px; }
|
||||
.opportunities-calendar-day-card { display: block; width: calc(100% - 8px); padding: 0; text-align: left; font: inherit; }
|
||||
.opportunities-calendar-day-card:focus-visible { outline: 2px solid #00758f; outline-offset: -2px; }
|
||||
/* A single vertical sequence of months, with a separate scroll area per day. */
|
||||
.opportunities-extension-calendar .fc .fc-multimonth { overflow-y: auto; overflow-x: hidden; }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth-month { width: 100%; }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth-daygrid-table { height: auto !important; }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-body tbody { height: auto; }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-body tr { height: var(--calendar-week-height, 190px); }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-frame { position: relative; height: var(--calendar-week-height, 190px); min-height: var(--calendar-week-height, 190px); }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-events {
|
||||
position: absolute; inset: 27px 0 0; overflow-y: auto; overflow-x: hidden;
|
||||
margin: 0; padding-bottom: 4px; scrollbar-width: thin;
|
||||
}
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-event-harness { position: relative !important; top: auto !important; left: auto !important; right: auto !important; }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-bg { pointer-events: none; }
|
||||
.opportunities-extension-calendar .fc { min-width: 620px; }
|
||||
.opportunities-extension-calendar .fc .fc-toolbar { gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.opportunities-extension-calendar .fc .fc-toolbar-title { font-size: 18px; line-height: 26px; letter-spacing: 0; }
|
||||
@@ -103,3 +119,12 @@
|
||||
.opportunities-calendar-day-count { top: 25px; }
|
||||
.fc-daygrid-week-number ~ .opportunities-calendar-day-count { left: 6px; }
|
||||
}
|
||||
.opportunities-view-bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--oe-table-heading-border); flex-wrap: wrap; }
|
||||
.opportunities-view-bar .opportunities-extension-view-tabs { border-bottom: 0; }
|
||||
.opportunities-product-type-filters { display: flex; gap: 8px; margin-left: auto; padding: 4px 0; }
|
||||
.opportunities-product-type-filter { border: 1px solid #8f8a85; border-radius: 12px; padding: 3px 9px; background: transparent; color: inherit; font: 600 12px/16px Arial, sans-serif; cursor: pointer; }
|
||||
.opportunities-product-type-filter[aria-pressed="true"][data-type="Booking"] { color: #714016; background: #fff0df; border-color: #c58b50; }
|
||||
.opportunities-product-type-filter[aria-pressed="true"][data-type="Workload"] { color: #63358a; background: #f0e8f7; border-color: #bba5cf; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-product-type-filter[aria-pressed="true"][data-type="Booking"] { color: #ffdbb5; background: #4b321e; border-color: #c99760; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-product-type-filter[aria-pressed="true"][data-type="Workload"] { color: #ead5fc; background: #43334f; border-color: #9372ac; }
|
||||
.opportunities-product-type-filter:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion,
|
||||
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, getRange,
|
||||
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
|
||||
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon }) {
|
||||
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon,
|
||||
productTypeSelection, onProductTypeChange }) {
|
||||
const { document } = environment;
|
||||
let elements = null;
|
||||
let calendar = null;
|
||||
@@ -10,34 +11,49 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
let details = null;
|
||||
let lastEvents = "";
|
||||
let dayCounts = new Map();
|
||||
let dayEvents = new Map();
|
||||
const dayBadges = new Map();
|
||||
let resizeObserver = null;
|
||||
let layoutFrame = null;
|
||||
let eventLimit = 2;
|
||||
function visibleEventLimit(height, weeks, headerHeight) {
|
||||
return Math.max(0, Math.min(2, Math.floor((height / Math.max(1, weeks) - headerHeight - 26) / 48)));
|
||||
}
|
||||
let positionToday = false;
|
||||
function scheduleLayout() {
|
||||
for (const [name, icon] of [["today", "today"], ["dayGridMonth", "calendar"], ["dayGridWeek", "week"]]) {
|
||||
const button = elements?.calendarHost.querySelector?.(`.fc-${name}-button`);
|
||||
if (button && !button.querySelector("svg")) button.prepend(createViewIcon(icon));
|
||||
}
|
||||
const win = environment.window;
|
||||
if (!win?.requestAnimationFrame || layoutFrame !== null) return;
|
||||
layoutFrame = win.requestAnimationFrame(() => {
|
||||
layoutFrame = null;
|
||||
if (!calendar || selectedView !== "calendar" || !elements.calendarHost.clientHeight) return;
|
||||
calendar.updateSize();
|
||||
const frame = elements.calendarHost.querySelector(".fc-daygrid-day-frame");
|
||||
const events = frame?.querySelector(".fc-daygrid-day-events");
|
||||
if (!frame || !events) return;
|
||||
const scroller = frame.closest(".fc-scroller");
|
||||
const weeks = elements.calendarHost.querySelectorAll(".fc-daygrid-body tr").length;
|
||||
if (!scroller || !weeks) return;
|
||||
elements.calendarHost.style.setProperty("--opportunities-calendar-weeks", String(weeks));
|
||||
const headerHeight = events.getBoundingClientRect().top - frame.getBoundingClientRect().top;
|
||||
const limit = visibleEventLimit(scroller.clientHeight, weeks, headerHeight);
|
||||
if (limit !== eventLimit) { eventLimit = limit; calendar.setOption("dayMaxEvents", limit); }
|
||||
if (calendar && selectedView === "calendar") {
|
||||
calendar.updateSize();
|
||||
if (positionToday && !elements.calendarHost.hidden) {
|
||||
const scroller = elements.calendarHost.querySelector(".fc-multimonth");
|
||||
if (!scroller?.clientHeight) return;
|
||||
const today = dateKey(new Date());
|
||||
const range = getRange();
|
||||
const cell = range && today >= range.startCloseDate.slice(0, 10) && today < range.endCloseDate.slice(0, 10)
|
||||
? elements.calendarHost.querySelector(`[data-date="${today}"]:not(.fc-day-other)`)
|
||||
: null;
|
||||
if (cell) {
|
||||
const first = new Date(`${today}T12:00:00`);
|
||||
first.setDate(first.getDate() - first.getDay() - 7);
|
||||
const last = new Date(first);
|
||||
last.setDate(last.getDate() + 20);
|
||||
const start = dateKey(first), end = dateKey(last);
|
||||
// A week crossing a month boundary has two rendered rows. Include both.
|
||||
const rows = [...scroller.querySelectorAll(".fc-daygrid-body tr")].filter(row =>
|
||||
[...row.querySelectorAll("[data-date]:not(.fc-day-other)")].some(day => {
|
||||
const date = day.getAttribute("data-date");
|
||||
return date >= start && date <= end;
|
||||
}));
|
||||
const headings = [...new Set(rows.map(row => row.closest(".fc-multimonth-month")?.querySelector(".fc-multimonth-header")))].filter(Boolean);
|
||||
const headerSpace = headings.reduce((sum, header) => sum + header.getBoundingClientRect().height, 0);
|
||||
const height = Math.max(48, Math.floor((scroller.clientHeight - headerSpace - 20) / Math.max(1, rows.length)));
|
||||
elements.calendarHost.style.setProperty("--calendar-week-height", `${height}px`);
|
||||
const target = rows[0] || cell;
|
||||
const inset = (headings[0]?.getBoundingClientRect().height || 0) + 8;
|
||||
scroller.scrollTop += target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - inset;
|
||||
} else scroller.scrollTop = 0;
|
||||
positionToday = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
function dateKey(date) {
|
||||
@@ -64,6 +80,21 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
badge.setAttribute("aria-label", badge.title);
|
||||
badge.setAttribute("data-empty", String(count === 0));
|
||||
}
|
||||
function renderDayCards(container, key) {
|
||||
container.textContent = "";
|
||||
for (const event of dayEvents.get(key) || []) {
|
||||
const item = event.extendedProps.item;
|
||||
const button = node("button", `opportunities-calendar-day-card fc-daygrid-event ${event.classNames.join(" ")}`);
|
||||
button.type = "button";
|
||||
const card = node("div", "opportunities-calendar-event-content");
|
||||
const summary = `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/A"} | ${formatRevenue(item)}`;
|
||||
card.append(node("strong", "", event.title), node("span", "", summary));
|
||||
button.title = `${event.title}\n${summary}`;
|
||||
button.append(card);
|
||||
button.addEventListener("click", () => showDetails(item, button));
|
||||
container.append(button);
|
||||
}
|
||||
}
|
||||
function node(tag, className, text) {
|
||||
const el = document.createElement(tag);
|
||||
el.className = className;
|
||||
@@ -145,29 +176,10 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
content.append(identity, summary, badges);
|
||||
showDialog(item.Name || item.OptyNumber || "Opportunity", content, item.OptyNumber ? [link, copy] : [], trigger);
|
||||
}
|
||||
function showDayOpportunities({ date, allSegs, jsEvent }) {
|
||||
jsEvent.preventDefault();
|
||||
const trigger = jsEvent.currentTarget || elements?.tabs.calendar;
|
||||
const content = node("div", "opportunities-calendar-day-list");
|
||||
const controls = [];
|
||||
const events = new Map(allSegs.map(({ event }) => [event.id, event]));
|
||||
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/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} ${events.size === 1 ? "opportunity" : "opportunities"}`, content, controls, trigger);
|
||||
// A truthy non-string result prevents FullCalendar from opening its measured popover.
|
||||
return true;
|
||||
}
|
||||
function mount(body, tableSurface) {
|
||||
resizeObserver?.disconnect();
|
||||
if (layoutFrame !== null) environment.window?.cancelAnimationFrame(layoutFrame);
|
||||
layoutFrame = null; eventLimit = 2;
|
||||
layoutFrame = null;
|
||||
calendar?.destroy(); calendar = null; lastEvents = ""; anchoredVersion = null; selectedView = "overview";
|
||||
dayBadges.clear(); dayCounts.clear();
|
||||
closeDetails(false);
|
||||
@@ -194,7 +206,26 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
});
|
||||
tabs[key] = tab; tablist.append(tab);
|
||||
}
|
||||
workspace.append(tablist, tableSurface, surface); body.append(workspace);
|
||||
const viewbar = node("div", "opportunities-view-bar");
|
||||
viewbar.append(tablist);
|
||||
if (productTypeSelection) {
|
||||
const filters = node("div", "opportunities-product-type-filters");
|
||||
filters.setAttribute("role", "group"); filters.setAttribute("aria-label", "Opportunity type");
|
||||
for (const type of ["Booking", "Workload"]) {
|
||||
const button = node("button", "opportunities-product-type-filter", type);
|
||||
button.type = "button";
|
||||
button.setAttribute("data-type", type);
|
||||
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
|
||||
button.addEventListener("click", () => {
|
||||
if (productTypeSelection.has(type)) productTypeSelection.delete(type); else productTypeSelection.add(type);
|
||||
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
|
||||
onProductTypeChange();
|
||||
});
|
||||
filters.append(button);
|
||||
}
|
||||
viewbar.append(filters);
|
||||
}
|
||||
workspace.append(viewbar, tableSurface, surface); body.append(workspace);
|
||||
elements = { workspace, tableSurface, surface, message, calendarHost, undated, tabs };
|
||||
if (environment.window?.ResizeObserver) {
|
||||
resizeObserver = new environment.window.ResizeObserver(scheduleLayout);
|
||||
@@ -204,6 +235,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
}
|
||||
function selectView(view) {
|
||||
if (!elements || !["overview", "calendar"].includes(view)) return;
|
||||
if (view === "calendar" && selectedView !== "calendar") positionToday = true;
|
||||
closeDetails(false); closeSrPopover(); selectedView = view;
|
||||
elements.tableSurface.hidden = view !== "overview"; elements.surface.hidden = view !== "calendar";
|
||||
for (const [key, tab] of Object.entries(elements.tabs)) {
|
||||
@@ -217,41 +249,57 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
if (!elements || selectedView !== "calendar") return;
|
||||
const { surface, message, calendarHost, undated } = elements;
|
||||
surface.setAttribute("aria-busy", String(state.status === "loading"));
|
||||
if (["loading", "error", "empty", "idle"].includes(state.status)) {
|
||||
if (["loading", "error", "idle"].includes(state.status)) {
|
||||
message.textContent = state.message || (state.status === "loading" ? "Loading opportunities..." : "No opportunities found.");
|
||||
message.hidden = false; calendarHost.hidden = true; undated.hidden = true; closeDetails(false); return;
|
||||
}
|
||||
message.textContent = items.length ? "" : "No opportunities found."; message.hidden = !!items.length;
|
||||
calendarHost.hidden = false;
|
||||
const events = eventsFor(items);
|
||||
const range = getRange();
|
||||
if (!range) { calendarHost.hidden = true; return; }
|
||||
const start = range.startCloseDate.slice(0, 10);
|
||||
const end = range.endCloseDate.slice(0, 10);
|
||||
const visibleRange = { start, end }; // Fiscal ranges end at the next period's start (exclusive).
|
||||
const events = eventsFor(items).filter(event => event.start >= start && event.start < end);
|
||||
dayCounts = new Map();
|
||||
for (const event of events) dayCounts.set(event.start, (dayCounts.get(event.start) || 0) + 1);
|
||||
for (const { badge, key } of dayBadges.values()) updateDayBadge(badge, key);
|
||||
const anchor = initialDate(state.items);
|
||||
dayEvents = new Map();
|
||||
for (const event of events) {
|
||||
dayCounts.set(event.start, (dayCounts.get(event.start) || 0) + 1);
|
||||
if (!dayEvents.has(event.start)) dayEvents.set(event.start, []);
|
||||
dayEvents.get(event.start).push(event);
|
||||
}
|
||||
for (const { badge, key, cards } of dayBadges.values()) { updateDayBadge(badge, key); renderDayCards(cards, key); }
|
||||
const anchor = start;
|
||||
if (!calendar) {
|
||||
try {
|
||||
library ||= loadLibrary();
|
||||
calendar = new library.Calendar(calendarHost, {
|
||||
initialView: "dayGridMonth", initialDate: anchor, height: "100%", expandRows: true, fixedWeekCount: false,
|
||||
initialView: "referenceMonths", initialDate: anchor, height: "100%", fixedWeekCount: false,
|
||||
views: { referenceMonths: { type: "multiMonth", visibleRange, dayMaxEvents: false, dayMaxEventRows: false } },
|
||||
visibleRange, validRange: visibleRange, showNonCurrentDates: false,
|
||||
multiMonthMaxColumns: 1, multiMonthMinWidth: 0,
|
||||
multiMonthTitleFormat: { month: "long", year: "numeric" },
|
||||
datesSet: scheduleLayout,
|
||||
locale: "en", firstDay: 0, editable: false, selectable: false, eventInteractive: true,
|
||||
headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,dayGridWeek" },
|
||||
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 => `View ${count} more ${count === 1 ? "opportunity" : "opportunities"}`,
|
||||
moreLinkClick: showDayOpportunities,
|
||||
headerToolbar: false,
|
||||
dayMaxEvents: false, dayMaxEventRows: false, displayEventTime: false,
|
||||
weekNumbers: true,
|
||||
weekNumberContent: ({ date }) => ({ domNodes: [node("span", "opportunities-calendar-fiscal-week", fiscalWeekLabel(date))] }),
|
||||
dayCellDidMount: ({ el, date }) => {
|
||||
const badge = node("span", "opportunities-calendar-day-count");
|
||||
const key = dateKey(date);
|
||||
updateDayBadge(badge, key);
|
||||
el.querySelector(".fc-daygrid-day-frame").append(badge);
|
||||
dayBadges.set(el, { badge, key });
|
||||
const cards = node("div", "opportunities-calendar-day-cards");
|
||||
cards.tabIndex = 0;
|
||||
cards.setAttribute("role", "region");
|
||||
cards.setAttribute("aria-label", `Opportunities for ${formatDate(key)}`);
|
||||
renderDayCards(cards, key);
|
||||
el.querySelector(".fc-daygrid-day-frame").append(badge, cards);
|
||||
dayBadges.set(el, { badge, key, cards });
|
||||
},
|
||||
dayCellWillUnmount: ({ el }) => { dayBadges.get(el)?.badge.remove(); dayBadges.delete(el); },
|
||||
eventOrder: "title", eventDisplay: "block", events,
|
||||
dayCellWillUnmount: ({ el }) => { dayBadges.get(el)?.badge.remove(); dayBadges.get(el)?.cards.remove(); dayBadges.delete(el); },
|
||||
// MultiMonth imposes its own event limit. Render all cards in our scrollable day region.
|
||||
eventOrder: "title", eventDisplay: "none", events,
|
||||
eventContent: ({ event }) => {
|
||||
const item = event.extendedProps.item;
|
||||
const card = node("div", "opportunities-calendar-event-content");
|
||||
@@ -274,7 +322,15 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
}
|
||||
const signature = JSON.stringify(events);
|
||||
calendar.batchRendering(() => {
|
||||
if (anchoredVersion !== getVersion()) { calendar.gotoDate(anchor); anchoredVersion = getVersion(); closeDetails(false); }
|
||||
if (anchoredVersion !== getVersion()) {
|
||||
positionToday = true;
|
||||
calendar.setOption("views", { referenceMonths: { type: "multiMonth", visibleRange, dayMaxEvents: false, dayMaxEventRows: false } });
|
||||
calendar.setOption("visibleRange", visibleRange);
|
||||
calendar.setOption("validRange", visibleRange);
|
||||
calendar.gotoDate(anchor); anchoredVersion = getVersion(); closeDetails(false);
|
||||
const scroller = calendarHost.querySelector?.(".fc-multimonth");
|
||||
if (scroller) scroller.scrollTop = 0;
|
||||
}
|
||||
if (signature !== lastEvents) { calendar.removeAllEvents(); calendar.addEventSource(events); lastEvents = signature; closeDetails(false); }
|
||||
});
|
||||
const missing = items.filter(item => !closeDate(item.EffectiveDate));
|
||||
@@ -288,5 +344,5 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
}
|
||||
scheduleLayout();
|
||||
}
|
||||
return { mount, render, selectView, closeDetails, closeDate, eventsFor, initialDate, fiscalWeekLabel, visibleEventLimit };
|
||||
return { mount, render, selectView, closeDetails, closeDate, eventsFor, initialDate, fiscalWeekLabel };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ function createOpportunitiesColumns({ formatOracleResponseDate, formatRevenue, f
|
||||
{ key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue },
|
||||
{ key: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) },
|
||||
{ key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode },
|
||||
{ key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
|
||||
{ key: "winProbability", label: "Prob.", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
|
||||
{ key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c },
|
||||
{ key: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }
|
||||
];
|
||||
|
||||
@@ -23,6 +23,7 @@ function createOpportunitiesController({
|
||||
}) {
|
||||
async function requestOpportunitiesForPeriod(period) {
|
||||
const dateRange = getFiscalPeriodRange(period, new Date());
|
||||
opportunitiesQueryState.dateRange = dateRange;
|
||||
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
|
||||
resetWorkloadActions();
|
||||
resetWorkloads();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
function createOpportunitiesFilterState({ STAGE_OPTIONS, STATUS_OPTIONS } = {}) {
|
||||
return {
|
||||
selectedProductTypes: new Set(["Booking", "Workload"]),
|
||||
selectedStages: new Set(STAGE_OPTIONS),
|
||||
selectedStatuses: new Set(STATUS_OPTIONS),
|
||||
selectedCustomerKeys: new Set(),
|
||||
|
||||
@@ -25,6 +25,7 @@ function createOpportunitiesFilters({
|
||||
saveFilterPreferences,
|
||||
stringifySearchValue,
|
||||
matchesActivities,
|
||||
matchesProductType = () => true,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
@@ -345,7 +346,8 @@ function createOpportunitiesFilters({
|
||||
&& (allCustomers || opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)))
|
||||
&& (allOwners || opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)))
|
||||
&& matchesTableSearch(item, normalizedSearch)
|
||||
&& matchesActivities(item);
|
||||
&& matchesActivities(item)
|
||||
&& matchesProductType(item);
|
||||
});
|
||||
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
function createOpportunitiesQueryState() {
|
||||
return {
|
||||
periodRequestVersion: 0
|
||||
periodRequestVersion: 0,
|
||||
dateRange: null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, resetRelatedData,
|
||||
opportunitiesQueryState, productsContentState, consumptionPlansState, consumptionTrendState,
|
||||
aiSummaryState, reload, onFailure, notify, createRefreshIcon, environment }) {
|
||||
reload, onFailure, notify, createRefreshIcon, environment }) {
|
||||
let pending = null;
|
||||
function refresh(period) {
|
||||
if (pending) return pending;
|
||||
@@ -13,7 +13,6 @@ function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, res
|
||||
consumptionTrendState.consumptionTrendCache = new Map();
|
||||
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
||||
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
|
||||
aiSummaryState.aiSummariesByOptyNumber = new Map();
|
||||
resetRelatedData();
|
||||
pending = (async () => {
|
||||
await activityCache.invalidate();
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, sendRequest, reportProgress, appendLog, onReady = async () => {} }) {
|
||||
let pending = null;
|
||||
let failed = false;
|
||||
function ensure() {
|
||||
if (pending) return pending;
|
||||
pending = (async () => {
|
||||
failed = false;
|
||||
try {
|
||||
await ensureCurrentUser();
|
||||
if (!allowsCrmActivities()) return;
|
||||
reportProgress("running");
|
||||
const result = await sendRequest();
|
||||
// Navigation establishes SR cookies independently of the SPA identity.
|
||||
// Consume user-dependent data only once both preparations have finished.
|
||||
const [, result] = await Promise.all([ensureCurrentUser(), sendRequest()]);
|
||||
appendLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Session initialization failed.");
|
||||
if (!allowsCrmActivities()) { reportProgress("idle"); return; }
|
||||
reportProgress("complete");
|
||||
await onReady();
|
||||
} catch {
|
||||
failed = true;
|
||||
reportProgress("error");
|
||||
}
|
||||
})();
|
||||
return pending;
|
||||
}
|
||||
return { ensure };
|
||||
function retry() {
|
||||
if (failed) pending = null;
|
||||
return ensure();
|
||||
}
|
||||
return { ensure, retry };
|
||||
}
|
||||
|
||||
@@ -625,3 +625,9 @@
|
||||
}
|
||||
.opportunities-extension-option-description { display: block; font-size: 12px; line-height: 1.4; margin-top: 4px; white-space: normal; overflow-wrap: anywhere; opacity: .85; }
|
||||
.opportunities-extension-action-form-dialog.opportunities-extension-internal-sr-dialog { width: min(760px, 100%); }
|
||||
.opportunities-extension-session-retry {
|
||||
margin-left: 8px; padding: 2px 6px; border: 1px solid currentColor;
|
||||
border-radius: 4px; background: transparent; color: inherit; font: inherit; cursor: pointer;
|
||||
}
|
||||
.opportunities-extension-session-retry:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
.opportunities-extension-session-retry:disabled { opacity: .6; cursor: wait; }
|
||||
|
||||
@@ -171,16 +171,16 @@ function createOpportunitiesTable({
|
||||
const productsResult = item.OptyNumber && productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
|
||||
if (productsResult) {
|
||||
productActions.append(createProductsCountButton(item.OptyNumber, productsResult));
|
||||
const rampBadge = createProductsRampBadge(productsResult);
|
||||
const rampBadge = createProductsRampBadge(productsResult, item);
|
||||
if (rampBadge) productActions.append(rampBadge);
|
||||
}
|
||||
const projectionIndicator = createProjectionIndicator(productsResult, item,
|
||||
productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber)));
|
||||
if (projectionIndicator) productActions.append(projectionIndicator);
|
||||
const srBadge = createCrmServiceRequestBadge(item.OptyNumber);
|
||||
if (srBadge) productActions.append(srBadge);
|
||||
const actionsBadge = createWorkloadActionsBadge(item.OptyNumber);
|
||||
if (actionsBadge) productActions.append(actionsBadge);
|
||||
const projectionIndicator = createProjectionIndicator(productsResult, item,
|
||||
productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber)));
|
||||
if (projectionIndicator) productActions.append(projectionIndicator);
|
||||
nameCellContent.append(opportunityLink, productActions);
|
||||
cell.append(nameCellContent);
|
||||
} else if (column.key === "optyNumber" && item.OptyNumber) {
|
||||
|
||||
Reference in New Issue
Block a user