import { LightningElement, api, wire } from 'lwc'; import { refreshApex } from '@salesforce/apex'; import USER_ID from '@salesforce/user/Id'; import SYNC_LOGO from '@salesforce/resourceUrl/syncLogo1'; import getStores from '@salesforce/apex/ShopifySyncDashboardController.getStores'; import getObjectCounts from '@salesforce/apex/ShopifySyncDashboardController.getObjectCounts'; import getShopifyCounts from '@salesforce/apex/ShopifySyncDashboardController.getShopifyCounts'; import getObjectFields from '@salesforce/apex/ShopifySyncDashboardController.getObjectFields'; import getObjectRecords from '@salesforce/apex/ShopifySyncDashboardController.getObjectRecords'; import getObjectRecordById from '@salesforce/apex/ShopifySyncDashboardController.getObjectRecordById'; import getLastOrderPerStore from '@salesforce/apex/ShopifySyncDashboardController.getLastOrderPerStore'; import getDashboardChartData from '@salesforce/apex/ShopifySyncDashboardController.getDashboardChartData'; import getDashboardKpis from '@salesforce/apex/ShopifySyncDashboardController.getDashboardKpis'; import createPublicFileLink from '@salesforce/apex/ShopifySyncDashboardController.createPublicFileLink'; import scheduleReport from '@salesforce/apex/ScheduleReportController.scheduleReport'; import getSchedulableReports from '@salesforce/apex/ScheduleReportController.getSchedulableReports'; import getScheduledReports from '@salesforce/apex/ScheduleReportController.getScheduledReports'; import deleteScheduledReport from '@salesforce/apex/ScheduleReportController.deleteScheduledReport'; import generatePdfBase64 from '@salesforce/apex/ShopifySyncDashboardController.generatePdfBase64'; const REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours (API callout cooldown) const PAGE_REFRESH_MS = 5 * 60 * 1000; // 5 minutes (silent data refresh) const COUNTDOWN_TICK_MS = 60 * 1000; const MODAL_TICK_MS = 1000; const PAGE_SIZE = 15; const PER_OBJECT_REFRESH_DELAY = 220; // localStorage key — namespaced per user so multi-user browser profiles stay isolated const LS_LAST_REFRESH_KEY = 'shopifySyncDashboard.lastRefreshTs'; const LS_THEME_KEY = 'shopifySyncDashboard.theme'; const LS_STORE_ID_KEY = 'shopifySyncDashboard.selectedStoreId'; // Per-object: which field is the "primary" clickable field that opens the record itself. // Everything else with a lookup target opens the LOOKUP record (Apex provides __name + __sobject). const PRIMARY_LINK_FIELD = { product: 'Name', variant: 'Name', lead: 'Name', pacc: 'Name', account: 'Name', contact: 'Name', paidOpp: 'Name', draftOpp: 'Name', draftOrder: 'OrderNumber', paidOrder: 'OrderNumber', collection: 'Name', inventory: 'Name', location: 'Name' }; // Maps each sidebar object to the Store config boolean that controls its visibility. // Objects sharing a boolean (e.g. product+variant) show/hide together. const OBJECT_CONFIG_MAP = { product: 'productSync', variant: 'productSync', lead: 'enableLead', pacc: 'personAccount', account: 'accountContact', paidOpp: 'opportunity', // Paid Order Opportunity draftOpp: 'draftOpportunity', // Draft Order Opportunity draftOrder: 'draftOrderToggle', // ← CHILD toggle for Draft Order (HIC_ShopifySync__Enable_Draft_Order__c) paidOrder: 'orderToggle', // ← CHILD toggle for Paid Order (HIC_ShopifySync__Order__c) collection: 'collectionSync', inventory: 'inventoryLocation', location: 'inventoryLocation' }; // Keyword filter for the Schedule Report picklist: activeObjectId → terms // matched (case-insensitive) against Report.Name. Reports whose name hits a // keyword are offered first; if nothing matches, the full package list shows. const SCHED_REPORT_KEYWORDS = { product: ['product'], variant: ['variant', 'product'], lead: ['lead', 'marketing'], pacc: ['person account', 'customer', 'account'], account: ['account', 'customer'], contact: ['contact', 'customer'], paidOpp: ['opportunity', 'paid'], draftOpp: ['opportunity', 'draft'], draftOrder: ['draft order', 'order'], paidOrder: ['order'], collection: ['collect'], inventory: ['inventor'], location: ['location'] }; // Reverse map: SObject API name → dashboard objectKey. Used when the user // clicks a lookup cell — we need to look up the TARGET object's default // fields to display in the detail popup. Ambiguous SObjects (Account is shared // by 'account' + 'pacc', Order by 'draftOrder' + 'paidOrder') default to the // B2B / "paid" variant — covers the more common case. const SOBJECT_TO_OBJECT_KEY = { 'Product2': 'product', 'HIC_ShopifySync__Variant__c': 'variant', 'Lead': 'lead', 'Account': 'account', 'Contact': 'contact', 'Opportunity': 'paidOpp', // Default to paidOpp 'Order': 'paidOrder', 'HIC_ShopifySync__Collection__c': 'collection', 'HIC_ShopifySync__Inventory__c': 'inventory', 'HIC_ShopifySync__Location__c': 'location' }; /* --------------------------------------------------------------------------- * Live data flow: * @wire(getStores) → SELECT Id, Name FROM HIC_ShopifySync__HIC_Configuration__c * WHERE Name != 'default' * @wire(getObjectCounts, → COUNT() per object key with base filter * { storeName }) HIC_ShopifySync__Shopify_Id__c != null * plus per-object extra filter and (when a store * is selected) HIC_ShopifySync__Store__c = :storeName. * * KPIs / dashboard charts / report rows are still mock-driven (Apex hooks documented * below) — next iteration wires them up. The sidebar counts and store list are now * fully live. * ------------------------------------------------------------------------ */ const OBJECT_DEFS = [ { id: 'product', name: 'Product', api: 'Product2', filter: '', icon: 'standard:product' }, { id: 'variant', name: 'Variants', api: 'HIC_ShopifySync__Variant__c', filter: '', icon: 'standard:product_quantity_rules' }, { id: 'pacc', name: 'Person Account', api: 'Account (IsPersonAccount = true)', filter: 'IsPersonAccount = true', icon: 'standard:person_account' }, { id: 'account', name: 'Account', api: 'Account', filter: 'IsPersonAccount = false', icon: 'standard:account' }, { id: 'draftOrder', name: 'Draft Order', api: 'Order', filter: 'HIC_ShopifySync__Payment_Status__c = null', icon: 'standard:quotes' }, { id: 'paidOrder', name: 'Paid Order', api: 'Order', filter: 'HIC_ShopifySync__Payment_Status__c != null', icon: 'standard:orders' }, { id: 'draftOpp', name: 'Draft Opportunity', api: 'Opportunity', filter: 'HIC_ShopifySync__Payment_Status__c = null', icon: 'standard:opportunity', hideShopify: true }, { id: 'paidOpp', name: 'Paid Opportunity', api: 'Opportunity', filter: 'HIC_ShopifySync__Payment_Status__c != null', icon: 'standard:opportunity', hideShopify: true }, { id: 'collection', name: 'Collection', api: 'HIC_ShopifySync__Collection__c', filter: '', icon: 'standard:product_consumed_state' }, { id: 'location', name: 'Location', api: 'HIC_ShopifySync__Location__c', filter: '', icon: 'standard:location' }, { id: 'inventory', name: 'Inventory', api: 'HIC_ShopifySync__Inventory__c', filter: '', icon: 'standard:product_item', hideShopify: true }, { id: 'lead', name: 'Lead', api: 'Lead', filter: '', icon: 'standard:lead' } ]; function fmtInt(n) { return Number(n).toLocaleString(); } // Dashboard layout — purely structural (which charts, what default chart type). // ALL data (titles, labels, values) comes exclusively from Apex. // No hardcoded mock values anywhere in this file. const DASH_CONFIG = { product: [{ id:'dash1', defaultChart:'bar' }, { id:'dash2', defaultChart:'bar' }, { id:'dash3', defaultChart:'bar' }], variant: [{ id:'dash1', defaultChart:'bar' }, { id:'dash2', defaultChart:'bar' }, { id:'dash3', defaultChart:'bar' }], lead: [{ id:'dash1', defaultChart:'donut' }, { id:'dash2', defaultChart:'bar' }, { id:'dash3', defaultChart:'bar' }], pacc: [{ id:'dash1', defaultChart:'bar' }, { id:'dash2', defaultChart:'bar' }, { id:'dash3', defaultChart:'line' }], account: [{ id:'dash1', defaultChart:'bar' }, { id:'dash2', defaultChart:'bar' }, { id:'dash3', defaultChart:'bar' }], contact: [{ id:'dash1', defaultChart:'donut' }, { id:'dash2', defaultChart:'line' }, { id:'dash3', defaultChart:'bar' }], opp: [{ id:'dash1', defaultChart:'bar' }, { id:'dash2', defaultChart:'donut' }, { id:'dash3', defaultChart:'bar' }], paidOpp: [{ id:'dash1', defaultChart:'bar' }, { id:'dash2', defaultChart:'bar' }, { id:'dash3', defaultChart:'bar' }], draftOpp: [{ id:'dash1', defaultChart:'bar' }, { id:'dash2', defaultChart:'bar' }, { id:'dash3', defaultChart:'bar' }], draftOrder: [{ id:'dash1', defaultChart:'bar' }, { id:'dash2', defaultChart:'donut' }, { id:'dash3', defaultChart:'line' }], paidOrder: [{ id:'dash1', defaultChart:'line' }, { id:'dash2', defaultChart:'donut' }, { id:'dash3', defaultChart:'bar' }], collection: [{ id:'dash1', defaultChart:'bar' }], inventory: [{ id:'dash1', defaultChart:'bar' }], location: [{ id:'dash1', defaultChart:'bar' }] }; /* ======================================================================== */ /* Field lists and row data are now loaded from Apex (Schema.describe + * SOQL). The previous client-side FIELDS_BY_OBJECT + makeRows mocks were * removed in May 2026 — see _fetchFieldsForObject / _fetchRecordsForObject. * ====================================================================== */ export default class ShopifySyncDashboard extends LightningElement { // @api kept ONLY for managed-package compatibility (targetConfig property // tags can't be removed). Whatever App Builder passes is intentionally // IGNORED — the dashboard always opens on 'All Time'; users change the // range via the "Filter by" dropdown. _dateRange = 'All Time'; @api get dateRange() { return this._dateRange; } set dateRange(value) { // no-op by design — page-instance values must not override the default } @api storeScope = 'All Stores'; // unused; retained for managed-package property tag theme = 'light'; syncLogoUrl = SYNC_LOGO; // ---------- Custom gradient-styled toast (replaces ShowToastEvent) ---------- toasts = []; _toastSeq = 0; get hostClass() { let cls = this.theme === 'dark' ? 'app dark' : 'app'; // Freeze the dashboard's scroll when any modal is open so the // background doesn't drift underneath. if (this.showCooldownModal || this.showShareModal || this.showScheduleModal || this.showRecordDetailModal || this.showFilterInfoModal || this.showMoreShareModal) { cls += ' modal-open'; } return cls; } // ---------- Sidebar Sync Health panel ---------- // Shows every connected Shopify store with its connection status and // the timestamp of its last order sync. Derived from existing wire state // + a small deterministic offset per store for the last-order time // (until a real "last order synced at" field is exposed by Apex). get storeHealthList() { if (!this.stores || this.stores.length === 0) { // Empty placeholder so the panel still renders cleanly return [{ id: 'no-stores', name: 'No stores connected', statusClass: 'health-dot health-err', statusLabel: 'Idle', lastOrderLabel: '—', isEmpty: true }]; } // Per-store: use real last-order CreatedDate from Apex aggregate query. return this.stores.map((s) => { const ok = !this.shopifyError; const isoStr = this.lastOrderMap[s.name]; let lastOrderLabel = '—'; if (isoStr) { const ts = new Date(isoStr).getTime(); if (!isNaN(ts)) lastOrderLabel = this._relativeTime(ts); } return { id: s.id, name: s.name, statusClass: ok ? 'health-dot health-ok' : 'health-dot health-err', statusLabel: ok ? 'Connected' : 'Error', lastOrderLabel, isEmpty: false }; }); } _relativeTime(ts) { const diff = Date.now() - ts; if (diff < 0) return 'just now'; if (diff < 60 * 1000) return 'just now'; if (diff < 60 * 60 * 1000) { const m = Math.round(diff / 60000); return m + ' min ago'; } if (diff < 24 * 60 * 60 * 1000) { const h = Math.floor(diff / 3600000); const m = Math.round((diff % 3600000) / 60000); return h + 'h ' + m + 'm ago'; } const d = Math.floor(diff / (24 * 60 * 60 * 1000)); return d + 'd ago'; } get themeIconLabel() { return this.theme === 'dark' ? '☀' : '☾'; } get themeButtonTitle() { return 'Switch to ' + (this.theme === 'dark' ? 'light' : 'dark') + ' mode'; } toggleTheme(event) { if (event) event.stopPropagation(); this.theme = this.theme === 'dark' ? 'light' : 'dark'; try { localStorage.setItem(LS_THEME_KEY, this.theme); } catch (e) { // eslint-disable-next-line no-console console.warn('[ShopifySyncDashboard] failed to save theme to localStorage', e); } } // ----------- LIVE: Stores ----------- stores = []; storesError = null; @wire(getStores) wiredStores({ data, error }) { if (data) { this.stores = data.map(s => ({ id: s.id, name: s.name, active: true, productSync: s.productSync, accountContact: s.accountContact, enableLead: s.enableLead, personAccount: s.personAccount, collectionSync: s.collectionSync, inventoryLocation: s.inventoryLocation, // Paid Order toggles orderSync: s.orderSync, // Parent (HIC_ShopifySync__Order_Sync__c) - NOT used for tab visibility orderToggle: s.orderToggle, // ← CHILD - Controls Paid Order tab (HIC_ShopifySync__Order__c) opportunity: s.opportunity, // Child - Controls Paid Opportunity (HIC_ShopifySync__Opportunity__c) // Draft Order toggles draftOrderSync: s.draftOrderSync, // Parent (HIC_ShopifySync__Draft_Order_Sync__c) - NOT used for tab visibility draftOrderToggle: s.draftOrderToggle, // ← CHILD - Controls Draft Order tab (HIC_ShopifySync__Enable_Draft_Order__c) draftOpportunity: s.draftOpportunity // Child - Controls Draft Opportunity (HIC_ShopifySync__Enable_Opportunity_for_SDO__c) })); this.storesError = null; const storeExists = this.selectedStoreId && this.stores.some(s => s.id === this.selectedStoreId); if (!storeExists && this.stores.length > 0) { this.selectedStoreId = this.stores[0].id; try { localStorage.setItem(LS_STORE_ID_KEY, this.selectedStoreId); } catch (e) { // ignore } } this._fetchShopifyCounts(); this._fetchLastOrderTimes(); this._ensureActiveObjectVisible(); } else if (error) { this.stores = []; this.storesError = error.body ? error.body.message : String(error); console.error('[ShopifySyncDashboard] getStores failed', error); } } storeMenuOpen = false; selectedStoreId = null; // null = All Stores get selectedStore() { return this.selectedStoreId ? this.stores.find(s => s.id === this.selectedStoreId) : null; } get selectedStoreName() { // Used by the @wire below. null/undefined = "All Stores". return this.selectedStore ? this.selectedStore.name : null; } get currentStoreLabel() { if (this.storesError) return 'Stores unavailable'; if (!this.stores.length) return 'No stores connected'; // A specific store must always be selected — auto-selected in wiredStores return this.selectedStore ? this.selectedStore.name : 'Select a store'; } get storeNamesPreview() { if (this.storesError) return this.storesError; // Used to show the Id under the store name — removed per UX feedback. // Now shows the connected-store count as a subtle hint instead. if (this.stores.length > 1) return this.stores.length + ' stores connected'; return ''; } get storeMenuClass() { return this.storeMenuOpen ? 'store-menu open' : 'store-menu'; } toggleStoreMenu(event) { if (event) event.stopPropagation(); this.storeMenuOpen = !this.storeMenuOpen; this._closeOtherMenus('store'); } get storeOptions() { // "All Stores" option removed per user request — only specific connected // stores appear; one must always be selected. return this.stores.map(s => ({ id: s.id, name: s.name, domain: s.id, selected: s.id === this.selectedStoreId, rowClass: s.id === this.selectedStoreId ? 'store-menu-row selected' : 'store-menu-row' })); } handleStoreSelect(event) { event.stopPropagation(); const id = event.currentTarget.dataset.storeId; const newId = id || null; if (newId === this.selectedStoreId) { this.storeMenuOpen = false; return; } this.selectedStoreId = newId; try { if (newId) { localStorage.setItem(LS_STORE_ID_KEY, newId); } else { localStorage.removeItem(LS_STORE_ID_KEY); } } catch (e) { console.warn('[ShopifySyncDashboard] failed to save storeId to localStorage', e); } this.storeMenuOpen = false; this._resetReportState(); this._fetchShopifyCounts(); this._ensureActiveObjectVisible(); // ← Re-evaluate visibility this._fetchDashboardData(this.activeObjectId); this._fetchKpis(this.activeObjectId); } // ----------- LIVE: Object counts (per selected store, or aggregate) ----------- _wiredCounts; realCounts = {}; countsError = null; // ----------- LIVE: Shopify counts (imperative — callout, not cacheable) ----------- shopifyCounts = {}; isFetchingShopify = false; shopifyError = null; _fetchShopifyCounts() { // Single-store mode — straightforward callout. if (this.selectedStoreName) { this.isFetchingShopify = true; getShopifyCounts({ storeName: this.selectedStoreName }) .then(result => { this.shopifyCounts = result || {}; this.shopifyError = null; }) .catch(err => { // eslint-disable-next-line no-console console.error('[ShopifySyncDashboard] getShopifyCounts failed', err); this.shopifyCounts = {}; this.shopifyError = err && err.body ? err.body.message : String(err); }) .finally(() => { this.isFetchingShopify = false; }); return; } // All-stores mode — fan out one call per connected store and sum. // Empty stores list → nothing to query. if (!this.stores || this.stores.length === 0) { this.shopifyCounts = {}; return; } this.isFetchingShopify = true; const promises = this.stores.map(s => getShopifyCounts({ storeName: s.name }).catch(err => { // Keep aggregation resilient — one failing store doesn't kill the rest // eslint-disable-next-line no-console console.warn('[ShopifySyncDashboard] getShopifyCounts failed for store ' + s.name, err); return {}; }) ); Promise.all(promises) .then(results => { const aggregated = {}; results.forEach(counts => { if (!counts) return; Object.keys(counts).forEach(key => { const inc = (typeof counts[key] === 'number') ? counts[key] : 0; aggregated[key] = (aggregated[key] || 0) + inc; }); }); this.shopifyCounts = aggregated; this.shopifyError = null; }) .catch(err => { // eslint-disable-next-line no-console console.error('[ShopifySyncDashboard] all-stores Shopify aggregation failed', err); this.shopifyCounts = {}; this.shopifyError = err && err.body ? err.body.message : String(err); }) .finally(() => { this.isFetchingShopify = false; }); } @wire(getObjectCounts, { storeName: '$selectedStoreName', dateRange: '$dateRange' }) wiredCounts(result) { this._wiredCounts = result; if (result.data) { this.realCounts = result.data; this.countsError = null; } else if (result.error) { this.realCounts = {}; this.countsError = result.error.body ? result.error.body.message : String(result.error); // eslint-disable-next-line no-console console.error('[ShopifySyncDashboard] getObjectCounts failed', result.error); } } get aggregateCounts() { return this.realCounts || {}; } // ----------- Active object + sidebar ----------- activeObjectId = 'product'; get sidebarItems() { const sf = this.realCounts || {}; const sh = this.shopifyCounts || {}; const store = this.selectedStore; return OBJECT_DEFS .filter(o => { if (!store) return true; const configKey = OBJECT_CONFIG_MAP[o.id]; if (!configKey) return true; // ----- PAID ORDER VISIBILITY ----- // Shows if Order Toggle (HIC_ShopifySync__Order__c) is ON if (o.id === 'paidOrder') { return store.orderToggle === true; } // ----- DRAFT ORDER VISIBILITY ----- // Shows if Draft Order Toggle (HIC_ShopifySync__Enable_Draft_Order__c) is ON if (o.id === 'draftOrder') { return store.draftOrderToggle === true; } // ----- PAID OPPORTUNITY VISIBILITY ----- // Shows if Order Toggle AND Opportunity are both ON if (o.id === 'paidOpp') { return store.opportunity === true; } // ----- DRAFT OPPORTUNITY VISIBILITY ----- // Shows if Draft Order Toggle AND Draft Opportunity are both ON if (o.id === 'draftOpp') { return store.draftOpportunity === true; } // ----- ALL OTHER OBJECTS ----- return store[configKey] === true; }) .map(o => { const sfCnt = (typeof sf[o.id] === 'number') ? sf[o.id] : 0; const shCnt = (typeof sh[o.id] === 'number') ? sh[o.id] : 0; return { id: o.id, name: o.name, api: o.api, icon: o.icon || 'standard:default', shopify: fmtInt(shCnt), sf: fmtInt(sfCnt), showShopify: !o.hideShopify, active: o.id === this.activeObjectId, rowClass: o.id === this.activeObjectId ? 'obj-item active' : 'obj-item' }; }); } get activeObject() { return OBJECT_DEFS.find(o => o.id === this.activeObjectId) || OBJECT_DEFS[0]; } get activeObjectFilterPill() { const f = this.activeObject.filter; const base = 'HIC_ShopifySync__Shopify_Id__c != null'; const storePart = this.selectedStoreName ? " AND HIC_ShopifySync__Store__c = '" + this.selectedStoreName + "'" : ''; return base + storePart + (f ? ' AND ' + f : ''); } // Tooltip shown on hover over the (i) icon next to the "Records — " title. // Multi-line breakdown of the SOQL filter so users (devs/admins) can see // exactly which records are being included. get activeObjectFilterTooltip() { const lines = ['Active filter on this report:']; lines.push('• HIC_ShopifySync__Shopify_Id__c != null'); if (this.selectedStoreName) { lines.push("• HIC_ShopifySync__Store__c = '" + this.selectedStoreName + "'"); } else { lines.push('• All stores (no store filter)'); } if (this.activeObject.filter) { lines.push('• ' + this.activeObject.filter); } return lines.join('\n'); } // ----------- Last Order per store (real data for Sync Health) ----------- lastOrderMap = {}; _fetchLastOrderTimes() { getLastOrderPerStore() .then(result => { this.lastOrderMap = result || {}; }) .catch(err => { // eslint-disable-next-line no-console console.error('[ShopifySyncDashboard] getLastOrderPerStore failed', err); this.lastOrderMap = {}; }); } // ----------- Real dashboard chart data (Apex-backed) ----------- _realDashboardData = {}; // keyed by "store|objectId|dateRange" → { dash1, dash2, dash3 } _dashFetching = {}; _fetchDashboardData(objectKey) { const storeName = this.selectedStoreName || '__all__'; const dr = this.dateRange || 'All Time'; const cacheKey = storeName + '|' + objectKey + '|' + dr; // BUG FIX: guard against duplicate concurrent fetches for the same // key — the `dashboards` getter can run multiple times per render // cycle and would otherwise fire several overlapping callouts. if (this._dashFetching[cacheKey]) return; // CRITICAL FIX: Clear existing data for this specific cache key const newData = { ...this._realDashboardData }; delete newData[cacheKey]; this._realDashboardData = newData; // Set loading state this._dashFetching = { ...this._dashFetching, [cacheKey]: true }; getDashboardChartData({ objectKey, storeName: this.selectedStoreName || '', dateRange: dr }) .then(result => { this._realDashboardData = { ...this._realDashboardData, [cacheKey]: result || {} }; this._dashFetching = { ...this._dashFetching, [cacheKey]: false }; }) .catch(err => { console.error('[ShopifySyncDashboard] getDashboardChartData failed for ' + objectKey, err); this._realDashboardData = { ...this._realDashboardData, [cacheKey]: {} }; this._dashFetching = { ...this._dashFetching, [cacheKey]: false }; }); } /** * If the currently active object is hidden (config boolean = false for * the selected store), auto-switch to the first visible object. */ _ensureActiveObjectVisible() { const store = this.selectedStore; if (!store) return; let isVisible = true; if (this.activeObjectId === 'paidOrder') { isVisible = store.orderToggle === true; } else if (this.activeObjectId === 'draftOrder') { isVisible = store.draftOrderToggle === true; } else if (this.activeObjectId === 'paidOpp') { isVisible = store.opportunity === true; } else if (this.activeObjectId === 'draftOpp') { isVisible = store.draftOpportunity === true; } else { const configKey = OBJECT_CONFIG_MAP[this.activeObjectId]; if (configKey) { isVisible = store[configKey] === true; } } if (!isVisible) { const firstVisible = OBJECT_DEFS.find(o => { if (o.id === 'paidOrder') { return store.orderToggle === true; } if (o.id === 'draftOrder') { return store.draftOrderToggle === true; } if (o.id === 'paidOpp') { return store.opportunity === true; } if (o.id === 'draftOpp') { return store.draftOpportunity === true; } const ck = OBJECT_CONFIG_MAP[o.id]; if (ck) { return store[ck] === true; } return true; }); if (firstVisible) { this.activeObjectId = firstVisible.id; this._resetReportState(); this._resetDashboardState(); this._ensureFields(firstVisible.id); this._ensureRows(firstVisible.id); // BUG FIX: this call was missing — without it, the auto-switched // tab showed mock/hardcoded dashboard data until the user // happened to trigger another fetch manually. this._fetchDashboardData(firstVisible.id); this._fetchKpis(firstVisible.id); } } } handleObjectClick(event) { const id = event.currentTarget.dataset.id; if (id === this.activeObjectId) return; // CRITICAL FIX: Reset ALL dashboard data when switching objects this._resetDashboardState(); this.activeObjectId = id; this._resetReportState(); // Clear chart types for the new object this.chartTypeByDashboard = {}; // Lazy-fetch fields & records for the newly-active object this._ensureFields(id); this._ensureRows(id); this._fetchDashboardData(id); this._fetchKpis(id); } _invalidateDashboardData(objectKey) { const storeName = this.selectedStoreName || '__all__'; const dr = this.dateRange || 'All Time'; const cacheKey = storeName + '|' + objectKey + '|' + dr; // Remove the cached data for this specific object const newData = { ...this._realDashboardData }; delete newData[cacheKey]; this._realDashboardData = newData; // Also clear the fetching state const newFetching = { ...this._dashFetching }; delete newFetching[cacheKey]; this._dashFetching = newFetching; } // ----------- KPIs (real Apex data — no hardcoded values) ----------- _kpisByObject = {}; // keyed by "store|objectId|dateRange" → KpiData[] _kpisFetching = {}; _fetchKpis(objectKey) { const storeName = this.selectedStoreName || '__all__'; const dr = this.dateRange || 'All Time'; const cacheKey = storeName + '|' + objectKey + '|' + dr; if (this._kpisFetching[cacheKey]) return; this._kpisFetching = { ...this._kpisFetching, [cacheKey]: true }; getDashboardKpis({ objectKey, storeName: this.selectedStoreName || '', dateRange: dr }) .then(result => { this._kpisByObject = { ...this._kpisByObject, [cacheKey]: result || [] }; this._kpisFetching = { ...this._kpisFetching, [cacheKey]: false }; }) .catch(err => { console.error('[ShopifySyncDashboard] getDashboardKpis failed for ' + objectKey, err); this._kpisByObject = { ...this._kpisByObject, [cacheKey]: [] }; this._kpisFetching = { ...this._kpisFetching, [cacheKey]: false }; }); } get kpis() { const storeName = this.selectedStoreName || '__all__'; const dr = this.dateRange || 'All Time'; const cacheKey = storeName + '|' + this.activeObjectId + '|' + dr; // Self-heal: trigger fetch if not yet started if (!(cacheKey in this._kpisByObject) && !this._kpisFetching[cacheKey]) { this._fetchKpis(this.activeObjectId); } const real = this._kpisByObject[cacheKey]; const isLoading = this._kpisFetching[cacheKey] || false; const accents = ['k-blue','k-green','k-red','k-yellow','k-purple','k-cyan','k-blue','k-green']; if (!real || !real.length) { // Loading / empty state — no hardcoded labels or values return Array.from({ length: 8 }, (_, i) => ({ id: 'k' + i, label: isLoading ? '...' : '—', value: isLoading ? '...' : '—', sub: '', delta: '', deltaDir: 'up', accent: accents[i], cssClass: 'kpi ' + accents[i], deltaClass: 'delta up' })); } return real.map((k, i) => ({ ...k, id: 'k' + i, cssClass: 'kpi ' + (k.accent || accents[i]), deltaClass: 'delta ' + (k.deltaDir || 'up') })); } chartTypeByDashboard = {}; get dashboards() { const storeName = this.selectedStoreName || '__all__'; const dr = this.dateRange || 'All Time'; const cacheKey = storeName + '|' + this.activeObjectId + '|' + dr; // Self-heal: trigger fetch if cache is cold (same pattern as _ensureRows) if (!this._realDashboardData) this._realDashboardData = {}; if (!this._dashFetching) this._dashFetching = {}; if (!this.chartTypeByDashboard) this.chartTypeByDashboard = {}; if (!(cacheKey in this._realDashboardData) && !this._dashFetching[cacheKey]) { this._fetchDashboardData(this.activeObjectId); } const realMap = this._realDashboardData[cacheKey] || {}; const isLoading = this._dashFetching[cacheKey] || false; // DASH_CONFIG defines only structure (how many charts, default type). // ALL data (titles, labels, values, colors) comes exclusively from Apex. // If Apex hasn't returned yet → loading placeholders. // If Apex returned nothing → empty chart (no hardcoded fallback values). const config = DASH_CONFIG[this.activeObjectId] || [{ id:'dash1', defaultChart:'bar' }]; return config.map(cfg => { const real = realMap ? realMap[cfg.id] : null; const hasReal = real && real.title; const hasLabels = hasReal && real.labels && real.labels.length > 0; const dispType = this.chartTypeByDashboard[cfg.id] || (hasReal && real.defaultChart ? real.defaultChart : cfg.defaultChart); const title = hasReal ? (real.title || '—') : isLoading ? 'Loading...' : '—'; const labels = hasLabels ? real.labels : isLoading ? ['Loading...'] : ['']; const values = hasLabels && real.values && real.values.length ? real.values : [0]; const colors = hasReal && real.colors && real.colors.length ? real.colors : ['#95BE45']; return { id: cfg.id, title, chartType: dispType, isLine: dispType === 'line', isBar: dispType === 'bar', isDonut: dispType === 'donut', labels, values, colors, lineSvg: this._lineSvg(values, labels, colors[0]), barSvg: this._barSvg(values, labels, colors), donutSvg: this._donutSvg(values, labels, colors), legend: this._legendFor(values, labels, colors), btnLineClass: dispType === 'line' ? 'chip on' : 'chip', btnBarClass: dispType === 'bar' ? 'chip on' : 'chip', btnDonutClass: dispType === 'donut' ? 'chip on' : 'chip' }; }); } get dashRowClass() { const config = DASH_CONFIG[this.activeObjectId] || [{ id:'dash1', defaultChart:'bar' }]; return config.length === 1 ? 'dash-row dash-single' : 'dash-row'; } handleChartType(event) { if (event) event.stopPropagation(); const id = event.currentTarget.dataset.dashId; const type = event.currentTarget.dataset.type; this.chartTypeByDashboard = { ...this.chartTypeByDashboard, [id]: type }; } // ----------- Report (now backed by real Apex data) ----------- reportPage = 1; reportSortField = ''; reportSortDir = 'asc'; fieldMenuOpen = false; fieldFilterText = ''; // First report column is pinned (locked, non-draggable) by default. The pin // icon on that column toggles this. firstColumnPinned = true; _fieldsByObject = {}; // keyed by objectId → [{ api, label, type, visible, order }] _fieldsLoading = {}; // keyed by objectId → bool _rowsByObject = {}; // keyed by "store|objectId" → SObject row maps _rowsLoading = {}; // keyed by "store|objectId" → bool // Real-data backed: fields come from Schema describe, rows come from SOQL. // Both are cached client-side per objectKey so we don't re-fetch on every getter call. _ensureFields(key) { if (!this._fieldsByObject[key]) { // Return [] now and kick off a fetch; render will update when fetch completes. this._fetchFieldsForObject(key); return []; } return this._fieldsByObject[key]; } _ensureRows(key) { const dr = this.dateRange || 'All Time'; const storeKey = (this.selectedStoreName || '__all__') + '|' + key + '|' + dr; if (!this._rowsByObject[storeKey]) { this._fetchRecordsForObject(key); return []; } return this._rowsByObject[storeKey]; } _fetchFieldsForObject(key) { if (this._fieldsLoading[key]) return; this._fieldsLoading = { ...this._fieldsLoading, [key]: true }; getObjectFields({ objectKey: key }) .then(result => { // Transform Apex FieldInfo[] into the shape the rest of the LWC expects. // Apex already sorted the list so defaults come first in user-specified // order — we just mark them as `visible` and use the array index as // the column `order`, which preserves that sequence in the table. const list = (result || []).map((f, idx) => ({ api: f.apiName, label: f.label, type: f.type || 'text', sortable: f.sortable !== false, visible: f.isDefault === true, order: idx })); this._fieldsByObject = { ...this._fieldsByObject, [key]: list }; }) .catch(err => { // eslint-disable-next-line no-console console.error('[ShopifySyncDashboard] getObjectFields failed for ' + key, err); this._toast('Could not load fields for ' + key + ': ' + this._errMsg(err), 'error'); this._fieldsByObject = { ...this._fieldsByObject, [key]: [] }; }) .finally(() => { this._fieldsLoading = { ...this._fieldsLoading, [key]: false }; }); } _fetchRecordsForObject(key) { const storeName = this.selectedStoreName || null; const dr = this.dateRange || 'All Time'; const storeKey = (storeName || '__all__') + '|' + key + '|' + dr; if (this._rowsLoading[storeKey]) return; this._rowsLoading = { ...this._rowsLoading, [storeKey]: true }; // Use the currently-visible fields so we don't over-fetch const visible = (this._fieldsByObject[key] || []) .filter(f => f.visible) .map(f => f.api); getObjectRecords({ objectKey: key, storeName: storeName, fields: visible.length ? visible : null, dateRange: dr }) .then(result => { const rows = (result || []).map(r => { // Apex returns a Map per row — same shape as before const copy = {}; Object.keys(r).forEach(k => { copy[k] = r[k]; }); copy.__storeName = r.HIC_ShopifySync__Store__c || storeName || ''; return copy; }); this._rowsByObject = { ...this._rowsByObject, [storeKey]: rows }; }) .catch(err => { // eslint-disable-next-line no-console console.error('[ShopifySyncDashboard] getObjectRecords failed for ' + key, err); this._toast('Could not load records: ' + this._errMsg(err), 'error'); this._rowsByObject = { ...this._rowsByObject, [storeKey]: [] }; }) .finally(() => { this._rowsLoading = { ...this._rowsLoading, [storeKey]: false }; }); } _errMsg(err) { if (err && err.body && err.body.message) return err.body.message; if (err && err.message) return err.message; return String(err); } get reportRows() { const all = this._ensureRows(this.activeObjectId); const target = this.selectedStoreName; const filtered = target ? all.filter(r => r.__storeName === target) : all.slice(); if (this.reportSortField) { const dir = this.reportSortDir === 'asc' ? 1 : -1; const k = this.reportSortField; filtered.sort((a, b) => String(a[k] || '').localeCompare(String(b[k] || ''), undefined, { numeric: true }) * dir); } return filtered; } get reportTotalPages() { return Math.max(1, Math.ceil(this.reportRows.length / PAGE_SIZE)); } // When the first column is pinned, freeze it (header + cells) so the whole // column stays locked in place while the other columns move/scroll. get reportTableClass() { return this.firstColumnPinned ? 'report pinned-first' : 'report'; } _visibleFields() { return this._ensureFields(this.activeObjectId) .slice() .sort((a, b) => a.order - b.order) .filter(f => f.visible); } get reportVisibleRows() { const start = (this.reportPage - 1) * PAGE_SIZE; const visible = this._visibleFields(); const primaryField = PRIMARY_LINK_FIELD[this.activeObjectId]; return this.reportRows.slice(start, start + PAGE_SIZE).map((row, idx) => { const absIdx = start + idx; return { key: 'row-' + this.activeObjectId + '-' + absIdx, rowIndex: absIdx, cells: visible.map(f => this._buildCell(row, f, primaryField, absIdx)) }; }); } /** * Build a rich cell object for the report table. * Link cells (primary or lookup) no longer carry a URL — clicking opens * the record-detail popup instead. The popup shows all selected fields * for that row and provides a separate "Open Full Record" action. */ _buildCell(row, field, primaryFieldApi, rowIndex) { const api = field.api; const raw = row[api]; const recordId = row.__recordId__; const lookupName = row[api + '__name']; const isDate = field.type === 'date' || field.type === 'datetime'; // CASE 1: lookup link — Apex provided a related Name if (lookupName !== undefined && raw) { return { key: api, isLink: true, value: lookupName, rowIndex }; } // CASE 2: primary link — this is the object's "main" field (Name/OrderNumber) if (api === primaryFieldApi && raw !== undefined && raw !== null && raw !== '' && recordId) { return { key: api, isLink: true, value: String(raw), rowIndex }; } // CASE 3: date — format as 2026-03-03T11:42 if (isDate && raw) { return { key: api, isLink: false, value: this._formatIsoMinute(raw) }; } // CASE 4: currency — prefix with $ and add thousands separators if (field.type === 'currency' && raw !== null && raw !== undefined && raw !== '') { const num = Number(raw); if (!isNaN(num)) { return { key: api, isLink: false, value: '$' + num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }; } } // CASE 5: plain text return { key: api, isLink: false, value: (raw === undefined || raw === null) ? '' : String(raw) }; } // ----------- Filter info popup (the ⓘ next to " Record Details") ----------- showFilterInfoModal = false; openFilterInfo(event) { if (event) event.stopPropagation(); this.showFilterInfoModal = true; } closeFilterInfo() { this.showFilterInfoModal = false; } get activeObjectFilterLines() { const lines = ['HIC_ShopifySync__Shopify_Id__c != null', 'HIC_ShopifySync__Store__c != null']; if (this.selectedStoreName) { lines.push("HIC_ShopifySync__Store__c = '" + this.selectedStoreName + "'"); } if (this.activeObject.filter) { lines.push(this.activeObject.filter); } return lines.map((l, i) => ({ key: 'fl-' + i, value: l })); } // ----------- Record Detail popup modal ----------- // When the user clicks a primary or lookup field, a modal opens showing all // selected fields for that row. The first row is the record Id (clickable // to navigate to the Salesforce record page). showRecordDetailModal = false; _recordDetailRow = null; _recordDetailObjectKey = null; recordDetailLoading = false; openRecordDetail(event) { event.stopPropagation(); const idx = parseInt(event.currentTarget.dataset.rowIndex, 10); const fieldApi = event.currentTarget.dataset.fieldApi; if (isNaN(idx)) return; const row = this.reportRows[idx]; if (!row) return; // Lookup cell? Apex set __name + __sobject on the row. // Fetch the TARGET record + show its fields (using the target object's // defaults, not the current activeObject's). if (fieldApi && row[fieldApi + '__name'] !== undefined) { const lookupId = row[fieldApi]; const targetSObject = row[fieldApi + '__sobject']; const targetObjectKey = SOBJECT_TO_OBJECT_KEY[targetSObject]; if (lookupId && targetObjectKey) { this._loadLookupRecord(targetObjectKey, lookupId); return; } } // Primary cell — show the current row's data with the active object's fields. this._recordDetailRow = row; this._recordDetailObjectKey = this.activeObjectId; this.showRecordDetailModal = true; } /** * Load a related record (lookup target) and display it in the popup using * the TARGET object's default fields. Loading shows a spinner placeholder. */ _loadLookupRecord(objectKey, recordId) { // Make sure we have field metadata for the target object so labels render. this._ensureFields(objectKey); this._recordDetailRow = null; this._recordDetailObjectKey = objectKey; this.recordDetailLoading = true; this.showRecordDetailModal = true; getObjectRecordById({ objectKey, recordId }) .then(row => { if (row && row.__recordId__) { this._recordDetailRow = row; } else { this._toast('Related record not found or not accessible', 'warning'); this.showRecordDetailModal = false; } }) .catch(err => { // eslint-disable-next-line no-console console.error('[ShopifySyncDashboard] getObjectRecordById failed', err); this._toast('Could not load related record: ' + this._errMsg(err), 'error'); this.showRecordDetailModal = false; }) .finally(() => { this.recordDetailLoading = false; }); } closeRecordDetail() { this.showRecordDetailModal = false; this._recordDetailRow = null; this._recordDetailObjectKey = null; this.recordDetailLoading = false; } openFullRecordPage() { if (!this._recordDetailRow) return; const url = '/lightning/r/' + this._recordDetailRow.__sobjectApi__ + '/' + this._recordDetailRow.__recordId__ + '/view'; window.open(url, '_blank', 'noopener'); } get recordDetailTitle() { if (!this._recordDetailObjectKey) return 'Record Details'; const def = OBJECT_DEFS.find(o => o.id === this._recordDetailObjectKey); return (def ? def.name : 'Record') + ' Details'; } get recordDetailId() { return this._recordDetailRow ? this._recordDetailRow.__recordId__ : ''; } get recordDetailUrl() { if (!this._recordDetailRow) return '#'; return '/lightning/r/' + this._recordDetailRow.__sobjectApi__ + '/' + this._recordDetailRow.__recordId__ + '/view'; } get recordDetailFields() { if (!this._recordDetailRow || !this._recordDetailObjectKey) return []; const row = this._recordDetailRow; const fields = this._fieldsByObject[this._recordDetailObjectKey] || []; const visible = fields.slice().filter(f => f.visible).sort((a, b) => a.order - b.order); return visible.map(f => { const lookupName = row[f.api + '__name']; const raw = row[f.api]; let displayValue = ''; if (lookupName !== undefined && raw) { displayValue = lookupName; } else if ((f.type === 'date' || f.type === 'datetime') && raw) { displayValue = this._formatIsoMinute(raw); } else if (f.type === 'currency' && raw !== null && raw !== undefined && raw !== '') { const num = Number(raw); displayValue = isNaN(num) ? String(raw) : '$' + num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } else { displayValue = (raw === undefined || raw === null) ? '' : String(raw); } return { key: f.api, label: f.label, value: displayValue }; }); } /** * Format a date / ISO string / millisecond timestamp to 2026-03-03T11:42 * (year-month-day T hour:minute, no seconds, no timezone). */ _formatIsoMinute(v) { try { const d = (v instanceof Date) ? v : new Date(v); if (isNaN(d.getTime())) return String(v); const pad = n => String(n).padStart(2, '0'); return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()); } catch (e) { return String(v); } } get reportColumns() { const sortField = this.reportSortField; const dir = this.reportSortDir; const draggingField = this._draggingField; const pinned = this.firstColumnPinned; return this._visibleFields().map((f, i) => { const isFirst = i === 0; const isSorted = sortField === f.api; const isDragging = draggingField === f.api; // Only the first column can be pinned. It's locked (non-draggable) // while pinned; unpinning makes it draggable like the rest. const isLocked = isFirst && pinned; const cls = ['col-head']; if (isSorted) cls.push('sorted'); if (isDragging) cls.push('col-dragging'); if (isLocked) cls.push('col-locked'); return { ...f, key: f.api, isFirst, isLocked, showPin: isFirst, // pin icon only on the first column showGrip: !isLocked, // drag grip on every draggable column pinClass: isLocked ? 'col-pin on' : 'col-pin', pinTitle: isLocked ? 'Unpin column (allow dragging)' : 'Pin column (lock in place)', draggable: isLocked ? 'false' : 'true', sortIndicator: isSorted ? (dir === 'asc' ? '▲' : '▼') : '↕', headerClass: cls.join(' '), headerTitle: isLocked ? 'Pinned column · click to sort' : 'Click to sort · drag to reorder' }; }); } // API name of the pinned first column — used to block reorder attempts. // Returns null when the first column is unpinned (so it becomes draggable). get _lockedColumnApi() { if (!this.firstColumnPinned) return null; const visible = this._visibleFields(); return visible.length ? visible[0].api : null; } // Pin/unpin the first report column. Stops propagation so the click on the // pin icon doesn't also trigger the column's sort handler. togglePin(event) { if (event) event.stopPropagation(); this.firstColumnPinned = !this.firstColumnPinned; } // ---- Column drag-and-drop reorder ---- _draggingField = null; handleColDragStart(event) { const th = event.currentTarget || (event.target && event.target.closest ? event.target.closest('th') : null); const field = th && th.dataset ? th.dataset.field : null; // Block dragging the pinned column. Primary signal is the col-locked CLASS // (it matches the visual pinned state and doesn't depend on the draggable // attribute behaving in Locker); api match is a backup. Leaving // _draggingField null means a drop can never reposition this column. const isPinnedCol = (th && th.classList && th.classList.contains('col-locked')) || (field && field === this._lockedColumnApi); if (isPinnedCol) { event.preventDefault(); this._draggingField = null; return false; } this._draggingField = field; if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'move'; // Required for Firefox — must call setData to start drag event.dataTransfer.setData('text/plain', field); } } handleColDragOver(event) { // Must call preventDefault to allow drop event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; } handleColDrop(event) { event.preventDefault(); const targetField = event.currentTarget.dataset.field; const sourceField = this._draggingField; this._draggingField = null; if (!sourceField || !targetField || sourceField === targetField) return; // The anchored first column can't be moved, and nothing can be dropped // into its slot (drop onto column 2+ to position instead). const lockedApi = this._lockedColumnApi; if (sourceField === lockedApi || targetField === lockedApi) return; const list = this._fieldsByObject[this.activeObjectId]; if (!list) return; // Reorder by visible position — sourceField takes targetField's slot, // others shift accordingly. We only touch visible fields' order values. const visible = list.slice().filter(f => f.visible).sort((a, b) => a.order - b.order); const srcIdx = visible.findIndex(f => f.api === sourceField); const tgtIdx = visible.findIndex(f => f.api === targetField); if (srcIdx === -1 || tgtIdx === -1) return; // Splice out source, insert at target index const moved = visible.splice(srcIdx, 1)[0]; visible.splice(tgtIdx, 0, moved); // Re-assign order based on new visible sequence; hidden fields keep their // existing order (we shift them to AFTER the visible ones so the visible // sequence is contiguous and predictable). const updated = list.map(f => { const idx = visible.findIndex(v => v.api === f.api); if (idx !== -1) return { ...f, order: idx }; return f; }); // Hidden fields — give them orders beyond visible.length so they sort after const hiddenStart = visible.length; let h = hiddenStart; const finalList = updated.map(f => { if (f.visible) return f; return { ...f, order: h++ }; }); this._fieldsByObject = { ...this._fieldsByObject, [this.activeObjectId]: finalList }; } handleColDragEnd() { this._draggingField = null; } get pageButtons() { const total = this.reportTotalPages; const cur = this.reportPage; let start = Math.max(1, cur - 1); let end = Math.min(total, start + 3); if (end - start < 3) start = Math.max(1, end - 3); const out = []; for (let p = start; p <= end; p++) { out.push({ key: 'pg-' + p, num: p, cssClass: p === cur ? 'pg active' : 'pg' }); } return out; } get showPrevButton() { return this.reportPage > 1; } get showNextButton() { return this.reportPage < this.reportTotalPages; } get reportRangeLabel() { const total = this.reportRows.length; if (total === 0) return 'No records'; const start = (this.reportPage - 1) * PAGE_SIZE + 1; const end = Math.min(this.reportPage * PAGE_SIZE, total); return start + '–' + end + ' of ' + total; } handleSort(event) { const field = event.currentTarget.dataset.field; if (this.reportSortField === field) { this.reportSortDir = this.reportSortDir === 'asc' ? 'desc' : 'asc'; } else { this.reportSortField = field; this.reportSortDir = 'asc'; } } handlePagePrev() { if (this.reportPage > 1) this.reportPage--; } handlePageNext() { if (this.reportPage < this.reportTotalPages) this.reportPage++; } handlePageGo(event) { this.reportPage = parseInt(event.currentTarget.dataset.page, 10); } // ----------- Field selector ----------- get fieldOptions() { const list = this._ensureFields(this.activeObjectId); const q = (this.fieldFilterText || '').toLowerCase().trim(); const filtered = q ? list.filter(f => f.label.toLowerCase().includes(q) || f.api.toLowerCase().includes(q)) : list; const lastIdx = list.length - 1; return filtered.slice().sort((a, b) => a.order - b.order).map((f) => { // The order-0 field is the anchored first column: it can't be hidden, // can't move, and nothing can move above it. const isLocked = f.order === 0; return { ...f, key: 'fld-' + f.api, isLocked, visibleMark: f.visible ? '✓' : '', wrapperClass: f.visible ? 'field-row on' : 'field-row', // LWC templates can't use `!` — so we expose pre-negated flags whose // names match the `disabled` attribute semantics on the button. // disableMoveUp = true at the top AND at order 1 (can't pass the anchor). disableMoveUp: f.order <= 1, disableMoveDown: isLocked || f.order >= lastIdx }; }); } get fieldMenuClass() { return this.fieldMenuOpen ? 'field-menu open' : 'field-menu'; } toggleFieldMenu(event) { if (event) event.stopPropagation(); this.fieldMenuOpen = !this.fieldMenuOpen; this._closeOtherMenus('field'); } toggleField(event) { if (event) event.stopPropagation(); const api = event.currentTarget.dataset.api; const list = this._fieldsByObject[this.activeObjectId]; if (!list) return; const target = list.find(f => f.api === api); // The anchored first column (order 0) must always stay visible. if (target && target.order === 0 && target.visible) return; const becomingVisible = target && !target.visible; const updated = list.map(f => f.api === api ? { ...f, visible: !f.visible } : f); this._fieldsByObject = { ...this._fieldsByObject, [this.activeObjectId]: updated }; // If we just enabled a field, the cached row data may not include it — // invalidate and re-fetch so the new column actually shows values. if (becomingVisible) { this._invalidateRowsForActiveObject(); this._ensureRows(this.activeObjectId); } } _invalidateRowsForActiveObject() { // BUG FIX: row-cache keys are "store|object|dateRange" — this was built // without dateRange, so invalidation never matched and newly enabled // columns rendered empty (stale cached rows missing the new field). const dr = this.dateRange || 'All Time'; const storeKey = (this.selectedStoreName || '__all__') + '|' + this.activeObjectId + '|' + dr; if (this._rowsByObject[storeKey]) { const next = { ...this._rowsByObject }; delete next[storeKey]; this._rowsByObject = next; } } moveFieldUp(event) { event.stopPropagation(); this._moveField(event.currentTarget.dataset.api, -1); } moveFieldDown(event) { event.stopPropagation(); this._moveField(event.currentTarget.dataset.api, 1); } _moveField(api, delta) { const list = this._fieldsByObject[this.activeObjectId]; if (!list) return; const sorted = list.slice().sort((a, b) => a.order - b.order); const idx = sorted.findIndex(f => f.api === api); const swap = idx + delta; if (idx === -1 || swap < 0 || swap >= sorted.length) return; // First column is anchored — can't move it, and can't push anything above it. if (idx === 0 || swap === 0) return; const a = sorted[idx]; const b = sorted[swap]; const updated = list.map(f => { if (f.api === a.api) return { ...f, order: b.order }; if (f.api === b.api) return { ...f, order: a.order }; return f; }); this._fieldsByObject = { ...this._fieldsByObject, [this.activeObjectId]: updated }; } handleFieldSearch(event) { event.stopPropagation(); this.fieldFilterText = event.target.value; } stopMenuPropagation(event) { event.stopPropagation(); } _resetReportState() { this.reportPage = 1; this.reportSortField = ''; this.reportSortDir = 'asc'; this.fieldMenuOpen = false; this.fieldFilterText = ''; } _resetDashboardState() { this._realDashboardData = {}; this._dashFetching = {}; this._kpisByObject = {}; this._kpisFetching = {}; this.chartTypeByDashboard = {}; } // ----------- Export menu ----------- exportMenuOpen = false; get exportMenuClass() { return this.exportMenuOpen ? 'menu open' : 'menu'; } toggleExportMenu(event) { if (event) event.stopPropagation(); this.exportMenuOpen = !this.exportMenuOpen; this._closeOtherMenus('export'); } handleExport(event) { const fmt = event.currentTarget.dataset.fmt; this.exportMenuOpen = false; try { switch (fmt) { case 'csv': this._downloadCsv(); break; case 'json': this._downloadJson(); break; case 'excel': this._downloadExcel(); break; case 'word': this._downloadWord(); break; case 'pdf': this._downloadPdf(); break; default: this._toast('Unknown export format: ' + fmt, 'error'); } } catch (e) { // eslint-disable-next-line no-console console.error('[ShopifySyncDashboard] export failed', e); this._toast('Export failed: ' + (e && e.message ? e.message : 'unknown error'), 'error'); } } // ----- Export helpers (client-side, no Apex roundtrip) ----- _downloadCsv() { const fields = this._visibleFields(); const header = fields.map(f => this._csvCell(f.label)).join(','); const body = this.reportRows.map(r => fields.map(f => this._csvCell(r[f.api])).join(',') ).join('\n'); // BOM so Excel opens UTF-8 cleanly. MIME without charset suffix — // LWS Blob allowlist sometimes rejects ";charset=utf-8". this._triggerDownload('' + header + '\n' + body, this._exportFilename('csv'), 'text/csv'); } _csvCell(v) { if (v === null || v === undefined) return '""'; const s = String(v).replace(/"/g, '""'); return '"' + s + '"'; } _downloadJson() { const fields = this._visibleFields(); const data = this.reportRows.map(r => { const o = {}; fields.forEach(f => { o[f.label] = r[f.api] === undefined ? null : r[f.api]; }); return o; }); this._triggerDownload(JSON.stringify(data, null, 2), this._exportFilename('json'), 'application/json'); } _downloadExcel() { // SpreadsheetML keeps record identifiers as text, so Excel does not // strip leading zeroes or convert long Shopify IDs to scientific notation. const xml = this._reportAsExcelXml(); this._triggerDownload(xml, this._exportFilename('xls'), 'application/octet-stream'); } _downloadWord() { // Same MIME swap as Excel — Word opens .doc by extension, doesn't // need the application/msword MIME that LWS blocks. const html = this._reportAsWordHtml(); this._triggerDownload(html, this._exportFilename('doc'), 'application/octet-stream'); } async _downloadPdf() { try { const { content, isBase64 } = await this._buildShareContent('pdf'); if (!isBase64 || !content || !/^[A-Za-z0-9+/]+=*$/.test(content)) { throw new Error('Server did not return valid PDF data — check generatePdfBase64 in Apex debug logs.'); } const byteChars = atob(content); const byteNumbers = new Array(byteChars.length); for (let i = 0; i < byteChars.length; i++) { byteNumbers[i] = byteChars.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); const anchor = this.template.querySelector('a.hidden-download-anchor'); const blob = new Blob([byteArray], { type: 'application/pdf' }); const blobUrl = URL.createObjectURL(blob); this._lastDownloadUrl = blobUrl; anchor.href = blobUrl; anchor.download = this._exportFilename('pdf'); anchor.target = '_self'; anchor.rel = 'noopener'; anchor.click(); this._toast('PDF downloaded', 'success'); } catch (e) { console.error('[ShopifySyncDashboard] PDF export failed', e); this._toast('PDF export failed: ' + (e?.message || 'unknown error'), 'error'); } } _exportCellValue(row, field) { const raw = row[field.api]; const lookupName = row[field.api + '__name']; const type = String(field.type || '').toLowerCase(); if (lookupName !== undefined && raw) { return lookupName; } if ((type === 'date' || type === 'datetime') && raw) { return this._formatIsoMinute(raw); } if (type === 'currency' && raw !== null && raw !== undefined && raw !== '') { const num = Number(raw); if (!isNaN(num)) { return '$' + num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } } return (raw === undefined || raw === null) ? '' : String(raw); } _reportAsExcelXml() { const fields = this._visibleFields(); const rows = this.reportRows; const sheetName = this._excelSheetName(this.activeObject.name + ' Report'); let xml = '' + '' + '' + '' + 'Sync made easy' + '' + this._escapeXml(this.activeObject.name + ' Report') + '' + '' + '' + '' + '' + '' + ''; fields.forEach(f => { xml += ''; }); xml += ''; fields.forEach(f => { xml += '' + this._escapeXml(f.label) + ''; }); xml += ''; rows.forEach(row => { xml += ''; fields.forEach(f => { xml += '' + this._escapeXml(this._exportCellValue(row, f)) + ''; }); xml += ''; }); xml += '
' + '11' + 'FalseFalse' + '
'; return xml; } _excelColumnWidth(field, rows) { const label = field.label || ''; const fieldKey = (label + ' ' + field.api).toLowerCase(); let longest = label.length; rows.slice(0, 50).forEach(row => { longest = Math.max(longest, String(this._exportCellValue(row, field)).length); }); if (fieldKey.includes('description')) return 220; if (fieldKey.includes('date') || fieldKey.includes('updated') || fieldKey.includes('created')) return 145; if (fieldKey.includes('id')) return 135; if (fieldKey.includes('name')) return 150; if (fieldKey.includes('amount') || fieldKey.includes('price') || fieldKey.includes('total')) return 95; return Math.max(80, Math.min(180, (longest * 7) + 18)); } _excelSheetName(name) { const cleaned = String(name || 'Report') .replace(/[\[\]\:\*\?\/\\]/g, ' ') .replace(/\s+/g, ' ') .trim(); return (cleaned || 'Report').substring(0, 31); } _reportAsWordHtml() { const title = this._escapeHtml(this.activeObject.name + ' Report'); const table = this._reportAsHtml(true, 'word-report-table'); return '' + '' + '
' + '

' + title + '

' + table + '
'; } _reportAsHtml(escape, tableClass) { const fields = this._visibleFields(); const esc = (v) => escape ? this._escapeHtml(v) : String(v); const cls = tableClass ? ' class="' + this._escapeHtml(tableClass) + '"' : ''; let html = ''; fields.forEach(f => { html += '' + esc(f.label) + ''; }); html += ''; this.reportRows.forEach(r => { html += ''; fields.forEach(f => { const v = this._exportCellValue(r, f); html += '' + esc(v) + ''; }); html += ''; }); html += ''; return html; } _escapeXml(v) { if (v === null || v === undefined) return ''; return String(v) .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ' ') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } _escapeHtml(v) { if (v === null || v === undefined) return ''; return String(v) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } _exportFilename(ext) { const d = new Date(); const stamp = d.getFullYear() + String(d.getMonth() + 1).padStart(2, '0') + String(d.getDate()).padStart(2, '0') + '_' + String(d.getHours()).padStart(2, '0') + String(d.getMinutes()).padStart(2, '0'); return 'shopify-sync_' + this.activeObjectId + '_' + stamp + '.' + ext; } _triggerDownload(content, filename, mime) { // Use a data URI on a template-rendered anchor — avoids Blob.createObjectURL // which Salesforce Locker Service (LS) blocks ("Cannot createObjectURL using // an unsecure Blob") and avoids LS MIME-type blocklists on new Blob([...]). // application/octet-stream is used as the MIME regardless of the requested // type; the download attribute controls the filename and lets the OS decide // how to open the file. try { const anchor = this.template.querySelector('a.hidden-download-anchor'); if (!anchor) { this._toast('Internal error: download anchor not found in template', 'error'); return; } anchor.href = 'data:application/octet-stream;charset=utf-8,' + encodeURIComponent(content); anchor.download = filename; anchor.target = '_self'; anchor.rel = 'noopener'; anchor.click(); this._toast('Exported ' + filename, 'success'); } catch (e) { // eslint-disable-next-line no-console console.error('[ShopifySyncDashboard] export failed', e); this._toast('Export failed: ' + (e && e.message ? e.message : 'browser blocked the download'), 'error'); } } // Revoke the last blob URL when the component is destroyed _lastDownloadUrl = null; // ----------- Share modal ----------- showShareModal = false; shareStep = 1; shareFormat = ''; openShareModal(event) { if (event) event.stopPropagation(); this.showShareModal = true; this.shareStep = 1; this.shareFormat = ''; } closeShareModal() { this.showShareModal = false; } selectShareFormat(event) { this.shareFormat = event.currentTarget.dataset.fmt; this.shareStep = 2; } backToShareStep1() { this.shareStep = 1; this.shareFormat = ''; } selectShareDestination(event) { const dest = event.currentTarget.dataset.dest; if (dest === 'More') { this._shareMoreNative(); return; } // For all other destinations, generate a public file link and share THAT // instead of the dashboard page URL. this._createAndShareForDestination(dest); } async _createAndShareForDestination(dest) { try { this._toast('Generating shareable file…', 'info'); const { content, ext, isBase64 } = await this._buildShareContent(); const fileName = this._exportFilename(ext); const base64Data = isBase64 ? content : window.btoa(unescape(encodeURIComponent(content))); const publicUrl = await createPublicFileLink({ fileName, base64Data }); if (!publicUrl) { this._toast('Could not create shareable link — check Content Deliveries are enabled in Setup', 'error'); this.showShareModal = false; return; } const subject = 'Shopify Sync Dashboard — ' + this.activeObject.name + ' ' + (this.shareFormat || '').toUpperCase() + ' Report'; const bodyText = 'Shopify Sync Dashboard — ' + this.activeObject.name + ' ' + (this.shareFormat || '').toUpperCase() + ' Report\n\n' + 'Download: ' + publicUrl; let url = null; const eSubj = encodeURIComponent(subject); const eBody = encodeURIComponent(bodyText); const eText = encodeURIComponent(bodyText); const ePublicUrl = encodeURIComponent(publicUrl); // Copy the link to clipboard FIRST — so if the platform's URL scheme // can't pre-fill the message (paste-required platforms), the user // only needs to press Ctrl/Cmd+V after picking a recipient. // For platforms that DO pre-fill (WhatsApp/Gmail/Outlook), this is // still a no-cost safety net. this._copyToClipboard(bodyText); switch (dest) { case 'WhatsApp': // wa.me opens contact picker; text is pre-filled after picking url = 'https://wa.me/?text=' + eText; break; case 'Outlook': // deeplink/compose opens Outlook Web compose with To field focused + body filled url = 'https://outlook.office.com/mail/deeplink/compose?subject=' + eSubj + '&body=' + eBody; break; case 'Gmail': // fs=1 forces full compose view; tf=cm focuses To field; su + body pre-fill the rest url = 'https://mail.google.com/mail/?view=cm&tf=1&fs=1&su=' + eSubj + '&body=' + eBody; break; case 'Teams': // Teams share URL opens the "Share to Teams" dialog with recipient picker // and message pre-filled. href = the link being shared, msgText = surrounding note. url = 'https://teams.microsoft.com/share?href=' + ePublicUrl + '&msgText=' + eText + '&preview=true'; break; case 'Slack': // Slack has no compose-URL scheme. Best we can do: link already copied above, // open Slack, user picks channel/DM, Ctrl/Cmd+V + Enter to send. try { window.open('https://app.slack.com/client', '_blank', 'noopener,noreferrer'); } catch (e) { /* swallow */ } this._toast( 'Slack opened — pick a recipient, then Ctrl/Cmd+V + Enter to send (link already copied)', 'success' ); this.showShareModal = false; return; case 'Email': url = 'mailto:?subject=' + eSubj + '&body=' + eBody; break; default: this._toast('Share via ' + dest + ' is not configured', 'error'); this.showShareModal = false; return; } if (url) { try { window.open(url, '_blank', 'noopener,noreferrer'); // Tailored guidance per platform so the user knows what to expect const msg = (dest === 'WhatsApp') ? 'WhatsApp opened — pick a contact, message is already filled, just tap Send' : (dest === 'Gmail' || dest === 'Outlook' || dest === 'Email') ? dest + ' opened — recipient field is focused, body is filled, just add To + Send' : (dest === 'Teams') ? 'Teams opened — pick a recipient in the share dialog, link is already attached' : 'Opening ' + dest + '…'; this._toast(msg, 'success'); } catch (e) { this._toast('Could not open ' + dest + ' — popup may be blocked. Link: ' + publicUrl, 'error'); } } } catch (err) { console.error('[ShopifySyncDashboard] _createAndShareForDestination failed', err); this._toast('Share failed: ' + this._errMsg(err), 'error'); } this.showShareModal = false; } /** * "More" → open the OS native share sheet sharing the actual file (PDF/Excel/CSV). * Falls back to the curated sub-modal if Web Share with files isn't available. */ async _shareMoreNative() { const hasShare = typeof navigator !== 'undefined' && typeof navigator.share === 'function'; // 1) Best case — share the ACTUAL file through the OS native share sheet. let file = null; if (hasShare) { try { file = await this._buildShareFile(); } catch (e) { file = null; } } if (file && (typeof navigator.canShare !== 'function' || navigator.canShare({ files: [file] }))) { try { await navigator.share({ files: [file], title: 'Sync Made Easy — ' + this.activeObject.name + ' Report', text: this._shareSummary() }); this.showShareModal = false; this._toast('Shared ' + file.name, 'success'); return; } catch (err) { if (err && err.name === 'AbortError') { this.showShareModal = false; return; } // fall through } } // 2) Fallback — open the OS share sheet with text + link only (some contexts // allow this even when file sharing is blocked). if (hasShare) { try { await navigator.share({ title: 'Sync Made Easy — ' + this.activeObject.name + ' Report', text: this._shareSummary(), url: window.location.href }); this.showShareModal = false; return; } catch (err) { if (err && err.name === 'AbortError') { this.showShareModal = false; return; } // fall through } } // 3) The Salesforce iframe doesn't permit the Web Share API → curated sub-modal. this._openMoreShareSubModal(); } /** * Builds the report as a real File of the user-selected format. * - CSV → text/csv (.csv) * - Excel → application/octet-stream (.xls) [LWS rejects ms-excel MIME] * - PDF → printable HTML (.html) [no client-side PDF engine] */ async _buildShareContent(formatOverride) { const fmt = (formatOverride || this.shareFormat || 'csv').toLowerCase(); let content, mime, ext, isBase64; if (fmt === 'excel') { content = this._reportAsExcelXml(); mime = 'application/octet-stream'; ext = 'xls'; isBase64 = false; } else if (fmt === 'pdf') { const headers = this._visibleFields().map(f => f.label); const rows = this.reportRows.map(r => this._visibleFields().map(f => { const v = r[f.api]; return (v === undefined || v === null) ? '' : String(v); }) ); const title = this.activeObject.name + ' Report'; this._toast('Generating PDF…', 'info'); const base64Pdf = await generatePdfBase64({ headers: headers, rows: rows, title: title }); content = base64Pdf; mime = 'application/pdf'; ext = 'pdf'; isBase64 = true; } else { const fields = this._visibleFields(); const header = fields.map(f => this._csvCell(f.label)).join(','); const body = this.reportRows.map(r => fields.map(f => this._csvCell(r[f.api])).join(',') ).join('\n'); content = '\ufeff' + header + '\n' + body; mime = 'text/csv'; ext = 'csv'; isBase64 = false; } return { content, mime, ext, isBase64 }; } async _buildShareFile() { const fmt = (this.shareFormat || 'csv').toLowerCase(); if (fmt === 'pdf') { const fields = this._visibleFields(); const headers = fields.map(f => f.label); const rows = this.reportRows.map(r => fields.map(f => String(r[f.api] ?? ''))); const base64 = await generatePdfBase64({ headers, rows, title: this.activeObject.name + ' Report' }); // MUST decode before building the File — otherwise the "PDF" is just text const byteChars = atob(base64); const byteNumbers = new Array(byteChars.length); for (let i = 0; i < byteChars.length; i++) { byteNumbers[i] = byteChars.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); return new File([byteArray], this._exportFilename('pdf'), { type: 'application/pdf' }); } const { content, mime, ext } = await this._buildShareContent(); return new File([content], this._exportFilename(ext), { type: mime }); } /** * "Copy Link" — uploads the report (in the format picked in step 1) as a * ContentVersion + ContentDistribution and copies the PUBLIC download URL. * Anyone opening that link downloads the file directly — no SF login. */ _generatingShareLink = false; async _createAndCopyDownloadLink() { if (this._generatingShareLink) return; this._generatingShareLink = true; try { const { content, ext, isBase64 } = await this._buildShareContent(); const fileName = this._exportFilename(ext); this._toast('Generating public download link…', 'info'); let base64Data; if (isBase64) { base64Data = content; // PDF from Apex is already base64 } else { base64Data = window.btoa(unescape(encodeURIComponent(content))); } const url = await createPublicFileLink({ fileName, base64Data }); if (!url) { this._toast('Link could not be created — check Content Deliveries are enabled in Setup', 'error'); return; } const copied = this._copyToClipboard(url); this._toast( copied ? 'Public download link copied — anyone opening it downloads ' + fileName : 'Link created but clipboard blocked — copy manually: ' + url, copied ? 'success' : 'warning' ); } catch (err) { console.error('[ShopifySyncDashboard] createPublicFileLink failed', err); this._toast('Could not create download link: ' + this._errMsg(err), 'error'); } finally { this._generatingShareLink = false; } } _shareSummary() { const fmt = (this.shareFormat || 'report').toUpperCase(); return 'Shopify Sync Dashboard — ' + this.activeObject.name + ' ' + fmt + ' report\n' + window.location.href; } /** * Reuses the existing Export pipeline to save the report in whatever * format the user picked in the share-format step (PDF/Excel/CSV). * That file then lives in their Downloads — they can right-click it to * get the OS "Open with" dialog (which browsers can't trigger directly). */ _downloadReportForCurrentShareFormat() { const fmt = (this.shareFormat || 'csv').toLowerCase(); try { switch (fmt) { case 'pdf': this._downloadPdf(); break; case 'excel': this._downloadExcel(); break; case 'csv': this._downloadCsv(); break; default: this._downloadCsv(); } } catch (e) { // eslint-disable-next-line no-console console.error('[ShopifySyncDashboard] save-file download failed', e); this._toast('Save failed: ' + (e && e.message ? e.message : 'unknown'), 'error'); } } // ----------- More share options sub-modal ----------- // Acts as a curated "OS share sheet" replacement when Web Share API isn't // available. Lets users pick from X / LinkedIn / Facebook / Telegram / SMS // / Copy Link / Print — the closest equivalent to the system share dialog // we can offer from a sandboxed LWC. showMoreShareModal = false; _openMoreShareSubModal() { this.showMoreShareModal = true; // Keep the original share-format modal closed so the sub-modal stands alone this.showShareModal = false; } closeMoreShareModal() { this.showMoreShareModal = false; } // Back → return to the destination picker (step 2) of the main share modal. backFromMoreShare() { this.showMoreShareModal = false; this.showShareModal = true; this.shareStep = 2; } async handleMoreShareOption(event) { event.stopPropagation(); this.showMoreShareModal = false; const channel = event.currentTarget.dataset.channel; const text = encodeURIComponent(this._shareSummary()); const url = encodeURIComponent(window.location.href); const title = encodeURIComponent('Shopify Sync Dashboard — ' + this.activeObject.name); let openUrl = null; switch (channel) { case 'copy': // Copies a PUBLIC download link to the actual file (selected format) — // previously this copied the dashboard URL, which was useless to recipients. await this._createAndCopyDownloadLink(); this.showMoreShareModal = false; return; case 'saveFile': this._downloadReportForCurrentShareFormat(); this._toast( 'File saved to your Downloads. Right-click → Open with → pick any app.', 'success' ); this.showMoreShareModal = false; return; case 'email': openUrl = 'mailto:?subject=' + title + '&body=' + text; break; case 'print': // Phase 1: Close modals in LWC state this.showMoreShareModal = false; this.showShareModal = false; // Phase 2: Force synchronous DOM removal using imperative API // We MUST remove the modal elements from shadow DOM before window.print() this._removeModalsAndPrint(); return; default: this._toast('Unknown share channel: ' + channel, 'error'); this.showMoreShareModal = false; return; } try { window.open(openUrl, '_blank', 'noopener,noreferrer'); this._toast('Opening ' + channel.charAt(0).toUpperCase() + channel.slice(1) + '…', 'success'); } catch (e) { this._toast('Could not open ' + channel + ' — popup may be blocked', 'error'); } this.showMoreShareModal = false; } _removeModalsAndPrint() { // Immediate removal: query and hide all modal backdrops in shadow DOM const shadowBackdrops = this.template.querySelectorAll('.modal-backdrop'); shadowBackdrops.forEach(el => { el.style.display = 'none'; el.setAttribute('aria-hidden', 'true'); }); // Also try to find and hide modals in parent document (Salesforce header, etc.) // This requires escaping the shadow boundary try { // Access the host element and traverse up to find parent modals let host = this.template.host; while (host && host.parentNode) { host = host.parentNode; if (host.querySelectorAll) { const parentModals = host.querySelectorAll('.modal-backdrop, .slds-modal, .slds-backdrop'); parentModals.forEach(el => el.style.display = 'none'); } } } catch (e) { // Silent fail — LWS may block parent traversal } // Use double requestAnimationFrame to ensure browser has painted the hidden state requestAnimationFrame(() => { requestAnimationFrame(() => { // Final safety: hide any modal that might have reappeared const finalCheck = this.template.querySelectorAll('.modal-backdrop'); finalCheck.forEach(el => el.style.display = 'none'); // NOW print — modal should be gone from visual output window.print(); // Restore modals after print dialog closes (user interaction blocks here) // Note: window.print() is blocking in most browsers, so this runs after setTimeout(() => { shadowBackdrops.forEach(el => { el.style.display = ''; el.removeAttribute('aria-hidden'); }); }, 500); }); }); } _buildShareUrl(dest) { const subject = 'Shopify Sync Dashboard — ' + this.activeObject.name + ' ' + (this.shareFormat || '').toUpperCase() + ' Report'; const body = 'Hi,\n\nSharing the ' + (this.shareFormat || 'report').toUpperCase() + ' report for ' + this.activeObject.name + ' from Shopify Sync Dashboard.\n\n' + 'Dashboard URL: ' + window.location.href + '\n\nThanks'; const eSubj = encodeURIComponent(subject); const eBody = encodeURIComponent(body); const eText = encodeURIComponent(subject + '\n' + window.location.href); const eHref = encodeURIComponent(window.location.href); switch (dest) { case 'WhatsApp': return 'https://wa.me/?text=' + eText; case 'Outlook': return 'https://outlook.office.com/mail/deeplink/compose?subject=' + eSubj + '&body=' + eBody; case 'Gmail': return 'https://mail.google.com/mail/?view=cm&fs=1&su=' + eSubj + '&body=' + eBody; case 'Teams': return 'https://teams.microsoft.com/share?msgText=' + eText + '&href=' + eHref; case 'Email': return 'mailto:?subject=' + eSubj + '&body=' + eBody; default: return null; } } _copyToClipboard(text) { // Strategy 1: modern Clipboard API (requires secure context & gesture) if (navigator.clipboard && navigator.clipboard.writeText) { try { navigator.clipboard.writeText(text); return true; } catch (e) { /* fall through */ } } // Strategy 2: execCommand fallback for older browsers / iframe contexts try { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.left = '-9999px'; ta.style.top = '0'; document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, text.length); const ok = document.execCommand('copy'); document.body.removeChild(ta); return ok; } catch (e) { return false; } } get shareStep1() { return this.shareStep === 1; } get shareStep2() { return this.shareStep === 2; } get shareTitle() { return this.shareStep === 1 ? 'Choose file format' : 'Choose destination'; } get shareFormatLabel() { return this.shareFormat ? this.shareFormat.toUpperCase() : ''; } // ----------- Refresh: 4h cooldown + per-object recount via refreshApex ----------- lastRefreshTs = Date.now() - REFRESH_INTERVAL_MS - 1000; countdownHandle = null; modalCountdownHandle = null; showCooldownModal = false; cooldownRemainingLabel = ''; nextRefreshLabel = ''; _pageRefreshHandle = null; /** * Silent page refresh — runs every 5 minutes. Re-fetches Salesforce * counts (refreshApex), row data, and last-order times WITHOUT hitting * the Shopify API callout (that stays on its own 4hr cooldown). */ _silentPageRefresh() { // Refresh SF counts wire if (this._wiredCounts) { refreshApex(this._wiredCounts); } // Invalidate cached rows and re-fetch for the active object this._rowsByObject = {}; this._rowsLoading = {}; this._ensureRows(this.activeObjectId); // Refresh last-order times for Sync Health this._fetchLastOrderTimes(); // Refresh dashboard chart data + KPIs this._realDashboardData = {}; this._dashFetching = {}; this._kpisByObject = {}; this._kpisFetching = {}; this._fetchDashboardData(this.activeObjectId); this._fetchKpis(this.activeObjectId); } refreshing = false; refreshDone = false; refreshObjectIdx = 0; refreshObjectsTotal = OBJECT_DEFS.length; refreshCurrentObjectLabel = ''; refreshBannerClass = 'refresh-banner'; connectedCallback() { // FIX (Bug #2): restore lastRefreshTs from localStorage so the 4hr // cooldown survives users navigating away to another tab/page in // Salesforce. LWCs are destroyed on tab switch, so in-memory state // alone resets the timer on every reconnect. const stored = this._readStoredRefreshTs(); if (stored && !isNaN(stored) && stored > 0) { this.lastRefreshTs = stored; } // Restore theme and selected store ID from localStorage try { const storedTheme = localStorage.getItem(LS_THEME_KEY); if (storedTheme) { this.theme = storedTheme; } const storedStoreId = localStorage.getItem(LS_STORE_ID_KEY); if (storedStoreId) { this.selectedStoreId = storedStoreId; } } catch (e) { // eslint-disable-next-line no-console console.error('[ShopifySyncDashboard] failed to read theme or storeId from localStorage', e); } this.countdownHandle = setInterval(() => this._tickCountdown(), COUNTDOWN_TICK_MS); this._tickCountdown(); // Silent page-level data refresh every 5 minutes — re-fetches SF counts, // row data, and last-order times so the dashboard stays current without // hitting the Shopify API callout cooldown. this._pageRefreshHandle = setInterval(() => this._silentPageRefresh(), PAGE_REFRESH_MS); // Kick off the initial real-data fetch for the default active object. // (Subsequent objects fetch on first click via handleObjectClick.) if (this.activeObjectId) { this._ensureFields(this.activeObjectId); this._ensureRows(this.activeObjectId); this._fetchDashboardData(this.activeObjectId); this._fetchKpis(this.activeObjectId); } this._beforePrintHandler = () => { this._wasMoreShareModalOpenForPrint = this.showMoreShareModal; this._wasShareModalOpenForPrint = this.showShareModal; this.showMoreShareModal = false; this.showShareModal = false; this.showMoreShareModal = false; this.showScheduleModal = false; this.showRecordDetailModal = false; this.showFilterInfoModal = false; this.showCooldownModal = false; }; this._afterPrintHandler = () => { if (this._wasMoreShareModalOpenForPrint) this.showMoreShareModal = true; if (this._wasShareModalOpenForPrint) this.showShareModal = true; }; window.addEventListener('beforeprint', this._beforePrintHandler); window.addEventListener('afterprint', this._afterPrintHandler); } disconnectedCallback() { if (this.countdownHandle) clearInterval(this.countdownHandle); if (this.modalCountdownHandle) clearInterval(this.modalCountdownHandle); if (this._pageRefreshHandle) clearInterval(this._pageRefreshHandle); // Free any lingering blob URL from the last export if (this._lastDownloadUrl) { try { (window.URL || window.webkitURL).revokeObjectURL(this._lastDownloadUrl); } catch (e) { /* swallow */ } this._lastDownloadUrl = null; } // Restore body scroll so we don't leak the lock if the component is removed mid-modal. this._setBodyScrollLock(false); window.removeEventListener('beforeprint', this._beforePrintHandler); window.removeEventListener('afterprint', this._afterPrintHandler); } /** * Whenever the modal-state changes, this is recomputed and we sync * document.body.style.overflow accordingly. The LWS sandbox lets us mutate * style properties on existing elements (we're not creating or removing them). * This is what actually freezes the SALESFORCE PAGE behind the LWC — the .app * CSS lock only freezes the LWC's internal scroll, not the parent Lightning page. */ renderedCallback() { const anyModalOpen = this.showCooldownModal || this.showShareModal || this.showScheduleModal || this.showRecordDetailModal || this.showFilterInfoModal || this.showMoreShareModal; if (anyModalOpen !== this._bodyScrollLocked) { this._setBodyScrollLock(anyModalOpen); } // Hard-pin the first report column: set the DOM `draggable` property to // the boolean false directly. LWC/LWS can reflect the draggable *attribute* // as a truthy string ("false"), which still leaves the element draggable — // setting the property guarantees it cannot be dragged. Also defensively // clear draggable on any header that isn't the pinned one only if needed. try { const headers = this.template.querySelectorAll('th.col-head'); headers.forEach(th => { const isLocked = th.classList.contains('col-locked'); th.draggable = !isLocked; // boolean property th.setAttribute('draggable', isLocked ? 'false' : 'true'); // + attribute }); } catch (e) { // querySelectorAll is allowed in LWS; swallow defensively. } } _bodyScrollLocked = false; _setBodyScrollLock(lock) { try { if (lock) { this._prevBodyOverflow = document.body.style.overflow || ''; document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = this._prevBodyOverflow || ''; } this._bodyScrollLocked = lock; } catch (e) { // LWS may block in some configs — silent fallback (CSS lock still works for LWC internals) } } _storageKey() { // Namespace per user so multiple SF users sharing a browser profile // (e.g., a kiosk) keep independent countdowns. return LS_LAST_REFRESH_KEY + '.' + (USER_ID || 'anon'); } _readStoredRefreshTs() { try { const v = window.localStorage.getItem(this._storageKey()); return v ? parseInt(v, 10) : null; } catch (e) { // localStorage can be unavailable (privacy mode, iframe restrictions) return null; } } _writeStoredRefreshTs(ts) { try { window.localStorage.setItem(this._storageKey(), String(ts)); } catch (e) { // swallow — countdown will still work in-session } } _tickCountdown() { const elapsed = Date.now() - this.lastRefreshTs; if (elapsed >= REFRESH_INTERVAL_MS) { if (!this.refreshing) this._doRefresh(); return; } this.nextRefreshLabel = this._formatRoughDuration(REFRESH_INTERVAL_MS - elapsed); } get nextRefreshDisplay() { if (this.refreshing) return 'Refreshing…'; return this.nextRefreshLabel ? ('Next refresh in ' + this.nextRefreshLabel) : ''; } // Title attribute for the Refresh button — shown on hover (replaces the // always-visible "Next refresh in 3hr 34min" label that cluttered the top bar). get refreshButtonTitle() { if (this.refreshing) return 'Refresh in progress…'; const elapsed = Date.now() - this.lastRefreshTs; if (elapsed >= REFRESH_INTERVAL_MS) { return 'Refresh now — auto-refresh is due'; } const remaining = this._formatRoughDuration(REFRESH_INTERVAL_MS - elapsed); return 'Auto-refresh available in ' + remaining + '. Click for cooldown details, or use ⚡ Force to refresh now.'; } handleRefresh() { if (this.refreshing) return; const elapsed = Date.now() - this.lastRefreshTs; if (elapsed < REFRESH_INTERVAL_MS) { this.cooldownRemainingLabel = this._formatHms(REFRESH_INTERVAL_MS - elapsed); this.showCooldownModal = true; if (this.modalCountdownHandle) clearInterval(this.modalCountdownHandle); this.modalCountdownHandle = setInterval(() => { const e2 = Date.now() - this.lastRefreshTs; if (e2 >= REFRESH_INTERVAL_MS) { this.cooldownRemainingLabel = '00h 00m 00s'; clearInterval(this.modalCountdownHandle); this.modalCountdownHandle = null; } else { this.cooldownRemainingLabel = this._formatHms(REFRESH_INTERVAL_MS - e2); } }, MODAL_TICK_MS); return; } this._doRefresh(); } // Force Refresh — bypasses the 4hr cooldown. After completion, lastRefreshTs // is set to NOW (via _doRefresh's completion handler), so the next 4hr // countdown starts from this point. Persisted to localStorage so the // new countdown survives a tab navigation. handleForceRefresh() { if (this.refreshing) return; // Close the cooldown modal if it's open if (this.showCooldownModal) this.closeCooldownModal(); this._toast('Force-refreshing now — the 4hr cooldown will restart after this completes', 'info'); this._doRefresh(); } closeCooldownModal() { this.showCooldownModal = false; if (this.modalCountdownHandle) { clearInterval(this.modalCountdownHandle); this.modalCountdownHandle = null; } } _doRefresh() { this.refreshing = true; this.refreshDone = false; this.refreshObjectIdx = 0; this.refreshBannerClass = 'refresh-banner blinking'; this._refreshNextObject(); } _refreshNextObject() { if (this.refreshObjectIdx >= OBJECT_DEFS.length) { // All per-object steps animated — kick off all refreshers: // - refreshApex() on the cacheable SF count wire // - imperative Shopify callout // - last order times for Sync Health // - invalidate row cache so fresh records load this._fetchShopifyCounts(); this._fetchLastOrderTimes(); this._rowsByObject = {}; this._rowsLoading = {}; this._ensureRows(this.activeObjectId); this._realDashboardData = {}; this._dashFetching = {}; this._kpisByObject = {}; this._kpisFetching = {}; this._fetchDashboardData(this.activeObjectId); this._fetchKpis(this.activeObjectId); const p = this._wiredCounts ? refreshApex(this._wiredCounts) : Promise.resolve(); p.then(() => { this.refreshDone = true; this.refreshBannerClass = 'refresh-banner done'; this.refreshCurrentObjectLabel = 'Refresh completed'; this.lastRefreshTs = Date.now(); this._writeStoredRefreshTs(this.lastRefreshTs); this._tickCountdown(); setTimeout(() => { this.refreshing = false; this.refreshDone = false; this.refreshBannerClass = 'refresh-banner'; }, 1500); }).catch(err => { // eslint-disable-next-line no-console console.error('[ShopifySyncDashboard] refresh failed', err); this.refreshing = false; this.refreshBannerClass = 'refresh-banner'; }); return; } const obj = OBJECT_DEFS[this.refreshObjectIdx]; this.refreshCurrentObjectLabel = 'Recounting ' + obj.name + ' (' + (this.refreshObjectIdx + 1) + '/' + OBJECT_DEFS.length + ')'; this.refreshObjectIdx += 1; setTimeout(() => this._refreshNextObject(), PER_OBJECT_REFRESH_DELAY); } get refreshBannerVisible() { return this.refreshing; } get refreshProgressPct() { return Math.min(100, Math.round((this.refreshObjectIdx / OBJECT_DEFS.length) * 100)); } get refreshProgressStyle() { return 'width:' + this.refreshProgressPct + '%;'; } // ----------- Schedule Report modal ----------- showScheduleModal = false; schedSubmitting = false; schedReportDevName = ''; // DeveloperName of the picked report (picklist value) schedReportsAll = []; // [{label, developerName}] from Apex — package reports schedReportsLoading = false; schedReportsError = ''; schedFrequency = 'Daily'; // Daily | Weekly | Monthly | Yearly schedTime = '09:00'; // HH:MM 24h schedDayOfWeek = '2'; // 1=Sun..7=Sat (cron convention), default Monday schedDayOfMonth = '1'; // 1..28 schedMonth = '1'; // 1=Jan..12=Dec (used when Yearly) schedFormat = 'PDF'; // PDF | EXCEL | CSV schedRecipients = ''; // comma-separated emails schedError = ''; handleSchedule(event) { if (event) event.stopPropagation(); this.showScheduleModal = true; this.schedError = ''; this._loadSchedulableReports(); this._loadExistingSchedules(); } // ----------- Existing scheduled jobs (CronTrigger-backed, survives reloads) ----------- schedExistingJobs = []; schedJobsLoading = false; _loadExistingSchedules() { this.schedJobsLoading = true; getScheduledReports() .then(result => { this.schedExistingJobs = (result || []).map(j => ({ ...j, nextRunLabel: j.nextFireTime ? this._formatIsoMinute(j.nextFireTime) : '—', deleting: false })); }) .catch(err => { // eslint-disable-next-line no-console console.error('[ShopifySyncDashboard] getScheduledReports failed', err); this.schedExistingJobs = []; }) .finally(() => { this.schedJobsLoading = false; }); } get schedHasExistingJobs() { return this.schedExistingJobs.length > 0; } handleDeleteSchedule(event) { event.stopPropagation(); const jobId = event.currentTarget.dataset.jobId; if (!jobId) return; // Per-row busy flag so only the clicked row's button disables this.schedExistingJobs = this.schedExistingJobs.map(j => j.id === jobId ? { ...j, deleting: true } : j); deleteScheduledReport({ jobId }) .then(() => { this.schedExistingJobs = this.schedExistingJobs.filter(j => j.id !== jobId); this._toast('Scheduled report deleted', 'success'); }) .catch(err => { // eslint-disable-next-line no-console console.error('[ShopifySyncDashboard] deleteScheduledReport failed', err); this.schedExistingJobs = this.schedExistingJobs.map(j => j.id === jobId ? { ...j, deleting: false } : j); this._toast('Could not delete schedule: ' + this._errMsg(err), 'error'); }); } _loadSchedulableReports() { if (this.schedReportsAll.length || this.schedReportsLoading) { this._defaultSchedReportSelection(); return; } this.schedReportsLoading = true; this.schedReportsError = ''; getSchedulableReports() .then(result => { this.schedReportsAll = result || []; this._defaultSchedReportSelection(); }) .catch(err => { // eslint-disable-next-line no-console console.error('[ShopifySyncDashboard] getSchedulableReports failed', err); this.schedReportsError = 'Could not load reports: ' + this._errMsg(err); }) .finally(() => { this.schedReportsLoading = false; }); } // Pre-select the first report relevant to the active object whenever the // modal opens (or the previously picked report isn't in the current list). _defaultSchedReportSelection() { const opts = this.schedReportOptions; if (!opts.length) { this.schedReportDevName = ''; return; } if (!opts.some(o => o.value === this.schedReportDevName)) { this.schedReportDevName = opts[0].value; } } /** * Picklist options for the active object — users see the report NAME, * we schedule by DeveloperName. Filtered by keyword match against the * report name (e.g. product → "Products per store", "Shopify Products", * "SME Products"); falls back to the full package list when nothing matches. */ get schedReportOptions() { const all = this.schedReportsAll || []; const keywords = SCHED_REPORT_KEYWORDS[this.activeObjectId] || []; const matches = keywords.length ? all.filter(r => { const name = (r.label || '').toLowerCase(); return keywords.some(k => name.includes(k)); }) : []; const list = matches.length ? matches : all; return list.map(r => ({ label: r.label, value: r.developerName, selected: r.developerName === this.schedReportDevName })); } get schedReportsEmpty() { return !this.schedReportsLoading && this.schedReportOptions.length === 0; } closeScheduleModal() { if (this.schedSubmitting) return; this.showScheduleModal = false; } onSchedReportNameChange(e) { this.schedReportDevName = e.target.value; } onSchedFrequencyChange(e) { this.schedFrequency = e.target.value; } onSchedTimeChange(e) { this.schedTime = e.target.value; } onSchedDayOfWeekChange(e) { this.schedDayOfWeek = e.target.value; } onSchedDayOfMonthChange(e) { this.schedDayOfMonth = e.target.value; } onSchedMonthChange(e) { this.schedMonth = e.target.value; } onSchedFormatChange(e) { this.schedFormat = e.target.value; } onSchedRecipientsChange(e) { this.schedRecipients = e.target.value; } // Hard cap on recipients — both for UX (5 fits comfortably under the // textarea) and to keep email-send governor usage predictable. SCHED_MAX_RECIPIENTS = 5; // Live-parsed recipient pills so the user sees exactly which emails will // be subscribed before they click Schedule. Mirrors the Apex regex so // client + server validation match. Pills beyond the 5-cap are marked // as "over limit" so users see why they're not counted. get schedRecipientPills() { const raw = this.schedRecipients || ''; if (!raw.trim()) return []; const regex = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; let validCount = 0; return raw.split(/[,;]/).map((s, idx) => { const trimmed = (s || '').trim(); const valid = regex.test(trimmed); // First N valid emails are accepted; subsequent ones flagged as over-limit let overLimit = false; if (valid) { if (validCount >= this.SCHED_MAX_RECIPIENTS) { overLimit = true; } else { validCount++; } } const accepted = valid && !overLimit; return { key: 'rcpt-' + idx + '-' + trimmed, email: trimmed, valid: accepted, overLimit, cssClass: accepted ? 'rcpt-pill rcpt-valid' : overLimit ? 'rcpt-pill rcpt-overlimit' : 'rcpt-pill rcpt-invalid', icon: accepted ? '✓' : overLimit ? '⛔' : '⚠' }; }).filter(p => p.email.length > 0); } get schedRecipientSummary() { const pills = this.schedRecipientPills; if (!pills.length) return 'No recipients yet — add at least one email above (max ' + this.SCHED_MAX_RECIPIENTS + ')'; const accepted = pills.filter(p => p.valid).length; const invalid = pills.filter(p => !p.valid && !p.overLimit).length; const over = pills.filter(p => p.overLimit).length; const parts = [accepted + '/' + this.SCHED_MAX_RECIPIENTS + ' recipient' + (accepted === 1 ? '' : 's') + ' will be subscribed']; if (invalid) parts.push(invalid + ' invalid'); if (over) parts.push(over + ' over the 5-email limit'); return parts.join(' · '); } get schedHasValidRecipient() { return this.schedRecipientPills.some(p => p.valid); } get schedShowWeeklyDay() { return this.schedFrequency === 'Weekly'; } // Day-of-month selector is shown for both Monthly AND Yearly schedules get schedShowMonthlyDay() { return this.schedFrequency === 'Monthly' || this.schedFrequency === 'Yearly'; } get schedShowYearlyMonth() { return this.schedFrequency === 'Yearly'; } get schedFrequencyOptions() { return [ { label: 'Daily', value: 'Daily' }, { label: 'Weekly', value: 'Weekly' }, { label: 'Monthly', value: 'Monthly' }, { label: 'Yearly', value: 'Yearly' } ]; } get schedMonthOptions() { return [ { label: 'January', value: '1' }, { label: 'February', value: '2' }, { label: 'March', value: '3' }, { label: 'April', value: '4' }, { label: 'May', value: '5' }, { label: 'June', value: '6' }, { label: 'July', value: '7' }, { label: 'August', value: '8' }, { label: 'September', value: '9' }, { label: 'October', value: '10' }, { label: 'November', value: '11' }, { label: 'December', value: '12' } ]; } get schedDayOfWeekOptions() { // Salesforce CronTrigger DOW: 1=SUN, 2=MON, ..., 7=SAT return [ { label: 'Sunday', value: '1' }, { label: 'Monday', value: '2' }, { label: 'Tuesday', value: '3' }, { label: 'Wednesday', value: '4' }, { label: 'Thursday', value: '5' }, { label: 'Friday', value: '6' }, { label: 'Saturday', value: '7' } ]; } get schedDayOfMonthOptions() { const out = []; for (let i = 1; i <= 28; i++) out.push({ label: String(i), value: String(i) }); return out; } get schedFormatOptions() { return [ { label: 'PDF', value: 'PDF' }, { label: 'Excel (xls)', value: 'EXCEL' }, { label: 'CSV', value: 'CSV' } ]; } submitSchedule() { // Validation if (!this.schedReportDevName || !this.schedReportDevName.trim()) { this.schedError = 'Please select a report to schedule'; return; } if (!this.schedHasValidRecipient) { this.schedError = 'At least one valid recipient email is required (comma or semicolon separated)'; return; } if (!/^[0-2]\d:[0-5]\d$/.test(this.schedTime)) { this.schedError = 'Time must be in HH:MM 24-hour format'; return; } this.schedError = ''; this.schedSubmitting = true; scheduleReport({ reportDeveloperName: this.schedReportDevName.trim(), frequency: this.schedFrequency, timeOfDay: this.schedTime, dayOfWeek: this.schedDayOfWeek, dayOfMonth: this.schedDayOfMonth, monthOfYear: this.schedMonth, format: this.schedFormat, recipientsCsv: this.schedRecipients.trim(), storeName: this.selectedStoreName || '' }) .then(jobId => { this._toast('Schedule created — CronTrigger Id: ' + jobId, 'success'); this.showScheduleModal = false; // Refresh the existing-schedules list so the new job appears // immediately the next time the modal opens. this._loadExistingSchedules(); }) .catch(err => { // eslint-disable-next-line no-console console.error('[ShopifySyncDashboard] scheduleReport failed', err); this.schedError = (err && err.body && err.body.message) ? err.body.message : String(err); }) .finally(() => { this.schedSubmitting = false; }); } // ----------- Filter bar ----------- get filterRangeValue() { return this.dateRange; } get filterStoreValue() { return this.currentStoreLabel; } // === Date Range filter === dateRangeMenuOpen = false; get dateRangeMenuClass() { return this.dateRangeMenuOpen ? 'menu filter-menu open' : 'menu filter-menu'; } get dateRangeOptions() { const cur = this.dateRange; const items = [ 'Today', 'Yesterday', 'Last 7 days', 'Last 30 days', 'This Month', 'Last Month', 'This Quarter', 'This Year', 'All Time' ]; return items.map(label => ({ key: 'dr-' + label, label, cssClass: label === cur ? 'menu-item active' : 'menu-item' })); } toggleDateRangeMenu(e) { if (e) e.stopPropagation(); this.dateRangeMenuOpen = !this.dateRangeMenuOpen; this._closeOtherMenus('dateRange'); } handleDateRangeSelect(e) { e.stopPropagation(); const label = e.currentTarget.dataset.label; if (label && label !== this.dateRange) { // Write the backing field directly — the @api setter is a no-op // (it exists only to swallow App Builder page-instance values). this._dateRange = label; // Re-fetch records & dashboard charts for the new date range. // Counts are @wire-reactive on $dateRange so they refresh automatically. this._rowsByObject = {}; this._rowsLoading = {}; this._realDashboardData = {}; this._dashFetching = {}; this._kpisByObject = {}; this._kpisFetching = {}; this.reportPage = 1; this._ensureRows(this.activeObjectId); this._fetchDashboardData(this.activeObjectId); this._fetchKpis(this.activeObjectId); } this.dateRangeMenuOpen = false; } // === Status filter === statusFilter = 'All Statuses'; statusMenuOpen = false; get statusValue() { return this.statusFilter; } get statusMenuClass() { return this.statusMenuOpen ? 'menu filter-menu open' : 'menu filter-menu'; } get statusOptions() { const cur = this.statusFilter; const items = ['All Statuses', 'Synced', 'Pending', 'Failed', 'Conflict', 'Archived']; return items.map(label => ({ key: 'st-' + label, label, cssClass: label === cur ? 'menu-item active' : 'menu-item' })); } toggleStatusMenu(e) { if (e) e.stopPropagation(); this.statusMenuOpen = !this.statusMenuOpen; this._closeOtherMenus('status'); } handleStatusSelect(e) { e.stopPropagation(); const label = e.currentTarget.dataset.label; if (label && label !== this.statusFilter) { this.statusFilter = label; this._toast('Status filter: ' + label, 'info'); } this.statusMenuOpen = false; } // === Topbar Store filter (separate menu state from sidebar selector // so they don't toggle each other, but they share selectedStoreId // so selection in either place updates the other) === topbarStoreMenuOpen = false; get topbarStoreMenuClass() { return this.topbarStoreMenuOpen ? 'menu filter-menu open' : 'menu filter-menu'; } get topbarStoreOptions() { // "All Stores" option removed per user request. const cur = this.selectedStoreId; return this.stores.map(s => ({ id: s.id, name: s.name, cssClass: cur === s.id ? 'menu-item active' : 'menu-item' })); } toggleTopbarStoreMenu(e) { if (e) e.stopPropagation(); this.topbarStoreMenuOpen = !this.topbarStoreMenuOpen; this._closeOtherMenus('topbarStore'); } handleTopbarStoreSelect(e) { e.stopPropagation(); const id = e.currentTarget.dataset.id; const newId = id || null; if (newId !== this.selectedStoreId) { this.selectedStoreId = newId; this._resetReportState(); if (newId) this._fetchShopifyCounts(); else this.shopifyCounts = {}; this._ensureActiveObjectVisible(); this._fetchDashboardData(this.activeObjectId); this._fetchKpis(this.activeObjectId); } this.topbarStoreMenuOpen = false; } // ----------- Sort By dropdown (filter bar) ----------- sortByMenuOpen = false; get sortByMenuClass() { return this.sortByMenuOpen ? 'menu sort-menu open' : 'menu sort-menu'; } get sortByLabel() { if (!this.reportSortField) return 'Sort By'; const f = this._visibleFields().find(x => x.api === this.reportSortField); if (!f) return 'Sort By'; return f.label + ' ' + (this.reportSortDir === 'asc' ? '▲' : '▼'); } get sortByOptions() { const cur = this.reportSortField; const dir = this.reportSortDir; const out = [{ key: '__clear', field: '', dir: 'asc', label: '— Clear sort —', cssClass: !cur ? 'menu-item active' : 'menu-item' }]; this._visibleFields().forEach(f => { out.push({ key: f.api + '-asc', field: f.api, dir: 'asc', label: f.label + ' (A → Z)', cssClass: (cur === f.api && dir === 'asc') ? 'menu-item active' : 'menu-item' }); out.push({ key: f.api + '-desc', field: f.api, dir: 'desc', label: f.label + ' (Z → A)', cssClass: (cur === f.api && dir === 'desc') ? 'menu-item active' : 'menu-item' }); }); return out; } toggleSortByMenu(event) { if (event) event.stopPropagation(); this.sortByMenuOpen = !this.sortByMenuOpen; this._closeOtherMenus('sort'); } handleSortBySelect(event) { event.stopPropagation(); const field = event.currentTarget.dataset.field; const dir = event.currentTarget.dataset.dir; this.reportSortField = field || ''; this.reportSortDir = dir === 'desc' ? 'desc' : 'asc'; this.reportPage = 1; this.sortByMenuOpen = false; } // ----------- Body click handler ----------- _closeOtherMenus(keep) { if (keep !== 'export') this.exportMenuOpen = false; if (keep !== 'store') this.storeMenuOpen = false; if (keep !== 'field') this.fieldMenuOpen = false; if (keep !== 'sort') this.sortByMenuOpen = false; if (keep !== 'dateRange') this.dateRangeMenuOpen = false; if (keep !== 'status') this.statusMenuOpen = false; if (keep !== 'topbarStore') this.topbarStoreMenuOpen = false; } handleBodyClick() { if (this.exportMenuOpen) this.exportMenuOpen = false; if (this.storeMenuOpen) this.storeMenuOpen = false; if (this.fieldMenuOpen) this.fieldMenuOpen = false; if (this.sortByMenuOpen) this.sortByMenuOpen = false; if (this.dateRangeMenuOpen) this.dateRangeMenuOpen = false; if (this.statusMenuOpen) this.statusMenuOpen = false; if (this.topbarStoreMenuOpen) this.topbarStoreMenuOpen = false; } _toast(message, variant) { // Custom in-component toast — success uses the brand gradient instead // of the standard ShowToastEvent (which renders outside our shadow DOM // and can't be restyled from here). const v = variant === 'error' ? 'error' : variant === 'success' ? 'success' : variant === 'warning' ? 'warning' : 'info'; const title = v === 'error' ? 'Error' : v === 'success' ? 'Success' : v === 'warning' ? 'Warning' : 'Shopify Sync'; const id = 't' + (this._toastSeq++); const toast = { id, title, message, cssClass: 'toast toast-' + v, iconLabel: v === 'error' ? '⚠' : v === 'success' ? '✓' : v === 'warning' ? '!' : 'ℹ' }; this.toasts = [...this.toasts, toast]; if (v !== 'error') { // eslint-disable-next-line @lwc/lwc/no-async-operation setTimeout(() => this._dismissToast(id), 4000); } } _dismissToast(id) { this.toasts = this.toasts.filter(t => t.id !== id); } handleToastClose(event) { this._dismissToast(event.currentTarget.dataset.id); } _formatHms(ms) { const total = Math.round(ms / 1000); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = total % 60; const p = n => String(n).padStart(2, '0'); return p(h) + 'h ' + p(m) + 'm ' + p(s) + 's'; } _formatRoughDuration(ms) { const total = Math.round(ms / 60000); if (total <= 0) return 'now'; const h = Math.floor(total / 60); const m = total % 60; if (h === 0) return m + 'min'; if (m === 0) return h + 'hr'; return h + 'hr ' + m + 'min'; } /* ============ SVG builders ============ */ _lineSvg(values, labels, color) { // Guard: real (Apex) chart data can legitimately be empty for narrow // date filters — render a flat empty chart instead of crashing on pts[-1]. if (!values || values.length === 0) { values = [0]; labels = ['']; } const w = 540, h = 300, pad = 44, padBottom = 60; const max = Math.max(1, Math.max(...values) * 1.15); const chartH = h - pad - padBottom; const step = (w - pad * 2) / Math.max(1, values.length - 1); const pts = values.map((v, i) => ({ x: (pad + i * step).toFixed(1), y: (pad + chartH - (v / max) * chartH).toFixed(1), label: labels[i], tooltip: labels[i] + ': ' + fmtInt(v) })); const d = pts.map((p, i) => (i === 0 ? 'M' : 'L') + p.x + ',' + p.y).join(' '); const lastX = pts[pts.length - 1].x; const firstX = pts[0].x; const baseY = (pad + chartH).toFixed(1); const area = d + ' L ' + lastX + ',' + baseY + ' L ' + firstX + ',' + baseY + ' Z'; return { linePath: d, areaPath: area, color, points: pts.map((p, i) => ({ key: 'pt' + i, cx: p.x, cy: p.y, tooltip: p.tooltip })), xLabels: pts.map((p, i) => ({ key: 'xl' + i, x: p.x, label: p.label })), yTicks: [0, 0.5, 1].map((t, i) => { const v = Math.round((max * t) / 5) * 5; return { key: 'yt' + i, y: (pad + chartH - (v / max) * chartH).toFixed(1), label: v }; }), labelY: (pad + chartH + 22).toFixed(1), gridX2: (w - pad).toFixed(1) }; } _barSvg(values, labels, colors) { const w = 540, h = 300, pad = 44, padBottom = 60; const max = Math.max(1, Math.max(...values) * 1.15); const chartH = h - pad - padBottom; const barW = (w - pad * 2) / values.length * 0.6; const gap = (w - pad * 2) / values.length * 0.4; const slot = barW + gap; // Stagger labels when any is longer than 8 chars and there are 4+ bars const needsStagger = values.length >= 4 && labels.some(l => l && l.length > 8); const baselineY = pad + chartH + 18; return { bars: values.map((v, i) => { const displayLabel = (labels[i] && labels[i].length > 14) ? labels[i].substring(0, 12) + '…' : labels[i]; return { key: 'b' + i, x: (pad + slot * i + gap / 2).toFixed(1), y: (pad + chartH - (v / max) * chartH).toFixed(1), width: barW.toFixed(1), height: ((v / max) * chartH).toFixed(1), fill: colors[i % colors.length], label: displayLabel, fullLabel: labels[i], tooltip: labels[i] + ': ' + fmtInt(v), labelX: (pad + slot * i + gap / 2 + barW / 2).toFixed(1), labelY: needsStagger ? (baselineY + (i % 2 === 1 ? 16 : 0)).toFixed(1) : baselineY.toFixed(1) }; }), yTicks: [0, 0.5, 1].map((t, i) => { const v = Math.round((max * t) / 5) * 5; return { key: 'yt' + i, y: (pad + chartH - (v / max) * chartH).toFixed(1), label: v }; }), gridX2: (w - pad).toFixed(1) }; } _donutSvg(values, labels, colors) { const total = values.reduce((s, v) => s + v, 0) || 1; const r = 60, c = 2 * Math.PI * r; let acc = 0; const segs = values.map((v, i) => { const pct = ((v / total) * 100).toFixed(1); const len = c * (v / total); const seg = { key: 'd' + i, color: colors[i % colors.length], dasharray: len.toFixed(2) + ' ' + (c - len).toFixed(2), dashoffset: (-acc).toFixed(2), tooltip: labels[i] + ': ' + fmtInt(v) + ' (' + pct + '%)' }; acc += len; return seg; }); return { segments: segs, total }; } _legendFor(values, labels, colors) { const total = values.reduce((s, v) => s + v, 0) || 1; return labels.map((l, i) => ({ key: 'lg' + i, label: l, value: fmtInt(values[i]), pct: ((values[i] / total) * 100).toFixed(1) + '%', swatchStyle: 'background:' + colors[i % colors.length] + ';' })); } }