Programação Por Equipe DevBras

Capa do artigo: Arrastar evento no calendário: confirme no servidor e desfaça quando falhar

Arrastar evento no calendário: confirme no servidor e desfaça quando falhar

Faça o arrastar e soltar do FullCalendar virar um pedido à sua API, com volta automática ao horário anterior quando o servidor recusa, conflito tratado e o banco como fonte da grade.

A recepcionista arrasta a consulta de segunda para terça, às 14h. A grade anima, o bloco fica no lugar novo e ela segue o dia. A requisição falhou em silêncio: o Wi-Fi tinha caído por dois segundos. Na terça, a paciente chega no horário antigo e a sala está ocupada. A resposta curta: soltar um evento na grade é um pedido, não uma gravação. No soltar, a tela chama a sua API; se a API recusar ou a rede falhar, o evento volta ao lugar anterior e uma mensagem diz por quê. Só a resposta de sucesso do servidor deixa o evento na posição nova, e recarregar a página sempre mostra a grade vinda do seu banco.

A biblioteca de calendário cuida da animação. A transação é responsabilidade sua. E quando duas pessoas mexem na mesma agenda, o servidor também é quem decide qual alteração vale.

Por que a grade mente quando a requisição falha

Bibliotecas como o FullCalendar mudam o evento na memória do navegador no instante em que você o solta. Isso é bom para a sensação de resposta imediata. O problema é que, a partir desse momento, a tela mostra um estado que ainda não existe no servidor. Se o código que chama a API ignora o erro, ou se nem existe código chamando a API, a tela fica errada até alguém recarregar.

Há um segundo atalho tentador: ler e gravar direto na agenda Google da profissional, já que o FullCalendar sabe exibir uma agenda Google. Isso pula as suas regras. Conflito de horário, sala ocupada, intervalo mínimo entre consultas: nada disso é conferido pelo provedor externo. O post calendário na tela é só a vista trata de onde o evento deve nascer. Aqui o foco é a alteração por arrastar.

Como ligar o arrastar à sua API

O exemplo é a agenda da clínica, com o FullCalendar carregado pelo pacote que expõe o objeto global FullCalendar.

  1. Alimente a grade pela sua API. Com uma URL em events, o FullCalendar pede os eventos do intervalo visível, enviando os parâmetros start e end.
  2. Trate os callbacks eventDrop e eventResize, que disparam quando a pessoa move ou redimensiona um evento.
  3. Envie o novo horário e a versão do registro para a API.
  4. Chame info.revert() em qualquer falha: resposta de erro, conflito ou rede fora.
  5. Mostre o motivo em texto, numa região de status, não só no console.
  6. No sucesso, atualize a versão guardada no evento, para a próxima alteração.

A configuração da grade

const aviso = document.getElementById('aviso-agenda');

function avisar(texto) {
  aviso.textContent = texto;
}

async function salvarMudanca(info) {
  const ev = info.event;
  try {
    const resposta = await fetch('/api/compromissos/' + encodeURIComponent(ev.id), {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        inicio: ev.start.toISOString(),
        fim: ev.end ? ev.end.toISOString() : null,
        versao: ev.extendedProps.versao
      })
    });
    if (!resposta.ok) {
      const problema = await resposta.json().catch(() => ({}));
      info.revert();
      avisar(problema.mensagem || 'Não foi possível remarcar. O horário anterior foi mantido.');
      return;
    }
    const salvo = await resposta.json();
    ev.setExtendedProp('versao', salvo.versao);
    avisar('Consulta remarcada para ' + ev.start.toLocaleString('pt-BR') + '.');
  } catch (erro) {
    info.revert();
    avisar('Sem conexão com o servidor. A consulta continua no horário anterior.');
  }
}

const calendario = new FullCalendar.Calendar(document.getElementById('agenda'), {
  initialView: 'timeGridWeek',
  locale: 'pt-br',
  editable: true,
  events: '/api/compromissos',
  eventDrop: salvarMudanca,
  eventResize: salvarMudanca
});
calendario.render();

O elemento aviso-agenda é um parágrafo com role="status", para que leitores de tela anunciem o resultado. A opção locale com valor pt-br depende de o arquivo de idioma estar incluído no seu pacote do FullCalendar. Campos que o FullCalendar não conhece, como versao, vão para extendedProps quando vêm no JSON dos eventos.

A rota que decide

No servidor, a rota confere a versão, procura conflito e só então grava. O exemplo usa MySQL com o pacote mysql2; pool é o seu pool de conexões.

app.patch('/api/compromissos/:id', async (req, res) => {
  const { inicio, fim, versao } = req.body;
  const conn = await pool.getConnection();
  try {
    await conn.beginTransaction();
    const [[atual]] = await conn.query(
      'SELECT profissional_id, versao FROM compromissos WHERE id = ? FOR UPDATE',
      [req.params.id]
    );
    if (!atual) {
      await conn.rollback();
      return res.status(404).json({ mensagem: 'Compromisso não encontrado.' });
    }
    if (atual.versao !== versao) {
      await conn.rollback();
      return res.status(409).json({ mensagem: 'Outra pessoa alterou este horário. Recarregue a agenda.' });
    }
    const [[choque]] = await conn.query(
      'SELECT COUNT(*) AS n FROM compromissos WHERE profissional_id = ? AND id <> ? AND inicio_utc < ? AND fim_utc > ?',
      [atual.profissional_id, req.params.id, new Date(fim), new Date(inicio)]
    );
    if (choque.n > 0) {
      await conn.rollback();
      return res.status(409).json({ mensagem: 'Já existe consulta nesse horário.' });
    }
    await conn.query(
      'UPDATE compromissos SET inicio_utc = ?, fim_utc = ?, versao = versao + 1 WHERE id = ?',
      [new Date(inicio), new Date(fim), req.params.id]
    );
    await conn.commit();
    res.json({ versao: atual.versao + 1 });
  } catch (erro) {
    await conn.rollback();
    res.status(500).json({ mensagem: 'Erro ao remarcar. O horário anterior foi mantido.' });
  } finally {
    conn.release();
  }
});

A condição de sobreposição, início existente antes do fim novo e fim existente depois do início novo, pega todos os casos de choque, inclusive o evento novo engolindo um antigo. O FOR UPDATE trava a linha do compromisso durante a transação. Para garantir que duas remarcações simultâneas não ocupem o mesmo horário de outra consulta, uma trava por profissional (por exemplo, um FOR UPDATE na linha da profissional) fecha a última brecha. Nesta rota, eventos sem fim não são aceitos; se a sua agenda permite, defina uma duração padrão antes da conta.

Depois do commit, a cópia para a agenda Google entra numa fila, como descreve o guia sobre guardar o id do evento externo. A recepção não espera o Google para ver a remarcação confirmada.

Arrastar não pode ser o único jeito

Quem usa teclado ou leitor de tela não arrasta. Ofereça, ao abrir o evento, um formulário "Remarcar" com data e horário, que chama a mesma rota PATCH. As regras e as mensagens ficam iguais, e a grade só é atualizada com a resposta. O post arrastar e soltar com teclado mostra outras alternativas.

Erros comuns

  • Ignorar a resposta da API no soltar: a tela mostra o horário novo, o banco guarda o antigo e a paciente chega na hora errada.
  • Não chamar revert() no erro de rede: o catch registra no console e o evento fica no lugar que nunca foi gravado.
  • Gravar direto na agenda externa: as regras de conflito da clínica são puladas e duas consultas ocupam o mesmo horário.
  • Sem controle de versão: duas recepcionistas remarcam o mesmo evento e a última sobrescreve a primeira sem aviso.
  • Erro só no console: a pessoa vê o evento voltar e não sabe se foi conflito, falta de rede ou bug.
  • Só o mouse remarca: quem navega por teclado não consegue mudar um horário.

Checklist

  • A grade lê os eventos da sua API, não direto do provedor externo.
  • Mover e redimensionar chamam a API e esperam a resposta.
  • Toda falha, inclusive de rede, chama info.revert().
  • A mensagem de resultado aparece em texto numa região com role="status".
  • O servidor confere versão e conflito dentro de uma transação.
  • Recarregar a página mostra exatamente o que o banco tem.
  • Existe um formulário de remarcar que não depende de arrastar.

Abra a agenda do seu sistema, marque o modo offline no DevTools e arraste um evento. Ele voltou para o lugar original com uma mensagem? Se ficou no lugar novo, a tela mentiu.

Avalie este artigo

Referência: https://fullcalendar.io/docs/google-calendar