diff --git a/.env.example b/.env.example index c3b4c29..35c17f0 100644 --- a/.env.example +++ b/.env.example @@ -79,10 +79,11 @@ NOTIFICACAO_SECRET= APP_HOST=localhost:3000 APP_NAME=Reem Logística -# ── WhatsApp (Baileys — sessão pareada por QR code) ──────────────────────── +# ── WhatsApp (ponte própria — sessão pareada por QR code) ────────────────── # Token compartilhado entre o Rails e o container `whatsapp`. Qualquer string # longa e aleatória: `openssl rand -hex 32`. Sem ele a ponte recusa TUDO. WHATSAPP_TOKEN=troque_por_uma_string_aleatoria_longa # URL da ponte dentro da rede do compose (não mexa, a menos que troque o nome # do serviço no docker-compose.yml). -BAILEYS_URL=http://whatsapp:3001 +# (o nome antigo `BAILEYS_URL` ainda é aceito, para .env já em produção) +WHATSAPP_URL=http://whatsapp:3001 diff --git a/app/models/configuracao_notificacao.rb b/app/models/configuracao_notificacao.rb index 5ff8836..55d1ad4 100644 --- a/app/models/configuracao_notificacao.rb +++ b/app/models/configuracao_notificacao.rb @@ -123,8 +123,13 @@ class ConfiguracaoNotificacao < ApplicationRecord def baileys_exigido? = whatsapp_ativo? && baileys? # URL e token da ponte: banco > .env, mesma hierarquia do resto da tela. + # + # `WHATSAPP_URL` é o nome atual — a tela e o .env.example falam em "ponte do + # WhatsApp", não no nome da biblioteca. `BAILEYS_URL` continua sendo lido para + # não quebrar quem já tem a variável antiga no .env em produção. def baileys_url_efetiva - baileys_url.presence || ENV['BAILEYS_URL'].presence || BAILEYS_URL_PADRAO + baileys_url.presence || ENV['WHATSAPP_URL'].presence || + ENV['BAILEYS_URL'].presence || BAILEYS_URL_PADRAO end def baileys_token_efetivo diff --git a/app/services/notificacao/cliente_whatsapp.rb b/app/services/notificacao/cliente_whatsapp.rb index 5c0581c..bbc57d4 100644 --- a/app/services/notificacao/cliente_whatsapp.rb +++ b/app/services/notificacao/cliente_whatsapp.rb @@ -97,6 +97,12 @@ module Notificacao def mensagem_de_erro(resposta, dados) return 'Token da ponte do WhatsApp inválido.' if resposta.code == '401' return 'WhatsApp desconectado — leia o QR code na tela.' if resposta.code == '503' + # 404 = a ponte respondeu, mas não conhece a rota: a imagem do container + # `whatsapp` é anterior a ela. "rota desconhecida" cru na tela não diz o + # que fazer; o conserto é rebuildar a ponte, não mexer no Rails. + if resposta.code == '404' + return 'Ponte do WhatsApp desatualizada (não conhece esta rota) — refaça o build do container `whatsapp`.' + end dados['erro'].presence || "HTTP #{resposta.code}" end diff --git a/app/views/admin/configuracao_notificacoes/show.html.erb b/app/views/admin/configuracao_notificacoes/show.html.erb index 8714221..8487387 100644 --- a/app/views/admin/configuracao_notificacoes/show.html.erb +++ b/app/views/admin/configuracao_notificacoes/show.html.erb @@ -181,7 +181,7 @@ - <%# Provedor: sem este seletor as colunas do Baileys existiam no banco mas + <%# Provedor: sem este seletor as colunas da ponte existiam no banco mas não havia como sair do Twilio pela tela — o pareamento por QR ficava inalcançável. %>
@@ -206,13 +206,15 @@
- <%# ── Modo QR (Baileys) ───────────────────────────────── %> + <%# ── Modo QR: a ponte própria do WhatsApp ─────────────── %>
-

Modo QR code

+

+ <%= icone :telefone, tamanho: 'w-4 h-4', espaco: false %> Modo QR code +

⚠️ Canal não oficial — a Meta pode banir o número. Use um chip dedicado. Em branco, os dois campos abaixo usam - BAILEYS_URL e + WHATSAPP_URL e WHATSAPP_TOKEN do .env.

@@ -230,7 +232,9 @@
-

Modo Twilio

+

+ <%= icone :chave, tamanho: 'w-4 h-4', espaco: false %> Modo Twilio +

Credenciais em Twilio Console → Account Info

diff --git a/app/views/admin/contatos/_form.html.erb b/app/views/admin/contatos/_form.html.erb index 0f0a5ad..d4c02db 100644 --- a/app/views/admin/contatos/_form.html.erb +++ b/app/views/admin/contatos/_form.html.erb @@ -1,8 +1,27 @@ -<%= form_with model: [:admin, contato], data: { turbo: false }, class: 'space-y-5' do |f| %> +<% + input = 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1 + focus:ring-[#f97316] transition-colors' + rotulo = 'block text-sm font-medium text-gray-300 mb-1.5' + ajuda = 'text-gray-500 text-xs mt-1.5' + toggle = "w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer + peer-checked:after:translate-x-full peer-checked:after:border-white + after:content-[''] after:absolute after:top-[2px] after:left-[2px] + after:bg-white after:border-gray-300 after:border after:rounded-full + after:h-5 after:w-5 after:transition-all peer-checked:bg-[#f97316]" +%> +<%= form_with model: [:admin, contato], data: { turbo: false }, + class: 'bg-[#1a1a1a] rounded-2xl border border-white/5 p-8 space-y-6' do |f| %> + <% if contato.errors.any? %> -
+
+

+ <%= pluralize(contato.errors.count, 'erro', 'erros') %> encontrado<%= contato.errors.count > 1 ? 's' : '' %>: +

    - <% contato.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %> + <% contato.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %>
<% end %> @@ -10,42 +29,46 @@ <%# Pessoa x grupo do WhatsApp. O JavaScript no fim troca os blocos; sem JS a página continua utilizável — os dois blocos ficam visíveis e o model limpa o que não pertence ao tipo escolhido. %> -
- <%= f.label :tipo, 'Tipo de destinatário', class: 'block text-gray-400 text-sm mb-1' %> - <%= f.select :tipo, - [['Pessoa (número ou e-mail)', 'pessoa'], ['Grupo do WhatsApp', 'grupo_whatsapp']], {}, - 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', - id: 'contato-tipo' %> -
- -
- <%= 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' %> -

Como aparece nas listas e em {{contato}}.

-
- -
-
- <%= 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' %> -

Pode digitar com máscara — é normalizado ao salvar.

+
+
+ <%= f.label :tipo, 'Tipo de destinatário', class: rotulo %> + <%= f.select :tipo, + [['Pessoa (número ou e-mail)', 'pessoa'], ['Grupo do WhatsApp', 'grupo_whatsapp']], {}, + class: "#{input} cursor-pointer", id: 'contato-tipo' %>
+
- <%= 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' %> + <%= f.label :nome, class: rotulo %> + <%= f.text_field :nome, required: true, placeholder: 'Maria Souza', class: input %> +

Como aparece nas listas e em {{contato}}.

+ <%# ── Pessoa ──────────────────────────────────────────────── %> +
+

Como falar com esta pessoa

+
+
+ <%= f.label :telefone, 'WhatsApp', class: rotulo %> + <%= f.text_field :telefone, placeholder: '(11) 92005-1157', class: input %> +

Pode digitar com máscara — é normalizado ao salvar.

+
+
+ <%= f.label :email, 'E-mail', class: rotulo %> + <%= f.email_field :email, placeholder: 'maria@empresa.com', class: input %> +

Sem e-mail, este contato só recebe WhatsApp.

+
+
+
+ + <%# ── Grupo do WhatsApp ───────────────────────────────────── %> <%# O identificador do grupo é opaco ("120363…@g.us") — digitar na mão é pedir erro. A lista vem da ponte, com o número já conectado. %> -
- <%= f.label :whatsapp_grupo_jid, 'Grupo do WhatsApp', class: 'block text-gray-400 text-sm mb-1' %> -
- <% if contato.whatsapp_grupo_jid.present? %>
<%= f.hidden_field :whatsapp_grupo_nome, id: 'grupo-nome' %> -

+

Só aparecem grupos em que o número conectado já é membro. Grupo com "somente administradores" recusa o envio se o número não for admin.

-
- <%= 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' %> -

É o grupo que define quais eventos este contato recebe.

+ <%# ── Assinatura e situação ───────────────────────────────── %> +
+
+ <%= f.label :grupo_contato_id, 'Grupo', class: rotulo %> + <%= f.collection_select :grupo_contato_id, @grupos, :id, :nome, + { include_blank: 'Sem grupo (não recebe nada)' }, class: "#{input} cursor-pointer" %> +

É o grupo que define quais eventos este contato recebe.

+
+ +
+ <%= f.label :observacao, 'Observação', class: rotulo %> + <%= f.text_field :observacao, placeholder: 'Opcional', class: input %> +
+ +
+
+ <%= f.label :ativo, 'Contato ativo', class: 'block text-sm font-medium text-gray-300' %> +

Contato inativo continua cadastrado, mas não recebe nada

+
+ +
-
- <%= 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' %> -
- - - -
- <%= 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' %> +
+ <%= link_to 'Cancelar', admin_contatos_path, data: { turbo: false }, + class: 'px-6 py-3 text-gray-400 hover:text-white border border-white/10 + hover:border-white/20 rounded-xl transition-colors' %> + <%= f.submit contato.new_record? ? 'Criar Contato' : 'Salvar Alterações', + class: 'px-8 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold + rounded-xl transition-colors cursor-pointer min-h-[48px]' %>
<% end %> @@ -101,6 +139,10 @@ var botao = document.getElementById('btn-carregar-grupos'); if (!tipo || !pessoa || !grupo) return; + // O rótulo do botão vive num próprio: trocar o textContent do botão + // inteiro apagaria o ícone junto. + var botaoTexto = botao.querySelector('[data-btn-texto]'); + function alternar() { var ehGrupo = tipo.value === 'grupo_whatsapp'; pessoa.classList.toggle('hidden', ehGrupo); @@ -109,6 +151,15 @@ tipo.addEventListener('change', alternar); alternar(); + // Estado do aviso: erro em vermelho, sucesso em verde, neutro em cinza — + // o texto sozinho não dizia se a busca deu certo. + function avisar(texto, estado) { + aviso.textContent = texto; + aviso.className = 'text-xs mt-1.5 ' + ( + estado === 'erro' ? 'text-red-400' : estado === 'ok' ? 'text-green-400' : 'text-gray-500' + ); + } + // O nome do grupo é gravado junto com o id: a tela precisa mostrar algo // legível mesmo com a ponte fora do ar (o id sozinho não diz nada). select.addEventListener('change', function () { @@ -118,13 +169,13 @@ botao.addEventListener('click', function () { botao.disabled = true; - botao.textContent = 'Buscando…'; + botaoTexto.textContent = 'Buscando…'; fetch(grupo.dataset.url, { headers: { Accept: 'application/json' } }) .then(function (r) { return r.json(); }) .then(function (dados) { - if (dados.erro) { aviso.textContent = dados.erro; return; } + if (dados.erro) { avisar(dados.erro, 'erro'); return; } if (!dados.grupos.length) { - aviso.textContent = 'Nenhum grupo encontrado — o número conectado não participa de nenhum.'; + avisar('Nenhum grupo encontrado — o número conectado não participa de nenhum.', 'erro'); return; } select.innerHTML = ''; @@ -138,10 +189,10 @@ o.textContent = g.nome + ' (' + g.participantes + ')' + alerta; select.appendChild(o); }); - aviso.textContent = dados.grupos.length + ' grupo(s) encontrado(s).'; + avisar(dados.grupos.length + ' grupo(s) encontrado(s) — escolha um acima.', 'ok'); }) - .catch(function () { aviso.textContent = 'Não foi possível falar com a ponte do WhatsApp.'; }) - .finally(function () { botao.disabled = false; botao.textContent = 'Buscar grupos'; }); + .catch(function () { avisar('Não foi possível falar com a ponte do WhatsApp.', 'erro'); }) + .finally(function () { botao.disabled = false; botaoTexto.textContent = 'Buscar grupos'; }); }); })(); diff --git a/app/views/admin/contatos/edit.html.erb b/app/views/admin/contatos/edit.html.erb index a584faa..7698323 100644 --- a/app/views/admin/contatos/edit.html.erb +++ b/app/views/admin/contatos/edit.html.erb @@ -1,4 +1,8 @@ -
-

Editar contato

- <%= render "form", contato: @contato %> +
+
+

Editar: <%= @contato.nome %>

+

Atualize os dados deste contato

+
+ <%= render 'shared/flash' %> + <%= render 'form', contato: @contato %>
diff --git a/app/views/admin/contatos/index.html.erb b/app/views/admin/contatos/index.html.erb index 12fa0bb..e622273 100644 --- a/app/views/admin/contatos/index.html.erb +++ b/app/views/admin/contatos/index.html.erb @@ -1,85 +1,146 @@
+ + <%# Header — mesmo padrão de Usuários: título, subtítulo e ação primária %>
-

Contatos

+

+ <%= icone :usuarios, espaco: false %> Contatos +

Quem recebe as mensagens. Quem tem só número recebe só WhatsApp; quem tem só e-mail recebe só e-mail.

- <%= 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' %> + <%= link_to new_admin_contato_path(grupo_id: params[:grupo_id]), data: { turbo: false }, + class: 'inline-flex items-center gap-2 px-5 py-3 bg-[#f97316] hover:bg-orange-500 + text-white font-semibold rounded-xl transition-colors min-h-[48px] whitespace-nowrap' do %> + <%= icone :adicionar, cor: nil, tamanho: 'w-5 h-5', espaco: false %> Novo contato + <% end %>
-
- - +
+ - + + <% if params[:q].present? || params[:grupo_id].present? %> + <%= link_to 'Limpar', admin_contatos_path, data: { turbo: false }, + class: 'text-gray-500 hover:text-white text-sm transition-colors' %> + <% end %> + <%# Tabela %>
- <% if @contatos.empty? %> -

Nenhum contato encontrado.

- <% else %> -
- - - - - - - - - - - - - <% @contatos.each do |contato| %> - - - - - - + + + + + + <% end %> + + <% if @contatos.empty? %> + + + + <% end %> + +
NomeWhatsAppE-mailGrupoSituação
- <%= contato.nome %> - <% if contato.grupo_whatsapp? %> - grupo - <% end %> - - <% if contato.grupo_whatsapp? %> - <%= contato.whatsapp_grupo_nome.presence || contato.whatsapp_grupo_jid %> - <% else %> - <%= contato.telefone.presence || '—' %> - <% end %> - <%= contato.email.presence || '—' %> - <% if contato.grupo_contato %> - <%= contato.grupo_contato.nome %> - <% else %> - sem grupo - <% end %> - - - <%= contato.ativo? ? 'Ativo' : 'Inativo' %> +
+ + + + + + + + + + + + + <% @contatos.each do |contato| %> + + + - - - <% end %> - -
NomeWhatsAppE-mailGrupoSituaçãoAções
+
+ <%# Avatar: inicial para pessoa, ícone para grupo — dá para + distinguir os dois tipos sem ler a linha inteira. %> +
+ <% if contato.grupo_whatsapp? %> + <%= icone :usuarios, cor: nil, tamanho: 'w-4 h-4', espaco: false %> + <% else %> + <%= contato.nome.to_s[0].to_s.upcase %> + <% end %> +
+
+ <%= contato.nome %> + <% if contato.grupo_whatsapp? %> + grupo do WhatsApp + <% end %> +
+
+
+ <% if contato.grupo_whatsapp? %> + + <%= contato.whatsapp_grupo_nome.presence || contato.whatsapp_grupo_jid %> - - <%= 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' %> -
-
- <% end %> + <% else %> + <%= contato.telefone.presence || '—' %> + <% end %> +
<%= contato.email.presence || '—' %> + <% if contato.grupo_contato %> + <%= contato.grupo_contato.nome %> + <% else %> + + <%= icone :alerta, cor: nil, tamanho: 'w-3.5 h-3.5', espaco: false %> sem grupo + + <% end %> + <%= badge_status_usuario(contato.ativo?) %> +
+ <%= link_to edit_admin_contato_path(contato), data: { turbo: false }, + class: 'p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors', + title: 'Editar' do %> + <%= icone :editar, cor: nil, tamanho: 'w-4 h-4', espaco: false %> + <% end %> + <%= button_to admin_contato_path(contato), method: :delete, + class: 'p-2 text-gray-600 hover:text-red-400 hover:bg-red-900/20 rounded-lg transition-colors', + title: 'Excluir', + form: { data: { turbo_confirm: "Excluir #{contato.nome}?" } } do %> + <%= icone :excluir, cor: nil, tamanho: 'w-4 h-4', espaco: false %> + <% end %> +
+
+
<%= icone :vazio, tamanho: 'w-10 h-10', cor: 'text-gray-600', espaco: false %>
+

Nenhum contato encontrado.

+
+
diff --git a/app/views/admin/contatos/new.html.erb b/app/views/admin/contatos/new.html.erb index a2f5fa8..9cf802c 100644 --- a/app/views/admin/contatos/new.html.erb +++ b/app/views/admin/contatos/new.html.erb @@ -1,4 +1,8 @@ -
-

Novo contato

- <%= render "form", contato: @contato %> +
+
+

Novo contato

+

Cadastre quem vai receber as mensagens

+
+ <%= render 'shared/flash' %> + <%= render 'form', contato: @contato %>
diff --git a/app/views/admin/eventos_notificacao/_form.html.erb b/app/views/admin/eventos_notificacao/_form.html.erb index 70fa856..7cca379 100644 --- a/app/views/admin/eventos_notificacao/_form.html.erb +++ b/app/views/admin/eventos_notificacao/_form.html.erb @@ -1,102 +1,172 @@ -<%= form_with model: [:admin, evento], url: (evento.persisted? ? admin_evento_path(evento) : admin_eventos_path), - data: { turbo: false }, class: 'space-y-5' do |f| %> +<% + input = 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1 + focus:ring-[#f97316] transition-colors' + menor = 'w-full px-3 py-2.5 bg-[#0a0a0a] border border-white/10 rounded-xl text-white text-sm + focus:outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316] transition-colors' + rotulo = 'block text-sm font-medium text-gray-300 mb-1.5' + secao = 'text-sm font-medium text-gray-300 mb-4' + toggle = "w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer + peer-checked:after:translate-x-full peer-checked:after:border-white + after:content-[''] after:absolute after:top-[2px] after:left-[2px] + after:bg-white after:border-gray-300 after:border after:rounded-full + after:h-5 after:w-5 after:transition-all peer-checked:bg-[#f97316]" +%> +<%= form_with model: [:admin, evento], + url: (evento.persisted? ? admin_evento_path(evento) : admin_eventos_path), + data: { turbo: false }, + class: 'bg-[#1a1a1a] rounded-2xl border border-white/5 p-8 space-y-6' do |f| %> + <% if evento.errors.any? %> -
+
+

+ <%= pluralize(evento.errors.count, 'erro', 'erros') %> encontrado<%= evento.errors.count > 1 ? 's' : '' %>: +

    - <% evento.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %> + <% evento.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %>
<% end %> -
- <%= 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' %> +
+
+ <%= f.label :nome, class: rotulo %> + <%= f.text_field :nome, required: true, + placeholder: 'Aviso de fechamento para a diretoria', class: input %> +
+ +
+ <%= f.label :gatilho, 'Gatilho (o que faz disparar)', class: rotulo %> + <% if evento.sistema? %> +

+ <%= icone :travado, cor: 'text-gray-500', tamanho: 'w-4 h-4', espaco: false %> + <%= evento.gatilho_label %> +

+

+ Evento de sistema: o gatilho e a chave são fixos porque o código dispara por eles. +

+ <% else %> + <%= f.select :gatilho, + EventoNotificacao::GATILHOS.map { |g| [EventoNotificacao::GATILHO_LABEL[g], g] }, {}, + class: "#{input} cursor-pointer" %> + <% end %> +
+ +
+ <%= f.label :descricao, 'Descrição', class: rotulo %> + <%= f.text_field :descricao, placeholder: 'Opcional', class: input %> +
-
- <%= f.label :gatilho, 'Gatilho (o que faz disparar)', class: 'block text-gray-400 text-sm mb-1' %> - <% if evento.sistema? %> -

<%= evento.gatilho_label %>

-

Evento de sistema: o gatilho e a chave são fixos porque o código dispara por eles.

- <% 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 %> -
- -
- <%= 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' %> -
- - <%# Só faz sentido no gatilho `agendado`; deixo sempre visível para não - depender de JS, com a explicação de quando vale. %> -
- Agendamento (só vale no gatilho "Agendado") -
+ <%# Agendamento — só faz sentido no gatilho `agendado`; deixo sempre visível + para não depender de JS, com a explicação de quando vale. %> +
+

+ Agendamento + — só vale no gatilho "Agendado" +

+
- <%= 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' %> + <%= f.label :frequencia, 'Frequência', class: rotulo %> + <%= f.select :frequencia, EventoNotificacao::FREQUENCIAS.map { |x| [x.capitalize, x] }, + { include_blank: '—' }, class: "#{menor} cursor-pointer" %>
- <%= 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' %> + <%= f.label :hora, 'Hora', class: rotulo %> + <%= f.number_field :hora, min: 0, max: 23, class: menor %>
- <%= 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' %> + <%= f.label :dia_semana, 'Dia (semanal)', class: rotulo %> + <%= f.select :dia_semana, Date::DAYNAMES.each_with_index.map { |nome, i| [nome, i] }, + { include_blank: '—' }, class: "#{menor} cursor-pointer" %>
-
- - - - +
<%# 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. %> -
- Quais grupos recebem +
+

Quais grupos recebem

+ <% if @linhas_assinatura.blank? %> -

- Nenhum grupo cadastrado ainda — - <%= link_to 'crie um grupo', new_admin_grupo_path, data: { turbo: false }, class: 'text-orange-400 underline' %> - antes. -

- <% end %> - <% Array(@linhas_assinatura).each do |linha| %> - <% grupo = linha[:grupo]; assinatura = linha[:assinatura] %> -
- - <%= 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' %> +
+
<%= icone :vazio, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %>
+

+ Nenhum grupo cadastrado ainda — + <%= link_to 'crie um grupo', new_admin_grupo_path, data: { turbo: false }, + class: 'text-[#f97316] hover:text-orange-400 underline' %> + antes. +

+
+ <% else %> +
+ <% Array(@linhas_assinatura).each do |linha| %> + <% grupo = linha[:grupo]; assinatura = linha[:assinatura] %> +
+ + <%= select_tag "assinaturas[#{grupo.id}][canal]", + options_for_select( + GrupoEventoAssinatura::CANAIS.map { |c| [GrupoEventoAssinatura::CANAL_LABEL[c], c] }, + assinatura&.canal || 'ambos' + ), + class: 'px-3 py-2 bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm + focus:outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316] + transition-colors cursor-pointer' %> +
+ <% end %>
<% end %> -
+
-
- <%= 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' %> + <%# Opções %> +
+
+
+ <%= f.label :notificar_envolvido, 'Avisar também a pessoa envolvida no fato', + class: 'block text-sm font-medium text-gray-300' %> +

+ Ex.: o motorista dono do pagamento, além dos grupos. Só se aplica aos gatilhos de + consolidação/pagamento/valor. +

+
+ +
+ +
+
+ <%= f.label :ativo, 'Evento ativo', class: 'block text-sm font-medium text-gray-300' %> +

Evento inativo não dispara nada

+
+ +
+
+ +
+ <%= link_to 'Cancelar', admin_eventos_path, data: { turbo: false }, + class: 'px-6 py-3 text-gray-400 hover:text-white border border-white/10 + hover:border-white/20 rounded-xl transition-colors' %> + <%= f.submit evento.new_record? ? 'Criar Evento' : 'Salvar Alterações', + class: 'px-8 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold + rounded-xl transition-colors cursor-pointer min-h-[48px]' %>
<% end %> diff --git a/app/views/admin/eventos_notificacao/edit.html.erb b/app/views/admin/eventos_notificacao/edit.html.erb index c77367a..1af5e2b 100644 --- a/app/views/admin/eventos_notificacao/edit.html.erb +++ b/app/views/admin/eventos_notificacao/edit.html.erb @@ -1,4 +1,8 @@ -
-

Editar evento

- <%= render "form", evento: @evento %> +
+
+

Editar: <%= @evento.nome %>

+

Atualize o gatilho, o agendamento e os grupos assinantes

+
+ <%= render 'shared/flash' %> + <%= render 'form', evento: @evento %>
diff --git a/app/views/admin/eventos_notificacao/index.html.erb b/app/views/admin/eventos_notificacao/index.html.erb index 962cd2e..ac20768 100644 --- a/app/views/admin/eventos_notificacao/index.html.erb +++ b/app/views/admin/eventos_notificacao/index.html.erb @@ -1,90 +1,157 @@ +<% + input = 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1 + focus:ring-[#f97316] transition-colors text-sm' + pill = 'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold' +%>
+
-

Eventos de notificação

+

+ <%= icone :diario, espaco: false %> Eventos de notificação +

O que dispara mensagem e para quais grupos. Você cria quantos eventos quiser — o gatilho sai de uma lista fixa, porque gatilho é código.

- <%= 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' %> + <%= link_to new_admin_evento_path, data: { turbo: false }, + class: 'inline-flex items-center gap-2 px-5 py-3 bg-[#f97316] hover:bg-orange-500 + text-white font-semibold rounded-xl transition-colors min-h-[48px] whitespace-nowrap' do %> + <%= icone :adicionar, cor: nil, tamanho: 'w-5 h-5', espaco: false %> Novo evento + <% end %>
-
+ <%= render 'shared/flash' %> + +
<% @eventos.each do |evento| %> -
-
-
-

- <%= evento.nome %> - <%= evento.gatilho_label %> + <%# Evento desativado fica esmaecido: a lista mistura ativo e inativo e sem + isso os dois pesam igual na varredura visual. %> +

+
+
+
+

<%= evento.nome %>

+ + <%= evento.gatilho_label %> + <% if evento.sistema? %> - sistema + sistema <% end %> <% unless evento.ativo? %> - desativado + desativado <% end %> -

-

<%= evento.descricao.presence || '—' %>

-

+

+ +

<%= evento.descricao.presence || '—' %>

+ +
<% assinantes = evento.assinaturas.select(&:ativo) %> <% if assinantes.any? %> - Grupos: <%= assinantes.map { |a| "#{a.grupo_contato.nome} (#{a.canal_label})" }.join(' · ') %> +
+ Grupos + <% assinantes.each do |a| %> + + <%= a.grupo_contato.nome %> + · <%= a.canal_label %> + + <% end %> +
<% else %> - Nenhum grupo assina — este evento não manda nada. +

+ <%= icone :alerta, cor: nil, tamanho: 'w-3.5 h-3.5', espaco: false %> + Nenhum grupo assina — este evento não manda nada. +

<% 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) %> +

+ <%= icone :relogio, cor: 'text-gray-500', tamanho: 'w-3.5 h-3.5', espaco: false %> + <%= evento.frequencia == 'semanal' ? "Toda #{Date::DAYNAMES[evento.dia_semana.to_i]}" : 'Todo dia' %> + às <%= format('%02d:00', evento.hora.to_i) %> +

<% end %> -

+
-
- <%# 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 %> - | - <%= 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 %> + + <%# Ações: editor de mensagem por canal + editar/excluir %> +
+
+ <%# 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 template_admin_evento_path(evento, canal: canal), data: { turbo: false }, + title: montado ? 'Mensagem montada no editor' : 'Sem mensagem — usa o texto padrão', + class: "inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border transition-colors #{ + montado ? 'bg-green-500/10 border-green-500/30 text-green-300 hover:border-green-500' + : 'bg-[#0a0a0a] border-white/10 text-gray-400 hover:border-[#f97316] hover:text-white'}" do %> + <%= icone(montado ? :sucesso : (canal == 'email' ? :email : :telefone), + cor: nil, tamanho: 'w-4 h-4', espaco: false) %> + <%= canal == 'email' ? 'E-mail' : 'WhatsApp' %> + <% end %> + <% end %> +
+ +
+ <%= link_to edit_admin_evento_path(evento), data: { turbo: false }, + class: 'p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors', + title: 'Editar' do %> + <%= icone :editar, cor: nil, tamanho: 'w-4 h-4', espaco: false %> + <% end %> + <% if evento.apagavel? %> + <%= button_to admin_evento_path(evento), method: :delete, + class: 'p-2 text-gray-600 hover:text-red-400 hover:bg-red-900/20 rounded-lg transition-colors', + title: 'Excluir', + form: { data: { turbo_confirm: "Excluir o evento #{evento.nome}?" } } do %> + <%= icone :excluir, cor: nil, tamanho: 'w-4 h-4', espaco: false %> + <% end %> + <% end %> +
- <%# Disparo manual: manda mensagem DE VERDADE. O texto é digitado aqui - porque o editor de blocos ainda não existe. %> + <%# Disparo manual: manda mensagem DE VERDADE. %> <% if evento.manual? && evento.ativo? %> -
- Disparar agora +
+ + <%= icone :expandir, cor: nil, tamanho: 'w-4 h-4', espaco: false %> Disparar agora + <% 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| %> + data: { turbo: false }, class: 'mt-4 space-y-3' do |f| %> <% if com_template %> -

- 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. +

+ <%= icone :sucesso, cor: nil, tamanho: 'w-4 h-4', espaco: false, classe: 'mt-0.5' %> + + 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. +

<% 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.text_field :assunto, placeholder: 'Assunto (só e-mail)', value: evento.nome, class: input %> + <%= f.text_area :corpo, rows: 4, required: !com_template, class: input, + placeholder: com_template ? 'Opcional — só para o canal sem mensagem montada' : 'Mensagem que será enviada…' %> <%= 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' %> + data: { turbo_confirm: 'Isso envia mensagem de verdade para todos os contatos dos grupos assinantes. Confirmar?' }, + class: 'px-6 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold + rounded-xl transition-colors cursor-pointer min-h-[48px]' %> <% end %>
<% end %>
<% end %> + + <% if @eventos.empty? %> +
+
<%= icone :vazio, tamanho: 'w-10 h-10', cor: 'text-gray-600', espaco: false %>
+

Nenhum evento cadastrado.

+
+ <% end %>
diff --git a/app/views/admin/eventos_notificacao/new.html.erb b/app/views/admin/eventos_notificacao/new.html.erb index e5353cf..9f2cc8f 100644 --- a/app/views/admin/eventos_notificacao/new.html.erb +++ b/app/views/admin/eventos_notificacao/new.html.erb @@ -1,4 +1,8 @@ -
-

Novo evento

- <%= render "form", evento: @evento %> +
+
+

Novo evento

+

Defina o que dispara a mensagem e quem recebe

+
+ <%= render 'shared/flash' %> + <%= render 'form', evento: @evento %>
diff --git a/app/views/admin/grupos_contato/_form.html.erb b/app/views/admin/grupos_contato/_form.html.erb index fb20ded..f8ecbd4 100644 --- a/app/views/admin/grupos_contato/_form.html.erb +++ b/app/views/admin/grupos_contato/_form.html.erb @@ -1,36 +1,63 @@ +<% + input = 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1 + focus:ring-[#f97316] transition-colors' + rotulo = 'block text-sm font-medium text-gray-300 mb-1.5' + toggle = "w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer + peer-checked:after:translate-x-full peer-checked:after:border-white + after:content-[''] after:absolute after:top-[2px] after:left-[2px] + after:bg-white after:border-gray-300 after:border after:rounded-full + after:h-5 after:w-5 after:transition-all peer-checked:bg-[#f97316]" +%> <%# 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| %> + data: { turbo: false }, + class: 'bg-[#1a1a1a] rounded-2xl border border-white/5 p-8 space-y-6' do |f| %> + <% if grupo.errors.any? %> -
+
+

+ <%= pluralize(grupo.errors.count, 'erro', 'erros') %> encontrado<%= grupo.errors.count > 1 ? 's' : '' %>: +

    - <% grupo.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %> + <% grupo.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %>
<% end %>
- <%= 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' %> + <%= f.label :nome, class: rotulo %> + <%= f.text_field :nome, required: true, placeholder: 'Diretoria', class: input %>
- <%= 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' %> + <%= f.label :descricao, 'Descrição', class: rotulo %> + <%= f.text_field :descricao, placeholder: 'Quem acompanha o fechamento', class: input %> +

Opcional — ajuda a lembrar para que serve o grupo.

- +
+
+ <%= f.label :ativo, 'Grupo ativo', class: 'block text-sm font-medium text-gray-300' %> +

Grupo inativo não recebe nada, mesmo assinando eventos

+
+ +
-
- <%= 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' %> +
+ <%= link_to 'Cancelar', admin_grupos_path, data: { turbo: false }, + class: 'px-6 py-3 text-gray-400 hover:text-white border border-white/10 + hover:border-white/20 rounded-xl transition-colors' %> + <%= f.submit grupo.new_record? ? 'Criar Grupo' : 'Salvar Alterações', + class: 'px-8 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold + rounded-xl transition-colors cursor-pointer min-h-[48px]' %>
<% end %> diff --git a/app/views/admin/grupos_contato/edit.html.erb b/app/views/admin/grupos_contato/edit.html.erb index 0c729a8..1942319 100644 --- a/app/views/admin/grupos_contato/edit.html.erb +++ b/app/views/admin/grupos_contato/edit.html.erb @@ -1,4 +1,8 @@ -
-

Editar grupo

+
+
+

Editar: <%= @grupo.nome %>

+

Atualize as informações do grupo

+
+ <%= render 'shared/flash' %> <%= render 'form', grupo: @grupo %>
diff --git a/app/views/admin/grupos_contato/index.html.erb b/app/views/admin/grupos_contato/index.html.erb index 13c7c28..646f108 100644 --- a/app/views/admin/grupos_contato/index.html.erb +++ b/app/views/admin/grupos_contato/index.html.erb @@ -1,65 +1,105 @@
+
-

Grupos de contato

+

+ <%= icone :tag, espaco: false %> Grupos de contato +

É o grupo que assina os eventos — cadastrar alguém novo é escolher o grupo, não repetir a configuração pessoa por pessoa.

- <%= 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' %> -
- -
- <% if @grupos.empty? %> -

- Nenhum grupo ainda. Crie um (ex.: Diretoria, - Motoristas) e depois cadastre os contatos dentro dele. -

- <% else %> -
- - - - - - - - - - - - - <% @grupos.each do |grupo| %> - - - - - - - - - <% end %> - -
GrupoDescriçãoContatosAssina os eventosSituação
<%= grupo.nome %><%= grupo.descricao.presence || '—' %> - <%= link_to grupo.contatos.size, admin_contatos_path(grupo_id: grupo.id), - data: { turbo: false }, class: 'text-orange-400 hover:text-orange-300 underline' %> - - <% nomes = grupo.assinaturas.select(&:ativo).map { |a| a.evento_notificacao.nome } %> - <%= nomes.any? ? nomes.join(', ') : '— nenhum' %> - - - <%= grupo.ativo? ? 'Ativo' : 'Inativo' %> - - - <%= 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' %> -
-
+ <%= link_to new_admin_grupo_path, data: { turbo: false }, + class: 'inline-flex items-center gap-2 px-5 py-3 bg-[#f97316] hover:bg-orange-500 + text-white font-semibold rounded-xl transition-colors min-h-[48px] whitespace-nowrap' do %> + <%= icone :adicionar, cor: nil, tamanho: 'w-5 h-5', espaco: false %> Novo grupo <% end %>
+ + <%= render 'shared/flash' %> + +
+
+ + + + + + + + + + + + + <% @grupos.each do |grupo| %> + + + + + + + + + <% end %> + + <% if @grupos.empty? %> + + + + <% end %> + +
GrupoDescriçãoContatosAssina os eventosSituaçãoAções
+
+
+ <%= icone :tag, cor: nil, tamanho: 'w-4 h-4', espaco: false %> +
+ <%= grupo.nome %> +
+
<%= grupo.descricao.presence || '—' %> + <%= link_to admin_contatos_path(grupo_id: grupo.id), data: { turbo: false }, + class: 'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold + bg-[#f97316]/15 border border-[#f97316]/30 text-[#f97316] + hover:bg-[#f97316]/25 transition-colors', + title: 'Ver os contatos deste grupo' do %> + <%= grupo.contatos.size %> + <% end %> + + <% nomes = grupo.assinaturas.select(&:ativo).map { |a| a.evento_notificacao.nome } %> + <% if nomes.any? %> +
+ <% nomes.each do |nome| %> + <%= nome %> + <% end %> +
+ <% else %> + — nenhum + <% end %> +
<%= badge_status_usuario(grupo.ativo?) %> +
+ <%= link_to edit_admin_grupo_path(grupo), data: { turbo: false }, + class: 'p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors', + title: 'Editar' do %> + <%= icone :editar, cor: nil, tamanho: 'w-4 h-4', espaco: false %> + <% end %> + <%= button_to admin_grupo_path(grupo), method: :delete, + class: 'p-2 text-gray-600 hover:text-red-400 hover:bg-red-900/20 rounded-lg transition-colors', + title: 'Excluir', + form: { data: { turbo_confirm: "Excluir #{grupo.nome}? Os #{grupo.contatos.size} contato(s) ficam sem grupo e param de receber." } } do %> + <%= icone :excluir, cor: nil, tamanho: 'w-4 h-4', espaco: false %> + <% end %> +
+
+
<%= icone :vazio, tamanho: 'w-10 h-10', cor: 'text-gray-600', espaco: false %>
+

Nenhum grupo ainda.

+

+ Crie um (ex.: Diretoria, + Motoristas) e depois cadastre os contatos dentro dele. +

+
+
+
diff --git a/app/views/admin/grupos_contato/new.html.erb b/app/views/admin/grupos_contato/new.html.erb index 19714bb..aba1218 100644 --- a/app/views/admin/grupos_contato/new.html.erb +++ b/app/views/admin/grupos_contato/new.html.erb @@ -1,4 +1,8 @@ -
-

Novo grupo de contato

+
+
+

Novo grupo de contato

+

Junte contatos que recebem os mesmos eventos

+
+ <%= render 'shared/flash' %> <%= render 'form', grupo: @grupo %>
diff --git a/app/views/admin/mensagem_templates/edit.html.erb b/app/views/admin/mensagem_templates/edit.html.erb index e5572ac..a6bf97a 100644 --- a/app/views/admin/mensagem_templates/edit.html.erb +++ b/app/views/admin/mensagem_templates/edit.html.erb @@ -6,27 +6,53 @@
-
-
-

Mensagem — <%= @evento.nome %>

-

- Canal: <%= @canal == 'email' ? 'E-mail' : 'WhatsApp' %> - · Gatilho: <%= @evento.gatilho_label %> -

+
+
+

+ <%= icone :editar, espaco: false %> Mensagem — <%= @evento.nome %> +

+
+ <% wpp = @canal != 'email' %> + + <%= icone(wpp ? :telefone : :email, cor: nil, tamanho: 'w-3.5 h-3.5', espaco: false) %> + <%= wpp ? 'WhatsApp' : 'E-mail' %> + + + <%= @evento.gatilho_label %> + +
-
- <%= 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' %> +
+ <%= link_to template_admin_evento_path(@evento, canal: outro_canal), data: { turbo: false }, + class: 'inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm border + border-white/10 text-gray-300 hover:border-white/20 hover:text-white + transition-colors whitespace-nowrap' do %> + <%= icone(outro_canal == 'email' ? :email : :telefone, cor: nil, tamanho: 'w-4 h-4', espaco: false) %> + Editar <%= outro_canal == 'email' ? 'e-mail' : 'WhatsApp' %> + <% end %> + <%= link_to admin_eventos_path, data: { turbo: false }, + class: 'inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm border + border-white/10 text-gray-300 hover:border-white/20 hover:text-white + transition-colors whitespace-nowrap' do %> + <%= icone :voltar, cor: nil, tamanho: 'w-4 h-4', espaco: false %> Voltar + <% end %>
+ <%= render 'shared/flash' %> + <% if @template.errors.any? %> -
+
+

+ <%= pluralize(@template.errors.count, 'erro', 'erros') %> encontrado<%= @template.errors.count > 1 ? 's' : '' %>: +

    - <% @template.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %> + <% @template.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %>
<% end %> @@ -40,11 +66,12 @@ <%# ── Paleta ──────────────────────────────────────────── %>
-

Blocos

+

Blocos

Arraste para a mensagem — ou clique.

<% Notificacao::Blocos::CATALOGO.each do |tipo, meta| %> -
<%= meta[:rotulo] %> @@ -56,14 +83,17 @@ <%# ── Variáveis ─────────────────────────────────────── %>
-

Variáveis

+

Variáveis

Clique num campo da mensagem e depois numa variável para inseri-la ali.

<% @variaveis.each do |nome, meta| %> - <% end %> @@ -75,34 +105,47 @@
<% if @canal == 'email' %>
- - <%= 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' %> + + <%= text_field_tag :assunto, @template.assunto, id: 'assunto', + class: 'campo-editavel w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl + text-white placeholder-gray-600 focus:outline-none focus:border-[#f97316] + focus:ring-1 focus:ring-[#f97316] transition-colors text-sm' %>
<% end %>
-

Mensagem

+

Mensagem

-

- Arraste um bloco para cá. Sem blocos, o evento continua usando o texto padrão do sistema. -

+
+
<%= icone :vazio, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %>
+

+ Arraste um bloco para cá. Sem blocos, o evento continua usando o texto padrão do sistema. +

+
-
- <%= 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' %> -
<%# ── Preview ─────────────────────────────────────────── %>
-

Preview

+

Preview

Com valores de exemplo, renderizado pelo mesmo código do envio.

@@ -157,12 +200,12 @@ if (especie === 'multilinha') { return '' + ''; } return '' + ''; + ' class="campo-editavel campo-bloco w-full bg-[#0a0a0a] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-[#f97316]">'; } function listaHtml(indice, linhas) { @@ -170,9 +213,9 @@ (linhas || []).forEach(function (linha, j) { html += '
' + '' + + ' class="campo-editavel campo-linha flex-1 bg-[#0a0a0a] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-[#f97316]">' + '' + + ' class="campo-editavel campo-linha flex-1 bg-[#0a0a0a] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-[#f97316]">' + '' + '
'; }); @@ -187,7 +230,7 @@ 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.className = 'bg-[#0a0a0a] border border-white/10 rounded-xl p-3 hover:border-white/20 transition-colors'; card.dataset.indice = i; var corpo = '
' + diff --git a/app/views/admin/notificacao_envios/index.html.erb b/app/views/admin/notificacao_envios/index.html.erb index 710a37e..a027a7e 100644 --- a/app/views/admin/notificacao_envios/index.html.erb +++ b/app/views/admin/notificacao_envios/index.html.erb @@ -1,95 +1,142 @@ <% ctx = { status: params[:status], canal: params[:canal] }.compact_blank %> +<% + # Pílula de filtro: uma só definição para os dois grupos (situação e canal), + # senão o estilo do "ativo" fatalmente diverge entre eles com o tempo. + pilula = lambda do |ativo| + base = 'px-4 py-2 rounded-xl text-sm border transition-colors' + ativo ? "#{base} bg-[#f97316] text-white border-[#f97316] font-semibold" + : "#{base} bg-[#0a0a0a] text-gray-400 border-white/10 hover:border-white/20 hover:text-white" + end +%>
+
-

Envios

+

+ <%= icone :historico, espaco: false %> Envios +

O que saiu, para quem e se chegou. Uma falha de envio nunca trava o fechamento — então é aqui que ela aparece.

-
- <% [['', '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 %> - | - <% [['', '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 %> + <%= render 'shared/flash' %> + + <%# Filtros %> +
+
+ Situação + <% [['', 'Todos'], ['enviado', 'Enviados'], ['falhou', 'Falhados'], ['pendente', 'Pendentes']].each do |valor, rotulo| %> + <%= link_to rotulo, admin_envios_path(ctx.merge(status: valor.presence).compact), + data: { turbo: false }, class: pilula.call(params[:status].to_s == valor) %> + <% end %> +
+ + + +
+ Canal + <% [['', 'Todos'], ['whatsapp', 'WhatsApp'], ['email', 'E-mail']].each do |valor, rotulo| %> + <%= link_to rotulo, admin_envios_path(ctx.merge(canal: valor.presence).compact), + data: { turbo: false }, class: pilula.call(params[:canal].to_s == valor) %> + <% end %> +
+ <% if @total_falhas.positive? %> - <%= @total_falhas %> falha(s) neste filtro + + <%= icone :erro, cor: nil, tamanho: 'w-3.5 h-3.5', espaco: false %> + <%= @total_falhas %> falha(s) neste filtro + <% end %>
+ <%# Tabela %>
- <% if @envios.empty? %> -

Nenhum envio registrado ainda.

- <% else %> -
- - - - - - - - - +
+
QuandoEventoDestinatárioCanalSituaçãoMensagem
+ + + + + + + + + + + + <% @envios.each do |envio| %> + + + + + + + - - - <% @envios.each do |envio| %> - - - - - - - - - <% end %> - -
QuandoEventoDestinatárioCanalSituaçãoMensagem
+ <%= envio.created_at.strftime('%d/%m/%Y') %> + <%= envio.created_at.strftime('%H:%M') %> + <%= envio.evento_notificacao&.nome || '—' %> + <%= envio.contato&.nome || envio.user&.nome || '—' %> + <%= envio.destino %> + + <% wpp = envio.canal == 'whatsapp' %> + + <%= icone(wpp ? :telefone : :email, cor: nil, tamanho: 'w-3.5 h-3.5', espaco: false) %> + <%= wpp ? 'WhatsApp' : 'E-mail' %> + + + <% cfg = case envio.status + when 'enviado' then { cor: 'bg-green-600 text-white', icone: :sucesso, label: 'Enviado' } + when 'falhou' then { cor: 'bg-red-600 text-white', icone: :erro, label: 'Falhou' } + else { cor: 'bg-yellow-500 text-black', icone: :relogio, label: envio.status.to_s.capitalize } + end %> + <%= badge_com_icone(cfg) %> + <% if envio.falhou? && envio.erro.present? %> + <%= envio.erro.truncate(160) %> + <% end %> + + <%= envio.corpo.to_s.truncate(90) %> +
<%= envio.created_at.strftime('%d/%m/%Y %H:%M') %><%= envio.evento_notificacao&.nome || '—' %> - <%= envio.contato&.nome || envio.user&.nome || '—' %> - <%= envio.destino %> - <%= envio.canal == 'whatsapp' ? 'WhatsApp' : 'E-mail' %> - <% cor = { 'enviado' => 'text-green-400', 'falhou' => 'text-red-400' }.fetch(envio.status, 'text-yellow-400') %> - <%= envio.status.capitalize %> - <% if envio.falhou? && envio.erro.present? %> - <%= envio.erro.truncate(160) %> - <% end %> - - <%= envio.corpo.to_s.truncate(90) %> -
-
+ <% end %> - <% if @pagy.pages > 1 %> - <% pag = ->(p) { admin_envios_path(ctx.merge(page: p)) } %> -
+ + <% if @envios.any? && @pagy.pages > 1 %> + <% pag = ->(p) { admin_envios_path(ctx.merge(page: p)) } %> + - <% 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-white/10 text-white hover:border-[#f97316] transition-colors' %> + <% end %> + <% end %>
diff --git a/app/views/admin/whatsapp_sessoes/show.html.erb b/app/views/admin/whatsapp_sessoes/show.html.erb index bd56113..76e2a8b 100644 --- a/app/views/admin/whatsapp_sessoes/show.html.erb +++ b/app/views/admin/whatsapp_sessoes/show.html.erb @@ -1,86 +1,117 @@ -
+
+
-

WhatsApp — conexão

+

+ <%= icone :telefone, espaco: false %> WhatsApp — conexão +

Pareia o número da empresa com este servidor lendo um QR code, como no WhatsApp Web.

+ <%= render 'shared/flash' %> + <%# O aviso fica na TELA, não só no código: quem parear precisa saber o que está aceitando. %> -
-

⚠️ Este canal não é oficial.

-

+

+

+ <%= icone :alerta, cor: nil, tamanho: 'w-5 h-5', espaco: false %> Este canal não é oficial. +

+

A conexão por QR usa a porta do WhatsApp Web por engenharia reversa. É gratuita e ilimitada, mas está fora dos Termos do WhatsApp — a Meta pode banir o número sem aviso. Use um chip dedicado, nunca o número principal da operação.

-

+

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' %> + data: { turbo: false }, class: 'underline hover:text-white transition-colors' %> (hoje: <%= @config.intervalo_envio %>s).

-
+
<% if @estado['conectado'] %> -
- +
+ <%# Ponto pulsante: à distância, é o que diz "está no ar" sem ler o texto. %> + + + +
-

Conectado

-

+

Conectado

+

Número <%= @estado['numero'] %> - <% if @config.whatsapp_conectado_em %> · desde <%= l @config.whatsapp_conectado_em, format: :short rescue @config.whatsapp_conectado_em %><% end %> + <% if @config.whatsapp_conectado_em %> + · desde <%= l @config.whatsapp_conectado_em, format: :short rescue @config.whatsapp_conectado_em %> + <% end %>

-
+
<%= form_with url: testar_admin_whatsapp_sessao_path, method: :post, data: { turbo: false }, class: 'flex flex-wrap items-end gap-3' do |f| %> -
- <%= f.label :numero, 'Enviar teste para', class: 'block text-gray-400 text-sm mb-1' %> +
+ <%= f.label :numero, 'Enviar teste para', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> <%= 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' %> + class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1 + focus:ring-[#f97316] transition-colors' %>
<%= 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' %> + class: 'px-6 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold + rounded-xl transition-colors cursor-pointer min-h-[48px]' %> <% 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' %> +
+ <%= button_to 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: 'inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm border + border-red-500/30 text-red-400 hover:bg-red-900/20 hover:border-red-500/50 + transition-colors cursor-pointer' do %> + <%= icone :sair, cor: nil, tamanho: 'w-4 h-4', espaco: false %> Desconectar + <% end %> +
<% elsif @estado['qr'].present? %>
-

Leia o QR code

-

- No celular do chip dedicado: WhatsApp → Aparelhos conectados → - Conectar um aparelho → aponte para o código abaixo. + + Aguardando leitura + +

Leia o QR code

+

+ No celular do chip dedicado: WhatsApp → Aparelhos conectados → + Conectar um aparelho → aponte para o código abaixo.

<%# 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. %> QR code do WhatsApp + class="rounded-2xl bg-white p-3 shadow-lg" id="qr-whatsapp">

O código se renova sozinho a cada poucos segundos.

<% else %> -
- -
-

Desconectado

-

+

+ +
+

Desconectado

+

<%= @estado['erro'].presence || @estado['ultimo_erro'].presence || 'A ponte ainda não gerou um QR code. Aguarde alguns segundos e recarregue.' %>

<% unless @cliente.configurado? %> -

- Falta configurar a ponte: defina WHATSAPP_TOKEN no .env - (o mesmo valor do container whatsapp) e suba os containers. -

+
+

+ Falta configurar a ponte: defina + WHATSAPP_TOKEN no + .env (o mesmo valor do container + whatsapp) e suba os containers. +

+
<% end %>