/** * 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//count.json for products, variants, customers, * orders, draft_orders, locations. NOT cacheable (Apex rules * forbid HTTP callouts on cacheable @AuraEnabled methods). * * Heads-up: each .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 getStores() { List stores = new List(); 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 getObjectCounts(String storeName, String dateRange) { Map counts = initZeroCounts(); Map 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 getShopifyCounts(String storeName) { Map 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 endpoints = new Map{ '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 ep = new Map(); 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 getObjectFields(String objectKey) { List out = new List(); Map 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 fmap = dsr.fields.getMap(); // System fields to hide Set hidden = new Set{ '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 defaultFields = defaultFieldsFor(objectKey); Map defaultPos = new Map(); 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> getObjectRecords( String objectKey, String storeName, List fields, String dateRange ) { List> out = new List>(); if (String.isBlank(objectKey)) return out; Map 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 fmap = sot.getDescribe().fields.getMap(); // Build safe field set — skip fields that don't exist or aren't accessible List safeFields = new List(); Map lookupRelMap = new Map(); Map lookupTargetMap = new Map(); 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 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 selectFields = new List(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 row = new Map(); 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 personOnlyFields = new Set{ '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 ensureStoreFieldAfterShopifyId(List fields) { if (fields == null) return new List(); // 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 result = new List(); 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 shape as getObjectRecords rows, including: * __recordId__, __sobjectApi__, each field value, plus __name + * __sobject for any lookup fields the target object has. */ @AuraEnabled(cacheable=true) public static Map getObjectRecordById(String objectKey, String recordId) { Map empty = new Map(); if (String.isBlank(objectKey) || String.isBlank(recordId)) return empty; Map 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 fmap = sot.getDescribe().fields.getMap(); // Use target object's default field list (same logic as getObjectRecords) List fields = defaultFieldsFor(objectKey); List safeFields = new List(); Map lookupRelMap = new Map(); Map lookupTargetMap = new Map(); 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 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 selectFields = new List(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 rs = Database.query(soql); if (rs.isEmpty()) return empty; SObject r = rs[0]; Map row = new Map(); 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 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> defaults = new Map>{ 'product' => new List{ '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{ '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{ '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{ '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{ '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{ '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{ '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{ '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{ '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{ '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{ '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{ '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{'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 getLastOrderPerStore() { Map result = new Map(); 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 getDashboardChartData(String objectKey, String storeName, String dateRange) { Map result = new Map(); 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 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(); } catch (Exception e) { System.debug('[ShopifySyncDashboardController] getDashboardKpis failed for ' + objectKey + ': ' + e.getMessage()); return new List(); } } // ---------- 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 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 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 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 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 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 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 r = new List(); 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 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 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 r = new List(); 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 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 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 r = new List(); 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 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 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 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 r = new List(); 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 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 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 r = new List(); 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 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 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 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 r = new List(); 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 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 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 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 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 r = new List(); 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 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 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 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 r = new List(); 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 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 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 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 r = new List(); 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 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 r = new List(); 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 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{'#2f9e44','#1f6feb','#f59e0b'}; Date today = Date.today(); List monthKeys = new List(); d.labels = new List(); d.values = new List{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{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2','#16a34a'}; d.labels = new List(); d.values = new List(); 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{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2','#16a34a'}; d.labels = new List(); d.values = new List(); // 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 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{'#1f6feb','#f59e0b','#2f9e44','#94a3b8','#7c3aed','#0891b2'}; d.labels = new List(); d.values = new List(); 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{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2','#16a34a'}; d.labels = new List(); d.values = new List(); 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{'#1f6feb','#2f9e44','#f59e0b','#7c3aed','#0891b2','#16a34a'}; d.labels = new List(); d.values = new List(); 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{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'}; d.labels = new List(); d.values = new List(); 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{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'}; d.labels = new List(); d.values = new List(); 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{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'}; d.labels = new List(); d.values = new List(); 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{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'}; d.labels = new List(); d.values = new List(); 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{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'}; d.labels = new List(); d.values = new List(); 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{'#2f9e44','#f59e0b'}; d.labels = new List{'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{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{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'}; d.labels = new List(); d.values = new List(); 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{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'}; d.labels = new List(); d.values = new List(); 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{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'}; d.labels = new List(); d.values = new List(); 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{'#2f9e44','#94a3b8'}; d.labels = new List{'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{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{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'}; d.labels = new List(); d.values = new List(); 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{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'}; d.labels = new List(); d.values = new List(); // 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{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'}; d.labels = new List(); d.values = new List(); // 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 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{'#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{'Tracked','Not Tracked'}; d.values = new List{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{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'}; d.labels = new List(); d.values = new List(); // 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 names = new List{'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 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 body = (Map) 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 m, String k) { Integer v = m.get(k); return v == null ? 0 : v; } private static Map initZeroCounts() { return new Map{ '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 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 objectDefs() { Boolean paOn = personAccountsEnabled(); return new Map{ '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 labels; @AuraEnabled public List values; @AuraEnabled public List 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 headers, List> 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 ops = new List(); // ---- 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 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 objects = new List{ '1 0 obj\n<>\nendobj\n', '2 0 obj\n<>\nendobj\n', '3 0 obj\n<>' + '/F2<>>>>>/Rotate 0>>\nendobj\n', '4 0 obj\n<>\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 objects, Integer rootObjNum) { String header = '%PDF-1.4\n'; List offsets = new List(); 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 xrefEntries = new List(); 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<>\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 objects; private Integer objCounter; private Integer pageCount; private List 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(); this.objCounter = 0; this.pageCount = 0; this.pageRefs = new List(); 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 <> endobj\n'; Integer pagesObj = ++this.objCounter; String kids = String.join(this.pageRefs, ' '); String pagesStr = pagesObj + ' 0 obj <> 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 offsetsByNum = new Map(); 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 xrefEntries = new List(); // 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<>\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 <> stream\n' + stream + 'endstream endobj\n'; this.objects.add(obj); Integer pageObj = this.currentPageObj; String page = pageObj + ' 0 obj <>/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 parts; public StringBuilder() { this.parts = new List(); } public void add(String s) { this.parts.add(s); } public override String toString() { return String.join(parts, ''); } } }