Tutorial Apps Script

Como Criar um Mapa de Clientes no Google Planilhas com Geolocalização

Nesta aula prática, vamos construir uma Interface de Cadastro de Clientes integrada a um Mapa Interativo de página única (SPA). Você vai aprender a utilizar a biblioteca do Leaflet (que é totalmente leve e gratuita) para plotar pins azuis na tela a partir dos dados da sua planilha, funcionando de forma responsiva no celular e computador.

O nosso maior diferencial técnico será mostrar como o Apps Script faz o trabalho de geolocalização de forma automática nos bastidores: seja decodificando o endereço por extenso através do Geocoder do Google, ou extraindo as coordenadas lat/lng de um link do Google Maps usando expressões regulares (Regex).

 O que você vai aprender:

  • Banco de Dados Inteligente (setupSheet): Como programar o script para criar a aba de clientes e os cabeçalhos sozinho na sua primeira execução.
  • Integração de Mapas com Leaflet: Como carregar e configurar o mapa de forma leve, gratuita e perfeitamente adaptada a telas de smartphones.
  • Geolocalização Automática por Texto: O uso do geocodificador nativo do Google Apps Script para transformar endereços comuns em coordenadas numéricas de GPS.
  • Descompactação de URLs (Regex): A engenharia necessária para expandir links encurtados de mapas e “pescar” a latitude e longitude embutidas na URL.

const SHEET_NAME = 'Clientes';
const HEADERS = ['ID_Cliente', 'Nome', 'Telefone', 'Endereco', 'Latitude', 'Longitude'];

/**
 * Ponto de entrada leve.
 */
function doGet(e) {
  return HtmlService.createTemplateFromFile('Index')
    .evaluate()
    .addMetaTag('viewport', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no')
    .setTitle('Gestão de Clientes & Mapa');
}

/**
 * Cria a aba e os cabeçalhos se não existirem. Executada apenas nas gravações.
 */
function setupSheet() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = ss.getSheetByName(SHEET_NAME);
  if (!sheet) {
    sheet = ss.insertSheet(SHEET_NAME);
    sheet.appendRow(HEADERS);
    sheet.setFrozenRows(1);
  }
  return sheet;
}

/**
 * Extrai as coordenadas reais do cliente analisando a URL final.
 * Utiliza o método de redirecionamento de cabeçalho extremamente rápido e imune a consentimentos.
 * O Regex foi otimizado para pescar qualquer par de coordenadas decimais na URL (seja sob /search/, /place/ ou @).
 */
function extrairCoordenadasDeLink(url) {
  try {
    let targetUrl = url;

    // Se for um link encurtado, expande capturando o cabeçalho Location (rápido e imune a captchas)
    if (url.includes("maps.app.goo.gl") || url.includes("goo.gl/maps") || url.includes("g.co")) {
      const response = UrlFetchApp.fetch(url, {followRedirects: false, muteHttpExceptions: true});
      const headers = response.getHeaders();
      const location = headers['Location'] || headers['location'];
      if (location) {
        targetUrl = decodeURIComponent(location);
      } else {
        return { error: "Não foi possível descompactar o link curto fornecido." };
      }
    }

    Logger.log("Analisando URL final expandida: " + targetUrl);

    let match = targetUrl.match(/([-]?\d+\.\d+),\s*\+?([-]?\d+\.\d+)/);
    if (match) {
      const lat = parseFloat(match[1]);
      const lng = parseFloat(match[2]);
      
      if (!(Math.abs(lat - 37.0625) < 0.001 && Math.abs(lng - -95.677068) < 0.001)) {
        Logger.log("✅ Coordenadas extraídas com sucesso: " + lat + ", " + lng);
        return { lat: lat, lng: lng };
      } else {
        Logger.log("Aviso: Par de coordenadas padrão dos EUA ignorado.");
      }
    }
    
    match = targetUrl.match(/\/place\/([^/]+)/);
    if (match && match[1] && match[1].trim() !== "") {
      const enderecoTexto = match[1].replace(/\+/g, " "); // Limpa os caracteres "+"
      Logger.log("Geocodificando nome do local extraído: " + enderecoTexto);
      
      const geoResponse = Maps.newGeocoder().geocode(enderecoTexto);
      if (geoResponse.status === 'OK' && geoResponse.results.length > 0) {
        const loc = geoResponse.results[0].geometry.location;
        Logger.log("✅ Coordenadas obtidas via fallback de texto: " + loc.lat + ", " + loc.lng);
        return { lat: loc.lat, lng: loc.lng };
      }
    }

  } catch (e) {
    return { error: "Erro de rede (UrlFetchApp): " + e.message };
  }
  return null;
}

/**
 * Salva o cliente e gerencia as coordenadas com retorno inteligente de erros.
 */
function salvarCliente(clienteData) {
  try {
    const sheet = setupSheet();
    const idCliente = "CLI_" + new Date().getTime();
    
    let lat = "";
    let lng = "";
    let enderecoFinal = "";

    if (clienteData.MapsLink && clienteData.MapsLink.trim() !== "") {
      const result = extrairCoordenadasDeLink(clienteData.MapsLink);
      
      if (result && result.error) {
        return { success: false, error: result.error };
      }
      
      if (result && result.lat) {
        lat = result.lat;
        lng = result.lng;
        enderecoFinal = clienteData.MapsLink;
      } else {
        return { success: false, error: "Não conseguimos extrair as coordenadas desse link. Verifique o link ou preencha o endereço por extenso." };
      }
    } else {
      const logradouro = clienteData.Logradouro || "";
      const bairro = clienteData.Bairro || "";
      const cidade = clienteData.Cidade || "";
      const estado = clienteData.Estado || "";
      
      enderecoFinal = `${logradouro}, ${bairro} - ${cidade}, ${estado}, Brasil`;
      
      try {
        const response = Maps.newGeocoder().geocode(enderecoFinal);
        if (response.status === 'OK' && response.results.length > 0) {
          lat = response.results[0].geometry.location.lat;
          lng = response.results[0].geometry.location.lng;
        }
      } catch (geoError) {
        Logger.log("Erro na API do Geocoder: " + geoError.message);
      }
    }

    sheet.appendRow([idCliente, clienteData.Nome, clienteData.Telefone, enderecoFinal, lat, lng]);
    return { success: true };
    
  } catch (e) {
    return { success: false, error: "Erro geral no salvamento: " + e.message };
  }
}

/**
 * Busca a base de clientes cadastrados.
 */
function obterClientes() {
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = ss.getSheetByName(SHEET_NAME);
    if (!sheet) return [];
    
    const lastRow = sheet.getLastRow();
    if (lastRow <= 1) return [];
    
    const data = sheet.getRange(2, 1, lastRow - 1, HEADERS.length).getValues();
    return data.map(row => ({
      id: row[0],
      nome: row[1],
      telefone: row[2],
      endereco: row[3],
      lat: row[4],
      lng: row[5]
    }));
  } catch (e) {
    Logger.log("Erro ao carregar clientes: " + e.message);
    return [];
  }
}
<!DOCTYPE html>
<html>

<head>
  <base target="_top">
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Clientes & Mapa</title>

  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
  <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
  <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap"
    rel="stylesheet">

  <style>
    :root {
      --primary: #4f46e5;
      --primary-hover: #4338ca;
      --background: #f8fafc;
      --surface: #ffffff;
      --text: #0f172a;
      --text-muted: #64748b;
      --border: #e2e8f0;
      --nav-bg: #1e293b;
      --radius: 12px;
      --shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
    }

    * {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
    }

    body {
      font-family: 'Plus Jakarta Sans', sans-serif;
      background-color: var(--background);
      color: var(--text);
      display: flex;
      flex-direction: column;
      height: 100vh;
      overflow: hidden;
    }

    header {
      background-color: var(--nav-bg);
      color: white;
      padding: 0 1.5rem;
      height: 64px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      z-index: 1000;
      box-shadow: var(--shadow);
    }

    .logo {
      display: flex;
      align-items: center;
      gap: 0.5rem;
      font-weight: 700;
      font-size: 1.1rem;
    }

    .nav-buttons {
      display: flex;
      gap: 0.5rem;
    }

    .nav-btn {
      background: transparent;
      border: none;
      color: #94a3b8;
      padding: 0.5rem 1rem;
      border-radius: 9999px;
      font-weight: 600;
      font-size: 0.9rem;
      cursor: pointer;
      display: flex;
      align-items: center;
      gap: 0.25rem;
      transition: all 0.2s ease;
    }

    .nav-btn:hover {
      color: white;
      background-color: rgba(255, 255, 255, 0.1);
    }

    .nav-btn.active {
      color: white;
      background-color: var(--primary);
    }

    main {
      margin-top: 64px;
      flex-grow: 1;
      height: calc(100vh - 64px);
      position: relative;
    }

    .section {
      display: none;
      height: 100%;
      padding: 1.5rem;
      overflow-y: auto;
    }

    /* Animação de transição suave para as telas (SPA) */
    @keyframes sectionFadeIn {
      from {
        opacity: 0;
        transform: translateY(8px);
      }

      to {
        opacity: 1;
        transform: translateY(0);
      }
    }

    .section.active {
      display: flex;
      flex-direction: column;
      animation: sectionFadeIn 0.45s cubic-bezier(0.16, 1, 0.3, 1) forwards;
    }

    .section-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 1.5rem;
    }

    .section-header h2 {
      font-size: 1.5rem;
      font-weight: 700;
    }

    .btn {
      background-color: var(--primary);
      color: white;
      border: none;
      padding: 0.6rem 1.2rem;
      border-radius: var(--radius);
      font-weight: 600;
      font-size: 0.9rem;
      cursor: pointer;
      display: inline-flex;
      align-items: center;
      gap: 0.5rem;
      transition: background 0.2s ease, transform 0.1s ease;
    }

    .btn:hover {
      background-color: var(--primary-hover);
    }

    .btn:active {
      transform: scale(0.98);
    }

    .cards-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
      gap: 1rem;
    }

    .card {
      background-color: var(--surface);
      border: 1px solid var(--border);
      border-radius: var(--radius);
      padding: 1.25rem;
      box-shadow: var(--shadow);
      display: flex;
      flex-direction: column;
      gap: 0.5rem;
      transition: transform 0.2s ease, box-shadow 0.2s ease;
    }

    .card:hover {
      transform: translateY(-2px);
      box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
    }

    .card-name {
      font-size: 1.1rem;
      font-weight: 600;
    }

    .card-info {
      display: flex;
      align-items: center;
      gap: 0.5rem;
      font-size: 0.85rem;
      color: var(--text-muted);
    }

    .card-info .material-icons {
      font-size: 1rem;
    }

    .card-link {
      color: var(--primary);
      text-decoration: none;
      font-weight: 500;
    }

    .card-link:hover {
      text-decoration: underline;
    }

    #map {
      width: 100%;
      height: 100%;
      border-radius: var(--radius);
      border: 1px solid var(--border);
    }

    /* Modais */
    .modal-backdrop {
      display: none;
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background-color: rgba(15, 23, 42, 0.6);
      z-index: 2000;
      justify-content: center;
      align-items: center;
      padding: 1rem;
    }

    .modal-backdrop.open {
      display: flex;
    }

    .modal-content {
      background-color: var(--surface);
      border-radius: var(--radius);
      width: 100%;
      max-width: 500px;
      box-shadow: var(--shadow);
      overflow: hidden;
      animation: modalSlide 0.3s cubic-bezier(0.16, 1, 0.3, 1);
    }

    @keyframes modalSlide {
      from {
        transform: translateY(10px);
        opacity: 0;
      }

      to {
        transform: translateY(0);
        opacity: 1;
      }
    }

    .modal-header {
      padding: 1.25rem;
      border-bottom: 1px solid var(--border);
      display: flex;
      justify-content: space-between;
      align-items: center;
    }

    .modal-header h3 {
      font-size: 1.2rem;
      font-weight: 700;
    }

    .close-btn {
      background: transparent;
      border: none;
      cursor: pointer;
      color: var(--text-muted);
    }

    .modal-body {
      padding: 1.25rem;
      max-height: 70vh;
      overflow-y: auto;
    }

    .form-group {
      margin-bottom: 1rem;
    }

    .form-group label {
      display: block;
      margin-bottom: 0.35rem;
      font-weight: 600;
      font-size: 0.85rem;
    }

    .form-group input {
      width: 100%;
      padding: 0.6rem;
      border: 1px solid var(--border);
      border-radius: 6px;
      font-family: inherit;
      font-size: 0.9rem;
      outline: none;
      transition: border 0.2s;
    }

    .form-group input:focus {
      border-color: var(--primary);
    }

    .form-group input:disabled {
      background-color: #f1f5f9;
      cursor: not-allowed;
    }

    .divider-or {
      text-align: center;
      margin: 1.5rem 0;
      font-weight: 700;
      font-size: 0.75rem;
      color: var(--text-muted);
      position: relative;
    }

    .divider-or span {
      background: var(--surface);
      padding: 0 12px;
      position: relative;
      z-index: 2;
    }

    .divider-or::after {
      content: "";
      position: absolute;
      top: 50%;
      left: 0;
      width: 100%;
      border-top: 1px dashed var(--border);
      z-index: 1;
    }

    .modal-footer {
      padding: 1rem 1.25rem;
      background-color: #f8fafc;
      border-top: 1px solid var(--border);
      display: flex;
      justify-content: flex-end;
      gap: 0.5rem;
    }

    .loader {
      display: none;
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(15, 23, 42, 0.4);
      z-index: 3000;
      justify-content: center;
      align-items: center;
    }

    .loader.active {
      display: flex;
    }

    .spinner {
      width: 40px;
      height: 40px;
      border: 4px solid rgba(255, 255, 255, 0.3);
      border-left-color: white;
      border-radius: 50%;
      animation: spin 0.8s linear infinite;
    }

    @keyframes spin {
      to {
        transform: rotate(360deg);
      }
    }

    .toast {
      position: fixed;
      bottom: 20px;
      left: 50%;
      transform: translateX(-50%) translateY(100px);
      background-color: #0f172a;
      color: white;
      padding: 0.75rem 1.5rem;
      border-radius: 9999px;
      z-index: 4000;
      font-weight: 500;
      font-size: 0.9rem;
      box-shadow: var(--shadow);
      opacity: 0;
      transition: all 0.3s ease;
    }

    .toast.show {
      transform: translateX(-50%) translateY(0);
      opacity: 1;
    }

    .custom-popup .leaflet-popup-content-wrapper {
      border-radius: 8px;
      font-family: 'Plus Jakarta Sans', sans-serif;
    }

    .custom-popup-title {
      font-weight: 700;
      font-size: 1rem;
      margin-bottom: 0.25rem;
    }

    .custom-popup-phone {
      color: var(--text-muted);
      font-size: 0.8rem;
      margin-bottom: 0.5rem;
    }

    .whatsapp-btn {
      display: inline-flex;
      align-items: center;
      gap: 0.25rem;
      color: #10b981;
      text-decoration: none;
      font-weight: 600;
      font-size: 0.85rem;
    }

    .whatsapp-btn:hover {
      text-decoration: underline;
    }
  </style>
</head>

<body>

  <header>
    <div class="logo">
      <span class="material-icons" style="color: var(--primary);">pin_drop</span>
      <span>GeoClientes</span>
    </div>
    <nav class="nav-buttons">
      <button class="nav-btn active" id="btn-tab-clientes" onclick="switchSection('clientes')">
        <span class="material-icons">people</span> Clientes
      </button>
      <button class="nav-btn" id="btn-tab-mapa" onclick="switchSection('mapa')">
        <span class="material-icons">map</span> Mapa
      </button>
    </nav>
  </header>

  <main>
    <section id="section-clientes" class="section active">
      <div class="section-header">
        <h2>Clientes Cadastrados</h2>
        <button class="btn" onclick="openModal()">
          <span class="material-icons">add</span> Novo Cliente
        </button>
      </div>
      <div class="cards-grid" id="clientes-list"></div>
    </section>

    <section id="section-mapa" class="section">
      <div id="map"></div>
    </section>
  </main>

  <!-- Modal de Cadastro -->
  <div class="modal-backdrop" id="cadastro-modal">
    <div class="modal-content">
      <div class="modal-header">
        <h3>Novo Cliente</h3>
        <button class="close-btn" onclick="closeModal()">
          <span class="material-icons">close</span>
        </button>
      </div>
      <form id="cliente-form" onsubmit="enviarFormulario(event)">
        <div class="modal-body">
          <div class="form-group">
            <label for="Nome">Nome do Cliente</label>
            <input type="text" id="Nome" required placeholder="Nome completo">
          </div>
          <div class="form-group">
            <label for="Telefone">Telefone</label>
            <input type="tel" id="Telefone" required placeholder="Ex: (11) 00000-0000">
          </div>

          <!-- OPÇÃO 1: ENDEREÇO ESTRUTURADO (NO TOPO) -->
          <div id="grupo-endereco-estruturado">
            <div class="form-group">
              <label for="Logradouro">Logradouro (Rua, Av, Qd e Lt)</label>
              <input type="text" id="Logradouro" >
            </div>
            <div class="form-group">
              <label for="Bairro">Bairro</label>
              <input type="text" id="Bairro" >
            </div>
            <div style="display: grid; grid-template-columns: 2fr 1fr; gap: 0.5rem;">
              <div class="form-group">
                <label for="Cidade">Cidade</label>
                <!-- Ajuste os valores padronizados da sua cidade foco -->
                <input type="text" id="Cidade" >
              </div>
              <div class="form-group">
                <label for="Estado">Estado</label>
                <input type="text" id="Estado" maxlength="2">
              </div>
            </div>
          </div>

          <!-- DIVISOR "OU" NO MEIO -->
          <div class="divider-or">
            <span>OU COLE O LINK DO GOOGLE MAPS</span>
          </div>

          <!-- OPÇÃO 2: LINK DO GOOGLE MAPS (ABAIXO) -->
          <div class="form-group">
            <label for="MapsLink" style="color: var(--primary);">Opção 2: Link do Google Maps (Compartilhamento)</label>
            <input type="url" id="MapsLink" placeholder="https://maps.app.goo.gl/... ou https://goo.gl..." oninput="restringirCamposEndereco(this.value)">
            <small style="color: var(--text-muted); font-size: 0.75rem; display:block; margin-top:0.25rem;">Cole o link compartilhado para 100% de precisão de GPS (ideal para Qd/Lt ou Chácaras).</small>
          </div>

        </div>
        <div class="modal-footer">
          <button type="button" class="btn" style="background-color: transparent; color: var(--text-muted);" onclick="closeModal()">Cancelar</button>
          <button type="submit" class="btn">Salvar Cliente</button>
        </div>
      </form>
    </div>
  </div>

  <div class="loader" id="app-loader">
    <div class="spinner"></div>
  </div>

  <div class="toast" id="app-toast"></div>

  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>

  <script>
    let clientes = [];
    let map = null;
    let markersGroup = null;

    document.addEventListener("DOMContentLoaded", () => {
      carregarClientes();
      const ultimaSecao = localStorage.getItem('secaoAtiva') || 'clientes';
      switchSection(ultimaSecao);
    });

    function switchSection(target) {
      document.querySelectorAll('.section').forEach(el => el.classList.remove('active'));
      document.querySelectorAll('.nav-btn').forEach(el => el.classList.remove('active'));

      document.getElementById(`section-${target}`).classList.add('active');
      document.getElementById(`btn-tab-${target}`).classList.add('active');
      
      localStorage.setItem('secaoAtiva', target);

      if (target === 'mapa') {
        renderizarMapa();
      }
    }

    // UX: Se o link do Maps for inserido, os outros campos de endereço ficam desabilitados
    function restringirCamposEndereco(linkValue) {
      const inputs = document.querySelectorAll("#grupo-endereco-estruturado input");
      const isFilled = linkValue.trim() !== "";
      
      inputs.forEach(input => {
        input.disabled = isFilled;
        if (isFilled) {
          input.value = "";
        }
      });

      // Se limpar o link, restaura os padrões da sua cidade
      if (!isFilled) {
        document.getElementById("Cidade").value = "Londrina";
        document.getElementById("Estado").value = "PR";
      }
    }

    function carregarClientes() {
      showLoader(true);
      google.script.run
        .withSuccessHandler(data => {
          clientes = data;
          atualizarCards();
          showLoader(false);
        })
        .withFailureHandler(err => {
          showToast("Erro ao carregar dados: " + err.message);
          showLoader(false);
        })
        .obterClientes();
    }

    function atualizarCards() {
      const listContainer = document.getElementById("clientes-list");
      if (clientes.length === 0) {
        listContainer.innerHTML = `
          <div style="grid-column: 1/-1; text-align: center; padding: 3rem; color: var(--text-muted);">
            <span class="material-icons" style="font-size: 3rem; margin-bottom: 0.5rem;">people_outline</span>
            <p>Nenhum cliente cadastrado.</p>
          </div>
        `;
        return;
      }

      listContainer.innerHTML = clientes.map(c => {
        // Se o endereço for um link, exibe como um link de redirecionamento interativo
        const enderecoExibicao = c.endereco.startsWith("http") 
          ? `<a href="${c.endereco}" target="_blank" class="card-link">Localização Compartilhada (Link)</a>`
          : escapeHtml(c.endereco);

        return `
          <div class="card">
            <div class="card-name">${escapeHtml(c.nome)}</div>
            <div class="card-info">
              <span class="material-icons">phone</span>
              <span>${escapeHtml(c.telefone)}</span>
            </div>
            <div class="card-info">
              <span class="material-icons">place</span>
              <span>${enderecoExibicao}</span>
            </div>
          </div>
        `;
      }).join('');
    }

    function renderizarMapa() {
      if (!map) {
        map = L.map('map').setView([-15.78, -47.92], 4); 
        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
          attribution: '&copy; OpenStreetMap'
        }).addTo(map);
        markersGroup = L.layerGroup().addTo(map);
      }

      setTimeout(() => {
        map.invalidateSize();
        plotarMarcadores();
      }, 180);
    }

    function plotarMarcadores() {
      if (!markersGroup) return;
      markersGroup.clearLayers();

      const validPoints = [];

      clientes.forEach(c => {
        if (c.lat && c.lng) {
          const latNum = parseFloat(c.lat);
          const lngNum = parseFloat(c.lng);
          
          if (!isNaN(latNum) && !isNaN(lngNum)) {
            const whatsLink = "https://wa.me/55" + c.telefone.replace(/\D/g, "");
            
            const popupContent = `
              <div class="custom-popup">
                <div class="custom-popup-title">${escapeHtml(c.nome)}</div>
                <div class="custom-popup-phone">${escapeHtml(c.telefone)}</div>
                <a href="${whatsLink}" target="_blank" class="whatsapp-btn">
                  <span class="material-icons" style="font-size: 1rem; vertical-align: middle;">chat</span> WhatsApp
                </a>
              </div>
            `;

            const marker = L.marker([latNum, lngNum]).bindPopup(popupContent);
            markersGroup.addLayer(marker);
            validPoints.push([latNum, lngNum]);
          }
        }
      });

      if (validPoints.length > 0) {
        const bounds = L.latLngBounds(validPoints);
        map.flyToBounds(bounds, { 
          padding: [40, 40],
          duration: 2.0 
        });
      }
    }

    function enviarFormulario(e) {
      e.preventDefault();
      
      const mapsLink = document.getElementById("MapsLink").value.trim();
      const logradouro = document.getElementById("Logradouro").value.trim();
      const bairro = document.getElementById("Bairro").value.trim();
      const cidade = document.getElementById("Cidade").value.trim();
      const estado = document.getElementById("Estado").value.trim();

      // Validação: Exige um ou outro método de endereço
      if (!mapsLink && (!logradouro || !bairro || !cidade || !estado)) {
        showToast("Por favor, cole um link do Google Maps OU preencha o endereço completo!");
        return;
      }

      const payload = {
        Nome: document.getElementById("Nome").value.trim(),
        Telefone: document.getElementById("Telefone").value.trim(),
        MapsLink: mapsLink,
        Logradouro: logradouro,
        Bairro: bairro,
        Cidade: cidade,
        Estado: estado
      };

      showLoader(true);
      closeModal();

      google.script.run
        .withSuccessHandler(res => {
          if (res.success) {
            showToast("Cliente salvo com sucesso!");
            document.getElementById("cliente-form").reset();
            restringirCamposEndereco(""); // Restaura inputs de endereço
            carregarClientes();
          } else {
            showToast(res.error);
            showLoader(false);
          }
        })
        .withFailureHandler(err => {
          showToast("Erro de comunicação: " + err.message);
          showLoader(false);
        })
        .salvarCliente(payload);
    }

    function openModal() {
      document.getElementById("cadastro-modal").classList.add("open");
      setTimeout(() => document.getElementById("Nome").focus(), 150);
    }

    function closeModal() {
      document.getElementById("cadastro-modal").classList.remove("open");
    }

    function showLoader(show) {
      const loader = document.getElementById("app-loader");
      if (show) loader.classList.add("active");
      else loader.classList.remove("active");
    }

    function showToast(message) {
      const toast = document.getElementById("app-toast");
      toast.innerText = message;
      toast.classList.add("show");
      setTimeout(() => toast.classList.remove("show"), 3500);
    }

    function escapeHtml(text) {
      if (!text) return "";
      return String(text)
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#039;");
    }
  </script>
</body>

</html>

Soluções prontas para você

Escolha um dos nossos templates profissionais de alta performance e comece a otimizar sua gestão hoje mesmo, sem mensalidades.

Vamos Construir sua Ferramenta Sob Medida?

// PROJETOS PERSONALIZADOS
🔍

Entendendo o Desafio

Nosso ponto de partida é o seu objetivo final. Analisamos a fundo a sua necessidade de negócio para mapear a estrutura e as funcionalidades da ferramenta ideal, garantindo uma solução que nasce alinhada à sua estratégia.

⚙️

Criação da Interface

Transformamos a complexidade da sua planilha em um painel de controle simples de usar. O resultado é uma tela limpa, com botões e visões claras, permitindo que você gerencie seus dados sem que precise de conhecimento técnico.

Entrega da Ferramenta

Você recebe o sistema completo e funcional, implementado diretamente na sua conta Google. Entregamos uma ferramenta pronta para o uso, desbloqueando imediatamente o potencial de uma gestão visual e centralizada.

×
Solução Pronta

Gosta de praticidade?

Sabia que nós desenvolvemos sistemas prontos baseados nesses tutoriais? Dê uma olhada nos nossos templates e pule a etapa da programação!

Conhecer os Templates