Tutorial – Transforme sua Planilha em um Sistema de Estoque com Interface Web

Sua equipe ainda digita entradas e saídas de mercadorias diretamente na grade de células da planilha? Esse é o caminho mais curto para erros de digitação, fórmulas apagadas e estoque que nunca bate com a realidade. Neste tutorial completo, vamos elevar o nível da sua gestão criando um Terminal de Estoque  totalmente independente da interface padrão do Google Planilhas.

Você aprenderá a construir uma interface web moderna (estilo software de logística) onde o operador realiza movimentações através de modais inteligentes. O sistema conta com uma lógica de simulação, que mostra o saldo final antes mesmo de salvar, e uma trava de segurança que impede que o estoque fique negativo. É a tecnologia transformando sua planilha em uma ferramenta de controle de elite.

O que você vai aprender:

  • Alertas Visuais Inteligentes: O passo a passo para criar indicadores visuais (bolinhas pulsantes em vermelho) que avisam automaticamente quando um produto atinge o nível crítico de estoque.

  • UX com Confirmação em Dois Passos: Como desenvolver modais de entrada e saída que forçam o usuário a revisar o novo saldo antes de registrar, eliminando 99% dos erros operacionais.

  • Validação de Regras de Negócio: Como programar o sistema para barrar saídas maiores que o saldo disponível, protegendo a integridade do seu inventário em tempo real.

Código.gs

function doGet() {
  return HtmlService.createTemplateFromFile('Index')
    .evaluate()
    .setTitle('Gestão de Estoque Profissional')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

function getAppData() {
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = ss.getSheetByName('Estoque');
    if (!sheet) throw new Error("Aba 'Estoque' não encontrada.");

    const data = sheet.getDataRange().getValues();
    data.shift(); 
    const produtos = data.map((row, index) => {
      const limparNumero = (val) => {
        if (typeof val === 'number') return val;
        if (typeof val === 'string') {
          let limpo = val.replace(/[R$\s.]/g, '').replace(',', '.');
          return parseFloat(limpo) || 0;
        }
        return 0;
      };

      let qtd = limparNumero(row[2]);
      let preco = limparNumero(row[3]);
      
      let dataFmt = "";
      if (row[4] instanceof Date) {
        dataFmt = Utilities.formatDate(row[4], Session.getScriptTimeZone(), "dd/MM/yyyy");
      }

      return {
        id: row[0] ? row[0].toString() : "ID-" + index,
        nome: row[1] || "Produto sem nome",
        quantidade: qtd,
        preco: preco,
        ultimaAtualizacao: dataFmt
      };
    });

    return { produtos: produtos };
  } catch (e) {
    throw new Error(e.message);
  }
}

function processarMovimentacao(id, qtdAlteracao) {
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = ss.getSheetByName('Estoque');
    const data = sheet.getDataRange().getValues();
    
    let rowIdx = -1;
    for (let i = 1; i < data.length; i++) {
      if (data[i][0].toString() === id.toString()) {
        rowIdx = i + 1;
        break;
      }
    }

    if (rowIdx === -1) throw new Error("Produto não encontrado.");

    // Busca valor bruto para evitar erros de formatação
    const estoqueAtual = parseFloat(sheet.getRange(rowIdx, 3).getValue()) || 0;
    const novoEstoque = estoqueAtual + qtdAlteracao;

    if (novoEstoque < 0) throw new Error("Estoque insuficiente.");

    sheet.getRange(rowIdx, 3).setValue(novoEstoque);
    sheet.getRange(rowIdx, 5).setValue(new Date());
    
    return { success: true, novoEstoque: novoEstoque };
  } catch (e) {
    return { success: false, error: e.message };
  } 
}

Index.html

<!DOCTYPE html>
<html>

<head>
  <base target="_top">
  <!-- Importando fonte moderna -->
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
  <style>
    :root {
      --bg-body: #edf2f7;
      --bg-card: #ffffff;
      --header-dark: #1e293b;
      --primary: #4f46e5;
      --accent: #3b82f6;
      --danger: #e11d48;
      --warning: #f59e0b;
      --success: #10b981;
      --text-main: #1e293b;
      --text-muted: #64748b;
      --border: #e2e8f0;
    }

    * {
      box-sizing: border-box;
    }

    body {
      font-family: 'Inter', sans-serif;
      background-color: var(--bg-body);
      color: var(--text-main);
      margin: 0;
      padding: 40px 20px;
    }

    .app-card {
      width: 100%;
      max-width: 950px;
      margin: 0 auto;
      background: var(--bg-card);
      border-radius: 12px;
      box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
      overflow: hidden;
      border: 1px solid var(--border);
    }

    .app-header {
      padding: 25px 35px;
      background-color: var(--header-dark);
      color: white;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }

    .app-header h1 {
      margin: 0;
      font-size: 1.3rem;
      font-weight: 700;
    }

    #loading {
      background: rgba(255, 255, 255, 0.1);
      padding: 4px 12px;
      border-radius: 20px;
      font-size: 0.75rem;
    }

    table {
      width: 100%;
      border-collapse: collapse;
    }

    th {
      background-color: #334155;
      color: #f8fafc;
      padding: 15px 20px;
      text-align: left;
      font-size: 0.75rem;
      text-transform: uppercase;
      font-weight: 700;
    }

    td {
      padding: 18px 20px;
      border-bottom: 1px solid #f1f5f9;
      font-size: 0.95rem;
    }

    tr:nth-child(even) {
      background-color: #f8fafc;
    }

    tr:hover td {
      background-color: #f1f5f9;
    }

    .row-warning {
      background-color: #fff1f2 !important;
    }

    .stock-badge {
      display: inline-flex;
      align-items: center;
      gap: 8px;
      font-weight: 700;
      padding: 4px 12px;
      border-radius: 8px;
      background: #f1f5f9;
    }

    .stock-baixo {
      color: var(--danger);
      background: #ffe4e6;
    }

    .alert-dot {
      width: 10px;
      height: 10px;
      background-color: var(--danger);
      border-radius: 50%;
      display: inline-block;
      animation: pulse-red 1s infinite alternate;
    }

    @keyframes pulse-red {
      from {
        transform: scale(1);
        box-shadow: 0 0 0 0 rgba(225, 29, 72, 0.7);
      }

      to {
        transform: scale(1.2);
        box-shadow: 0 0 8px 2px rgba(225, 29, 72, 0.3);
      }
    }

    .btn-group {
      display: flex;
      gap: 8px;
      justify-content: flex-end;
    }

    .btn-action {
      padding: 8px 16px;
      border-radius: 8px;
      border: none;
      font-size: 0.75rem;
      font-weight: 700;
      cursor: pointer;
      display: flex;
      align-items: center;
      gap: 5px;
      text-transform: uppercase;
    }

    .btn-in {
      background: #e0e7ff;
      color: #4338ca;
    }

    .btn-in:hover {
      background: #4338ca;
      color: white;
    }

    .btn-out {
      background: #ffe4e6;
      color: #991b1b;
    }

    .btn-out:hover {
      background: #991b1b;
      color: white;
    }

    .modal-overlay {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(15, 23, 42, 0.8);
      backdrop-filter: blur(8px);
      display: none;
      align-items: center;
      justify-content: center;
      z-index: 1000;
    }

    .modal {
      background: white;
      padding: 35px;
      border-radius: 20px;
      width: 90%;
      max-width: 400px;
    }

    .modal-info {
      background: #f1f5f9;
      padding: 20px;
      border-radius: 12px;
      margin: 20px 0;
      border-left: 5px solid var(--primary);
      font-size: 0.9rem;
      line-height: 1.5;
    }

    .input-field {
      width: 100%;
      padding: 12px;
      border: 2px solid var(--border);
      border-radius: 10px;
      font-size: 1.2rem;
      font-weight: 700;
      text-align: center;
      color: var(--primary);
      outline: none;
    }

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

    .modal-btns {
      display: flex;
      gap: 12px;
      margin-top: 25px;
    }

    .btn-main {
      flex: 2;
      padding: 14px;
      border: none;
      border-radius: 10px;
      background: var(--primary);
      color: white;
      font-weight: 700;
      cursor: pointer;
    }

    .btn-sec {
      flex: 1;
      padding: 14px;
      border-radius: 10px;
      border: 1px solid var(--border);
      background: white;
      color: var(--text-muted);
      font-weight: 600;
      cursor: pointer;
    }

    .toast {
      position: fixed;
      bottom: 40px;
      left: 50%;
      transform: translateX(-50%);
      padding: 14px 30px;
      border-radius: 50px;
      color: white;
      font-weight: 700;
      display: none;
      z-index: 9999;
      box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
      white-space: nowrap;
    }
  </style>
</head>

<body>

  <div class="app-card">
    <div class="app-header">
      <h1>📦 Gestão de Estoque</h1>
      <div id="loading" style="font-size: 0.8rem; color: var(--text-muted);">Sincronizando...</div>
    </div>

    <div class="table-container">
      <table id="tabela-estoque" style="display:none;">
        <thead>
          <tr>
            <th>Produto</th>
            <th>Saldo Atual</th>
            <th>Preço Unitário</th>
            <th style="text-align:right">Ações</th>
          </tr>
        </thead>
        <tbody id="corpo-tabela"></tbody>
      </table>
    </div>
  </div>

  <!-- Modal -->
  <div id="modal-mov" class="modal-overlay">
    <div class="modal" id="passo-1">
      <h3 id="modal-titulo" style="margin-top:0; color: var(--header-dark);">Título</h3>
      <div class="modal-info" id="modal-detalhes"></div>
      <label style="font-size: 0.7rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 8px; display: block;">Digite a Quantidade</label>
      <input type="number" id="input-qtd" class="input-field" placeholder="0">
      <div class="modal-btns">
        <button class="btn-sec" onclick="fecharModal()">Sair</button>
        <button class="btn-main" onclick="prepararConfirmacao()">Prosseguir</button>
      </div>
    </div>

    <div class="modal" id="passo-2" style="display:none;">
      <h3 style="margin-top:0; color: var(--warning)">Tudo certo?</h3>
      <div class="modal-info" id="confirmacao-texto"></div>
      <div class="modal-btns">
        <button class="btn-sec" onclick="voltarPasso()">Voltar</button>
        <button class="btn-main" id="btn-finalizar" style="background: var(--success)">Sim, Confirmar</button>
      </div>
    </div>
  </div>

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

  <script>
    let dadosLocais = [];
  let itemSelecionado = null;
  let tipoOperacao = '';
  const fmtMoeda = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });

  window.onload = carregar;

  function carregar() {
    google.script.run
      .withSuccessHandler(res => {
        dadosLocais = res.produtos;
        renderizar();
        document.getElementById('loading').style.display = 'none';
        document.getElementById('tabela-estoque').style.display = 'table';
      })
      .getAppData();
  }

  function renderizar() {
  const tbody = document.getElementById('corpo-tabela');
  tbody.innerHTML = '';
  dadosLocais.forEach(p => {
    const isBaixo = p.quantidade < 10;
    const rowClass = isBaixo ? 'row-warning' : '';
    
    tbody.innerHTML += `
      <tr class="${rowClass}">
        <td>
          <span style="font-weight: 700; color: var(--header-dark); display: block;">${p.nome}</span>
          <span style="font-size: 0.75rem; color: var(--text-muted);">Ref: ${p.id} • ${p.ultimaAtualizacao}</span>
        </td>
        <td>
          <div class="stock-badge ${isBaixo ? 'stock-baixo' : ''}">
             ${isBaixo ? '<span class="alert-dot"></span>' : ''}
             ${p.quantidade}
          </div>
        </td>
        <td style="font-weight: 600; color: var(--text-muted);">${fmtMoeda.format(p.preco)}</td>
        <td>
          <div class="btn-group">
            <button class="btn-action btn-in" onclick="abrirModal('${p.id}', 'entrada')">➕ Entrada</button>
            <button class="btn-action btn-out" onclick="abrirModal('${p.id}', 'saida')">➖ Saída</button>
          </div>
        </td>
      </tr>
    `;
  });
}

  function abrirModal(id, tipo) {
    itemSelecionado = dadosLocais.find(p => p.id === id);
    tipoOperacao = tipo;
    document.getElementById('input-qtd').value = ''; 
    document.getElementById('modal-titulo').innerText = tipo === 'entrada' ? 'Entrada de Mercadoria' : 'Registrar Saída';
    document.getElementById('modal-detalhes').innerHTML = `<strong>${itemSelecionado.nome}</strong><br>Saldo atual no estoque: ${itemSelecionado.quantidade}`;
    document.getElementById('modal-mov').style.display = 'flex';
    setTimeout(() => document.getElementById('input-qtd').focus(), 100);
  }

  function prepararConfirmacao() {
    const qtd = parseInt(document.getElementById('input-qtd').value);
    if (isNaN(qtd) || qtd <= 0) return msg("Digite a quantidade", "#f59e0b");
    if (tipoOperacao === 'saida' && qtd > itemSelecionado.quantidade) return msg("Estoque insuficiente", "#ef4444");

    const novoSaldo = tipoOperacao === 'entrada' ? itemSelecionado.quantidade + qtd : itemSelecionado.quantidade - qtd;
    document.getElementById('confirmacao-texto').innerHTML = `Você está registrando a <strong>${tipoOperacao.toUpperCase()}</strong> de <strong>${qtd}</strong> unidade(s).<br><br>Saldo final: ${itemSelecionado.quantidade} ➔ <strong>${novoSaldo}</strong>`;
    
    document.getElementById('passo-1').style.display = 'none';
    document.getElementById('passo-2').style.display = 'block';
    document.getElementById('btn-finalizar').onclick = () => executar(qtd);
  }

  function executar(qtd) {
    const alteracao = tipoOperacao === 'entrada' ? qtd : qtd * -1;
    document.getElementById('btn-finalizar').innerText = "Salvando...";
    google.script.run.withSuccessHandler(res => {
      if (res.success) {
        msg("✅ Estoque Atualizado!", "#10b981");
        fecharModal();
        carregar();
      }
    }).processarMovimentacao(itemSelecionado.id, alteracao);
  }

  function fecharModal() { document.getElementById('modal-mov').style.display = 'none'; voltarPasso(); }
  function voltarPasso() { 
    document.getElementById('passo-1').style.display = 'block'; 
    document.getElementById('passo-2').style.display = 'none'; 
    document.getElementById('btn-finalizar').innerText = "Sim, Confirmar";
  }
  
  function msg(t, c) {
    const toast = document.getElementById('toast');
    toast.innerText = t; toast.style.backgroundColor = c;
    toast.style.display = 'block'; setTimeout(() => toast.style.display = 'none', 3000);
  }
  </script>
</body>

</html>

Acompanhe nas redes sociais

Projetos Personalizados

Precisa de uma solução sob medida?

© 2025 Transformando Planilhas. Todos os direitos reservados.