293 lines
17 KiB
JavaScript
293 lines
17 KiB
JavaScript
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion,
|
|
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
|
|
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon }) {
|
|
const { document } = environment;
|
|
let elements = null;
|
|
let calendar = null;
|
|
let library = null;
|
|
let selectedView = "overview";
|
|
let anchoredVersion = null;
|
|
let details = null;
|
|
let lastEvents = "";
|
|
let dayCounts = 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)));
|
|
}
|
|
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); }
|
|
});
|
|
}
|
|
function dateKey(date) {
|
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
}
|
|
function fiscalWeekLabel(date) {
|
|
// FullCalendar's week hook supplies a UTC date marker, not a local instant.
|
|
const start = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
start.setUTCDate(start.getUTCDate() - start.getUTCDay());
|
|
const end = new Date(start.getTime() + 6 * 86400000);
|
|
const year = end.getUTCMonth() >= 5 ? end.getUTCFullYear() : end.getUTCFullYear() - 1;
|
|
const fiscalStart = new Date(Date.UTC(year, 5, 1));
|
|
const firstSunday = fiscalStart.getTime() - fiscalStart.getUTCDay() * 86400000;
|
|
const representative = start < fiscalStart ? fiscalStart : start;
|
|
const quarter = Math.floor(((representative.getUTCMonth() + 7) % 12) / 3) + 1;
|
|
const week = Math.round((start.getTime() - firstSunday) / (7 * 86400000)) + 1;
|
|
return `FY${String(year + 1).slice(-2)}-Q${quarter}-W${week}`;
|
|
}
|
|
function updateDayBadge(badge, key) {
|
|
const count = dayCounts.get(key) || 0;
|
|
badge.hidden = count === 0;
|
|
badge.textContent = String(count);
|
|
badge.title = `${count} oportunidade${count === 1 ? "" : "s"}`;
|
|
badge.setAttribute("aria-label", badge.title);
|
|
badge.setAttribute("data-empty", String(count === 0));
|
|
}
|
|
function node(tag, className, text) {
|
|
const el = document.createElement(tag);
|
|
el.className = className;
|
|
if (text !== undefined) el.textContent = text;
|
|
return el;
|
|
}
|
|
function closeDate(value) {
|
|
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}(?:T|$)/.test(value)) return null;
|
|
const day = value.slice(0, 10);
|
|
const date = new Date(`${day}T00:00:00Z`);
|
|
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === day ? day : null;
|
|
}
|
|
function eventsFor(items) {
|
|
return items.flatMap((item, index) => {
|
|
const start = closeDate(item.EffectiveDate);
|
|
if (!start) return [];
|
|
return [{ id: String(item.OptyNumber || item.OptyId || index), title: String(item.Name || item.OptyNumber || "-"),
|
|
start, allDay: true, extendedProps: { item }, classNames: [`opportunities-calendar-stage-${stage(item)}`] }];
|
|
});
|
|
}
|
|
function stage(item) { return ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"].includes(item.ForecastGroup_c) ? item.ForecastGroup_c : "OTHER"; }
|
|
function initialDate(items, now = new Date()) {
|
|
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
const dates = items.map(item => closeDate(item.EffectiveDate)).filter(Boolean).sort();
|
|
return dates.find(date => date >= today) || dates.at(-1) || today;
|
|
}
|
|
function closeDetails(restoreFocus = true) {
|
|
if (!details) return false;
|
|
const previous = details;
|
|
details = null;
|
|
previous.overlay.remove();
|
|
if (restoreFocus) (previous.trigger?.isConnected ? previous.trigger : elements?.tabs.calendar)?.focus();
|
|
return true;
|
|
}
|
|
function showDialog(heading, content, contentControls, trigger) {
|
|
closeDetails(false);
|
|
const host = document.getElementById(modalId);
|
|
if (!host || host.hidden) return;
|
|
const overlay = node("section", "opportunities-extension-action-form-modal opportunities-calendar-detail");
|
|
overlay.setAttribute("role", "dialog"); overlay.setAttribute("aria-modal", "true");
|
|
overlay.setAttribute("aria-labelledby", "opportunities-calendar-detail-title");
|
|
const panel = node("div", "opportunities-extension-action-form-dialog");
|
|
const header = node("header", "opportunities-extension-account-plans-header");
|
|
const title = node("h2", "", heading);
|
|
title.id = "opportunities-calendar-detail-title";
|
|
const dismiss = node("button", "opportunities-extension-icon-button");
|
|
dismiss.type = "button"; dismiss.title = "Close"; dismiss.setAttribute("aria-label", "Close opportunity details");
|
|
dismiss.append(createCloseIcon()); dismiss.addEventListener("click", () => closeDetails());
|
|
header.append(title, dismiss);
|
|
panel.append(header, content); overlay.append(panel); host.append(overlay);
|
|
details = { overlay, trigger };
|
|
overlay.addEventListener("click", event => { if (event.target === overlay) closeDetails(); });
|
|
overlay.addEventListener("keydown", event => {
|
|
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeDetails(); }
|
|
if (event.key === "Tab") {
|
|
const controls = [dismiss, ...contentControls];
|
|
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();
|
|
}
|
|
}
|
|
});
|
|
dismiss.focus();
|
|
}
|
|
function showDetails(item, trigger) {
|
|
const content = node("div", "opportunities-calendar-detail-content");
|
|
const link = createDetailLink(item.OptyNumber || "-", item.OptyNumber ? opportunityUrl + encodeURIComponent(item.OptyNumber) : "");
|
|
const identity = node("div", "opportunities-extension-opty-number-content");
|
|
identity.append(link);
|
|
const copy = item.OptyNumber ? createCopyButton(item.OptyNumber) : null;
|
|
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)]]) {
|
|
summary.append(node("dt", "", label), node("dd", "", String(value || "-")));
|
|
}
|
|
const badges = node("div", "opportunities-calendar-detail-badges");
|
|
badges.append(createStatusBadge(item.StatusCode), createStageBadge(item.ForecastGroup_c));
|
|
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/D"} | ${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);
|
|
// 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;
|
|
calendar?.destroy(); calendar = null; lastEvents = ""; anchoredVersion = null; selectedView = "overview";
|
|
dayBadges.clear(); dayCounts.clear();
|
|
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");
|
|
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]]) {
|
|
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);
|
|
tab.setAttribute("role", "tab"); tab.setAttribute("aria-controls", panel.id);
|
|
tab.addEventListener("click", () => selectView(key));
|
|
tab.addEventListener("keydown", event => {
|
|
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
|
event.preventDefault();
|
|
const next = event.key === "Home" ? "overview" : event.key === "End" ? "calendar" : key === "overview" ? "calendar" : "overview";
|
|
selectView(next); tabs[next].focus();
|
|
});
|
|
tabs[key] = tab; tablist.append(tab);
|
|
}
|
|
workspace.append(tablist, tableSurface, surface); body.append(workspace);
|
|
elements = { workspace, tableSurface, surface, message, calendarHost, undated, tabs };
|
|
if (environment.window?.ResizeObserver) {
|
|
resizeObserver = new environment.window.ResizeObserver(scheduleLayout);
|
|
resizeObserver.observe(calendarHost);
|
|
}
|
|
selectView("overview");
|
|
}
|
|
function selectView(view) {
|
|
if (!elements || !["overview", "calendar"].includes(view)) return;
|
|
closeDetails(false); closeSrPopover(); selectedView = view;
|
|
elements.tableSurface.hidden = view !== "overview"; elements.surface.hidden = view !== "calendar";
|
|
for (const [key, tab] of Object.entries(elements.tabs)) {
|
|
tab.setAttribute("aria-selected", String(view === key)); tab.tabIndex = view === key ? 0 : -1;
|
|
}
|
|
render(getItems(), getState());
|
|
if (view === "calendar") calendar?.updateSize();
|
|
scheduleLayout();
|
|
}
|
|
function render(items, state) {
|
|
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)) {
|
|
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);
|
|
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);
|
|
if (!calendar) {
|
|
try {
|
|
library ||= loadLibrary();
|
|
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,
|
|
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" },
|
|
dayMaxEvents: 2, moreLinkText: count => `... +${count}`, displayEventTime: false,
|
|
moreLinkHint: count => `Ver mais ${count} oportunidades`,
|
|
moreLinkClick: showDayOpportunities,
|
|
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 });
|
|
},
|
|
dayCellWillUnmount: ({ el }) => { dayBadges.get(el)?.badge.remove(); dayBadges.delete(el); },
|
|
eventOrder: "title", eventDisplay: "block", events,
|
|
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)}`);
|
|
summary.title = summary.textContent;
|
|
card.append(node("strong", "", event.title), summary);
|
|
return { domNodes: [card] };
|
|
},
|
|
eventDidMount: ({ el, event }) => {
|
|
el.title = `${event.title}\n${event.extendedProps.item.CustomerAccount?.PartyUniqueName || ""}\n${formatDate(event.startStr)} | ${event.extendedProps.item.ForecastGroup_c || "-"}`;
|
|
el.setAttribute("role", "button"); el.setAttribute("aria-label", el.title);
|
|
},
|
|
eventClick: ({ event, el, jsEvent }) => { jsEvent.preventDefault(); showDetails(event.extendedProps.item, el); }
|
|
});
|
|
calendar.render(); lastEvents = JSON.stringify(events); anchoredVersion = getVersion();
|
|
} catch {
|
|
calendar?.destroy(); calendar = null; message.textContent = "Unable to display the calendar. Use the overview or try again.";
|
|
message.hidden = false; calendarHost.hidden = true; undated.hidden = true; return;
|
|
}
|
|
}
|
|
const signature = JSON.stringify(events);
|
|
calendar.batchRendering(() => {
|
|
if (anchoredVersion !== getVersion()) { calendar.gotoDate(anchor); anchoredVersion = getVersion(); closeDetails(false); }
|
|
if (signature !== lastEvents) { calendar.removeAllEvents(); calendar.addEventSource(events); lastEvents = signature; closeDetails(false); }
|
|
});
|
|
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})`));
|
|
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);
|
|
}
|
|
}
|
|
scheduleLayout();
|
|
}
|
|
return { mount, render, selectView, closeDetails, closeDate, eventsFor, initialDate, fiscalWeekLabel, visibleEventLimit };
|
|
}
|