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.
- Alimente a grade pela sua API. Com uma URL em
events, o FullCalendar pede os eventos do intervalo visível, enviando os parâmetrosstarteend. - Trate os callbacks
eventDropeeventResize, que disparam quando a pessoa move ou redimensiona um evento. - Envie o novo horário e a versão do registro para a API.
- Chame
info.revert()em qualquer falha: resposta de erro, conflito ou rede fora. - Mostre o motivo em texto, numa região de status, não só no console.
- 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: ocatchregistra 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.
Referência: https://fullcalendar.io/docs/google-calendar