Implemented the calendar view for opportunities
This commit is contained in:
21
README.md
21
README.md
@@ -1,6 +1,25 @@
|
||||
# Opportunities Extension 1.0.2
|
||||
|
||||
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table.
|
||||
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table or calendar.
|
||||
|
||||
## Opportunity Views
|
||||
|
||||
**Visao geral** is the default table view. **Calendario** displays opportunities
|
||||
by Close Date (`EffectiveDate`) with month and week views only. Both views share
|
||||
Reference, search and all existing filters; switching views does not send requests.
|
||||
Events use date-only values without time-zone shifts, stage colors, and a read-only
|
||||
detail with an opportunity link and copy button. Invalid or missing dates appear
|
||||
in a separate list. Calendar navigation does not change the selected Reference.
|
||||
Each day shows a filtered opportunity count, at most two events and an overflow
|
||||
link to the remaining events. The grid fits the available height and reduces
|
||||
visible events to one or zero when needed to keep every week visible. Missing
|
||||
close dates are available in a collapsible list below the calendar.
|
||||
Fiscal labels use `FY27-Q2-W19`: June-May fiscal
|
||||
years, Sunday-Saturday weeks, and W1 for the week containing June 1. A quarter is
|
||||
assigned by the week's Sunday, except the first fiscal week which belongs to Q1.
|
||||
|
||||
FullCalendar Standard 6.1.19 is bundled locally under `src/vendor/fullcalendar/`
|
||||
with its MIT license. It is initialized only on first use, without a runtime CDN.
|
||||
|
||||
## Development Structure
|
||||
|
||||
|
||||
377
dist/chromium/content.js
vendored
377
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
377
dist/firefox/content.js
vendored
377
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -2,6 +2,7 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const sources = require("./sources.cjs");
|
||||
const vendorSources = { content: ["src/vendor/fullcalendar/index.global.min.js"], background: [] };
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
|
||||
@@ -16,7 +17,10 @@ function composeScript(target, { bootstrap = true } = {}) {
|
||||
return `// Source: ${file}\n${source}`;
|
||||
});
|
||||
if (target === "content") {
|
||||
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css"]
|
||||
const calendarLibrary = fs.readFileSync(path.join(root, vendorSources.content[0]), "utf8");
|
||||
const calendarLicense = fs.readFileSync(path.join(root, "src/vendor/fullcalendar/LICENSE.md"), "utf8");
|
||||
parts.unshift(`function loadOpportunityCalendarLibrary() {\n/* ${calendarLicense} */\n${calendarLibrary}\nreturn FullCalendar;\n}`);
|
||||
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"]
|
||||
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
||||
}
|
||||
@@ -27,4 +31,4 @@ function composeScript(target, { bootstrap = true } = {}) {
|
||||
return output;
|
||||
}
|
||||
|
||||
module.exports = { composeScript, sources };
|
||||
module.exports = { composeScript, sources, vendorSources };
|
||||
|
||||
@@ -48,6 +48,7 @@ module.exports = {
|
||||
"src/features/opportunities/workload-action-options.js",
|
||||
"src/features/opportunities/fiscal-periods.js",
|
||||
"src/features/opportunities/table.js",
|
||||
"src/features/opportunities/calendar.js",
|
||||
"src/features/opportunities/dashboard.js",
|
||||
"src/features/opportunities/filters.js",
|
||||
"src/features/opportunities/formatters.js",
|
||||
|
||||
@@ -57,7 +57,8 @@ function createContentApplication(overrides = {}) {
|
||||
});
|
||||
const contentModal = createContentModal({
|
||||
prepareSrSession: () => srSessionPreparation.ensure(),
|
||||
closeActionForm: (...args) => crmSrForm.close(...args) || workloadActionForm.close(...args),
|
||||
closeActionForm: (...args) => opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
|
||||
mountOpportunityViews: (...args) => opportunityCalendar.mount(...args),
|
||||
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
|
||||
closeCrmSrPopover: () => crmSrPopover.close(),
|
||||
ensureCurrentUser: () => currentUser.ensure(),
|
||||
@@ -271,7 +272,25 @@ function createContentApplication(overrides = {}) {
|
||||
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
|
||||
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
|
||||
});
|
||||
const opportunityCalendar = createOpportunityCalendar({
|
||||
createViewIcon: view => contentIcons.createViewIcon(view),
|
||||
environment, loadLibrary: () => loadOpportunityCalendarLibrary(),
|
||||
getState: () => opportunitiesTableState.opportunitiesTableState,
|
||||
getItems: () => opportunitiesFilters.getSortedOpportunities(),
|
||||
getVersion: () => opportunitiesQueryState.periodRequestVersion,
|
||||
formatRevenue: item => opportunitiesFormatters.formatRevenue(item),
|
||||
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||
getOwnerName: item => opportunitiesFormatters.getOwnerName(item),
|
||||
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
||||
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||
createCloseIcon: () => contentIcons.createCloseIcon(),
|
||||
createStageBadge: value => opportunitiesLinks.createStageBadge(value),
|
||||
createStatusBadge: value => opportunitiesLinks.createStatusBadge(value),
|
||||
opportunityUrl: contracts.OPPORTUNITY_DETAIL_URL, modalId: contracts.MODAL_ID,
|
||||
closeSrPopover: () => crmSrPopover.close()
|
||||
});
|
||||
const opportunitiesTable = createOpportunitiesTable({
|
||||
renderCalendar: (...args) => opportunityCalendar.render(...args),
|
||||
renderActivityFilters: () => activityFilters.render(),
|
||||
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
|
||||
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
|
||||
@@ -638,6 +657,7 @@ function createContentApplication(overrides = {}) {
|
||||
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
|
||||
}
|
||||
return {
|
||||
opportunityCalendar,
|
||||
workloadActionForm,
|
||||
activityFilters,
|
||||
start, contracts,
|
||||
|
||||
@@ -85,5 +85,25 @@ function createContentIcons({
|
||||
svg.append(path);
|
||||
return svg;
|
||||
}
|
||||
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon };
|
||||
function createViewIcon(view) {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.setAttribute("focusable", "false");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
const calendarFrame = "M8 2v4m8-4v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z";
|
||||
path.setAttribute("d", view === "today" ? `${calendarFrame}M9 16l2 2 4-4`
|
||||
: view === "week" ? `${calendarFrame}M6 14h12v4H6Z M10 14v4m4-4v4`
|
||||
: view === "calendar"
|
||||
? "M8 2v4m8-4v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2ZM7 14h2m6 0h2m-10 4h2m6 0h2"
|
||||
: "M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1ZM3 9h18M9 9v12M3 15h18");
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "currentColor");
|
||||
path.setAttribute("stroke-width", "1.8");
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
path.setAttribute("stroke-linejoin", "round");
|
||||
svg.append(path);
|
||||
return svg;
|
||||
}
|
||||
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon, createViewIcon };
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ function createContentModal({
|
||||
createSettingsIcon,
|
||||
createRefreshAllButton,
|
||||
createActivityFilterControls,
|
||||
mountOpportunityViews,
|
||||
debugState,
|
||||
ensureExtensionStyles,
|
||||
getCustomerOptions,
|
||||
@@ -403,7 +404,7 @@ function createContentModal({
|
||||
opportunitiesTable.setAttribute("aria-label", "Opportunities");
|
||||
tableSurface.append(opportunitiesTable);
|
||||
|
||||
body.append(tableSurface);
|
||||
mountOpportunityViews(body, tableSurface);
|
||||
|
||||
const debugPanel = document.createElement("aside");
|
||||
debugPanel.id = DEBUG_PANEL_ID;
|
||||
|
||||
105
src/features/opportunities/calendar.css
Normal file
105
src/features/opportunities/calendar.css
Normal 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; }
|
||||
}
|
||||
292
src/features/opportunities/calendar.js
Normal file
292
src/features/opportunities/calendar.js
Normal 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 };
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
22
src/vendor/fullcalendar/LICENSE.md
vendored
Normal file
22
src/vendor/fullcalendar/LICENSE.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Adam Shaw
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
7
src/vendor/fullcalendar/NOTICE.md
vendored
Normal file
7
src/vendor/fullcalendar/NOTICE.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
FullCalendar Standard Bundle 6.1.19 (MIT)
|
||||
|
||||
Vendored from https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js
|
||||
License: LICENSE.md in this directory.
|
||||
|
||||
The build embeds this pinned bundle in a lazy local factory. No CDN or runtime
|
||||
code download is used by the extension. Only dayGridMonth and dayGridWeek are enabled.
|
||||
6
src/vendor/fullcalendar/index.global.min.js
vendored
Normal file
6
src/vendor/fullcalendar/index.global.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@ const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const { createHash } = require("node:crypto");
|
||||
const { test } = require("node:test");
|
||||
const { sources, composeScript } = require("../scripts/compose.cjs");
|
||||
const { sources, vendorSources, composeScript } = require("../scripts/compose.cjs");
|
||||
const { loadApplication } = require("./support/application.cjs");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
@@ -14,7 +14,13 @@ const json = value => JSON.parse(JSON.stringify(value));
|
||||
test("every source belongs to an explicit runtime composition, with no duplicate factories", () => {
|
||||
const actual = fs.readdirSync(path.join(root, "src"), { recursive: true })
|
||||
.filter(file => file.endsWith(".js")).map(file => `src/${file.replaceAll(path.sep, "/")}`).sort();
|
||||
assert.deepEqual([...new Set(Object.values(sources).flat())].sort(), actual);
|
||||
assert.deepEqual([...new Set([...Object.values(sources).flat(), ...Object.values(vendorSources).flat()])].sort(), actual);
|
||||
for (const files of Object.values(vendorSources)) {
|
||||
for (const file of files) {
|
||||
assert.doesNotThrow(() => new vm.Script(read(file)));
|
||||
assert.doesNotMatch(read(file), /\beval\s*\(|\bnew\s+Function\s*\(/, file);
|
||||
}
|
||||
}
|
||||
for (const [target, files] of Object.entries(sources)) {
|
||||
assert.equal(new Set(files).size, files.length);
|
||||
const factories = files.flatMap(file => [...read(file).matchAll(/^function (\w+)\(/gm)].map(match => match[1]));
|
||||
|
||||
@@ -269,7 +269,7 @@ test("full-page debug CSS defines responsive bounds and readable semantic colors
|
||||
|
||||
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
|
||||
const root = path.join(__dirname, "..");
|
||||
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css"]
|
||||
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"]
|
||||
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
|
||||
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));
|
||||
|
||||
3
tests/fixtures/extension.js
vendored
3
tests/fixtures/extension.js
vendored
@@ -7,7 +7,7 @@
|
||||
CustomerAccount: { PartyUniqueName: "Example public sector customer (Example City, BR)", PartyId: "test-party", PartyNumber: "test-account" },
|
||||
Owner: { PersonFirstName: "Example", PersonLastName: "Owner" },
|
||||
PrimaryRevenue: { RevnAmountCurcyCode: "USD", RevnAmount: "25000", WinProb: 60 },
|
||||
EffectiveDate: "2026-11-15T00:00:00Z",
|
||||
EffectiveDate: index === 24 ? null : `2026-11-${String(index < 8 ? 15 : 1 + index).padStart(2, "0")}T00:00:00Z`,
|
||||
LastUpdateDate: "2026-09-15T17:00:00Z",
|
||||
ForecastGroup_c: ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"][index % 5],
|
||||
StatusCode: ["OPEN", "CLOSED", "LOST", "WON", "WON_PENDING"][index % 5]
|
||||
@@ -28,6 +28,7 @@
|
||||
onMessage: { addListener: listener => listeners.push(listener) },
|
||||
async sendMessage(message) {
|
||||
const operation = message.type.split(".").at(-1);
|
||||
if (operation === "requestCurrentUser") return { ok: true, user: { id: "example@example.invalid", name: "Example Owner", roles: ["SALES_REP"] }, debugRequests: [] };
|
||||
if (operation === "getXsrfToken") return { ok: true, token: "synthetic-xsrf", cookieName: "XSRF-TOKEN-TEST" };
|
||||
if (operation === "requestConsumerTokenService") return { ok: true, authorizations, debugRequests: [] };
|
||||
if (operation === "requestOpptyProducts") {
|
||||
|
||||
227
tests/opportunityCalendar.test.cjs
Normal file
227
tests/opportunityCalendar.test.cjs
Normal file
@@ -0,0 +1,227 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const { test } = require("node:test");
|
||||
|
||||
const factory = new vm.Script(`${fs.readFileSync(path.join(__dirname, "../src/features/opportunities/calendar.js"), "utf8")}\ncreateOpportunityCalendar;`).runInNewContext();
|
||||
const plain = value => JSON.parse(JSON.stringify(value));
|
||||
const opportunity = (number, date) => ({ OptyNumber: number, Name: `<Opportunity ${number}>`, EffectiveDate: date,
|
||||
ForecastGroup_c: "FORECAST", StatusCode: "OPEN", CustomerAccount: { PartyUniqueName: "Customer" } });
|
||||
|
||||
function setup(items = [opportunity("A", "2026-11-15")]) {
|
||||
let document;
|
||||
class Element {
|
||||
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; }
|
||||
set textContent(value) { this.text = value; this.children = []; }
|
||||
get textContent() { return this.text + this.children.map(child => child.textContent).join(""); }
|
||||
append(...nodes) { for (const child of nodes) { child.parent = this; this.children.push(child); } }
|
||||
setAttribute(key, value) { this.attributes[key] = value; }
|
||||
addEventListener(key, fn) { this.listeners[key] = fn; }
|
||||
focus() { document.activeElement = this; }
|
||||
remove() { this.parent.children = this.parent.children.filter(child => child !== this); this.isConnected = false; }
|
||||
click() { this.listeners.click?.({ target: this, preventDefault() {}, stopPropagation() {} }); }
|
||||
}
|
||||
const host = new Element("host");
|
||||
document = { createElement: tag => new Element(tag), getElementById: () => host };
|
||||
const state = { status: "ready", items };
|
||||
const h = { state, filtered: items, version: 1, instances: [], loads: 0, host, document };
|
||||
class Calendar {
|
||||
constructor(element, options) { this.element = element; this.options = options; this.events = options.events; this.dates = []; h.instances.push(this); }
|
||||
render() { this.rendered = true; }
|
||||
destroy() { this.destroyed = true; }
|
||||
updateSize() { this.resized = true; }
|
||||
batchRendering(fn) { fn(); }
|
||||
gotoDate(date) { this.dates.push(date); }
|
||||
removeAllEvents() { this.events = []; }
|
||||
addEventSource(events) { this.events = events; }
|
||||
}
|
||||
h.api = factory({ environment: { document }, loadLibrary: () => { h.loads++; return { Calendar }; },
|
||||
getState: () => state, getItems: () => h.filtered, getVersion: () => h.version,
|
||||
formatRevenue: () => "$100", formatDate: value => value.slice(0, 10), getOwnerName: () => "Owner",
|
||||
createDetailLink: (text, href) => { const a = new Element("a"); a.textContent = text; a.href = href; return a; },
|
||||
createCopyButton: () => new Element("button"), createCloseIcon: () => new Element("svg"),
|
||||
createViewIcon: view => { const icon = new Element("svg"); icon.view = view; return icon; },
|
||||
createStageBadge: () => new Element("span"), createStatusBadge: () => new Element("span"),
|
||||
opportunityUrl: "https://example.invalid/?puid=", modalId: "modal", closeSrPopover() {} });
|
||||
h.body = new Element("body"); h.table = new Element("table-surface"); host.append(h.body);
|
||||
const walk = element => [element, ...element.children.flatMap(walk)];
|
||||
h.nodes = () => walk(host);
|
||||
h.byClass = name => h.nodes().find(element => element.className === name);
|
||||
h.mount = () => h.api.mount(h.body, h.table);
|
||||
h.activate = () => { h.mount(); h.api.selectView("calendar"); return h.instances[0]; };
|
||||
return h;
|
||||
}
|
||||
|
||||
test("close dates retain the API day across offsets and reject invalid dates", () => {
|
||||
const { api } = setup();
|
||||
for (const date of ["2026-11-15", "2026-11-15T00:00:00Z", "2026-11-15T23:00:00-05:00"]) assert.equal(api.closeDate(date), "2026-11-15");
|
||||
assert.equal(api.closeDate("2028-02-29"), "2028-02-29");
|
||||
for (const value of [null, undefined, "", "2026-02-29", "2026-13-01", "2026-04-31", "15/11/2026", 123]) assert.equal(api.closeDate(value), null);
|
||||
});
|
||||
|
||||
test("fiscal week labels use June-May years and Sunday weeks containing June 1", () => {
|
||||
const { api } = setup();
|
||||
for (const [day, expected] of [
|
||||
["2026-05-24", "FY26-Q4-W52"], ["2026-05-31", "FY27-Q1-W1"],
|
||||
["2026-06-01", "FY27-Q1-W1"], ["2026-06-07", "FY27-Q1-W2"],
|
||||
["2026-09-06", "FY27-Q2-W15"], ["2026-10-04", "FY27-Q2-W19"],
|
||||
["2026-11-15", "FY27-Q2-W25"], ["2026-12-06", "FY27-Q3-W28"],
|
||||
["2027-03-07", "FY27-Q4-W41"], ["2027-05-30", "FY28-Q1-W1"],
|
||||
["2028-02-27", "FY28-Q3-W40"], ["2028-06-04", "FY29-Q1-W2"]
|
||||
]) assert.equal(api.fiscalWeekLabel(new Date(`${day}T00:00:00Z`)), expected, day);
|
||||
});
|
||||
|
||||
test("daily count badges follow filters and are cleaned up when cells unmount", () => {
|
||||
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", "2026-11-15"), opportunity("C", "2026-11-15")]);
|
||||
const calendar = h.activate();
|
||||
const frame = h.document.createElement("div");
|
||||
const el = { querySelector: selector => { assert.equal(selector, ".fc-daygrid-day-frame"); return frame; } };
|
||||
calendar.options.dayCellDidMount({ el, date: new Date(2026, 10, 15) });
|
||||
const badge = frame.children[0];
|
||||
assert.equal(badge.textContent, "3");
|
||||
assert.equal(badge.hidden, false);
|
||||
assert.equal(badge.attributes["aria-label"], "3 oportunidades");
|
||||
h.api.render([h.state.items[0]], h.state);
|
||||
assert.equal(badge.textContent, "1");
|
||||
assert.equal(badge.attributes["aria-label"], "1 oportunidade");
|
||||
h.api.render([], h.state);
|
||||
assert.equal(badge.textContent, "0");
|
||||
assert.equal(badge.attributes["data-empty"], "true");
|
||||
assert.equal(badge.hidden, true);
|
||||
h.api.render(h.state.items, h.state);
|
||||
assert.equal(badge.hidden, false);
|
||||
assert.equal(badge.textContent, "3");
|
||||
calendar.options.dayCellWillUnmount({ el });
|
||||
assert.equal(frame.children.length, 0);
|
||||
});
|
||||
|
||||
test("calendar events use EffectiveDate, all-day semantics and literal untrusted names", () => {
|
||||
const { api } = setup();
|
||||
const events = plain(api.eventsFor([opportunity("A", "2026-11-15"), opportunity("B", null)]));
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].start, "2026-11-15");
|
||||
assert.equal(events[0].allDay, true);
|
||||
assert.equal(events[0].title, "<Opportunity A>");
|
||||
assert.deepEqual(events[0].classNames, ["opportunities-calendar-stage-FORECAST"]);
|
||||
});
|
||||
|
||||
test("initial calendar date selects the next close date, last past date or today", () => {
|
||||
const { api } = setup();
|
||||
const now = new Date(2026, 9, 5);
|
||||
assert.equal(api.initialDate([opportunity("A", "2026-11-15"), opportunity("B", "2026-10-12")], now), "2026-10-12");
|
||||
assert.equal(api.initialDate([opportunity("A", "2026-09-01"), opportunity("B", "2026-10-01")], now), "2026-10-01");
|
||||
assert.equal(api.initialDate([opportunity("A", null)], now), "2026-10-05");
|
||||
});
|
||||
|
||||
test("overview is the default; tabs lazily initialize and reuse a monthly/weekly calendar", () => {
|
||||
const h = setup(); h.mount();
|
||||
const surface = h.byClass("opportunities-extension-calendar");
|
||||
assert.equal(h.table.hidden, false); assert.equal(surface.hidden, true); assert.equal(h.loads, 0);
|
||||
const tabs = h.nodes().filter(element => element.attributes.role === "tab");
|
||||
assert.deepEqual(tabs.map(tab => tab.children[0].view), ["overview", "calendar"]);
|
||||
assert.deepEqual(tabs.map(tab => tab.children[1].textContent), ["Vis\u00e3o geral", "Calend\u00e1rio"]);
|
||||
assert.equal(tabs[0].attributes["aria-selected"], "true");
|
||||
tabs[1].click();
|
||||
const instance = h.instances[0];
|
||||
assert.equal(h.table.hidden, true); assert.equal(surface.hidden, false);
|
||||
assert.equal(instance.options.initialView, "dayGridMonth");
|
||||
assert.equal(instance.options.height, "100%");
|
||||
assert.equal(instance.options.expandRows, true);
|
||||
assert.equal(instance.options.headerToolbar.right, "dayGridMonth,dayGridWeek");
|
||||
assert.equal(instance.options.editable, false);
|
||||
assert.equal(instance.options.dayMaxEvents, 2);
|
||||
assert.equal(instance.options.moreLinkText(7), "... +7");
|
||||
assert.equal(instance.options.weekNumbers, true);
|
||||
tabs[0].click(); tabs[1].click();
|
||||
assert.equal(h.loads, 1); assert.equal(h.instances.length, 1); assert.equal(instance.resized, true);
|
||||
tabs[1].listeners.keydown({ key: "Home", preventDefault() {} });
|
||||
assert.equal(h.table.hidden, false); assert.equal(h.document.activeElement, tabs[0]);
|
||||
});
|
||||
|
||||
test("responsive event limit reserves room for every week and the overflow control", () => {
|
||||
const { api } = setup();
|
||||
assert.equal(api.visibleEventLimit(240, 6, 21), 0);
|
||||
assert.equal(api.visibleEventLimit(500, 5, 21), 1);
|
||||
assert.equal(api.visibleEventLimit(800, 5, 21), 2);
|
||||
assert.equal(api.visibleEventLimit(500, 1, 21), 2);
|
||||
assert.equal(api.visibleEventLimit(0, 6, 21), 0);
|
||||
});
|
||||
|
||||
test("calendar follows filtered data; only a new Reference request changes its date", () => {
|
||||
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", "2026-12-12")]);
|
||||
const calendar = h.activate();
|
||||
h.filtered = [h.state.items[1]]; h.api.render(h.filtered, h.state);
|
||||
assert.deepEqual(plain(calendar.events.map(event => event.id)), ["B"]);
|
||||
assert.equal(calendar.dates.length, 0);
|
||||
h.version++; h.state.items = [opportunity("C", "2027-02-20")]; h.filtered = h.state.items;
|
||||
h.api.render(h.filtered, h.state);
|
||||
assert.deepEqual(calendar.dates, ["2027-02-20"]);
|
||||
assert.deepEqual(plain(calendar.events.map(event => event.id)), ["C"]);
|
||||
h.filtered = []; h.api.render(h.filtered, h.state);
|
||||
assert.equal(calendar.events.length, 0);
|
||||
assert.equal(h.byClass("opportunities-calendar-message").textContent, "No opportunities found.");
|
||||
});
|
||||
|
||||
test("undated opportunities remain visible and open read-only details", () => {
|
||||
const h = setup([opportunity("A", null)]); const calendar = h.activate();
|
||||
assert.equal(calendar.events.length, 0);
|
||||
const undated = h.byClass("opportunities-calendar-undated");
|
||||
assert.equal(undated.hidden, false); undated.children[1].click();
|
||||
assert.ok(h.nodes().some(node => node.attributes.role === "dialog"));
|
||||
assert.deepEqual(h.nodes().filter(node => node.tag === "dt").map(node => node.textContent), ["Customer", "Owner", "Close Date", "Amount"]);
|
||||
assert.equal(h.nodes().find(node => node.tag === "a").href, "https://example.invalid/?puid=A");
|
||||
assert.ok(h.nodes().some(node => node.tag === "dd" && node.textContent === "N/D"));
|
||||
assert.equal(h.api.closeDetails(), true);
|
||||
assert.equal(h.document.activeElement, undated.children[1]);
|
||||
});
|
||||
|
||||
test("event details close on changed data, loading/error hide stale calendar content", () => {
|
||||
const h = setup(); const calendar = h.activate();
|
||||
calendar.options.eventClick({ event: { extendedProps: { item: h.state.items[0] } }, el: h.table, jsEvent: { preventDefault() {} } });
|
||||
assert.ok(h.nodes().some(node => node.attributes.role === "dialog"));
|
||||
h.api.render([], h.state);
|
||||
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
|
||||
for (const status of ["loading", "error", "empty"]) {
|
||||
h.api.render([], { status, items: [], message: "State message" });
|
||||
assert.equal(h.byClass("opportunities-calendar-grid").hidden, true);
|
||||
assert.equal(h.byClass("opportunities-calendar-undated").hidden, true);
|
||||
assert.equal(h.byClass("opportunities-calendar-message").textContent, "State message");
|
||||
}
|
||||
});
|
||||
|
||||
test("overflow uses our dialog instead of the FullCalendar offsetParent-dependent popover", () => {
|
||||
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", "2026-11-15"), opportunity("C", "2026-11-15")]);
|
||||
const calendar = h.activate();
|
||||
const trigger = h.document.createElement("a");
|
||||
const allSegs = calendar.events.map(event => ({ event }));
|
||||
const open = () => calendar.options.moreLinkClick({ date: new Date("2026-11-15T00:00:00Z"), allSegs,
|
||||
jsEvent: { currentTarget: trigger, preventDefault() {} } });
|
||||
assert.equal(open(), true, "truthy non-string result suppresses vendor popover");
|
||||
const list = h.byClass("opportunities-calendar-day-list");
|
||||
assert.equal(list.children.length, 3);
|
||||
assert.ok(list.children.every(button => button.children.length === 2 && button.children[1].textContent.includes(" | FORECAST | $100")));
|
||||
list.children[2].click();
|
||||
assert.ok(h.nodes().some(node => node.tag === "h2" && node.textContent === "<Opportunity C>"));
|
||||
h.api.closeDetails();
|
||||
assert.equal(h.document.activeElement, trigger);
|
||||
open(); h.api.selectView("overview");
|
||||
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
|
||||
h.api.selectView("calendar"); open();
|
||||
const dialog = h.nodes().find(node => node.attributes.role === "dialog");
|
||||
dialog.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() {} });
|
||||
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
|
||||
});
|
||||
|
||||
test("calendar events show their stage and handle missing stage values", () => {
|
||||
const h = setup();
|
||||
const calendar = h.activate();
|
||||
for (const [value, label] of [["FORECAST", "FORECAST"], ["UPSIDE", "UPSIDE"], [null, "N/D"]]) {
|
||||
const item = { ...h.state.items[0], ForecastGroup_c: value };
|
||||
const { domNodes } = calendar.options.eventContent({ event: { title: item.Name, extendedProps: { item } } });
|
||||
assert.equal(domNodes[0].children.length, 2);
|
||||
assert.equal(domNodes[0].children[0].textContent, item.Name);
|
||||
assert.equal(domNodes[0].children[1].textContent, `A | ${label} | $100`);
|
||||
assert.equal(domNodes[0].children[1].title, `A | ${label} | $100`);
|
||||
}
|
||||
});
|
||||
@@ -254,6 +254,6 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
|
||||
assert.ok(remaining > 0);
|
||||
assert.ok(remaining / 2 >= 12 + 84, "three customer controls fit after the left inset");
|
||||
}
|
||||
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css"].map(read).join("\n");
|
||||
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"].map(read).join("\n");
|
||||
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user