Implandação da customização das permissões dos usuarios e das notificaçoes

This commit is contained in:
2026-08-26 17:24:25 -03:00
parent 808c2c7545
commit 5bb5d74e22
71 changed files with 2487 additions and 269 deletions

View File

@@ -82,22 +82,54 @@
</div>
<%# ── Variáveis ─────────────────────────────────────── %>
<%# ── EMOJIS ─────────────────────────────────────────
Digitar emoji no teclado do computador é o passo que trava o ADM;
aqui ele clica. A lista é curta e curada de propósito — um seletor
completo viraria uma biblioteca externa (e CSP) para inserir 3
caracteres. %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4 mb-4">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Emojis</p>
<div class="flex flex-wrap gap-1">
<% %w[✅ ❌ ⚠️ 📌 📢 💰 💵 🧾 📅 🕒 🚚 📦 📍 👤 👍 🙏 ⭐ 🔴 🟢 🟡 ➡️ 📄 📷 🔔 ✍️ 😀 🎉].each do |emoji| %>
<button type="button" class="chip-emoji w-9 h-9 rounded-lg bg-[#0a0a0a] border border-white/10
hover:border-[#f97316] text-lg leading-none"
data-emoji="<%= emoji %>" title="Inserir <%= emoji %>"><%= emoji %></button>
<% end %>
</div>
</div>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4">
<p class="text-xs font-semibold text-gray-400 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" title="<%= meta[:descricao] %> (ex.: <%= meta[:amostra] %>)"
class="chip-variavel px-2.5 py-1 rounded-full bg-[#f97316]/10 border border-[#f97316]/30
text-xs font-mono text-[#f97316] hover:bg-[#f97316]/20 hover:border-[#f97316]
transition-colors cursor-pointer"
data-variavel="<%= nome %>">
<%= "{{#{nome}}}" %>
</button>
<% end %>
</div>
<%# Agrupadas por ORIGEM e sem esconder o que não se aplica: a variável
de outro gatilho aparece apagada, com o aviso de que sai em branco.
Antes, a tela só listava as 6 do contexto — qualquer outro dado
exigia deploy. %>
<% @variaveis.each do |origem, itens| %>
<% next if itens.empty? %>
<p class="text-[11px] uppercase tracking-wider text-gray-500 mt-3 mb-1.5">
<%= Notificacao::CatalogoVariaveis::ORIGENS[origem] %>
</p>
<div class="flex flex-wrap gap-1.5">
<% itens.each do |v| %>
<button type="button"
title="<%= v[:descricao] %> (ex.: <%= v[:amostra] %>)<%= ' — não existe neste gatilho: sai em branco' unless v[:aplicavel] %>"
class="chip-variavel px-2.5 py-1 rounded-full text-xs font-mono transition-colors cursor-pointer
<%= v[:aplicavel] ? 'bg-[#f97316]/10 border border-[#f97316]/30 text-[#f97316] hover:bg-[#f97316]/20 hover:border-[#f97316]' : 'bg-white/5 border border-white/10 text-gray-500 hover:border-gray-500' %>"
data-variavel="<%= v[:chave] %>">
{{<%= v[:chave] %>}}<%= ' ·' unless v[:aplicavel] %>
</button>
<% end %>
</div>
<% end %>
<p class="text-[11px] text-gray-500 mt-3 leading-relaxed">
As apagadas (·) não existem neste gatilho e sairiam em branco.
<%= link_to 'Criar variável própria', admin_variaveis_path, class: 'text-orange-500 hover:underline' %>.
</p>
</div>
</div>
@@ -312,20 +344,29 @@
});
}
// ── Variáveis: insere no cursor do último campo focado ─────
// ── Inserção no cursor do último campo focado ──────────────
// Serve para variável e para emoji: a mecânica é a mesma (recortar o valor
// na posição do cursor e reemitir `input` para o JSON do editor atualizar).
function inserirNoCampo(texto) {
if (!ultimoCampo) { alert('Clique primeiro no campo onde o texto 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;
}
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;
inserirNoCampo('{{' + chip.dataset.variavel + '}}');
});
});
document.querySelectorAll('.chip-emoji').forEach(function (chip) {
chip.addEventListener('click', function () { inserirNoCampo(chip.dataset.emoji); });
});
// ── 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 () {

View File

@@ -0,0 +1,83 @@
<%# app/views/admin/perfis_acesso/_form.html.erb
O ADM marca o que o perfil enxerga. As caixas vêm agrupadas por área do
catálogo (Permissao.por_grupo) e cada uma traz a descrição do EFEITO real —
"Registrar pagamento" sem a linha de baixo não diz se inclui estornar. %>
<%= form_with model: [:admin, perfil], url: (perfil.persisted? ? admin_perfil_path(perfil) : admin_perfis_path),
data: { turbo: false }, class: 'space-y-6' do |f| %>
<% if perfil.errors.any? %>
<div class="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
<p class="text-red-400 font-semibold mb-1"><%= icone :erro, cor: 'text-red-400' %> Não foi possível salvar</p>
<ul class="text-red-300 text-sm list-disc list-inside space-y-0.5">
<% perfil.errors.full_messages.each do |msg| %><li><%= msg %></li><% end %>
</ul>
</div>
<% end %>
<%# ── Identificação ──────────────────────────────────── %>
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl p-6 space-y-4">
<div>
<%= f.label :nome, 'Nome do perfil', class: 'block text-sm font-medium text-gray-300 mb-1.5' %>
<%= f.text_field :nome, required: true, placeholder: 'Ex.: Gerente sem configurações',
class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
focus:outline-none focus:border-[#f97316]' %>
</div>
<div>
<%= f.label :descricao, 'Para que serve', class: 'block text-sm font-medium text-gray-300 mb-1.5' %>
<%= f.text_field :descricao, placeholder: 'Quem usa este perfil e por quê',
class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
focus:outline-none focus:border-[#f97316]' %>
</div>
<div class="flex items-center justify-between">
<div>
<%= f.label :ativo, 'Perfil ativo', class: 'block text-sm font-medium text-gray-300' %>
<p class="text-gray-500 text-xs mt-0.5">Desativado, quem estiver nele perde os acessos marcados aqui.</p>
</div>
<%= f.check_box :ativo, class: 'w-5 h-5 accent-orange-500' %>
</div>
<% if perfil.sistema? %>
<p class="text-amber-400 text-sm">
<%= icone :alerta, cor: 'text-amber-400' %> Perfil que vem com o sistema: pode ser editado e desativado, mas não excluído.
</p>
<% end %>
</div>
<%# ── Permissões ─────────────────────────────────────────
O campo vazio antes das caixas é obrigatório: sem ele, desmarcar TUDO faria
o parâmetro sumir do POST e o controller entenderia "não mexeu". %>
<%= hidden_field_tag 'perfil_acesso[permissoes][]', '' %>
<% marcadas = perfil.permissoes_validas %>
<% Permissao.por_grupo.each do |grupo, itens| %>
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl overflow-hidden">
<div class="flex items-center justify-between gap-3 px-5 py-3 bg-[#0f0f0f] border-b border-[#2a2a2a]">
<h2 class="text-white font-bold"><%= Permissao.grupo_label(grupo) %></h2>
<span class="text-gray-500 text-xs"><%= itens.count { |chave, _| marcadas.include?(chave) } %> de <%= itens.size %></span>
</div>
<div class="divide-y divide-[#2a2a2a]">
<% itens.each do |chave, cfg| %>
<label class="flex items-start gap-3 px-5 py-4 cursor-pointer hover:bg-[#222] min-h-[56px]">
<%= check_box_tag 'perfil_acesso[permissoes][]', chave, marcadas.include?(chave),
id: "perm-#{chave.tr('.', '-')}", class: 'mt-1 w-5 h-5 accent-orange-500 shrink-0' %>
<span class="min-w-0">
<span class="block text-white font-semibold"><%= cfg[:label] %></span>
<span class="block text-gray-400 text-sm mt-0.5"><%= cfg[:descricao] %></span>
</span>
</label>
<% end %>
</div>
</div>
<% end %>
<div class="flex flex-wrap gap-3">
<%= f.submit perfil.persisted? ? 'Salvar perfil' : 'Criar perfil',
class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-6 py-3.5 rounded-xl
min-h-[48px] cursor-pointer' %>
<%= link_to 'Cancelar', admin_perfis_path,
class: 'px-5 py-3.5 text-gray-300 hover:text-white border border-[#2a2a2a] rounded-xl min-h-[48px] flex items-center' %>
</div>
<% end %>

View File

@@ -0,0 +1,10 @@
<% content_for :title, "Perfil #{@perfil.nome}" %>
<div class="max-w-3xl">
<h1 class="text-2xl font-bold text-white mb-1 flex items-center gap-2">
<%= icone :usuarios, espaco: false %> <%= @perfil.nome %>
</h1>
<p class="text-gray-400 text-sm mb-6">
<%= @perfil.users.count %> usuário(s) neste perfil — o que você mudar aqui vale para todos eles.
</p>
<%= render 'form', perfil: @perfil %>
</div>

View File

@@ -0,0 +1,70 @@
<%# app/views/admin/perfis_acesso/index.html.erb %>
<% content_for :title, 'Perfis de acesso' %>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold text-white flex items-center gap-2">
<%= icone :usuarios, espaco: false %> Perfis de acesso
</h1>
<p class="text-gray-400 text-sm mt-0.5">
O perfil define o que a pessoa enxerga e pode fazer. Mudou a regra, muda aqui — vale para todos do perfil.
</p>
</div>
<%= link_to new_admin_perfil_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 %> Novo perfil
<% end %>
</div>
<% if @perfis.any? %>
<div class="space-y-3">
<% @perfis.each do |perfil| %>
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl p-5">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h2 class="text-white font-bold text-lg"><%= perfil.nome %></h2>
<% if perfil.sistema? %>
<span class="px-2.5 py-1 rounded-full text-xs font-bold bg-gray-700 text-gray-300">Do sistema</span>
<% end %>
<% unless perfil.ativo? %>
<span class="px-2.5 py-1 rounded-full text-xs font-bold bg-red-600/20 text-red-400">Desativado</span>
<% end %>
<% if perfil.vazio? %>
<span class="px-2.5 py-1 rounded-full text-xs font-bold bg-amber-500/20 text-amber-400">Sem nenhum acesso</span>
<% end %>
</div>
<% if perfil.descricao.present? %>
<p class="text-gray-400 text-sm mt-1"><%= perfil.descricao %></p>
<% end %>
<p class="text-gray-500 text-sm mt-1">
<%= icone :pessoa %> <%= perfil.users.size %> usuário(s)
· <%= icone :chave %> <%= perfil.total_permissoes %> de <%= Permissao.chaves.size %> permissões
</p>
</div>
<div class="flex flex-wrap gap-2 shrink-0">
<%= link_to rotulo(:editar, 'Editar', cor: nil), edit_admin_perfil_path(perfil), data: { turbo: false },
class: 'px-4 py-3 text-gray-300 hover:text-white border border-[#2a2a2a] hover:border-orange-500 rounded-xl min-h-[48px] flex items-center' %>
<%# button_to com BLOCO: passando o rótulo como primeiro argumento, o
Rails escapa o HTML e o ícone sai como texto cru na tela. %>
<% if policy(perfil).destroy? %>
<%= button_to admin_perfil_path(perfil), method: :delete,
class: 'px-4 py-3 text-red-400 hover:text-red-300 border border-[#2a2a2a] hover:border-red-500 rounded-xl min-h-[48px] flex items-center gap-2',
form: { data: { turbo_confirm: "Excluir o perfil #{perfil.nome}?" } } do %>
<%= icone :excluir, cor: nil, tamanho: 'w-4 h-4', espaco: false %> Excluir
<% end %>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl p-12 text-center">
<p class="mb-4"><%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %></p>
<p class="text-gray-300">Nenhum perfil cadastrado ainda.</p>
<p class="text-gray-500 text-sm mt-1">Enquanto não houver perfil, cada pessoa acessa conforme o tipo de conta dela.</p>
</div>
<% end %>

View File

@@ -0,0 +1,8 @@
<% content_for :title, 'Novo perfil de acesso' %>
<div class="max-w-3xl">
<h1 class="text-2xl font-bold text-white mb-1 flex items-center gap-2">
<%= icone :usuarios, espaco: false %> Novo perfil de acesso
</h1>
<p class="text-gray-400 text-sm mb-6">Marque o que este perfil enxerga. Você atribui o perfil a cada pessoa na tela de Usuários.</p>
<%= render 'form', perfil: @perfil %>
</div>

View File

@@ -40,16 +40,46 @@
placeholder: 'João da Silva' %>
</div>
<%# Perfil %>
<div>
<%= f.label :role, 'Perfil de acesso', class: 'block text-sm font-medium text-gray-300 mb-1.5' %>
<%= f.select :role,
options_for_select(role_options_for_select, usuario.role),
{},
class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
focus:outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316]
transition-colors cursor-pointer' %>
</div>
<%# TIPO DE CONTA x PERFIL DE ACESSO — são coisas diferentes:
• tipo de conta define COMO a pessoa entra e onde ela vive (motorista
entra por PIN e tem painel próprio);
• perfil de acesso define O QUE ela enxerga (Permissao::TODAS).
Antes este select se chamava "Perfil de acesso" e editava `role`.
Os dois só aparecem para quem pode alterar acesso — e nunca na própria
ficha, senão qualquer usuário se promoveria editando o próprio cadastro
(o controller também filtra os campos, não só a tela). %>
<% if pode_alterar_acesso? %>
<div>
<%= f.label :role, 'Tipo de conta', class: 'block text-sm font-medium text-gray-300 mb-1.5' %>
<%= f.select :role,
options_for_select(role_options_for_select, usuario.role),
{},
class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
focus:outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316]
transition-colors cursor-pointer' %>
</div>
<div id="campo-perfil-acesso">
<%= f.label :perfil_acesso_id, 'Perfil de acesso', class: 'block text-sm font-medium text-gray-300 mb-1.5' %>
<%= f.select :perfil_acesso_id,
options_from_collection_for_select(PerfilAcesso.ativos.ordenados, :id, :nome, usuario.perfil_acesso_id),
{ include_blank: 'Sem perfil (usa o padrão do tipo de conta)' },
class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
focus:outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316]
transition-colors cursor-pointer' %>
<p class="text-gray-500 text-xs mt-1.5">
Define o que a pessoa enxerga.
<%= link_to 'Gerenciar perfis', admin_perfis_path, class: 'text-orange-500 hover:underline' %>
</p>
</div>
<% else %>
<div class="bg-[#0a0a0a] border border-white/10 rounded-xl px-4 py-3">
<p class="text-sm text-gray-400">Tipo de conta</p>
<p class="text-white font-semibold"><%= usuario.role_label %></p>
<p class="text-gray-500 text-xs mt-1">Só quem administra usuários altera tipo de conta e perfil de acesso.</p>
</div>
<% end %>
<%# Ativo toggle %>
<div class="flex items-center">
@@ -142,10 +172,16 @@
const fieldsPin = document.getElementById('fields-pin');
if (!roleSelect || !fieldsEmail || !fieldsPin) return;
// Motorista não usa perfil de acesso: o que ele enxerga é o painel próprio,
// governado pelo tipo de conta. Deixar o select à mostra sugeriria que dá
// para liberar telas administrativas para ele.
const campoPerfil = document.getElementById('campo-perfil-acesso');
function toggleFields() {
const motorista = roleSelect.value === 'motorista';
fieldsEmail.classList.toggle('hidden', motorista);
fieldsPin.classList.toggle('hidden', !motorista);
if (campoPerfil) campoPerfil.classList.toggle('hidden', motorista);
}
roleSelect.addEventListener('change', toggleFields);

View File

@@ -29,7 +29,8 @@
<tr class="border-b border-white/10">
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Nome</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">E-mail / PIN</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Perfil</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Tipo de conta</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Perfil de acesso</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-4 text-right text-xs font-semibold text-gray-400 uppercase tracking-wider">Ações</th>
</tr>
@@ -56,6 +57,22 @@
<td class="px-6 py-4">
<%= badge_role(usuario.role) %>
</td>
<%# Tipo de conta x perfil são coisas diferentes: o primeiro diz como
a pessoa entra, o segundo o que ela enxerga. Sem perfil, vale o
padrão do tipo de conta — a tela precisa dizer isso, senão parece
que a pessoa está sem acesso nenhum. %>
<td class="px-6 py-4">
<% if usuario.motorista? %>
<span class="text-gray-500 text-sm">—</span>
<% elsif usuario.perfil_acesso %>
<span class="text-white text-sm font-semibold"><%= usuario.perfil_acesso.nome %></span>
<% unless usuario.perfil_acesso.ativo? %>
<span class="block text-red-400 text-xs">perfil desativado</span>
<% end %>
<% else %>
<span class="text-gray-400 text-sm">Padrão do tipo de conta</span>
<% end %>
</td>
<td class="px-6 py-4">
<%= badge_status_usuario(usuario.ativo?) %>
</td>

View File

@@ -0,0 +1,55 @@
<%= form_with model: [:admin, variavel],
url: (variavel.persisted? ? admin_variavel_path(variavel) : admin_variaveis_path),
data: { turbo: false }, class: 'space-y-6' do |f| %>
<% if variavel.errors.any? %>
<div class="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
<ul class="text-red-300 text-sm list-disc list-inside space-y-0.5">
<% variavel.errors.full_messages.each do |msg| %><li><%= msg %></li><% end %>
</ul>
</div>
<% end %>
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl p-6 space-y-4">
<div>
<%= f.label :chave, 'Nome da variável', class: 'block text-sm font-medium text-gray-300 mb-1.5' %>
<%= f.text_field :chave, required: true, placeholder: 'telefone_suporte',
class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white font-mono
focus:outline-none focus:border-[#f97316]' %>
<p class="text-gray-500 text-xs mt-1.5">
Só letras minúsculas, números e <span class="font-mono">_</span>. Na mensagem você escreve
<span class="font-mono text-orange-400">{{<%= variavel.chave.presence || 'telefone_suporte' %>}}</span>.
</p>
</div>
<div>
<%= f.label :valor, 'Valor', class: 'block text-sm font-medium text-gray-300 mb-1.5' %>
<%= f.text_field :valor, required: true, placeholder: '(11) 4002-8922',
class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
focus:outline-none focus:border-[#f97316]' %>
<p class="text-gray-500 text-xs mt-1.5">É este texto que aparece no lugar da variável, em toda mensagem que a usar.</p>
</div>
<div>
<%= f.label :descricao, 'Para que serve', class: 'block text-sm font-medium text-gray-300 mb-1.5' %>
<%= f.text_field :descricao, placeholder: 'Telefone que atende o motorista',
class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
focus:outline-none focus:border-[#f97316]' %>
</div>
<div class="flex items-center justify-between">
<div>
<%= f.label :ativo, 'Variável ativa', class: 'block text-sm font-medium text-gray-300' %>
<p class="text-gray-500 text-xs mt-0.5">Desativada, some do editor e sai vazia nas mensagens que já a usam.</p>
</div>
<%= f.check_box :ativo, class: 'w-5 h-5 accent-orange-500' %>
</div>
</div>
<div class="flex flex-wrap gap-3">
<%= f.submit variavel.persisted? ? 'Salvar' : 'Criar variável',
class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-6 py-3.5 rounded-xl min-h-[48px] cursor-pointer' %>
<%= link_to 'Cancelar', admin_variaveis_path,
class: 'px-5 py-3.5 text-gray-300 hover:text-white border border-[#2a2a2a] rounded-xl min-h-[48px] flex items-center' %>
</div>
<% end %>

View File

@@ -0,0 +1,8 @@
<% content_for :title, "Variável #{@variavel.chave}" %>
<div class="max-w-2xl">
<h1 class="text-2xl font-bold text-white mb-1 flex items-center gap-2">
<%= icone :tag, espaco: false %> <span class="font-mono"><%= @variavel.marcador %></span>
</h1>
<p class="text-gray-400 text-sm mb-6">O novo valor passa a valer no próximo disparo de toda mensagem que usa esta variável.</p>
<%= render 'form', variavel: @variavel %>
</div>

View File

@@ -0,0 +1,76 @@
<% content_for :title, 'Variáveis de mensagem' %>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold text-white flex items-center gap-2"><%= icone :tag, espaco: false %> Variáveis de mensagem</h1>
<p class="text-gray-400 text-sm mt-0.5">
Escreva <span class="font-mono text-orange-400">{{assim}}</span> no texto e o sistema troca pelo valor na hora do envio.
</p>
</div>
<%= link_to new_admin_variavel_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 min-h-[48px] whitespace-nowrap' do %>
<%= icone :adicionar, cor: nil %> Nova variável
<% end %>
</div>
<%# ── As suas ─────────────────────────────────────────── %>
<% if @variaveis.any? %>
<div class="space-y-3 mb-8">
<% @variaveis.each do |v| %>
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl p-5 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono text-orange-400 font-semibold"><%= v.marcador %></span>
<% unless v.ativo? %>
<span class="px-2.5 py-1 rounded-full text-xs font-bold bg-red-600/20 text-red-400">Desativada</span>
<% end %>
</div>
<p class="text-white mt-1 break-words"><%= v.valor %></p>
<% if v.descricao.present? %>
<p class="text-gray-500 text-sm mt-0.5"><%= v.descricao %></p>
<% end %>
</div>
<div class="flex gap-2 shrink-0">
<%= link_to rotulo(:editar, 'Editar', cor: nil), edit_admin_variavel_path(v), data: { turbo: false },
class: 'px-4 py-3 text-gray-300 hover:text-white border border-[#2a2a2a] hover:border-orange-500 rounded-xl min-h-[48px] flex items-center' %>
<%= button_to admin_variavel_path(v), method: :delete,
class: 'px-4 py-3 text-red-400 hover:text-red-300 border border-[#2a2a2a] hover:border-red-500 rounded-xl min-h-[48px] flex items-center gap-2',
form: { data: { turbo_confirm: "Excluir #{v.marcador}? Mensagens que a usam passam a mostrar espaço em branco." } } do %>
<%= icone :excluir, cor: nil, tamanho: 'w-4 h-4', espaco: false %> Excluir
<% end %>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl p-10 text-center mb-8">
<p class="mb-3"><%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %></p>
<p class="text-gray-300">Você ainda não criou nenhuma variável.</p>
<p class="text-gray-500 text-sm mt-1">Boas primeiras: telefone do suporte, horário de atendimento, endereço da base.</p>
</div>
<% end %>
<%# ── As que já existem no sistema ─────────────────────
Listadas aqui para o ADM não tentar cadastrar uma que já existe (o cadastro
recusaria) e para descobrir o que dá para usar sem criar nada. %>
<h2 class="text-white font-bold text-lg mb-1">Já disponíveis no sistema</h2>
<p class="text-gray-400 text-sm mb-4">Use à vontade nas mensagens — não precisam ser cadastradas.</p>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<% @do_sistema.each do |origem, itens| %>
<% next if itens.empty? %>
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl overflow-hidden">
<div class="px-5 py-3 bg-[#0f0f0f] border-b border-[#2a2a2a]">
<h3 class="text-white font-semibold text-sm"><%= Notificacao::CatalogoVariaveis::ORIGENS[origem] %></h3>
</div>
<div class="divide-y divide-[#2a2a2a]">
<% itens.each do |v| %>
<div class="px-5 py-3">
<span class="font-mono text-orange-400 text-sm">{{<%= v[:chave] %>}}</span>
<p class="text-gray-400 text-xs mt-0.5"><%= v[:descricao] %></p>
</div>
<% end %>
</div>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,6 @@
<% content_for :title, 'Nova variável' %>
<div class="max-w-2xl">
<h1 class="text-2xl font-bold text-white mb-1 flex items-center gap-2"><%= icone :tag, espaco: false %> Nova variável</h1>
<p class="text-gray-400 text-sm mb-6">Um texto fixo que você reaproveita em qualquer mensagem — mudou aqui, mudou em todas.</p>
<%= render 'form', variavel: @variavel %>
</div>

View File

@@ -80,7 +80,7 @@
<td class="px-4 py-3 text-right font-bold <%= c.desconto? ? 'text-red-400' : 'text-white' %>">
<div class="flex items-center justify-end gap-2">
<span><%= c.desconto? ? '-' : '' %><%= moeda(c.valor_aplicado) %></span>
<% if c.manual? && !(@consolidacao.finalizada? && current_user.operador?) %>
<% if c.manual? && !(@consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada')) %>
<%= button_to remover_apontamento_consolidacao_consolidacao_entregas_path(@consolidacao, id: c.id, motorista: @motorista),
method: :delete,
form: { data: { turbo_confirm: 'Remover este apontamento manual?' } },

View File

@@ -56,7 +56,7 @@
<%# Fechar por veículo — filtra a tela por carro, mostra o estado (pronto/
consolidado/arquivado) e permite CONSOLIDAR o veículo selecionado.
Só aparece quando o motorista usou mais de um veículo no período. %>
<% pode_fechar_veiculo = !(@consolidacao.finalizada? && current_user.operador?) %>
<% pode_fechar_veiculo = !(@consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada')) %>
<% if @veiculos_status.size > 1 %>
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-3 mb-3">
<div class="flex items-center gap-2 mb-2 flex-wrap">
@@ -196,7 +196,7 @@
controllers existentes são reaproveitados; só o gatilho mudou.
Fica DENTRO da barra de ações (order-2) para não custar uma linha própria;
os modais são `fixed`, então não afetam o layout da faixa. %>
<% unless @consolidacao.finalizada? && current_user.operador? %>
<% unless @consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada') %>
<div class="order-3 relative"
data-controller="ferramentas apontamento-manual apontamento-termo"
data-apontamento-manual-apontar-manual-url-value="<%= apontar_manual_consolidacao_consolidacao_entregas_path(@consolidacao) %>"

View File

@@ -167,7 +167,7 @@
<%# ── Apontamentos manuais (notas que chegaram por fora) ─────────────── %>
<% if @apontamentos.present? %>
<% pode_editar = !(@consolidacao.finalizada? && current_user.operador?) %>
<% pode_editar = !(@consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada')) %>
<h2 class="text-white font-bold text-lg mt-8 mb-3 flex items-center gap-2"><%= icone :adicionar, espaco: false %> Apontamentos manuais</h2>
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl overflow-hidden">
<table class="w-full text-sm">

View File

@@ -33,7 +33,7 @@
<%# Caixa de ferramentas do Passo 1 — concentra "Apontamento manual" (busca pela
NF) e "Adicionar motorista" num único botão com menu suspenso. %>
<% unless @consolidacao.finalizada? && current_user.operador? %>
<% unless @consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada') %>
<div class="relative mb-4"
data-controller="ferramentas apontamento adicionar-motorista"
data-apontamento-buscar-url-value="<%= buscar_nf_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
@@ -161,7 +161,7 @@
<% end %>
<%# Lista de motoristas %>
<% pode_arquivar_motorista = !(@consolidacao.finalizada? && current_user.operador?) %>
<% pode_arquivar_motorista = !(@consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada')) %>
<div class="space-y-3">
<% @motoristas.each do |m| %>
<%# Container relativo: o card é um link; o botão de arquivar fica FORA da

View File

@@ -106,7 +106,9 @@
</details>
<% end %>
<%# Imprimir relatório financeiro do período/operação atuais %>
<%# Imprimir relatório financeiro do período/operação atuais — só para quem
pode ver custo (permissão `dashboard.financeiro`). %>
<% if @ver_financeiro %>
<%= link_to rotulo(:imprimir, 'Relatório', cor: nil),
dashboard_relatorio_financeiro_path(
inicio: @periodo_inicio.strftime('%Y-%m-%d'),
@@ -114,6 +116,7 @@
operacoes: @operacao_filtro),
data: { turbo: false },
class: 'pl-4 pr-4 py-2.5 bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-xl text-sm whitespace-nowrap' %>
<% end %>
</form>
</div>
@@ -147,6 +150,7 @@
</div>
<%# Card destacado: total consolidado e pago (consolidações finalizadas do período) %>
<% if @ver_financeiro %>
<div class="sm:col-span-2 xl:col-span-1 bg-[#1a1a1a] rounded-2xl border border-green-500/30 p-6">
<p class="text-gray-400 text-sm font-medium mb-2 flex items-center gap-2"><%= icone :consolidado_pago, espaco: false %> Consolidado / Pago</p>
<p class="text-3xl font-black text-white mb-1"><%= moeda(@fin_custo_total) %></p>
@@ -156,6 +160,7 @@
<span class="text-gray-400">pago</span>
</div>
</div>
<% end %>
<%# Card 2: Total de entregas %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
@@ -203,6 +208,7 @@
</div>
<%# KPIs FINANCEIROS (consolidações finalizadas, já com filtro de operação) %>
<% if @ver_financeiro %>
<div class="grid grid-cols-[repeat(auto-fit,minmax(13rem,1fr))] gap-4">
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-2 flex items-center gap-2"><%= icone :dinheiro, espaco: false %> Custo total</p>
@@ -225,6 +231,7 @@
<p class="text-gray-400 text-xs mt-1">pendente</p>
</div>
</div>
<% end %>
<%# GRÁFICO + MOTORISTAS (lado a lado no desktop) %>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
@@ -244,7 +251,9 @@
<canvas id="grafico-diario"></canvas>
</div>
<%# Pagamentos por dia (fluxo de caixa) — quando o dinheiro saiu de fato %>
<%# Pagamentos por dia (fluxo de caixa) — quando o dinheiro saiu de fato.
É valor pago: fica atrás da mesma permissão dos demais blocos de custo. %>
<% if @ver_financeiro %>
<div class="flex flex-wrap items-center justify-between gap-2 mt-6 mb-4 pt-6 border-t border-white/5">
<div>
<h2 class="text-lg font-semibold text-white">Pagamentos por dia</h2>
@@ -273,6 +282,7 @@
<% end %>
</div>
<% end %>
<% end %>
</div>
<%# Top motoristas — DUAS ABAS sobre o mesmo ranking:
@@ -320,6 +330,7 @@
</div>
</div>
<% if @ver_financeiro %>
<%# ── FINANCEIRO POR OPERAÇÃO ───────────────────────────── %>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
@@ -472,7 +483,9 @@
</div>
</div>
<%# Por operação %>
<% end %>
<%# Por operação (contagem de entregas — não é custo, fica para todos) %>
<% if @por_operacao.any? %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2"><%= icone :operacao, espaco: false %> Entregas por Local</h2>
@@ -719,7 +732,9 @@
})();
</script>
<%# Fluxo de pagamentos: barras (pago no dia) + linha (acumulado no período) %>
<%# Fluxo de pagamentos: barras (pago no dia) + linha (acumulado no período).
Sem permissão de financeiro os dados não são carregados — o script sai fora. %>
<% if @ver_financeiro %>
<script>
(function () {
const ctx = document.getElementById('grafico-pagamentos-dia');
@@ -795,7 +810,7 @@
})();
</script>
<%# Rosca de pagamentos (pago x a pagar, por valor) %>
<%# Rosca de pagamentos (pago x a pagar, por valor) — mesmo bloco de permissão. %>
<script>
(function () {
const ctx = document.getElementById('grafico-pagamentos');
@@ -839,7 +854,12 @@
})();
</script>
<%# ── Gráficos financeiros por operação ─────────────────────── %>
<% end %>
<%# ── Gráficos financeiros por operação ───────────────────────
Sem a permissão de financeiro os dados nem chegam à página: o controller não
carrega, e este script inteiro fica de fora do HTML. %>
<% if @ver_financeiro %>
<script>
(function () {
const moedaTick = (v) => 'R$ ' + Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 0 });
@@ -905,3 +925,4 @@
})();
})();
</script>
<% end %>

View File

@@ -34,14 +34,17 @@
<%# Navegação %>
<nav class="px-3 py-4 space-y-1 flex-1 overflow-y-auto">
<%# Dashboard — admin/gerente/operador/externo (todos menos motorista) %>
<% unless current_user.motorista? %>
<%# Cada item pergunta por PERMISSÃO, não por papel: o menu é o espelho do
perfil que o ADM montou (Permissao::TODAS). Item sem permissão SOME — não
fica desabilitado, para não anunciar o que a pessoa não pode fazer. %>
<% if current_user.pode?('dashboard.ver') %>
<%= nav_link_to 'Dashboard', dashboard_path, icon: :dashboard %>
<% end %>
<% if current_user.pode?('dashboard.operacoes') %>
<%= nav_link_to 'Operações', operacoes_dashboard_path, icon: :operacoes %>
<% end %>
<%# Consolidações — só quem pode consolidar (exclui o externo) %>
<% if current_user.pode_consolidar? %>
<% if current_user.pode?('consolidacao.ver') %>
<%= nav_link_to 'Consolidações', consolidacoes_path, icon: :consolidacoes %>
<%= nav_link_to 'Arquivadas', arquivadas_consolidacoes_path, icon: :arquivadas %>
<% end %>
@@ -51,29 +54,55 @@
<%= nav_link_to 'Meu Painel', motorista_dashboard_path, icon: :painel %>
<% end %>
<%# Admin/Gerente — Configurações %>
<% if current_user.pode_ver_config? %>
<%# Administração — a seção inteira some quando nenhum item dela está liberado. %>
<% itens_admin = %w[admin.usuarios admin.perfis admin.configuracoes admin.auditoria
admin.planilha_simpli_route admin.edicao_lancamento] %>
<% if itens_admin.any? { |chave| current_user.pode?(chave) } %>
<div class="pt-4 mt-4 border-t border-[#2a2a2a]">
<p class="px-3 text-xs text-gray-600 uppercase tracking-wider mb-2">Administração</p>
<%= nav_link_to 'Usuários', admin_usuarios_path, icon: :usuarios %>
<%= nav_link_to 'Configurações', admin_configuracoes_path, icon: :configuracoes %>
<%= nav_link_to 'Auditoria', admin_auditoria_logs_path, icon: :auditoria %>
<%= nav_link_to 'Planilha SimpliRoute', admin_planilha_simpli_route_path, icon: :planilha %>
<% if current_user.admin? %>
<% if current_user.pode?('admin.usuarios') %>
<%= nav_link_to 'Usuários', admin_usuarios_path, icon: :usuarios %>
<% end %>
<% if current_user.pode?('admin.perfis') %>
<%= nav_link_to 'Perfis de acesso', admin_perfis_path, icon: :chave %>
<% end %>
<% if current_user.pode?('admin.configuracoes') %>
<%= nav_link_to 'Configurações', admin_configuracoes_path, icon: :configuracoes %>
<% end %>
<% if current_user.pode?('admin.auditoria') %>
<%= nav_link_to 'Auditoria', admin_auditoria_logs_path, icon: :auditoria %>
<% end %>
<% if current_user.pode?('admin.planilha_simpli_route') %>
<%= nav_link_to 'Planilha SimpliRoute', admin_planilha_simpli_route_path, icon: :planilha %>
<% end %>
<% if current_user.pode?('admin.edicao_lancamento') %>
<%= nav_link_to 'Editar Lançamento', admin_edicao_lancamento_path, icon: :editar_lancamento %>
<% end %>
</div>
<%# Notificações: quem recebe (grupos/contatos) é admin+gerente; ligar o
WhatsApp e criar eventos manda mensagem em nome da empresa — só ADM.
Mesmo recorte das policies. %>
<% end %>
<%# Notificações — cada item com sua chave: ver contatos é uma coisa; ligar o
WhatsApp e disparar mensagem em nome da empresa é outra. %>
<% itens_notif = %w[notificacao.contatos notificacao.envios notificacao.eventos
notificacao.variaveis notificacao.whatsapp_sessao] %>
<% if itens_notif.any? { |chave| current_user.pode?(chave) } %>
<div class="pt-4 mt-4 border-t border-[#2a2a2a]">
<p class="px-3 text-xs text-gray-600 uppercase tracking-wider mb-2">Notificações</p>
<%= nav_link_to 'Contatos', admin_contatos_path, icon: :usuarios %>
<%= nav_link_to 'Grupos', admin_grupos_path, icon: :tag %>
<%= nav_link_to 'Envios', admin_envios_path, icon: :historico %>
<% if current_user.admin? %>
<% if current_user.pode?('notificacao.contatos') %>
<%= nav_link_to 'Contatos', admin_contatos_path, icon: :usuarios %>
<%= nav_link_to 'Grupos', admin_grupos_path, icon: :tag %>
<% end %>
<% if current_user.pode?('notificacao.envios') %>
<%= nav_link_to 'Envios', admin_envios_path, icon: :historico %>
<% end %>
<% if current_user.pode?('notificacao.eventos') %>
<%= nav_link_to 'Eventos', admin_eventos_path, icon: :diario %>
<% end %>
<% if current_user.pode?('notificacao.variaveis') %>
<%= nav_link_to 'Variáveis', admin_variaveis_path, icon: :tag %>
<% end %>
<% if current_user.pode?('notificacao.whatsapp_sessao') %>
<%= nav_link_to 'WhatsApp', admin_whatsapp_sessao_path, icon: :telefone %>
<% end %>
</div>

View File

@@ -0,0 +1,20 @@
<% content_for :title, 'Sem acesso' %>
<div class="max-w-xl mx-auto text-center py-16">
<p class="mb-4"><%= icone :travado, tamanho: 'w-14 h-14', cor: 'text-gray-600', espaco: false %></p>
<h1 class="text-2xl font-bold text-white mb-2">Você ainda não tem acesso a nenhuma tela</h1>
<p class="text-gray-300 text-base">
Olá, <%= current_user.nome_display %>. Sua conta está ativa, mas o perfil de acesso
<% if @perfil %>
(<span class="text-white font-semibold"><%= @perfil.nome %></span>)
<% end %>
não libera nenhuma área do sistema.
</p>
<p class="text-gray-400 text-base mt-3">
Peça a um administrador para revisar seu perfil em <span class="text-gray-200">Administração Perfis de acesso</span>.
</p>
<%= link_to rotulo(:sair, 'Sair', cor: nil), destroy_user_session_path,
data: { turbo_method: :delete },
class: 'inline-flex items-center mt-8 px-5 py-3 border border-[#2a2a2a] rounded-xl text-gray-300 hover:text-white min-h-[48px]' %>
</div>