One-time setup · ~10 minutes
What this sets up
Paste one script into Google Apps Script and run it once. It automatically creates two sheets: Raw Data (every audit submission lands here as a new row) and Dashboard (auto-calculated summary with scores by country, brand, CM, grade distribution, and recent audits — updates live as new data comes in).
1
Create a new Google Sheet

Go to sheets.google.com → create a blank spreadsheet → name it Retail Distribution Audit Results.

Leave it empty — the script in Step 3 will create both tabs and all headers automatically.

⚠ Keep this sheet separate from the TC Direct Store Audit sheet. They each need their own Apps Script deployment.
2
Open Apps Script

In your new Google Sheet: Extensions → Apps Script

Delete any existing code in the editor. Then paste the full script below.

3
Paste the full script

This single script handles everything: receiving audit data, setting up both sheets, and building the Dashboard formulas.

// ─────────────────────────────────────────────────────────────────
// RETAIL DISTRIBUTION AUDIT — Google Apps Script
// Paste into Extensions → Apps Script, Save, then run setupSheets()
// once. Then deploy as Web App for live data from the scorecard.
// ─────────────────────────────────────────────────────────────────

const RAW_SHEET  = "Raw Data";
const DASH_SHEET = "Dashboard";

// ── RECEIVE AUDIT DATA (called by scorecard Submit button) ────────
function doPost(e) {
  try {
    var ss   = SpreadsheetApp.getActiveSpreadsheet();
    var data = JSON.parse(e.postData.contents);

    // ── PHOTO UPLOAD ──────────────────────────────────────────────
    if (data.action === 'uploadPhoto') {
      try {
        // Log the attempt to a debug sheet so we can see what's arriving
        var dbg = ss.getSheetByName('Debug') || ss.insertSheet('Debug');
        if (dbg.getLastRow() === 0) dbg.appendRow(['Time','Action','Store','Section','DataSize','Status','Error']);
        var imgSize = data.imageData ? data.imageData.length : 0;
        
        var folder = getOrCreateFolder(data.store, data.date, data.section, data.country || 'SG');
        var blob   = Utilities.newBlob(
          Utilities.base64Decode(data.imageData),
          data.mimeType || 'image/jpeg',
          data.fileName || 'photo.jpg'
        );
        var file = folder.createFile(blob);
        file.setDescription(data.caption || '');
        file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
        
        dbg.appendRow([new Date().toISOString(), 'uploadPhoto', data.store, data.section, imgSize, 'SUCCESS', '']);
        
        return ContentService
          .createTextOutput(JSON.stringify({ status:'success', driveUrl: file.getUrl() }))
          .setMimeType(ContentService.MimeType.JSON);
      } catch(uploadErr) {
        var dbg2 = ss.getSheetByName('Debug') || ss.insertSheet('Debug');
        dbg2.appendRow([new Date().toISOString(), 'uploadPhoto', data.store || '', data.section || '', 
          data.imageData ? data.imageData.length : 0, 'FAILED', uploadErr.message]);
        return ContentService
          .createTextOutput(JSON.stringify({ status:'error', message: uploadErr.message }))
          .setMimeType(ContentService.MimeType.JSON);
      }
    }

    // ── SAVE COMMENT ─────────────────────────────────────────────
    if (data.action === 'saveComment') {
      var cSheet = ss.getSheetByName('Comments') || ss.insertSheet('Comments');
      if (cSheet.getLastRow() === 0) {
        cSheet.appendRow(['Timestamp','AuditKey','Store','Date','Section','CriterionID','Comment','Author','Resolved','ReplyTo']);
      }
      cSheet.appendRow([
        new Date().toISOString(),
        data.auditKey, data.store, data.auditDate,
        data.section, data.criterionId,
        data.comment, data.author,
        data.resolved || false,
        data.replyTo || ''
      ]);
      return ContentService
        .createTextOutput(JSON.stringify({ status:'success' }))
        .setMimeType(ContentService.MimeType.JSON);
    }

    // ── RESOLVE COMMENT ──────────────────────────────────────────
    if (data.action === 'resolveComment') {
      var cSheet = ss.getSheetByName('Comments');
      if (cSheet) {
        var rows = cSheet.getDataRange().getValues();
        for (var i = 1; i < rows.length; i++) {
          if (rows[i][1] === data.auditKey && rows[i][5] === data.criterionId && rows[i][7] === data.author) {
            cSheet.getRange(i+1, 9).setValue(true);
          }
        }
      }
      return ContentService
        .createTextOutput(JSON.stringify({ status:'success' }))
        .setMimeType(ContentService.MimeType.JSON);
    }

    // ── LOG PHOTO SUBMISSION (from CM upload page) ────────────────
    if (data.action === 'logPhotoSubmission') {
      var pLog = ss.getSheetByName('Photo Log') || ss.insertSheet('Photo Log');
      if (pLog.getLastRow() === 0) {
        pLog.appendRow(['Timestamp','Store','Date','Country','CM Name','Total Photos','Sec A','Sec B','Sec C','Sec D','Sec E','Sec F','Notes A','Notes B','Notes C','Notes D','Notes E','Notes F']);
      }
      var secs = data.sections || {};
      var notes = data.sectionNotes || {};
      pLog.appendRow([
        new Date().toISOString(), data.store, data.date, data.country, data.cmName,
        data.totalPhotos, secs.A||0, secs.B||0, secs.C||0, secs.D||0, secs.E||0, secs.F||0,
        notes.A||'', notes.B||'', notes.C||'', notes.D||'', notes.E||'', notes.F||''
      ]);
      return ContentService
        .createTextOutput(JSON.stringify({ status:'success' }))
        .setMimeType(ContentService.MimeType.JSON);
    }
    var sheet = ss.getSheetByName(RAW_SHEET) || ss.insertSheet(RAW_SHEET);
    var headers = ['Timestamp','Country','Store','Brand','Tier','Channel Manager','Auditor','Audit Date','Type',
      'Sec A %','Sec B %','Sec C %','Sec D %','Sec E %','Sec F %','Overall %','Grade','Passed','Total','Failed Items','Actions'];
    if (sheet.getLastRow() === 0) {
      sheet.appendRow(headers);
      sheet.getRange(1,1,1,headers.length).setBackground('#1a1a1a').setFontColor('#C8A84B').setFontWeight('bold');
      sheet.setFrozenRows(1);
    }
    var rowData = [
      new Date().toISOString(),
      data.countryLabel || data.country,
      data.store, data.brand, data.tier, data.cm,
      data.auditor, data.date, data.type,
      data.scoreA ?? '', data.scoreB ?? '', data.scoreC ?? '',
      data.scoreD ?? '', data.scoreE ?? '', data.scoreF ?? '',
      data.overall, data.grade, data.passed, data.total,
      data.failedItems, data.actions
    ];
    sheet.appendRow(rowData);

    // ── ALSO WRITE TO COUNTRY TAB ──────────────────────────────
    var countryCode = data.country || 'Other';
    var countryNames = { SG:'Singapore', MY:'Malaysia', HK:'Hong Kong', TH:'Thailand' };
    var countryTabName = countryNames[countryCode] || countryCode;
    var cSheet = ss.getSheetByName(countryTabName);
    if (!cSheet) {
      cSheet = ss.insertSheet(countryTabName);
      cSheet.appendRow(headers);
      cSheet.getRange(1,1,1,headers.length).setBackground('#1a1a1a').setFontColor('#C8A84B').setFontWeight('bold');
      cSheet.setFrozenRows(1);
    }
    cSheet.appendRow(rowData);

    // ── ALSO WRITE TO CM TAB ───────────────────────────────────
    if (data.cm && data.cm !== '—') {
      var cmTabName = 'CM — ' + data.cm;
      var cmSheet = ss.getSheetByName(cmTabName);
      if (!cmSheet) {
        cmSheet = ss.insertSheet(cmTabName);
        cmSheet.appendRow(headers);
        cmSheet.getRange(1,1,1,headers.length).setBackground('#1a1a1a').setFontColor('#C8A84B').setFontWeight('bold');
        cmSheet.setFrozenRows(1);
      }
      cmSheet.appendRow(rowData);
    }

    return ContentService
      .createTextOutput(JSON.stringify({ status:'success' }))
      .setMimeType(ContentService.MimeType.JSON);

  } catch(err) {
    return ContentService
      .createTextOutput(JSON.stringify({ status:'error', message: err.message }))
      .setMimeType(ContentService.MimeType.JSON);
  }
}

function doGet(e) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();

  // ── SEND TELEGRAM REPORT ─────────────────────────────────────
  if (e && e.parameter && e.parameter.action === 'sendReport') {
    return handleSendReport(e);
  }

  // ── RETURN AUDIT SCORES ───────────────────────────────────────
  if (e && e.parameter && e.parameter.action === 'getScores') {
    var sheet = ss.getSheetByName(RAW_SHEET);
    if (!sheet) return ContentService.createTextOutput('[]').setMimeType(ContentService.MimeType.JSON);
    var rows = sheet.getDataRange().getValues();
    var headers = rows[0];
    var data = rows.slice(1).map(function(row) {
      var obj = {};
      headers.forEach(function(h, i) { obj[h] = row[i]; });
      return {
        store: obj['Store'] || '', country: obj['Country'] || '',
        brand: obj['Brand'] || '', tier: obj['Tier'] || '',
        cm: obj['Channel Manager'] || '', auditor: obj['Auditor'] || '',
        date: obj['Audit Date'] ? Utilities.formatDate(new Date(obj['Audit Date']), 'GMT+8', 'yyyy-MM-dd') : '',
        overall: obj['Overall %'] || null, grade: obj['Grade'] || '',
        scoreA: obj['Sec A %'] || null, scoreB: obj['Sec B %'] || null,
        scoreC: obj['Sec C %'] || null, scoreD: obj['Sec D %'] || null,
        scoreE: obj['Sec E %'] || null, scoreF: obj['Sec F %'] || null,
      };
    }).filter(function(r) { return r.store; });
    return ContentService
      .createTextOutput(JSON.stringify(data))
      .setMimeType(ContentService.MimeType.JSON);
  }

  // ── RETURN PHOTOS FOR AN AUDIT ───────────────────────────────
  if (e && e.parameter && e.parameter.action === 'getPhotos') {
    var photos = [];
    var key = e.parameter.auditKey || '';
    var section = e.parameter.section || '';
    // Parse store and date from auditKey (format: store|date)
    var parts = key.split('|');
    var filterStore = parts[0] || '';
    var filterDate  = parts[1] || '';
    // Search Drive folder: Retail Distribution Audits / store / date /
    try {
      var countryId = (e.parameter.country && COUNTRY_FOLDERS[e.parameter.country])
        ? COUNTRY_FOLDERS[e.parameter.country]
        : null;
      var searchRoots = countryId
        ? [DriveApp.getFolderById(countryId)]
        : Object.values(COUNTRY_FOLDERS).map(function(id) { try { return DriveApp.getFolderById(id); } catch(e) { return null; }}).filter(Boolean);

      searchRoots.forEach(function(countryFolder) {
        var yearFolders = countryFolder.getFolders();
        while (yearFolders.hasNext()) {
          var yf = yearFolders.next();
          var storeFolders2 = filterStore ? yf.getFoldersByName(filterStore) : yf.getFolders();
          while (storeFolders2.hasNext()) {
            var sf = storeFolders2.next();
            var weekFolders = sf.getFolders();
            while (weekFolders.hasNext()) {
              var wf = weekFolders.next();
              // Only search this week folder if date matches (week label contains date approx)
              var secFolders = section ? wf.getFoldersByName('Section ' + section) : wf.getFolders();
              while (secFolders.hasNext()) {
                var secF = secFolders.next();
                var secId = secF.getName().replace('Section ','');
                var files = secF.getFiles();
                while (files.hasNext()) {
                  var file = files.next();
                  // Filter by date in filename if provided
                  if (!filterDate || file.getName().indexOf(filterDate) !== -1 || wf.getName().indexOf(filterDate.substring(0,7)) !== -1) {
                    photos.push({
                      section: secId,
                      fileName: file.getName(),
                      driveUrl: file.getDownloadUrl(),
                      viewUrl: file.getUrl(),
                      caption: file.getDescription() || '',
                      auditKey: key,
                    });
                  }
                }
              }
            }
          }
        }
      });
    } catch(err) {}
    return ContentService
      .createTextOutput(JSON.stringify(photos))
      .setMimeType(ContentService.MimeType.JSON);
  }
  if (e && e.parameter && e.parameter.action === 'getComments') {
    var cSheet = ss.getSheetByName('Comments');
    if (!cSheet) return ContentService.createTextOutput('[]').setMimeType(ContentService.MimeType.JSON);
    var rows = cSheet.getDataRange().getValues();
    var key = e.parameter.auditKey;
    var comments = rows.slice(1)
      .filter(function(r) { return !key || r[1] === key; })
      .map(function(r) { return {
        timestamp: r[0], auditKey: r[1], store: r[2], auditDate: r[3],
        section: r[4], criterionId: r[5], comment: r[6],
        author: r[7], resolved: r[8], replyTo: r[9]
      }; });
    return ContentService
      .createTextOutput(JSON.stringify(comments))
      .setMimeType(ContentService.MimeType.JSON);
  }

  return ContentService
    .createTextOutput("Retail Distribution Audit endpoint is live.")
    .setMimeType(ContentService.MimeType.TEXT);
}

// ── DRIVE FOLDER HELPER ───────────────────────────────────────────
// Uses existing TC Acoustic audit Drive folders
var COUNTRY_FOLDERS = {
  'SG': '15hbjnuW2yzMMTdhw4tvEfvyOiRZKyoyF',
  'MY': '18nuTsVAhBhUvDc6Cc9aqaTdOx8PSjgBw',
  'HK': '1xXqpw_csYUuuxIVeEtvKBsfwU2YpGpuB',
  'TH': '192ZJlFmK8s2FEKAQKflz9FZ06XH70cJv',
};

function getOrCreateFolder(store, date, section, country) {
  // Match existing structure: Country > Year > Store > Week > Section
  var countryFolderId = COUNTRY_FOLDERS[country] || COUNTRY_FOLDERS['SG'];
  var countryFolder   = DriveApp.getFolderById(countryFolderId);

  // Year folder (e.g. "2026")
  var year = date ? date.substring(0, 4) : new Date().getFullYear().toString();
  var yearFolders = countryFolder.getFoldersByName(year);
  var yearFolder  = yearFolders.hasNext() ? yearFolders.next() : countryFolder.createFolder(year);

  // Store folder
  var storeFolders = yearFolder.getFoldersByName(store);
  var storeFolder  = storeFolders.hasNext() ? storeFolders.next() : yearFolder.createFolder(store);

  // Week folder (e.g. "W24 - 2026-03-30")
  var weekLabel = getWeekLabel(date);
  var weekFolders = storeFolder.getFoldersByName(weekLabel);
  var weekFolder  = weekFolders.hasNext() ? weekFolders.next() : storeFolder.createFolder(weekLabel);

  // Section subfolder
  var secLabel   = 'Section ' + section;
  var secFolders = weekFolder.getFoldersByName(secLabel);
  return secFolders.hasNext() ? secFolders.next() : weekFolder.createFolder(secLabel);
}

function getWeekLabel(dateStr) {
  // Returns e.g. "W13 - 2026-03-24"
  if (!dateStr) return 'Unknown Week';
  var d    = new Date(dateStr);
  var start = new Date(d);
  start.setDate(d.getDate() - d.getDay() + 1); // Monday of that week
  var pad  = function(n) { return n < 10 ? '0' + n : '' + n; };
  var mon  = start.getFullYear() + '-' + pad(start.getMonth()+1) + '-' + pad(start.getDate());
  // Calculate week number of year
  var oneJan = new Date(d.getFullYear(), 0, 1);
  var wk = Math.ceil((((d - oneJan) / 86400000) + oneJan.getDay() + 1) / 7);
  return 'W' + pad(wk) + ' - ' + mon;
}

// ── TELEGRAM BOT ──────────────────────────────────────────────────
// Replace with your BotFather token
var TELEGRAM_TOKEN = 'YOUR_BOT_TOKEN_HERE';
var TELEGRAM_API   = 'https://api.telegram.org/bot' + TELEGRAM_TOKEN;

// Send a Telegram message to a chat ID
function sendTelegram(chatId, message) {
  var url = TELEGRAM_API + '/sendMessage';
  var payload = {
    chat_id: chatId,
    text: message,
    parse_mode: 'Markdown'
  };
  var options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };
  var response = UrlFetchApp.fetch(url, options);
  var result = JSON.parse(response.getContentText());
  return result.ok;
}

// Get CM chat ID from CM Contacts sheet
function getCMChatId(cmName) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName('CM Contacts');
  if (!sheet) return null;
  var data = sheet.getDataRange().getValues();
  for (var i = 1; i < data.length; i++) {
    if (data[i][0] === cmName) return data[i][2]; // col C = Chat ID
  }
  return null;
}

// Called from dashboard "Send Report" button
// cmName: e.g. "Johnathan"
// weekLabel: e.g. "W13 - 2026-03-24"
function sendWeeklyReport(cmName, weekLabel) {
  var ss      = SpreadsheetApp.getActiveSpreadsheet();
  var rawSheet = ss.getSheetByName('Raw Data');
  if (!rawSheet) return { success: false, error: 'No Raw Data sheet' };

  var chatId = getCMChatId(cmName);
  if (!chatId) return { success: false, error: 'No Telegram Chat ID for ' + cmName };

  // Pull all audits for this CM this week
  var rows    = rawSheet.getDataRange().getValues();
  var headers = rows[0];
  var audits  = rows.slice(1).filter(function(r) {
    return r[5] === cmName; // col F = CM
  });

  if (audits.length === 0) {
    return { success: false, error: 'No audits found for ' + cmName };
  }

  // Pull previous week's audits for comparison
  var prevWeekAudits = rows.slice(1).filter(function(r) {
    return r[5] === cmName && r[7] !== audits[0][7]; // different date
  });

  var prevByStore = {};
  prevWeekAudits.forEach(function(r) {
    if (!prevByStore[r[2]] || r[7] > prevByStore[r[2]][7]) prevByStore[r[2]] = r;
  });

  // Build message
  var totalAudits = audits.length;
  var avgScore    = Math.round(audits.reduce(function(s,r){ return s + (r[15]||0); }, 0) / totalAudits);
  var goldCount   = audits.filter(function(r){ return r[16] === 'Gold'; }).length;

  var msg = '📊 *Weekly Audit Report*\n';
  msg += '*' + cmName + '* | ' + weekLabel + '\n';
  msg += '━━━━━━━━━━━━━━━━━━━━\n\n';
  msg += '🏆 *Summary*\n';
  msg += totalAudits + ' stores audited | Avg: *' + avgScore + '%* | Gold: ' + goldCount + '/' + totalAudits + '\n\n';

  // Grade emoji
  function gradeEmoji(g) {
    return {Gold:'🥇',A:'🥈',B:'🅱️',C:'🅾️',D:'⚠️',E:'🔴'}[g] || '—';
  }

  // Per store details
  audits.forEach(function(r) {
    var store    = r[2];
    var score    = r[15];
    var grade    = r[16];
    var failed   = r[19] ? r[19].split(' | ') : [];
    var secScores = [r[9],r[10],r[11],r[12],r[13],r[14]];
    var secLabels = ['A','B','C','D','E','F'];

    // Trend vs previous week
    var trend = '';
    if (prevByStore[store]) {
      var diff = score - prevByStore[store][15];
      if (diff > 0) trend = ' ↑ +' + diff + '%';
      else if (diff < 0) trend = ' ↓ ' + diff + '%';
      else trend = ' → same';
    }

    msg += '🏪 *' + store + '*\n';
    msg += gradeEmoji(grade) + ' ' + score + '% — ' + grade + trend + '\n';

    // Section scores
    var secLine = secLabels.map(function(l,i) {
      return l + ': ' + (secScores[i] !== '' && secScores[i] !== null ? secScores[i] + '%' : '—');
    }).join(' | ');
    msg += '`' + secLine + '`\n';

    // Failed criteria
    if (failed.length > 0) {
      msg += '❌ *To improve:*\n';
      failed.slice(0,5).forEach(function(f) {
        msg += '  • ' + f.trim() + '\n';
      });
    }
    msg += '\n';
  });

  // Footer
  msg += '━━━━━━━━━━━━━━━━━━━━\n';
  msg += '_Sent from TC Retail Distribution Audit System_';

  var sent = sendTelegram(chatId, msg);
  return { success: sent, message: sent ? 'Report sent to ' + cmName : 'Failed to send' };
}

// Web App endpoint for sending report from dashboard
// Called via: doGet?action=sendReport&cm=Johnathan&week=W13%20-%202026-03-24
function handleSendReport(e) {
  var cmName    = e.parameter.cm || '';
  var weekLabel = e.parameter.week || '';
  var result    = sendWeeklyReport(cmName, weekLabel);
  return ContentService
    .createTextOutput(JSON.stringify(result))
    .setMimeType(ContentService.MimeType.JSON);
}

// ── ONE-TIME SETUP (run manually once after pasting) ──────────────
function setupSheets() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();

  // ── RAW DATA SHEET ──────────────────────────────────────────────
  let raw = ss.getSheetByName(RAW_SHEET);
  if (!raw) raw = ss.insertSheet(RAW_SHEET);
  raw.clearContents();

  const rawHeaders = [
    "Timestamp","Country","Store","Brand","Tier","Channel Manager",
    "Auditor","Audit Date","Type",
    "Sec A %","Sec B %","Sec C %","Sec D %","Sec E %","Sec F %",
    "Overall %","Grade","Items Passed","Total Items",
    "Failed Items","Action Items"
  ];
  raw.getRange(1, 1, 1, rawHeaders.length).setValues([rawHeaders]);

  // Style header row
  const rawHeaderRange = raw.getRange(1, 1, 1, rawHeaders.length);
  rawHeaderRange.setBackground("#1a1a1a").setFontColor("#C8A84B")
    .setFontWeight("bold").setFontSize(10);
  raw.setFrozenRows(1);
  raw.setColumnWidth(1, 160);  // Timestamp
  raw.setColumnWidth(2, 100);  // Country
  raw.setColumnWidth(3, 220);  // Store
  raw.setColumnWidth(4, 80);   // Brand
  raw.setColumnWidth(5, 80);   // Tier
  raw.setColumnWidth(6, 120);  // CM
  raw.setColumnWidth(7, 100);  // Auditor
  raw.setColumnWidth(8, 100);  // Date
  raw.setColumnWidth(20, 300); // Failed Items
  raw.setColumnWidth(21, 200); // Actions

  // ── DASHBOARD SHEET ─────────────────────────────────────────────
  let dash = ss.getSheetByName(DASH_SHEET);
  if (!dash) dash = ss.insertSheet(DASH_SHEET, 0); // first tab
  else dash.clearContents();

  const D = dash;
  const raw_name = RAW_SHEET;

  function h1(row, col, text) {
    D.getRange(row, col).setValue(text)
      .setFontSize(14).setFontWeight("bold").setFontColor("#C8A84B");
  }
  function h2(row, col, text, color) {
    D.getRange(row, col).setValue(text)
      .setFontSize(10).setFontWeight("bold").setFontColor(color || "#888888");
  }
  function label(row, col, text) {
    D.getRange(row, col).setValue(text).setFontColor("#666666").setFontSize(10);
  }
  function formula(row, col, f) {
    D.getRange(row, col).setFormula(f).setFontColor("#E8E0D0").setFontSize(11).setFontWeight("bold");
  }
  function sectionBg(r1, c1, r2, c2, color) {
    D.getRange(r1, c1, r2-r1+1, c2-c1+1).setBackground(color);
  }

  // Title
  D.getRange("A1").setValue("RETAIL DISTRIBUTION AUDIT — DASHBOARD")
    .setFontSize(16).setFontWeight("bold").setFontColor("#C8A84B");
  D.getRange("A2").setFormula('="Last updated: "&TEXT(NOW(),"dd mmm yyyy HH:MM")')
    .setFontSize(9).setFontColor("#555555");

  // ── KEY STATS (row 4) ──────────────────────────────────────────
  h2(4, 1, "KEY STATS");
  const statLabels = ["Total Audits","Avg Score","Gold Stores","Gold Rate","Needs Attention (D/E)"];
  const statFormulas = [
    `=COUNTA('${raw_name}'!C2:C)`,
    `=IFERROR(TEXT(AVERAGE('${raw_name}'!P2:P),"0")&"%","—")`,
    `=COUNTIF('${raw_name}'!Q2:Q,"Gold")`,
    `=IFERROR(TEXT(COUNTIF('${raw_name}'!Q2:Q,"Gold")/COUNTA('${raw_name}'!Q2:Q),"0%"),"—")`,
    `=COUNTIF('${raw_name}'!Q2:Q,"D")+COUNTIF('${raw_name}'!Q2:Q,"E")`,
  ];
  statLabels.forEach((l, i) => {
    label(5, i+1, l);
    formula(6, i+1, statFormulas[i]);
    D.getRange(6, i+1).setFontSize(16).setFontColor("#C8A84B");
  });
  sectionBg(4, 1, 6, 5, "#141414");
  D.getRange("A4:E4").setBackground("#1A1A1A");

  // ── GRADE DISTRIBUTION (row 8) ────────────────────────────────
  h2(8, 1, "GRADE DISTRIBUTION");
  const grades = ["Gold","A","B","C","D","E"];
  const gradeColors = ["#C8A84B","#7aa8b8","#6aaa62","#b8965a","#e08030","#c06060"];
  grades.forEach((g, i) => {
    D.getRange(9, i+1).setValue(g).setFontWeight("bold").setFontColor(gradeColors[i]).setFontSize(13);
    D.getRange(10, i+1).setFormula(`=COUNTIF('${raw_name}'!Q2:Q,"${g}")`)
      .setFontSize(18).setFontWeight("bold").setFontColor(gradeColors[i]);
    D.getRange(11, i+1).setFormula(`=IFERROR(TEXT(COUNTIF('${raw_name}'!Q2:Q,"${g}")/COUNTA('${raw_name}'!Q2:Q),"0%"),"—")`)
      .setFontSize(10).setFontColor("#666666");
  });
  sectionBg(8, 1, 11, 6, "#141414");
  D.getRange("A8:F8").setBackground("#1A1A1A");

  // ── BY COUNTRY (row 13) ───────────────────────────────────────
  h2(13, 1, "BY COUNTRY");
  ["Country","Audits","Avg Score","Gold","Gold Rate"].forEach((h, i) => {
    D.getRange(14, i+1).setValue(h).setFontColor("#888888").setFontSize(9).setFontWeight("bold");
  });
  const countries = ["Singapore","Malaysia","Hong Kong","Thailand"];
  countries.forEach((c, i) => {
    const r = 15 + i;
    D.getRange(r, 1).setValue(c).setFontColor("#E8E0D0").setFontSize(10);
    D.getRange(r, 2).setFormula(`=COUNTIF('${raw_name}'!B2:B,"${c}")`).setFontColor("#888888").setFontSize(10);
    D.getRange(r, 3).setFormula(`=IFERROR(TEXT(AVERAGEIF('${raw_name}'!B2:B,"${c}",'${raw_name}'!P2:P),"0")&"%","—")`).setFontColor("#C8A84B").setFontSize(11).setFontWeight("bold");
    D.getRange(r, 4).setFormula(`=COUNTIFS('${raw_name}'!B2:B,"${c}",'${raw_name}'!Q2:Q,"Gold")`).setFontColor("#C8A84B").setFontSize(10);
    D.getRange(r, 5).setFormula(`=IFERROR(TEXT(COUNTIFS('${raw_name}'!B2:B,"${c}",'${raw_name}'!Q2:Q,"Gold")/COUNTIF('${raw_name}'!B2:B,"${c}"),"0%"),"—")`).setFontColor("#888888").setFontSize(10);
  });
  sectionBg(13, 1, 19, 5, "#141414");
  D.getRange("A13:E13").setBackground("#1A1A1A");

  // ── BY BRAND (col 7, row 13) ──────────────────────────────────
  h2(13, 7, "BY BRAND");
  ["Brand","Audits","Avg Score","Gold","Gold Rate"].forEach((h, i) => {
    D.getRange(14, 7+i).setValue(h).setFontColor("#888888").setFontSize(9).setFontWeight("bold");
  });
  const brands = ["Sonos","Marshall","Bowers & Wilkins"];
  brands.forEach((b, i) => {
    const r = 15 + i;
    D.getRange(r, 7).setValue(b).setFontColor("#E8E0D0").setFontSize(10);
    D.getRange(r, 8).setFormula(`=COUNTIF('${raw_name}'!D2:D,"${b}")`).setFontColor("#888888").setFontSize(10);
    D.getRange(r, 9).setFormula(`=IFERROR(TEXT(AVERAGEIF('${raw_name}'!D2:D,"${b}",'${raw_name}'!P2:P),"0")&"%","—")`).setFontColor("#C8A84B").setFontSize(11).setFontWeight("bold");
    D.getRange(r, 10).setFormula(`=COUNTIFS('${raw_name}'!D2:D,"${b}",'${raw_name}'!Q2:Q,"Gold")`).setFontColor("#C8A84B").setFontSize(10);
    D.getRange(r, 11).setFormula(`=IFERROR(TEXT(COUNTIFS('${raw_name}'!D2:D,"${b}",'${raw_name}'!Q2:Q,"Gold")/COUNTIF('${raw_name}'!D2:D,"${b}"),"0%"),"—")`).setFontColor("#888888").setFontSize(10);
  });
  sectionBg(13, 7, 19, 11, "#141414");
  D.getRange("G13:K13").setBackground("#1A1A1A");

  // ── BY CHANNEL MANAGER (row 21) ───────────────────────────────
  h2(21, 1, "BY CHANNEL MANAGER");
  ["Channel Manager","Country","Audits","Avg Score","Gold","Gold Rate"].forEach((h, i) => {
    D.getRange(22, i+1).setValue(h).setFontColor("#888888").setFontSize(9).setFontWeight("bold");
  });
  const cms = [
    ["Zhi Yong","Singapore"],["Johnathan","Singapore"],["Jerome","Singapore"],
    ["Goh Xinyi","Singapore"],["Sinclair","Singapore"],
    ["Adrian","Malaysia"],["Jin Nong","Malaysia"],
    ["Ginger Kiang","Hong Kong"],["Ricky","Hong Kong"],
    ["Ning","Thailand"],
  ];
  cms.forEach(([cm, country], i) => {
    const r = 23 + i;
    D.getRange(r, 1).setValue(cm).setFontColor("#E8E0D0").setFontSize(10);
    D.getRange(r, 2).setValue(country).setFontColor("#666666").setFontSize(10);
    D.getRange(r, 3).setFormula(`=COUNTIF('${raw_name}'!F2:F,"${cm}")`).setFontColor("#888888").setFontSize(10);
    D.getRange(r, 4).setFormula(`=IFERROR(TEXT(AVERAGEIF('${raw_name}'!F2:F,"${cm}",'${raw_name}'!P2:P),"0")&"%","—")`).setFontColor("#C8A84B").setFontSize(11).setFontWeight("bold");
    D.getRange(r, 5).setFormula(`=COUNTIFS('${raw_name}'!F2:F,"${cm}",'${raw_name}'!Q2:Q,"Gold")`).setFontColor("#C8A84B").setFontSize(10);
    D.getRange(r, 6).setFormula(`=IFERROR(TEXT(COUNTIFS('${raw_name}'!F2:F,"${cm}",'${raw_name}'!Q2:Q,"Gold")/COUNTIF('${raw_name}'!F2:F,"${cm}"),"0%"),"—")`).setFontColor("#888888").setFontSize(10);
  });
  sectionBg(21, 1, 33, 6, "#141414");
  D.getRange("A21:F21").setBackground("#1A1A1A");

  // ── SECTION AVERAGES (row 21, col 8) ─────────────────────────
  h2(21, 8, "SECTION AVERAGES (all audits)");
  const secCols = [["A",10],["B",11],["C",12],["D",13],["E",14],["F",15]];
  const secNames = ["Assets & Condition","Brand Identity","Cleanliness","Conquer & Visibility","Marketing Execution","Demo & Experience"];
  ["Section","Name","Avg %"].forEach((h, i) => {
    D.getRange(22, 8+i).setValue(h).setFontColor("#888888").setFontSize(9).setFontWeight("bold");
  });
  secCols.forEach(([sec, colIdx], i) => {
    const r = 23 + i;
    const colLetter = String.fromCharCode(64 + colIdx);
    D.getRange(r, 8).setValue(sec).setFontColor("#E8E0D0").setFontSize(10).setFontWeight("bold");
    D.getRange(r, 9).setValue(secNames[i]).setFontColor("#666666").setFontSize(10);
    D.getRange(r, 10).setFormula(`=IFERROR(TEXT(AVERAGE('${raw_name}'!${colLetter}2:${colLetter}),"0")&"%","—")`).setFontColor("#C8A84B").setFontSize(11).setFontWeight("bold");
  });
  sectionBg(21, 8, 29, 10, "#141414");
  D.getRange("H21:J21").setBackground("#1A1A1A");

  // ── RECENT AUDITS (row 35) ────────────────────────────────────
  h2(35, 1, "RECENT AUDITS (latest 50, auto-sorted newest first)");
  D.getRange("A35:U35").setBackground("#1A1A1A");
  const recentHeaders = ["Date","Country","Store","Brand","Tier","CM","Auditor","Overall %","Grade","A%","B%","C%","D%","E%","F%","Passed","Total","Failed Items","Actions"];
  recentHeaders.forEach((h, i) => {
    D.getRange(36, i+1).setValue(h).setFontColor("#888888").setFontSize(9).setFontWeight("bold");
  });
  // QUERY to pull latest 50, sorted by timestamp desc
  D.getRange("A37").setFormula(
    `=IFERROR(QUERY('${raw_name}'!A2:U,"SELECT H,B,C,D,E,F,G,P,Q,J,K,L,M,N,O,R,S,T,U ORDER BY A DESC LIMIT 50",0),"No data yet")`
  ).setFontColor("#E8E0D0").setFontSize(10);
  sectionBg(35, 1, 35, 19, "#1A1A1A");

  // Column widths for dashboard
  D.setColumnWidth(1, 160);
  D.setColumnWidth(2, 100);
  D.setColumnWidth(3, 220);
  D.setColumnWidth(4, 90);
  D.setColumnWidth(5, 80);
  D.setColumnWidth(6, 120);
  D.setColumnWidth(7, 10);
  D.setColumnWidth(8, 60);
  D.setColumnWidth(9, 200);
  D.setColumnWidth(10, 60);

  // Freeze first row of recent table
  D.setFrozenRows(1);

  // ── CM CONTACTS SHEET ──────────────────────────────────────────
  var contacts = ss.getSheetByName('CM Contacts') || ss.insertSheet('CM Contacts');
  contacts.clearContents();
  contacts.getRange(1,1,1,4).setValues([['CM Name','Country','Telegram Chat ID','Email']]);
  contacts.getRange(1,1,1,4).setBackground('#1a1a1a').setFontColor('#C8A84B').setFontWeight('bold');
  // Pre-fill CM names
  var cmList = [
    ['Sylvia','SG','',''],
    ['Zhi Yong','SG','',''],
    ['Johnathan','SG','',''],
    ['Jerome','SG','',''],
    ['Goh Xinyi','SG','',''],
    ['Sinclair','SG','',''],
    ['Adrian','MY','',''],
    ['Jin Nong','MY','',''],
    ['Ginger Kiang','HK','',''],
    ['Ricky','HK','',''],
    ['Ning','TH','',''],
  ];
  contacts.getRange(2,1,cmList.length,4).setValues(cmList);
  contacts.setColumnWidth(1,140);
  contacts.setColumnWidth(3,180);
  contacts.setFrozenRows(1);
  ss.setActiveSheet(dash);

  SpreadsheetApp.getUi().alert("✓ Setup complete! Both sheets are ready.\n\nNext: Deploy this script as a Web App and paste the URL into the scorecard Setup tab.");
}
✓ After pasting, click Save (the floppy disk icon or Ctrl+S)
4
Run the setup function once

In Apps Script, click the Run button (▶) at the top — make sure the function dropdown next to it shows setupSheets.

Google will ask for permissions — click Review permissions → Allow.

You'll see a popup confirming setup is complete. Your Google Sheet will now have two tabs:

📊 Dashboard
📥 Raw Data
Dashboard auto-calculates from Raw Data · Raw Data receives one row per submitted audit
You only need to run setupSheets() once. After that, data flows in automatically every time you submit an audit.
5
Deploy as Web App

Deploy → New deployment → click the gear ⚙ → Web app

Set the following:

Execute as: Me
Who has access: Anyone

Click Deploy → authorize when prompted → copy the Web App URL.

✓ The URL starts with: https://script.google.com/macros/s/...
6
Connect to the scorecard

Open the Retail Distribution Audit Scorecard → click ⚙ Setup tab → paste the Web App URL → click Test Connection.

You're done. Every time you complete an audit and hit Submit Audit ✓, the result automatically appears as a new row in Raw Data — and the Dashboard updates instantly.

✓ Share the Google Sheet with your channel managers in view-only mode so they can track their own scores on the Dashboard tab.
What the Dashboard looks like
The Dashboard tab contains these live-updating sections:
Key Stats
Total Audits · Avg Score · Gold Count · Gold Rate · Needs Attention
Grade Distribution
Count + % for Gold / A / B / C / D / E
By Country
SG / MY / HK / TH → Audits, Avg Score, Gold count, Gold rate
By Brand
Sonos / Marshall / B&W → Audits, Avg Score, Gold, Gold rate
By Channel Manager
All 10 CMs → Audits, Avg Score, Gold count, Gold rate
Section Averages
Average % for A / B / C / D / E / F across all audits
Recent Audits table (bottom) — auto-sorted newest first, shows last 50 audits with date, store, brand, CM, score, grade, and all section scores. Pulls live from Raw Data using QUERY formula — no manual refresh needed.