SyncMadeEasy/force-app/main/default/lwc/shopifySyncDashboard/shopifySyncDashboard.js

3173 lines
138 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 — <Object>" 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<String, Object> 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 "<Object> 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 <field>__name + <field>__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 = '<?xml version="1.0" encoding="UTF-8"?>' +
'<?mso-application progid="Excel.Sheet"?>' +
'<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" ' +
'xmlns:o="urn:schemas-microsoft-com:office:office" ' +
'xmlns:x="urn:schemas-microsoft-com:office:excel" ' +
'xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet" ' +
'xmlns:html="http://www.w3.org/TR/REC-html40">' +
'<DocumentProperties xmlns="urn:schemas-microsoft-com:office:office">' +
'<Author>Sync made easy</Author>' +
'<Title>' + this._escapeXml(this.activeObject.name + ' Report') + '</Title>' +
'</DocumentProperties>' +
'<Styles>' +
'<Style ss:ID="Header"><Font ss:Bold="1"/><Interior ss:Color="#D9EAD3" ss:Pattern="Solid"/><Alignment ss:Horizontal="Center" ss:Vertical="Center" ss:WrapText="1"/></Style>' +
'<Style ss:ID="Text"><NumberFormat ss:Format="@"/><Alignment ss:Vertical="Top" ss:WrapText="1"/></Style>' +
'</Styles>' +
'<Worksheet ss:Name="' + this._escapeXml(sheetName) + '"><Table>';
fields.forEach(f => {
xml += '<Column ss:AutoFitWidth="1" ss:Width="' + this._excelColumnWidth(f, rows) + '"/>';
});
xml += '<Row ss:AutoFitHeight="1">';
fields.forEach(f => {
xml += '<Cell ss:StyleID="Header"><Data ss:Type="String">' +
this._escapeXml(f.label) + '</Data></Cell>';
});
xml += '</Row>';
rows.forEach(row => {
xml += '<Row ss:AutoFitHeight="1">';
fields.forEach(f => {
xml += '<Cell ss:StyleID="Text"><Data ss:Type="String">' +
this._escapeXml(this._exportCellValue(row, f)) + '</Data></Cell>';
});
xml += '</Row>';
});
xml += '</Table><WorksheetOptions xmlns="urn:schemas-microsoft-com:office:excel">' +
'<FreezePanes/><FrozenNoSplit/><SplitHorizontal>1</SplitHorizontal><TopRowBottomPane>1</TopRowBottomPane>' +
'<ProtectObjects>False</ProtectObjects><ProtectScenarios>False</ProtectScenarios>' +
'</WorksheetOptions></Worksheet></Workbook>';
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 '<html xmlns:o="urn:schemas-microsoft-com:office:office" ' +
'xmlns:w="urn:schemas-microsoft-com:office:word" ' +
'xmlns="http://www.w3.org/TR/REC-html40">' +
'<head><meta charset="utf-8">' +
'<style>' +
'@page WordSection1 { size: 11.69in 8.27in; margin: 0.35in; mso-page-orientation: landscape; }' +
'div.WordSection1 { page: WordSection1; }' +
'body { font-family: Calibri, Arial, sans-serif; color: #111827; }' +
'h1 { font-size: 18pt; margin: 0 0 12pt 0; }' +
'table.word-report-table { width: 100%; border-collapse: collapse; table-layout: fixed; mso-table-layout-alt: fixed; }' +
'.word-report-table th, .word-report-table td { border: 1pt solid #d1d5db; padding: 4pt 5pt; font-size: 8pt; vertical-align: top; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }' +
'.word-report-table th { background: #3f9a86; color: #ffffff; font-weight: bold; text-align: center; }' +
'.word-report-table td { mso-number-format: "\\@"; }' +
'</style></head><body><div class="WordSection1">' +
'<h1>' + title + '</h1>' +
table +
'</div></body></html>';
}
_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 = '<table' + cls + '><thead><tr>';
fields.forEach(f => { html += '<th>' + esc(f.label) + '</th>'; });
html += '</tr></thead><tbody>';
this.reportRows.forEach(r => {
html += '<tr>';
fields.forEach(f => {
const v = this._exportCellValue(r, f);
html += '<td>' + esc(v) + '</td>';
});
html += '</tr>';
});
html += '</tbody></table>';
return html;
}
_escapeXml(v) {
if (v === null || v === undefined) return '';
return String(v)
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ' ')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
_escapeHtml(v) {
if (v === null || v === undefined) return '';
return String(v)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
_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] + ';'
}));
}
}