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.
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.
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 };
}
} <!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> Precisa de uma solução sob medida?
Nós usamos cookies para analisar o tráfego do site e melhorar sua experiência. Ao clicar em 'Aceitar', você concorda com o uso de ferramentas de estatística, conforme detalhado em nossa Política de Privacidade.