Implantação da geração de mensagem de forma livre e mudança na engine de mensagem
This commit is contained in:
52
app/views/admin/contatos/_form.html.erb
Normal file
52
app/views/admin/contatos/_form.html.erb
Normal file
@@ -0,0 +1,52 @@
|
||||
<%= form_with model: [:admin, contato], data: { turbo: false }, class: 'space-y-5' do |f| %>
|
||||
<% if contato.errors.any? %>
|
||||
<div class="bg-red-500/10 border border-red-500/30 rounded-xl px-4 py-3 text-red-300 text-sm">
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<% contato.errors.full_messages.each do |msg| %><li><%= msg %></li><% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= f.label :nome, class: 'block text-gray-400 text-sm mb-1' %>
|
||||
<%= f.text_field :nome, required: true,
|
||||
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<%= f.label :telefone, 'WhatsApp', class: 'block text-gray-400 text-sm mb-1' %>
|
||||
<%= f.text_field :telefone, placeholder: '(11) 92005-1157',
|
||||
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||
<p class="text-xs text-gray-500 mt-1">Pode digitar com máscara — é normalizado ao salvar.</p>
|
||||
</div>
|
||||
<div>
|
||||
<%= f.label :email, 'E-mail', class: 'block text-gray-400 text-sm mb-1' %>
|
||||
<%= f.email_field :email,
|
||||
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :grupo_contato_id, 'Grupo', class: 'block text-gray-400 text-sm mb-1' %>
|
||||
<%= f.collection_select :grupo_contato_id, @grupos, :id, :nome, { include_blank: 'Sem grupo (não recebe nada)' },
|
||||
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||
<p class="text-xs text-gray-500 mt-1">É o grupo que define quais eventos este contato recebe.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :observacao, 'Observação', class: 'block text-gray-400 text-sm mb-1' %>
|
||||
<%= f.text_field :observacao,
|
||||
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||
<%= f.check_box :ativo, class: 'rounded border-white/20 bg-[#1a1a1a] text-orange-500' %>
|
||||
Ativo
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<%= f.submit 'Salvar', class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %>
|
||||
<%= link_to 'Cancelar', admin_contatos_path, data: { turbo: false }, class: 'text-gray-400 hover:text-white text-sm' %>
|
||||
</div>
|
||||
<% end %>
|
||||
4
app/views/admin/contatos/edit.html.erb
Normal file
4
app/views/admin/contatos/edit.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="max-w-xl space-y-6">
|
||||
<h1 class="text-2xl font-bold text-white">Editar contato</h1>
|
||||
<%= render "form", contato: @contato %>
|
||||
</div>
|
||||
74
app/views/admin/contatos/index.html.erb
Normal file
74
app/views/admin/contatos/index.html.erb
Normal file
@@ -0,0 +1,74 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Contatos</h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">
|
||||
Quem recebe as mensagens. Quem tem só número recebe só WhatsApp; quem tem só
|
||||
e-mail recebe só e-mail.
|
||||
</p>
|
||||
</div>
|
||||
<%= link_to 'Novo contato', new_admin_contato_path(grupo_id: params[:grupo_id]), data: { turbo: false },
|
||||
class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400' %>
|
||||
</div>
|
||||
|
||||
<form method="get" action="<%= admin_contatos_path %>" data-turbo="false" class="flex flex-wrap items-center gap-2">
|
||||
<input type="text" name="q" value="<%= params[:q] %>" placeholder="Buscar por nome, número ou e-mail"
|
||||
class="bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500 min-w-[260px]">
|
||||
<select name="grupo_id" class="bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500">
|
||||
<option value="">Todos os grupos</option>
|
||||
<% @grupos.each do |grupo| %>
|
||||
<option value="<%= grupo.id %>" <%= 'selected' if params[:grupo_id].to_s == grupo.id.to_s %>><%= grupo.nome %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
<button type="submit" class="px-4 py-2.5 rounded-xl text-sm bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500">Filtrar</button>
|
||||
</form>
|
||||
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 overflow-hidden">
|
||||
<% if @contatos.empty? %>
|
||||
<p class="px-6 py-10 text-center text-gray-500 text-sm">Nenhum contato encontrado.</p>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-white/5 text-gray-400 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="text-left px-6 py-3 font-medium">Nome</th>
|
||||
<th class="text-left px-4 py-3 font-medium">WhatsApp</th>
|
||||
<th class="text-left px-4 py-3 font-medium">E-mail</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Grupo</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Situação</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5">
|
||||
<% @contatos.each do |contato| %>
|
||||
<tr class="hover:bg-white/5">
|
||||
<td class="px-6 py-2.5 text-white"><%= contato.nome %></td>
|
||||
<td class="px-4 py-2.5 text-gray-300 font-mono"><%= contato.telefone.presence || '—' %></td>
|
||||
<td class="px-4 py-2.5 text-gray-300"><%= contato.email.presence || '—' %></td>
|
||||
<td class="px-4 py-2.5">
|
||||
<% if contato.grupo_contato %>
|
||||
<span class="text-gray-300"><%= contato.grupo_contato.nome %></span>
|
||||
<% else %>
|
||||
<span class="text-amber-400" title="Sem grupo não recebe nada">sem grupo</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="<%= contato.ativo? ? 'text-green-400' : 'text-gray-500' %>">
|
||||
<%= contato.ativo? ? 'Ativo' : 'Inativo' %>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<%= link_to 'Editar', edit_admin_contato_path(contato), data: { turbo: false },
|
||||
class: 'text-gray-300 hover:text-orange-400 mr-3' %>
|
||||
<%= button_to 'Excluir', admin_contato_path(contato), method: :delete,
|
||||
form: { data: { turbo_confirm: "Excluir #{contato.nome}?" } },
|
||||
class: 'text-red-400 hover:text-red-300 inline' %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
4
app/views/admin/contatos/new.html.erb
Normal file
4
app/views/admin/contatos/new.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="max-w-xl space-y-6">
|
||||
<h1 class="text-2xl font-bold text-white">Novo contato</h1>
|
||||
<%= render "form", contato: @contato %>
|
||||
</div>
|
||||
102
app/views/admin/eventos_notificacao/_form.html.erb
Normal file
102
app/views/admin/eventos_notificacao/_form.html.erb
Normal file
@@ -0,0 +1,102 @@
|
||||
<%= form_with model: [:admin, evento], url: (evento.persisted? ? admin_evento_path(evento) : admin_eventos_path),
|
||||
data: { turbo: false }, class: 'space-y-5' do |f| %>
|
||||
<% if evento.errors.any? %>
|
||||
<div class="bg-red-500/10 border border-red-500/30 rounded-xl px-4 py-3 text-red-300 text-sm">
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<% evento.errors.full_messages.each do |msg| %><li><%= msg %></li><% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= f.label :nome, class: 'block text-gray-400 text-sm mb-1' %>
|
||||
<%= f.text_field :nome, required: true, placeholder: 'Aviso de fechamento para a diretoria',
|
||||
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :gatilho, 'Gatilho (o que faz disparar)', class: 'block text-gray-400 text-sm mb-1' %>
|
||||
<% if evento.sistema? %>
|
||||
<p class="text-white text-sm bg-[#141414] border border-white/10 rounded-xl px-3 py-2.5"><%= evento.gatilho_label %></p>
|
||||
<p class="text-xs text-gray-500 mt-1">Evento de sistema: o gatilho e a chave são fixos porque o código dispara por eles.</p>
|
||||
<% else %>
|
||||
<%= f.select :gatilho, EventoNotificacao::GATILHOS.map { |g| [EventoNotificacao::GATILHO_LABEL[g], g] }, {},
|
||||
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :descricao, 'Descrição', class: 'block text-gray-400 text-sm mb-1' %>
|
||||
<%= f.text_field :descricao,
|
||||
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||
</div>
|
||||
|
||||
<%# Só faz sentido no gatilho `agendado`; deixo sempre visível para não
|
||||
depender de JS, com a explicação de quando vale. %>
|
||||
<fieldset class="border border-white/10 rounded-xl p-4 space-y-3">
|
||||
<legend class="text-gray-400 text-sm px-2">Agendamento (só vale no gatilho "Agendado")</legend>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<%= f.label :frequencia, 'Frequência', class: 'block text-gray-500 text-xs mb-1' %>
|
||||
<%= f.select :frequencia, EventoNotificacao::FREQUENCIAS.map { |x| [x.capitalize, x] }, { include_blank: '—' },
|
||||
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5' %>
|
||||
</div>
|
||||
<div>
|
||||
<%= f.label :hora, 'Hora', class: 'block text-gray-500 text-xs mb-1' %>
|
||||
<%= f.number_field :hora, min: 0, max: 23,
|
||||
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5' %>
|
||||
</div>
|
||||
<div>
|
||||
<%= f.label :dia_semana, 'Dia (semanal)', class: 'block text-gray-500 text-xs mb-1' %>
|
||||
<%= f.select :dia_semana, Date::DAYNAMES.each_with_index.map { |nome, i| [nome, i] }, { include_blank: '—' },
|
||||
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5' %>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label class="flex items-start gap-2 text-sm text-gray-300">
|
||||
<%= f.check_box :notificar_envolvido, class: 'mt-1 rounded border-white/20 bg-[#1a1a1a] text-orange-500' %>
|
||||
<span>
|
||||
Avisar também a pessoa envolvida no fato
|
||||
<span class="block text-xs text-gray-500">Ex.: o motorista dono do pagamento, além dos grupos. Só se aplica aos gatilhos de consolidação/pagamento/valor.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||
<%= f.check_box :ativo, class: 'rounded border-white/20 bg-[#1a1a1a] text-orange-500' %>
|
||||
Ativo
|
||||
</label>
|
||||
|
||||
<%# Assinatura por grupo: TODOS os grupos ativos aparecem, marcados os que já
|
||||
assinam. Sem isso a tela só mostraria os já configurados e não haveria por
|
||||
onde adicionar. %>
|
||||
<fieldset class="border border-white/10 rounded-xl p-4 space-y-3">
|
||||
<legend class="text-gray-400 text-sm px-2">Quais grupos recebem</legend>
|
||||
<% if @linhas_assinatura.blank? %>
|
||||
<p class="text-sm text-gray-500">
|
||||
Nenhum grupo cadastrado ainda —
|
||||
<%= link_to 'crie um grupo', new_admin_grupo_path, data: { turbo: false }, class: 'text-orange-400 underline' %>
|
||||
antes.
|
||||
</p>
|
||||
<% end %>
|
||||
<% Array(@linhas_assinatura).each do |linha| %>
|
||||
<% grupo = linha[:grupo]; assinatura = linha[:assinatura] %>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-300 min-w-[200px]">
|
||||
<%= check_box_tag "assinaturas[#{grupo.id}][ativo]", '1', assinatura&.ativo,
|
||||
class: 'rounded border-white/20 bg-[#1a1a1a] text-orange-500' %>
|
||||
<%= grupo.nome %>
|
||||
<span class="text-xs text-gray-500">(<%= grupo.contatos.size %>)</span>
|
||||
</label>
|
||||
<%= select_tag "assinaturas[#{grupo.id}][canal]",
|
||||
options_for_select(GrupoEventoAssinatura::CANAIS.map { |c| [GrupoEventoAssinatura::CANAL_LABEL[c], c] }, assinatura&.canal || 'ambos'),
|
||||
class: 'bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<%= f.submit 'Salvar', class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %>
|
||||
<%= link_to 'Cancelar', admin_eventos_path, data: { turbo: false }, class: 'text-gray-400 hover:text-white text-sm' %>
|
||||
</div>
|
||||
<% end %>
|
||||
4
app/views/admin/eventos_notificacao/edit.html.erb
Normal file
4
app/views/admin/eventos_notificacao/edit.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="max-w-2xl space-y-6">
|
||||
<h1 class="text-2xl font-bold text-white">Editar evento</h1>
|
||||
<%= render "form", evento: @evento %>
|
||||
</div>
|
||||
90
app/views/admin/eventos_notificacao/index.html.erb
Normal file
90
app/views/admin/eventos_notificacao/index.html.erb
Normal file
@@ -0,0 +1,90 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Eventos de notificação</h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">
|
||||
O que dispara mensagem e para quais grupos. Você cria quantos eventos quiser —
|
||||
o <strong class="text-gray-300">gatilho</strong> sai de uma lista fixa, porque gatilho é código.
|
||||
</p>
|
||||
</div>
|
||||
<%= link_to 'Novo evento', new_admin_evento_path, data: { turbo: false },
|
||||
class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400' %>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<% @eventos.each do |evento| %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-white font-bold flex items-center gap-2 flex-wrap">
|
||||
<%= evento.nome %>
|
||||
<span class="px-2 py-0.5 rounded-full bg-white/5 border border-white/10 text-xs text-gray-400"><%= evento.gatilho_label %></span>
|
||||
<% if evento.sistema? %>
|
||||
<span class="px-2 py-0.5 rounded-full bg-blue-500/15 border border-blue-500/30 text-xs text-blue-300" title="Nasce com o sistema: pode desativar, não apagar">sistema</span>
|
||||
<% end %>
|
||||
<% unless evento.ativo? %>
|
||||
<span class="px-2 py-0.5 rounded-full bg-white/5 border border-white/10 text-xs text-gray-500">desativado</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="text-sm text-gray-400 mt-1"><%= evento.descricao.presence || '—' %></p>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
<% assinantes = evento.assinaturas.select(&:ativo) %>
|
||||
<% if assinantes.any? %>
|
||||
Grupos: <%= assinantes.map { |a| "#{a.grupo_contato.nome} (#{a.canal_label})" }.join(' · ') %>
|
||||
<% else %>
|
||||
<span class="text-amber-400">Nenhum grupo assina — este evento não manda nada.</span>
|
||||
<% end %>
|
||||
<% if evento.agendado? %>
|
||||
· <%= evento.frequencia == 'semanal' ? "toda #{Date::DAYNAMES[evento.dia_semana.to_i]}" : 'todo dia' %> às <%= format('%02d:00', evento.hora.to_i) %>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 whitespace-nowrap">
|
||||
<%# Editor de blocos, um por canal. O ✓ diz se já existe mensagem
|
||||
montada — sem ela o evento usa o texto padrão do sistema. %>
|
||||
<% %w[whatsapp email].each do |canal| %>
|
||||
<% montado = evento.template_utilizavel(canal).present? %>
|
||||
<%= link_to "#{montado ? '✓ ' : ''}#{canal == 'email' ? 'E-mail' : 'WhatsApp'}",
|
||||
template_admin_evento_path(evento, canal: canal), data: { turbo: false },
|
||||
title: montado ? 'Mensagem montada no editor' : 'Sem mensagem — usa o texto padrão',
|
||||
class: "text-sm #{montado ? 'text-green-400 hover:text-green-300' : 'text-gray-400 hover:text-orange-400'}" %>
|
||||
<% end %>
|
||||
<span class="text-gray-700">|</span>
|
||||
<%= link_to 'Editar', edit_admin_evento_path(evento), data: { turbo: false },
|
||||
class: 'text-gray-300 hover:text-orange-400 text-sm' %>
|
||||
<% if evento.apagavel? %>
|
||||
<%= button_to 'Excluir', admin_evento_path(evento), method: :delete,
|
||||
form: { data: { turbo_confirm: "Excluir o evento #{evento.nome}?" } },
|
||||
class: 'text-red-400 hover:text-red-300 text-sm inline' %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Disparo manual: manda mensagem DE VERDADE. O texto é digitado aqui
|
||||
porque o editor de blocos ainda não existe. %>
|
||||
<% if evento.manual? && evento.ativo? %>
|
||||
<details class="mt-4 border-t border-white/5 pt-4">
|
||||
<summary class="cursor-pointer text-sm text-orange-400 hover:text-orange-300 select-none">Disparar agora</summary>
|
||||
<% com_template = %w[whatsapp email].any? { |c| evento.template_utilizavel(c) } %>
|
||||
<%= form_with url: disparar_admin_evento_path(evento), method: :post,
|
||||
data: { turbo: false }, class: 'mt-3 space-y-3' do |f| %>
|
||||
<% if com_template %>
|
||||
<p class="text-xs text-green-400">
|
||||
Este evento tem mensagem montada no editor — ela será usada nos canais configurados.
|
||||
O texto abaixo só vale para o canal que ainda não tem mensagem própria.
|
||||
</p>
|
||||
<% end %>
|
||||
<%= f.text_field :assunto, placeholder: 'Assunto (só e-mail)', value: evento.nome,
|
||||
class: 'w-full bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||
<%= f.text_area :corpo, rows: 4, required: !com_template, placeholder: com_template ? 'Opcional — só para o canal sem mensagem montada' : 'Mensagem que será enviada…',
|
||||
class: 'w-full bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||
<%= f.submit 'Enviar para os grupos assinantes',
|
||||
data: { turbo_confirm: 'Isso envia mensagem de verdade para todos os contatos dos grupos assinantes. Confirmar?' },
|
||||
class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %>
|
||||
<% end %>
|
||||
</details>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
4
app/views/admin/eventos_notificacao/new.html.erb
Normal file
4
app/views/admin/eventos_notificacao/new.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="max-w-2xl space-y-6">
|
||||
<h1 class="text-2xl font-bold text-white">Novo evento</h1>
|
||||
<%= render "form", evento: @evento %>
|
||||
</div>
|
||||
36
app/views/admin/grupos_contato/_form.html.erb
Normal file
36
app/views/admin/grupos_contato/_form.html.erb
Normal file
@@ -0,0 +1,36 @@
|
||||
<%# URL explícita: a rota se chama `grupo`/`grupos` (ver routes.rb), então a
|
||||
rota polimórfica de `model:` procuraria admin_grupo_contato_path e estouraria.
|
||||
O `model:` continua valendo para nomear os campos (grupo_contato[...]). %>
|
||||
<%= form_with model: [:admin, grupo],
|
||||
url: (grupo.persisted? ? admin_grupo_path(grupo) : admin_grupos_path),
|
||||
data: { turbo: false }, class: 'space-y-5' do |f| %>
|
||||
<% if grupo.errors.any? %>
|
||||
<div class="bg-red-500/10 border border-red-500/30 rounded-xl px-4 py-3 text-red-300 text-sm">
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<% grupo.errors.full_messages.each do |msg| %><li><%= msg %></li><% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= f.label :nome, class: 'block text-gray-400 text-sm mb-1' %>
|
||||
<%= f.text_field :nome, required: true, placeholder: 'Diretoria',
|
||||
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :descricao, 'Descrição', class: 'block text-gray-400 text-sm mb-1' %>
|
||||
<%= f.text_field :descricao, placeholder: 'Quem acompanha o fechamento',
|
||||
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||
<%= f.check_box :ativo, class: 'rounded border-white/20 bg-[#1a1a1a] text-orange-500' %>
|
||||
Ativo (grupo inativo não recebe nada)
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<%= f.submit 'Salvar', class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %>
|
||||
<%= link_to 'Cancelar', admin_grupos_path, data: { turbo: false }, class: 'text-gray-400 hover:text-white text-sm' %>
|
||||
</div>
|
||||
<% end %>
|
||||
4
app/views/admin/grupos_contato/edit.html.erb
Normal file
4
app/views/admin/grupos_contato/edit.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="max-w-xl space-y-6">
|
||||
<h1 class="text-2xl font-bold text-white">Editar grupo</h1>
|
||||
<%= render 'form', grupo: @grupo %>
|
||||
</div>
|
||||
65
app/views/admin/grupos_contato/index.html.erb
Normal file
65
app/views/admin/grupos_contato/index.html.erb
Normal file
@@ -0,0 +1,65 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Grupos de contato</h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">
|
||||
É o grupo que assina os eventos — cadastrar alguém novo é escolher o grupo,
|
||||
não repetir a configuração pessoa por pessoa.
|
||||
</p>
|
||||
</div>
|
||||
<%= link_to 'Novo grupo', new_admin_grupo_path, data: { turbo: false },
|
||||
class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400' %>
|
||||
</div>
|
||||
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 overflow-hidden">
|
||||
<% if @grupos.empty? %>
|
||||
<p class="px-6 py-10 text-center text-gray-500 text-sm">
|
||||
Nenhum grupo ainda. Crie um (ex.: <strong class="text-gray-300">Diretoria</strong>,
|
||||
<strong class="text-gray-300">Motoristas</strong>) e depois cadastre os contatos dentro dele.
|
||||
</p>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-white/5 text-gray-400 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="text-left px-6 py-3 font-medium">Grupo</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Descrição</th>
|
||||
<th class="text-right px-4 py-3 font-medium">Contatos</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Assina os eventos</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Situação</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5">
|
||||
<% @grupos.each do |grupo| %>
|
||||
<tr class="hover:bg-white/5">
|
||||
<td class="px-6 py-2.5 text-white font-semibold"><%= grupo.nome %></td>
|
||||
<td class="px-4 py-2.5 text-gray-400"><%= grupo.descricao.presence || '—' %></td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<%= link_to grupo.contatos.size, admin_contatos_path(grupo_id: grupo.id),
|
||||
data: { turbo: false }, class: 'text-orange-400 hover:text-orange-300 underline' %>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-gray-300">
|
||||
<% nomes = grupo.assinaturas.select(&:ativo).map { |a| a.evento_notificacao.nome } %>
|
||||
<%= nomes.any? ? nomes.join(', ') : '— nenhum' %>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="<%= grupo.ativo? ? 'text-green-400' : 'text-gray-500' %>">
|
||||
<%= grupo.ativo? ? 'Ativo' : 'Inativo' %>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<%= link_to 'Editar', edit_admin_grupo_path(grupo), data: { turbo: false },
|
||||
class: 'text-gray-300 hover:text-orange-400 mr-3' %>
|
||||
<%= button_to 'Excluir', admin_grupo_path(grupo), method: :delete,
|
||||
form: { data: { turbo_confirm: "Excluir #{grupo.nome}? Os #{grupo.contatos.size} contato(s) ficam sem grupo e param de receber." } },
|
||||
class: 'text-red-400 hover:text-red-300 inline' %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
4
app/views/admin/grupos_contato/new.html.erb
Normal file
4
app/views/admin/grupos_contato/new.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="max-w-xl space-y-6">
|
||||
<h1 class="text-2xl font-bold text-white">Novo grupo de contato</h1>
|
||||
<%= render 'form', grupo: @grupo %>
|
||||
</div>
|
||||
314
app/views/admin/mensagem_templates/edit.html.erb
Normal file
314
app/views/admin/mensagem_templates/edit.html.erb
Normal file
@@ -0,0 +1,314 @@
|
||||
<%# Editor de blocos de UM evento em UM canal.
|
||||
O estado vive num array JS; o HTML dos cards é desenhado a partir dele e
|
||||
serializado num hidden ao salvar. Desenhar a partir do estado (em vez de ler
|
||||
o DOM) é o que faz arrastar, remover e reordenar não perderem conteúdo. %>
|
||||
<% outro_canal = @canal == 'email' ? 'whatsapp' : 'email' %>
|
||||
<div class="space-y-6" id="editor-mensagem"
|
||||
data-preview-url="<%= preview_template_admin_evento_path(@evento, canal: @canal) %>">
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Mensagem — <%= @evento.nome %></h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">
|
||||
Canal: <strong class="text-white"><%= @canal == 'email' ? 'E-mail' : 'WhatsApp' %></strong>
|
||||
· Gatilho: <%= @evento.gatilho_label %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= link_to "Editar #{outro_canal == 'email' ? 'e-mail' : 'WhatsApp'}",
|
||||
template_admin_evento_path(@evento, canal: outro_canal), data: { turbo: false },
|
||||
class: 'px-4 py-2.5 rounded-xl text-sm bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500' %>
|
||||
<%= link_to 'Voltar', admin_eventos_path, data: { turbo: false },
|
||||
class: 'px-4 py-2.5 rounded-xl text-sm bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500' %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @template.errors.any? %>
|
||||
<div class="bg-red-500/10 border border-red-500/30 rounded-xl px-4 py-3 text-red-300 text-sm">
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<% @template.errors.full_messages.each do |msg| %><li><%= msg %></li><% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with url: template_admin_evento_path(@evento, canal: @canal), method: :patch,
|
||||
data: { turbo: false }, id: 'form-template' do |f| %>
|
||||
<%= hidden_field_tag :blocos, @template.blocos.to_json, id: 'campo-blocos' %>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-[16rem_minmax(0,1fr)_22rem] gap-4">
|
||||
|
||||
<%# ── Paleta ──────────────────────────────────────────── %>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4">
|
||||
<p class="text-gray-400 text-xs font-medium uppercase tracking-wider mb-3">Blocos</p>
|
||||
<p class="text-xs text-gray-500 mb-3">Arraste para a mensagem — ou clique.</p>
|
||||
<div id="paleta" class="space-y-2">
|
||||
<% Notificacao::Blocos::CATALOGO.each do |tipo, meta| %>
|
||||
<div class="cursor-grab select-none bg-[#141414] border border-white/10 rounded-xl px-3 py-2.5 hover:border-orange-500"
|
||||
data-tipo="<%= tipo %>" title="<%= meta[:dica] %>">
|
||||
<span class="text-gray-500 mr-1">⠿</span>
|
||||
<span class="text-sm text-white"><%= meta[:rotulo] %></span>
|
||||
<span class="block text-xs text-gray-500 mt-0.5"><%= meta[:dica] %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ── Variáveis ─────────────────────────────────────── %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4">
|
||||
<p class="text-gray-400 text-xs font-medium uppercase tracking-wider mb-3">Variáveis</p>
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
Clique num campo da mensagem e depois numa variável para inseri-la ali.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<% @variaveis.each do |nome, meta| %>
|
||||
<button type="button" class="chip-variavel px-2 py-1 rounded-full bg-white/5 border border-white/10 text-xs text-orange-300 hover:border-orange-500"
|
||||
data-variavel="<%= nome %>" title="<%= meta[:descricao] %> (ex.: <%= meta[:amostra] %>)">
|
||||
<%= "{{#{nome}}}" %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ── Mensagem ────────────────────────────────────────── %>
|
||||
<div class="space-y-4">
|
||||
<% if @canal == 'email' %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4">
|
||||
<label class="block text-gray-400 text-sm mb-1" for="assunto">Assunto</label>
|
||||
<%= text_field_tag :assunto, @template.assunto, id: 'assunto', class: 'campo-editavel w-full bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4">
|
||||
<p class="text-gray-400 text-xs font-medium uppercase tracking-wider mb-3">Mensagem</p>
|
||||
<div id="canvas" class="space-y-3 min-h-[8rem]"></div>
|
||||
<p id="canvas-vazio" class="text-sm text-gray-500 text-center py-8">
|
||||
Arraste um bloco para cá. Sem blocos, o evento continua usando o texto padrão do sistema.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<%= submit_tag 'Salvar mensagem', class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||
<%= check_box_tag :ativo, '1', @template.ativo, class: 'rounded border-white/20 bg-[#1a1a1a] text-orange-500' %>
|
||||
Usar esta mensagem
|
||||
</label>
|
||||
<button type="button" id="btn-preview" class="px-4 py-2.5 rounded-xl text-sm bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500">
|
||||
Atualizar preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ── Preview ─────────────────────────────────────────── %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4 space-y-3">
|
||||
<p class="text-gray-400 text-xs font-medium uppercase tracking-wider">Preview</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
Com valores de exemplo, renderizado pelo mesmo código do envio.
|
||||
</p>
|
||||
<p id="preview-assunto" class="text-sm text-white font-semibold break-words"></p>
|
||||
<%# iframe sandbox: o HTML do e-mail é montado pelo nosso renderizador
|
||||
(que escapa tudo), mas exibir dentro de um sandbox sem scripts evita
|
||||
que os estilos do e-mail vazem para o admin — e vice-versa. %>
|
||||
<iframe id="preview-email" class="w-full h-[26rem] rounded-xl bg-white border-0 <%= 'hidden' unless @canal == 'email' %>"
|
||||
sandbox="" title="Preview do e-mail"></iframe>
|
||||
<pre id="preview-whatsapp" class="whitespace-pre-wrap break-words text-sm text-gray-200 bg-[#0b141a] rounded-xl p-4 min-h-[10rem] <%= 'hidden' if @canal == 'email' %>"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var raiz = document.getElementById('editor-mensagem');
|
||||
if (!raiz) return;
|
||||
|
||||
var CATALOGO = <%= raw Notificacao::Blocos::CATALOGO.to_json %>;
|
||||
var CANAL = <%= raw @canal.to_json %>;
|
||||
var campo = document.getElementById('campo-blocos');
|
||||
var canvas = document.getElementById('canvas');
|
||||
var vazio = document.getElementById('canvas-vazio');
|
||||
|
||||
var blocos;
|
||||
try { blocos = JSON.parse(campo.value) || []; } catch (e) { blocos = []; }
|
||||
|
||||
// Último campo focado: é nele que a variável clicada é inserida.
|
||||
var ultimoCampo = null;
|
||||
document.addEventListener('focusin', function (e) {
|
||||
if (e.target.classList && e.target.classList.contains('campo-editavel')) ultimoCampo = e.target;
|
||||
});
|
||||
|
||||
function escapar(t) {
|
||||
return String(t == null ? '' : t)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function blocoVazio(tipo) {
|
||||
var b = { tipo: tipo };
|
||||
var campos = (CATALOGO[tipo] || {}).campos || {};
|
||||
Object.keys(campos).forEach(function (c) { b[c] = campos[c] === 'lista' ? [] : ''; });
|
||||
return b;
|
||||
}
|
||||
|
||||
function campoHtml(indice, chave, especie, valor) {
|
||||
var id = 'b' + indice + '_' + chave;
|
||||
var rotulo = chave.charAt(0).toUpperCase() + chave.slice(1);
|
||||
if (especie === 'multilinha') {
|
||||
return '<label class="block text-xs text-gray-500 mb-1" for="' + id + '">' + rotulo + '</label>' +
|
||||
'<textarea id="' + id + '" rows="3" data-indice="' + indice + '" data-campo="' + chave + '"' +
|
||||
' class="campo-editavel campo-bloco w-full bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-orange-500">' +
|
||||
escapar(valor) + '</textarea>';
|
||||
}
|
||||
return '<label class="block text-xs text-gray-500 mb-1" for="' + id + '">' + rotulo + '</label>' +
|
||||
'<input id="' + id + '" type="text" value="' + escapar(valor) + '" data-indice="' + indice + '" data-campo="' + chave + '"' +
|
||||
' class="campo-editavel campo-bloco w-full bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-orange-500">';
|
||||
}
|
||||
|
||||
function listaHtml(indice, linhas) {
|
||||
var html = '<p class="text-xs text-gray-500 mb-1">Linhas da tabela</p>';
|
||||
(linhas || []).forEach(function (linha, j) {
|
||||
html += '<div class="flex gap-2 mb-2">' +
|
||||
'<input type="text" value="' + escapar(linha.rotulo) + '" placeholder="Rótulo" data-indice="' + indice + '" data-linha="' + j + '" data-parte="rotulo"' +
|
||||
' class="campo-editavel campo-linha flex-1 bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-orange-500">' +
|
||||
'<input type="text" value="' + escapar(linha.valor) + '" placeholder="Valor" data-indice="' + indice + '" data-linha="' + j + '" data-parte="valor"' +
|
||||
' class="campo-editavel campo-linha flex-1 bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-orange-500">' +
|
||||
'<button type="button" class="btn-remove-linha px-2 text-red-400 hover:text-red-300" data-indice="' + indice + '" data-linha="' + j + '" title="Remover linha">✕</button>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '<button type="button" class="btn-add-linha text-xs text-orange-400 hover:text-orange-300" data-indice="' + indice + '">+ linha</button>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function desenhar() {
|
||||
canvas.innerHTML = '';
|
||||
vazio.classList.toggle('hidden', blocos.length > 0);
|
||||
|
||||
blocos.forEach(function (bloco, i) {
|
||||
var meta = CATALOGO[bloco.tipo] || { rotulo: bloco.tipo, campos: {} };
|
||||
var card = document.createElement('div');
|
||||
card.className = 'bg-[#141414] border border-white/10 rounded-xl p-3';
|
||||
card.dataset.indice = i;
|
||||
|
||||
var corpo = '<div class="flex items-center justify-between mb-2">' +
|
||||
'<span class="text-sm text-white"><span class="alca cursor-grab text-gray-500 mr-1">⠿</span>' + escapar(meta.rotulo) + '</span>' +
|
||||
'<button type="button" class="btn-remove text-red-400 hover:text-red-300 text-sm" data-indice="' + i + '" title="Remover bloco">✕</button>' +
|
||||
'</div>';
|
||||
|
||||
var campos = meta.campos || {};
|
||||
Object.keys(campos).forEach(function (chave) {
|
||||
corpo += '<div class="mb-2">' +
|
||||
(campos[chave] === 'lista' ? listaHtml(i, bloco[chave]) : campoHtml(i, chave, campos[chave], bloco[chave])) +
|
||||
'</div>';
|
||||
});
|
||||
if (!Object.keys(campos).length) {
|
||||
corpo += '<p class="text-xs text-gray-500">Sem campos — só a linha separadora.</p>';
|
||||
}
|
||||
|
||||
card.innerHTML = corpo;
|
||||
canvas.appendChild(card);
|
||||
});
|
||||
|
||||
campo.value = JSON.stringify(blocos);
|
||||
}
|
||||
|
||||
// ── Edição dos campos ──────────────────────────────────────
|
||||
canvas.addEventListener('input', function (e) {
|
||||
var alvo = e.target;
|
||||
if (alvo.classList.contains('campo-bloco')) {
|
||||
blocos[+alvo.dataset.indice][alvo.dataset.campo] = alvo.value;
|
||||
} else if (alvo.classList.contains('campo-linha')) {
|
||||
var bloco = blocos[+alvo.dataset.indice];
|
||||
bloco.linhas = bloco.linhas || [];
|
||||
bloco.linhas[+alvo.dataset.linha][alvo.dataset.parte] = alvo.value;
|
||||
} else { return; }
|
||||
campo.value = JSON.stringify(blocos); // sem redesenhar: perderia o cursor
|
||||
});
|
||||
|
||||
canvas.addEventListener('click', function (e) {
|
||||
var alvo = e.target;
|
||||
if (alvo.classList.contains('btn-remove')) {
|
||||
blocos.splice(+alvo.dataset.indice, 1); desenhar();
|
||||
} else if (alvo.classList.contains('btn-add-linha')) {
|
||||
var b = blocos[+alvo.dataset.indice];
|
||||
b.linhas = (b.linhas || []).concat([{ rotulo: '', valor: '' }]); desenhar();
|
||||
} else if (alvo.classList.contains('btn-remove-linha')) {
|
||||
blocos[+alvo.dataset.indice].linhas.splice(+alvo.dataset.linha, 1); desenhar();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Paleta: clicar adiciona, arrastar insere na posição ────
|
||||
document.getElementById('paleta').addEventListener('click', function (e) {
|
||||
var item = e.target.closest('[data-tipo]');
|
||||
if (!item) return;
|
||||
blocos.push(blocoVazio(item.dataset.tipo));
|
||||
desenhar();
|
||||
});
|
||||
|
||||
if (window.Sortable) {
|
||||
Sortable.create(document.getElementById('paleta'), {
|
||||
group: { name: 'blocos', pull: 'clone', put: false },
|
||||
sort: false
|
||||
});
|
||||
Sortable.create(canvas, {
|
||||
group: 'blocos',
|
||||
handle: '.alca',
|
||||
animation: 150,
|
||||
onAdd: function (evt) {
|
||||
// O clone da paleta é DOM sem estado: converte em bloco e redesenha.
|
||||
var tipo = evt.item.dataset.tipo;
|
||||
evt.item.parentNode.removeChild(evt.item);
|
||||
blocos.splice(evt.newIndex, 0, blocoVazio(tipo));
|
||||
desenhar();
|
||||
},
|
||||
onUpdate: function (evt) {
|
||||
var movido = blocos.splice(evt.oldIndex, 1)[0];
|
||||
blocos.splice(evt.newIndex, 0, movido);
|
||||
desenhar();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Variáveis: insere no cursor do último campo focado ─────
|
||||
document.querySelectorAll('.chip-variavel').forEach(function (chip) {
|
||||
chip.addEventListener('click', function () {
|
||||
var texto = '{{' + chip.dataset.variavel + '}}';
|
||||
if (!ultimoCampo) { alert('Clique primeiro no campo onde a variável deve entrar.'); return; }
|
||||
var ini = ultimoCampo.selectionStart != null ? ultimoCampo.selectionStart : ultimoCampo.value.length;
|
||||
var fim = ultimoCampo.selectionEnd != null ? ultimoCampo.selectionEnd : ini;
|
||||
ultimoCampo.value = ultimoCampo.value.slice(0, ini) + texto + ultimoCampo.value.slice(fim);
|
||||
ultimoCampo.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
ultimoCampo.focus();
|
||||
ultimoCampo.selectionStart = ultimoCampo.selectionEnd = ini + texto.length;
|
||||
});
|
||||
});
|
||||
|
||||
// ── Preview: renderizado no servidor, pelo mesmo código do envio ──
|
||||
var token = document.querySelector('meta[name="csrf-token"]');
|
||||
document.getElementById('btn-preview').addEventListener('click', function () {
|
||||
var assunto = document.getElementById('assunto');
|
||||
fetch(raiz.dataset.previewUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-Token': token ? token.content : ''
|
||||
},
|
||||
body: JSON.stringify({ blocos: campo.value, assunto: assunto ? assunto.value : '' })
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (dados) {
|
||||
document.getElementById('preview-assunto').textContent = dados.assunto || '';
|
||||
if (CANAL === 'email') {
|
||||
document.getElementById('preview-email').srcdoc = dados.html || '';
|
||||
} else {
|
||||
document.getElementById('preview-whatsapp').textContent = dados.html || '';
|
||||
}
|
||||
})
|
||||
.catch(function () { alert('Não foi possível gerar o preview agora.'); });
|
||||
});
|
||||
|
||||
desenhar();
|
||||
document.getElementById('btn-preview').click();
|
||||
})();
|
||||
</script>
|
||||
95
app/views/admin/notificacao_envios/index.html.erb
Normal file
95
app/views/admin/notificacao_envios/index.html.erb
Normal file
@@ -0,0 +1,95 @@
|
||||
<% ctx = { status: params[:status], canal: params[:canal] }.compact_blank %>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Envios</h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">
|
||||
O que saiu, para quem e se chegou. Uma falha de envio nunca trava o fechamento —
|
||||
então é aqui que ela aparece.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<% [['', 'Todos'], ['enviado', 'Enviados'], ['falhou', 'Falhados'], ['pendente', 'Pendentes']].each do |valor, rotulo| %>
|
||||
<% ativo = params[:status].to_s == valor %>
|
||||
<%= link_to rotulo, admin_envios_path(ctx.merge(status: valor.presence).compact), data: { turbo: false },
|
||||
class: "px-3 py-2 rounded-xl text-sm border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %>
|
||||
<% end %>
|
||||
<span class="text-gray-600">|</span>
|
||||
<% [['', 'Todos os canais'], ['whatsapp', 'WhatsApp'], ['email', 'E-mail']].each do |valor, rotulo| %>
|
||||
<% ativo = params[:canal].to_s == valor %>
|
||||
<%= link_to rotulo, admin_envios_path(ctx.merge(canal: valor.presence).compact), data: { turbo: false },
|
||||
class: "px-3 py-2 rounded-xl text-sm border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %>
|
||||
<% end %>
|
||||
<% if @total_falhas.positive? %>
|
||||
<span class="ml-auto text-sm text-red-400"><%= @total_falhas %> falha(s) neste filtro</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 overflow-hidden">
|
||||
<% if @envios.empty? %>
|
||||
<p class="px-6 py-10 text-center text-gray-500 text-sm">Nenhum envio registrado ainda.</p>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-white/5 text-gray-400 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="text-left px-6 py-3 font-medium">Quando</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Evento</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Destinatário</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Canal</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Situação</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Mensagem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5">
|
||||
<% @envios.each do |envio| %>
|
||||
<tr class="hover:bg-white/5 align-top">
|
||||
<td class="px-6 py-2.5 text-gray-400 whitespace-nowrap"><%= envio.created_at.strftime('%d/%m/%Y %H:%M') %></td>
|
||||
<td class="px-4 py-2.5 text-gray-300"><%= envio.evento_notificacao&.nome || '—' %></td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="text-white"><%= envio.contato&.nome || envio.user&.nome || '—' %></span>
|
||||
<span class="block text-xs text-gray-500 font-mono"><%= envio.destino %></span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-gray-300"><%= envio.canal == 'whatsapp' ? 'WhatsApp' : 'E-mail' %></td>
|
||||
<td class="px-4 py-2.5">
|
||||
<% cor = { 'enviado' => 'text-green-400', 'falhou' => 'text-red-400' }.fetch(envio.status, 'text-yellow-400') %>
|
||||
<span class="<%= cor %>"><%= envio.status.capitalize %></span>
|
||||
<% if envio.falhou? && envio.erro.present? %>
|
||||
<span class="block text-xs text-red-300/80 max-w-[22rem]"><%= envio.erro.truncate(160) %></span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-gray-400 max-w-[24rem]">
|
||||
<span class="block truncate" title="<%= envio.corpo %>"><%= envio.corpo.to_s.truncate(90) %></span>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<% if @pagy.pages > 1 %>
|
||||
<% pag = ->(p) { admin_envios_path(ctx.merge(page: p)) } %>
|
||||
<nav class="flex items-center justify-center gap-1.5 py-4 flex-wrap border-t border-white/5">
|
||||
<% if @pagy.prev %>
|
||||
<%= link_to '‹', pag.call(@pagy.prev), data: { turbo: false },
|
||||
class: 'min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg border border-[#2a2a2a] text-white hover:border-orange-500' %>
|
||||
<% end %>
|
||||
<% @pagy.series.each do |item| %>
|
||||
<% if item == :gap %>
|
||||
<span class="px-2 text-gray-500">…</span>
|
||||
<% elsif item.is_a?(String) %>
|
||||
<span class="min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg bg-orange-500 text-black font-bold"><%= item %></span>
|
||||
<% else %>
|
||||
<%= link_to item, pag.call(item), data: { turbo: false },
|
||||
class: 'min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg border border-[#2a2a2a] text-white hover:border-orange-500' %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if @pagy.next %>
|
||||
<%= link_to '›', pag.call(@pagy.next), data: { turbo: false },
|
||||
class: 'min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg border border-[#2a2a2a] text-white hover:border-orange-500' %>
|
||||
<% end %>
|
||||
</nav>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
114
app/views/admin/whatsapp_sessoes/show.html.erb
Normal file
114
app/views/admin/whatsapp_sessoes/show.html.erb
Normal file
@@ -0,0 +1,114 @@
|
||||
<div class="max-w-3xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">WhatsApp — conexão</h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">
|
||||
Pareia o número da empresa com este servidor lendo um QR code, como no WhatsApp Web.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%# O aviso fica na TELA, não só no código: quem parear precisa saber o que
|
||||
está aceitando. %>
|
||||
<div class="bg-amber-500/10 border border-amber-500/30 rounded-2xl px-5 py-4 text-sm text-amber-200 space-y-1">
|
||||
<p class="font-bold">⚠️ Este canal não é oficial.</p>
|
||||
<p>
|
||||
A conexão por QR usa a porta do WhatsApp Web por engenharia reversa. É gratuita e ilimitada,
|
||||
mas está <strong>fora dos Termos do WhatsApp</strong> — a Meta pode banir o número sem aviso.
|
||||
Use um <strong>chip dedicado</strong>, nunca o número principal da operação.
|
||||
</p>
|
||||
<p>
|
||||
Disparo em rajada é o que mais causa banimento. O intervalo entre mensagens fica em
|
||||
<%= link_to 'Configurações → Notificações', admin_configuracao_notificacao_path,
|
||||
data: { turbo: false }, class: 'underline hover:text-white' %>
|
||||
(hoje: <strong><%= @config.intervalo_envio %>s</strong>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6" data-estado-url="<%= status_admin_whatsapp_sessao_path %>" id="painel-whatsapp">
|
||||
<% if @estado['conectado'] %>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-3 h-3 rounded-full bg-green-500"></span>
|
||||
<div>
|
||||
<p class="text-white font-bold">Conectado</p>
|
||||
<p class="text-sm text-gray-400">
|
||||
Número <span class="font-mono text-gray-300"><%= @estado['numero'] %></span>
|
||||
<% if @config.whatsapp_conectado_em %> · desde <%= l @config.whatsapp_conectado_em, format: :short rescue @config.whatsapp_conectado_em %><% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 border-t border-white/5 pt-5 space-y-4">
|
||||
<%= form_with url: testar_admin_whatsapp_sessao_path, method: :post, data: { turbo: false },
|
||||
class: 'flex flex-wrap items-end gap-3' do |f| %>
|
||||
<div>
|
||||
<%= f.label :numero, 'Enviar teste para', class: 'block text-gray-400 text-sm mb-1' %>
|
||||
<%= f.text_field :numero, placeholder: '(11) 92005-1157',
|
||||
class: 'bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||
</div>
|
||||
<%= f.submit 'Enviar teste',
|
||||
class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %>
|
||||
<% end %>
|
||||
|
||||
<%= button_to 'Desconectar', desconectar_admin_whatsapp_sessao_path, method: :delete,
|
||||
form: { data: { turbo_confirm: 'Isso encerra a sessão e para TODOS os envios de WhatsApp até você ler o QR de novo. Confirmar?' } },
|
||||
class: 'text-red-400 hover:text-red-300 text-sm' %>
|
||||
</div>
|
||||
|
||||
<% elsif @estado['qr'].present? %>
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<p class="text-white font-bold">Leia o QR code</p>
|
||||
<p class="text-sm text-gray-400 max-w-md">
|
||||
No celular do chip dedicado: WhatsApp → <strong>Aparelhos conectados</strong> →
|
||||
<strong>Conectar um aparelho</strong> → aponte para o código abaixo.
|
||||
</p>
|
||||
<%# O QR expira em ~20s e a ponte gera outro — o polling abaixo troca a
|
||||
imagem sozinho, senão o operador ficaria lendo um código morto. %>
|
||||
<img src="<%= @estado['qr'] %>" alt="QR code do WhatsApp" width="320" height="320"
|
||||
class="rounded-xl bg-white p-3" id="qr-whatsapp">
|
||||
<p class="text-xs text-gray-500">O código se renova sozinho a cada poucos segundos.</p>
|
||||
</div>
|
||||
|
||||
<% else %>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="w-3 h-3 rounded-full bg-red-500 mt-1.5"></span>
|
||||
<div>
|
||||
<p class="text-white font-bold">Desconectado</p>
|
||||
<p class="text-sm text-gray-400 mt-1">
|
||||
<%= @estado['erro'].presence || @estado['ultimo_erro'].presence ||
|
||||
'A ponte ainda não gerou um QR code. Aguarde alguns segundos e recarregue.' %>
|
||||
</p>
|
||||
<% unless @cliente.configurado? %>
|
||||
<p class="text-sm text-amber-300 mt-3">
|
||||
Falta configurar a ponte: defina <code>WHATSAPP_TOKEN</code> no <code>.env</code>
|
||||
(o mesmo valor do container <code>whatsapp</code>) e suba os containers.
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Polling do estado enquanto não está conectado: o QR expira sozinho e a
|
||||
// página precisa buscar o novo, senão o operador lê um código já morto.
|
||||
(function () {
|
||||
var painel = document.getElementById('painel-whatsapp');
|
||||
if (!painel) return;
|
||||
var conectado = <%= @estado['conectado'] ? 'true' : 'false' %>;
|
||||
if (conectado) return;
|
||||
|
||||
var url = painel.dataset.estadoUrl;
|
||||
setInterval(function () {
|
||||
fetch(url, { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (dados) {
|
||||
// Conectou: recarrega para mostrar o painel de conectado.
|
||||
if (dados.conectado) { window.location.reload(); return; }
|
||||
var img = document.getElementById('qr-whatsapp');
|
||||
if (dados.qr && img) { img.src = dados.qr; }
|
||||
else if (dados.qr && !img) { window.location.reload(); }
|
||||
})
|
||||
.catch(function () { /* ponte fora do ar: a próxima tentativa resolve */ });
|
||||
}, 8000);
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user