Implemented the calendar view for opportunities

This commit is contained in:
2026-10-05 18:35:28 -03:00
parent 7a7143b6e7
commit c9de750261
19 changed files with 1490 additions and 19 deletions

View File

@@ -0,0 +1,105 @@
.opportunities-extension-views { min-height: 0; min-width: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 8px; }
.opportunities-extension-views > [hidden] { display: none; }
.opportunities-extension-view-tabs { display: flex; gap: 20px; border-bottom: 1px solid #dedbd7; }
.opportunities-extension-view-tabs button { border: 0; border-bottom: 3px solid transparent; background: transparent; color: #625d58; padding: 6px 2px 9px; font: 600 13px/20px Arial, sans-serif; cursor: pointer; }
.opportunities-extension-view-tabs button[aria-selected="true"] { border-bottom-color: #00758f; color: #00657b; }
.opportunities-extension-view-tabs button:focus-visible { outline: 2px solid #00758f; outline-offset: -2px; }
.opportunities-extension-view-tabs button { display: inline-flex; align-items: center; gap: 6px; }
.opportunities-extension-view-tabs button svg { width: 16px; height: 16px; flex: 0 0 16px; }
.opportunities-extension-calendar {
--fc-border-color: #dedbd7; --fc-page-bg-color: #fff; --fc-neutral-bg-color: #f5f4f2;
--fc-neutral-text-color: #625d58; --fc-today-bg-color: #eef7fa;
--fc-button-bg-color: #fff; --fc-button-text-color: #312d2a; --fc-button-border-color: #b9b4ae;
--fc-button-hover-bg-color: #e6f3f5; --fc-button-hover-border-color: #00758f;
--fc-button-active-bg-color: #dceff3; --fc-button-active-border-color: #00758f;
min-height: 0; min-width: 0; overflow: hidden; display: flex; flex-direction: column; padding: 12px; background: #fff; color: #312d2a;
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-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; }
.opportunities-extension-calendar .fc .fc-button { font-size: 13px; line-height: 20px; padding: 5px 10px; border-radius: 4px; box-shadow: none; }
.opportunities-extension-calendar .fc .fc-button:focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
.opportunities-extension-calendar .fc .fc-button svg { width: 16px; height: 16px; margin-right: 6px; vertical-align: -3px; }
.opportunities-extension-calendar .fc a { color: inherit; text-decoration: none; }
.opportunities-extension-calendar .fc th { background: var(--fc-neutral-bg-color); font-size: 12px; font-weight: 600; padding: 4px 0; }
.opportunities-extension-calendar .fc .fc-daygrid-day-number { font-size: 11px; line-height: 18px; padding: 1px 6px; }
.opportunities-extension-calendar .fc .fc-daygrid-day-events { min-height: 0; margin-bottom: 0; }
.opportunities-extension-calendar .fc .fc-daygrid-day-bottom { margin: 0; }
.opportunities-extension-calendar .fc .fc-daygrid-day-frame { min-height: 0; padding-top: 0; }
/* Event content must not contribute to the table's intrinsic row heights. */
.opportunities-extension-calendar .fc .fc-daygrid-body tbody { height: 100%; }
.opportunities-extension-calendar .fc .fc-daygrid-body tr { height: calc(100% / var(--opportunities-calendar-weeks, 5)); }
.opportunities-extension-calendar .fc .fc-daygrid-day { position: relative; }
.opportunities-extension-calendar .fc .fc-daygrid-day-frame { position: absolute; inset: 0; }
.opportunities-extension-calendar .fc .fc-daygrid-week-number { top: 0; left: 0; padding: 3px 6px; background: var(--fc-neutral-bg-color); color: inherit; font: 600 10px/16px Arial, sans-serif; white-space: nowrap; }
.opportunities-calendar-day-count { position: absolute; top: 2px; left: 6px; min-width: 18px; height: 16px; padding: 0 4px; border-radius: 8px; background: #dceff3; color: #005467; font: 600 10px/16px Arial, sans-serif; text-align: center; }
.fc-daygrid-week-number ~ .opportunities-calendar-day-count { left: 82px; }
.opportunities-calendar-day-count[hidden] { display: none; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-calendar-day-count:not([data-empty="true"]) { background: #16454f; color: #b9eaf4; }
.opportunities-extension-calendar .fc .fc-day-other { background: var(--fc-neutral-bg-color); }
.opportunities-extension-calendar .fc .fc-daygrid-event { white-space: normal; margin: 3px 4px; border-radius: 4px; cursor: pointer; border-width: 0 0 0 3px; border-style: solid; }
.opportunities-extension-calendar .fc .fc-event-main { color: inherit; }
.opportunities-calendar-event-content { min-width: 0; padding: 5px 6px; overflow-wrap: anywhere; }
.opportunities-calendar-event-content strong { display: block; font-size: 12px; line-height: 16px; font-weight: 600; }
.opportunities-calendar-event-content span { display: block; margin-top: 3px; font-size: 11px; line-height: 15px; }
.opportunities-calendar-event-content strong,
.opportunities-calendar-event-content span,
.opportunities-calendar-day-item strong,
.opportunities-calendar-day-item span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.opportunities-extension-calendar .fc .fc-daygrid-more-link { color: #00657b; font-size: 11px; line-height: 16px; padding: 0 5px; }
.opportunities-calendar-day-list { min-height: 0; overflow-y: auto; padding: 8px 20px 20px; }
.opportunities-calendar-day-item { display: block; width: 100%; padding: 12px 0; border: 0; border-bottom: 1px solid #dedbd7; background: transparent; color: #00657b; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
.opportunities-calendar-day-item strong, .opportunities-calendar-day-item span { display: block; font: 600 13px/20px Arial, sans-serif; }
.opportunities-calendar-day-item span { margin-top: 4px; font-weight: 400; }
.opportunities-calendar-day-item:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-calendar-day-item { border-bottom-color: #514d48; color: #80d6e8; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-SQL { background: #dff1f4; border-color: #00829d; color: #005467; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-PIPELINE,
.opportunities-extension-calendar .fc .opportunities-calendar-stage-OTHER { background: #efeeec; border-color: #706a64; color: #49443f; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-UPSIDE { background: #fff0da; border-color: #ba7000; color: #774700; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-FORECAST { background: #f0e8f7; border-color: #8262a3; color: #624379; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-WON { background: #e2f1e5; border-color: #2f8050; color: #215939; }
.opportunities-calendar-message { margin: 0 0 12px; font-size: 13px; }
.opportunities-calendar-message[hidden], .opportunities-calendar-grid[hidden], .opportunities-calendar-undated[hidden] { display: none; }
.opportunities-calendar-undated { flex: 0 0 auto; max-height: 90px; overflow-y: auto; margin-top: 6px; border-top: 1px solid var(--fc-border-color); padding-top: 4px; }
.opportunities-calendar-undated summary { cursor: pointer; font-size: 12px; line-height: 18px; }
.opportunities-calendar-undated h3 { font-size: 13px; margin: 0 0 8px; }
.opportunities-calendar-undated button { display: block; background: none; border: 0; text-align: left; font: inherit; padding: 6px 0; color: inherit; text-decoration: underline; cursor: pointer; overflow-wrap: anywhere; }
.opportunities-calendar-detail .opportunities-extension-account-plans-header { align-items: start; gap: 16px; }
.opportunities-calendar-detail .opportunities-extension-action-form-dialog { display: grid; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; }
.opportunities-calendar-detail h2 { font-size: 18px; overflow-wrap: anywhere; }
.opportunities-calendar-detail-content { padding: 20px; overflow-y: auto; }
.opportunities-calendar-detail-content a { color: #006b82; }
.opportunities-calendar-detail-badges { display: flex; gap: 8px; margin-top: 18px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-view-tabs { border-color: #514d48; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-view-tabs button { color: #c7c2bc; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-view-tabs button[aria-selected="true"] { color: #80d6e8; border-bottom-color: #80d6e8; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar {
--fc-border-color: #514d48; --fc-page-bg-color: #292725; --fc-neutral-bg-color: #33302d;
--fc-neutral-text-color: #c7c2bc; --fc-today-bg-color: #223b3f;
--fc-button-bg-color: #292725; --fc-button-text-color: #eeeae6; --fc-button-border-color: #756f68;
--fc-button-hover-bg-color: #234850; --fc-button-hover-border-color: #80d6e8;
--fc-button-active-bg-color: #234850; --fc-button-active-border-color: #80d6e8;
background: #292725; color: #eeeae6;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-SQL { background: #16454f; color: #b9eaf4; border-color: #67cde0; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-PIPELINE,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-OTHER { background: #403c38; color: #e4dfd9; border-color: #b8b1a8; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-UPSIDE { background: #533e1e; color: #ffe0a9; border-color: #dba44f; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-FORECAST { background: #493657; color: #ead5fc; border-color: #b998d8; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-WON { background: #244a32; color: #c7edce; border-color: #84c396; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .fc-daygrid-more-link,
.opportunities-extension-modal[data-theme="dark"] .opportunities-calendar-detail-content a { color: #80d6e8; }
@media (max-width: 700px) {
.opportunities-extension-body { overflow-y: auto; grid-template-rows: max-content max-content minmax(480px, 1fr); }
.opportunities-extension-views { min-height: 480px; }
.opportunities-extension-calendar { padding: 10px; overflow-x: auto; }
.opportunities-extension-calendar .fc { min-width: 0; }
.opportunities-extension-calendar .fc .fc-view-harness { min-width: 560px; }
.opportunities-extension-calendar .fc .fc-toolbar-title { font-size: 16px; }
.opportunities-extension-calendar .fc .fc-daygrid-day-frame { padding-top: 22px; }
.opportunities-calendar-day-count { top: 25px; }
.fc-daygrid-week-number ~ .opportunities-calendar-day-count { left: 6px; }
}

View File

@@ -0,0 +1,292 @@
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 };
}

View File

@@ -25,6 +25,7 @@ function createOpportunitiesTable({
renderOwnerFilter,
renderActivityFilters,
renderStageDashboard,
renderCalendar,
resetCustomerFilter,
resetOwnerFilter,
environment
@@ -102,6 +103,7 @@ function createOpportunitiesTable({
const tableBody = document.createElement("tbody");
const items = getSortedOpportunities();
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)) {
const row = document.createElement("tr");