Tutorial Apps Script

Como Criar um Quadro Kanban estilo Trello no Google Planilhas

Se você se perde no meio de listas de tarefas sem fim ou quer dar um visual de software para os seus processos diários, este tutorial foi feito para você.

Nesta aula completa, vamos construir um Quadro Kanban Interativo. Você vai aprender a integrar o Google Planilhas com uma interface web inspirada em ferramentas como o Trello, onde você arrasta as tarefas entre as colunas (“A Fazer”, “Em Andamento” e “Concluído”) e a planilha é atualizada de forma automática e silenciosa nos bastidores.

Também mostraremos como resolver a usabilidade no celular, adicionando setas de direcionamento táticas para evitar conflitos de toque na tela, e o script de banco de dados inteligente que monta a planilha inteira sozinho na sua primeira execução.

 O que você vai aprender:

  • Banco de Dados Inteligente (setupSheet): Como programar o script para criar a aba, formatar os cabeçalhos e inserir os dados de teste de forma automática.
  • Lógica de Arrastar e Soltar (Drag & Drop): Como implementar interações fluidas de movimentação de cards usando JavaScript e HTML5 de forma nativa.
  • Usabilidade Mobile com Setas: O segredo de código para desativar o arrastar no celular e ativar setas de transição de um único toque.
  • Selo de Prazos Dinâmicos: Criação de badges automatizados que analisam a data de vencimento e avisam se a tarefa está em dia ou atrasada.

/**
 * Gestão de Demandas - TUTORIAL APPS SCRIPT
 */

const SHEET_TAREFAS = 'Tarefas';
const HEADERS_TAREFAS = ["ID_Tarefa", "Titulo", "Descricao", "Status", "Responsavel", "Data_Limite"];

/**
 * Ponto de entrada leve (doGet).
 * Não executa rotinas pesadas para garantir o menor tempo de resposta inicial (TTFB).
 */
function doGet() {
  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 Demandas');
}

/**
 * Garante a existência da aba e estrutura de dados.
 * Executado estritamente nas rotinas de gravação (CRUD) para segurança.
 */
function setupSheet() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = ss.getSheetByName(SHEET_TAREFAS);
  if (!sheet) {
    sheet = ss.insertSheet(SHEET_TAREFAS);
    sheet.appendRow(HEADERS_TAREFAS);
    sheet.setFrozenRows(1);
  }
  return sheet;
}

/**
 * Leitura de dados otimizada (Não chama o setupSheet para evitar API calls redundantes).
 */
function getTasks() {
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = ss.getSheetByName(SHEET_TAREFAS);
    if (!sheet) return [];

    const lastRow = sheet.getLastRow();
    if (lastRow <= 1) return [];

    const values = sheet.getRange(2, 1, lastRow - 1, HEADERS_TAREFAS.length).getValues();
    
    return values.map(row => {
      const task = {};
      HEADERS_TAREFAS.forEach((header, index) => {
        let val = row[index];
        if (val instanceof Date) {
          task[header] = Utilities.formatDate(val, Session.getScriptTimeZone(), "yyyy-MM-dd");
        } else {
          task[header] = val;
        }
      });
      return task;
    }).filter(t => t.ID_Tarefa);
  } catch (e) {
    Logger.log(`Erro ao buscar tarefas: ${e.message}`);
    return { error: e.message };
  }
}

/**
 * Salva ou edita uma tarefa.
 */
function saveTask(taskData) {
  try {
    const sheet = setupSheet(); 
    const isEdit = taskData.ID_Tarefa ? true : false;
    const taskId = isEdit ? taskData.ID_Tarefa : "TSK_" + new Date().getTime();

    // Normaliza a data recebida (YYYY-MM-DD) para salvar no formato padrão string (DD/MM/YYYY)
    let formattedDate = taskData.Data_Limite;
    if (formattedDate && formattedDate.includes('-')) {
      const [y, m, d] = formattedDate.split('-');
      formattedDate = `${d}/${m}/${y}`;
    }

    const taskToSave = {
      ID_Tarefa: taskId,
      Titulo: taskData.Titulo,
      Descricao: taskData.Descricao,
      Status: taskData.Status || "A Fazer",
      Responsavel: taskData.Responsavel,
      Data_Limite: formattedDate
    };

    if (isEdit) {
      const { rowIndex } = findRowById(sheet, taskId);
      if (rowIndex === -1) throw new Error("Tarefa não encontrada.");
      const updatedRow = HEADERS_TAREFAS.map(h => taskToSave[h]);
      sheet.getRange(rowIndex, 1, 1, HEADERS_TAREFAS.length).setValues([updatedRow]);
    } else {
      const newRow = HEADERS_TAREFAS.map(h => taskToSave[h]);
      sheet.appendRow(newRow);
    }

    return { success: true, taskId: taskId };
  } catch (e) {
    return { error: e.message };
  }
}

/**
 * Atualização rápida de status (Disparado nativamente no evento 'drop' do front-end).
 */
function updateTaskStatus(taskId, newStatus) {
  try {
    const sheet = setupSheet();
    const { rowIndex } = findRowById(sheet, taskId);
    if (rowIndex === -1) throw new Error("Tarefa não encontrada.");

    const statusColIndex = HEADERS_TAREFAS.indexOf("Status") + 1;
    sheet.getRange(rowIndex, statusColIndex).setValue(newStatus);
    return { success: true };
  } catch (e) {
    return { error: e.message };
  }
}

/**
 * Exclui uma tarefa.
 */
function deleteTask(taskId) {
  try {
    const sheet = setupSheet();
    const { rowIndex } = findRowById(sheet, taskId);
    if (rowIndex === -1) throw new Error("Tarefa não encontrada.");
    
    sheet.deleteRow(rowIndex);
    return { success: true };
  } catch (e) {
    return { error: e.message };
  }
}

/**
 * Função utilitária para localizar a linha do registro.
 */
function findRowById(sheet, id) {
  const data = sheet.getDataRange().getValues();
  const idColIndex = HEADERS_TAREFAS.indexOf("ID_Tarefa");

  for (let i = 1; i < data.length; i++) {
    if (String(data[i][idColIndex]).trim() === String(id).trim()) {
      return { rowIndex: i + 1 };
    }
  }
  return { rowIndex: -1 };
}
<!DOCTYPE html>
<html>
<head>
  <base target="_top">
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Gestão de Demandas</title>
  
  <!-- Ícones do Google -->
  <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">

<style>
    /* --- DESIGN VIBRANT TECH LIGHT (NEON & TINTED SENSATIONS) --- */
    :root {
      --bg-body: #f4f7fc;          /* Fundo azul-claro tecnológico, zero cinza */
      --card-bg: #ffffff;          /* Branco puro brilhante para os cartões */
      --text-main: #0f172a;        /* Azul escuro quase preto */
      --text-muted: #475569;       /* Slate escuro para descrições */
      
      /* Cores Vibrantes Neon para Light Mode */
      --primary: #4f46e5;          /* Indigo brilhante */
      --primary-glow: rgba(79, 70, 229, 0.15);
      
      --todo-color: #ff4d4d;       /* Vermelho vibrante */
      --todo-bg: #fff5f5;          /* Fundo sutil rosado */
      
      --doing-color: #ff9f00;      /* Laranja dourado */
      --doing-bg: #fffbeb;         /* Fundo sutil âmbar */
      
      --done-color: #00ca84;       /* Verde esmeralda vivo */
      --done-bg: #f0fdf4;          /* Fundo sutil menta */
      
      --border-radius: 14px;
      --transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
    }

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

    body {
      font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
      background-color: var(--bg-body);
      color: var(--text-main);
      padding: 30px 20px;
      min-height: 100vh;
      /* Gradiente de fundo de alta tecnologia */
      background-image: 
        radial-gradient(circle at 10% 20%, rgba(79, 70, 229, 0.05) 0%, transparent 40%),
        radial-gradient(circle at 90% 80%, rgba(0, 202, 132, 0.04) 0%, transparent 40%);
    }

    /* --- CABEÇALHO GLOSSY --- */
    .app-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      max-width: 1200px;
      margin: 0 auto 35px auto;
      flex-wrap: wrap;
      gap: 15px;
    }

    .app-header h1 {
      font-size: 2rem;
      font-weight: 900;
      letter-spacing: -0.7px;
      display: flex;
      align-items: center;
      gap: 12px;
      /* Gradiente no título para impacto imediato */
      background: linear-gradient(135deg, #4f46e5 0%, #06b6d4 100%);
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
    }

    .btn {
      display: inline-flex;
      align-items: center;
      gap: 8px;
      padding: 12px 24px;
      border: 1px solid transparent;
      border-radius: var(--border-radius);
      cursor: pointer;
      font-weight: 700;
      font-size: 0.95rem;
      transition: var(--transition);
    }

    .btn-primary {
      background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
      color: white;
      box-shadow: 0 4px 15px rgba(79, 70, 229, 0.25);
    }

    .btn-primary:hover {
      box-shadow: 0 6px 20px rgba(79, 70, 229, 0.4);
      transform: translateY(-2px);
    }

    .btn-secondary {
      background-color: #ffffff;
      color: var(--text-main);
      border-color: #e2e8f0;
    }

    .btn-secondary:hover {
      background-color: #f8fafc;
      border-color: #cbd5e1;
    }

    /* --- GRID KANBAN VIBRANTE --- */
    .kanban-board {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 24px;
      max-width: 1200px;
      margin: 0 auto;
      align-items: start;
    }

    .kanban-column {
      border-radius: var(--border-radius);
      padding: 22px;
      min-height: 600px;
      border: 1px solid rgba(226, 232, 240, 0.8);
      transition: var(--transition);
      box-shadow: 0 4px 12px rgba(15, 23, 42, 0.02);
    }

    /* Cores e fundos customizados para quebrar a frieza do cinza */
    .kanban-column[data-status="A Fazer"] { 
      background-color: var(--todo-bg); 
      border-top: 4px solid var(--todo-color);
    }
    .kanban-column[data-status="Em Andamento"] { 
      background-color: var(--doing-bg); 
      border-top: 4px solid var(--doing-color);
    }
    .kanban-column[data-status="Concluido"] { 
      background-color: var(--done-bg); 
      border-top: 4px solid var(--done-color);
    }

    .kanban-column.drag-over {
      transform: scale(1.01);
      box-shadow: 0 10px 30px rgba(79, 70, 229, 0.08);
    }

    .column-header {
      margin-bottom: 22px;
      font-size: 1.1rem;
      font-weight: 800;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }

    .kanban-column[data-status="A Fazer"] .column-header { color: #b91c1c; }
    .kanban-column[data-status="Em Andamento"] .column-header { color: #b45309; }
    .kanban-column[data-status="Concluido"] .column-header { color: #047857; }

    .column-body {
      display: flex;
      flex-direction: column;
      gap: 16px;
      min-height: 500px;
    }

    /* --- CARDS DE TAREFA COLOR-BAR (ESTILO METODOLOGIA ÁGIL) --- */
    .task-card {
      background-color: var(--card-bg);
      border: 1px solid rgba(226, 232, 240, 0.8);
      border-radius: var(--border-radius);
      padding: 18px;
      cursor: grab;
      transition: var(--transition);
      position: relative;
      box-shadow: 0 4px 10px rgba(15, 23, 42, 0.03);
    }

    /* Borda lateral grossa que identifica o status e traz cor imediata */
    .task-card[data-status="A Fazer"] { border-left: 5px solid var(--todo-color); }
    .task-card[data-status="Em Andamento"] { border-left: 5px solid var(--doing-color); }
    .task-card[data-status="Concluido"] { border-left: 5px solid var(--done-color); }

    .task-card:hover {
      transform: translateY(-4px);
      /* Glow shadow sutil combinando com o status */
      box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
    }

    .task-card.dragging {
      opacity: 0.4;
      transform: scale(0.97);
    }

    .task-title {
      font-size: 1.05rem;
      font-weight: 800;
      color: var(--text-main);
      margin-bottom: 8px;
      padding-right: 65px;
    }

    .task-description {
      font-size: 0.88rem;
      color: var(--text-muted);
      margin-bottom: 15px;
      line-height: 1.5;
    }

    .task-footer {
      display: flex;
      justify-content: space-between;
      align-items: center;
      font-size: 0.8rem;
      border-top: 1px solid rgba(226, 232, 240, 0.6);
      padding-top: 12px;
    }

    .task-assignee {
      font-weight: 700;
      color: #334155;
      display: inline-flex;
      align-items: center;
      gap: 6px;
      background: #f1f5f9;
      padding: 5px 12px;
      border-radius: 12px;
    }

    /* --- BADGES VIBRANTES COM FUNDO SUAVE --- */
    .badge {
      padding: 4px 12px;
      border-radius: 20px;
      font-size: 0.75rem;
      font-weight: 800;
      display: inline-flex;
      align-items: center;
      gap: 4px;
    }

    .badge-success { background-color: rgba(16, 185, 129, 0.15); color: #047857; }
    .badge-future { background-color: rgba(79, 70, 229, 0.1); color: #4f46e5; }
    .badge-today { background-color: rgba(245, 158, 11, 0.15); color: #b45309; }
    .badge-overdue { background-color: rgba(239, 68, 68, 0.12); color: #b91c1c; }

    /* --- BOTÕES DE AÇÕES MODERNOS --- */
    .task-actions {
      position: absolute;
      top: 16px;
      right: 16px;
      display: flex;
      gap: 4px;
    }

    .btn-icon {
      background: #ffffff;
      border: 1px solid #e2e8f0;
      cursor: pointer;
      color: #94a3b8;
      border-radius: 50%;
      padding: 6px;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      transition: var(--transition);
    }

    .btn-icon:hover {
      background-color: #f8fafc;
      border-color: #cbd5e1;
      transform: scale(1.1);
    }

    .btn-icon.edit:hover { color: var(--primary); }
    .btn-icon.delete:hover { color: var(--todo-color); }

     .mobile-arrows {
      display: none; /* Escondido por padrão no Desktop */
      gap: 6px;
    }

    .btn-arrow {
      background: #f1f5f9;
      border: 1px solid #cbd5e1;
      color: #334155;
      border-radius: 8px;
      padding: 4px 8px;
      cursor: pointer;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      transition: var(--transition);
    }

    .btn-arrow:active {
      background-color: #cbd5e1;
    }

    /* --- MODAIS COM BLUR --- */
    .modal-backdrop {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(15, 23, 42, 0.4);
      backdrop-filter: blur(5px);
      z-index: 1000;
      display: none;
    }

    .modal {
      position: fixed;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      background: #ffffff;
      border: 1px solid #e2e8f0;
      border-radius: var(--border-radius);
      box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);
      z-index: 1001;
      width: 95%;
      max-width: 450px;
      padding: 25px;
      display: none;
    }

    .modal.open, .modal-backdrop.open {
      display: block;
    }

    .modal-header {
      font-size: 1.3rem;
      font-weight: 900;
      margin-bottom: 20px;
      border-bottom: 1px solid #f1f5f9;
      padding-bottom: 12px;
      color: var(--text-main);
    }

    .form-group {
      margin-bottom: 15px;
    }

    .form-group label {
      display: block;
      font-size: 0.85rem;
      font-weight: 700;
      margin-bottom: 6px;
      color: #334155;
    }

    .form-control {
      width: 100%;
      padding: 10px 14px;
      background: #ffffff;
      border: 1px solid #cbd5e1;
      border-radius: var(--border-radius);
      font-size: 0.95rem;
      outline: none;
      color: var(--text-main);
      transition: var(--transition);
    }

    .form-control:focus {
      border-color: var(--primary);
      box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15);
    }

    .modal-actions {
      display: flex;
      justify-content: flex-end;
      gap: 12px;
      margin-top: 25px;
    }

    /* --- FEEDBACKS VISUAIS --- */
    #loader {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background-color: rgba(244, 247, 252, 0.6);
      backdrop-filter: blur(4px);
      z-index: 9999;
      display: flex;
      justify-content: center;
      align-items: center;
    }

    #loader.hidden { display: none; }

    .spinner {
      width: 40px;
      height: 40px;
      border: 3px solid #e2e8f0;
      border-left-color: var(--primary);
      border-radius: 50%;
      animation: spin 0.8s linear infinite;
    }

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

    .toast {
      position: fixed;
      top: 25px;
      left: 50%;
      transform: translateX(-50%) translateY(-100px);
      background: #0f172a;
      color: white;
      padding: 14px 28px;
      border-radius: 30px;
      font-weight: 700;
      z-index: 9999;
      box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
      border: 1px solid rgba(255, 255, 255, 0.1);
      transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
    }

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

    .toast.success { background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; }
    .toast.error { background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); color: white; }

    /* --- RESPONSIVIDADE --- */
    @media (max-width: 768px) {
      body { padding: 15px 10px; }
      .app-header h1 { font-size: 1.6rem; }
      .kanban-board { grid-template-columns: 1fr; gap: 20px; }
      .kanban-column { min-height: auto; padding: 15px; }
      .column-body { min-height: 150px; }
      .mobile-arrows {
        display: inline-flex;
      }
    }
  </style>  
</head>
<body>

  <!-- Bloqueador de Tela contra Cliques Duplos -->
  <div id="loader">
    <div class="spinner"></div>
  </div>

  <!-- Alerta Flutuante (Toast) -->
  <div id="toast" class="toast">Mensagem</div>

  <!-- Cabeçalho Principal -->
  <header class="app-header">
    <h1><span class="material-icons" style="color: var(--primary)">assignment</span> Gestão de Demandas</h1>
    <button class="btn btn-primary" onclick="openFormModal()"><span class="material-icons">add</span> Nova Tarefa</button>
  </header>

  <!-- Quadro Kanban Principal -->
  <main class="kanban-board">
    
    <!-- Coluna: A Fazer -->
    <div class="kanban-column" data-status="A Fazer">
      <div class="column-header">📋 A Fazer</div>
      <div class="column-body" id="col-todo"></div>
    </div>

    <!-- Coluna: Em Andamento -->
    <div class="kanban-column" data-status="Em Andamento">
      <div class="column-header">⚡ Em Andamento</div>
      <div class="column-body" id="col-doing"></div>
    </div>

    <!-- Coluna: Concluído -->
    <div class="kanban-column" data-status="Concluido">
      <div class="column-header">✅ Concluído</div>
      <div class="column-body" id="col-done"></div>
    </div>

  </main>

  <!-- ========================================================================== -->
  <!-- MODAIS E COMPONENTES -->
  <!-- ========================================================================== -->
  
  <div class="modal-backdrop" id="modalBackdrop" onclick="closeAllModals()"></div>

  <!-- Modal do Formulário de Tarefa -->
  <div class="modal" id="taskModal">
    <div class="modal-header" id="modalTitle">Nova Tarefa</div>
    <form id="taskForm" onsubmit="handleFormSubmit(event)">
      <input type="hidden" id="ID_Tarefa">
      
      <div class="form-group">
        <label for="Titulo">Título da Tarefa</label>
        <input type="text" id="Titulo" class="form-control" required placeholder="Ex: Criar modelo de relatório">
      </div>

      <div class="form-group">
        <label for="Descricao">Descrição</label>
        <textarea id="Descricao" class="form-control" rows="3" placeholder="Detalhes da entrega..."></textarea>
      </div>

      <div class="form-group">
        <label for="Responsavel">Responsável</label>
        <input type="text" id="Responsavel" class="form-control" required placeholder="Nome do executor">
      </div>

      <div class="form-group">
        <label for="Data_Limite">Data de Entrega</label>
        <input type="date" id="Data_Limite" class="form-control" required>
      </div>

      <div class="form-group" id="statusGroup" style="display: none;">
        <label for="Status">Status</label>
        <select id="Status" class="form-control">
          <option value="A Fazer">A Fazer</option>
          <option value="Em Andamento">Em Andamento</option>
          <option value="Concluido">Concluído</option>
        </select>
      </div>

      <div class="modal-actions">
        <button type="button" class="btn btn-secondary" onclick="closeAllModals()">Cancelar</button>
        <button type="submit" class="btn btn-primary">Salvar</button>
      </div>
    </form>
  </div>

  <!-- Modal de Confirmação de Exclusão -->
  <div class="modal" id="confirmModal">
    <div class="modal-header">Excluir Tarefa?</div>
    <p style="font-size: 0.95rem; margin-bottom: 20px;">Esta ação removerá a tarefa definitivamente da sua planilha de dados. Deseja continuar?</p>
    <div class="modal-actions">
      <button class="btn btn-secondary" onclick="closeAllModals()">Não</button>
      <button class="btn btn-primary" style="background-color: #e74c3c" id="btnConfirmDelete">Sim, Excluir</button>
    </div>
  </div>

  <!-- ========================================================================== -->
  <!-- JAVASCRIPT FRONT-END -->
  <!-- ========================================================================== -->
  <script>
    let localTasks = []; // Memória local de tarefas (SPA State)
    let currentDeleteId = null;

    // Inicialização ao carregar o DOM
    document.addEventListener('DOMContentLoaded', () => {
      loadTasks();
      setupDragAndDropEvents();
    });

    /**
     * Carrega e renderiza as tarefas vindas da planilha.
     */
    function loadTasks() {
      showLoader(true);
      google.script.run
        .withSuccessHandler(tasks => {
          showLoader(false);
          if (tasks.error) {
            showToast(tasks.error, 'error');
            return;
          }
          localTasks = tasks;
          renderKanban();
        })
        .withFailureHandler(err => {
          showLoader(false);
          showToast("Erro ao se comunicar com o servidor: " + err.message, 'error');
        })
        .getTasks();
    }

    /**
     * Renderiza os cards nas colunas corretas e ativa as interações de Drag.
     */
    function renderKanban() {
      const colTodo = document.getElementById('col-todo');
      const colDoing = document.getElementById('col-doing');
      const colDone = document.getElementById('col-done');

      colTodo.innerHTML = '';
      colDoing.innerHTML = '';
      colDone.innerHTML = '';

      localTasks.forEach(task => {
        const cardHtml = createTaskCard(task);
        if (task.Status === 'A Fazer') colTodo.innerHTML += cardHtml;
        else if (task.Status === 'Em Andamento') colDoing.innerHTML += cardHtml;
        else if (task.Status === 'Concluido') colDone.innerHTML += cardHtml;
      });

      // Anexa os eventos de drag nos novos elementos gerados
      attachDragToCards();
    }

    /**
     * Gera a estrutura HTML de um cartão de tarefa com os selos de prazo inteligentes e setas mobile.
     */
    function createTaskCard(task) {
      const deadlineBadge = getDeadlineBadge(task.Status, task.Data_Limite);
      
      // Controla a visibilidade das setas mobile dependendo do status atual
      const showLeft = task.Status !== "A Fazer" ? 'inline-flex' : 'none';
      const showRight = task.Status !== "Concluido" ? 'inline-flex' : 'none';

      const mobileArrowsHtml = `
        <div class="mobile-arrows" onclick="event.stopPropagation()">
          <button class="btn-arrow" style="display: ${showLeft};" onclick="moveTaskMobile('${task.ID_Tarefa}', 'left', event)" title="Voltar Etapa">
            <span class="material-icons" style="font-size: 0.95rem">arrow_back</span>
          </button>
          <button class="btn-arrow" style="display: ${showRight};" onclick="moveTaskMobile('${task.ID_Tarefa}', 'right', event)" title="Avançar Etapa">
            <span class="material-icons" style="font-size: 0.95rem">arrow_forward</span>
          </button>
        </div>
      `;
      
      return `
        <div class="task-card" draggable="true" data-id="${task.ID_Tarefa}" data-status="${task.Status}">
          <div class="task-title">${task.Titulo}</div>
          <p class="task-description">${task.Descricao || 'Sem descrição.'}</p>
          <div class="task-footer">
            <div class="task-assignee">
              <span class="material-icons" style="font-size: 1rem; color: #7f8c8d;">person</span>
              ${task.Responsavel}
            </div>
            <div style="display: flex; align-items: center; gap: 8px;">
              ${deadlineBadge}
              ${mobileArrowsHtml}
            </div>
          </div>
          <div class="task-actions">
            <button class="btn-icon edit" onclick="openFormModal('${task.ID_Tarefa}')" title="Editar"><span class="material-icons" style="font-size: 1.1rem">edit</span></button>
            <button class="btn-icon delete" onclick="confirmDelete('${task.ID_Tarefa}')" title="Excluir"><span class="material-icons" style="font-size: 1.1rem">delete</span></button>
          </div>
        </div>
      `;
    }

    /**
     * Calcula dinamicamente o status do prazo baseado na data de entrega (DD/MM/YYYY).
     */
    function getDeadlineBadge(status, limitDateStr) {
      if (status === "Concluido") {
        return '<span class="badge badge-success">✔️ Entregue</span>';
      }

      if (!limitDateStr) return '';
      
      // Transforma DD/MM/YYYY em um Date object funcional
      const parts = limitDateStr.split('/');
      if (parts.length !== 3) return '';
      const limitDate = new Date(parts[2], parts[1] - 1, parts[0], 12, 0, 0);

      // Normaliza a data atual
      const today = new Date();
      today.setHours(12, 0, 0, 0);

      const diffTime = limitDate - today;
      const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));

      if (diffDays > 1) {
        return `<span class="badge badge-future">🟢 Faltam ${diffDays} dias</span>`;
      } else if (diffDays === 1) {
        return '<span class="badge badge-future">🟢 Falta 1 dia</span>';
      } else if (diffDays === 0) {
        return '<span class="badge badge-today">🟡 Vence hoje!</span>';
      } else {
        const absoluteDays = Math.abs(diffDays);
        return `<span class="badge badge-overdue">🔴 Atrasada há ${absoluteDays} dia${absoluteDays > 1 ? 's' : ''}</span>`;
      }
    }

    // ==========================================================================
    // LÓGICA DO ARRASTAR E SOLTAR (HTML5 DRAG AND DROP NATIVO)
    // ==========================================================================

    function setupDragAndDropEvents() {
      const columns = document.querySelectorAll('.kanban-column');

      columns.forEach(col => {
        col.addEventListener('dragover', function(e) {
          e.preventDefault(); // Necessário para habilitar o Drop
        });

        col.addEventListener('dragenter', function(e) {
          e.preventDefault();
          this.classList.add('drag-over');
        });

        col.addEventListener('dragleave', function() {
          this.classList.remove('drag-over');
        });

        col.addEventListener('drop', function(e) {
          e.preventDefault();
          this.classList.remove('drag-over');

          const taskId = e.dataTransfer.getData('text/plain');
          const card = document.querySelector(`.task-card[data-id="${taskId}"]`);
          if (!card) return;

          const newStatus = this.dataset.status;
          const oldStatus = card.dataset.status;

          // Se soltou na mesma coluna, cancela o processo
          if (oldStatus === newStatus) return;

          // Executa a transição visual imediata no front-end para maior fluidez
          this.querySelector('.column-body').appendChild(card);
          card.dataset.status = newStatus;

          // Bloqueia interações e salva a mudança no banco de dados (planilha)
          showLoader(true);
          google.script.run
            .withSuccessHandler(response => {
              showLoader(false);
              if (response.error) {
                showToast("Erro ao mover: " + response.error, "error");
                loadTasks(); // Recarrega para voltar à posição original
              } else {
                showToast("Tarefa atualizada!", "success");
                loadTasks(); // Recarrega para recalcular prazos/badges dinâmicos
              }
            })
            .withFailureHandler(err => {
              showLoader(false);
              showToast("Falha de rede: " + err.message, "error");
              loadTasks();
            })
            .updateTaskStatus(taskId, newStatus);
        });
      });
    }

    function attachDragToCards() {
      const cards = document.querySelectorAll('.task-card');
      cards.forEach(card => {
        card.addEventListener('dragstart', function(e) {
          e.dataTransfer.setData('text/plain', this.dataset.id);
          this.classList.add('dragging');
        });

        card.addEventListener('dragend', function() {
          this.classList.remove('dragging');
        });
      });
    }

    /**
     * Lógica para mover tarefas via cliques nas setas (Mobile-friendly).
     */
    function moveTaskMobile(taskId, direction, event) {
      if (event) event.stopPropagation(); // Impede abertura de modais ou disparos de cliques indesejados
      
      const task = localTasks.find(t => t.ID_Tarefa === taskId);
      if (!task) return;

      let newStatus = task.Status;
      if (direction === 'right') {
        if (task.Status === 'A Fazer') newStatus = 'Em Andamento';
        else if (task.Status === 'Em Andamento') newStatus = 'Concluido';
      } else if (direction === 'left') {
        if (task.Status === 'Concluido') newStatus = 'Em Andamento';
        else if (task.Status === 'Em Andamento') newStatus = 'A Fazer';
      }

      if (newStatus === task.Status) return;

      showLoader(true);
      google.script.run
        .withSuccessHandler(response => {
          showLoader(false);
          if (response.error) {
            showToast("Erro ao mover: " + response.error, "error");
          } else {
            showToast("Demanda movimentada!", "success");
            loadTasks();
          }
        })
        .withFailureHandler(err => {
          showLoader(false);
          showToast("Falha de rede: " + err.message, "error");
        })
        .updateTaskStatus(taskId, newStatus);
    }

    // ==========================================================================
    // OPERAÇÕES DO FORMULÁRIO (CRIAR / EDITAR / DELETAR)
    // ==========================================================================

    function openFormModal(taskId = '') {
      const form = document.getElementById('taskForm');
      form.reset();

      document.getElementById('ID_Tarefa').value = taskId;
      const statusGroup = document.getElementById('statusGroup');

      if (taskId) {
        // Modo Edição
        document.getElementById('modalTitle').innerText = "Editar Tarefa";
        statusGroup.style.display = 'block';

        const task = localTasks.find(t => t.ID_Tarefa === taskId);
        if (task) {
          document.getElementById('Titulo').value = task.Titulo;
          document.getElementById('Descricao').value = task.Descricao;
          document.getElementById('Responsavel').value = task.Responsavel;
          document.getElementById('Status').value = task.Status;

          // Transforma data no formato DD/MM/YYYY para YYYY-MM-DD para o seletor HTML
          if (task.Data_Limite) {
            const parts = task.Data_Limite.split('/');
            if (parts.length === 3) {
              document.getElementById('Data_Limite').value = `${parts[2]}-${parts[1]}-${parts[0]}`;
            }
          }
        }
      } else {
        // Modo Nova Tarefa
        document.getElementById('modalTitle').innerText = "Nova Tarefa";
        statusGroup.style.display = 'none';
        document.getElementById('Data_Limite').valueAsDate = new Date();
      }

      document.getElementById('modalBackdrop').classList.add('open');
      document.getElementById('taskModal').classList.add('open');
      document.getElementById('Titulo').focus();
    }

    function closeAllModals() {
      document.getElementById('modalBackdrop').classList.remove('open');
      document.getElementById('taskModal').classList.remove('open');
      document.getElementById('confirmModal').classList.remove('open');
    }

    function handleFormSubmit(e) {
      e.preventDefault();
      closeAllModals();
      showLoader(true);

      const taskData = {
        ID_Tarefa: document.getElementById('ID_Tarefa').value,
        Titulo: document.getElementById('Titulo').value,
        Descricao: document.getElementById('Descricao').value,
        Responsavel: document.getElementById('Responsavel').value,
        Data_Limite: document.getElementById('Data_Limite').value,
        Status: document.getElementById('Status').value
      };

      google.script.run
        .withSuccessHandler(response => {
          showLoader(false);
          if (response.error) {
            showToast("Erro ao salvar: " + response.error, "error");
          } else {
            showToast("Tarefa salva com sucesso!", "success");
            loadTasks();
          }
        })
        .withFailureHandler(err => {
          showLoader(false);
          showToast("Falha de rede: " + err.message, "error");
        })
        .saveTask(taskData);
    }

    function confirmDelete(taskId) {
      currentDeleteId = taskId;
      document.getElementById('modalBackdrop').classList.add('open');
      document.getElementById('confirmModal').classList.add('open');
    }

    document.getElementById('btnConfirmDelete').addEventListener('click', () => {
      closeAllModals();
      if (!currentDeleteId) return;

      showLoader(true);
      google.script.run
        .withSuccessHandler(response => {
          showLoader(false);
          if (response.error) {
            showToast("Erro ao excluir: " + response.error, "error");
          } else {
            showToast("Tarefa excluída com sucesso!", "success");
            loadTasks();
          }
        })
        .withFailureHandler(err => {
          showLoader(false);
          showToast("Falha de rede: " + err.message, "error");
        })
        .deleteTask(currentDeleteId);
    });

    // ==========================================================================
    // UTILIÁRIOS VISUAIS
    // ==========================================================================

    function showLoader(show) {
      const loader = document.getElementById('loader');
      if (show) loader.classList.remove('hidden');
      else loader.classList.add('hidden');
    }

    function showToast(message, type = 'success') {
      const toast = document.getElementById('toast');
      toast.innerText = message;
      toast.className = `toast show ${type}`;

      setTimeout(() => {
        toast.classList.remove('show');
      }, 3000);
    }

    /**
     * Interpretador de datas à prova de falhas (Lida com YYYY-MM-DD e DD/MM/YYYY).
     */
    function parseDateSafe(dateStr) {
      if (!dateStr) return null;
      let d, m, y;
      
      if (typeof dateStr === 'string' && dateStr.includes('/')) {
        [d, m, y] = dateStr.split('/');
      } else if (typeof dateStr === 'string' && dateStr.includes('-')) {
        const parts = dateStr.split('T')[0].split('-');
        [y, m, d] = parts;
      } else if (dateStr instanceof Date) {
        return dateStr;
      } else {
        return null;
      }
      return new Date(y, m - 1, d, 12, 0, 0);
    }

    /**
     * Calcula e monta o selo de prazos de forma inteligente.
     */
    function getDeadlineBadge(status, limitDateStr) {
      if (status === "Concluido") {
        return '<span class="badge badge-success"><span class="material-icons" style="font-size:0.9rem;">check_circle</span> Concluído</span>';
      }

      const limitDate = parseDateSafe(limitDateStr);
      if (!limitDate) return '';
      
      const today = new Date();
      today.setHours(12, 0, 0, 0);

      const diffTime = limitDate - today;
      const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));

      if (diffDays > 1) {
        return `<span class="badge badge-future"><span class="material-icons" style="font-size:0.9rem;">schedule</span> Faltam ${diffDays} dias</span>`;
      } else if (diffDays === 1) {
        return '<span class="badge badge-future"><span class="material-icons" style="font-size:0.9rem;">schedule</span> Falta 1 dia</span>';
      } else if (diffDays === 0) {
        return '<span class="badge badge-today"><span class="material-icons" style="font-size:0.9rem;">priority_high</span> Vence hoje!</span>';
      } else {
        const absoluteDays = Math.abs(diffDays);
        return `<span class="badge badge-overdue"><span class="material-icons" style="font-size:0.9rem;">warning</span> Atrasado há ${absoluteDays} dia${absoluteDays > 1 ? 's' : ''}</span>`;
      }
    }
  </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