SyncMadeEasy/force-app/main/default/classes/ShopifySyncDashboardControl...

2709 lines
140 KiB
OpenEdge ABL

/**
* Backing controller for the shopifySyncDashboard LWC.
*
* getStores() → SELECT Id, Name FROM HIC_ShopifySync__HIC_Configuration__c WHERE Name != 'default'
*
* getObjectCounts() → COUNT() per object via SOQL with HIC_ShopifySync__Shopify_Id__c != null
* + per-object filter + HIC_ShopifySync__Store__c = :storeName.
* (cacheable — feeds the Salesforce column on the sidebar.)
*
* getShopifyCounts() → Live Shopify REST callouts per selected store. Reads the
* access token from the matching HIC_ShopifySync__HIC_Configuration__c
* record (HIC_ShopifySync__Private_Key_Password__c) and hits
* /admin/<resource>/count.json for products, variants, customers,
* orders, draft_orders, locations. NOT cacheable (Apex rules
* forbid HTTP callouts on cacheable @AuraEnabled methods).
*
* Heads-up: each <storename>.myshopify.com must be allowed in
* Setup → Security → Remote Site Settings, OR via a Named Credential.
* The HIC_ShopifySync managed package very likely already sets these up
* during onboarding — if a callout returns "Unauthorized endpoint",
* that's the cause.
*/
public with sharing class ShopifySyncDashboardController {
/* =================== Stores =================== */
private static Boolean cachedPaEnabled;
private static Boolean paCacheSet = false;
@AuraEnabled(cacheable=true)
public static List<Store> getStores() {
List<Store> stores = new List<Store>();
for (HIC_ShopifySync__HIC_Configuration__c c : [
SELECT Id, Name, CreatedDate,
HIC_ShopifySync__Product_Sync__c,
HIC_ShopifySync__Person_Account__c,
HIC_ShopifySync__Order__c, // Parent: Order Sync
HIC_ShopifySync__Order_Sync__c, // Child: Order Toggle
HIC_ShopifySync__Opportunity__c, // Child: Opportunity Toggle
HIC_ShopifySync__Draft_Order_Sync__c, // Parent: Draft Order Sync
HIC_ShopifySync__Enable_Draft_Order__c, // Child: Draft Order Toggle
HIC_ShopifySync__Enable_Opportunity_for_SDO__c, // Child: Draft Order Opportunity Toggle
HIC_ShopifySync__Collection_Sync__c,
HIC_ShopifySync__Inventory_Location__c,
HIC_ShopifySync__Account_Contact__c,
HIC_ShopifySync__Enable_Lead__c
FROM HIC_ShopifySync__HIC_Configuration__c
WHERE Name != 'default'
ORDER BY CreatedDate ASC, Name ASC
LIMIT 200
]) {
Store s = new Store();
s.id = c.Id;
s.name = c.Name;
// General toggles
s.productSync = c.HIC_ShopifySync__Product_Sync__c == true;
s.accountContact = c.HIC_ShopifySync__Account_Contact__c == true;
s.enableLead = c.HIC_ShopifySync__Enable_Lead__c == true;
s.personAccount = c.HIC_ShopifySync__Person_Account__c == true;
s.collectionSync = c.HIC_ShopifySync__Collection_Sync__c == true;
s.inventoryLocation = c.HIC_ShopifySync__Inventory_Location__c == true;
// Paid Order toggles
s.orderSync = c.HIC_ShopifySync__Order_Sync__c == true; // Parent
s.orderToggle = c.HIC_ShopifySync__Order__c == true; // Child
s.opportunity = c.HIC_ShopifySync__Opportunity__c == true; // Child
// Draft Order toggles
s.draftOrderSync = c.HIC_ShopifySync__Draft_Order_Sync__c == true; // Parent
s.draftOrderToggle = c.HIC_ShopifySync__Enable_Draft_Order__c == true; // Child
s.draftOpportunity = c.HIC_ShopifySync__Enable_Opportunity_for_SDO__c == true; // Child
stores.add(s);
}
return stores;
}
/* =================== Salesforce side: SOQL counts =================== */
@AuraEnabled(cacheable=true)
public static Map<String, Integer> getObjectCounts(String storeName, String dateRange) {
Map<String, Integer> counts = initZeroCounts();
Map<String, ObjectDef> defs = objectDefs();
Boolean hasStoreFilter = String.isNotBlank(storeName);
String safeStore = hasStoreFilter ? String.escapeSingleQuotes(storeName) : null;
String drClause = dateRangeClause(dateRange);
for (String key : defs.keySet()) {
ObjectDef def = defs.get(key);
try {
// BOTH must be populated to count a record
String soql = 'SELECT COUNT() FROM ' + def.sobjectName
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null'
+ ' AND HIC_ShopifySync__Store__c != null';
if (hasStoreFilter) {
soql += ' AND HIC_ShopifySync__Store__c = \'' + safeStore + '\'';
}
if (String.isNotBlank(def.extraFilter)) {
soql += ' AND ' + def.extraFilter;
}
soql += drClause;
counts.put(key, Database.countQuery(soql));
} catch (Exception e) {
String msg = e.getMessage();
// DEFENSIVE: If IsPersonAccount error, feature not truly enabled
if (msg != null && msg.contains('IsPersonAccount')) {
System.debug('[ShopifySyncDashboardController] Person Accounts not queryable for '
+ key + ', returning 0. ' + msg);
counts.put(key, 0);
} else {
System.debug('[ShopifySyncDashboardController] SF count failed for '
+ key + ' (' + def.sobjectName + '): ' + msg);
counts.put(key, 0);
}
}
}
return counts;
}
/* =================== Shopify side: REST callouts =================== */
@AuraEnabled
public static Map<String, Integer> getShopifyCounts(String storeName) {
Map<String, Integer> counts = initZeroCounts();
if (String.isBlank(storeName)) return counts;
String token = lookupAccessToken(storeName);
if (String.isBlank(token)) {
System.debug('[ShopifySyncDashboardController] no access token for store ' + storeName);
return counts;
}
String base = 'https://' + storeName + '.myshopify.com';
Map<String, String> endpoints = new Map<String, String>{
'products' => base + '/admin/products/count.json',
'variants' => base + '/admin/variants/count.json',
'customers' => base + '/admin/customers/count.json',
'orders' => base + '/admin/orders/count.json?status=any',
'draftOrders' => base + '/admin/draft_orders/count.json',
'locations' => base + '/admin/locations/count.json',
'customCollections' => base + '/admin/custom_collections/count.json',
'smartCollections' => base + '/admin/smart_collections/count.json'
};
Map<String, Integer> ep = new Map<String, Integer>();
for (String name : endpoints.keySet()) {
try {
ep.put(name, fetchShopifyCount(endpoints.get(name), token));
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] Shopify callout failed for '
+ name + ': ' + e.getMessage());
ep.put(name, 0);
}
}
// Map raw Shopify resources to the dashboard's sidebar keys.
counts.put('product', safeGet(ep, 'products'));
counts.put('variant', safeGet(ep, 'variants'));
counts.put('pacc', safeGet(ep, 'customers'));
counts.put('account', safeGet(ep, 'customers'));
counts.put('contact', safeGet(ep, 'customers'));
counts.put('opp', safeGet(ep, 'orders'));
counts.put('paidOrder', safeGet(ep, 'orders'));
counts.put('draftOrder', safeGet(ep, 'draftOrders'));
counts.put('location', safeGet(ep, 'locations'));
counts.put('collection', safeGet(ep, 'customCollections') + safeGet(ep, 'smartCollections'));
// lead, inventory → no Shopify endpoint specified yet, stay at 0.
return counts;
}
/* =================== Record list & describe (new in May 2026) =================== */
/**
* Returns the field metadata for the SObject mapped to objectKey.
* Used by the "Select fields" menu in the LWC.
* Excludes audit/system fields the user can't usefully select.
*/
@AuraEnabled(cacheable=true)
public static List<FieldInfo> getObjectFields(String objectKey) {
List<FieldInfo> out = new List<FieldInfo>();
Map<String, ObjectDef> defs = objectDefs();
ObjectDef def = defs.get(objectKey);
if (def == null) return out;
Schema.SObjectType sot = Schema.getGlobalDescribe().get(def.sobjectName);
if (sot == null) return out;
Schema.DescribeSObjectResult dsr = sot.getDescribe();
if (!dsr.isAccessible()) return out;
Map<String, Schema.SObjectField> fmap = dsr.fields.getMap();
// System fields to hide
Set<String> hidden = new Set<String>{
'systemmodstamp', 'isdeleted', 'lastvieweddate', 'lastreferenceddate',
'lastactivitydate', 'jigsaw', 'cleanstatus', 'masterrecordid',
'photourl', 'mediumphotourl', 'smallphotourl', 'isprivate'
};
// CRITICAL FIX: Also hide Person Account fields when feature disabled
Boolean paEnabled = personAccountsEnabled();
List<String> defaultFields = defaultFieldsFor(objectKey);
Map<String, Integer> defaultPos = new Map<String, Integer>();
for (Integer i = 0; i < defaultFields.size(); i++) {
String df = defaultFields[i];
if (df != null) defaultPos.put(df.toLowerCase(), i);
}
for (String apiLower : fmap.keySet()) {
Schema.DescribeFieldResult dfr = fmap.get(apiLower).getDescribe();
// Skip inaccessible fields
if (!dfr.isAccessible()) continue;
// Skip system fields
if (hidden.contains(apiLower)) continue;
// CRITICAL FIX: Skip Person Account-only fields when feature disabled
if (!paEnabled && def.sobjectName == 'Account') {
if (isPersonAccountOnlyField(apiLower)) continue;
}
FieldInfo fi = new FieldInfo();
fi.apiName = dfr.getName();
fi.type = String.valueOf(dfr.getType()).toLowerCase();
fi.sortable = dfr.isSortable();
// Friendly label for Reference fields
String rawLabel = dfr.getLabel();
if (dfr.getType() == Schema.DisplayType.REFERENCE && rawLabel != null) {
String stripped = rawLabel;
if (stripped.endsWithIgnoreCase(' ID')) {
stripped = stripped.substring(0, stripped.length() - 3);
}
fi.label = stripped + ' Name';
} else {
fi.label = rawLabel;
}
Integer pos = defaultPos.get(apiLower);
fi.isDefault = (pos != null);
fi.defaultOrder = pos;
out.add(fi);
}
out.sort();
return out;
}
/**
* Returns REAL Salesforce records for the active object — driven by the
* caller's selected fields. Replaces the client-side mock data generator.
* Hard-capped at MAX_ROWS to keep heap usage predictable.
*/
private static final Integer MAX_ROWS = 200;
@AuraEnabled(cacheable=true)
public static List<Map<String, Object>> getObjectRecords(
String objectKey,
String storeName,
List<String> fields,
String dateRange
) {
List<Map<String, Object>> out = new List<Map<String, Object>>();
if (String.isBlank(objectKey)) return out;
Map<String, ObjectDef> defs = objectDefs();
ObjectDef def = defs.get(objectKey);
if (def == null) return out;
// If caller didn't pass fields, use the per-object defaults
if (fields == null || fields.isEmpty()) {
fields = defaultFieldsFor(objectKey);
}
// "All stores" mode — include Store column
if (String.isBlank(storeName)) {
fields = ensureStoreFieldAfterShopifyId(fields);
}
// Validate fields against the SObject schema
Schema.SObjectType sot = Schema.getGlobalDescribe().get(def.sobjectName);
if (sot == null) return out;
Map<String, Schema.SObjectField> fmap = sot.getDescribe().fields.getMap();
// Build safe field set — skip fields that don't exist or aren't accessible
List<String> safeFields = new List<String>();
Map<String, String> lookupRelMap = new Map<String, String>();
Map<String, String> lookupTargetMap = new Map<String, String>();
for (String f : fields) {
if (String.isBlank(f)) continue;
String lower = f.toLowerCase();
// CRITICAL FIX: Skip Person Account-only fields when feature is disabled
if (!personAccountsEnabled() && def.sobjectName == 'Account') {
if (isPersonAccountOnlyField(lower)) continue;
}
if (!fmap.containsKey(lower)) continue;
Schema.DescribeFieldResult dfr = fmap.get(lower).getDescribe();
if (!dfr.isAccessible()) continue; // Field exists but user can't see it
safeFields.add(f);
if (dfr.getType() == Schema.DisplayType.REFERENCE) {
String relName = dfr.getRelationshipName();
List<Schema.SObjectType> refs = dfr.getReferenceTo();
if (String.isNotBlank(relName) && refs != null && !refs.isEmpty()) {
lookupRelMap.put(f, relName);
lookupTargetMap.put(f, refs[0].getDescribe().getName());
}
}
}
if (safeFields.isEmpty()) safeFields.add('Id');
if (!safeFields.contains('Id')) safeFields.add('Id');
// Build SELECT with relationship traversals
List<String> selectFields = new List<String>(safeFields);
for (String f : lookupRelMap.keySet()) {
selectFields.add(lookupRelMap.get(f) + '.Name');
}
// Build SOQL
String soql = 'SELECT ' + String.join(selectFields, ', ')
+ ' FROM ' + def.sobjectName
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null'
+ ' AND HIC_ShopifySync__Store__c != null';
if (String.isNotBlank(storeName)) {
String safeStore = String.escapeSingleQuotes(storeName);
soql += ' AND HIC_ShopifySync__Store__c = \'' + safeStore + '\'';
}
if (String.isNotBlank(def.extraFilter)) {
soql += ' AND ' + def.extraFilter;
}
soql += dateRangeClause(dateRange);
soql += ' ORDER BY CreatedDate DESC LIMIT ' + MAX_ROWS;
try {
for (SObject r : Database.query(soql)) {
Map<String, Object> row = new Map<String, Object>();
row.put('__recordId__', r.Id);
row.put('__sobjectApi__', def.sobjectName);
for (String f : safeFields) {
row.put(f, r.get(f));
if (lookupRelMap.containsKey(f)) {
String relName = lookupRelMap.get(f);
try {
SObject relRec = r.getSObject(relName);
if (relRec != null) {
row.put(f + '__name', relRec.get('Name'));
}
} catch (Exception relEx) { /* skip silently */ }
row.put(f + '__sobject', lookupTargetMap.get(f));
}
}
out.add(row);
}
} catch (Exception e) {
String msg = e.getMessage();
// DEFENSIVE: Gracefully handle IsPersonAccount errors
if (msg != null && msg.contains('IsPersonAccount')) {
System.debug('[ShopifySyncDashboardController] Person Accounts not queryable for '
+ objectKey + ', returning empty. ' + msg);
return out; // empty list
}
System.debug('[ShopifySyncDashboardController] getObjectRecords failed for '
+ objectKey + ': ' + msg + ' :: SOQL: ' + soql);
throw new AuraHandledException('Record query failed: ' + msg);
}
return out;
}
/**
* Returns true for fields that ONLY exist when Person Accounts are enabled.
* These fields are on Account but don't exist in regular business-account orgs.
*/
private static Boolean isPersonAccountOnlyField(String fieldApiLower) {
Set<String> personOnlyFields = new Set<String>{
'ispersonaccount',
'personemail',
'personbirthdate',
'personcontactid',
'persondepartment',
'persondonotcall',
'personhasoptedoutofemail',
'personhasoptedoutoffax',
'personhomephone',
'personindividualid',
'personlastcurequestdate',
'personlastcuupdatedate',
'personleadsource',
'personmailingcity',
'personmailingcountry',
'personmailinggeocodeaccuracy',
'personmailinglatitude',
'personmailinglongitude',
'personmailingpostalcode',
'personmailingstate',
'personmailingstreet',
'personmobilephone',
'personothercity',
'personothercountry',
'personothergeocodeaccuracy',
'personotherlatitude',
'personotherlongitude',
'personotherphone',
'personotherpostalcode',
'personotherstate',
'personotherstreet',
'persontitle'
};
return personOnlyFields.contains(fieldApiLower);
}
/**
* Inserts HIC_ShopifySync__Store__c immediately AFTER HIC_ShopifySync__Shopify_Id__c
* in the given field list, if it isn't already present.
* Used when "All stores" is selected so the Store column is always
* visible — otherwise users can't tell which store a row came from.
*/
@TestVisible
private static List<String> ensureStoreFieldAfterShopifyId(List<String> fields) {
if (fields == null) return new List<String>();
// Already there? Nothing to do — preserves the user's chosen position.
for (String f : fields) {
if (f != null && f.equalsIgnoreCase('HIC_ShopifySync__Store__c')) {
return fields;
}
}
List<String> result = new List<String>();
Boolean inserted = false;
for (String f : fields) {
result.add(f);
if (!inserted && f != null && f.equalsIgnoreCase('HIC_ShopifySync__Shopify_Id__c')) {
result.add('HIC_ShopifySync__Store__c');
inserted = true;
}
}
// Fallback: if Shopify Id wasn't in the list, append Store at the end.
if (!inserted) result.add('HIC_ShopifySync__Store__c');
return result;
}
/**
* Fetch a single record by Id for the target object — used when the user
* clicks a LOOKUP field in the report (e.g., clicking the Account name on a
* Contact row should show the Account's full details in the popup, using
* the Account object's own default fields, not Contact's).
*
* Returns the same Map<String,Object> shape as getObjectRecords rows, including:
* __recordId__, __sobjectApi__, each field value, plus <field>__name +
* <field>__sobject for any lookup fields the target object has.
*/
@AuraEnabled(cacheable=true)
public static Map<String, Object> getObjectRecordById(String objectKey, String recordId) {
Map<String, Object> empty = new Map<String, Object>();
if (String.isBlank(objectKey) || String.isBlank(recordId)) return empty;
Map<String, ObjectDef> defs = objectDefs();
ObjectDef def = defs.get(objectKey);
if (def == null) return empty;
Schema.SObjectType sot = Schema.getGlobalDescribe().get(def.sobjectName);
if (sot == null) return empty;
Map<String, Schema.SObjectField> fmap = sot.getDescribe().fields.getMap();
// Use target object's default field list (same logic as getObjectRecords)
List<String> fields = defaultFieldsFor(objectKey);
List<String> safeFields = new List<String>();
Map<String, String> lookupRelMap = new Map<String, String>();
Map<String, String> lookupTargetMap = new Map<String, String>();
for (String f : fields) {
if (String.isBlank(f)) continue;
String lower = f.toLowerCase();
if (!fmap.containsKey(lower)) continue;
safeFields.add(f);
Schema.DescribeFieldResult dfr = fmap.get(lower).getDescribe();
if (dfr.getType() == Schema.DisplayType.REFERENCE) {
String relName = dfr.getRelationshipName();
List<Schema.SObjectType> refs = dfr.getReferenceTo();
if (String.isNotBlank(relName) && refs != null && !refs.isEmpty()) {
lookupRelMap.put(f, relName);
lookupTargetMap.put(f, refs[0].getDescribe().getName());
}
}
}
if (safeFields.isEmpty()) safeFields.add('Id');
if (!safeFields.contains('Id')) safeFields.add('Id');
List<String> selectFields = new List<String>(safeFields);
for (String f : lookupRelMap.keySet()) {
selectFields.add(lookupRelMap.get(f) + '.Name');
}
String safeId = String.escapeSingleQuotes(recordId);
String soql = 'SELECT ' + String.join(selectFields, ', ')
+ ' FROM ' + def.sobjectName
+ ' WHERE Id = \'' + safeId + '\''
+ ' LIMIT 1';
try {
List<SObject> rs = Database.query(soql);
if (rs.isEmpty()) return empty;
SObject r = rs[0];
Map<String, Object> row = new Map<String, Object>();
row.put('__recordId__', r.Id);
row.put('__sobjectApi__', def.sobjectName);
for (String f : safeFields) {
row.put(f, r.get(f));
if (lookupRelMap.containsKey(f)) {
String relName = lookupRelMap.get(f);
try {
SObject relRec = r.getSObject(relName);
if (relRec != null) row.put(f + '__name', relRec.get('Name'));
} catch (Exception relEx) { /* skip silently */ }
row.put(f + '__sobject', lookupTargetMap.get(f));
}
}
return row;
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] getObjectRecordById failed: ' + e.getMessage());
throw new AuraHandledException('Record fetch failed: ' + e.getMessage());
}
}
private static List<String> defaultFieldsFor(String objectKey) {
// Per-object default visible fields, in the user-requested order.
// Field names that don't exist on the target SObject are silently
// skipped by getObjectRecords()'s Schema.getGlobalDescribe() validation,
// so listing speculative custom fields (e.g. HIC_ShopifySync__Tags__c)
// is safe — they just won't render if absent.
Map<String, List<String>> defaults = new Map<String, List<String>>{
'product' => new List<String>{
'Name', // Product Name
'IsActive', // Active
'HIC_ShopifySync__Tags__c', // Tags
'Description', // Description
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
'CreatedDate' // Created At
},
'variant' => new List<String>{
'Name', // Variant Name
'HIC_ShopifySync__Variant_Title__c', // Variant Title
'HIC_ShopifySync__Product__c', // Product Name (lookup)
'HIC_ShopifySync__Inventory_Quantity__c', // Inventory Quantity
'HIC_ShopifySync__SKU__c', // SKU
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
'CreatedDate' // Created At
},
'lead' => new List<String>{
'Name', // Name
'Email', // Email
'Phone', // Phone
'LeadSource', // Lead Source
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
'CreatedDate' // Created At
},
'pacc' => new List<String>{
'Name', // Account Name
'PersonEmail', // personEmail
'Phone', // personPhone
'HIC_ShopifySync__Tags__c', // Tag
'HIC_ShopifySync__Notes__c', // Notes
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
'CreatedDate' // Created At
},
'account' => new List<String>{
'Name', // Name
'HIC_ShopifySync__Tags__c', // Tag
'HIC_ShopifySync__Notes__c', // Notes
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
'CreatedDate' // Created At
},
'contact' => new List<String>{
'Name', // Name
'AccountId', // Account Name (lookup)
'Phone', // Phone
'Email', // Email
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
'CreatedDate' // Created At
},
'opp' => new List<String>{
'Name', // Opportunity Name
'AccountId', // Account (lookup)
'CloseDate', // Close Date
'StageName', // Stage
'Amount', // Amount
'HIC_ShopifySync__Payment_Status__c', // Payment Status (NEW)
'HIC_ShopifySync__Paid_Order_Id__c', // Paid Order ID (NEW)
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
'CreatedDate' // Created At
},
'draftOrder' => new List<String>{
'OrderNumber', // Order Number (clickable)
'Name', // Order Name
'AccountId', // Account Name (lookup)
'TotalAmount', // Order Amount
'Status', // Status
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
'CreatedDate' // Created At
},
'paidOrder' => new List<String>{
'OrderNumber', // Order Number (clickable)
'Name', // Order Name
'AccountId', // Account Name (lookup)
'TotalAmount', // Order Amount
'Status', // Status
'HIC_ShopifySync__Payment_Status__c', // Payment Status
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
'CreatedDate' // Created At
},
'collection' => new List<String>{
'Name', // Collection Name
'HIC_ShopifySync__Rule__c', // Rule
'HIC_ShopifySync__Description__c', // Description
'HIC_ShopifySync__Currency__c', // Currency
'HIC_ShopifySync__Published_At__c', // Published At
'HIC_ShopifySync__Updated_At__c', // Updated At
'HIC_ShopifySync__Published_Scope__c', // Published scope
'HIC_ShopifySync__Handle__c', // Handle
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
'CreatedDate' // Created At
},
'inventory' => new List<String>{
'Name', // Inventory Name
'HIC_ShopifySync__Available__c', // Quantity
'HIC_ShopifySync__Currency__c', // Currency
'HIC_ShopifySync__Cost__c', // Cost
'HIC_ShopifySync__Tracked__c', // Tracked
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
'CreatedDate' // Created At
},
'location' => new List<String>{
'Name', // Location Name
'HIC_ShopifySync__Is_Processed__c', // is processed
'HIC_ShopifySync__Phone__c', // Phone
'HIC_ShopifySync__Active__c', // Active
'HIC_ShopifySync__Currency__c', // Currency
'HIC_ShopifySync__Address__c', // Address 1
'HIC_ShopifySync__Address_2__c', // Address 2
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
'CreatedDate' // Created At
}
};
return defaults.containsKey(objectKey) ? defaults.get(objectKey) : new List<String>{'Name','Id'};
}
/* =================== Sync Health: last order per store =================== */
/**
* Returns the CreatedDate of the most recent synced Order per store.
* Used by the Sync Health panel to show "Last order: X ago".
*/
@AuraEnabled
public static Map<String, String> getLastOrderPerStore() {
Map<String, String> result = new Map<String, String>();
for (AggregateResult ar : [
SELECT HIC_ShopifySync__Store__c store, MAX(CreatedDate) lastOrder
FROM Order
WHERE HIC_ShopifySync__Shopify_Id__c != null
AND HIC_ShopifySync__Store__c != null
GROUP BY HIC_ShopifySync__Store__c
]) {
String storeName = (String) ar.get('store');
Datetime lastOrder = (Datetime) ar.get('lastOrder');
if (storeName != null && lastOrder != null) {
result.put(storeName, lastOrder.formatGmt('yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\''));
}
}
return result;
}
/* =================== Dashboard chart data (real SOQL) =================== */
/**
* Returns real chart data for the active object's 3 dashboard panels.
* Keys = 'dash1', 'dash2', 'dash3'. Only objects with real dashboards
* return data; missing keys → LWC falls back to its client-side mock.
*/
@AuraEnabled
public static Map<String, DashboardData> getDashboardChartData(String objectKey, String storeName, String dateRange) {
Map<String, DashboardData> result = new Map<String, DashboardData>();
if (String.isBlank(objectKey)) return result;
// Store + date-range scoping — EVERY chart honors both filters so the
// dashboards always match the global "Filter by" selection in the LWC.
String storeClause = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
String drClause = dateRangeClause(dateRange);
String scope = storeClause + drClause;
String baseWhere = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + scope;
// Builders return null on per-chart failure (e.g. field/object missing
// in this org) — putChart() skips nulls so one failed chart no longer
// kills the other charts in the same branch, and the LWC mock fallback
// applies only to charts that genuinely couldn't be computed.
try {
if (objectKey == 'product') {
putChart(result, 'dash1', chartTopProductsByUnitsSold(storeName, dateRange));
putChart(result, 'dash2', chartCreatedLast3Months('Product2', baseWhere, 'Products Created Last 3 Months'));
putChart(result, 'dash3', chartTopProductsByPrice(scope));
} else if (objectKey == 'variant') {
putChart(result, 'dash1', chartTopVariantsByInventory(scope));
putChart(result, 'dash2', chartCreatedLast3Months('HIC_ShopifySync__Variant__c', baseWhere, 'Variants Created Last 3 Months'));
putChart(result, 'dash3', chartTopVariantsByPrice(scope));
} else if (objectKey == 'lead') {
putChart(result, 'dash1', chartLeadsByStatus(scope));
putChart(result, 'dash2', chartLeadsBySource(scope));
putChart(result, 'dash3', chartCreatedLast3Months('Lead', baseWhere, 'Leads Created Last 3 Months'));
} else if (objectKey == 'contact') {
putChart(result, 'dash1', chartContactsByCity(scope));
putChart(result, 'dash2', chartCreatedLast3Months('Contact', baseWhere, 'Contacts Created Last 3 Months'));
putChart(result, 'dash3', chartContactsPerAccount(scope));
} else if (objectKey == 'account') {
// IsPersonAccount only exists when Person Accounts are enabled.
// Without it, ALL accounts are business accounts — no filter needed.
String paAccFilter = personAccountsEnabled() ? ' AND Account.IsPersonAccount = false' : '';
String paWhereFilter = personAccountsEnabled() ? ' AND IsPersonAccount = false' : '';
putChart(result, 'dash1', chartTopAccountsByRevenue(scope, paAccFilter));
putChart(result, 'dash2', chartTopAccountsByOrderCount(scope, paAccFilter));
putChart(result, 'dash3', chartCreatedLast3Months('Account', baseWhere + paWhereFilter, 'Accounts Created Last 3 Months'));
} else if (objectKey == 'pacc') {
// Person Accounts disabled → no person accounts can exist; force
// empty result sets with a valid always-false filter instead of
// referencing the non-existent IsPersonAccount column.
String paAccFilter = personAccountsEnabled() ? ' AND Account.IsPersonAccount = true' : ' AND Account.Id = null';
String paWhereFilter = personAccountsEnabled() ? ' AND IsPersonAccount = true' : ' AND Id = null';
putChart(result, 'dash1', chartTopAccountsByRevenue(scope, paAccFilter));
putChart(result, 'dash2', chartTopAccountsByOrderCount(scope, paAccFilter));
putChart(result, 'dash3', chartCreatedLast3Months('Account', baseWhere + paWhereFilter, 'Person Accounts Created Last 3 Months'));
} else if (objectKey == 'opp') {
putChart(result, 'dash1', chartOpportunitiesByAccount(scope, null));
putChart(result, 'dash2', chartPaidVsDraftOpportunities(scope));
putChart(result, 'dash3', chartTopOpportunitiesByAmount(scope, null));
} else if (objectKey == 'paidOpp') {
// BUG FIX: the LWC sidebar sends 'paidOpp'/'draftOpp' (never
// 'opp'), so this branch and the next never ran before — both
// tabs silently fell back to 100% mock data. Mirrors the
// existing paidOrder/draftOrder pattern, scoped by the same
// Payment_Status field Opportunity shares with Order.
String oppFilter = 'HIC_ShopifySync__Payment_Status__c != null';
putChart(result, 'dash1', chartOpportunitiesByAccount(scope, oppFilter));
putChart(result, 'dash2', chartTopOpportunitiesByAmount(scope, oppFilter));
putChart(result, 'dash3', chartCreatedLast3Months('Opportunity', baseWhere + ' AND ' + oppFilter, 'Paid Opportunities Created Last 3 Months'));
} else if (objectKey == 'draftOpp') {
String oppFilter = 'HIC_ShopifySync__Payment_Status__c = null';
putChart(result, 'dash1', chartOpportunitiesByAccount(scope, oppFilter));
putChart(result, 'dash2', chartTopOpportunitiesByAmount(scope, oppFilter));
putChart(result, 'dash3', chartCreatedLast3Months('Opportunity', baseWhere + ' AND ' + oppFilter, 'Draft Opportunities Created Last 3 Months'));
} else if (objectKey == 'paidOrder') {
putChart(result, 'dash1', chartOrdersByAccount(scope, 'HIC_ShopifySync__Payment_Status__c != null'));
putChart(result, 'dash2', chartTopOrdersByAmount(scope, 'HIC_ShopifySync__Payment_Status__c != null'));
putChart(result, 'dash3', chartConvertedOrders(scope, 'HIC_ShopifySync__Shopify_Draft_Order_ID__c', 'HIC_ShopifySync__Payment_Status__c != null'));
} else if (objectKey == 'draftOrder') {
putChart(result, 'dash1', chartOrdersByAccount(scope, 'HIC_ShopifySync__Payment_Status__c = null'));
putChart(result, 'dash2', chartTopOrdersByAmount(scope, 'HIC_ShopifySync__Payment_Status__c = null'));
putChart(result, 'dash3', chartConvertedOrders(scope, 'HIC_ShopifySync__Shopify_Order_ID__c', 'HIC_ShopifySync__Payment_Status__c = null'));
} else if (objectKey == 'collection') {
// Junction object — store filter traverses to the parent Collection;
// the date filter applies to the junction's own CreatedDate
// ("products added to collections within the range").
String collScope = storeClauseFor(storeName, 'HIC_ShopifySync__Collection__r.HIC_ShopifySync__Store__c') + drClause;
putChart(result, 'dash1', chartProductsPerCollection(collScope));
} else if (objectKey == 'inventory') {
putChart(result, 'dash1', chartInventoryByVariant(scope));
} else if (objectKey == 'location') {
String locScope = storeClauseFor(storeName, 'HIC_ShopifySync__Location__r.HIC_ShopifySync__Store__c') + drClause;
putChart(result, 'dash1', chartProductsPerLocation(locScope, scope));
}
} catch (Exception e) {
String msg = e.getMessage();
// DEFENSIVE: Handle Person Accounts not queryable
if (msg != null && msg.contains('IsPersonAccount')) {
System.debug('[ShopifySyncDashboardController] Person Accounts not queryable, returning empty chart. ' + msg);
return null;
}
System.debug('[ShopifySyncDashboardController] chart failed: ' + msg);
return null;
}
return result;
}
/* =================== Dashboard KPI data (real SOQL — no hardcoded values) =================== */
/**
* Returns 8 real KpiData objects for the active sidebar object.
* Every value comes from SOQL — no ratio-based fakes, no mock constants.
*/
@AuraEnabled
public static List<KpiData> getDashboardKpis(String objectKey, String storeName, String dateRange) {
try {
if (objectKey == 'product') return kpisProduct(storeName, dateRange);
if (objectKey == 'variant') return kpisVariant(storeName, dateRange);
if (objectKey == 'lead') return kpisLead(storeName, dateRange);
if (objectKey == 'pacc') return kpisAccount(storeName, dateRange, true);
if (objectKey == 'account') return kpisAccount(storeName, dateRange, false);
if (objectKey == 'contact') return kpisContact(storeName, dateRange);
if (objectKey == 'paidOpp') return kpisOpportunity(storeName, dateRange, 'HIC_ShopifySync__Payment_Status__c != null');
if (objectKey == 'draftOpp') return kpisOpportunity(storeName, dateRange, 'HIC_ShopifySync__Payment_Status__c = null');
if (objectKey == 'opp') return kpisOpportunity(storeName, dateRange, null);
if (objectKey == 'paidOrder') return kpisOrder(storeName, dateRange, 'HIC_ShopifySync__Payment_Status__c != null');
if (objectKey == 'draftOrder') return kpisOrder(storeName, dateRange, 'HIC_ShopifySync__Payment_Status__c = null');
if (objectKey == 'collection') return kpisCollection(storeName, dateRange);
if (objectKey == 'inventory') return kpisInventory(storeName, dateRange);
if (objectKey == 'location') return kpisLocation(storeName, dateRange);
return new List<KpiData>();
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] getDashboardKpis failed for ' + objectKey + ': ' + e.getMessage());
return new List<KpiData>();
}
}
// ---------- KPI formatting helpers ----------
private static String fmtNum(Decimal n) {
if (n == null) return '0';
Long v = n.setScale(0).longValue();
Boolean neg = v < 0;
String s = String.valueOf(neg ? -v : v);
String r = '';
for (Integer i = 0; i < s.length(); i++) {
if (i > 0 && Math.mod(s.length() - i, 3) == 0) r += ',';
r += s.substring(i, i + 1);
}
return neg ? '-' + r : r;
}
private static String fmtNum(Integer n) { return fmtNum(n == null ? (Decimal)0 : Decimal.valueOf(n)); }
private static String fmtCurrency(Decimal n) {
if (n == null) return '$0';
Decimal abs = n.abs();
if (abs >= 1000000) return '$' + (n / 1000000).setScale(1) + 'M';
if (abs >= 1000) return '$' + (n / 1000).setScale(1) + 'K';
return '$' + n.setScale(0);
}
private static String fmtPct(Decimal numerator, Decimal denominator) {
if (denominator == null || denominator == 0) return '0%';
return (numerator / denominator * 100).setScale(1) + '%';
}
private static KpiData kd(String label, String value, String sub, String accent) {
return kd(label, value, sub, accent, null, 'neutral');
}
private static KpiData kd(String label, String value, String sub, String accent, String delta, String deltaDir) {
KpiData k = new KpiData();
k.label = label;
k.value = value;
k.sub = sub;
k.accent = accent;
k.delta = delta;
k.deltaDir = deltaDir;
return k;
}
/** Returns the first field value from a GROUP BY aggregate result, or '—' if none. */
private static String topGroupVal(List<AggregateResult> ars, String alias) {
if (ars != null && !ars.isEmpty()) {
Object v = ars[0].get(alias);
if (v != null && String.valueOf(v).trim() != '') return String.valueOf(v);
}
return '—';
}
/** Returns [deltaString, deltaDir] comparing current vs prior period for a simple COUNT. */
private static String[] periodDelta(String sobjectName, String baseWhere, String dateRange) {
try {
String cur = dateRangeClause(dateRange);
String prior = priorPeriodClause(dateRange);
if (String.isBlank(prior)) return new String[]{'', 'neutral'};
Integer c = Database.countQuery('SELECT COUNT() FROM ' + sobjectName + baseWhere + cur);
Integer p = Database.countQuery('SELECT COUNT() FROM ' + sobjectName + baseWhere + prior);
if (p == 0) return new String[]{'', 'neutral'};
Decimal pct = ((c - p) * 100.0) / p;
String sign = pct >= 0 ? '▲' : '▼';
String dir = pct >= 0 ? 'up' : 'down';
return new String[]{sign + ' ' + pct.abs().setScale(1) + '%', dir};
} catch (Exception e) { return new String[]{'', 'neutral'}; }
}
private static String priorPeriodClause(String dateRange) {
if (String.isBlank(dateRange) || dateRange == 'All Time') return ' AND CreatedDate = LAST_YEAR';
if (dateRange == 'Today') return ' AND CreatedDate = YESTERDAY';
if (dateRange == 'Last 7 days') return ' AND CreatedDate >= ' + Date.today().addDays(-14) + ' AND CreatedDate < ' + Date.today().addDays(-7);
if (dateRange == 'Last 30 days') return ' AND CreatedDate >= ' + Date.today().addDays(-60) + ' AND CreatedDate < ' + Date.today().addDays(-30);
if (dateRange == 'This Month') return ' AND CreatedDate = LAST_MONTH';
if (dateRange == 'This Quarter') return ' AND CreatedDate = LAST_QUARTER';
if (dateRange == 'This Year') return ' AND CreatedDate = LAST_YEAR';
return '';
}
// ---------- Per-object KPI builders ----------
private static List<KpiData> kpisProduct(String storeName, String dateRange) {
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
String dr = dateRangeClause(dateRange);
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr;
String vw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr;
Integer total = Database.countQuery('SELECT COUNT() FROM Product2' + bw);
Integer active = Database.countQuery('SELECT COUNT() FROM Product2' + bw + ' AND IsActive = true');
// OUT OF STOCK: group by Product so we count distinct products, not variants
Integer oos = 0;
try {
List<AggregateResult> oosAr = Database.query(
'SELECT HIC_ShopifySync__Product__c'
+ ' FROM HIC_ShopifySync__Variant__c'
+ vw
+ ' AND HIC_ShopifySync__Inventory_Quantity__c <= 0'
+ ' AND HIC_ShopifySync__Product__c != null'
+ ' GROUP BY HIC_ShopifySync__Product__c'
);
oos = oosAr.size();
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] oos count failed: ' + e.getMessage());
}
// LOW STOCK: group by Product so we count distinct products, not variants
Integer low = 0;
try {
List<AggregateResult> lowAr = Database.query(
'SELECT HIC_ShopifySync__Product__c'
+ ' FROM HIC_ShopifySync__Variant__c'
+ vw
+ ' AND HIC_ShopifySync__Inventory_Quantity__c > 0'
+ ' AND HIC_ShopifySync__Inventory_Quantity__c < 10'
+ ' AND HIC_ShopifySync__Product__c != null'
+ ' GROUP BY HIC_ShopifySync__Product__c'
);
low = lowAr.size();
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] low stock count failed: ' + e.getMessage());
}
Integer newM = Database.countQuery('SELECT COUNT() FROM Product2 WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + ' AND CreatedDate = THIS_MONTH');
Decimal revenue = 0, avgPrice = 0;
try {
List<AggregateResult> revAr = Database.query('SELECT SUM(TotalPrice) rev FROM OrderItem WHERE Product2Id != null AND Order.HIC_ShopifySync__Shopify_Id__c != null AND Order.HIC_ShopifySync__Store__c != null' + storeClauseFor(storeName, 'Order.HIC_ShopifySync__Store__c') + dateRangeClauseFor(dateRange, 'Order.CreatedDate'));
if (!revAr.isEmpty() && revAr[0].get('rev') != null) revenue = (Decimal) revAr[0].get('rev');
} catch (Exception e) {}
try {
List<AggregateResult> priceAr = Database.query('SELECT AVG(HIC_ShopifySync__Price__c) avg FROM HIC_ShopifySync__Variant__c' + vw + ' AND HIC_ShopifySync__Price__c != null');
if (!priceAr.isEmpty() && priceAr[0].get('avg') != null) avgPrice = (Decimal) priceAr[0].get('avg');
} catch (Exception e) {}
Integer cats = 0;
try { cats = Database.query('SELECT Family FROM Product2' + bw + ' AND Family != null GROUP BY Family').size(); } catch (Exception e) {}
String[] delta = periodDelta('Product2', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf, dateRange);
List<KpiData> r = new List<KpiData>();
r.add(kd('TOTAL PRODUCTS', fmtNum(total), 'synced', 'k-blue', delta[0], delta[1]));
r.add(kd('ACTIVE', fmtNum(active), 'live', 'k-green'));
r.add(kd('OUT OF STOCK', fmtNum(oos), 'products', 'k-red'));
r.add(kd('LOW STOCK', fmtNum(low), '< 10 qty', 'k-yellow'));
r.add(kd('REVENUE', fmtCurrency(revenue), 'total', 'k-purple'));
r.add(kd('AVG UNIT PRICE', fmtCurrency(avgPrice),'per SKU', 'k-cyan'));
r.add(kd('NEW THIS MONTH', fmtNum(newM), 'added', 'k-blue'));
r.add(kd('CATEGORIES', fmtNum(cats), 'product family', 'k-green'));
return r;
}
private static List<KpiData> kpisVariant(String storeName, String dateRange) {
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
String dr = dateRangeClause(dateRange);
String vw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr;
Integer total = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Variant__c' + vw);
Integer active = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Variant__c' + vw + ' AND HIC_ShopifySync__Inventory_Quantity__c > 0');
Integer oos = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Variant__c' + vw + ' AND HIC_ShopifySync__Inventory_Quantity__c <= 0');
Integer low = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Variant__c' + vw + ' AND HIC_ShopifySync__Inventory_Quantity__c > 0 AND HIC_ShopifySync__Inventory_Quantity__c < 10');
Decimal avgPrice = 0, avgQty = 0;
try {
List<AggregateResult> ar = Database.query('SELECT AVG(HIC_ShopifySync__Price__c) ap, AVG(HIC_ShopifySync__Inventory_Quantity__c) aq FROM HIC_ShopifySync__Variant__c' + vw);
if (!ar.isEmpty()) {
if (ar[0].get('ap') != null) avgPrice = (Decimal) ar[0].get('ap');
if (ar[0].get('aq') != null) avgQty = (Decimal) ar[0].get('aq');
}
} catch (Exception e) {}
Integer prods = 0;
try { prods = Database.query('SELECT HIC_ShopifySync__Product__c FROM HIC_ShopifySync__Variant__c' + vw + ' AND HIC_ShopifySync__Product__c != null GROUP BY HIC_ShopifySync__Product__c').size(); } catch (Exception e) {}
String varPerProd = prods > 0 ? (Decimal.valueOf(total) / prods).setScale(1) + '' : '—';
String[] delta = periodDelta('HIC_ShopifySync__Variant__c', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf, dateRange);
List<KpiData> r = new List<KpiData>();
r.add(kd('TOTAL VARIANTS', fmtNum(total), 'synced', 'k-blue', delta[0], delta[1]));
r.add(kd('ACTIVE SKUS', fmtNum(active), 'qty > 0', 'k-green'));
r.add(kd('OUT OF STOCK', fmtNum(oos), 'qty = 0', 'k-red'));
r.add(kd('LOW STOCK', fmtNum(low), '< 10 qty', 'k-yellow'));
r.add(kd('AVG PRICE', fmtCurrency(avgPrice), 'per variant', 'k-purple'));
r.add(kd('AVG QTY', fmtNum(avgQty), 'per variant', 'k-cyan'));
r.add(kd('VARIANTS/PROD', varPerProd, 'avg', 'k-blue'));
r.add(kd('PRODUCTS', fmtNum(prods), 'with variants','k-green'));
return r;
}
private static List<KpiData> kpisLead(String storeName, String dateRange) {
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
String dr = dateRangeClause(dateRange);
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr;
Integer total = Database.countQuery('SELECT COUNT() FROM Lead' + bw);
Integer newL = Database.countQuery('SELECT COUNT() FROM Lead' + bw + ' AND Status = \'New\'');
Integer working = Database.countQuery('SELECT COUNT() FROM Lead' + bw + ' AND Status = \'Working\'');
Integer qualified = Database.countQuery('SELECT COUNT() FROM Lead' + bw + ' AND Status = \'Qualified\'');
Integer converted = Database.countQuery('SELECT COUNT() FROM Lead' + bw + ' AND IsConverted = true');
String topSrc = '—';
try {
List<AggregateResult> ar = Database.query('SELECT LeadSource src, COUNT(Id) cnt FROM Lead' + bw + ' AND LeadSource != null GROUP BY LeadSource ORDER BY COUNT(Id) DESC LIMIT 1');
topSrc = topGroupVal(ar, 'src');
} catch (Exception e) {}
String[] delta = periodDelta('Lead', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf, dateRange);
List<KpiData> r = new List<KpiData>();
r.add(kd('TOTAL LEADS', fmtNum(total), 'synced', 'k-blue', delta[0], delta[1]));
r.add(kd('NEW', fmtNum(newL), 'open', 'k-green'));
r.add(kd('WORKING', fmtNum(working), 'in progress', 'k-yellow'));
r.add(kd('QUALIFIED', fmtNum(qualified), 'ready', 'k-purple'));
r.add(kd('CONVERTED', fmtNum(converted), 'total', 'k-cyan'));
r.add(kd('CONV. RATE', fmtPct(converted, total), '%', 'k-green'));
r.add(kd('TOP SOURCE', topSrc, 'channel', 'k-blue'));
r.add(kd('UNCONVERTED', fmtNum(total - converted), 'open pipeline', 'k-red'));
return r;
}
private static List<KpiData> kpisAccount(String storeName, String dateRange, Boolean personAccounts) {
Boolean paOn = personAccountsEnabled();
String paFilter = paOn ? (personAccounts ? ' AND IsPersonAccount = true' : ' AND IsPersonAccount = false') : '';
String paOrdFilter = paOn ? (personAccounts ? ' AND Account.IsPersonAccount = true' : ' AND Account.IsPersonAccount = false') : '';
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
String dr = dateRangeClause(dateRange);
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr + paFilter;
Integer total = (paOn && personAccounts && !paOn) ? 0 : Database.countQuery('SELECT COUNT() FROM Account' + bw);
Integer newQ = Database.countQuery('SELECT COUNT() FROM Account' + ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + paFilter + ' AND CreatedDate = THIS_QUARTER');
Integer withOpps = 0;
try { withOpps = Database.countQuery('SELECT COUNT() FROM Account WHERE Id IN (SELECT AccountId FROM Opportunity WHERE IsClosed = false AND HIC_ShopifySync__Shopify_Id__c != null) AND HIC_ShopifySync__Shopify_Id__c != null' + paFilter); } catch (Exception e) {}
Decimal avgRev = 0;
Integer ordAccts = 0;
try {
List<AggregateResult> ar = Database.query('SELECT AVG(TotalAmount) avg, COUNT_DISTINCT(AccountId) ac FROM Order WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null AND AccountId != null AND TotalAmount != null' + sf + paOrdFilter);
if (!ar.isEmpty()) {
if (ar[0].get('avg') != null) avgRev = (Decimal) ar[0].get('avg');
if (ar[0].get('ac') != null) ordAccts = Integer.valueOf(String.valueOf(ar[0].get('ac')));
}
} catch (Exception e) {}
Integer oppCount = 0;
try { oppCount = Database.countQuery('SELECT COUNT() FROM Opportunity WHERE HIC_ShopifySync__Shopify_Id__c != null AND AccountId != null' + sf); } catch (Exception e) {}
String oppPerAcct = total > 0 ? (Decimal.valueOf(oppCount) / total).setScale(1) + '' : '0';
String topRegion = '—';
try {
List<AggregateResult> ar = Database.query('SELECT BillingCountry r, COUNT(Id) cnt FROM Account' + bw + ' AND BillingCountry != null GROUP BY BillingCountry ORDER BY COUNT(Id) DESC LIMIT 1');
topRegion = topGroupVal(ar, 'r');
} catch (Exception e) {}
String[] delta = periodDelta('Account', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + paFilter, dateRange);
String label1 = personAccounts ? 'PERSON ACCOUNTS' : 'TOTAL ACCOUNTS';
List<KpiData> r = new List<KpiData>();
r.add(kd(label1, fmtNum(total), 'synced', 'k-blue', delta[0], delta[1]));
r.add(kd('WITH ORDERS', fmtNum(ordAccts), 'active buyers', 'k-green'));
r.add(kd('NEW THIS QTR', fmtNum(newQ), 'added', 'k-cyan'));
r.add(kd('AVG ORDER VAL', fmtCurrency(avgRev), 'per order', 'k-purple'));
r.add(kd('WITH OPEN OPPS',fmtNum(withOpps), 'in pipeline', 'k-blue'));
r.add(kd('OPPS/ACCT', oppPerAcct, 'avg', 'k-green'));
r.add(kd('TOP REGION', topRegion, '', 'k-yellow'));
r.add(kd('OPEN OPPS', fmtNum(oppCount), 'total pipeline','k-red'));
return r;
}
private static List<KpiData> kpisContact(String storeName, String dateRange) {
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
String dr = dateRangeClause(dateRange);
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr;
Integer total = Database.countQuery('SELECT COUNT() FROM Contact' + bw);
Integer newM = Database.countQuery('SELECT COUNT() FROM Contact WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + ' AND CreatedDate = THIS_MONTH');
Integer withAcct = Database.countQuery('SELECT COUNT() FROM Contact' + bw + ' AND AccountId != null');
Integer withEmail = Database.countQuery('SELECT COUNT() FROM Contact' + bw + ' AND Email != null');
String topCity = '—';
try {
List<AggregateResult> ar = Database.query('SELECT MailingCity c, COUNT(Id) cnt FROM Contact' + bw + ' AND MailingCity != null GROUP BY MailingCity ORDER BY COUNT(Id) DESC LIMIT 1');
topCity = topGroupVal(ar, 'c');
} catch (Exception e) {}
Integer accts = 0;
try { accts = Database.query('SELECT AccountId FROM Contact' + bw + ' AND AccountId != null GROUP BY AccountId').size(); } catch (Exception e) {}
String ctPerAcct = accts > 0 ? (Decimal.valueOf(withAcct) / accts).setScale(1) + '' : '0';
String[] delta = periodDelta('Contact', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf, dateRange);
List<KpiData> r = new List<KpiData>();
r.add(kd('TOTAL CONTACTS', fmtNum(total), 'synced', 'k-blue', delta[0], delta[1]));
r.add(kd('NEW THIS MONTH', fmtNum(newM), 'added', 'k-green'));
r.add(kd('WITH EMAIL', fmtNum(withEmail), '% with email', 'k-cyan'));
r.add(kd('EMAIL %', fmtPct(withEmail, total), 'opt-in', 'k-purple'));
r.add(kd('WITH ACCOUNT', fmtNum(withAcct), 'linked', 'k-yellow'));
r.add(kd('CONTACTS/ACCT', ctPerAcct, 'avg', 'k-blue'));
r.add(kd('TOP CITY', topCity, 'location', 'k-green'));
r.add(kd('NO ACCOUNT', fmtNum(total - withAcct), 'unlinked','k-red'));
return r;
}
private static List<KpiData> kpisOpportunity(String storeName, String dateRange, String payFilter) {
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
String dr = dateRangeClause(dateRange);
String pf = String.isNotBlank(payFilter) ? ' AND ' + payFilter : '';
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr + pf;
Integer total = Database.countQuery('SELECT COUNT() FROM Opportunity' + bw);
Integer closedWon = Database.countQuery('SELECT COUNT() FROM Opportunity' + bw + ' AND StageName = \'Closed Won\'');
Integer closingSoon = 0;
try { closingSoon = Database.countQuery('SELECT COUNT() FROM Opportunity' + bw + ' AND IsClosed = false AND CloseDate <= NEXT_N_DAYS:30'); } catch (Exception e) {}
Integer wonQtr = 0;
try { wonQtr = Database.countQuery('SELECT COUNT() FROM Opportunity' + bw + ' AND StageName = \'Closed Won\' AND CloseDate = THIS_QUARTER'); } catch (Exception e) {}
Decimal pipeline = 0, avgDeal = 0, maxOpen = 0;
try {
List<AggregateResult> ar = Database.query('SELECT SUM(Amount) total, AVG(Amount) avg FROM Opportunity' + bw + ' AND Amount != null');
if (!ar.isEmpty()) {
if (ar[0].get('total') != null) pipeline = (Decimal) ar[0].get('total');
if (ar[0].get('avg') != null) avgDeal = (Decimal) ar[0].get('avg');
}
} catch (Exception e) {}
try {
List<AggregateResult> ar = Database.query('SELECT MAX(Amount) mx FROM Opportunity' + bw + ' AND IsClosed = false AND Amount != null');
if (!ar.isEmpty() && ar[0].get('mx') != null) maxOpen = (Decimal) ar[0].get('mx');
} catch (Exception e) {}
String label1 = String.isNotBlank(payFilter) && payFilter.contains('!= null') ? 'PAID OPPS'
: String.isNotBlank(payFilter) ? 'DRAFT OPPS' : 'OPEN OPPS';
String[] delta = periodDelta('Opportunity', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + pf, dateRange);
List<KpiData> r = new List<KpiData>();
r.add(kd(label1, fmtNum(total), 'total', 'k-blue', delta[0], delta[1]));
r.add(kd('PIPELINE VALUE',fmtCurrency(pipeline), 'total $', 'k-green'));
r.add(kd('WIN RATE', fmtPct(closedWon, total), '%', 'k-cyan'));
r.add(kd('AVG DEAL SIZE', fmtCurrency(avgDeal), 'per opp', 'k-purple'));
r.add(kd('CLOSING SOON', fmtNum(closingSoon), 'next 30 days', 'k-yellow'));
r.add(kd('WON THIS QTR', fmtNum(wonQtr), 'closed won', 'k-green'));
r.add(kd('CLOSED WON', fmtNum(closedWon), 'total', 'k-blue'));
r.add(kd('LARGEST OPEN', fmtCurrency(maxOpen), '$', 'k-red'));
return r;
}
private static List<KpiData> kpisOrder(String storeName, String dateRange, String payFilter) {
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
String dr = dateRangeClause(dateRange);
String pf = String.isNotBlank(payFilter) ? ' AND ' + payFilter : '';
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr + pf;
Integer total = Database.countQuery('SELECT COUNT() FROM Order' + bw);
Integer newWeek = Database.countQuery('SELECT COUNT() FROM Order' + ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + pf + ' AND CreatedDate = LAST_N_DAYS:7');
Decimal revenue = 0, avgOrder = 0;
try {
List<AggregateResult> ar = Database.query('SELECT SUM(TotalAmount) s, AVG(TotalAmount) a FROM Order' + bw + ' AND TotalAmount != null');
if (!ar.isEmpty()) {
if (ar[0].get('s') != null) revenue = (Decimal) ar[0].get('s');
if (ar[0].get('a') != null) avgOrder = (Decimal) ar[0].get('a');
}
} catch (Exception e) {}
String topStore = '—';
try {
List<AggregateResult> ar = Database.query('SELECT HIC_ShopifySync__Store__c s, SUM(TotalAmount) rev FROM Order' + bw + ' AND TotalAmount != null GROUP BY HIC_ShopifySync__Store__c ORDER BY SUM(TotalAmount) DESC LIMIT 1');
topStore = topGroupVal(ar, 's');
} catch (Exception e) {}
Integer acctCount = 0;
Integer repeatAccts = 0;
try {
List<AggregateResult> ar = Database.query('SELECT AccountId ac, COUNT(Id) cnt FROM Order' + bw + ' AND AccountId != null GROUP BY AccountId');
acctCount = ar.size();
for (AggregateResult r : ar) {
if ((Decimal) r.get('cnt') > 1) repeatAccts++;
}
} catch (Exception e) {}
String repeatRate = acctCount > 0 ? fmtPct(repeatAccts, acctCount) : '—';
String label1 = payFilter != null && payFilter.contains('!= null') ? 'PAID ORDERS' : 'DRAFT ORDERS';
String label2 = payFilter != null && payFilter.contains('!= null') ? 'REVENUE' : 'CART VALUE';
String[] delta = periodDelta('Order', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + pf, dateRange);
List<KpiData> r = new List<KpiData>();
r.add(kd(label1, fmtNum(total), 'total', 'k-blue', delta[0], delta[1]));
r.add(kd(label2, fmtCurrency(revenue), 'total $', 'k-green'));
r.add(kd('AVG ORDER', fmtCurrency(avgOrder), 'per order', 'k-yellow'));
r.add(kd('REPEAT RATE',repeatRate, 'returning', 'k-purple'));
r.add(kd('CUSTOMERS', fmtNum(acctCount), 'distinct', 'k-cyan'));
r.add(kd('TOP STORE', topStore, 'by revenue', 'k-blue'));
r.add(kd('NEW THIS WEEK', fmtNum(newWeek), 'last 7 days', 'k-green'));
r.add(kd('REPEAT BUYERS', fmtNum(repeatAccts), '> 1 order', 'k-red'));
return r;
}
private static List<KpiData> kpisCollection(String storeName, String dateRange) {
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
String dr = dateRangeClause(dateRange);
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr;
Integer total = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Collection__c' + bw);
Integer active = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Collection__c' + bw + ' AND HIC_ShopifySync__Published_At__c != null');
Integer newM = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Collection__c WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + ' AND CreatedDate = THIS_MONTH');
Integer junctionTotal = 0;
String topColl = '—';
try {
List<AggregateResult> jAr = Database.query('SELECT COUNT(Id) cnt FROM HIC_ShopifySync__Collect__c WHERE HIC_ShopifySync__Collection__c != null AND HIC_ShopifySync__Collection__r.HIC_ShopifySync__Shopify_Id__c != null' + storeClauseFor(storeName, 'HIC_ShopifySync__Collection__r.HIC_ShopifySync__Store__c'));
if (!jAr.isEmpty() && jAr[0].get('cnt') != null) junctionTotal = Integer.valueOf(String.valueOf(jAr[0].get('cnt')));
} catch (Exception e) {}
try {
List<AggregateResult> tAr = Database.query('SELECT HIC_ShopifySync__Collection__r.Name n, COUNT(Id) cnt FROM HIC_ShopifySync__Collect__c WHERE HIC_ShopifySync__Collection__c != null AND HIC_ShopifySync__Collection__r.HIC_ShopifySync__Shopify_Id__c != null' + storeClauseFor(storeName, 'HIC_ShopifySync__Collection__r.HIC_ShopifySync__Store__c') + ' GROUP BY HIC_ShopifySync__Collection__r.Name ORDER BY COUNT(Id) DESC LIMIT 1');
topColl = topGroupVal(tAr, 'n');
} catch (Exception e) {}
String avgProds = total > 0 ? (Decimal.valueOf(junctionTotal) / total).setScale(1) + '' : '0';
String[] delta = periodDelta('HIC_ShopifySync__Collection__c', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf, dateRange);
List<KpiData> r = new List<KpiData>();
r.add(kd('COLLECTIONS', fmtNum(total), 'synced', 'k-blue', delta[0], delta[1]));
r.add(kd('ACTIVE', fmtNum(active), 'published', 'k-green'));
r.add(kd('AVG PRODUCTS', avgProds, 'per collection','k-cyan'));
r.add(kd('TOP COLLECTION',topColl, 'most products', 'k-purple'));
r.add(kd('TOTAL ITEMS', fmtNum(junctionTotal), 'in collections', 'k-yellow'));
r.add(kd('NEW THIS MONTH',fmtNum(newM), 'added', 'k-blue'));
r.add(kd('INACTIVE', fmtNum(total - active), 'unpublished', 'k-red'));
r.add(kd('ACTIVE %', fmtPct(active, total), 'published %', 'k-green'));
return r;
}
private static List<KpiData> kpisInventory(String storeName, String dateRange) {
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
String dr = dateRangeClause(dateRange);
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr;
Integer total = 0, oos = 0, low = 0, alerts = 0, tracked = 0;
try { total = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Inventory__c' + bw); } catch (Exception e) {}
try { oos = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Inventory__c' + bw + ' AND HIC_ShopifySync__Available__c <= 0'); } catch (Exception e) {}
try { low = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Inventory__c' + bw + ' AND HIC_ShopifySync__Available__c > 0 AND HIC_ShopifySync__Available__c < 10'); } catch (Exception e) {}
try { alerts = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Inventory__c' + bw + ' AND HIC_ShopifySync__Available__c < 5'); } catch (Exception e) {}
try { tracked = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Inventory__c' + bw + ' AND HIC_ShopifySync__Tracked__c = true'); } catch (Exception e) {}
Decimal avgQty = 0;
try {
List<AggregateResult> ar = Database.query('SELECT AVG(HIC_ShopifySync__Available__c) avg FROM HIC_ShopifySync__Inventory__c' + bw + ' AND HIC_ShopifySync__Available__c != null');
if (!ar.isEmpty() && ar[0].get('avg') != null) avgQty = (Decimal) ar[0].get('avg');
} catch (Exception e) {}
String topLoc = '—';
try {
String locStoreClause = storeClauseFor(storeName, 'HIC_ShopifySync__Location__r.HIC_ShopifySync__Store__c');
List<AggregateResult> ar = Database.query(
'SELECT HIC_ShopifySync__Location__r.Name l, COUNT(Id) cnt'
+ ' FROM HIC_ShopifySync__Inventory_Level__c'
+ ' WHERE HIC_ShopifySync__Location__c != null'
+ ' AND HIC_ShopifySync__Location__r.HIC_ShopifySync__Shopify_Id__c != null'
+ ' AND HIC_ShopifySync__Location__r.HIC_ShopifySync__Store__c != null'
+ locStoreClause
+ ' GROUP BY HIC_ShopifySync__Location__r.Name'
+ ' ORDER BY COUNT(Id) DESC LIMIT 1');
topLoc = topGroupVal(ar, 'l');
} catch (Exception e) {}
String[] delta = periodDelta('HIC_ShopifySync__Inventory__c', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf, dateRange);
List<KpiData> r = new List<KpiData>();
r.add(kd('TOTAL ITEMS', fmtNum(total), 'tracked', 'k-blue', delta[0], delta[1]));
r.add(kd('TRACKED', fmtNum(tracked), 'monitored', 'k-green'));
r.add(kd('OUT OF STOCK', fmtNum(oos), '0 qty', 'k-red'));
r.add(kd('LOW STOCK', fmtNum(low), '< 10 qty', 'k-yellow'));
r.add(kd('AVG QUANTITY', fmtNum(avgQty), 'per item', 'k-cyan'));
r.add(kd('TOP LOCATION', topLoc, 'by item count', 'k-purple'));
r.add(kd('REORDER ALERTS', fmtNum(alerts), 'qty < 5', 'k-red'));
r.add(kd('IN STOCK %', total > 0 ? fmtPct(total - oos, total) : '—', 'available', 'k-green'));
return r;
}
private static List<KpiData> kpisLocation(String storeName, String dateRange) {
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
String dr = dateRangeClause(dateRange);
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr;
Integer total = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Location__c' + bw);
Integer active = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Location__c' + bw + ' AND HIC_ShopifySync__Active__c = true');
Integer newYear = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Location__c WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + ' AND CreatedDate = THIS_YEAR');
Integer countries = 0, cities = 0;
try { countries = Database.query('SELECT HIC_ShopifySync__Country__c FROM HIC_ShopifySync__Location__c' + bw + ' AND HIC_ShopifySync__Country__c != null GROUP BY HIC_ShopifySync__Country__c').size(); } catch (Exception e) {}
try { cities = Database.query('SELECT HIC_ShopifySync__City__c FROM HIC_ShopifySync__Location__c' + bw + ' AND HIC_ShopifySync__City__c != null GROUP BY HIC_ShopifySync__City__c').size(); } catch (Exception e) {}
Integer withInv = 0;
try { withInv = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Location__c WHERE Id IN (SELECT HIC_ShopifySync__Location__c FROM HIC_ShopifySync__Inventory_Level__c) AND HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf); } catch (Exception e) {}
Integer storeCount = 0;
try { storeCount = Database.query('SELECT HIC_ShopifySync__Store__c FROM HIC_ShopifySync__Location__c' + bw + ' AND HIC_ShopifySync__Store__c != null GROUP BY HIC_ShopifySync__Store__c').size(); } catch (Exception e) {}
String avgPerStore = storeCount > 0 ? (Decimal.valueOf(total) / storeCount).setScale(1) + '' : '—';
String[] delta = periodDelta('HIC_ShopifySync__Location__c', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf, dateRange);
List<KpiData> r = new List<KpiData>();
r.add(kd('LOCATIONS', fmtNum(total), 'synced', 'k-blue', delta[0], delta[1]));
r.add(kd('ACTIVE', fmtNum(active), 'open', 'k-green'));
r.add(kd('COUNTRIES', fmtNum(countries), 'distinct', 'k-cyan'));
r.add(kd('CITIES', fmtNum(cities), 'distinct', 'k-purple'));
r.add(kd('WITH INVENTORY',fmtNum(withInv), 'stocked', 'k-yellow'));
r.add(kd('AVG PER STORE', avgPerStore, 'locations', 'k-blue'));
r.add(kd('INACTIVE', fmtNum(total - active), 'inactive', 'k-red'));
r.add(kd('NEW THIS YEAR', fmtNum(newYear), 'added', 'k-green'));
return r;
}
private static void putChart(Map<String, DashboardData> m, String key, DashboardData d) {
if (d != null) m.put(key, d);
}
// ---------- Private chart builders ----------
private static DashboardData chartCreatedLast3Months(String sobjectName, String baseWhere, String title) {
DashboardData d = new DashboardData();
d.title = title;
d.defaultChart = 'bar';
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b'};
Date today = Date.today();
List<String> monthKeys = new List<String>();
d.labels = new List<String>();
d.values = new List<Decimal>{0, 0, 0};
for (Integer i = 3; i >= 1; i--) {
Date m = today.addMonths(-i);
monthKeys.add(m.year() + '-' + m.month());
d.labels.add(monthAbbrev(m.month()) + ' ' + m.year());
}
String soql = 'SELECT CALENDAR_MONTH(CreatedDate) mo, CALENDAR_YEAR(CreatedDate) yr, COUNT(Id) cnt'
+ ' FROM ' + sobjectName + baseWhere
+ ' AND CreatedDate >= LAST_N_MONTHS:3'
+ ' GROUP BY CALENDAR_MONTH(CreatedDate), CALENDAR_YEAR(CreatedDate)';
try {
for (AggregateResult ar : Database.query(soql)) {
Integer mo = (Integer) ar.get('mo');
Integer yr = (Integer) ar.get('yr');
String key = yr + '-' + mo;
Integer idx = monthKeys.indexOf(key);
if (idx >= 0) {
d.values[idx] = (Decimal) ar.get('cnt');
}
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartCreatedLast3Months(' + sobjectName + ') failed: ' + e.getMessage());
return null;
}
return d;
}
/**
* Product dash1 — REAL "Top 6 Products by Units Sold" (replaces the LWC mock).
* Sums OrderItem.Quantity per product across synced Orders; store + date
* filters apply to the parent Order (i.e. units sold WITHIN the range).
*/
private static DashboardData chartTopProductsByUnitsSold(String storeName, String dateRange) {
DashboardData d = new DashboardData();
d.title = 'Top 6 Products by Units Sold';
d.defaultChart = 'bar';
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2','#16a34a'};
d.labels = new List<String>();
d.values = new List<Decimal>();
String soql = 'SELECT Product2.Name prodName, SUM(Quantity) units'
+ ' FROM OrderItem'
+ ' WHERE Product2Id != null'
+ ' AND Order.HIC_ShopifySync__Shopify_Id__c != null'
+ ' AND Order.HIC_ShopifySync__Store__c != null'
+ storeClauseFor(storeName, 'Order.HIC_ShopifySync__Store__c')
+ dateRangeClauseFor(dateRange, 'Order.CreatedDate')
+ ' GROUP BY Product2.Name'
+ ' ORDER BY SUM(Quantity) DESC'
+ ' LIMIT 6';
try {
for (AggregateResult ar : Database.query(soql)) {
String name = (String) ar.get('prodName');
Decimal units = (Decimal) ar.get('units');
d.labels.add(name != null ? name.left(20) : 'Unknown');
d.values.add(units != null ? units : 0);
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartTopProductsByUnitsSold failed: ' + e.getMessage());
return null;
}
return d;
}
/** Variant dash1 — REAL "Top 6 Variants by Inventory" (replaces the LWC mock). */
private static DashboardData chartTopVariantsByInventory(String scopeClause) {
DashboardData d = new DashboardData();
d.title = 'Top 6 Products by Variant Count';
d.defaultChart = 'bar';
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2','#16a34a'};
d.labels = new List<String>();
d.values = new List<Decimal>();
// Fallback path: try qty-based ordering first (matches original title if data is populated).
// If ALL variants have null qty in this store, we return a "variants per product" bar instead
// so the tile always renders something meaningful.
String qtySoql = 'SELECT Name, HIC_ShopifySync__Inventory_Quantity__c qty'
+ ' FROM HIC_ShopifySync__Variant__c'
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ scopeClause
+ ' AND HIC_ShopifySync__Inventory_Quantity__c != null'
+ ' AND HIC_ShopifySync__Inventory_Quantity__c > 0'
+ ' ORDER BY HIC_ShopifySync__Inventory_Quantity__c DESC'
+ ' LIMIT 6';
try {
List<SObject> qtyRows = Database.query(qtySoql);
if (!qtyRows.isEmpty()) {
d.title = 'Top 6 Variants by Inventory';
for (SObject r : qtyRows) {
String name = (String) r.get('Name');
d.labels.add(name != null ? name.left(20) : 'Unknown');
d.values.add((Decimal) r.get('HIC_ShopifySync__Inventory_Quantity__c'));
}
return d;
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartTopVariantsByInventory qty path failed: ' + e.getMessage());
}
// Fallback: variants grouped per parent product (always populated when variants exist)
try {
String grpSoql = 'SELECT HIC_ShopifySync__Product__r.Name prodName, COUNT(Id) cnt'
+ ' FROM HIC_ShopifySync__Variant__c'
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ scopeClause
+ ' AND HIC_ShopifySync__Product__c != null'
+ ' GROUP BY HIC_ShopifySync__Product__r.Name'
+ ' ORDER BY COUNT(Id) DESC'
+ ' LIMIT 6';
for (AggregateResult ar : Database.query(grpSoql)) {
String name = (String) ar.get('prodName');
d.labels.add(name != null ? name.left(20) : 'Unknown');
d.values.add((Decimal) ar.get('cnt'));
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartTopVariantsByInventory group path failed: ' + e.getMessage());
return null;
}
return d;
}
/** Lead dash1 — REAL "Leads by Status" donut (replaces the LWC mock ratios). */
private static DashboardData chartLeadsByStatus(String scopeClause) {
DashboardData d = new DashboardData();
d.title = 'Leads by Status';
d.defaultChart = 'donut';
d.colors = new List<String>{'#1f6feb','#f59e0b','#2f9e44','#94a3b8','#7c3aed','#0891b2'};
d.labels = new List<String>();
d.values = new List<Decimal>();
String soql = 'SELECT Status st, COUNT(Id) cnt'
+ ' FROM Lead'
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ scopeClause
+ ' GROUP BY Status'
+ ' ORDER BY COUNT(Id) DESC'
+ ' LIMIT 6';
try {
for (AggregateResult ar : Database.query(soql)) {
String st = (String) ar.get('st');
d.labels.add(st != null ? st : 'Unknown');
d.values.add((Decimal) ar.get('cnt'));
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartLeadsByStatus failed: ' + e.getMessage());
return null;
}
return d;
}
/** Lead dash2 — REAL "Top Lead Sources" bar (replaces the LWC mock ratios). */
private static DashboardData chartLeadsBySource(String scopeClause) {
DashboardData d = new DashboardData();
d.title = 'Top Lead Sources';
d.defaultChart = 'bar';
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2','#16a34a'};
d.labels = new List<String>();
d.values = new List<Decimal>();
String soql = 'SELECT LeadSource src, COUNT(Id) cnt'
+ ' FROM Lead'
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ scopeClause
+ ' GROUP BY LeadSource'
+ ' ORDER BY COUNT(Id) DESC'
+ ' LIMIT 6';
try {
for (AggregateResult ar : Database.query(soql)) {
String src = (String) ar.get('src');
d.labels.add(src != null ? src : 'Unknown');
d.values.add((Decimal) ar.get('cnt'));
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartLeadsBySource failed: ' + e.getMessage());
return null;
}
return d;
}
/** Contact dash1 — REAL "Contacts by City" donut (replaces the LWC mock). */
private static DashboardData chartContactsByCity(String scopeClause) {
DashboardData d = new DashboardData();
d.title = 'Contacts by City';
d.defaultChart = 'donut';
d.colors = new List<String>{'#1f6feb','#2f9e44','#f59e0b','#7c3aed','#0891b2','#16a34a'};
d.labels = new List<String>();
d.values = new List<Decimal>();
String soql = 'SELECT MailingCity city, COUNT(Id) cnt'
+ ' FROM Contact'
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ scopeClause
+ ' GROUP BY MailingCity'
+ ' ORDER BY COUNT(Id) DESC'
+ ' LIMIT 6';
try {
for (AggregateResult ar : Database.query(soql)) {
String city = (String) ar.get('city');
d.labels.add(city != null ? city : 'Unknown');
d.values.add((Decimal) ar.get('cnt'));
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartContactsByCity failed: ' + e.getMessage());
return null;
}
return d;
}
/** Contact dash3 — REAL "Top 5 Accounts by Contact Count" (replaces the LWC mock). */
private static DashboardData chartContactsPerAccount(String scopeClause) {
DashboardData d = new DashboardData();
d.title = 'Top 5 Accounts by Contact Count';
d.defaultChart = 'bar';
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
d.labels = new List<String>();
d.values = new List<Decimal>();
String soql = 'SELECT Account.Name accName, COUNT(Id) cnt'
+ ' FROM Contact'
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ scopeClause
+ ' AND AccountId != null'
+ ' GROUP BY Account.Name'
+ ' ORDER BY COUNT(Id) DESC'
+ ' LIMIT 5';
try {
for (AggregateResult ar : Database.query(soql)) {
d.labels.add((String) ar.get('accName'));
d.values.add((Decimal) ar.get('cnt'));
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartContactsPerAccount failed: ' + e.getMessage());
return null;
}
return d;
}
private static DashboardData chartTopProductsByPrice(String storeClause) {
DashboardData d = new DashboardData();
d.title = 'Top 5 Products by Price';
d.defaultChart = 'bar';
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
d.labels = new List<String>();
d.values = new List<Decimal>();
String soql = 'SELECT HIC_ShopifySync__Product__r.Name prodName, MAX(HIC_ShopifySync__Price__c) maxPrice'
+ ' FROM HIC_ShopifySync__Variant__c'
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ storeClause
+ ' AND HIC_ShopifySync__Product__c != null AND HIC_ShopifySync__Price__c != null'
+ ' GROUP BY HIC_ShopifySync__Product__r.Name'
+ ' ORDER BY MAX(HIC_ShopifySync__Price__c) DESC'
+ ' LIMIT 5';
try {
for (AggregateResult ar : Database.query(soql)) {
String name = (String) ar.get('prodName');
Decimal price = (Decimal) ar.get('maxPrice');
d.labels.add(name != null ? name : 'Unknown');
d.values.add(price != null ? price : 0);
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartTopProductsByPrice failed: ' + e.getMessage());
return null;
}
return d;
}
private static DashboardData chartTopVariantsByPrice(String storeClause) {
DashboardData d = new DashboardData();
d.title = 'Top 5 Variants by Price';
d.defaultChart = 'bar';
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
d.labels = new List<String>();
d.values = new List<Decimal>();
String soql = 'SELECT Name, HIC_ShopifySync__Price__c'
+ ' FROM HIC_ShopifySync__Variant__c'
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ storeClause
+ ' AND HIC_ShopifySync__Price__c != null'
+ ' ORDER BY HIC_ShopifySync__Price__c DESC'
+ ' LIMIT 5';
try {
for (SObject r : Database.query(soql)) {
d.labels.add((String) r.get('Name'));
d.values.add((Decimal) r.get('HIC_ShopifySync__Price__c'));
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartTopVariantsByPrice failed: ' + e.getMessage());
return null;
}
return d;
}
// accountTypeFilter: ' AND Account.IsPersonAccount = true/false' — splits the
// chart correctly between the Account and Person Account sidebar tabs.
private static DashboardData chartTopAccountsByOrderCount(String storeClause, String accountTypeFilter) {
DashboardData d = new DashboardData();
d.title = 'Top 5 Accounts by Order Count';
d.defaultChart = 'bar';
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
d.labels = new List<String>();
d.values = new List<Decimal>();
String soql = 'SELECT Account.Name accName, COUNT(Id) cnt'
+ ' FROM Order'
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ storeClause
+ ' AND AccountId != null'
+ (String.isNotBlank(accountTypeFilter) ? accountTypeFilter : '')
+ ' GROUP BY Account.Name'
+ ' ORDER BY COUNT(Id) DESC'
+ ' LIMIT 5';
try {
for (AggregateResult ar : Database.query(soql)) {
d.labels.add((String) ar.get('accName'));
d.values.add((Decimal) ar.get('cnt'));
}
} catch (Exception e) {
// e.g. IsPersonAccount not available (Person Accounts disabled)
System.debug('[ShopifySyncDashboardController] chartTopAccountsByOrderCount failed: ' + e.getMessage());
return null;
}
return d;
}
/**
* oppTypeFilter: optional field filter (e.g. payment-status) appended with
* an explicit AND, mirroring the parameterized chartOrdersByAccount() used
* by paidOrder/draftOrder. Pass null/blank for the unfiltered 'opp' tab.
*/
private static DashboardData chartOpportunitiesByAccount(String storeClause, String oppTypeFilter) {
DashboardData d = new DashboardData();
d.title = 'Opportunities per Account';
d.defaultChart = 'bar';
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
d.labels = new List<String>();
d.values = new List<Decimal>();
String soql = 'SELECT Account.Name accName, COUNT(Id) cnt'
+ ' FROM Opportunity'
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ storeClause
+ ' AND AccountId != null'
+ (String.isNotBlank(oppTypeFilter) ? ' AND ' + oppTypeFilter : '')
+ ' GROUP BY Account.Name'
+ ' ORDER BY COUNT(Id) DESC'
+ ' LIMIT 5';
try {
for (AggregateResult ar : Database.query(soql)) {
d.labels.add((String) ar.get('accName'));
d.values.add((Decimal) ar.get('cnt'));
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartOpportunitiesByAccount failed: ' + e.getMessage());
return null;
}
return d;
}
private static DashboardData chartPaidVsDraftOpportunities(String storeClause) {
DashboardData d = new DashboardData();
d.title = 'Paid vs Draft Opportunities';
d.defaultChart = 'donut';
d.colors = new List<String>{'#2f9e44','#f59e0b'};
d.labels = new List<String>{'Paid', 'Draft'};
String baseWhere = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + storeClause;
try {
Integer paid = Database.countQuery(
'SELECT COUNT() FROM Opportunity' + baseWhere + ' AND HIC_ShopifySync__Payment_Status__c != null'
);
Integer draft = Database.countQuery(
'SELECT COUNT() FROM Opportunity' + baseWhere + ' AND HIC_ShopifySync__Payment_Status__c = null'
);
d.values = new List<Decimal>{paid, draft};
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartPaidVsDraftOpportunities failed: ' + e.getMessage());
return null;
}
return d;
}
/**
* oppTypeFilter: optional field filter (e.g. payment-status) appended with
* an explicit AND, mirroring the parameterized chartTopOrdersByAmount()
* used by paidOrder/draftOrder. Pass null/blank for the unfiltered 'opp' tab.
*/
private static DashboardData chartTopOpportunitiesByAmount(String storeClause, String oppTypeFilter) {
DashboardData d = new DashboardData();
d.title = 'Top 5 Opportunities by Amount';
d.defaultChart = 'bar';
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
d.labels = new List<String>();
d.values = new List<Decimal>();
String soql = 'SELECT Name, Amount'
+ ' FROM Opportunity'
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ storeClause
+ (String.isNotBlank(oppTypeFilter) ? ' AND ' + oppTypeFilter : '')
+ ' AND Amount != null'
+ ' ORDER BY Amount DESC'
+ ' LIMIT 5';
try {
for (SObject r : Database.query(soql)) {
String name = (String) r.get('Name');
d.labels.add(name != null ? name.left(20) : 'Unknown');
d.values.add((Decimal) r.get('Amount'));
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartTopOpportunitiesByAmount failed: ' + e.getMessage());
return null;
}
return d;
}
private static DashboardData chartOrdersByAccount(String storeClause, String orderTypeFilter) {
DashboardData d = new DashboardData();
d.title = 'Orders per Account';
d.defaultChart = 'bar';
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
d.labels = new List<String>();
d.values = new List<Decimal>();
String soql = 'SELECT Account.Name accName, COUNT(Id) cnt'
+ ' FROM Order'
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ storeClause
+ ' AND AccountId != null'
+ (String.isNotBlank(orderTypeFilter) ? ' AND ' + orderTypeFilter : '')
+ ' GROUP BY Account.Name'
+ ' ORDER BY COUNT(Id) DESC'
+ ' LIMIT 5';
try {
for (AggregateResult ar : Database.query(soql)) {
d.labels.add((String) ar.get('accName'));
d.values.add((Decimal) ar.get('cnt'));
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartOrdersByAccount failed: ' + e.getMessage());
return null;
}
return d;
}
private static DashboardData chartTopOrdersByAmount(String storeClause, String orderTypeFilter) {
DashboardData d = new DashboardData();
d.title = 'Top 5 Orders by Amount';
d.defaultChart = 'bar';
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
d.labels = new List<String>();
d.values = new List<Decimal>();
String soql = 'SELECT OrderNumber, TotalAmount'
+ ' FROM Order'
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ storeClause
+ (String.isNotBlank(orderTypeFilter) ? ' AND ' + orderTypeFilter : '')
+ ' AND TotalAmount != null'
+ ' ORDER BY TotalAmount DESC'
+ ' LIMIT 5';
try {
for (SObject r : Database.query(soql)) {
d.labels.add((String) r.get('OrderNumber'));
d.values.add((Decimal) r.get('TotalAmount'));
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartTopOrdersByAmount failed: ' + e.getMessage());
return null;
}
return d;
}
private static DashboardData chartConvertedOrders(String storeClause, String convFieldName, String orderTypeFilter) {
DashboardData d = new DashboardData();
d.title = 'Converted vs Non-Converted';
d.defaultChart = 'donut';
d.colors = new List<String>{'#2f9e44','#94a3b8'};
d.labels = new List<String>{'Converted', 'Non-Converted'};
String baseWhere = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ storeClause
+ (String.isNotBlank(orderTypeFilter) ? ' AND ' + orderTypeFilter : '');
try {
Integer converted = Database.countQuery(
'SELECT COUNT() FROM Order' + baseWhere + ' AND ' + convFieldName + ' != null'
);
Integer nonConverted = Database.countQuery(
'SELECT COUNT() FROM Order' + baseWhere + ' AND ' + convFieldName + ' = null'
);
d.values = new List<Decimal>{converted, nonConverted};
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartConvertedOrders failed: ' + e.getMessage());
return null;
}
return d;
}
private static DashboardData chartTopAccountsByRevenue(String storeClause, String accountTypeFilter) {
DashboardData d = new DashboardData();
d.title = 'Top 5 Accounts by Revenue';
d.defaultChart = 'bar';
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
d.labels = new List<String>();
d.values = new List<Decimal>();
String soql = 'SELECT Account.Name accName, SUM(TotalAmount) total'
+ ' FROM Order'
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ storeClause
+ ' AND AccountId != null AND TotalAmount != null'
+ (String.isNotBlank(accountTypeFilter) ? accountTypeFilter : '')
+ ' GROUP BY Account.Name'
+ ' ORDER BY SUM(TotalAmount) DESC'
+ ' LIMIT 5';
try {
for (AggregateResult ar : Database.query(soql)) {
d.labels.add((String) ar.get('accName'));
d.values.add((Decimal) ar.get('total'));
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartTopAccountsByRevenue failed: ' + e.getMessage());
return null;
}
return d;
}
// scopeClause arrives pre-prefixed for the junction (Collection__r.Store +
// junction CreatedDate) — built by the caller, no string surgery here.
private static DashboardData chartProductsPerCollection(String scopeClause) {
DashboardData d = new DashboardData();
d.title = 'Top 5 Collections by Product Count';
d.defaultChart = 'bar';
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
d.labels = new List<String>();
d.values = new List<Decimal>();
// Collect__c is the junction between Collection and Product
String soql = 'SELECT HIC_ShopifySync__Collection__r.Name collName, COUNT(Id) cnt'
+ ' FROM HIC_ShopifySync__Collect__c'
+ ' WHERE HIC_ShopifySync__Collection__c != null'
+ ' AND HIC_ShopifySync__Collection__r.HIC_ShopifySync__Shopify_Id__c != null'
+ ' AND HIC_ShopifySync__Collection__r.HIC_ShopifySync__Store__c != null'
+ scopeClause
+ ' GROUP BY HIC_ShopifySync__Collection__r.Name'
+ ' ORDER BY COUNT(Id) DESC'
+ ' LIMIT 5';
try {
for (AggregateResult ar : Database.query(soql)) {
String name = (String) ar.get('collName');
d.labels.add(name != null ? name.left(20) : 'Unknown');
d.values.add((Decimal) ar.get('cnt'));
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartProductsPerCollection failed: ' + e.getMessage());
return null;
}
return d;
}
private static DashboardData chartInventoryByVariant(String storeClause) {
DashboardData d = new DashboardData();
d.title = 'Top 5 Inventory Items by Quantity';
d.defaultChart = 'bar';
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
d.labels = new List<String>();
d.values = new List<Decimal>();
// Primary path: top inventory items by Available quantity (only when populated).
String soql = 'SELECT Name, HIC_ShopifySync__Available__c'
+ ' FROM HIC_ShopifySync__Inventory__c'
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ storeClause
+ ' AND HIC_ShopifySync__Available__c != null'
+ ' AND HIC_ShopifySync__Available__c > 0'
+ ' ORDER BY HIC_ShopifySync__Available__c DESC'
+ ' LIMIT 5';
try {
List<SObject> rows = Database.query(soql);
if (!rows.isEmpty()) {
for (SObject r : rows) {
String name = (String) r.get('Name');
d.labels.add(name != null ? name.left(20) : 'Unknown');
d.values.add((Decimal) r.get('HIC_ShopifySync__Available__c'));
}
return d;
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartInventoryByVariant qty path failed: ' + e.getMessage());
}
// Fallback: donut of Tracked vs Not Tracked (Tracked field is always populated)
try {
d.title = 'Inventory by Tracked Status';
d.defaultChart = 'donut';
d.colors = new List<String>{'#2f9e44','#94a3b8'};
String baseWhere = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + storeClause;
Integer tracked = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Inventory__c' + baseWhere + ' AND HIC_ShopifySync__Tracked__c = true');
Integer untracked = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Inventory__c' + baseWhere + ' AND (HIC_ShopifySync__Tracked__c = false OR HIC_ShopifySync__Tracked__c = null)');
d.labels = new List<String>{'Tracked','Not Tracked'};
d.values = new List<Decimal>{tracked, untracked};
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] chartInventoryByVariant tracked fallback failed: ' + e.getMessage());
return null;
}
return d;
}
// prefixedScope = store/date clauses on Location__r path (for the junction
// query); plainScope = same clauses on the Location object itself (fallback).
private static DashboardData chartProductsPerLocation(String prefixedScope, String plainScope) {
DashboardData d = new DashboardData();
d.title = 'Inventory Items per Location';
d.defaultChart = 'bar';
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
d.labels = new List<String>();
d.values = new List<Decimal>();
// Try Inventory_Level__c which links inventory items to locations
try {
String soql = 'SELECT HIC_ShopifySync__Location__r.Name locName, COUNT(Id) cnt'
+ ' FROM HIC_ShopifySync__Inventory_Level__c'
+ ' WHERE HIC_ShopifySync__Location__c != null'
+ ' AND HIC_ShopifySync__Location__r.HIC_ShopifySync__Shopify_Id__c != null'
+ ' AND HIC_ShopifySync__Location__r.HIC_ShopifySync__Store__c != null'
+ prefixedScope
+ ' GROUP BY HIC_ShopifySync__Location__r.Name'
+ ' ORDER BY COUNT(Id) DESC'
+ ' LIMIT 5';
for (AggregateResult ar : Database.query(soql)) {
String name = (String) ar.get('locName');
d.labels.add(name != null ? name : 'Unknown');
d.values.add((Decimal) ar.get('cnt'));
}
} catch (Exception e) {
// Inventory_Level__c may not exist — fallback to showing Location names
System.debug('[ShopifySyncDashboardController] chartProductsPerLocation fallback: ' + e.getMessage());
try {
String soql2 = 'SELECT Name FROM HIC_ShopifySync__Location__c'
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
+ plainScope
+ ' ORDER BY Name ASC LIMIT 5';
for (SObject r : Database.query(soql2)) {
d.labels.add((String) r.get('Name'));
d.values.add(1);
}
} catch (Exception e2) {
System.debug('[ShopifySyncDashboardController] chartProductsPerLocation failed: ' + e2.getMessage());
return null;
}
}
return d;
}
/**
* Converts a UI date-range label into a SOQL WHERE fragment on CreatedDate.
* Returns empty string for 'All Time' or unrecognised values (= no filter).
*/
private static String dateRangeClause(String dateRange) {
return dateRangeClauseFor(dateRange, 'CreatedDate');
}
/**
* Field-path-aware variant — lets charts on related objects scope by the
* correct path (e.g. 'Order.CreatedDate' on OrderItem queries).
*/
private static String dateRangeClauseFor(String dateRange, String fieldPath) {
if (String.isBlank(dateRange) || dateRange == 'All Time') return '';
if (dateRange == 'Today') return ' AND ' + fieldPath + ' = TODAY';
if (dateRange == 'Yesterday') return ' AND ' + fieldPath + ' = YESTERDAY';
if (dateRange == 'Last 7 days') return ' AND ' + fieldPath + ' = LAST_N_DAYS:7';
if (dateRange == 'Last 30 days') return ' AND ' + fieldPath + ' = LAST_N_DAYS:30';
if (dateRange == 'This Month') return ' AND ' + fieldPath + ' = THIS_MONTH';
if (dateRange == 'Last Month') return ' AND ' + fieldPath + ' = LAST_MONTH';
if (dateRange == 'This Quarter') return ' AND ' + fieldPath + ' = THIS_QUARTER';
if (dateRange == 'This Year') return ' AND ' + fieldPath + ' = THIS_YEAR';
return '';
}
/** Store filter on an arbitrary field path; empty when no store selected. */
private static String storeClauseFor(String storeName, String fieldPath) {
if (String.isBlank(storeName)) return '';
return ' AND ' + fieldPath + ' = \'' + String.escapeSingleQuotes(storeName) + '\'';
}
private static String monthAbbrev(Integer m) {
List<String> names = new List<String>{'Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'};
return (m >= 1 && m <= 12) ? names[m - 1] : String.valueOf(m);
}
/* =================== Internals =================== */
private static String lookupAccessToken(String storeName) {
try {
List<HIC_ShopifySync__HIC_Configuration__c> configs = [
SELECT Id, HIC_ShopifySync__Private_Key_Password__c
FROM HIC_ShopifySync__HIC_Configuration__c
WHERE HIC_ShopifySync__Store__c = :storeName
LIMIT 1
];
if (!configs.isEmpty()) {
return configs[0].HIC_ShopifySync__Private_Key_Password__c;
}
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] config lookup failed: ' + e.getMessage());
}
return null;
}
private static Integer fetchShopifyCount(String url, String token) {
HttpRequest req = new HttpRequest();
req.setEndpoint(url);
req.setMethod('GET');
req.setHeader('X-Shopify-Access-Token', token);
req.setHeader('Content-Type', 'application/json');
req.setTimeout(30000);
Http http = new Http();
HttpResponse res = http.send(req);
Integer status = res.getStatusCode();
if (status < 200 || status >= 300) {
throw new ShopifyCalloutException(
'HTTP ' + status + ' from ' + url + ' :: ' + res.getBody());
}
Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
Object countVal = body.get('count');
if (countVal == null) return 0;
if (countVal instanceof Integer) return (Integer) countVal;
if (countVal instanceof Long) return ((Long) countVal).intValue();
if (countVal instanceof Decimal) return ((Decimal) countVal).intValue();
return Integer.valueOf(String.valueOf(countVal));
}
private static Integer safeGet(Map<String, Integer> m, String k) {
Integer v = m.get(k);
return v == null ? 0 : v;
}
private static Map<String, Integer> initZeroCounts() {
return new Map<String, Integer>{
'product' => 0, 'variant' => 0, 'lead' => 0,
'pacc' => 0, 'account' => 0, 'contact' => 0,
'opp' => 0, 'draftOrder' => 0, 'paidOrder' => 0,
'collection' => 0, 'inventory' => 0, 'location' => 0
};
}
/**
* True when Person Accounts are enabled in the org — i.e. the
* IsPersonAccount column exists on Account. Querying it in orgs where the
* feature is OFF throws "No such column 'IsPersonAccount'".
*/
@TestVisible
private static Boolean personAccountsEnabled() {
if (paCacheSet) return cachedPaEnabled;
paCacheSet = true;
cachedPaEnabled = false;
try {
// Check 1: Field exists in schema map
Map<String, Schema.SObjectField> fieldMap = Schema.SObjectType.Account.fields.getMap();
if (!fieldMap.containsKey('ispersonaccount')) {
return false;
}
// Check 2: Field is actually accessible and usable in WHERE clauses
Schema.DescribeFieldResult dfr = fieldMap.get('ispersonaccount').getDescribe();
if (!dfr.isAccessible() || !dfr.isFilterable()) {
return false;
}
// Check 3: Defensive — actually try to query the field
// LIMIT 0 = no rows returned, minimal cost, but query must compile + execute
Database.countQuery('SELECT COUNT() FROM Account WHERE IsPersonAccount = false LIMIT 0');
cachedPaEnabled = true;
} catch (Exception e) {
// Any exception = field not truly usable (No such column, Invalid field, etc.)
System.debug('[ShopifySyncDashboardController] Person Accounts verification failed: ' + e.getMessage());
cachedPaEnabled = false;
}
return cachedPaEnabled;
}
private static Map<String, ObjectDef> objectDefs() {
Boolean paOn = personAccountsEnabled();
return new Map<String, ObjectDef>{
'product' => new ObjectDef('Product2', null),
'variant' => new ObjectDef('HIC_ShopifySync__Variant__c', null),
'lead' => new ObjectDef('Lead', null),
'pacc' => new ObjectDef('Account', paOn ? 'IsPersonAccount = true' : 'Id = null'),
'account' => new ObjectDef('Account', paOn ? 'IsPersonAccount = false' : null),
'contact' => new ObjectDef('Contact', null),
'paidOpp' => new ObjectDef('Opportunity', 'HIC_ShopifySync__Payment_Status__c != null'), // Paid Order Opportunities
'draftOpp' => new ObjectDef('Opportunity', 'HIC_ShopifySync__Payment_Status__c = null'), // Draft Order Opportunities
'draftOrder' => new ObjectDef('Order', 'HIC_ShopifySync__Payment_Status__c = null'),
'paidOrder' => new ObjectDef('Order', 'HIC_ShopifySync__Payment_Status__c != null'),
'collection' => new ObjectDef('HIC_ShopifySync__Collection__c', null),
'inventory' => new ObjectDef('HIC_ShopifySync__Inventory__c', null),
'location' => new ObjectDef('HIC_ShopifySync__Location__c', null)
};
}
/* =================== DTO + helpers =================== */
public class Store {
@AuraEnabled public String id;
@AuraEnabled public String name;
// General toggles
@AuraEnabled public Boolean productSync;
@AuraEnabled public Boolean accountContact;
@AuraEnabled public Boolean enableLead;
@AuraEnabled public Boolean personAccount;
@AuraEnabled public Boolean collectionSync;
@AuraEnabled public Boolean inventoryLocation;
// Paid Order toggles
@AuraEnabled public Boolean orderSync; // Parent: HIC_ShopifySync__Order__c
@AuraEnabled public Boolean orderToggle; // Child: HIC_ShopifySync__Order_Sync__c
@AuraEnabled public Boolean opportunity; // Child: HIC_ShopifySync__Opportunity__c
// Draft Order toggles
@AuraEnabled public Boolean draftOrderSync; // Parent: HIC_ShopifySync__Draft_Order_Sync__c
@AuraEnabled public Boolean draftOrderToggle; // Child: HIC_ShopifySync__Enable_Draft_Order__c
@AuraEnabled public Boolean draftOpportunity; // Child: HIC_ShopifySync__Enable_Opportunity_for_SDO__c
}
private class ObjectDef {
public String sobjectName;
public String extraFilter;
public ObjectDef(String s, String f) { this.sobjectName = s; this.extraFilter = f; }
}
public class FieldInfo implements Comparable {
@AuraEnabled public String apiName;
@AuraEnabled public String label;
@AuraEnabled public String type;
@AuraEnabled public Boolean sortable;
@AuraEnabled public Boolean isDefault; // true if in defaultFieldsFor() list
@AuraEnabled public Integer defaultOrder; // position in defaultFieldsFor(), null otherwise
/**
* Sort order for the field selector menu AND default visible columns:
* 1) Default fields first, in the order they appear in defaultFieldsFor()
* 2) Non-default fields after, alphabetically by label
* Apex doesn't have Integer.MAX_VALUE — we handle null defaultOrder
* with explicit checks so the comparator stays primitive-safe.
*/
public Integer compareTo(Object o) {
FieldInfo other = (FieldInfo) o;
Boolean a = (this.isDefault == true);
Boolean b = (other.isDefault == true);
if (a != b) return a ? -1 : 1;
if (a && b) {
// Both flagged as default — order by defaultOrder.
// null defaultOrder sorts AFTER any concrete value (treated as last).
if (this.defaultOrder == null && other.defaultOrder == null) return 0;
if (this.defaultOrder == null) return 1;
if (other.defaultOrder == null) return -1;
return this.defaultOrder - other.defaultOrder;
}
// Both non-default: alphabetical by label
if (this.label == null) return -1;
if (other.label == null) return 1;
return this.label.compareTo(other.label);
}
}
public class DashboardData {
@AuraEnabled public String title;
@AuraEnabled public String defaultChart;
@AuraEnabled public List<String> labels;
@AuraEnabled public List<Decimal> values;
@AuraEnabled public List<String> colors;
}
public class KpiData {
@AuraEnabled public String label; // e.g. 'TOTAL PRODUCTS'
@AuraEnabled public String value; // pre-formatted e.g. '1,234' or '$12.3K'
@AuraEnabled public String sub; // sub-label e.g. 'synced'
@AuraEnabled public String accent; // CSS class e.g. 'k-blue'
@AuraEnabled public String delta; // e.g. '▲ 12.3%' or null
@AuraEnabled public String deltaDir; // 'up', 'down', or 'neutral'
}
/**
* Share → More → Copy Link backend.
* Saves the client-generated report file (CSV/XLS/HTML) as a ContentVersion,
* wraps it in a ContentDistribution, and returns the PUBLIC download URL.
* Anyone opening that URL downloads the file directly — no SF login needed.
*
* @param fileName e.g. "shopify-sync_product_20260610_1430.csv"
* @param base64Data base64-encoded file content
* @return ContentDownloadUrl (direct download)
*/
@AuraEnabled
public static String createPublicFileLink(String fileName, String base64Data) {
if (String.isBlank(fileName) || String.isBlank(base64Data)) {
AuraHandledException bad = new AuraHandledException('fileName and base64Data are required.');
bad.setMessage('fileName and base64Data are required.');
throw bad;
}
try {
ContentVersion cv = new ContentVersion(
Title = fileName,
PathOnClient = fileName,
VersionData = EncodingUtil.base64Decode(base64Data),
IsMajorVersion = true
);
insert cv;
ContentDistribution cd = new ContentDistribution(
Name = fileName,
ContentVersionId = cv.Id,
PreferencesAllowOriginalDownload = true,
PreferencesAllowPDFDownload = true,
PreferencesAllowViewInBrowser = false, // force download, not preview
PreferencesNotifyOnVisit = false,
PreferencesLinkLatestVersion = true
);
insert cd;
cd = [SELECT ContentDownloadUrl, DistributionPublicUrl
FROM ContentDistribution WHERE Id = :cd.Id LIMIT 1];
return String.isNotBlank(cd.ContentDownloadUrl)
? cd.ContentDownloadUrl
: cd.DistributionPublicUrl;
} catch (Exception e) {
AuraHandledException ex = new AuraHandledException(e.getMessage());
ex.setMessage('Could not create public download link: ' + e.getMessage());
throw ex;
}
}
public class ShopifyCalloutException extends Exception {}
/* =================== Pure Apex PDF Generation (no VF page) =================== */
@AuraEnabled
public static String generatePdfBase64(List<String> headers, List<List<String>> rows, String title) {
if (headers == null || headers.isEmpty()) {
throw new AuraHandledException('Headers are required.');
}
try {
// ---- Page geometry ----
Integer margin = 36;
Integer pageWidth = 842; // A4 landscape
Integer pageHeight = 612;
Integer contentWidth = pageWidth - (margin * 2);
Integer colCount = headers.size();
// Give every column an equal share of the content width.
// Integer division is fine — any leftover pixel lands in whitespace.
Integer colWidth = contentWidth / colCount;
String reportTitle = String.isNotBlank(title) ? title : 'Shopify Sync Report';
Integer totalRows = rows != null ? rows.size() : 0;
// ---- Brand colours ----
// #95BE45 → R=149 G=190 B=69 → PDF: 0.584 0.745 0.271
// Already used as accent; now ALSO the header band colour.
String GREEN_RG = '0.584 0.745 0.271'; // #95BE45
String NAVY_RG = '0.122 0.161 0.216'; // #1F2937 dark navy
String WHITE_RG = '1 1 1';
String LIGHT_RG = '0.961 0.969 0.976'; // alternating row tint
String RULE_RG = '0.88 0.89 0.91'; // thin rule / footer line
String GRAY_RG = '0.45 0.45 0.45'; // subtitle text
List<String> ops = new List<String>();
// ---- Top accent strip (6px green bar across full width) ----
ops.add(GREEN_RG + ' rg');
ops.add('0 ' + (pageHeight - 6) + ' ' + pageWidth + ' 6 re f');
// ---- Title line ----
Integer titleY = pageHeight - margin - 36;
ops.add('BT /F1 18 Tf ' + NAVY_RG + ' rg ' + margin + ' ' + titleY + ' Td (' + escapePdfText(reportTitle) + ') Tj ET');
// ---- Record-count badge (right-aligned on title line) ----
String badgeText = String.valueOf(totalRows) + (totalRows == 1 ? ' record' : ' records');
// ~6px per char + 20px padding each side → badge always fits its text
Integer badgeW = badgeText.length() * 6 + 40;
Integer badgeX = pageWidth - margin - badgeW;
Integer badgeY = titleY - 4;
// Light green fill
ops.add('0.906 0.953 0.835 rg');
ops.add(badgeX + ' ' + badgeY + ' ' + badgeW + ' 18 re f');
// Dark green text centred in badge
ops.add('BT /F2 8 Tf 0.18 0.42 0.1 rg '
+ (badgeX + 10) + ' ' + (badgeY + 4) + ' Td ('
+ escapePdfText(badgeText) + ') Tj ET');
// ---- Generated-at subtitle ----
Integer subY = titleY - 18;
ops.add('BT /F2 8 Tf ' + GRAY_RG + ' rg ' + margin + ' ' + subY + ' Td (Generated: '
+ escapePdfText(Datetime.now().formatGmt('yyyy-MM-dd HH:mm:ss')) + ' UTC) Tj ET');
// ---- Thin navy rule above header band ----
Integer ruleY = subY - 10;
ops.add(NAVY_RG + ' rg');
ops.add(margin + ' ' + ruleY + ' ' + contentWidth + ' 1 re f');
// ---- Header band: #95BE45 background, white bold text ----
Integer headerBandH = 20;
Integer headerBandY = ruleY - headerBandH - 2;
ops.add(GREEN_RG + ' rg');
ops.add(margin + ' ' + headerBandY + ' ' + contentWidth + ' ' + headerBandH + ' re f');
// Column header labels — centred vertically inside the band
Integer headerTextY = headerBandY + 6; // baseline sits 6px above band bottom
Integer cellX = margin;
for (Integer i = 0; i < colCount; i++) {
String h = headers[i] != null ? headers[i] : '';
// Truncate if too wide for the column (≈ 5px per char at font size 7)
Integer maxChars = Math.max(5, colWidth / 5);
if (h.length() > maxChars) h = h.substring(0, maxChars - 1) + '.';
ops.add('BT /F1 7 Tf ' + WHITE_RG + ' rg '
+ (cellX + 4) + ' ' + headerTextY + ' Td ('
+ escapePdfText(h) + ') Tj ET');
cellX += colWidth;
}
// ---- Thin navy rule immediately below header band ----
Integer postHeaderY = headerBandY - 1;
ops.add(NAVY_RG + ' rg');
ops.add(margin + ' ' + postHeaderY + ' ' + contentWidth + ' 1 re f');
// ---- Data rows ----
Integer rowH = 14;
Integer rowY = postHeaderY - rowH; // top of first data row
Integer rowNum = 0;
for (List<String> row : rows) {
// Stop before we'd overwrite the footer area (30px reserve)
if (rowY < margin + 30) break;
// Alternating row background
if (Math.mod(rowNum, 2) == 0) {
ops.add(LIGHT_RG + ' rg');
ops.add(margin + ' ' + rowY + ' ' + contentWidth + ' ' + rowH + ' re f');
}
// Thin bottom rule for every row
ops.add(RULE_RG + ' rg');
ops.add(margin + ' ' + rowY + ' ' + contentWidth + ' 0.5 re f');
// Cell text — baseline 3px above row bottom
Integer textY = rowY + 3;
Integer cellXR = margin;
for (Integer i = 0; i < colCount; i++) {
String cell = (i < row.size() && row[i] != null) ? row[i] : '';
Integer maxC = Math.max(5, colWidth / 5);
if (cell.length() > maxC) cell = cell.substring(0, maxC - 2) + '..';
ops.add('BT /F2 7 Tf ' + NAVY_RG + ' rg '
+ (cellXR + 4) + ' ' + textY + ' Td ('
+ escapePdfText(cell) + ') Tj ET');
cellXR += colWidth;
}
rowY -= rowH;
rowNum++;
}
// ---- Bottom border closing the table ----
ops.add(NAVY_RG + ' rg');
ops.add(margin + ' ' + (rowY + rowH) + ' ' + contentWidth + ' 1 re f');
// ---- Footer ----
ops.add(RULE_RG + ' rg');
ops.add(margin + ' 28 ' + contentWidth + ' 0.5 re f');
ops.add('BT /F2 7 Tf ' + GRAY_RG + ' rg ' + margin + ' 16 Td (Sync Made Easy) Tj ET');
String footerRight = 'Page 1';
// ~5px per char for Helvetica at size 7
Integer frX = pageWidth - margin - footerRight.length() * 5;
ops.add('BT /F2 7 Tf ' + GRAY_RG + ' rg ' + frX + ' 16 Td (' + escapePdfText(footerRight) + ') Tj ET');
// ---- Assemble PDF ----
String contentStream = String.join(ops, '\n') + '\n';
// Object 3 = page — font resource dictionary references F1 (Bold) and F2 (Regular)
List<String> objects = new List<String>{
'1 0 obj\n<</Type/Catalog/Pages 2 0 R>>\nendobj\n',
'2 0 obj\n<</Type/Pages/Kids[3 0 R]/Count 1>>\nendobj\n',
'3 0 obj\n<</Type/Page/Parent 2 0 R/MediaBox[0 0 842 612]/Contents 4 0 R'
+ '/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica-Bold>>'
+ '/F2<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>/Rotate 0>>\nendobj\n',
'4 0 obj\n<</Length ' + contentStream.length() + '>>\nstream\n'
+ contentStream + 'endstream\nendobj\n'
};
String pdfString = buildPdfWithXref(objects, 1);
Blob pdfBlob = Blob.valueOf(pdfString);
return EncodingUtil.base64Encode(pdfBlob);
} catch (Exception e) {
System.debug('[ShopifySyncDashboardController] PDF generation failed: ' + e.getMessage());
throw new AuraHandledException('PDF generation failed: ' + e.getMessage());
}
}
/**
* Assembles object strings (passed in ascending object-number order,
* numbered 1..N contiguously) into a spec-valid PDF: header + objects +
* xref table + trailer, with correct byte offsets. This xref table is what
* was missing before — without it, strict readers (Preview, Acrobat, most
* mobile viewers, many download managers) can't locate Root and refuse to
* open the file, even though lenient browser viewers may still render it.
*/
private static String buildPdfWithXref(List<String> objects, Integer rootObjNum) {
String header = '%PDF-1.4\n';
List<Integer> offsets = new List<Integer>();
Integer running = header.length();
for (String obj : objects) {
offsets.add(running);
running += obj.length();
}
Integer xrefOffset = running;
Integer objCount = objects.size() + 1; // +1 for the mandatory free object 0
String body = header + String.join(objects, '');
// Each entry must be EXACTLY 20 bytes: 10-digit offset + space + 5-digit
// generation + space + n/f + CRLF. Wrong width = rejected by strict readers.
List<String> xrefEntries = new List<String>();
xrefEntries.add(pad10(0) + ' 65535 f\r\n');
for (Integer off : offsets) {
xrefEntries.add(pad10(off) + ' 00000 n\r\n');
}
String xrefSection = 'xref\n0 ' + objCount + '\n' + String.join(xrefEntries, '');
String trailer = 'trailer\n<</Size ' + objCount + '/Root ' + rootObjNum + ' 0 R>>\nstartxref\n' + xrefOffset + '\n%%EOF';
return body + xrefSection + trailer;
}
private static String pad10(Integer n) {
String s = String.valueOf(n);
while (s.length() < 10) s = '0' + s;
return s;
}
// === TOP-LEVEL STATIC HELPER — accessible from both generatePdfBase64 and PdfBuilder ===
private static String escapePdfText(String s) {
if (s == null) return '';
String result = '';
for (Integer i = 0; i < s.length(); i++) {
String c = s.substring(i, i + 1);
Integer code = c.charAt(0);
if (c == '\\') result += '\\\\';
else if (c == '(') result += '\\(';
else if (c == ')') result += '\\)';
else if (code > 126 || code < 32) result += ' ';
else result += c;
}
return result;
}
public class PdfBuilder {
private List<String> objects;
private Integer objCounter;
private Integer pageCount;
private List<String> pageRefs;
private String currentContent;
private Integer currentPageObj;
private Boolean hasContent;
private final String FONT_HELVETICA = '/Helvetica';
private final String FONT_HELVETICA_BOLD = '/Helvetica-Bold';
public PdfBuilder() {
this.objects = new List<String>();
this.objCounter = 0;
this.pageCount = 0;
this.pageRefs = new List<String>();
this.currentContent = '';
this.currentPageObj = 0;
this.hasContent = false;
this.newPage();
}
public void newPage() {
if (this.hasContent) {
this.finishPage();
}
this.pageCount++;
this.currentContent = '';
this.hasContent = false;
this.currentPageObj = ++this.objCounter;
}
public void drawText(String text, Integer x, Integer y, Integer fontSize, Boolean bold) {
drawText(text, x, y, fontSize, bold, '1F2937');
}
public void drawText(String text, Integer x, Integer y, Integer fontSize, Boolean bold, String color) {
String font = bold ? FONT_HELVETICA_BOLD : FONT_HELVETICA;
String rgb = hexToRgb(color);
this.currentContent += 'BT ' + font + ' ' + fontSize + ' Tf ' + rgb + ' ' + x + ' ' + y + ' Td (' + ShopifySyncDashboardController.escapePdfText(text) + ') Tj ET\n';
this.hasContent = true;
}
public void drawLine(Integer x1, Integer y1, Integer x2, Integer y2, String color, Decimal width) {
String rgb = hexToRgb(color);
this.currentContent += rgb + ' ' + width + ' w ' + x1 + ' ' + y1 + ' m ' + x2 + ' ' + y2 + ' l S\n';
this.hasContent = true;
}
public void fillRect(Integer x, Integer y, Integer w, Integer h, String color) {
String rgb = hexToRgb(color);
this.currentContent += rgb + ' ' + x + ' ' + y + ' ' + w + ' ' + h + ' re f\n';
this.hasContent = true;
}
public Blob toBlob() {
if (this.hasContent) {
this.finishPage();
}
String header = '%PDF-1.4\n';
Integer catalogObj = ++this.objCounter;
String catalogStr = catalogObj + ' 0 obj <</Type/Catalog/Pages 2 0 R>> endobj\n';
Integer pagesObj = ++this.objCounter;
String kids = String.join(this.pageRefs, ' ');
String pagesStr = pagesObj + ' 0 obj <</Type/Pages/Kids[' + kids + ']/Count ' + this.pageCount + '>> endobj\n';
// this.objects holds content/page objects interleaved out of numeric
// order (e.g. 2,1,4,3...), so offsets are tracked by NUMBER, not by
// array position, then re-sorted ascending when the xref table is built.
Map<Integer, Integer> offsetsByNum = new Map<Integer, Integer>();
Integer running = header.length();
offsetsByNum.put(catalogObj, running);
running += catalogStr.length();
offsetsByNum.put(pagesObj, running);
running += pagesStr.length();
String bodyRest = '';
for (String obj : this.objects) {
Integer num = extractObjNum(obj);
offsetsByNum.put(num, running);
running += obj.length();
bodyRest += obj;
}
Integer xrefOffset = running;
Integer objCount = this.objCounter + 1; // +1 for free object 0
List<String> xrefEntries = new List<String>();
// NOTE: calls the OUTER class's pad10 — inner classes in Apex cannot
// declare their own `static` members, so there is only ever one pad10.
xrefEntries.add(ShopifySyncDashboardController.pad10(0) + ' 65535 f\r\n');
for (Integer n = 1; n <= this.objCounter; n++) {
Integer off = offsetsByNum.containsKey(n) ? offsetsByNum.get(n) : 0;
xrefEntries.add(ShopifySyncDashboardController.pad10(off) + ' 00000 n\r\n');
}
String xrefSection = 'xref\n0 ' + objCount + '\n' + String.join(xrefEntries, '');
String trailer = 'trailer\n<</Size ' + objCount + '/Root ' + catalogObj + ' 0 R>>\nstartxref\n' + xrefOffset + '\n%%EOF';
String full = header + catalogStr + pagesStr + bodyRest + xrefSection + trailer;
return Blob.valueOf(full);
}
private Integer extractObjNum(String objStr) {
Integer spaceIdx = objStr.indexOf(' ');
if (spaceIdx <= 0) return 0;
return Integer.valueOf(objStr.substring(0, spaceIdx));
}
private void finishPage() {
Integer contentObj = ++this.objCounter;
String stream = this.currentContent;
String obj = contentObj + ' 0 obj <</Length ' + stream.length() + '>> stream\n' + stream + 'endstream endobj\n';
this.objects.add(obj);
Integer pageObj = this.currentPageObj;
String page = pageObj + ' 0 obj <</Type/Page/Parent 2 0 R/MediaBox[0 0 842 612]/Contents ' + contentObj + ' 0 R/Resources<</Font<</Helvetica<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>/Helvetica-Bold<</Type/Font/Subtype/Type1/BaseFont/Helvetica-Bold>>>>>>/Rotate 0>> endobj\n';
this.objects.add(page);
this.pageRefs.add(pageObj + ' 0 R');
}
private String hexToRgb(String hex) {
if (String.isBlank(hex)) return '0 0 0';
String h = hex;
if (h.startsWith('#')) h = h.substring(1);
if (h.length() == 3) {
h = h.substring(0,1) + h.substring(0,1) + h.substring(1,2) + h.substring(1,2) + h.substring(2,3) + h.substring(2,3);
}
if (h.length() != 6) return '0 0 0';
Integer r = hexToDec(h.substring(0,2));
Integer g = hexToDec(h.substring(2,4));
Integer b = hexToDec(h.substring(4,6));
Decimal rd = Decimal.valueOf(r) / 255.0;
Decimal gd = Decimal.valueOf(g) / 255.0;
Decimal bd = Decimal.valueOf(b) / 255.0;
return rd.setScale(3) + ' ' + gd.setScale(3) + ' ' + bd.setScale(3);
}
private Integer hexToDec(String hex) {
String chars = '0123456789ABCDEF';
Integer result = 0;
hex = hex.toUpperCase();
for (Integer i = 0; i < hex.length(); i++) {
String c = hex.substring(i, i + 1);
Integer val = chars.indexOf(c);
if (val < 0) return 0;
result = result * 16 + val;
}
return result;
}
// pad10 intentionally NOT redeclared here — Apex forbids `static`
// members on inner classes. We call ShopifySyncDashboardController.pad10
// above instead.
}
public class StringBuilder {
private List<String> parts;
public StringBuilder() { this.parts = new List<String>(); }
public void add(String s) { this.parts.add(s); }
public override String toString() { return String.join(parts, ''); }
}
}