diff --git a/dist/chromium/content.js b/dist/chromium/content.js index 8416ed8..a137352 100644 --- a/dist/chromium/content.js +++ b/dist/chromium/content.js @@ -11,11 +11,20 @@ const DEBUG_PANEL_ID = "opportunities-extension-debug-panel"; const OPPORTUNITIES_TABLE_ID = "opportunities-extension-table"; const STAGE_FILTERS_ID = "opportunities-extension-stage-filters"; + const STATUS_FILTERS_ID = "opportunities-extension-status-filters"; const CUSTOMER_FILTER_BUTTON_ID = "opportunities-extension-customer-filter-button"; const CUSTOMER_FILTER_PANEL_ID = "opportunities-extension-customer-filter-panel"; const CUSTOMER_FILTER_SEARCH_ID = "opportunities-extension-customer-filter-search"; const CUSTOMER_FILTER_SELECT_ALL_ID = "opportunities-extension-customer-filter-select-all"; const CUSTOMER_FILTER_LIST_ID = "opportunities-extension-customer-filter-list"; + const OWNER_FILTER_BUTTON_ID = "opportunities-extension-owner-filter-button"; + const OWNER_FILTER_PANEL_ID = "opportunities-extension-owner-filter-panel"; + const OWNER_FILTER_SEARCH_ID = "opportunities-extension-owner-filter-search"; + const OWNER_FILTER_SELECT_ALL_ID = "opportunities-extension-owner-filter-select-all"; + const OWNER_FILTER_LIST_ID = "opportunities-extension-owner-filter-list"; + const TABLE_SEARCH_ID = "opportunities-extension-table-search"; + const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard"; + const FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences"; const OPPORTUNITY_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid="; const ACCOUNT_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/accounts/accounts-detail?id="; const DEBUG_BODY_LIMIT = 12000; @@ -29,14 +38,22 @@ unauthenticated: "unauthenticated" }; const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"]; + const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"]; let tokenRelayRequest = null; let accessToken = ""; let periodRequestVersion = 0; let authStatus = AUTH_STATUS.idle; let requestLog = []; let selectedStages = new Set(STAGE_OPTIONS); + let selectedStatuses = new Set(STATUS_OPTIONS); let selectedCustomerKeys = new Set(); let customerSearch = ""; + let selectedOwnerKeys = new Set(); + let ownerSearch = ""; + let tableSearch = ""; + let hasSavedCustomerFilter = false; + let hasSavedOwnerFilter = false; + let stageDashboardAmounts = new Map(STAGE_OPTIONS.map((stage) => [stage, 0])); let opportunitiesTableState = { items: [], status: "idle", @@ -57,7 +74,8 @@ { key: "optyNumber", label: "Opty Number", value: (item) => item.OptyNumber, display: (item) => item.OptyNumber }, { key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability }, { key: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]) }, - { key: "revenue", label: "Revenue", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue }, + { key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName }, + { key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue }, { key: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) }, { key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c }, { key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode }, @@ -223,11 +241,13 @@ }); select.addEventListener("change", () => { + saveFilterPreferences(select.value); requestOpportunitiesForPeriod(select.value); }); selectWrap.append(select); field.append(labelText, selectWrap); + restoreFilterPreferences(select); const stageFilter = document.createElement("section"); stageFilter.className = "opportunities-extension-stage-filter"; @@ -272,6 +292,7 @@ customerSearchInput.setAttribute("aria-label", "Search customers"); customerSearchInput.addEventListener("input", () => { customerSearch = customerSearchInput.value; + saveFilterPreferences(); renderCustomerFilterList(); }); @@ -286,6 +307,8 @@ selectedCustomerKeys = selectAllCheckbox.checked ? new Set(customerOptions.map((customer) => customer.key)) : new Set(); + hasSavedCustomerFilter = true; + saveFilterPreferences(); renderCustomerFilter(); renderOpportunitiesTable(); }); @@ -300,9 +323,110 @@ customerFilterPanel.append(customerSearchInput, selectAllLabel, customerFilterList); customerFilter.append(customerFilterLabel, customerFilterButton, customerFilterPanel); - form.append(field, stageFilter, customerFilter); + + const ownerFilter = document.createElement("section"); + ownerFilter.className = "opportunities-extension-customer-filter"; + + const ownerFilterLabel = document.createElement("span"); + ownerFilterLabel.className = "opportunities-extension-customer-filter-label"; + ownerFilterLabel.textContent = "Owner"; + + const ownerFilterButton = document.createElement("button"); + ownerFilterButton.id = OWNER_FILTER_BUTTON_ID; + ownerFilterButton.type = "button"; + ownerFilterButton.className = "opportunities-extension-customer-filter-trigger"; + ownerFilterButton.setAttribute("aria-expanded", "false"); + ownerFilterButton.setAttribute("aria-controls", OWNER_FILTER_PANEL_ID); + ownerFilterButton.addEventListener("click", toggleOwnerFilterPanel); + + const ownerFilterPanel = document.createElement("section"); + ownerFilterPanel.id = OWNER_FILTER_PANEL_ID; + ownerFilterPanel.className = "opportunities-extension-customer-filter-panel"; + ownerFilterPanel.hidden = true; + + const ownerSearchInput = document.createElement("input"); + ownerSearchInput.id = OWNER_FILTER_SEARCH_ID; + ownerSearchInput.className = "opportunities-extension-customer-filter-search"; + ownerSearchInput.type = "search"; + ownerSearchInput.placeholder = "Search owners"; + ownerSearchInput.setAttribute("aria-label", "Search owners"); + ownerSearchInput.addEventListener("input", () => { + ownerSearch = ownerSearchInput.value; + saveFilterPreferences(); + renderOwnerFilterList(); + }); + + const ownerSelectAllLabel = document.createElement("label"); + ownerSelectAllLabel.className = "opportunities-extension-customer-filter-select-all"; + + const ownerSelectAllCheckbox = document.createElement("input"); + ownerSelectAllCheckbox.id = OWNER_FILTER_SELECT_ALL_ID; + ownerSelectAllCheckbox.type = "checkbox"; + ownerSelectAllCheckbox.addEventListener("change", () => { + const ownerOptions = getOwnerOptions(); + selectedOwnerKeys = ownerSelectAllCheckbox.checked + ? new Set(ownerOptions.map((owner) => owner.key)) + : new Set(); + hasSavedOwnerFilter = true; + saveFilterPreferences(); + renderOwnerFilter(); + renderOpportunitiesTable(); + }); + + const ownerSelectAllText = document.createElement("span"); + ownerSelectAllText.textContent = "Select all"; + ownerSelectAllLabel.append(ownerSelectAllCheckbox, ownerSelectAllText); + + const ownerFilterList = document.createElement("div"); + ownerFilterList.id = OWNER_FILTER_LIST_ID; + ownerFilterList.className = "opportunities-extension-customer-filter-list"; + + ownerFilterPanel.append(ownerSearchInput, ownerSelectAllLabel, ownerFilterList); + ownerFilter.append(ownerFilterLabel, ownerFilterButton, ownerFilterPanel); + + const tableSearchField = document.createElement("label"); + tableSearchField.className = "opportunities-extension-table-search-field"; + + const tableSearchLabel = document.createElement("span"); + tableSearchLabel.textContent = "Search"; + + const tableSearchInput = document.createElement("input"); + tableSearchInput.id = TABLE_SEARCH_ID; + tableSearchInput.type = "search"; + tableSearchInput.placeholder = "Search opportunities"; + tableSearchInput.setAttribute("aria-label", "Search opportunities"); + tableSearchInput.addEventListener("input", () => { + tableSearch = tableSearchInput.value; + saveFilterPreferences(); + renderOpportunitiesTable(); + }); + + tableSearchField.append(tableSearchLabel, tableSearchInput); + + const statusFilter = document.createElement("section"); + statusFilter.className = "opportunities-extension-status-filter"; + + const statusFilterLabel = document.createElement("span"); + statusFilterLabel.className = "opportunities-extension-status-filter-label"; + statusFilterLabel.textContent = "Status"; + + const statusFilters = document.createElement("div"); + statusFilters.id = STATUS_FILTERS_ID; + statusFilters.className = "opportunities-extension-status-filter-controls"; + statusFilters.setAttribute("role", "group"); + statusFilters.setAttribute("aria-label", "Filter by status"); + + statusFilter.append(statusFilterLabel, statusFilters); + form.addEventListener("submit", (event) => event.preventDefault()); + form.append(field, customerFilter, ownerFilter, tableSearchField, statusFilter, stageFilter); body.append(form); + const stageDashboard = document.createElement("section"); + stageDashboard.id = STAGE_DASHBOARD_ID; + stageDashboard.className = "opportunities-extension-stage-dashboard"; + stageDashboard.setAttribute("aria-label", "Opportunity amount by stage"); + body.append(stageDashboard); + const tableSurface = document.createElement("section"); tableSurface.className = "opportunities-extension-table-surface"; @@ -326,7 +450,9 @@ document.documentElement.classList.add("opportunities-extension-scroll-lock"); ensureEscapeKeyHandler(); renderStageFilterButtons(); + renderStatusFilterButtons(); renderCustomerFilter(); + renderOwnerFilter(); renderOpportunitiesTable(); requestOpportunitiesForPeriod(select.value); select.focus(); @@ -568,8 +694,15 @@ function resetOpportunitiesTable() { selectedStages = new Set(STAGE_OPTIONS); + selectedStatuses = new Set(STATUS_OPTIONS); selectedCustomerKeys = new Set(); customerSearch = ""; + selectedOwnerKeys = new Set(); + ownerSearch = ""; + tableSearch = ""; + hasSavedCustomerFilter = false; + hasSavedOwnerFilter = false; + stageDashboardAmounts = new Map(STAGE_OPTIONS.map((stage) => [stage, 0])); opportunitiesTableState = { items: [], status: "idle", @@ -579,9 +712,74 @@ }; } + function restoreFilterPreferences(select) { + try { + const rawPreferences = localStorage.getItem(FILTER_PREFERENCES_STORAGE_KEY); + + if (!rawPreferences) { + return; + } + + const preferences = JSON.parse(rawPreferences); + const savedStages = Array.isArray(preferences.stages) + ? preferences.stages.filter((stage) => STAGE_OPTIONS.includes(stage)) + : null; + const savedStatuses = Array.isArray(preferences.statuses) + ? preferences.statuses.filter((status) => STATUS_OPTIONS.includes(status)) + : null; + + if (savedStages) { + selectedStages = new Set(savedStages); + } + + if (savedStatuses) { + selectedStatuses = new Set(savedStatuses); + } + + if (Array.isArray(preferences.customerKeys)) { + selectedCustomerKeys = new Set(preferences.customerKeys.map(String)); + hasSavedCustomerFilter = true; + } + + if (Array.isArray(preferences.ownerKeys)) { + selectedOwnerKeys = new Set(preferences.ownerKeys.map(String)); + hasSavedOwnerFilter = true; + } + + customerSearch = typeof preferences.customerSearch === "string" ? preferences.customerSearch : ""; + ownerSearch = typeof preferences.ownerSearch === "string" ? preferences.ownerSearch : ""; + tableSearch = typeof preferences.tableSearch === "string" ? preferences.tableSearch : ""; + + if (PERIOD_OPTIONS.includes(preferences.period)) { + select.value = preferences.period; + } + } catch (error) { + // Ignore malformed or unavailable browser storage and use default filters. + } + } + + function saveFilterPreferences(period) { + try { + const currentPeriod = period || document.querySelector(`#${MODAL_ID} select`)?.value || PERIOD_OPTIONS[0]; + localStorage.setItem(FILTER_PREFERENCES_STORAGE_KEY, JSON.stringify({ + period: currentPeriod, + stages: Array.from(selectedStages), + statuses: Array.from(selectedStatuses), + customerKeys: hasSavedCustomerFilter ? Array.from(selectedCustomerKeys) : null, + ownerKeys: hasSavedOwnerFilter ? Array.from(selectedOwnerKeys) : null, + customerSearch, + ownerSearch, + tableSearch + })); + } catch (error) { + // Ignore unavailable browser storage; filters still work for the current modal. + } + } + function setOpportunitiesTableState(nextState) { if (nextState.status === "ready" && Array.isArray(nextState.items)) { resetCustomerFilter(nextState.items); + resetOwnerFilter(nextState.items); } opportunitiesTableState = { @@ -592,6 +790,7 @@ }; renderOpportunitiesTable(); renderCustomerFilter(); + renderOwnerFilter(); } function renderOpportunitiesTable() { @@ -633,6 +832,7 @@ const tableBody = document.createElement("tbody"); const items = getSortedOpportunities(); + renderStageDashboard(items); if (opportunitiesTableState.status === "loading" || opportunitiesTableState.status === "error" || opportunitiesTableState.status === "empty" || (opportunitiesTableState.status === "ready" && items.length === 0)) { const row = document.createElement("tr"); @@ -651,6 +851,8 @@ if (column.key === "stage") { cell.append(createStageBadge(column.display(item))); + } else if (column.key === "status") { + cell.append(createStatusBadge(column.display(item))); } else if (column.key === "optyNumber") { cell.append(createDetailLink( column.display(item), @@ -677,6 +879,97 @@ table.append(caption, tableHead, tableBody); } + function renderStageDashboard(items) { + const dashboard = document.getElementById(STAGE_DASHBOARD_ID); + + if (!dashboard) { + return; + } + + dashboard.textContent = ""; + + STAGE_OPTIONS.forEach((stage) => { + const stageItems = items.filter((item) => item.ForecastGroup_c === stage); + const amount = stageItems.reduce((total, item) => { + const value = Number(getNestedValue(item, ["PrimaryRevenue", "RevnAmount"])); + return Number.isFinite(value) ? total + value : total; + }, 0); + const card = document.createElement("section"); + const label = document.createElement("span"); + const value = document.createElement("strong"); + + card.className = "opportunities-extension-stage-dashboard-card"; + card.setAttribute("data-stage", stage); + label.textContent = stage; + value.className = "opportunities-extension-stage-dashboard-value"; + value.setAttribute("aria-label", `${stage} total amount`); + + animateStageDashboardValue( + value, + stage, + amount, + createStageAmountFormatter(stageItems) + ); + + card.append(label, value); + dashboard.append(card); + }); + } + + function createStageAmountFormatter(items) { + const currencies = new Set(items + .map((item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"])) + .filter(Boolean)); + + if (currencies.size === 1) { + const [currency] = currencies; + + try { + const formatter = new Intl.NumberFormat(undefined, { + style: "currency", + currency, + maximumFractionDigits: 0 + }); + return (amount) => formatter.format(amount); + } catch (error) { + // Fall through to a neutral numeric total when the currency code is invalid. + } + } + + return (amount) => amount.toLocaleString(undefined, { + maximumFractionDigits: 0 + }); + } + + function animateStageDashboardValue(element, stage, target, format) { + const current = stageDashboardAmounts.get(stage) || 0; + stageDashboardAmounts.set(stage, target); + + if (current === target) { + element.textContent = format(target); + return; + } + + const startTime = performance.now(); + const duration = 480; + + function tick(now) { + const progress = Math.min((now - startTime) / duration, 1); + const easedProgress = 1 - Math.pow(1 - progress, 3); + const value = current + ((target - current) * easedProgress); + + element.textContent = format(value); + + if (progress < 1) { + requestAnimationFrame(tick); + } else { + element.textContent = format(target); + } + } + + requestAnimationFrame(tick); + } + function sortOpportunitiesBy(key) { const isSameColumn = opportunitiesTableState.sortKey === key; opportunitiesTableState.sortKey = key; @@ -716,10 +1009,46 @@ selectedStages.add(stage); } + saveFilterPreferences(); renderStageFilterButtons(); renderOpportunitiesTable(); } + function renderStatusFilterButtons() { + const controls = document.getElementById(STATUS_FILTERS_ID); + + if (!controls) { + return; + } + + controls.textContent = ""; + + STATUS_OPTIONS.forEach((status) => { + const button = document.createElement("button"); + const isPressed = selectedStatuses.has(status); + + button.type = "button"; + button.className = "opportunities-extension-status-filter-button"; + button.setAttribute("data-status", status); + button.setAttribute("aria-pressed", isPressed ? "true" : "false"); + button.textContent = status; + button.addEventListener("click", () => toggleStatusFilter(status)); + controls.append(button); + }); + } + + function toggleStatusFilter(status) { + if (selectedStatuses.has(status)) { + selectedStatuses.delete(status); + } else { + selectedStatuses.add(status); + } + + saveFilterPreferences(); + renderStatusFilterButtons(); + renderOpportunitiesTable(); + } + function toggleCustomerFilterPanel() { const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID); @@ -737,7 +1066,7 @@ } } - function closeCustomerFilterPanel() { + function closeCustomerFilterPanel(restoreFocus = true) { const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID); @@ -747,14 +1076,65 @@ panel.hidden = true; button.setAttribute("aria-expanded", "false"); - button.focus(); + + if (restoreFocus) { + button.focus(); + } + + return true; + } + + function toggleOwnerFilterPanel() { + const panel = document.getElementById(OWNER_FILTER_PANEL_ID); + const button = document.getElementById(OWNER_FILTER_BUTTON_ID); + + if (!panel || !button || button.disabled) { + return; + } + + panel.hidden = !panel.hidden; + button.setAttribute("aria-expanded", panel.hidden ? "false" : "true"); + + if (!panel.hidden) { + const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID); + searchInput.focus(); + } + } + + function closeOwnerFilterPanel(restoreFocus = true) { + const panel = document.getElementById(OWNER_FILTER_PANEL_ID); + const button = document.getElementById(OWNER_FILTER_BUTTON_ID); + + if (!panel || panel.hidden) { + return false; + } + + panel.hidden = true; + button.setAttribute("aria-expanded", "false"); + + if (restoreFocus) { + button.focus(); + } + return true; } function resetCustomerFilter(items) { const customerOptions = getCustomerOptions(items); - selectedCustomerKeys = new Set(customerOptions.map((customer) => customer.key)); - customerSearch = ""; + selectedCustomerKeys = hasSavedCustomerFilter + ? new Set(customerOptions + .map((customer) => customer.key) + .filter((key) => selectedCustomerKeys.has(key))) + : new Set(customerOptions.map((customer) => customer.key)); + } + + function resetOwnerFilter(items) { + const ownerOptions = getOwnerOptions(items); + selectedOwnerKeys = hasSavedOwnerFilter + ? new Set(ownerOptions + .map((owner) => owner.key) + .filter((key) => selectedOwnerKeys.has(key))) + : new Set(ownerOptions.map((owner) => owner.key)); } function renderCustomerFilter() { @@ -813,6 +1193,8 @@ selectedCustomerKeys.delete(customer.key); } + hasSavedCustomerFilter = true; + saveFilterPreferences(); renderCustomerFilter(); renderOpportunitiesTable(); }); @@ -854,6 +1236,26 @@ return partyName ? `name:${partyName}` : ""; } + function getOwnerName(item) { + const owner = item.Owner; + + if (typeof owner === "string" || typeof owner === "number") { + return owner; + } + + if (owner && typeof owner === "object") { + return owner.PartyName + || owner.PartyUniqueName + || owner.Name + || owner.DisplayName + || owner.ResourceName + || owner.OwnerName + || ""; + } + + return ""; + } + function getCustomerFilterSummary(total, selected) { if (total === 0) { return "No customers"; @@ -870,9 +1272,115 @@ return `${selected} customer${selected === 1 ? "" : "s"}`; } + function renderOwnerFilter() { + const trigger = document.getElementById(OWNER_FILTER_BUTTON_ID); + const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID); + const selectAllCheckbox = document.getElementById(OWNER_FILTER_SELECT_ALL_ID); + const ownerOptions = getOwnerOptions(); + + if (!trigger || !searchInput || !selectAllCheckbox) { + return; + } + + const selectedCount = ownerOptions.filter((owner) => selectedOwnerKeys.has(owner.key)).length; + trigger.disabled = ownerOptions.length === 0; + trigger.textContent = getCustomerFilterSummary(ownerOptions.length, selectedCount) + .replace("customer", "owner"); + searchInput.value = ownerSearch; + selectAllCheckbox.disabled = ownerOptions.length === 0; + selectAllCheckbox.checked = ownerOptions.length > 0 && selectedCount === ownerOptions.length; + selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < ownerOptions.length; + renderOwnerFilterList(); + } + + function renderOwnerFilterList() { + const list = document.getElementById(OWNER_FILTER_LIST_ID); + + if (!list) { + return; + } + + const normalizedSearch = ownerSearch.trim().toLocaleLowerCase(); + const ownerOptions = getOwnerOptions().filter((owner) => { + return owner.label.toLocaleLowerCase().includes(normalizedSearch); + }); + + list.textContent = ""; + + if (ownerOptions.length === 0) { + const empty = document.createElement("p"); + empty.className = "opportunities-extension-customer-filter-empty"; + empty.textContent = ownerSearch ? "No matching owners." : "No owners available."; + list.append(empty); + return; + } + + ownerOptions.forEach((owner) => { + const option = document.createElement("label"); + option.className = "opportunities-extension-customer-filter-option"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = selectedOwnerKeys.has(owner.key); + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + selectedOwnerKeys.add(owner.key); + } else { + selectedOwnerKeys.delete(owner.key); + } + + hasSavedOwnerFilter = true; + saveFilterPreferences(); + renderOwnerFilter(); + renderOpportunitiesTable(); + }); + + const label = document.createElement("span"); + label.textContent = owner.label; + option.append(checkbox, label); + list.append(option); + }); + } + + function getOwnerOptions(items) { + const optionsByKey = new Map(); + + (items || opportunitiesTableState.items).forEach((item) => { + const key = getOwnerKey(item); + const label = getOwnerName(item); + + if (key && label && !optionsByKey.has(key)) { + optionsByKey.set(key, { key, label: String(label) }); + } + }); + + return Array.from(optionsByKey.values()).sort((first, second) => { + return first.label.localeCompare(second.label, undefined, { + sensitivity: "base" + }); + }); + } + + function getOwnerKey(item) { + const owner = item.Owner; + const partyId = owner && typeof owner === "object" ? owner.PartyId : ""; + const name = getOwnerName(item); + + if (partyId !== null && partyId !== undefined && partyId !== "") { + return `party:${partyId}`; + } + + return name ? `name:${name}` : ""; + } + function getSortedOpportunities() { + const normalizedSearch = tableSearch.trim().toLocaleLowerCase(); const items = opportunitiesTableState.items.filter((item) => { - return selectedStages.has(item.ForecastGroup_c) && selectedCustomerKeys.has(getCustomerKey(item)); + return selectedStages.has(item.ForecastGroup_c) + && selectedStatuses.has(item.StatusCode) + && selectedCustomerKeys.has(getCustomerKey(item)) + && selectedOwnerKeys.has(getOwnerKey(item)) + && matchesTableSearch(item, normalizedSearch); }); const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.sortKey); @@ -910,6 +1418,30 @@ }); } + function matchesTableSearch(item, normalizedSearch) { + if (!normalizedSearch) { + return true; + } + + return OPPORTUNITIES_COLUMNS.some((column) => { + const rawValue = stringifySearchValue(column.value(item)); + const displayValue = stringifySearchValue(column.display(item)); + return `${rawValue} ${displayValue}`.toLocaleLowerCase().includes(normalizedSearch); + }); + } + + function stringifySearchValue(value) { + if (value === null || value === undefined) { + return ""; + } + + if (typeof value === "object") { + return JSON.stringify(value); + } + + return String(value); + } + function displayOpportunityValue(column, item) { const value = column.display(item); return value === null || value === undefined || value === "" ? "-" : String(value); @@ -946,6 +1478,16 @@ return badge; } + function createStatusBadge(status) { + const badge = document.createElement("span"); + const normalizedStatus = typeof status === "string" ? status.toUpperCase() : ""; + + badge.className = "opportunities-extension-status-badge"; + badge.setAttribute("data-status", normalizedStatus); + badge.textContent = normalizedStatus || "-"; + return badge; + } + function getNestedValue(value, path) { return path.reduce((result, key) => result && result[key], value); } @@ -1033,6 +1575,7 @@ "EffectiveDate", "StatusCode", "DealRisk_c", + "Owner", "ForecastGroup_c", "LastUpdateDate", "PrimaryRevenue.RevnAmountCurcyCode", @@ -1047,6 +1590,7 @@ "EffectiveDate", "StatusCode", "DealRisk_c", + "Owner", "ForecastGroup_c", "LastUpdateDate", "PrimaryRevenue.RevnAmountCurcyCode", @@ -1072,7 +1616,10 @@ const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3); if (period === "Current Fiscal Year") { - return createDateRange(fiscalStartYear, 5, fiscalStartYear + 1, 4); + const isBeforeFiscalYearStart = currentMonth < 5; + const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear; + const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1; + return createDateRange(startYear, 5, endYear, 4); } let quarterOffset; @@ -1272,19 +1819,28 @@ } requestLog.forEach((entry) => { - const item = document.createElement("article"); + const item = document.createElement("details"); item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`; - const heading = document.createElement("div"); + const heading = document.createElement("summary"); heading.className = "opportunities-extension-debug-heading"; const name = document.createElement("strong"); name.textContent = `${entry.id}. ${entry.name}`; + const httpStatus = document.createElement("span"); + httpStatus.className = "opportunities-extension-debug-http-status"; + httpStatus.textContent = entry.httpStatus ? String(entry.httpStatus) : "pending"; + const status = document.createElement("span"); + status.className = "opportunities-extension-debug-status"; status.textContent = entry.status; - heading.append(name, status); + const summaryMeta = document.createElement("span"); + summaryMeta.className = "opportunities-extension-debug-summary-meta"; + summaryMeta.append(httpStatus, status); + + heading.append(name, summaryMeta); const lines = [ `${entry.method} ${entry.url}`, @@ -1463,9 +2019,40 @@ return; } + if (closeOwnerFilterPanel()) { + event.preventDefault(); + return; + } + closeOpportunitiesModal(); } }); + + document.addEventListener("pointerdown", (event) => { + const modal = document.getElementById(MODAL_ID); + const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); + const ownerPanel = document.getElementById(OWNER_FILTER_PANEL_ID); + + if (!modal || modal.hidden) { + return; + } + + if (panel && !panel.hidden) { + const customerFilter = panel.closest(".opportunities-extension-customer-filter"); + + if (customerFilter && !customerFilter.contains(event.target)) { + closeCustomerFilterPanel(false); + } + } + + if (ownerPanel && !ownerPanel.hidden) { + const ownerFilter = ownerPanel.closest(".opportunities-extension-customer-filter"); + + if (ownerFilter && !ownerFilter.contains(event.target)) { + closeOwnerFilterPanel(false); + } + } + }); } function ensureExtensionStyles() { @@ -1601,7 +2188,7 @@ .opportunities-extension-body { display: grid; min-height: 0; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr); gap: 16px; padding: 22px 40px 36px; background: #f5f4f2; @@ -1611,6 +2198,7 @@ .opportunities-extension-form { display: flex; align-items: end; + flex-wrap: wrap; gap: 24px; min-height: 82px; max-width: none; @@ -1623,8 +2211,9 @@ .opportunities-extension-field { display: grid; - max-width: 360px; - width: 360px; + max-width: max-content; + min-width: 270px; + width: max-content; gap: 6px; color: #312d2a; font-size: 13px; @@ -1632,6 +2221,60 @@ line-height: 1.3; } + .opportunities-extension-stage-dashboard { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + } + + .opportunities-extension-stage-dashboard-card { + display: grid; + gap: 6px; + min-width: 0; + padding: 12px 16px; + border: 1px solid #dedbd7; + border-top: 3px solid #5f5a55; + border-radius: 4px; + background: #ffffff; + } + + .opportunities-extension-stage-dashboard-card span { + color: #5f5a55; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + } + + .opportunities-extension-stage-dashboard-value { + overflow: hidden; + color: #312d2a; + font-size: 22px; + font-weight: 700; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; + } + + .opportunities-extension-stage-dashboard-card[data-stage="SQL"] { + border-top-color: #008aa6; + } + + .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { + border-top-color: #6f6863; + } + + .opportunities-extension-stage-dashboard-card[data-stage="UPSIDE"] { + border-top-color: #c87b00; + } + + .opportunities-extension-stage-dashboard-card[data-stage="FORECAST"] { + border-top-color: #755b8b; + } + + .opportunities-extension-stage-dashboard-card[data-stage="WON"] { + border-top-color: #4b7f22; + } + .opportunities-extension-stage-filter { display: grid; grid-template-rows: auto 44px; @@ -1722,6 +2365,85 @@ outline-offset: 2px; } + .opportunities-extension-status-filter { + display: grid; + grid-template-rows: auto 44px; + gap: 6px; + min-width: 0; + } + + .opportunities-extension-status-filter-label { + color: #312d2a; + font-size: 13px; + font-weight: 600; + line-height: 1.3; + } + + .opportunities-extension-status-filter-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + } + + .opportunities-extension-status-filter-button { + min-height: 36px; + padding: 6px 12px; + border: 1px solid currentColor; + border-radius: 18px; + background: #ffffff; + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + line-height: 1.2; + cursor: pointer; + } + + .opportunities-extension-status-filter-button[data-status="OPEN"] { + color: #197a3d; + } + + .opportunities-extension-status-filter-button[data-status="CLOSED"] { + color: #4b5563; + } + + .opportunities-extension-status-filter-button[data-status="LOST"] { + color: #b13b34; + } + + .opportunities-extension-status-filter-button[data-status="WON"] { + color: #1f5f99; + } + + .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="OPEN"] { + border-color: #197a3d; + background: #197a3d; + color: #ffffff; + } + + .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="CLOSED"] { + border-color: #4b5563; + background: #4b5563; + color: #ffffff; + } + + .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="LOST"] { + border-color: #b13b34; + background: #b13b34; + color: #ffffff; + } + + .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="WON"] { + border-color: #1f5f99; + background: #1f5f99; + color: #ffffff; + } + + .opportunities-extension-status-filter-button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 2px; + } + .opportunities-extension-customer-filter { position: relative; display: grid; @@ -1876,9 +2598,40 @@ font-size: 13px; } + .opportunities-extension-table-search-field { + display: grid; + grid-template-rows: auto 44px; + gap: 6px; + width: 260px; + color: #312d2a; + font-size: 13px; + font-weight: 600; + line-height: 1.3; + } + + .opportunities-extension-table-search-field input { + width: 100%; + min-height: 44px; + padding: 10px 12px; + border: 1px solid #b8b2ad; + border-radius: 3px; + background: #ffffff; + color: #312d2a; + font-size: 14px; + line-height: 1.3; + } + + .opportunities-extension-table-search-field input:focus { + border-color: #312d2a; + box-shadow: 0 0 0 1px #312d2a; + outline: none; + } + .opportunities-extension-select-wrap { position: relative; display: block; + width: 270px; + max-width: 100%; } .opportunities-extension-select-wrap::after { @@ -1897,7 +2650,10 @@ .opportunities-extension-field select { width: 100%; + min-width: 0; + max-width: 100%; min-height: 44px; + box-sizing: border-box; appearance: none; border: 1px solid #b8b2ad; border-radius: 3px; @@ -1960,27 +2716,27 @@ } .opportunities-extension-table th:nth-child(1) { - width: 17%; + width: 14%; } .opportunities-extension-table th:nth-child(2) { - width: 9%; + width: 8%; } .opportunities-extension-table th:nth-child(3) { - width: 10%; + width: 8%; } .opportunities-extension-table th:nth-child(4) { - width: 21%; + width: 17%; } .opportunities-extension-table th:nth-child(5) { - width: 10%; + width: 12%; } .opportunities-extension-table th:nth-child(6) { - width: 10%; + width: 9%; } .opportunities-extension-table th:nth-child(7) { @@ -1988,20 +2744,24 @@ } .opportunities-extension-table th:nth-child(8) { - width: 7%; + width: 8%; } .opportunities-extension-table th:nth-child(9) { - width: 12%; + width: 7%; + } + + .opportunities-extension-table th:nth-child(10) { + width: 8%; } .opportunities-extension-table td:nth-child(2), .opportunities-extension-table td:nth-child(3), - .opportunities-extension-table td:nth-child(5), .opportunities-extension-table td:nth-child(6), .opportunities-extension-table td:nth-child(7), .opportunities-extension-table td:nth-child(8), - .opportunities-extension-table td:nth-child(9) { + .opportunities-extension-table td:nth-child(9), + .opportunities-extension-table td:nth-child(10) { white-space: nowrap; } @@ -2063,6 +2823,40 @@ color: #3f6f17; } + .opportunities-extension-status-badge { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 8px; + border-radius: 12px; + background: #ebe8e5; + color: #312d2a; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; + } + + .opportunities-extension-status-badge[data-status="OPEN"] { + background: #e2f2e5; + color: #197a3d; + } + + .opportunities-extension-status-badge[data-status="CLOSED"] { + background: #e5e7eb; + color: #4b5563; + } + + .opportunities-extension-status-badge[data-status="LOST"] { + background: #f9e3e1; + color: #b13b34; + } + + .opportunities-extension-status-badge[data-status="WON"] { + background: #e3edf8; + color: #1f5f99; + } + .opportunities-extension-sort-button { position: relative; display: inline-flex; @@ -2172,37 +2966,100 @@ background: #faf9f8; } + .opportunities-extension-debug-item[open] { + background: #ffffff; + } + .opportunities-extension-debug-item-success { border-left-color: #3f6f17; } + .opportunities-extension-debug-item-success .opportunities-extension-debug-status { + background: #e2f2e5; + color: #356d19; + } + .opportunities-extension-debug-item-failed { border-left-color: #c5331f; } + .opportunities-extension-debug-item-failed .opportunities-extension-debug-status { + background: #f9e3e1; + color: #a52b1c; + } + .opportunities-extension-debug-item-pending { border-left-color: #6f5a7f; } + .opportunities-extension-debug-item-pending .opportunities-extension-debug-status { + background: #eee8f2; + color: #6f5a7f; + } + .opportunities-extension-debug-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; - padding: 10px 12px 0; + min-height: 42px; + padding: 9px 12px 9px 10px; + cursor: pointer; + list-style: none; + } + + .opportunities-extension-debug-heading::-webkit-details-marker { + display: none; + } + + .opportunities-extension-debug-heading::before { + content: "⌄"; + width: 14px; + color: #5f5a55; + font-size: 16px; + line-height: 1; + transform: rotate(-90deg); + transition: transform .15s ease; + } + + .opportunities-extension-debug-item[open] .opportunities-extension-debug-heading::before { + transform: rotate(0deg); + } + + .opportunities-extension-debug-summary-meta { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: auto; + } + + .opportunities-extension-debug-http-status, + .opportunities-extension-debug-status { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + line-height: 1; + text-transform: uppercase; + } + + .opportunities-extension-debug-http-status { + background: #e8f4f6; + color: #006d7a; + } + + .opportunities-extension-debug-status { + background: #ebe8e5; + color: #5f5a55; } .opportunities-extension-debug-heading strong { font-size: 13px; } - .opportunities-extension-debug-heading span { - color: #312d2a; - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - } - .opportunities-extension-debug-item pre { margin: 0; padding: 8px 12px 12px; @@ -2268,10 +3125,26 @@ width: min(360px, calc(100vw - 64px)); } + .opportunities-extension-table-search-field { + width: 100%; + } + + .opportunities-extension-stage-dashboard { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .opportunities-extension-stage-dashboard-value { + font-size: 18px; + } + .opportunities-extension-stage-filter-controls { gap: 6px; } + .opportunities-extension-status-filter-controls { + gap: 6px; + } + .opportunities-extension-table-surface { min-width: 0; min-height: calc(100vh - 230px); diff --git a/dist/firefox/content.js b/dist/firefox/content.js index 8416ed8..a137352 100644 --- a/dist/firefox/content.js +++ b/dist/firefox/content.js @@ -11,11 +11,20 @@ const DEBUG_PANEL_ID = "opportunities-extension-debug-panel"; const OPPORTUNITIES_TABLE_ID = "opportunities-extension-table"; const STAGE_FILTERS_ID = "opportunities-extension-stage-filters"; + const STATUS_FILTERS_ID = "opportunities-extension-status-filters"; const CUSTOMER_FILTER_BUTTON_ID = "opportunities-extension-customer-filter-button"; const CUSTOMER_FILTER_PANEL_ID = "opportunities-extension-customer-filter-panel"; const CUSTOMER_FILTER_SEARCH_ID = "opportunities-extension-customer-filter-search"; const CUSTOMER_FILTER_SELECT_ALL_ID = "opportunities-extension-customer-filter-select-all"; const CUSTOMER_FILTER_LIST_ID = "opportunities-extension-customer-filter-list"; + const OWNER_FILTER_BUTTON_ID = "opportunities-extension-owner-filter-button"; + const OWNER_FILTER_PANEL_ID = "opportunities-extension-owner-filter-panel"; + const OWNER_FILTER_SEARCH_ID = "opportunities-extension-owner-filter-search"; + const OWNER_FILTER_SELECT_ALL_ID = "opportunities-extension-owner-filter-select-all"; + const OWNER_FILTER_LIST_ID = "opportunities-extension-owner-filter-list"; + const TABLE_SEARCH_ID = "opportunities-extension-table-search"; + const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard"; + const FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences"; const OPPORTUNITY_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid="; const ACCOUNT_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/accounts/accounts-detail?id="; const DEBUG_BODY_LIMIT = 12000; @@ -29,14 +38,22 @@ unauthenticated: "unauthenticated" }; const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"]; + const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"]; let tokenRelayRequest = null; let accessToken = ""; let periodRequestVersion = 0; let authStatus = AUTH_STATUS.idle; let requestLog = []; let selectedStages = new Set(STAGE_OPTIONS); + let selectedStatuses = new Set(STATUS_OPTIONS); let selectedCustomerKeys = new Set(); let customerSearch = ""; + let selectedOwnerKeys = new Set(); + let ownerSearch = ""; + let tableSearch = ""; + let hasSavedCustomerFilter = false; + let hasSavedOwnerFilter = false; + let stageDashboardAmounts = new Map(STAGE_OPTIONS.map((stage) => [stage, 0])); let opportunitiesTableState = { items: [], status: "idle", @@ -57,7 +74,8 @@ { key: "optyNumber", label: "Opty Number", value: (item) => item.OptyNumber, display: (item) => item.OptyNumber }, { key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability }, { key: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]) }, - { key: "revenue", label: "Revenue", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue }, + { key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName }, + { key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue }, { key: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) }, { key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c }, { key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode }, @@ -223,11 +241,13 @@ }); select.addEventListener("change", () => { + saveFilterPreferences(select.value); requestOpportunitiesForPeriod(select.value); }); selectWrap.append(select); field.append(labelText, selectWrap); + restoreFilterPreferences(select); const stageFilter = document.createElement("section"); stageFilter.className = "opportunities-extension-stage-filter"; @@ -272,6 +292,7 @@ customerSearchInput.setAttribute("aria-label", "Search customers"); customerSearchInput.addEventListener("input", () => { customerSearch = customerSearchInput.value; + saveFilterPreferences(); renderCustomerFilterList(); }); @@ -286,6 +307,8 @@ selectedCustomerKeys = selectAllCheckbox.checked ? new Set(customerOptions.map((customer) => customer.key)) : new Set(); + hasSavedCustomerFilter = true; + saveFilterPreferences(); renderCustomerFilter(); renderOpportunitiesTable(); }); @@ -300,9 +323,110 @@ customerFilterPanel.append(customerSearchInput, selectAllLabel, customerFilterList); customerFilter.append(customerFilterLabel, customerFilterButton, customerFilterPanel); - form.append(field, stageFilter, customerFilter); + + const ownerFilter = document.createElement("section"); + ownerFilter.className = "opportunities-extension-customer-filter"; + + const ownerFilterLabel = document.createElement("span"); + ownerFilterLabel.className = "opportunities-extension-customer-filter-label"; + ownerFilterLabel.textContent = "Owner"; + + const ownerFilterButton = document.createElement("button"); + ownerFilterButton.id = OWNER_FILTER_BUTTON_ID; + ownerFilterButton.type = "button"; + ownerFilterButton.className = "opportunities-extension-customer-filter-trigger"; + ownerFilterButton.setAttribute("aria-expanded", "false"); + ownerFilterButton.setAttribute("aria-controls", OWNER_FILTER_PANEL_ID); + ownerFilterButton.addEventListener("click", toggleOwnerFilterPanel); + + const ownerFilterPanel = document.createElement("section"); + ownerFilterPanel.id = OWNER_FILTER_PANEL_ID; + ownerFilterPanel.className = "opportunities-extension-customer-filter-panel"; + ownerFilterPanel.hidden = true; + + const ownerSearchInput = document.createElement("input"); + ownerSearchInput.id = OWNER_FILTER_SEARCH_ID; + ownerSearchInput.className = "opportunities-extension-customer-filter-search"; + ownerSearchInput.type = "search"; + ownerSearchInput.placeholder = "Search owners"; + ownerSearchInput.setAttribute("aria-label", "Search owners"); + ownerSearchInput.addEventListener("input", () => { + ownerSearch = ownerSearchInput.value; + saveFilterPreferences(); + renderOwnerFilterList(); + }); + + const ownerSelectAllLabel = document.createElement("label"); + ownerSelectAllLabel.className = "opportunities-extension-customer-filter-select-all"; + + const ownerSelectAllCheckbox = document.createElement("input"); + ownerSelectAllCheckbox.id = OWNER_FILTER_SELECT_ALL_ID; + ownerSelectAllCheckbox.type = "checkbox"; + ownerSelectAllCheckbox.addEventListener("change", () => { + const ownerOptions = getOwnerOptions(); + selectedOwnerKeys = ownerSelectAllCheckbox.checked + ? new Set(ownerOptions.map((owner) => owner.key)) + : new Set(); + hasSavedOwnerFilter = true; + saveFilterPreferences(); + renderOwnerFilter(); + renderOpportunitiesTable(); + }); + + const ownerSelectAllText = document.createElement("span"); + ownerSelectAllText.textContent = "Select all"; + ownerSelectAllLabel.append(ownerSelectAllCheckbox, ownerSelectAllText); + + const ownerFilterList = document.createElement("div"); + ownerFilterList.id = OWNER_FILTER_LIST_ID; + ownerFilterList.className = "opportunities-extension-customer-filter-list"; + + ownerFilterPanel.append(ownerSearchInput, ownerSelectAllLabel, ownerFilterList); + ownerFilter.append(ownerFilterLabel, ownerFilterButton, ownerFilterPanel); + + const tableSearchField = document.createElement("label"); + tableSearchField.className = "opportunities-extension-table-search-field"; + + const tableSearchLabel = document.createElement("span"); + tableSearchLabel.textContent = "Search"; + + const tableSearchInput = document.createElement("input"); + tableSearchInput.id = TABLE_SEARCH_ID; + tableSearchInput.type = "search"; + tableSearchInput.placeholder = "Search opportunities"; + tableSearchInput.setAttribute("aria-label", "Search opportunities"); + tableSearchInput.addEventListener("input", () => { + tableSearch = tableSearchInput.value; + saveFilterPreferences(); + renderOpportunitiesTable(); + }); + + tableSearchField.append(tableSearchLabel, tableSearchInput); + + const statusFilter = document.createElement("section"); + statusFilter.className = "opportunities-extension-status-filter"; + + const statusFilterLabel = document.createElement("span"); + statusFilterLabel.className = "opportunities-extension-status-filter-label"; + statusFilterLabel.textContent = "Status"; + + const statusFilters = document.createElement("div"); + statusFilters.id = STATUS_FILTERS_ID; + statusFilters.className = "opportunities-extension-status-filter-controls"; + statusFilters.setAttribute("role", "group"); + statusFilters.setAttribute("aria-label", "Filter by status"); + + statusFilter.append(statusFilterLabel, statusFilters); + form.addEventListener("submit", (event) => event.preventDefault()); + form.append(field, customerFilter, ownerFilter, tableSearchField, statusFilter, stageFilter); body.append(form); + const stageDashboard = document.createElement("section"); + stageDashboard.id = STAGE_DASHBOARD_ID; + stageDashboard.className = "opportunities-extension-stage-dashboard"; + stageDashboard.setAttribute("aria-label", "Opportunity amount by stage"); + body.append(stageDashboard); + const tableSurface = document.createElement("section"); tableSurface.className = "opportunities-extension-table-surface"; @@ -326,7 +450,9 @@ document.documentElement.classList.add("opportunities-extension-scroll-lock"); ensureEscapeKeyHandler(); renderStageFilterButtons(); + renderStatusFilterButtons(); renderCustomerFilter(); + renderOwnerFilter(); renderOpportunitiesTable(); requestOpportunitiesForPeriod(select.value); select.focus(); @@ -568,8 +694,15 @@ function resetOpportunitiesTable() { selectedStages = new Set(STAGE_OPTIONS); + selectedStatuses = new Set(STATUS_OPTIONS); selectedCustomerKeys = new Set(); customerSearch = ""; + selectedOwnerKeys = new Set(); + ownerSearch = ""; + tableSearch = ""; + hasSavedCustomerFilter = false; + hasSavedOwnerFilter = false; + stageDashboardAmounts = new Map(STAGE_OPTIONS.map((stage) => [stage, 0])); opportunitiesTableState = { items: [], status: "idle", @@ -579,9 +712,74 @@ }; } + function restoreFilterPreferences(select) { + try { + const rawPreferences = localStorage.getItem(FILTER_PREFERENCES_STORAGE_KEY); + + if (!rawPreferences) { + return; + } + + const preferences = JSON.parse(rawPreferences); + const savedStages = Array.isArray(preferences.stages) + ? preferences.stages.filter((stage) => STAGE_OPTIONS.includes(stage)) + : null; + const savedStatuses = Array.isArray(preferences.statuses) + ? preferences.statuses.filter((status) => STATUS_OPTIONS.includes(status)) + : null; + + if (savedStages) { + selectedStages = new Set(savedStages); + } + + if (savedStatuses) { + selectedStatuses = new Set(savedStatuses); + } + + if (Array.isArray(preferences.customerKeys)) { + selectedCustomerKeys = new Set(preferences.customerKeys.map(String)); + hasSavedCustomerFilter = true; + } + + if (Array.isArray(preferences.ownerKeys)) { + selectedOwnerKeys = new Set(preferences.ownerKeys.map(String)); + hasSavedOwnerFilter = true; + } + + customerSearch = typeof preferences.customerSearch === "string" ? preferences.customerSearch : ""; + ownerSearch = typeof preferences.ownerSearch === "string" ? preferences.ownerSearch : ""; + tableSearch = typeof preferences.tableSearch === "string" ? preferences.tableSearch : ""; + + if (PERIOD_OPTIONS.includes(preferences.period)) { + select.value = preferences.period; + } + } catch (error) { + // Ignore malformed or unavailable browser storage and use default filters. + } + } + + function saveFilterPreferences(period) { + try { + const currentPeriod = period || document.querySelector(`#${MODAL_ID} select`)?.value || PERIOD_OPTIONS[0]; + localStorage.setItem(FILTER_PREFERENCES_STORAGE_KEY, JSON.stringify({ + period: currentPeriod, + stages: Array.from(selectedStages), + statuses: Array.from(selectedStatuses), + customerKeys: hasSavedCustomerFilter ? Array.from(selectedCustomerKeys) : null, + ownerKeys: hasSavedOwnerFilter ? Array.from(selectedOwnerKeys) : null, + customerSearch, + ownerSearch, + tableSearch + })); + } catch (error) { + // Ignore unavailable browser storage; filters still work for the current modal. + } + } + function setOpportunitiesTableState(nextState) { if (nextState.status === "ready" && Array.isArray(nextState.items)) { resetCustomerFilter(nextState.items); + resetOwnerFilter(nextState.items); } opportunitiesTableState = { @@ -592,6 +790,7 @@ }; renderOpportunitiesTable(); renderCustomerFilter(); + renderOwnerFilter(); } function renderOpportunitiesTable() { @@ -633,6 +832,7 @@ const tableBody = document.createElement("tbody"); const items = getSortedOpportunities(); + renderStageDashboard(items); if (opportunitiesTableState.status === "loading" || opportunitiesTableState.status === "error" || opportunitiesTableState.status === "empty" || (opportunitiesTableState.status === "ready" && items.length === 0)) { const row = document.createElement("tr"); @@ -651,6 +851,8 @@ if (column.key === "stage") { cell.append(createStageBadge(column.display(item))); + } else if (column.key === "status") { + cell.append(createStatusBadge(column.display(item))); } else if (column.key === "optyNumber") { cell.append(createDetailLink( column.display(item), @@ -677,6 +879,97 @@ table.append(caption, tableHead, tableBody); } + function renderStageDashboard(items) { + const dashboard = document.getElementById(STAGE_DASHBOARD_ID); + + if (!dashboard) { + return; + } + + dashboard.textContent = ""; + + STAGE_OPTIONS.forEach((stage) => { + const stageItems = items.filter((item) => item.ForecastGroup_c === stage); + const amount = stageItems.reduce((total, item) => { + const value = Number(getNestedValue(item, ["PrimaryRevenue", "RevnAmount"])); + return Number.isFinite(value) ? total + value : total; + }, 0); + const card = document.createElement("section"); + const label = document.createElement("span"); + const value = document.createElement("strong"); + + card.className = "opportunities-extension-stage-dashboard-card"; + card.setAttribute("data-stage", stage); + label.textContent = stage; + value.className = "opportunities-extension-stage-dashboard-value"; + value.setAttribute("aria-label", `${stage} total amount`); + + animateStageDashboardValue( + value, + stage, + amount, + createStageAmountFormatter(stageItems) + ); + + card.append(label, value); + dashboard.append(card); + }); + } + + function createStageAmountFormatter(items) { + const currencies = new Set(items + .map((item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"])) + .filter(Boolean)); + + if (currencies.size === 1) { + const [currency] = currencies; + + try { + const formatter = new Intl.NumberFormat(undefined, { + style: "currency", + currency, + maximumFractionDigits: 0 + }); + return (amount) => formatter.format(amount); + } catch (error) { + // Fall through to a neutral numeric total when the currency code is invalid. + } + } + + return (amount) => amount.toLocaleString(undefined, { + maximumFractionDigits: 0 + }); + } + + function animateStageDashboardValue(element, stage, target, format) { + const current = stageDashboardAmounts.get(stage) || 0; + stageDashboardAmounts.set(stage, target); + + if (current === target) { + element.textContent = format(target); + return; + } + + const startTime = performance.now(); + const duration = 480; + + function tick(now) { + const progress = Math.min((now - startTime) / duration, 1); + const easedProgress = 1 - Math.pow(1 - progress, 3); + const value = current + ((target - current) * easedProgress); + + element.textContent = format(value); + + if (progress < 1) { + requestAnimationFrame(tick); + } else { + element.textContent = format(target); + } + } + + requestAnimationFrame(tick); + } + function sortOpportunitiesBy(key) { const isSameColumn = opportunitiesTableState.sortKey === key; opportunitiesTableState.sortKey = key; @@ -716,10 +1009,46 @@ selectedStages.add(stage); } + saveFilterPreferences(); renderStageFilterButtons(); renderOpportunitiesTable(); } + function renderStatusFilterButtons() { + const controls = document.getElementById(STATUS_FILTERS_ID); + + if (!controls) { + return; + } + + controls.textContent = ""; + + STATUS_OPTIONS.forEach((status) => { + const button = document.createElement("button"); + const isPressed = selectedStatuses.has(status); + + button.type = "button"; + button.className = "opportunities-extension-status-filter-button"; + button.setAttribute("data-status", status); + button.setAttribute("aria-pressed", isPressed ? "true" : "false"); + button.textContent = status; + button.addEventListener("click", () => toggleStatusFilter(status)); + controls.append(button); + }); + } + + function toggleStatusFilter(status) { + if (selectedStatuses.has(status)) { + selectedStatuses.delete(status); + } else { + selectedStatuses.add(status); + } + + saveFilterPreferences(); + renderStatusFilterButtons(); + renderOpportunitiesTable(); + } + function toggleCustomerFilterPanel() { const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID); @@ -737,7 +1066,7 @@ } } - function closeCustomerFilterPanel() { + function closeCustomerFilterPanel(restoreFocus = true) { const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID); @@ -747,14 +1076,65 @@ panel.hidden = true; button.setAttribute("aria-expanded", "false"); - button.focus(); + + if (restoreFocus) { + button.focus(); + } + + return true; + } + + function toggleOwnerFilterPanel() { + const panel = document.getElementById(OWNER_FILTER_PANEL_ID); + const button = document.getElementById(OWNER_FILTER_BUTTON_ID); + + if (!panel || !button || button.disabled) { + return; + } + + panel.hidden = !panel.hidden; + button.setAttribute("aria-expanded", panel.hidden ? "false" : "true"); + + if (!panel.hidden) { + const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID); + searchInput.focus(); + } + } + + function closeOwnerFilterPanel(restoreFocus = true) { + const panel = document.getElementById(OWNER_FILTER_PANEL_ID); + const button = document.getElementById(OWNER_FILTER_BUTTON_ID); + + if (!panel || panel.hidden) { + return false; + } + + panel.hidden = true; + button.setAttribute("aria-expanded", "false"); + + if (restoreFocus) { + button.focus(); + } + return true; } function resetCustomerFilter(items) { const customerOptions = getCustomerOptions(items); - selectedCustomerKeys = new Set(customerOptions.map((customer) => customer.key)); - customerSearch = ""; + selectedCustomerKeys = hasSavedCustomerFilter + ? new Set(customerOptions + .map((customer) => customer.key) + .filter((key) => selectedCustomerKeys.has(key))) + : new Set(customerOptions.map((customer) => customer.key)); + } + + function resetOwnerFilter(items) { + const ownerOptions = getOwnerOptions(items); + selectedOwnerKeys = hasSavedOwnerFilter + ? new Set(ownerOptions + .map((owner) => owner.key) + .filter((key) => selectedOwnerKeys.has(key))) + : new Set(ownerOptions.map((owner) => owner.key)); } function renderCustomerFilter() { @@ -813,6 +1193,8 @@ selectedCustomerKeys.delete(customer.key); } + hasSavedCustomerFilter = true; + saveFilterPreferences(); renderCustomerFilter(); renderOpportunitiesTable(); }); @@ -854,6 +1236,26 @@ return partyName ? `name:${partyName}` : ""; } + function getOwnerName(item) { + const owner = item.Owner; + + if (typeof owner === "string" || typeof owner === "number") { + return owner; + } + + if (owner && typeof owner === "object") { + return owner.PartyName + || owner.PartyUniqueName + || owner.Name + || owner.DisplayName + || owner.ResourceName + || owner.OwnerName + || ""; + } + + return ""; + } + function getCustomerFilterSummary(total, selected) { if (total === 0) { return "No customers"; @@ -870,9 +1272,115 @@ return `${selected} customer${selected === 1 ? "" : "s"}`; } + function renderOwnerFilter() { + const trigger = document.getElementById(OWNER_FILTER_BUTTON_ID); + const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID); + const selectAllCheckbox = document.getElementById(OWNER_FILTER_SELECT_ALL_ID); + const ownerOptions = getOwnerOptions(); + + if (!trigger || !searchInput || !selectAllCheckbox) { + return; + } + + const selectedCount = ownerOptions.filter((owner) => selectedOwnerKeys.has(owner.key)).length; + trigger.disabled = ownerOptions.length === 0; + trigger.textContent = getCustomerFilterSummary(ownerOptions.length, selectedCount) + .replace("customer", "owner"); + searchInput.value = ownerSearch; + selectAllCheckbox.disabled = ownerOptions.length === 0; + selectAllCheckbox.checked = ownerOptions.length > 0 && selectedCount === ownerOptions.length; + selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < ownerOptions.length; + renderOwnerFilterList(); + } + + function renderOwnerFilterList() { + const list = document.getElementById(OWNER_FILTER_LIST_ID); + + if (!list) { + return; + } + + const normalizedSearch = ownerSearch.trim().toLocaleLowerCase(); + const ownerOptions = getOwnerOptions().filter((owner) => { + return owner.label.toLocaleLowerCase().includes(normalizedSearch); + }); + + list.textContent = ""; + + if (ownerOptions.length === 0) { + const empty = document.createElement("p"); + empty.className = "opportunities-extension-customer-filter-empty"; + empty.textContent = ownerSearch ? "No matching owners." : "No owners available."; + list.append(empty); + return; + } + + ownerOptions.forEach((owner) => { + const option = document.createElement("label"); + option.className = "opportunities-extension-customer-filter-option"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = selectedOwnerKeys.has(owner.key); + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + selectedOwnerKeys.add(owner.key); + } else { + selectedOwnerKeys.delete(owner.key); + } + + hasSavedOwnerFilter = true; + saveFilterPreferences(); + renderOwnerFilter(); + renderOpportunitiesTable(); + }); + + const label = document.createElement("span"); + label.textContent = owner.label; + option.append(checkbox, label); + list.append(option); + }); + } + + function getOwnerOptions(items) { + const optionsByKey = new Map(); + + (items || opportunitiesTableState.items).forEach((item) => { + const key = getOwnerKey(item); + const label = getOwnerName(item); + + if (key && label && !optionsByKey.has(key)) { + optionsByKey.set(key, { key, label: String(label) }); + } + }); + + return Array.from(optionsByKey.values()).sort((first, second) => { + return first.label.localeCompare(second.label, undefined, { + sensitivity: "base" + }); + }); + } + + function getOwnerKey(item) { + const owner = item.Owner; + const partyId = owner && typeof owner === "object" ? owner.PartyId : ""; + const name = getOwnerName(item); + + if (partyId !== null && partyId !== undefined && partyId !== "") { + return `party:${partyId}`; + } + + return name ? `name:${name}` : ""; + } + function getSortedOpportunities() { + const normalizedSearch = tableSearch.trim().toLocaleLowerCase(); const items = opportunitiesTableState.items.filter((item) => { - return selectedStages.has(item.ForecastGroup_c) && selectedCustomerKeys.has(getCustomerKey(item)); + return selectedStages.has(item.ForecastGroup_c) + && selectedStatuses.has(item.StatusCode) + && selectedCustomerKeys.has(getCustomerKey(item)) + && selectedOwnerKeys.has(getOwnerKey(item)) + && matchesTableSearch(item, normalizedSearch); }); const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.sortKey); @@ -910,6 +1418,30 @@ }); } + function matchesTableSearch(item, normalizedSearch) { + if (!normalizedSearch) { + return true; + } + + return OPPORTUNITIES_COLUMNS.some((column) => { + const rawValue = stringifySearchValue(column.value(item)); + const displayValue = stringifySearchValue(column.display(item)); + return `${rawValue} ${displayValue}`.toLocaleLowerCase().includes(normalizedSearch); + }); + } + + function stringifySearchValue(value) { + if (value === null || value === undefined) { + return ""; + } + + if (typeof value === "object") { + return JSON.stringify(value); + } + + return String(value); + } + function displayOpportunityValue(column, item) { const value = column.display(item); return value === null || value === undefined || value === "" ? "-" : String(value); @@ -946,6 +1478,16 @@ return badge; } + function createStatusBadge(status) { + const badge = document.createElement("span"); + const normalizedStatus = typeof status === "string" ? status.toUpperCase() : ""; + + badge.className = "opportunities-extension-status-badge"; + badge.setAttribute("data-status", normalizedStatus); + badge.textContent = normalizedStatus || "-"; + return badge; + } + function getNestedValue(value, path) { return path.reduce((result, key) => result && result[key], value); } @@ -1033,6 +1575,7 @@ "EffectiveDate", "StatusCode", "DealRisk_c", + "Owner", "ForecastGroup_c", "LastUpdateDate", "PrimaryRevenue.RevnAmountCurcyCode", @@ -1047,6 +1590,7 @@ "EffectiveDate", "StatusCode", "DealRisk_c", + "Owner", "ForecastGroup_c", "LastUpdateDate", "PrimaryRevenue.RevnAmountCurcyCode", @@ -1072,7 +1616,10 @@ const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3); if (period === "Current Fiscal Year") { - return createDateRange(fiscalStartYear, 5, fiscalStartYear + 1, 4); + const isBeforeFiscalYearStart = currentMonth < 5; + const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear; + const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1; + return createDateRange(startYear, 5, endYear, 4); } let quarterOffset; @@ -1272,19 +1819,28 @@ } requestLog.forEach((entry) => { - const item = document.createElement("article"); + const item = document.createElement("details"); item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`; - const heading = document.createElement("div"); + const heading = document.createElement("summary"); heading.className = "opportunities-extension-debug-heading"; const name = document.createElement("strong"); name.textContent = `${entry.id}. ${entry.name}`; + const httpStatus = document.createElement("span"); + httpStatus.className = "opportunities-extension-debug-http-status"; + httpStatus.textContent = entry.httpStatus ? String(entry.httpStatus) : "pending"; + const status = document.createElement("span"); + status.className = "opportunities-extension-debug-status"; status.textContent = entry.status; - heading.append(name, status); + const summaryMeta = document.createElement("span"); + summaryMeta.className = "opportunities-extension-debug-summary-meta"; + summaryMeta.append(httpStatus, status); + + heading.append(name, summaryMeta); const lines = [ `${entry.method} ${entry.url}`, @@ -1463,9 +2019,40 @@ return; } + if (closeOwnerFilterPanel()) { + event.preventDefault(); + return; + } + closeOpportunitiesModal(); } }); + + document.addEventListener("pointerdown", (event) => { + const modal = document.getElementById(MODAL_ID); + const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); + const ownerPanel = document.getElementById(OWNER_FILTER_PANEL_ID); + + if (!modal || modal.hidden) { + return; + } + + if (panel && !panel.hidden) { + const customerFilter = panel.closest(".opportunities-extension-customer-filter"); + + if (customerFilter && !customerFilter.contains(event.target)) { + closeCustomerFilterPanel(false); + } + } + + if (ownerPanel && !ownerPanel.hidden) { + const ownerFilter = ownerPanel.closest(".opportunities-extension-customer-filter"); + + if (ownerFilter && !ownerFilter.contains(event.target)) { + closeOwnerFilterPanel(false); + } + } + }); } function ensureExtensionStyles() { @@ -1601,7 +2188,7 @@ .opportunities-extension-body { display: grid; min-height: 0; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr); gap: 16px; padding: 22px 40px 36px; background: #f5f4f2; @@ -1611,6 +2198,7 @@ .opportunities-extension-form { display: flex; align-items: end; + flex-wrap: wrap; gap: 24px; min-height: 82px; max-width: none; @@ -1623,8 +2211,9 @@ .opportunities-extension-field { display: grid; - max-width: 360px; - width: 360px; + max-width: max-content; + min-width: 270px; + width: max-content; gap: 6px; color: #312d2a; font-size: 13px; @@ -1632,6 +2221,60 @@ line-height: 1.3; } + .opportunities-extension-stage-dashboard { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + } + + .opportunities-extension-stage-dashboard-card { + display: grid; + gap: 6px; + min-width: 0; + padding: 12px 16px; + border: 1px solid #dedbd7; + border-top: 3px solid #5f5a55; + border-radius: 4px; + background: #ffffff; + } + + .opportunities-extension-stage-dashboard-card span { + color: #5f5a55; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + } + + .opportunities-extension-stage-dashboard-value { + overflow: hidden; + color: #312d2a; + font-size: 22px; + font-weight: 700; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; + } + + .opportunities-extension-stage-dashboard-card[data-stage="SQL"] { + border-top-color: #008aa6; + } + + .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { + border-top-color: #6f6863; + } + + .opportunities-extension-stage-dashboard-card[data-stage="UPSIDE"] { + border-top-color: #c87b00; + } + + .opportunities-extension-stage-dashboard-card[data-stage="FORECAST"] { + border-top-color: #755b8b; + } + + .opportunities-extension-stage-dashboard-card[data-stage="WON"] { + border-top-color: #4b7f22; + } + .opportunities-extension-stage-filter { display: grid; grid-template-rows: auto 44px; @@ -1722,6 +2365,85 @@ outline-offset: 2px; } + .opportunities-extension-status-filter { + display: grid; + grid-template-rows: auto 44px; + gap: 6px; + min-width: 0; + } + + .opportunities-extension-status-filter-label { + color: #312d2a; + font-size: 13px; + font-weight: 600; + line-height: 1.3; + } + + .opportunities-extension-status-filter-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + } + + .opportunities-extension-status-filter-button { + min-height: 36px; + padding: 6px 12px; + border: 1px solid currentColor; + border-radius: 18px; + background: #ffffff; + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + line-height: 1.2; + cursor: pointer; + } + + .opportunities-extension-status-filter-button[data-status="OPEN"] { + color: #197a3d; + } + + .opportunities-extension-status-filter-button[data-status="CLOSED"] { + color: #4b5563; + } + + .opportunities-extension-status-filter-button[data-status="LOST"] { + color: #b13b34; + } + + .opportunities-extension-status-filter-button[data-status="WON"] { + color: #1f5f99; + } + + .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="OPEN"] { + border-color: #197a3d; + background: #197a3d; + color: #ffffff; + } + + .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="CLOSED"] { + border-color: #4b5563; + background: #4b5563; + color: #ffffff; + } + + .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="LOST"] { + border-color: #b13b34; + background: #b13b34; + color: #ffffff; + } + + .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="WON"] { + border-color: #1f5f99; + background: #1f5f99; + color: #ffffff; + } + + .opportunities-extension-status-filter-button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 2px; + } + .opportunities-extension-customer-filter { position: relative; display: grid; @@ -1876,9 +2598,40 @@ font-size: 13px; } + .opportunities-extension-table-search-field { + display: grid; + grid-template-rows: auto 44px; + gap: 6px; + width: 260px; + color: #312d2a; + font-size: 13px; + font-weight: 600; + line-height: 1.3; + } + + .opportunities-extension-table-search-field input { + width: 100%; + min-height: 44px; + padding: 10px 12px; + border: 1px solid #b8b2ad; + border-radius: 3px; + background: #ffffff; + color: #312d2a; + font-size: 14px; + line-height: 1.3; + } + + .opportunities-extension-table-search-field input:focus { + border-color: #312d2a; + box-shadow: 0 0 0 1px #312d2a; + outline: none; + } + .opportunities-extension-select-wrap { position: relative; display: block; + width: 270px; + max-width: 100%; } .opportunities-extension-select-wrap::after { @@ -1897,7 +2650,10 @@ .opportunities-extension-field select { width: 100%; + min-width: 0; + max-width: 100%; min-height: 44px; + box-sizing: border-box; appearance: none; border: 1px solid #b8b2ad; border-radius: 3px; @@ -1960,27 +2716,27 @@ } .opportunities-extension-table th:nth-child(1) { - width: 17%; + width: 14%; } .opportunities-extension-table th:nth-child(2) { - width: 9%; + width: 8%; } .opportunities-extension-table th:nth-child(3) { - width: 10%; + width: 8%; } .opportunities-extension-table th:nth-child(4) { - width: 21%; + width: 17%; } .opportunities-extension-table th:nth-child(5) { - width: 10%; + width: 12%; } .opportunities-extension-table th:nth-child(6) { - width: 10%; + width: 9%; } .opportunities-extension-table th:nth-child(7) { @@ -1988,20 +2744,24 @@ } .opportunities-extension-table th:nth-child(8) { - width: 7%; + width: 8%; } .opportunities-extension-table th:nth-child(9) { - width: 12%; + width: 7%; + } + + .opportunities-extension-table th:nth-child(10) { + width: 8%; } .opportunities-extension-table td:nth-child(2), .opportunities-extension-table td:nth-child(3), - .opportunities-extension-table td:nth-child(5), .opportunities-extension-table td:nth-child(6), .opportunities-extension-table td:nth-child(7), .opportunities-extension-table td:nth-child(8), - .opportunities-extension-table td:nth-child(9) { + .opportunities-extension-table td:nth-child(9), + .opportunities-extension-table td:nth-child(10) { white-space: nowrap; } @@ -2063,6 +2823,40 @@ color: #3f6f17; } + .opportunities-extension-status-badge { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 8px; + border-radius: 12px; + background: #ebe8e5; + color: #312d2a; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; + } + + .opportunities-extension-status-badge[data-status="OPEN"] { + background: #e2f2e5; + color: #197a3d; + } + + .opportunities-extension-status-badge[data-status="CLOSED"] { + background: #e5e7eb; + color: #4b5563; + } + + .opportunities-extension-status-badge[data-status="LOST"] { + background: #f9e3e1; + color: #b13b34; + } + + .opportunities-extension-status-badge[data-status="WON"] { + background: #e3edf8; + color: #1f5f99; + } + .opportunities-extension-sort-button { position: relative; display: inline-flex; @@ -2172,37 +2966,100 @@ background: #faf9f8; } + .opportunities-extension-debug-item[open] { + background: #ffffff; + } + .opportunities-extension-debug-item-success { border-left-color: #3f6f17; } + .opportunities-extension-debug-item-success .opportunities-extension-debug-status { + background: #e2f2e5; + color: #356d19; + } + .opportunities-extension-debug-item-failed { border-left-color: #c5331f; } + .opportunities-extension-debug-item-failed .opportunities-extension-debug-status { + background: #f9e3e1; + color: #a52b1c; + } + .opportunities-extension-debug-item-pending { border-left-color: #6f5a7f; } + .opportunities-extension-debug-item-pending .opportunities-extension-debug-status { + background: #eee8f2; + color: #6f5a7f; + } + .opportunities-extension-debug-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; - padding: 10px 12px 0; + min-height: 42px; + padding: 9px 12px 9px 10px; + cursor: pointer; + list-style: none; + } + + .opportunities-extension-debug-heading::-webkit-details-marker { + display: none; + } + + .opportunities-extension-debug-heading::before { + content: "⌄"; + width: 14px; + color: #5f5a55; + font-size: 16px; + line-height: 1; + transform: rotate(-90deg); + transition: transform .15s ease; + } + + .opportunities-extension-debug-item[open] .opportunities-extension-debug-heading::before { + transform: rotate(0deg); + } + + .opportunities-extension-debug-summary-meta { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: auto; + } + + .opportunities-extension-debug-http-status, + .opportunities-extension-debug-status { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + line-height: 1; + text-transform: uppercase; + } + + .opportunities-extension-debug-http-status { + background: #e8f4f6; + color: #006d7a; + } + + .opportunities-extension-debug-status { + background: #ebe8e5; + color: #5f5a55; } .opportunities-extension-debug-heading strong { font-size: 13px; } - .opportunities-extension-debug-heading span { - color: #312d2a; - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - } - .opportunities-extension-debug-item pre { margin: 0; padding: 8px 12px 12px; @@ -2268,10 +3125,26 @@ width: min(360px, calc(100vw - 64px)); } + .opportunities-extension-table-search-field { + width: 100%; + } + + .opportunities-extension-stage-dashboard { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .opportunities-extension-stage-dashboard-value { + font-size: 18px; + } + .opportunities-extension-stage-filter-controls { gap: 6px; } + .opportunities-extension-status-filter-controls { + gap: 6px; + } + .opportunities-extension-table-surface { min-width: 0; min-height: calc(100vh - 230px); diff --git a/src/content.js b/src/content.js index 8416ed8..a137352 100644 --- a/src/content.js +++ b/src/content.js @@ -11,11 +11,20 @@ const DEBUG_PANEL_ID = "opportunities-extension-debug-panel"; const OPPORTUNITIES_TABLE_ID = "opportunities-extension-table"; const STAGE_FILTERS_ID = "opportunities-extension-stage-filters"; + const STATUS_FILTERS_ID = "opportunities-extension-status-filters"; const CUSTOMER_FILTER_BUTTON_ID = "opportunities-extension-customer-filter-button"; const CUSTOMER_FILTER_PANEL_ID = "opportunities-extension-customer-filter-panel"; const CUSTOMER_FILTER_SEARCH_ID = "opportunities-extension-customer-filter-search"; const CUSTOMER_FILTER_SELECT_ALL_ID = "opportunities-extension-customer-filter-select-all"; const CUSTOMER_FILTER_LIST_ID = "opportunities-extension-customer-filter-list"; + const OWNER_FILTER_BUTTON_ID = "opportunities-extension-owner-filter-button"; + const OWNER_FILTER_PANEL_ID = "opportunities-extension-owner-filter-panel"; + const OWNER_FILTER_SEARCH_ID = "opportunities-extension-owner-filter-search"; + const OWNER_FILTER_SELECT_ALL_ID = "opportunities-extension-owner-filter-select-all"; + const OWNER_FILTER_LIST_ID = "opportunities-extension-owner-filter-list"; + const TABLE_SEARCH_ID = "opportunities-extension-table-search"; + const STAGE_DASHBOARD_ID = "opportunities-extension-stage-dashboard"; + const FILTER_PREFERENCES_STORAGE_KEY = "opportunities-extension-filter-preferences"; const OPPORTUNITY_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid="; const ACCOUNT_DETAIL_URL = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/accounts/accounts-detail?id="; const DEBUG_BODY_LIMIT = 12000; @@ -29,14 +38,22 @@ unauthenticated: "unauthenticated" }; const STAGE_OPTIONS = ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"]; + const STATUS_OPTIONS = ["OPEN", "CLOSED", "LOST", "WON"]; let tokenRelayRequest = null; let accessToken = ""; let periodRequestVersion = 0; let authStatus = AUTH_STATUS.idle; let requestLog = []; let selectedStages = new Set(STAGE_OPTIONS); + let selectedStatuses = new Set(STATUS_OPTIONS); let selectedCustomerKeys = new Set(); let customerSearch = ""; + let selectedOwnerKeys = new Set(); + let ownerSearch = ""; + let tableSearch = ""; + let hasSavedCustomerFilter = false; + let hasSavedOwnerFilter = false; + let stageDashboardAmounts = new Map(STAGE_OPTIONS.map((stage) => [stage, 0])); let opportunitiesTableState = { items: [], status: "idle", @@ -57,7 +74,8 @@ { key: "optyNumber", label: "Opty Number", value: (item) => item.OptyNumber, display: (item) => item.OptyNumber }, { key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability }, { key: "customer", label: "Customer", value: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]), display: (item) => getNestedValue(item, ["CustomerAccount", "PartyUniqueName"]) }, - { key: "revenue", label: "Revenue", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue }, + { key: "owner", label: "Owner", value: getOwnerName, display: getOwnerName }, + { key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue }, { key: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) }, { key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c }, { key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode }, @@ -223,11 +241,13 @@ }); select.addEventListener("change", () => { + saveFilterPreferences(select.value); requestOpportunitiesForPeriod(select.value); }); selectWrap.append(select); field.append(labelText, selectWrap); + restoreFilterPreferences(select); const stageFilter = document.createElement("section"); stageFilter.className = "opportunities-extension-stage-filter"; @@ -272,6 +292,7 @@ customerSearchInput.setAttribute("aria-label", "Search customers"); customerSearchInput.addEventListener("input", () => { customerSearch = customerSearchInput.value; + saveFilterPreferences(); renderCustomerFilterList(); }); @@ -286,6 +307,8 @@ selectedCustomerKeys = selectAllCheckbox.checked ? new Set(customerOptions.map((customer) => customer.key)) : new Set(); + hasSavedCustomerFilter = true; + saveFilterPreferences(); renderCustomerFilter(); renderOpportunitiesTable(); }); @@ -300,9 +323,110 @@ customerFilterPanel.append(customerSearchInput, selectAllLabel, customerFilterList); customerFilter.append(customerFilterLabel, customerFilterButton, customerFilterPanel); - form.append(field, stageFilter, customerFilter); + + const ownerFilter = document.createElement("section"); + ownerFilter.className = "opportunities-extension-customer-filter"; + + const ownerFilterLabel = document.createElement("span"); + ownerFilterLabel.className = "opportunities-extension-customer-filter-label"; + ownerFilterLabel.textContent = "Owner"; + + const ownerFilterButton = document.createElement("button"); + ownerFilterButton.id = OWNER_FILTER_BUTTON_ID; + ownerFilterButton.type = "button"; + ownerFilterButton.className = "opportunities-extension-customer-filter-trigger"; + ownerFilterButton.setAttribute("aria-expanded", "false"); + ownerFilterButton.setAttribute("aria-controls", OWNER_FILTER_PANEL_ID); + ownerFilterButton.addEventListener("click", toggleOwnerFilterPanel); + + const ownerFilterPanel = document.createElement("section"); + ownerFilterPanel.id = OWNER_FILTER_PANEL_ID; + ownerFilterPanel.className = "opportunities-extension-customer-filter-panel"; + ownerFilterPanel.hidden = true; + + const ownerSearchInput = document.createElement("input"); + ownerSearchInput.id = OWNER_FILTER_SEARCH_ID; + ownerSearchInput.className = "opportunities-extension-customer-filter-search"; + ownerSearchInput.type = "search"; + ownerSearchInput.placeholder = "Search owners"; + ownerSearchInput.setAttribute("aria-label", "Search owners"); + ownerSearchInput.addEventListener("input", () => { + ownerSearch = ownerSearchInput.value; + saveFilterPreferences(); + renderOwnerFilterList(); + }); + + const ownerSelectAllLabel = document.createElement("label"); + ownerSelectAllLabel.className = "opportunities-extension-customer-filter-select-all"; + + const ownerSelectAllCheckbox = document.createElement("input"); + ownerSelectAllCheckbox.id = OWNER_FILTER_SELECT_ALL_ID; + ownerSelectAllCheckbox.type = "checkbox"; + ownerSelectAllCheckbox.addEventListener("change", () => { + const ownerOptions = getOwnerOptions(); + selectedOwnerKeys = ownerSelectAllCheckbox.checked + ? new Set(ownerOptions.map((owner) => owner.key)) + : new Set(); + hasSavedOwnerFilter = true; + saveFilterPreferences(); + renderOwnerFilter(); + renderOpportunitiesTable(); + }); + + const ownerSelectAllText = document.createElement("span"); + ownerSelectAllText.textContent = "Select all"; + ownerSelectAllLabel.append(ownerSelectAllCheckbox, ownerSelectAllText); + + const ownerFilterList = document.createElement("div"); + ownerFilterList.id = OWNER_FILTER_LIST_ID; + ownerFilterList.className = "opportunities-extension-customer-filter-list"; + + ownerFilterPanel.append(ownerSearchInput, ownerSelectAllLabel, ownerFilterList); + ownerFilter.append(ownerFilterLabel, ownerFilterButton, ownerFilterPanel); + + const tableSearchField = document.createElement("label"); + tableSearchField.className = "opportunities-extension-table-search-field"; + + const tableSearchLabel = document.createElement("span"); + tableSearchLabel.textContent = "Search"; + + const tableSearchInput = document.createElement("input"); + tableSearchInput.id = TABLE_SEARCH_ID; + tableSearchInput.type = "search"; + tableSearchInput.placeholder = "Search opportunities"; + tableSearchInput.setAttribute("aria-label", "Search opportunities"); + tableSearchInput.addEventListener("input", () => { + tableSearch = tableSearchInput.value; + saveFilterPreferences(); + renderOpportunitiesTable(); + }); + + tableSearchField.append(tableSearchLabel, tableSearchInput); + + const statusFilter = document.createElement("section"); + statusFilter.className = "opportunities-extension-status-filter"; + + const statusFilterLabel = document.createElement("span"); + statusFilterLabel.className = "opportunities-extension-status-filter-label"; + statusFilterLabel.textContent = "Status"; + + const statusFilters = document.createElement("div"); + statusFilters.id = STATUS_FILTERS_ID; + statusFilters.className = "opportunities-extension-status-filter-controls"; + statusFilters.setAttribute("role", "group"); + statusFilters.setAttribute("aria-label", "Filter by status"); + + statusFilter.append(statusFilterLabel, statusFilters); + form.addEventListener("submit", (event) => event.preventDefault()); + form.append(field, customerFilter, ownerFilter, tableSearchField, statusFilter, stageFilter); body.append(form); + const stageDashboard = document.createElement("section"); + stageDashboard.id = STAGE_DASHBOARD_ID; + stageDashboard.className = "opportunities-extension-stage-dashboard"; + stageDashboard.setAttribute("aria-label", "Opportunity amount by stage"); + body.append(stageDashboard); + const tableSurface = document.createElement("section"); tableSurface.className = "opportunities-extension-table-surface"; @@ -326,7 +450,9 @@ document.documentElement.classList.add("opportunities-extension-scroll-lock"); ensureEscapeKeyHandler(); renderStageFilterButtons(); + renderStatusFilterButtons(); renderCustomerFilter(); + renderOwnerFilter(); renderOpportunitiesTable(); requestOpportunitiesForPeriod(select.value); select.focus(); @@ -568,8 +694,15 @@ function resetOpportunitiesTable() { selectedStages = new Set(STAGE_OPTIONS); + selectedStatuses = new Set(STATUS_OPTIONS); selectedCustomerKeys = new Set(); customerSearch = ""; + selectedOwnerKeys = new Set(); + ownerSearch = ""; + tableSearch = ""; + hasSavedCustomerFilter = false; + hasSavedOwnerFilter = false; + stageDashboardAmounts = new Map(STAGE_OPTIONS.map((stage) => [stage, 0])); opportunitiesTableState = { items: [], status: "idle", @@ -579,9 +712,74 @@ }; } + function restoreFilterPreferences(select) { + try { + const rawPreferences = localStorage.getItem(FILTER_PREFERENCES_STORAGE_KEY); + + if (!rawPreferences) { + return; + } + + const preferences = JSON.parse(rawPreferences); + const savedStages = Array.isArray(preferences.stages) + ? preferences.stages.filter((stage) => STAGE_OPTIONS.includes(stage)) + : null; + const savedStatuses = Array.isArray(preferences.statuses) + ? preferences.statuses.filter((status) => STATUS_OPTIONS.includes(status)) + : null; + + if (savedStages) { + selectedStages = new Set(savedStages); + } + + if (savedStatuses) { + selectedStatuses = new Set(savedStatuses); + } + + if (Array.isArray(preferences.customerKeys)) { + selectedCustomerKeys = new Set(preferences.customerKeys.map(String)); + hasSavedCustomerFilter = true; + } + + if (Array.isArray(preferences.ownerKeys)) { + selectedOwnerKeys = new Set(preferences.ownerKeys.map(String)); + hasSavedOwnerFilter = true; + } + + customerSearch = typeof preferences.customerSearch === "string" ? preferences.customerSearch : ""; + ownerSearch = typeof preferences.ownerSearch === "string" ? preferences.ownerSearch : ""; + tableSearch = typeof preferences.tableSearch === "string" ? preferences.tableSearch : ""; + + if (PERIOD_OPTIONS.includes(preferences.period)) { + select.value = preferences.period; + } + } catch (error) { + // Ignore malformed or unavailable browser storage and use default filters. + } + } + + function saveFilterPreferences(period) { + try { + const currentPeriod = period || document.querySelector(`#${MODAL_ID} select`)?.value || PERIOD_OPTIONS[0]; + localStorage.setItem(FILTER_PREFERENCES_STORAGE_KEY, JSON.stringify({ + period: currentPeriod, + stages: Array.from(selectedStages), + statuses: Array.from(selectedStatuses), + customerKeys: hasSavedCustomerFilter ? Array.from(selectedCustomerKeys) : null, + ownerKeys: hasSavedOwnerFilter ? Array.from(selectedOwnerKeys) : null, + customerSearch, + ownerSearch, + tableSearch + })); + } catch (error) { + // Ignore unavailable browser storage; filters still work for the current modal. + } + } + function setOpportunitiesTableState(nextState) { if (nextState.status === "ready" && Array.isArray(nextState.items)) { resetCustomerFilter(nextState.items); + resetOwnerFilter(nextState.items); } opportunitiesTableState = { @@ -592,6 +790,7 @@ }; renderOpportunitiesTable(); renderCustomerFilter(); + renderOwnerFilter(); } function renderOpportunitiesTable() { @@ -633,6 +832,7 @@ const tableBody = document.createElement("tbody"); const items = getSortedOpportunities(); + renderStageDashboard(items); if (opportunitiesTableState.status === "loading" || opportunitiesTableState.status === "error" || opportunitiesTableState.status === "empty" || (opportunitiesTableState.status === "ready" && items.length === 0)) { const row = document.createElement("tr"); @@ -651,6 +851,8 @@ if (column.key === "stage") { cell.append(createStageBadge(column.display(item))); + } else if (column.key === "status") { + cell.append(createStatusBadge(column.display(item))); } else if (column.key === "optyNumber") { cell.append(createDetailLink( column.display(item), @@ -677,6 +879,97 @@ table.append(caption, tableHead, tableBody); } + function renderStageDashboard(items) { + const dashboard = document.getElementById(STAGE_DASHBOARD_ID); + + if (!dashboard) { + return; + } + + dashboard.textContent = ""; + + STAGE_OPTIONS.forEach((stage) => { + const stageItems = items.filter((item) => item.ForecastGroup_c === stage); + const amount = stageItems.reduce((total, item) => { + const value = Number(getNestedValue(item, ["PrimaryRevenue", "RevnAmount"])); + return Number.isFinite(value) ? total + value : total; + }, 0); + const card = document.createElement("section"); + const label = document.createElement("span"); + const value = document.createElement("strong"); + + card.className = "opportunities-extension-stage-dashboard-card"; + card.setAttribute("data-stage", stage); + label.textContent = stage; + value.className = "opportunities-extension-stage-dashboard-value"; + value.setAttribute("aria-label", `${stage} total amount`); + + animateStageDashboardValue( + value, + stage, + amount, + createStageAmountFormatter(stageItems) + ); + + card.append(label, value); + dashboard.append(card); + }); + } + + function createStageAmountFormatter(items) { + const currencies = new Set(items + .map((item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmountCurcyCode"])) + .filter(Boolean)); + + if (currencies.size === 1) { + const [currency] = currencies; + + try { + const formatter = new Intl.NumberFormat(undefined, { + style: "currency", + currency, + maximumFractionDigits: 0 + }); + return (amount) => formatter.format(amount); + } catch (error) { + // Fall through to a neutral numeric total when the currency code is invalid. + } + } + + return (amount) => amount.toLocaleString(undefined, { + maximumFractionDigits: 0 + }); + } + + function animateStageDashboardValue(element, stage, target, format) { + const current = stageDashboardAmounts.get(stage) || 0; + stageDashboardAmounts.set(stage, target); + + if (current === target) { + element.textContent = format(target); + return; + } + + const startTime = performance.now(); + const duration = 480; + + function tick(now) { + const progress = Math.min((now - startTime) / duration, 1); + const easedProgress = 1 - Math.pow(1 - progress, 3); + const value = current + ((target - current) * easedProgress); + + element.textContent = format(value); + + if (progress < 1) { + requestAnimationFrame(tick); + } else { + element.textContent = format(target); + } + } + + requestAnimationFrame(tick); + } + function sortOpportunitiesBy(key) { const isSameColumn = opportunitiesTableState.sortKey === key; opportunitiesTableState.sortKey = key; @@ -716,10 +1009,46 @@ selectedStages.add(stage); } + saveFilterPreferences(); renderStageFilterButtons(); renderOpportunitiesTable(); } + function renderStatusFilterButtons() { + const controls = document.getElementById(STATUS_FILTERS_ID); + + if (!controls) { + return; + } + + controls.textContent = ""; + + STATUS_OPTIONS.forEach((status) => { + const button = document.createElement("button"); + const isPressed = selectedStatuses.has(status); + + button.type = "button"; + button.className = "opportunities-extension-status-filter-button"; + button.setAttribute("data-status", status); + button.setAttribute("aria-pressed", isPressed ? "true" : "false"); + button.textContent = status; + button.addEventListener("click", () => toggleStatusFilter(status)); + controls.append(button); + }); + } + + function toggleStatusFilter(status) { + if (selectedStatuses.has(status)) { + selectedStatuses.delete(status); + } else { + selectedStatuses.add(status); + } + + saveFilterPreferences(); + renderStatusFilterButtons(); + renderOpportunitiesTable(); + } + function toggleCustomerFilterPanel() { const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID); @@ -737,7 +1066,7 @@ } } - function closeCustomerFilterPanel() { + function closeCustomerFilterPanel(restoreFocus = true) { const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); const button = document.getElementById(CUSTOMER_FILTER_BUTTON_ID); @@ -747,14 +1076,65 @@ panel.hidden = true; button.setAttribute("aria-expanded", "false"); - button.focus(); + + if (restoreFocus) { + button.focus(); + } + + return true; + } + + function toggleOwnerFilterPanel() { + const panel = document.getElementById(OWNER_FILTER_PANEL_ID); + const button = document.getElementById(OWNER_FILTER_BUTTON_ID); + + if (!panel || !button || button.disabled) { + return; + } + + panel.hidden = !panel.hidden; + button.setAttribute("aria-expanded", panel.hidden ? "false" : "true"); + + if (!panel.hidden) { + const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID); + searchInput.focus(); + } + } + + function closeOwnerFilterPanel(restoreFocus = true) { + const panel = document.getElementById(OWNER_FILTER_PANEL_ID); + const button = document.getElementById(OWNER_FILTER_BUTTON_ID); + + if (!panel || panel.hidden) { + return false; + } + + panel.hidden = true; + button.setAttribute("aria-expanded", "false"); + + if (restoreFocus) { + button.focus(); + } + return true; } function resetCustomerFilter(items) { const customerOptions = getCustomerOptions(items); - selectedCustomerKeys = new Set(customerOptions.map((customer) => customer.key)); - customerSearch = ""; + selectedCustomerKeys = hasSavedCustomerFilter + ? new Set(customerOptions + .map((customer) => customer.key) + .filter((key) => selectedCustomerKeys.has(key))) + : new Set(customerOptions.map((customer) => customer.key)); + } + + function resetOwnerFilter(items) { + const ownerOptions = getOwnerOptions(items); + selectedOwnerKeys = hasSavedOwnerFilter + ? new Set(ownerOptions + .map((owner) => owner.key) + .filter((key) => selectedOwnerKeys.has(key))) + : new Set(ownerOptions.map((owner) => owner.key)); } function renderCustomerFilter() { @@ -813,6 +1193,8 @@ selectedCustomerKeys.delete(customer.key); } + hasSavedCustomerFilter = true; + saveFilterPreferences(); renderCustomerFilter(); renderOpportunitiesTable(); }); @@ -854,6 +1236,26 @@ return partyName ? `name:${partyName}` : ""; } + function getOwnerName(item) { + const owner = item.Owner; + + if (typeof owner === "string" || typeof owner === "number") { + return owner; + } + + if (owner && typeof owner === "object") { + return owner.PartyName + || owner.PartyUniqueName + || owner.Name + || owner.DisplayName + || owner.ResourceName + || owner.OwnerName + || ""; + } + + return ""; + } + function getCustomerFilterSummary(total, selected) { if (total === 0) { return "No customers"; @@ -870,9 +1272,115 @@ return `${selected} customer${selected === 1 ? "" : "s"}`; } + function renderOwnerFilter() { + const trigger = document.getElementById(OWNER_FILTER_BUTTON_ID); + const searchInput = document.getElementById(OWNER_FILTER_SEARCH_ID); + const selectAllCheckbox = document.getElementById(OWNER_FILTER_SELECT_ALL_ID); + const ownerOptions = getOwnerOptions(); + + if (!trigger || !searchInput || !selectAllCheckbox) { + return; + } + + const selectedCount = ownerOptions.filter((owner) => selectedOwnerKeys.has(owner.key)).length; + trigger.disabled = ownerOptions.length === 0; + trigger.textContent = getCustomerFilterSummary(ownerOptions.length, selectedCount) + .replace("customer", "owner"); + searchInput.value = ownerSearch; + selectAllCheckbox.disabled = ownerOptions.length === 0; + selectAllCheckbox.checked = ownerOptions.length > 0 && selectedCount === ownerOptions.length; + selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < ownerOptions.length; + renderOwnerFilterList(); + } + + function renderOwnerFilterList() { + const list = document.getElementById(OWNER_FILTER_LIST_ID); + + if (!list) { + return; + } + + const normalizedSearch = ownerSearch.trim().toLocaleLowerCase(); + const ownerOptions = getOwnerOptions().filter((owner) => { + return owner.label.toLocaleLowerCase().includes(normalizedSearch); + }); + + list.textContent = ""; + + if (ownerOptions.length === 0) { + const empty = document.createElement("p"); + empty.className = "opportunities-extension-customer-filter-empty"; + empty.textContent = ownerSearch ? "No matching owners." : "No owners available."; + list.append(empty); + return; + } + + ownerOptions.forEach((owner) => { + const option = document.createElement("label"); + option.className = "opportunities-extension-customer-filter-option"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = selectedOwnerKeys.has(owner.key); + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + selectedOwnerKeys.add(owner.key); + } else { + selectedOwnerKeys.delete(owner.key); + } + + hasSavedOwnerFilter = true; + saveFilterPreferences(); + renderOwnerFilter(); + renderOpportunitiesTable(); + }); + + const label = document.createElement("span"); + label.textContent = owner.label; + option.append(checkbox, label); + list.append(option); + }); + } + + function getOwnerOptions(items) { + const optionsByKey = new Map(); + + (items || opportunitiesTableState.items).forEach((item) => { + const key = getOwnerKey(item); + const label = getOwnerName(item); + + if (key && label && !optionsByKey.has(key)) { + optionsByKey.set(key, { key, label: String(label) }); + } + }); + + return Array.from(optionsByKey.values()).sort((first, second) => { + return first.label.localeCompare(second.label, undefined, { + sensitivity: "base" + }); + }); + } + + function getOwnerKey(item) { + const owner = item.Owner; + const partyId = owner && typeof owner === "object" ? owner.PartyId : ""; + const name = getOwnerName(item); + + if (partyId !== null && partyId !== undefined && partyId !== "") { + return `party:${partyId}`; + } + + return name ? `name:${name}` : ""; + } + function getSortedOpportunities() { + const normalizedSearch = tableSearch.trim().toLocaleLowerCase(); const items = opportunitiesTableState.items.filter((item) => { - return selectedStages.has(item.ForecastGroup_c) && selectedCustomerKeys.has(getCustomerKey(item)); + return selectedStages.has(item.ForecastGroup_c) + && selectedStatuses.has(item.StatusCode) + && selectedCustomerKeys.has(getCustomerKey(item)) + && selectedOwnerKeys.has(getOwnerKey(item)) + && matchesTableSearch(item, normalizedSearch); }); const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.sortKey); @@ -910,6 +1418,30 @@ }); } + function matchesTableSearch(item, normalizedSearch) { + if (!normalizedSearch) { + return true; + } + + return OPPORTUNITIES_COLUMNS.some((column) => { + const rawValue = stringifySearchValue(column.value(item)); + const displayValue = stringifySearchValue(column.display(item)); + return `${rawValue} ${displayValue}`.toLocaleLowerCase().includes(normalizedSearch); + }); + } + + function stringifySearchValue(value) { + if (value === null || value === undefined) { + return ""; + } + + if (typeof value === "object") { + return JSON.stringify(value); + } + + return String(value); + } + function displayOpportunityValue(column, item) { const value = column.display(item); return value === null || value === undefined || value === "" ? "-" : String(value); @@ -946,6 +1478,16 @@ return badge; } + function createStatusBadge(status) { + const badge = document.createElement("span"); + const normalizedStatus = typeof status === "string" ? status.toUpperCase() : ""; + + badge.className = "opportunities-extension-status-badge"; + badge.setAttribute("data-status", normalizedStatus); + badge.textContent = normalizedStatus || "-"; + return badge; + } + function getNestedValue(value, path) { return path.reduce((result, key) => result && result[key], value); } @@ -1033,6 +1575,7 @@ "EffectiveDate", "StatusCode", "DealRisk_c", + "Owner", "ForecastGroup_c", "LastUpdateDate", "PrimaryRevenue.RevnAmountCurcyCode", @@ -1047,6 +1590,7 @@ "EffectiveDate", "StatusCode", "DealRisk_c", + "Owner", "ForecastGroup_c", "LastUpdateDate", "PrimaryRevenue.RevnAmountCurcyCode", @@ -1072,7 +1616,10 @@ const currentQuarterIndex = Math.floor(((currentMonth - 5 + 12) % 12) / 3); if (period === "Current Fiscal Year") { - return createDateRange(fiscalStartYear, 5, fiscalStartYear + 1, 4); + const isBeforeFiscalYearStart = currentMonth < 5; + const startYear = isBeforeFiscalYearStart ? currentYear - 1 : currentYear; + const endYear = isBeforeFiscalYearStart ? currentYear : currentYear + 1; + return createDateRange(startYear, 5, endYear, 4); } let quarterOffset; @@ -1272,19 +1819,28 @@ } requestLog.forEach((entry) => { - const item = document.createElement("article"); + const item = document.createElement("details"); item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`; - const heading = document.createElement("div"); + const heading = document.createElement("summary"); heading.className = "opportunities-extension-debug-heading"; const name = document.createElement("strong"); name.textContent = `${entry.id}. ${entry.name}`; + const httpStatus = document.createElement("span"); + httpStatus.className = "opportunities-extension-debug-http-status"; + httpStatus.textContent = entry.httpStatus ? String(entry.httpStatus) : "pending"; + const status = document.createElement("span"); + status.className = "opportunities-extension-debug-status"; status.textContent = entry.status; - heading.append(name, status); + const summaryMeta = document.createElement("span"); + summaryMeta.className = "opportunities-extension-debug-summary-meta"; + summaryMeta.append(httpStatus, status); + + heading.append(name, summaryMeta); const lines = [ `${entry.method} ${entry.url}`, @@ -1463,9 +2019,40 @@ return; } + if (closeOwnerFilterPanel()) { + event.preventDefault(); + return; + } + closeOpportunitiesModal(); } }); + + document.addEventListener("pointerdown", (event) => { + const modal = document.getElementById(MODAL_ID); + const panel = document.getElementById(CUSTOMER_FILTER_PANEL_ID); + const ownerPanel = document.getElementById(OWNER_FILTER_PANEL_ID); + + if (!modal || modal.hidden) { + return; + } + + if (panel && !panel.hidden) { + const customerFilter = panel.closest(".opportunities-extension-customer-filter"); + + if (customerFilter && !customerFilter.contains(event.target)) { + closeCustomerFilterPanel(false); + } + } + + if (ownerPanel && !ownerPanel.hidden) { + const ownerFilter = ownerPanel.closest(".opportunities-extension-customer-filter"); + + if (ownerFilter && !ownerFilter.contains(event.target)) { + closeOwnerFilterPanel(false); + } + } + }); } function ensureExtensionStyles() { @@ -1601,7 +2188,7 @@ .opportunities-extension-body { display: grid; min-height: 0; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr); gap: 16px; padding: 22px 40px 36px; background: #f5f4f2; @@ -1611,6 +2198,7 @@ .opportunities-extension-form { display: flex; align-items: end; + flex-wrap: wrap; gap: 24px; min-height: 82px; max-width: none; @@ -1623,8 +2211,9 @@ .opportunities-extension-field { display: grid; - max-width: 360px; - width: 360px; + max-width: max-content; + min-width: 270px; + width: max-content; gap: 6px; color: #312d2a; font-size: 13px; @@ -1632,6 +2221,60 @@ line-height: 1.3; } + .opportunities-extension-stage-dashboard { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + } + + .opportunities-extension-stage-dashboard-card { + display: grid; + gap: 6px; + min-width: 0; + padding: 12px 16px; + border: 1px solid #dedbd7; + border-top: 3px solid #5f5a55; + border-radius: 4px; + background: #ffffff; + } + + .opportunities-extension-stage-dashboard-card span { + color: #5f5a55; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + } + + .opportunities-extension-stage-dashboard-value { + overflow: hidden; + color: #312d2a; + font-size: 22px; + font-weight: 700; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; + } + + .opportunities-extension-stage-dashboard-card[data-stage="SQL"] { + border-top-color: #008aa6; + } + + .opportunities-extension-stage-dashboard-card[data-stage="PIPELINE"] { + border-top-color: #6f6863; + } + + .opportunities-extension-stage-dashboard-card[data-stage="UPSIDE"] { + border-top-color: #c87b00; + } + + .opportunities-extension-stage-dashboard-card[data-stage="FORECAST"] { + border-top-color: #755b8b; + } + + .opportunities-extension-stage-dashboard-card[data-stage="WON"] { + border-top-color: #4b7f22; + } + .opportunities-extension-stage-filter { display: grid; grid-template-rows: auto 44px; @@ -1722,6 +2365,85 @@ outline-offset: 2px; } + .opportunities-extension-status-filter { + display: grid; + grid-template-rows: auto 44px; + gap: 6px; + min-width: 0; + } + + .opportunities-extension-status-filter-label { + color: #312d2a; + font-size: 13px; + font-weight: 600; + line-height: 1.3; + } + + .opportunities-extension-status-filter-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + } + + .opportunities-extension-status-filter-button { + min-height: 36px; + padding: 6px 12px; + border: 1px solid currentColor; + border-radius: 18px; + background: #ffffff; + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + line-height: 1.2; + cursor: pointer; + } + + .opportunities-extension-status-filter-button[data-status="OPEN"] { + color: #197a3d; + } + + .opportunities-extension-status-filter-button[data-status="CLOSED"] { + color: #4b5563; + } + + .opportunities-extension-status-filter-button[data-status="LOST"] { + color: #b13b34; + } + + .opportunities-extension-status-filter-button[data-status="WON"] { + color: #1f5f99; + } + + .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="OPEN"] { + border-color: #197a3d; + background: #197a3d; + color: #ffffff; + } + + .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="CLOSED"] { + border-color: #4b5563; + background: #4b5563; + color: #ffffff; + } + + .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="LOST"] { + border-color: #b13b34; + background: #b13b34; + color: #ffffff; + } + + .opportunities-extension-status-filter-button[aria-pressed="true"][data-status="WON"] { + border-color: #1f5f99; + background: #1f5f99; + color: #ffffff; + } + + .opportunities-extension-status-filter-button:focus-visible { + outline: 2px solid #00758f; + outline-offset: 2px; + } + .opportunities-extension-customer-filter { position: relative; display: grid; @@ -1876,9 +2598,40 @@ font-size: 13px; } + .opportunities-extension-table-search-field { + display: grid; + grid-template-rows: auto 44px; + gap: 6px; + width: 260px; + color: #312d2a; + font-size: 13px; + font-weight: 600; + line-height: 1.3; + } + + .opportunities-extension-table-search-field input { + width: 100%; + min-height: 44px; + padding: 10px 12px; + border: 1px solid #b8b2ad; + border-radius: 3px; + background: #ffffff; + color: #312d2a; + font-size: 14px; + line-height: 1.3; + } + + .opportunities-extension-table-search-field input:focus { + border-color: #312d2a; + box-shadow: 0 0 0 1px #312d2a; + outline: none; + } + .opportunities-extension-select-wrap { position: relative; display: block; + width: 270px; + max-width: 100%; } .opportunities-extension-select-wrap::after { @@ -1897,7 +2650,10 @@ .opportunities-extension-field select { width: 100%; + min-width: 0; + max-width: 100%; min-height: 44px; + box-sizing: border-box; appearance: none; border: 1px solid #b8b2ad; border-radius: 3px; @@ -1960,27 +2716,27 @@ } .opportunities-extension-table th:nth-child(1) { - width: 17%; + width: 14%; } .opportunities-extension-table th:nth-child(2) { - width: 9%; + width: 8%; } .opportunities-extension-table th:nth-child(3) { - width: 10%; + width: 8%; } .opportunities-extension-table th:nth-child(4) { - width: 21%; + width: 17%; } .opportunities-extension-table th:nth-child(5) { - width: 10%; + width: 12%; } .opportunities-extension-table th:nth-child(6) { - width: 10%; + width: 9%; } .opportunities-extension-table th:nth-child(7) { @@ -1988,20 +2744,24 @@ } .opportunities-extension-table th:nth-child(8) { - width: 7%; + width: 8%; } .opportunities-extension-table th:nth-child(9) { - width: 12%; + width: 7%; + } + + .opportunities-extension-table th:nth-child(10) { + width: 8%; } .opportunities-extension-table td:nth-child(2), .opportunities-extension-table td:nth-child(3), - .opportunities-extension-table td:nth-child(5), .opportunities-extension-table td:nth-child(6), .opportunities-extension-table td:nth-child(7), .opportunities-extension-table td:nth-child(8), - .opportunities-extension-table td:nth-child(9) { + .opportunities-extension-table td:nth-child(9), + .opportunities-extension-table td:nth-child(10) { white-space: nowrap; } @@ -2063,6 +2823,40 @@ color: #3f6f17; } + .opportunities-extension-status-badge { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 8px; + border-radius: 12px; + background: #ebe8e5; + color: #312d2a; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; + } + + .opportunities-extension-status-badge[data-status="OPEN"] { + background: #e2f2e5; + color: #197a3d; + } + + .opportunities-extension-status-badge[data-status="CLOSED"] { + background: #e5e7eb; + color: #4b5563; + } + + .opportunities-extension-status-badge[data-status="LOST"] { + background: #f9e3e1; + color: #b13b34; + } + + .opportunities-extension-status-badge[data-status="WON"] { + background: #e3edf8; + color: #1f5f99; + } + .opportunities-extension-sort-button { position: relative; display: inline-flex; @@ -2172,37 +2966,100 @@ background: #faf9f8; } + .opportunities-extension-debug-item[open] { + background: #ffffff; + } + .opportunities-extension-debug-item-success { border-left-color: #3f6f17; } + .opportunities-extension-debug-item-success .opportunities-extension-debug-status { + background: #e2f2e5; + color: #356d19; + } + .opportunities-extension-debug-item-failed { border-left-color: #c5331f; } + .opportunities-extension-debug-item-failed .opportunities-extension-debug-status { + background: #f9e3e1; + color: #a52b1c; + } + .opportunities-extension-debug-item-pending { border-left-color: #6f5a7f; } + .opportunities-extension-debug-item-pending .opportunities-extension-debug-status { + background: #eee8f2; + color: #6f5a7f; + } + .opportunities-extension-debug-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; - padding: 10px 12px 0; + min-height: 42px; + padding: 9px 12px 9px 10px; + cursor: pointer; + list-style: none; + } + + .opportunities-extension-debug-heading::-webkit-details-marker { + display: none; + } + + .opportunities-extension-debug-heading::before { + content: "⌄"; + width: 14px; + color: #5f5a55; + font-size: 16px; + line-height: 1; + transform: rotate(-90deg); + transition: transform .15s ease; + } + + .opportunities-extension-debug-item[open] .opportunities-extension-debug-heading::before { + transform: rotate(0deg); + } + + .opportunities-extension-debug-summary-meta { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: auto; + } + + .opportunities-extension-debug-http-status, + .opportunities-extension-debug-status { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + line-height: 1; + text-transform: uppercase; + } + + .opportunities-extension-debug-http-status { + background: #e8f4f6; + color: #006d7a; + } + + .opportunities-extension-debug-status { + background: #ebe8e5; + color: #5f5a55; } .opportunities-extension-debug-heading strong { font-size: 13px; } - .opportunities-extension-debug-heading span { - color: #312d2a; - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - } - .opportunities-extension-debug-item pre { margin: 0; padding: 8px 12px 12px; @@ -2268,10 +3125,26 @@ width: min(360px, calc(100vw - 64px)); } + .opportunities-extension-table-search-field { + width: 100%; + } + + .opportunities-extension-stage-dashboard { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .opportunities-extension-stage-dashboard-value { + font-size: 18px; + } + .opportunities-extension-stage-filter-controls { gap: 6px; } + .opportunities-extension-status-filter-controls { + gap: 6px; + } + .opportunities-extension-table-surface { min-width: 0; min-height: calc(100vh - 230px);