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