@mahe_pkm/buzl-capi 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,948 @@
1
+ /**
2
+ * =========================================================================
3
+ * BUZL MULTI-TAB CRM & FORM DISPATCHER SCRIPT
4
+ * Google Apps Script Web App & Automated Sheet CRM Engine
5
+ * =========================================================================
6
+ *
7
+ * FEATURES:
8
+ * 1. 'All Leads' (Master Sheet): Inbound leads insert at Row 2 (Top of sheet).
9
+ * 2. Guaranteed Forward Layout: Handled By & Comments sit right next to Lead Stage.
10
+ * 3. Universal Fuzzy Matcher: Case-insensitive & whitespace-tolerant header mapping.
11
+ * 4. Official Buzl Navy Header: #1E4E9E background with bold white text (#FFFFFF).
12
+ * 5. Full-Row Highlighting: Entire rows (A:Z) color-coded by Lead Stage (LOWER formula).
13
+ * 6. Global Time & 12-Hour Format: 'dd-MMM-yyyy hh:mm a' with dynamic timezone.
14
+ * 7. Colored Sheet Tabs: Bottom tabs styled with high-contrast CRM colors.
15
+ * 8. Clean Team Tabs: Placed at the very end; auto-deleted when rep has 0 leads.
16
+ * 9. Shift-Proof INDIRECT Formulas: Sub-tabs never break when rows insert at top.
17
+ * 10. '⚡ Buzl Lead CRM' Top Menu: 1-click management, upgrades, and newest-first sorting.
18
+ * =========================================================================
19
+ */
20
+
21
+ // Core column definitions
22
+ var MASTER_SHEET_NAME = 'All Leads';
23
+ var STATUS_TABS = ['New', 'Contacted', 'Qualified', 'Converted', 'Spam', 'Test'];
24
+ var DEFAULT_TEAM_MEMBERS = ['Dr. Samya', 'Dr. John', 'Sales Rep', 'Front Desk'];
25
+
26
+ // Forward Operational Layout: Handled By and Comments sit right next to Lead Stage
27
+ var STANDARD_CRM_COLUMNS = [
28
+ 'Lead Stage',
29
+ 'Handled By',
30
+ 'Comments',
31
+ 'Is Qualified',
32
+ 'Qualified Date',
33
+ 'Is Spam',
34
+ 'Event Time',
35
+ 'Action Source',
36
+ 'Source',
37
+ 'UTM Source',
38
+ 'UTM Campaign',
39
+ 'Lead ID'
40
+ ];
41
+
42
+ var TAB_COLORS = {
43
+ 'All Leads': '#1E293B', // Deep Charcoal / Slate
44
+ 'New': '#0284C7', // Ocean Blue
45
+ 'Contacted': '#D97706', // Warm Amber
46
+ 'Qualified': '#16A34A', // Emerald Green
47
+ 'Converted': '#9333EA', // Royal Purple
48
+ 'Spam': '#DC2626', // Crimson Red
49
+ 'Test': '#64748B' // Cool Slate Gray
50
+ };
51
+
52
+ var COLUMN_ALIASES = {
53
+ name: ['Name', 'Full Name', 'Patient Name', 'Client Name', 'Customer'],
54
+ location: ['Location', 'City', 'Address', 'Branch', 'Site Location'],
55
+ phone: ['Phone', 'Mobile', 'Contact', 'Tel', 'WhatsApp'],
56
+ email: ['Email', 'E-mail', 'Mail'],
57
+ service: ['Service', 'Treatment', 'Inquiry', 'Department', 'Specialty'],
58
+ leadStage: ['Lead Stage', 'Lead stage', 'Stage', 'Status', 'Lead Status'],
59
+ handledBy: ['Handled By', 'Handled by', 'handledby', 'Rep', 'Assigned To', 'Doctor', 'Staff'],
60
+ comments: ['Comments', 'Comment', 'Notes', 'Note', 'Remarks', 'Call Notes'],
61
+ isQualified: ['Is Qualified', 'Qualified', 'Qualified?'],
62
+ qualifiedDate: ['Qualified Date', 'Qualification Date', 'Date Qualified'],
63
+ isSpam: ['Is Spam', 'Spam', 'Spam?'],
64
+ eventTime: ['Event Time', 'Timestamp', 'Date', 'Time', 'Created At'],
65
+ actionSource: ['Action Source', 'ActionSource', 'Source Type'],
66
+ source: ['Source', 'Source / Form', 'Form', 'Page', 'Source/Form'],
67
+ utmSource: ['UTM Source', 'utm_source'],
68
+ utmCampaign: ['UTM Campaign', 'utm_campaign'],
69
+ leadId: ['Lead ID', 'lead_id', 'leadId', 'ID']
70
+ };
71
+
72
+ /**
73
+ * Universal Case-Insensitive Header Index Matcher
74
+ */
75
+ function findHeaderIndex(headers, aliases) {
76
+ if (!headers || !headers.length) return -1;
77
+ for (var i = 0; i < headers.length; i++) {
78
+ var clean = (headers[i] || '').toString().trim().toLowerCase().replace(/[^a-z0-9]/g, '');
79
+ for (var a = 0; a < aliases.length; a++) {
80
+ var aliasClean = aliases[a].toLowerCase().replace(/[^a-z0-9]/g, '');
81
+ if (clean === aliasClean) {
82
+ return i;
83
+ }
84
+ }
85
+ }
86
+ return -1;
87
+ }
88
+
89
+ /**
90
+ * Main Webhook Receiver
91
+ */
92
+ function doPost(e) {
93
+ var lock = LockService.getScriptLock();
94
+ lock.tryLock(30000);
95
+
96
+ try {
97
+ var ss = SpreadsheetApp.getActiveSpreadsheet();
98
+ var sheet = getOrCreateMasterSheet(ss);
99
+ var tz = getSpreadsheetTz(ss);
100
+
101
+ // Parse incoming payload
102
+ var data = {};
103
+ if (e.postData && e.postData.contents) {
104
+ try {
105
+ data = JSON.parse(e.postData.contents);
106
+ } catch (err) {
107
+ data = e.parameter || {};
108
+ }
109
+ } else if (e.parameter) {
110
+ data = e.parameter;
111
+ }
112
+
113
+ var contact = data.contact || {};
114
+ var rawFields = data.rawFields || data.rawPayload || {};
115
+ var utm = data.utm || {};
116
+
117
+ // 1. Core Contact Fields
118
+ var name = data.name || contact.name || data.bizName || rawFields.name || rawFields.fullName || '';
119
+ var phone = data.phone || contact.phone || data.bizPhone || rawFields.phone || rawFields.mobile || '';
120
+ var location = data.location || contact.location || data.bizLocation || data.siteLocation || rawFields.location || rawFields.city || '';
121
+ var email = data.email || contact.email || rawFields.email || '';
122
+
123
+ // 2. Dynamic Inquiry Fields (next to Phone)
124
+ var excludedKeys = [
125
+ 'name', 'bizname', 'fullname', 'phone', 'bizphone', 'mobile',
126
+ 'location', 'bizlocation', 'city', 'leadid', 'timestamp', 'utm',
127
+ 'rawfields', 'rawpayload', 'contact', 'source', 'actionsource',
128
+ 'sitelocation', 'eventsourceurl', 'useragent', 'fbclid', 'fbc',
129
+ 'fbp', 'leadstage', 'isqualified', 'qualifieddate', 'isspam',
130
+ 'handledby', 'comments', 'istest', 'email', 'eventtime'
131
+ ];
132
+ var dynamicFields = {};
133
+
134
+ for (var k in rawFields) {
135
+ if (excludedKeys.indexOf(k.toLowerCase()) === -1 && typeof rawFields[k] !== 'object' && rawFields[k] !== '') {
136
+ dynamicFields[capitalize(k)] = rawFields[k];
137
+ }
138
+ }
139
+ for (var dk in data) {
140
+ if (excludedKeys.indexOf(dk.toLowerCase()) === -1 && typeof data[dk] !== 'object' && data[dk] !== '') {
141
+ var cleanDk = capitalize(dk);
142
+ if (!dynamicFields[cleanDk]) dynamicFields[cleanDk] = data[dk];
143
+ }
144
+ }
145
+
146
+ // Default 'Service' if discovered in source
147
+ if (Object.keys(dynamicFields).length === 0 && data.source && data.source !== 'Website Form') {
148
+ dynamicFields['Service'] = data.source;
149
+ }
150
+
151
+ // 3. Status & CRM Fields (12-Hour Format: dd-MMM-yyyy hh:mm a)
152
+ var leadDate = new Date();
153
+ if (data.timestamp || data.eventTime) {
154
+ var candidate = new Date(data.timestamp || data.eventTime);
155
+ if (!isNaN(candidate.getTime())) leadDate = candidate;
156
+ }
157
+ var timestamp = Utilities.formatDate(leadDate, tz, "dd-MMM-yyyy hh:mm a");
158
+
159
+ var isTestSubmission = data.isTest === true || (rawFields && rawFields.isTest === true) || (utm.source && utm.source.toString().indexOf('test') !== -1);
160
+ var leadStage = isTestSubmission ? 'Test' : (data.leadStage || 'New');
161
+ var isQualified = data.isQualified || 'No';
162
+ var qualifiedDate = data.qualifiedDate || '';
163
+ if (leadStage === 'Qualified' && !qualifiedDate) {
164
+ qualifiedDate = Utilities.formatDate(leadDate, tz, "dd-MMM-yyyy");
165
+ }
166
+ var isSpam = data.isSpam || 'No';
167
+
168
+ // Clean 'Rep - ' from Handled By if present
169
+ var rawHandledBy = data.handledBy || '';
170
+ var handledBy = rawHandledBy.replace(/^rep\s*[-:]?\s*/i, '').trim();
171
+
172
+ var comments = data.comments || '';
173
+ var actionSource = data.actionSource || 'website';
174
+ var source = data.source || data.formName || data.formId || 'Website Form';
175
+ var utmSource = utm.source || data.utm_source || '';
176
+ var utmCampaign = utm.campaign || data.utm_campaign || '';
177
+ var leadId = data.leadId || data.event_id || data.buzl_lead_id || ('lead-' + Date.now());
178
+
179
+ // 4. Ensure Form-First CRM Layout & Auto-Migrate Legacy Columns if present
180
+ var headers = ensureCrmLayout(sheet, dynamicFields, false);
181
+
182
+ // 5. Build New Lead Row
183
+ var rowData = [];
184
+ for (var i = 0; i < headers.length; i++) {
185
+ var h = headers[i];
186
+ var cleanH = h.toLowerCase().replace(/[^a-z0-9]/g, '');
187
+
188
+ if (cleanH === 'name') rowData.push(name);
189
+ else if (cleanH === 'location') rowData.push(location);
190
+ else if (cleanH === 'phone') rowData.push(phone);
191
+ else if (cleanH === 'email') rowData.push(email);
192
+ else if (cleanH === 'leadstage') rowData.push(leadStage);
193
+ else if (cleanH === 'handledby') rowData.push(handledBy);
194
+ else if (cleanH === 'comments') rowData.push(comments);
195
+ else if (cleanH === 'isqualified') rowData.push(isQualified);
196
+ else if (cleanH === 'qualifieddate') rowData.push(qualifiedDate);
197
+ else if (cleanH === 'isspam') rowData.push(isSpam);
198
+ else if (cleanH === 'eventtime') rowData.push(timestamp);
199
+ else if (cleanH === 'actionsource') rowData.push(actionSource);
200
+ else if (cleanH === 'source' || cleanH === 'sourceform') rowData.push(source);
201
+ else if (cleanH === 'utmsource') rowData.push(utmSource);
202
+ else if (cleanH === 'utmcampaign') rowData.push(utmCampaign);
203
+ else if (cleanH === 'leadid') rowData.push(leadId);
204
+ else if (dynamicFields[h] !== undefined) rowData.push(dynamicFields[h]);
205
+ else rowData.push('');
206
+ }
207
+
208
+ // Insert at Row 2 (Top of Sheet, below header)
209
+ sheet.insertRowBefore(2);
210
+ sheet.getRange(2, 1, 1, rowData.length).setValues([rowData]);
211
+ sheet.getRange(2, 1, 1, rowData.length).setFontWeight('normal');
212
+ var insertedRowIndex = 2;
213
+
214
+ // 6. Ensure Dropdowns, Formatting, Number Formats, and Sub-Tabs exist
215
+ applyDropdownValidations(sheet, headers, insertedRowIndex);
216
+ applyDateFormatting(sheet, headers, insertedRowIndex);
217
+ applyColorCoding(sheet, headers);
218
+ initializeCrmTabs(ss, headers);
219
+
220
+ return ContentService.createTextOutput(JSON.stringify({
221
+ status: 'success',
222
+ message: 'Lead recorded at top of CRM sheet',
223
+ leadId: leadId,
224
+ row: insertedRowIndex
225
+ })).setMimeType(ContentService.MimeType.JSON);
226
+
227
+ } catch (error) {
228
+ return ContentService.createTextOutput(JSON.stringify({
229
+ status: 'error',
230
+ message: error.toString()
231
+ })).setMimeType(ContentService.MimeType.JSON);
232
+
233
+ } finally {
234
+ lock.releaseLock();
235
+ }
236
+ }
237
+
238
+ function doGet(e) {
239
+ var tz = 'UTC';
240
+ try { tz = getSpreadsheetTz(SpreadsheetApp.getActiveSpreadsheet()); } catch (err) {}
241
+ return ContentService.createTextOutput(JSON.stringify({
242
+ status: 'online',
243
+ service: 'Buzl Multi-Tab CRM Sync Web App',
244
+ timestamp: Utilities.formatDate(new Date(), tz, "dd-MMM-yyyy hh:mm:ss a")
245
+ })).setMimeType(ContentService.MimeType.JSON);
246
+ }
247
+
248
+ /**
249
+ * Get active spreadsheet timezone safely
250
+ */
251
+ function getSpreadsheetTz(ss) {
252
+ try {
253
+ if (ss && ss.getSpreadsheetTimeZone) {
254
+ var tz = ss.getSpreadsheetTimeZone();
255
+ if (tz) return tz;
256
+ }
257
+ return Session.getScriptTimeZone() || 'UTC';
258
+ } catch (e) {
259
+ return 'UTC';
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Format Header Row 1 with official Buzl Navy Blue and crisp white text
265
+ */
266
+ function formatHeaderRow(sheet, numCols) {
267
+ if (!numCols || numCols < 1) numCols = sheet.getLastColumn() || 1;
268
+ var range = sheet.getRange(1, 1, 1, numCols);
269
+ range.setFontWeight('bold')
270
+ .setBackground('#1E4E9E')
271
+ .setFontColor('#FFFFFF')
272
+ .setFontSize(10)
273
+ .setVerticalAlignment('middle');
274
+ try {
275
+ sheet.setRowHeight(1, 32);
276
+ } catch (e) {}
277
+ sheet.setFrozenRows(1);
278
+ }
279
+
280
+ /**
281
+ * Ensure Master Sheet is named 'All Leads'
282
+ */
283
+ function getOrCreateMasterSheet(ss) {
284
+ var sheet = ss.getSheetByName(MASTER_SHEET_NAME);
285
+ if (!sheet) {
286
+ var allSheets = ss.getSheets();
287
+ for (var s = 0; s < allSheets.length; s++) {
288
+ var sName = allSheets[s].getName();
289
+ if (STATUS_TABS.indexOf(sName) === -1 && sName.indexOf('Rep -') !== 0) {
290
+ try {
291
+ allSheets[s].setName(MASTER_SHEET_NAME);
292
+ sheet = allSheets[s];
293
+ break;
294
+ } catch (e) {}
295
+ }
296
+ }
297
+ if (!sheet) {
298
+ sheet = ss.insertSheet(MASTER_SHEET_NAME, 0);
299
+ }
300
+ }
301
+ try {
302
+ sheet.setTabColor(TAB_COLORS['All Leads']);
303
+ } catch (e) {}
304
+ return sheet;
305
+ }
306
+
307
+ /**
308
+ * Ensure Form-First CRM Layout & Auto-Migrate Legacy Sheets with Resilient Fuzzy Mapping
309
+ */
310
+ function ensureCrmLayout(sheet, dynamicFields, forceReorganize) {
311
+ var lastRow = sheet.getLastRow();
312
+ var lastCol = sheet.getLastColumn();
313
+ var tz = getSpreadsheetTz(sheet.getParent());
314
+
315
+ // Empty sheet -> Build initial headers
316
+ if (lastRow === 0) {
317
+ var initialHeaders = ['Name', 'Location', 'Phone'];
318
+ if (dynamicFields) {
319
+ for (var df in dynamicFields) initialHeaders.push(df);
320
+ }
321
+ if (initialHeaders.indexOf('Service') === -1) {
322
+ initialHeaders.push('Service');
323
+ }
324
+ initialHeaders = initialHeaders.concat(STANDARD_CRM_COLUMNS);
325
+
326
+ sheet.appendRow(initialHeaders);
327
+ formatHeaderRow(sheet, initialHeaders.length);
328
+ applyDateFormatting(sheet, initialHeaders);
329
+ return initialHeaders;
330
+ }
331
+
332
+ var allData = sheet.getDataRange().getValues();
333
+ var oldHeaders = allData[0] || [];
334
+ var oldRows = allData.slice(1);
335
+
336
+ var leadStageIdx = findHeaderIndex(oldHeaders, COLUMN_ALIASES.leadStage);
337
+ var handledByIdx = findHeaderIndex(oldHeaders, COLUMN_ALIASES.handledBy);
338
+ var commentsIdx = findHeaderIndex(oldHeaders, COLUMN_ALIASES.comments);
339
+
340
+ // Determine if column reorganization is needed
341
+ var needsReorganization = forceReorganize === true ||
342
+ leadStageIdx === -1 ||
343
+ handledByIdx === -1 ||
344
+ commentsIdx === -1 ||
345
+ handledByIdx !== leadStageIdx + 1 ||
346
+ commentsIdx !== leadStageIdx + 2;
347
+
348
+ if (needsReorganization) {
349
+ // 1. Build index mapping for all standard fields
350
+ var idxMap = {};
351
+ for (var key in COLUMN_ALIASES) {
352
+ idxMap[key] = findHeaderIndex(oldHeaders, COLUMN_ALIASES[key]);
353
+ }
354
+
355
+ // 2. Build target headers: Name, Location, Phone, [Email if present], [Dynamic fields / Service], Standard CRM Columns
356
+ var targetHeaders = ['Name', 'Location', 'Phone'];
357
+ if (idxMap.email > -1) {
358
+ targetHeaders.push('Email');
359
+ }
360
+ if (dynamicFields) {
361
+ for (var k in dynamicFields) {
362
+ if (targetHeaders.indexOf(k) === -1) targetHeaders.push(k);
363
+ }
364
+ }
365
+ if (targetHeaders.indexOf('Service') === -1) {
366
+ targetHeaders.push('Service');
367
+ }
368
+ targetHeaders = targetHeaders.concat(STANDARD_CRM_COLUMNS);
369
+
370
+ // 3. Preserve any additional custom columns from old headers
371
+ for (var c = 0; c < oldHeaders.length; c++) {
372
+ var rawH = (oldHeaders[c] || '').toString().trim();
373
+ if (!rawH) continue;
374
+ var matched = false;
375
+ for (var aKey in COLUMN_ALIASES) {
376
+ if (findHeaderIndex([rawH], COLUMN_ALIASES[aKey]) > -1) {
377
+ matched = true;
378
+ break;
379
+ }
380
+ }
381
+ if (!matched && targetHeaders.indexOf(rawH) === -1) {
382
+ targetHeaders.push(rawH);
383
+ }
384
+ }
385
+
386
+ // 4. Migrate every existing row into the target columns
387
+ var migratedRows = [];
388
+ for (var r = 0; r < oldRows.length; r++) {
389
+ var oldRow = oldRows[r];
390
+
391
+ var rawPayload = {};
392
+ var rawPayloadIdx = -1;
393
+ for (var hi = 0; hi < oldHeaders.length; hi++) {
394
+ if ((oldHeaders[hi] || '').toString().toLowerCase().includes('raw')) {
395
+ rawPayloadIdx = hi;
396
+ break;
397
+ }
398
+ }
399
+ if (rawPayloadIdx > -1 && oldRow[rawPayloadIdx]) {
400
+ try { rawPayload = JSON.parse(oldRow[rawPayloadIdx]); } catch (e) {}
401
+ }
402
+
403
+ var rowName = (idxMap.name > -1 ? oldRow[idxMap.name] : '') || rawPayload.name || '';
404
+ var rowLoc = (idxMap.location > -1 ? oldRow[idxMap.location] : '') || rawPayload.location || rawPayload.siteLocation || '';
405
+ var rowPhone = (idxMap.phone > -1 ? oldRow[idxMap.phone] : '') || rawPayload.phone || '';
406
+ var rowEmail = (idxMap.email > -1 ? oldRow[idxMap.email] : '') || rawPayload.email || '';
407
+ var rowService = (idxMap.service > -1 ? oldRow[idxMap.service] : '') || (rawPayload.rawFields && rawPayload.rawFields.service) || rawPayload.service || '';
408
+ var rowStage = (idxMap.leadStage > -1 && oldRow[idxMap.leadStage]) ? oldRow[idxMap.leadStage] : 'New';
409
+ var rowHandledBy = (idxMap.handledBy > -1 ? oldRow[idxMap.handledBy] : '').toString().replace(/^rep\s*[-:]?\s*/i, '').trim();
410
+ var rowComments = idxMap.comments > -1 ? oldRow[idxMap.comments] : '';
411
+ var rowIsQual = idxMap.isQualified > -1 ? (oldRow[idxMap.isQualified] || 'No') : 'No';
412
+ var rowQualDate = idxMap.qualifiedDate > -1 ? oldRow[idxMap.qualifiedDate] : '';
413
+ var rowIsSpam = idxMap.isSpam > -1 ? (oldRow[idxMap.isSpam] || 'No') : 'No';
414
+
415
+ var rawTime = (idxMap.eventTime > -1 ? oldRow[idxMap.eventTime] : '') || rawPayload.timestamp;
416
+ var rowDateObj = new Date();
417
+ if (rawTime) {
418
+ var parsed = new Date(rawTime);
419
+ if (!isNaN(parsed.getTime())) rowDateObj = parsed;
420
+ }
421
+ var formattedRowTime = Utilities.formatDate(rowDateObj, tz, "dd-MMM-yyyy hh:mm a");
422
+
423
+ var rowAction = (idxMap.actionSource > -1 ? oldRow[idxMap.actionSource] : '') || 'website';
424
+ var rowSource = (idxMap.source > -1 ? oldRow[idxMap.source] : '') || rawPayload.source || 'Website Form';
425
+ var rowUtmSrc = (idxMap.utmSource > -1 ? oldRow[idxMap.utmSource] : '') || (rawPayload.utm && rawPayload.utm.source) || '';
426
+ var rowUtmCmp = (idxMap.utmCampaign > -1 ? oldRow[idxMap.utmCampaign] : '') || (rawPayload.utm && rawPayload.utm.campaign) || '';
427
+ var rowLeadId = (idxMap.leadId > -1 ? oldRow[idxMap.leadId] : '') || rawPayload.leadId || ('lead-' + (r + 1));
428
+
429
+ var newRow = [];
430
+ for (var th = 0; th < targetHeaders.length; th++) {
431
+ var tHeader = targetHeaders[th];
432
+ var cleanTH = tHeader.toLowerCase().replace(/[^a-z0-9]/g, '');
433
+
434
+ if (cleanTH === 'name') newRow.push(rowName);
435
+ else if (cleanTH === 'location') newRow.push(rowLoc);
436
+ else if (cleanTH === 'phone') newRow.push(rowPhone);
437
+ else if (cleanTH === 'email') newRow.push(rowEmail);
438
+ else if (cleanTH === 'service') newRow.push(rowService);
439
+ else if (cleanTH === 'leadstage') newRow.push(rowStage);
440
+ else if (cleanTH === 'handledby') newRow.push(rowHandledBy);
441
+ else if (cleanTH === 'comments') newRow.push(rowComments);
442
+ else if (cleanTH === 'isqualified') newRow.push(rowIsQual);
443
+ else if (cleanTH === 'qualifieddate') newRow.push(rowQualDate);
444
+ else if (cleanTH === 'isspam') newRow.push(rowIsSpam);
445
+ else if (cleanTH === 'eventtime') newRow.push(formattedRowTime);
446
+ else if (cleanTH === 'actionsource') newRow.push(rowAction);
447
+ else if (cleanTH === 'source' || cleanTH === 'sourceform') newRow.push(rowSource);
448
+ else if (cleanTH === 'utmsource') newRow.push(rowUtmSrc);
449
+ else if (cleanTH === 'utmcampaign') newRow.push(rowUtmCmp);
450
+ else if (cleanTH === 'leadid') newRow.push(rowLeadId);
451
+ else {
452
+ var originalIdx = oldHeaders.indexOf(tHeader);
453
+ newRow.push(originalIdx > -1 ? oldRow[originalIdx] : '');
454
+ }
455
+ }
456
+ migratedRows.push(newRow);
457
+ }
458
+
459
+ // 5. Clear sheet and write reorganized forward structure
460
+ sheet.clear();
461
+ sheet.appendRow(targetHeaders);
462
+ formatHeaderRow(sheet, targetHeaders.length);
463
+
464
+ if (migratedRows.length > 0) {
465
+ sheet.getRange(2, 1, migratedRows.length, targetHeaders.length).setValues(migratedRows);
466
+ }
467
+ applyDateFormatting(sheet, targetHeaders);
468
+ return targetHeaders;
469
+ }
470
+
471
+ // Already properly organized: ensure styling and return
472
+ formatHeaderRow(sheet, oldHeaders.length);
473
+ applyDateFormatting(sheet, oldHeaders);
474
+ return oldHeaders;
475
+ }
476
+
477
+ /**
478
+ * Apply native date/time number formats to Event Time & Qualified Date
479
+ */
480
+ function applyDateFormatting(sheet, headers, targetRow) {
481
+ var timeCol = findHeaderIndex(headers, COLUMN_ALIASES.eventTime) + 1;
482
+ var qualDateCol = findHeaderIndex(headers, COLUMN_ALIASES.qualifiedDate) + 1;
483
+ var startRow = targetRow || 2;
484
+ var numRows = targetRow ? 1 : Math.max(sheet.getLastRow() - 1, 1000);
485
+
486
+ if (timeCol > 0) {
487
+ sheet.getRange(startRow, timeCol, numRows, 1).setNumberFormat('dd-mmm-yyyy hh:mm am/pm');
488
+ }
489
+ if (qualDateCol > 0) {
490
+ sheet.getRange(startRow, qualDateCol, numRows, 1).setNumberFormat('dd-mmm-yyyy');
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Initialize / Update Auto-Filtered Status Tabs with shift-proof INDIRECT formulas
496
+ */
497
+ function initializeCrmTabs(ss, headers) {
498
+ var master = getOrCreateMasterSheet(ss);
499
+ if (!headers || headers.length === 0) {
500
+ headers = ensureCrmLayout(master, null, false);
501
+ }
502
+
503
+ var lastColLetter = getColLetter(headers.length);
504
+ var stageCol = findHeaderIndex(headers, COLUMN_ALIASES.leadStage) + 1;
505
+ var qualCol = findHeaderIndex(headers, COLUMN_ALIASES.isQualified) + 1;
506
+ var spamCol = findHeaderIndex(headers, COLUMN_ALIASES.isSpam) + 1;
507
+
508
+ var stageColLetter = getColLetter(stageCol);
509
+ var qualColLetter = getColLetter(qualCol);
510
+ var spamColLetter = getColLetter(spamCol);
511
+
512
+ // Keep master at index 1
513
+ try {
514
+ ss.setActiveSheet(master);
515
+ ss.moveActiveSheet(1);
516
+ } catch (e) {}
517
+
518
+ STATUS_TABS.forEach(function (tabName, index) {
519
+ var tab = ss.getSheetByName(tabName);
520
+ if (!tab) {
521
+ tab = ss.insertSheet(tabName);
522
+ }
523
+
524
+ // Ensure status tab sequence (tabs 2 through 7)
525
+ try {
526
+ ss.setActiveSheet(tab);
527
+ ss.moveActiveSheet(index + 2);
528
+ } catch (e) {}
529
+
530
+ // Tab Color
531
+ if (TAB_COLORS[tabName]) {
532
+ try { tab.setTabColor(TAB_COLORS[tabName]); } catch (e) {}
533
+ }
534
+
535
+ // Set Header with official Buzl Navy Blue
536
+ tab.getRange(1, 1, 1, headers.length).setValues([headers]);
537
+ formatHeaderRow(tab, headers.length);
538
+
539
+ // Clear previous formula contents below row 1 to prevent collision (#REF!)
540
+ var maxRows = Math.max(tab.getLastRow(), 2);
541
+ tab.getRange(2, 1, maxRows, tab.getLastColumn() || headers.length).clearContent();
542
+
543
+ // Shift-Proof Dynamic Filter Formula using INDIRECT and case-insensitive LOWER matching
544
+ var formula = '';
545
+ if (tabName === 'New') {
546
+ formula = `=IFERROR(FILTER(INDIRECT("'${MASTER_SHEET_NAME}'!A2:${lastColLetter}"), LOWER(INDIRECT("'${MASTER_SHEET_NAME}'!${stageColLetter}2:${stageColLetter}")) = "new"), "No new leads yet")`;
547
+ } else if (tabName === 'Contacted') {
548
+ formula = `=IFERROR(FILTER(INDIRECT("'${MASTER_SHEET_NAME}'!A2:${lastColLetter}"), LOWER(INDIRECT("'${MASTER_SHEET_NAME}'!${stageColLetter}2:${stageColLetter}")) = "contacted"), "No contacted leads yet")`;
549
+ } else if (tabName === 'Qualified') {
550
+ formula = `=IFERROR(FILTER(INDIRECT("'${MASTER_SHEET_NAME}'!A2:${lastColLetter}"), (LOWER(INDIRECT("'${MASTER_SHEET_NAME}'!${stageColLetter}2:${stageColLetter}")) = "qualified") + (LOWER(INDIRECT("'${MASTER_SHEET_NAME}'!${qualColLetter}2:${qualColLetter}")) = "yes")), "No qualified leads yet")`;
551
+ } else if (tabName === 'Converted') {
552
+ formula = `=IFERROR(FILTER(INDIRECT("'${MASTER_SHEET_NAME}'!A2:${lastColLetter}"), LOWER(INDIRECT("'${MASTER_SHEET_NAME}'!${stageColLetter}2:${stageColLetter}")) = "converted"), "No converted leads yet")`;
553
+ } else if (tabName === 'Spam') {
554
+ formula = `=IFERROR(FILTER(INDIRECT("'${MASTER_SHEET_NAME}'!A2:${lastColLetter}"), (LOWER(INDIRECT("'${MASTER_SHEET_NAME}'!${stageColLetter}2:${stageColLetter}")) = "spam") + (LOWER(INDIRECT("'${MASTER_SHEET_NAME}'!${spamColLetter}2:${spamColLetter}")) = "yes")), "No spam leads")`;
555
+ } else if (tabName === 'Test') {
556
+ formula = `=IFERROR(FILTER(INDIRECT("'${MASTER_SHEET_NAME}'!A2:${lastColLetter}"), LOWER(INDIRECT("'${MASTER_SHEET_NAME}'!${stageColLetter}2:${stageColLetter}")) = "test"), "No test leads")`;
557
+ }
558
+
559
+ if (formula) {
560
+ tab.getRange('A2').setFormula(formula);
561
+ }
562
+
563
+ applyColorCoding(tab, headers);
564
+ applyDateFormatting(tab, headers);
565
+ applyDropdownValidations(tab, headers, null, ss);
566
+ applyColumnWidths(tab, headers);
567
+ applyDropdownValidations(tab, headers, null, ss);
568
+ applyColumnWidths(tab, headers);
569
+ });
570
+
571
+ // Check, prune, and sync team member tabs at the very end
572
+ syncTeamMemberTabs(ss, headers);
573
+ }
574
+
575
+ /**
576
+ * Dynamic Team Member Tabs: Positioned at the very end; auto-pruned when 0 leads
577
+ */
578
+ function syncTeamMemberTabs(ss, headers) {
579
+ var master = getOrCreateMasterSheet(ss);
580
+ if (!headers) {
581
+ headers = master.getRange(1, 1, 1, master.getLastColumn()).getValues()[0];
582
+ }
583
+
584
+ var handledByIndex = findHeaderIndex(headers, COLUMN_ALIASES.handledBy);
585
+ if (handledByIndex === -1) return;
586
+
587
+ var lastColLetter = getColLetter(headers.length);
588
+ var handledByColLetter = getColLetter(handledByIndex + 1);
589
+
590
+ // 1. Calculate active lead counts per team member
591
+ var repCounts = {};
592
+ if (master.getLastRow() >= 2) {
593
+ var values = master.getRange(2, handledByIndex + 1, master.getLastRow() - 1, 1).getValues();
594
+ values.forEach(function (row) {
595
+ var rawVal = (row[0] || '').toString().trim();
596
+ if (rawVal && rawVal !== '') {
597
+ var cleanVal = rawVal.replace(/^rep\s*[-:]?\s*/i, '').trim();
598
+ if (cleanVal) {
599
+ repCounts[cleanVal] = (repCounts[cleanVal] || 0) + 1;
600
+ }
601
+ }
602
+ });
603
+ }
604
+
605
+ // 2. Auto-Prune: Delete any team tabs for reps who now have 0 leads
606
+ var allSheets = ss.getSheets();
607
+ allSheets.forEach(function (s) {
608
+ var sName = s.getName();
609
+ if (sName !== MASTER_SHEET_NAME && STATUS_TABS.indexOf(sName) === -1) {
610
+ var cleanSName = sName.replace(/^rep\s*[-:]?\s*/i, '').trim();
611
+ if (!repCounts[cleanSName] || repCounts[cleanSName] === 0) {
612
+ try {
613
+ ss.deleteSheet(s);
614
+ } catch (err) {}
615
+ }
616
+ }
617
+ });
618
+
619
+ // 3. Create or update tabs for active members and move them to the very end
620
+ var uniqueReps = Object.keys(repCounts);
621
+
622
+ uniqueReps.forEach(function (repName) {
623
+ if (!repCounts[repName] || repCounts[repName] < 1) return;
624
+
625
+ var tabName = repName; // Direct clean name (no 'Rep -')
626
+
627
+ // Auto-migrate legacy 'Rep - <Name>' tabs if present
628
+ var legacyTab = ss.getSheetByName('Rep - ' + repName);
629
+ var tab = ss.getSheetByName(tabName);
630
+ if (!tab && legacyTab) {
631
+ try {
632
+ legacyTab.setName(tabName);
633
+ tab = legacyTab;
634
+ } catch (e) {
635
+ tab = legacyTab;
636
+ }
637
+ }
638
+ if (!tab) {
639
+ tab = ss.insertSheet(tabName);
640
+ }
641
+
642
+ try { tab.setTabColor('#0D9488'); } catch (e) {} // Elegant Teal
643
+
644
+ tab.getRange(1, 1, 1, headers.length).setValues([headers]);
645
+ formatHeaderRow(tab, headers.length);
646
+
647
+ var maxRows = Math.max(tab.getLastRow(), 2);
648
+ tab.getRange(2, 1, maxRows, tab.getLastColumn() || headers.length).clearContent();
649
+
650
+ // Shift-Proof INDIRECT formula matching both clean and legacy names
651
+ var formula = `=IFERROR(FILTER(INDIRECT("'${MASTER_SHEET_NAME}'!A2:${lastColLetter}"), (LOWER(INDIRECT("'${MASTER_SHEET_NAME}'!${handledByColLetter}2:${handledByColLetter}")) = "${repName.toLowerCase()}") + (LOWER(INDIRECT("'${MASTER_SHEET_NAME}'!${handledByColLetter}2:${handledByColLetter}")) = "rep - ${repName.toLowerCase()}")), "No leads assigned to ${repName}")`;
652
+ tab.getRange('A2').setFormula(formula);
653
+
654
+ applyColorCoding(tab, headers);
655
+ applyDateFormatting(tab, headers);
656
+
657
+ // Strictly position team member tab at the very end
658
+ try {
659
+ ss.setActiveSheet(tab);
660
+ ss.moveActiveSheet(ss.getNumSheets());
661
+ } catch (e) {}
662
+ });
663
+ }
664
+
665
+ /**
666
+ * Dynamic Team Member List Aggregator
667
+ * Merges DEFAULT_TEAM_MEMBERS with any active members assigned in 'All Leads'
668
+ */
669
+ function getTeamMembersList(ss, master) {
670
+ var members = DEFAULT_TEAM_MEMBERS.slice();
671
+ if (!master && ss) master = getOrCreateMasterSheet(ss);
672
+ if (master && master.getLastRow() >= 2) {
673
+ var headers = master.getRange(1, 1, 1, master.getLastColumn()).getValues()[0];
674
+ var handledByIndex = findHeaderIndex(headers, COLUMN_ALIASES.handledBy);
675
+ if (handledByIndex > -1) {
676
+ var vals = master.getRange(2, handledByIndex + 1, master.getLastRow() - 1, 1).getValues();
677
+ vals.forEach(function (row) {
678
+ var raw = (row[0] || '').toString().trim();
679
+ if (raw) {
680
+ var clean = raw.replace(/^rep\s*[-:]?\s*/i, '').trim();
681
+ if (clean && members.indexOf(clean) === -1) {
682
+ members.push(clean);
683
+ }
684
+ }
685
+ });
686
+ }
687
+ }
688
+ return members;
689
+ }
690
+
691
+ /**
692
+ * Professional Column Widths for Optimal Readability
693
+ */
694
+ function applyColumnWidths(sheet, headers) {
695
+ if (!sheet || !headers) return;
696
+ try {
697
+ sheet.setHiddenGridlines(false);
698
+ } catch (e) {}
699
+
700
+ var widthMap = {
701
+ name: 160,
702
+ location: 130,
703
+ phone: 135,
704
+ email: 185,
705
+ service: 150,
706
+ leadStage: 125,
707
+ handledBy: 140,
708
+ comments: 220,
709
+ isQualified: 95,
710
+ qualifiedDate: 120,
711
+ isSpam: 95,
712
+ eventTime: 165,
713
+ actionSource: 120,
714
+ source: 140,
715
+ utmSource: 120,
716
+ utmCampaign: 130,
717
+ leadId: 130
718
+ };
719
+
720
+ for (var i = 0; i < headers.length; i++) {
721
+ var colNum = i + 1;
722
+ for (var key in COLUMN_ALIASES) {
723
+ var aliases = COLUMN_ALIASES[key];
724
+ for (var a = 0; a < aliases.length; a++) {
725
+ if ((headers[i] || '').toString().trim().toLowerCase() === aliases[a].toLowerCase()) {
726
+ if (widthMap[key]) {
727
+ try {
728
+ sheet.setColumnWidth(colNum, widthMap[key]);
729
+ } catch (e) {}
730
+ }
731
+ break;
732
+ }
733
+ }
734
+ }
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Dropdown Validations for Status, Team Members, and Spam
740
+ * Applies across full column (rows 2 to 1000) or a targeted row
741
+ */
742
+ function applyDropdownValidations(sheet, headers, targetRow, ss) {
743
+ if (!sheet || !headers || headers.length === 0) return;
744
+ var stageCol = findHeaderIndex(headers, COLUMN_ALIASES.leadStage) + 1;
745
+ var qualCol = findHeaderIndex(headers, COLUMN_ALIASES.isQualified) + 1;
746
+ var spamCol = findHeaderIndex(headers, COLUMN_ALIASES.isSpam) + 1;
747
+ var handledByCol = findHeaderIndex(headers, COLUMN_ALIASES.handledBy) + 1;
748
+
749
+ var stageRule = SpreadsheetApp.newDataValidation()
750
+ .requireValueInList(['New', 'Contacted', 'Qualified', 'Converted', 'Spam', 'Test'], true)
751
+ .build();
752
+
753
+ var yesNoRule = SpreadsheetApp.newDataValidation()
754
+ .requireValueInList(['No', 'Yes'], true)
755
+ .build();
756
+
757
+ var startRow = targetRow || 2;
758
+ var numRows = targetRow ? 1 : Math.max(sheet.getLastRow() - 1, 1000);
759
+ if (numRows < 1) numRows = 1;
760
+
761
+ if (stageCol > 0) {
762
+ sheet.getRange(startRow, stageCol, numRows, 1).setDataValidation(stageRule);
763
+ }
764
+ if (qualCol > 0) {
765
+ sheet.getRange(startRow, qualCol, numRows, 1).setDataValidation(yesNoRule);
766
+ }
767
+ if (spamCol > 0) {
768
+ sheet.getRange(startRow, spamCol, numRows, 1).setDataValidation(yesNoRule);
769
+ }
770
+
771
+ // Handled By Dropdown Validation (Team Members)
772
+ if (handledByCol > 0) {
773
+ var parentSs = ss || (sheet.getParent ? sheet.getParent() : SpreadsheetApp.getActiveSpreadsheet());
774
+ var teamList = getTeamMembersList(parentSs, sheet.getName() === MASTER_SHEET_NAME ? sheet : null);
775
+ if (teamList && teamList.length > 0) {
776
+ var handledByRule = SpreadsheetApp.newDataValidation()
777
+ .requireValueInList(teamList, true)
778
+ .build();
779
+ sheet.getRange(startRow, handledByCol, numRows, 1).setDataValidation(handledByRule);
780
+ }
781
+ }
782
+ }
783
+
784
+
785
+ /**
786
+ * Conditional Formatting: Full-Row Highlighting for Lead Stage (with case-insensitive LOWER matching)
787
+ */
788
+ function applyColorCoding(sheet, headers) {
789
+ var stageCol = findHeaderIndex(headers, COLUMN_ALIASES.leadStage) + 1;
790
+ if (stageCol === 0) return;
791
+
792
+ var lastRow = Math.max(sheet.getLastRow(), 1000);
793
+ var numCols = headers.length;
794
+ var stageColLetter = getColLetter(stageCol);
795
+
796
+ // Full-Row Range spanning Column A to last column
797
+ var fullRowRange = sheet.getRange(2, 1, lastRow - 1, numCols);
798
+
799
+ var colorMap = [
800
+ { text: 'new', bg: '#EBF5FF', fg: '#0369A1' }, // Soft Ice Blue
801
+ { text: 'contacted', bg: '#FEF9C3', fg: '#854D0E' }, // Soft Pale Amber
802
+ { text: 'qualified', bg: '#DCFCE7', fg: '#166534' }, // Soft Mint Green
803
+ { text: 'converted', bg: '#F3E8FF', fg: '#6B21A8' }, // Soft Lavender Purple
804
+ { text: 'spam', bg: '#FEE2E2', fg: '#991B1B' }, // Soft Blush Red
805
+ { text: 'test', bg: '#F3F4F6', fg: '#4B5563' } // Soft Slate Gray
806
+ ];
807
+
808
+ var newRules = [];
809
+ colorMap.forEach(function (c) {
810
+ var formula = '=$' + stageColLetter + '2="' + c.text + '"';
811
+ var rule = SpreadsheetApp.newConditionalFormatRule()
812
+ .whenFormulaSatisfied(formula)
813
+ .setBackground(c.bg)
814
+ .setFontColor(c.fg)
815
+ .setRanges([fullRowRange])
816
+ .build();
817
+ newRules.push(rule);
818
+ });
819
+
820
+ sheet.setConditionalFormatRules(newRules);
821
+ }
822
+
823
+ /**
824
+ * Custom UI Menu inside Google Sheets
825
+ */
826
+ function onOpen() {
827
+ try {
828
+ SpreadsheetApp.getUi().createMenu('⚡ Buzl Lead CRM')
829
+ .addItem('⚡ Initialize & Upgrade All CRM Tabs', 'menuInitCrm')
830
+ .addItem('🔄 Sort All Leads (Newest First)', 'menuSortNewestFirst')
831
+ .addItem('🎨 Apply Status Color Coding', 'menuColorCoding')
832
+ .addItem('👥 Sync Team Member Tabs', 'menuSyncReps')
833
+ .addItem('📋 Apply Dropdowns & Layout to All Sheets', 'menuApplyDropdownsAndLayout')
834
+ .addToUi();
835
+ } catch (e) {}
836
+ }
837
+
838
+ function menuInitCrm() {
839
+ var ss = SpreadsheetApp.getActiveSpreadsheet();
840
+ var master = getOrCreateMasterSheet(ss);
841
+ var headers = ensureCrmLayout(master, { Service: 'General Consultation' }, true);
842
+ initializeCrmTabs(ss, headers);
843
+ applyDropdownValidations(master, headers, null, ss);
844
+ applyColumnWidths(master, headers);
845
+ applyColorCoding(master, headers);
846
+ applyDateFormatting(master, headers);
847
+ SpreadsheetApp.getUi().alert('✔ Buzl CRM Upgraded! Columns reorganized forward, full-row colors active, and team tabs synced.');
848
+ }
849
+
850
+ function menuSortNewestFirst() {
851
+ var ss = SpreadsheetApp.getActiveSpreadsheet();
852
+ var master = getOrCreateMasterSheet(ss);
853
+ if (master.getLastRow() < 3) {
854
+ SpreadsheetApp.getUi().alert('Not enough rows to sort.');
855
+ return;
856
+ }
857
+ var headers = master.getRange(1, 1, 1, master.getLastColumn()).getValues()[0];
858
+ var timeCol = findHeaderIndex(headers, COLUMN_ALIASES.eventTime) + 1;
859
+ if (timeCol > 0) {
860
+ var range = master.getRange(2, 1, master.getLastRow() - 1, master.getLastColumn());
861
+ range.sort({ column: timeCol, ascending: false });
862
+ SpreadsheetApp.getUi().alert('✔ Master sheet sorted: newest leads are on top!');
863
+ } else {
864
+ SpreadsheetApp.getUi().alert('Event Time column not found for sorting.');
865
+ }
866
+ }
867
+
868
+ function menuColorCoding() {
869
+ var ss = SpreadsheetApp.getActiveSpreadsheet();
870
+ var master = getOrCreateMasterSheet(ss);
871
+ var headers = master.getRange(1, 1, 1, master.getLastColumn()).getValues()[0];
872
+ applyColorCoding(master, headers);
873
+ SpreadsheetApp.getUi().alert('✔ Full-row conditional status colors applied!');
874
+ }
875
+
876
+ function menuSyncReps() {
877
+ var ss = SpreadsheetApp.getActiveSpreadsheet();
878
+ syncTeamMemberTabs(ss);
879
+ SpreadsheetApp.getUi().alert('✔ Team Member tabs refreshed & pruned!');
880
+ }
881
+
882
+ function menuApplyDropdownsAndLayout() {
883
+ var ss = SpreadsheetApp.getActiveSpreadsheet();
884
+ var master = getOrCreateMasterSheet(ss);
885
+ var headers = master.getRange(1, 1, 1, master.getLastColumn()).getValues()[0];
886
+
887
+ applyDropdownValidations(master, headers, null, ss);
888
+ applyColumnWidths(master, headers);
889
+
890
+ STATUS_TABS.forEach(function (name) {
891
+ var tab = ss.getSheetByName(name);
892
+ if (tab) {
893
+ applyDropdownValidations(tab, headers, null, ss);
894
+ applyColumnWidths(tab, headers);
895
+ }
896
+ });
897
+
898
+ syncTeamMemberTabs(ss, headers);
899
+ SpreadsheetApp.getUi().alert('✔ Full-column dropdowns and column widths applied to all sheets!');
900
+ }
901
+
902
+ /**
903
+ * Trigger: On Edit in Google Sheets
904
+ */
905
+ function onEdit(e) {
906
+ if (!e || !e.range) return;
907
+ var sheet = e.range.getSheet();
908
+ if (sheet.getName() !== MASTER_SHEET_NAME) return;
909
+
910
+ var row = e.range.getRow();
911
+ var col = e.range.getColumn();
912
+ if (row <= 1) return;
913
+
914
+ var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
915
+ var stageCol = findHeaderIndex(headers, COLUMN_ALIASES.leadStage) + 1;
916
+ var qualCol = findHeaderIndex(headers, COLUMN_ALIASES.isQualified) + 1;
917
+ var qualDateCol = findHeaderIndex(headers, COLUMN_ALIASES.qualifiedDate) + 1;
918
+ var handledByCol = findHeaderIndex(headers, COLUMN_ALIASES.handledBy) + 1;
919
+
920
+ if (col === stageCol && (e.value || '').toString().toLowerCase() === 'qualified') {
921
+ if (qualCol > 0) sheet.getRange(row, qualCol).setValue('Yes');
922
+ if (qualDateCol > 0) {
923
+ var tz = getSpreadsheetTz(SpreadsheetApp.getActiveSpreadsheet());
924
+ var today = Utilities.formatDate(new Date(), tz, "dd-MMM-yyyy");
925
+ sheet.getRange(row, qualDateCol).setValue(today);
926
+ }
927
+ }
928
+
929
+ if (col === handledByCol) {
930
+ syncTeamMemberTabs(SpreadsheetApp.getActiveSpreadsheet(), headers);
931
+ }
932
+ }
933
+
934
+ function getColLetter(colIndex) {
935
+ var temp, letter = '';
936
+ while (colIndex > 0) {
937
+ temp = (colIndex - 1) % 26;
938
+ letter = String.fromCharCode(temp + 65) + letter;
939
+ colIndex = Math.floor((colIndex - temp - 1) / 26);
940
+ }
941
+ return letter;
942
+ }
943
+
944
+ function capitalize(str) {
945
+ if (!str) return '';
946
+ var clean = str.replace(/([A-Z])/g, ' $1').replace(/[-_]/g, ' ').trim();
947
+ return clean.charAt(0).toUpperCase() + clean.slice(1);
948
+ }