Código.gs
// ==========================================================================
// CONFIGURAÇÕES GLOBAIS
// ==========================================================================
// Nome da aba onde os dados das propostas estão.
const NOME_DA_ABA = "Propostas";
// ==========================================================================
// FUNÇÕES PRINCIPAIS DO APP WEB
// ==========================================================================
/**
* Função principal que serve a página HTML do nosso aplicativo.
*/
function doGet(e) {
return HtmlService.createTemplateFromFile('Index')
.evaluate()
.addMetaTag('viewport', 'width=device-width, initial-scale=1.0')
.setTitle('Gerador de Propostas');
}
/**
* Inclui o conteúdo de um arquivo no template HTML. Usado para CSS.
*/
function include(filename) {
return HtmlService.createHtmlOutputFromFile(filename).getContent();
}
// ==========================================================================
// FUNÇÕES EXPOSTAS PARA O FRONT-END (JAVASCRIPT)
// ==========================================================================
/**
* Lê todas as propostas da planilha e as retorna para a interface web.
* @returns {Array<Object>} Um array de objetos, onde cada objeto é uma proposta.
*/
function getPropostas() {
try {
const sheet = getSheet();
const range = sheet.getDataRange();
const values = range.getValues();
if (values.length <= 1) {
return []; // Retorna vazio se só houver o cabeçalho
}
const headers = values.shift(); // Remove a primeira linha (cabeçalhos)
// Mapeia os dados para objetos, tratando o campo de Data
const propostas = values.map(row => {
let proposta = {};
headers.forEach((header, index) => {
const cellValue = row[index];
// Se a coluna for 'Data' e o valor for um objeto Date, converte para string
if (header === 'Data' && cellValue instanceof Date) {
proposta[header] = cellValue.toISOString().split('T')[0]; // Formato AAAA-MM-DD
} else {
proposta[header] = cellValue;
}
});
proposta['ID'] = String(proposta['ID']); // Garante que o ID seja uma string
return proposta;
});
return propostas;
} catch (e) {
Logger.log(`Erro em getPropostas: ${e.message}`);
// Lança um erro que será capturado pelo withFailureHandler no front-end
throw new Error(`Falha ao ler propostas: ${e.message}`);
}
}
/**
* Gera um PDF para uma proposta específica com base no seu ID.
* @param {string} idDaProposta O ID da proposta a ser usada.
* @returns {Object} Um objeto com a URL do PDF gerado ou uma mensagem de erro.
*/
function gerarPdfProposta(idDaProposta) {
try {
const sheet = getSheet();
const propostaData = findRowById(sheet, idDaProposta);
if (!propostaData) {
throw new Error(`Proposta com ID "${idDaProposta}" não encontrada.`);
}
let dataFormatada = 'Data não informada';
if (propostaData.Data && typeof propostaData.Data.toLocaleDateString === 'function') { // Garante que é um objeto de data
const meses = ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"];
// O Apps Script já lê a data da planilha como um objeto Date. Não precisamos converter de string.
const dataObj = propostaData.Data;
const dia = dataObj.getDate();
const mes = meses[dataObj.getMonth()]; // getMonth() retorna 0-11
const ano = dataObj.getFullYear();
dataFormatada = `${dia} de ${mes} de ${ano}`;
}
// 1. Monta o conteúdo HTML do PDF
const htmlContent = `
<html>
<head>
<style>
body { font-family: 'Helvetica', 'Arial', sans-serif; font-size: 12pt; }
h1 { color: #444; border-bottom: 2px solid #27ae60; padding-bottom: 10px; }
.header { display: -webkit-flex; justify-content: space-between; margin-top: 20px; }
.section { margin-top: 20px; }
.label { font-weight: bold; color: #555; }
.value { margin-left: 10px; }
.valor { font-size: 1.5em; font-weight: bold; color: #27ae60; margin-top: 25px; }
</style>
</head>
<body>
<h1>Proposta de Projeto</h1>
<div class="header">
<div>
<span class="label">Cliente:</span>
<span class="value">${propostaData.Cliente}</span>
</div>
<div>
<span class="label">Data:</span>
<span class="value">${dataFormatada}</span>
</div>
</div>
<div class="section">
<span class="label">Projeto:</span>
<span class="value">${propostaData.Projeto}</span>
</div>
<div class="section">
<span class="label">Descrição:</span>
<p>${propostaData.Descrição}</p>
</div>
<div class="valor">
<span class="label">Valor:</span>
<span>R$ ${parseFloat(propostaData.Valor).toFixed(2)}</span>
</div>
</body>
</html>
`;
// 2. Cria o arquivo PDF
const blob = Utilities.newBlob(htmlContent, MimeType.HTML).getAs(MimeType.PDF);
const nomeDoArquivo = `Proposta - ${propostaData.Cliente} - ${propostaData.Projeto}.pdf`;
// 3. Salva o PDF na mesma pasta da planilha (ou na raiz, como fallback)
const planilhaArquivo = DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId());
const parents = planilhaArquivo.getParents();
let pdfFile;
if (parents.hasNext()) {
const folder = parents.next();
pdfFile = folder.createFile(blob).setName(nomeDoArquivo);
} else {
pdfFile = DriveApp.createFile(blob).setName(nomeDoArquivo); // Fallback para a pasta raiz
}
Logger.log(`PDF gerado com sucesso: ${pdfFile.getName()}, URL: ${pdfFile.getUrl()}`);
// 4. Retorna a URL para o front-end
return { success: true, url: pdfFile.getUrl() };
} catch (e) {
Logger.log(`Erro em gerarPdfProposta: ${e.message}`);
return { success: false, message: e.message };
}
}
// ==========================================================================
// FUNÇÕES AUXILIARES INTERNAS
// ==========================================================================
/**
* Função auxiliar para obter a aba da planilha.
* Lança um erro se a aba não forem encontradas.
*/
function getSheet() {
// Esta linha pega a planilha ATIVA, onde o script está rodando, independentemente do nome.
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(NOME_DA_ABA);
if (!sheet) {
// A mensagem de erro agora é mais inteligente, pois usa o nome real do arquivo.
throw new Error(`Aba "${NOME_DA_ABA}" não encontrada na planilha "${ss.getName()}".`);
}
return sheet;
}
/**
* Encontra uma linha na planilha pelo ID e retorna seus dados como um objeto.
* @param {GoogleAppsScript.Spreadsheet.Sheet} sheet A aba onde procurar.
* @param {string} id O ID a ser encontrado.
* @returns {Object|null} Um objeto com os dados da linha ou null se não encontrar.
*/
function findRowById(sheet, id) {
const range = sheet.getDataRange();
const values = range.getValues();
const headers = values.shift();
const idIndex = headers.indexOf('ID');
if (idIndex === -1) {
throw new Error('Coluna "ID" não encontrada na planilha.');
}
for (const row of values) {
if (String(row[idIndex]) === String(id)) {
let rowData = {};
headers.forEach((header, index) => {
rowData[header] = row[index];
});
return rowData;
}
}
return null; // Retorna null se não encontrar
}
Index.html
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
background-color: #f8f9fa;
margin: 0;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
background-color: #ffffff;
padding: 25px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.05);
}
h1 {
color: #343a40;
text-align: center;
border-bottom: 1px solid #dee2e6;
padding-bottom: 15px;
margin-top: 0;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #e9ecef;
}
th {
background-color: #f8f9fa;
font-weight: 600;
color: #495057;
}
tr:hover {
background-color: #f1f3f5;
}
.btn {
padding: 8px 12px;
font-size: 14px;
border: none;
border-radius: 5px;
cursor: pointer;
background-color: #007bff;
color: white;
transition: background-color 0.2s;
}
.btn:hover {
background-color: #0056b3;
}
.btn:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
#loader, #feedback {
text-align: center;
padding: 20px;
font-size: 1.1em;
color: #6c757d;
}
.feedback.error {
color: #dc3545;
}
.feedback.success {
color: #28a745;
}
/* Estilos para responsividade em telas menores */
@media (max-width: 768px) {
body {
padding: 10px;
}
.container {
padding: 15px;
}
table, thead, tbody, th, td, tr {
display: block;
}
thead tr {
position: absolute;
top: -9999px;
left: -9999px;
}
tr {
border: 1px solid #ccc;
border-radius: 5px;
margin-bottom: 15px;
}
td {
border: none;
border-bottom: 1px solid #eee;
position: relative;
padding-left: 50%;
}
td:before {
position: absolute;
left: 6px;
width: 45%;
padding-right: 10px;
white-space: nowrap;
font-weight: bold;
content: attr(data-label);
}
td:last-child {
border-bottom: 0;
}
}
</style>
</head>
<body>
<div class="container">
<h1>Gerador de Propostas</h1>
<div id="table-container">
<div id="loader">Carregando propostas...</div>
<table id="propostasTable" style="display:none;">
<thead>
<tr>
<th>Data</th>
<th>Cliente</th>
<th>Projeto</th>
<th style="text-align:right;">Valor</th>
<th>Ação</th>
</tr>
</thead>
<tbody id="propostasTbody">
<!-- As linhas da tabela serão inseridas aqui pelo JavaScript -->
</tbody>
</table>
</div>
<div id="feedback"></div>
</div>
<script>
const loader = document.getElementById('loader');
const feedback = document.getElementById('feedback');
const propostasTable = document.getElementById('propostasTable');
const propostasTbody = document.getElementById('propostasTbody');
// Função para mostrar feedback ao usuário
function showFeedback(message, type = 'info') {
feedback.textContent = message;
feedback.className = `feedback ${type}`;
}
// Função para gerar uma linha da tabela
function createTableRow(proposta) {
const tr = document.createElement('tr');
// Formata a data para exibição no formato DD/MM/AAAA
const dataFormatada = proposta.Data
? new Date(proposta.Data).toLocaleDateString('pt-BR', {timeZone: 'UTC'})
: 'N/A';
tr.innerHTML = `
<td data-label="Data">${dataFormatada}</td>
<td data-label="Cliente">${proposta.Cliente}</td>
<td data-label="Projeto">${proposta.Projeto}</td>
<td data-label="Valor" style="text-align:right;">${(proposta.Valor || 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}</td>
<td data-label="Ação">
<button class="btn" data-id="${proposta.ID}">Gerar PDF</button>
</td>
`;
return tr;
}
// Função para carregar e exibir as propostas
function carregarPropostas() {
loader.style.display = 'block';
propostasTable.style.display = 'none';
propostasTbody.innerHTML = '';
showFeedback('');
google.script.run
.withSuccessHandler(function(propostas) {
loader.style.display = 'none';
if (propostas && propostas.length > 0) {
propostas.forEach(p => propostasTbody.appendChild(createTableRow(p)));
propostasTable.style.display = 'table';
} else {
showFeedback('Nenhuma proposta encontrada na planilha.');
}
})
.withFailureHandler(function(error) {
loader.style.display = 'none';
showFeedback(error.message, 'error');
})
.getPropostas();
}
// Listener de clique para os botões "Gerar PDF"
propostasTbody.addEventListener('click', function(e) {
const button = e.target.closest('button[data-id]');
if (!button) return;
const propostaId = button.dataset.id;
button.textContent = 'Gerando...';
button.disabled = true;
showFeedback('');
google.script.run
.withSuccessHandler(function(response) {
if (response.success) {
showFeedback('PDF gerado com sucesso! Abrindo em nova aba...', 'success');
window.open(response.url, '_blank');
} else {
showFeedback(response.message, 'error');
}
button.textContent = 'Gerar PDF';
button.disabled = false;
})
.withFailureHandler(function(error) {
showFeedback(error.message, 'error');
button.textContent = 'Gerar PDF';
button.disabled = false;
})
.gerarPdfProposta(propostaId);
});
// Carrega as propostas quando a página é aberta
window.addEventListener('load', carregarPropostas);
</script>
</body>
</html>