Implandação da customização das permissões dos usuarios e das notificaçoes
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
# app/controllers/admin/auditoria_logs_controller.rb
|
# app/controllers/admin/auditoria_logs_controller.rb
|
||||||
class Admin::AuditoriaLogsController < ApplicationController
|
class Admin::AuditoriaLogsController < ApplicationController
|
||||||
def index
|
def index
|
||||||
authorize AuditoriaLog, policy_class: ApplicationPolicy
|
# Policy própria (AuditoriaLogPolicy). Com `policy_class: ApplicationPolicy`
|
||||||
|
# a régua era a de consolidação: quem podia consolidar abria a auditoria pela
|
||||||
|
# URL, mesmo sem o item no menu.
|
||||||
|
authorize AuditoriaLog
|
||||||
|
|
||||||
@logs = AuditoriaLog.recentes.limit(200)
|
@logs = AuditoriaLog.recentes.limit(200)
|
||||||
@logs = @logs.por_user(params[:user_id]) if params[:user_id].present?
|
@logs = @logs.por_user(params[:user_id]) if params[:user_id].present?
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class Admin::MensagemTemplatesController < ApplicationController
|
|||||||
assunto: params[:assunto], blocos: blocos_do_form, ativo: true)
|
assunto: params[:assunto], blocos: blocos_do_form, ativo: true)
|
||||||
provisorio.validate # dispara a limpeza dos blocos antes de renderizar
|
provisorio.validate # dispara a limpeza dos blocos antes de renderizar
|
||||||
|
|
||||||
dados = Notificacao::Variaveis.amostra(@evento.gatilho)
|
dados = Notificacao::CatalogoVariaveis.amostra(@evento.gatilho)
|
||||||
render json: { html: corpo_do_preview(provisorio, dados),
|
render json: { html: corpo_do_preview(provisorio, dados),
|
||||||
assunto: provisorio.renderizador(dados).interpolar(provisorio.assunto) }
|
assunto: provisorio.renderizador(dados).interpolar(provisorio.assunto) }
|
||||||
end
|
end
|
||||||
|
|||||||
110
app/controllers/admin/perfis_acesso_controller.rb
Normal file
110
app/controllers/admin/perfis_acesso_controller.rb
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# app/controllers/admin/perfis_acesso_controller.rb
|
||||||
|
#
|
||||||
|
# CRUD dos perfis de acesso. É aqui que o ADM decide o que cada função enxerga,
|
||||||
|
# sem depender de deploy: o catálogo do que pode ser marcado é código
|
||||||
|
# (Permissao::TODAS), a escolha é dado (PerfilAcesso#permissoes).
|
||||||
|
module Admin
|
||||||
|
class PerfisAcessoController < ApplicationController
|
||||||
|
include Auditavel
|
||||||
|
|
||||||
|
before_action :set_perfil, only: %i[edit update destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
authorize PerfilAcesso
|
||||||
|
@perfis = policy_scope(PerfilAcesso).ordenados.includes(:users)
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@perfil = PerfilAcesso.new(ativo: true, permissoes: [])
|
||||||
|
authorize @perfil
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@perfil = PerfilAcesso.new(perfil_params)
|
||||||
|
authorize @perfil
|
||||||
|
|
||||||
|
if @perfil.save
|
||||||
|
auditar!(:criar, @perfil, dados_novos: { nome: @perfil.nome, permissoes: @perfil.permissoes })
|
||||||
|
redirect_to admin_perfis_path, notice: "Perfil #{@perfil.nome} criado."
|
||||||
|
else
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit; end
|
||||||
|
|
||||||
|
def update
|
||||||
|
anteriores = @perfil.permissoes
|
||||||
|
|
||||||
|
# Ordem importa: aplicamos os valores em memória para a trava conferir o
|
||||||
|
# que o ADM ESTÁ salvando, não o que está no banco.
|
||||||
|
@perfil.assign_attributes(perfil_params)
|
||||||
|
|
||||||
|
if travaria_o_sistema?
|
||||||
|
@perfil.errors.add(:base, mensagem_da_trava)
|
||||||
|
return render(:edit, status: :unprocessable_entity)
|
||||||
|
end
|
||||||
|
|
||||||
|
if @perfil.save
|
||||||
|
auditar!(:editar, @perfil,
|
||||||
|
dados_anteriores: { permissoes: anteriores },
|
||||||
|
dados_novos: { permissoes: @perfil.permissoes, ativo: @perfil.ativo })
|
||||||
|
redirect_to admin_perfis_path, notice: "Perfil #{@perfil.nome} atualizado."
|
||||||
|
else
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
unless @perfil.apagavel?
|
||||||
|
return redirect_to(admin_perfis_path, alert: motivo_para_nao_apagar)
|
||||||
|
end
|
||||||
|
|
||||||
|
@perfil.destroy
|
||||||
|
auditar!(:excluir, @perfil, dados_anteriores: { nome: @perfil.nome })
|
||||||
|
redirect_to admin_perfis_path, notice: 'Perfil excluído.'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_perfil
|
||||||
|
@perfil = PerfilAcesso.find(params[:id])
|
||||||
|
authorize @perfil
|
||||||
|
end
|
||||||
|
|
||||||
|
def perfil_params
|
||||||
|
dados = params.require(:perfil_acesso).permit(:nome, :descricao, :ativo, permissoes: [])
|
||||||
|
# O form manda um campo vazio para o caso de NENHUMA caixa marcada (senão o
|
||||||
|
# parâmetro some e "desmarcar tudo" viraria "não mexer em nada").
|
||||||
|
dados[:permissoes] = Permissao.filtrar(dados[:permissoes])
|
||||||
|
dados
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Trava do último administrador ────────────────────────
|
||||||
|
# Mesma regra da tela de usuários (User.administradores_de_acesso): salvar um
|
||||||
|
# perfil que perde `admin.perfis`/`admin.usuarios_gerenciar` (ou é desativado)
|
||||||
|
# não pode deixar o sistema sem quem conserte.
|
||||||
|
def travaria_o_sistema?
|
||||||
|
return false if @perfil.administra_acesso? # continua podendo: nada a checar
|
||||||
|
return false unless @perfil.users.ativos.exists? # perfil vazio não tira acesso de ninguém
|
||||||
|
|
||||||
|
# Quem está NESTE perfil perderá o acesso; sobra alguém fora dele?
|
||||||
|
# Usuário sem perfil conta: cai no padrão do papel (admin legado administra).
|
||||||
|
restantes = User.administradores_de_acesso.reject { |u| u.perfil_acesso_id == @perfil.id }
|
||||||
|
restantes.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def mensagem_da_trava
|
||||||
|
'Esta alteração deixaria o sistema sem nenhum usuário ativo capaz de gerenciar ' \
|
||||||
|
'perfis e usuários. Garanta que outra pessoa mantenha esse acesso antes de salvar.'
|
||||||
|
end
|
||||||
|
|
||||||
|
def motivo_para_nao_apagar
|
||||||
|
if @perfil.sistema?
|
||||||
|
'Perfis que vêm com o sistema não podem ser excluídos — desative-o ou edite as permissões.'
|
||||||
|
else
|
||||||
|
"Este perfil está em uso por #{@perfil.users.count} usuário(s). Mova essas pessoas para outro perfil antes de excluir."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,7 +4,7 @@ class Admin::UsuariosController < ApplicationController
|
|||||||
|
|
||||||
def index
|
def index
|
||||||
authorize User
|
authorize User
|
||||||
@usuarios = policy_scope(User).order(:nome).limit(200)
|
@usuarios = policy_scope(User).includes(:perfil_acesso).order(:nome).limit(200)
|
||||||
@roles = User.roles.keys
|
@roles = User.roles.keys
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -36,6 +36,13 @@ class Admin::UsuariosController < ApplicationController
|
|||||||
def update
|
def update
|
||||||
authorize @usuario
|
authorize @usuario
|
||||||
|
|
||||||
|
# Trava do último administrador: mudar perfil ou desativar a única pessoa que
|
||||||
|
# ainda administra acesso deixaria o sistema sem quem conserte.
|
||||||
|
if desligaria_o_ultimo_administrador?
|
||||||
|
@usuario.errors.add(:base, MENSAGEM_ULTIMO_ADMIN)
|
||||||
|
return render(:edit, status: :unprocessable_entity)
|
||||||
|
end
|
||||||
|
|
||||||
if @usuario.update(usuario_params_update)
|
if @usuario.update(usuario_params_update)
|
||||||
AuditoriaLog.registrar(user: current_user, acao: 'editar_usuario', entidade: 'User', dados_novos: { detalhes: "Editou usuário #{@usuario.email}" }, request: request)
|
AuditoriaLog.registrar(user: current_user, acao: 'editar_usuario', entidade: 'User', dados_novos: { detalhes: "Editou usuário #{@usuario.email}" }, request: request)
|
||||||
redirect_to admin_usuarios_path, notice: 'Usuário atualizado.'
|
redirect_to admin_usuarios_path, notice: 'Usuário atualizado.'
|
||||||
@@ -47,6 +54,10 @@ class Admin::UsuariosController < ApplicationController
|
|||||||
def destroy
|
def destroy
|
||||||
authorize @usuario
|
authorize @usuario
|
||||||
|
|
||||||
|
if User.ultimo_administrador?(@usuario)
|
||||||
|
return redirect_to(admin_usuarios_path, alert: MENSAGEM_ULTIMO_ADMIN)
|
||||||
|
end
|
||||||
|
|
||||||
if @usuario == current_user
|
if @usuario == current_user
|
||||||
redirect_to admin_usuarios_path, alert: 'Você não pode excluir sua própria conta.'
|
redirect_to admin_usuarios_path, alert: 'Você não pode excluir sua própria conta.'
|
||||||
return
|
return
|
||||||
@@ -58,7 +69,11 @@ class Admin::UsuariosController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def toggle_ativo
|
def toggle_ativo
|
||||||
authorize @usuario, :update?
|
authorize @usuario, :toggle_ativo?
|
||||||
|
|
||||||
|
if @usuario.ativo? && User.ultimo_administrador?(@usuario)
|
||||||
|
return redirect_to(admin_usuarios_path, alert: MENSAGEM_ULTIMO_ADMIN)
|
||||||
|
end
|
||||||
@usuario.update!(ativo: !@usuario.ativo)
|
@usuario.update!(ativo: !@usuario.ativo)
|
||||||
status = @usuario.ativo? ? 'ativado' : 'desativado'
|
status = @usuario.ativo? ? 'ativado' : 'desativado'
|
||||||
AuditoriaLog.registrar(user: current_user, acao: "usuario_#{status}", entidade: 'User', dados_novos: { detalhes: "#{@usuario.email} foi #{status}" }, request: request)
|
AuditoriaLog.registrar(user: current_user, acao: "usuario_#{status}", entidade: 'User', dados_novos: { detalhes: "#{@usuario.email} foi #{status}" }, request: request)
|
||||||
@@ -67,18 +82,53 @@ class Admin::UsuariosController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
MENSAGEM_ULTIMO_ADMIN = 'Esta alteração deixaria o sistema sem nenhum usuário ativo capaz de ' \
|
||||||
|
'gerenciar perfis e usuários. Dê esse acesso a outra pessoa antes.'.freeze
|
||||||
|
|
||||||
def set_usuario
|
def set_usuario
|
||||||
@usuario = User.find(params[:id])
|
@usuario = User.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Aplica as mudanças em memória e pergunta: sobra alguém administrando?
|
||||||
|
def desligaria_o_ultimo_administrador?
|
||||||
|
simulado = @usuario.dup
|
||||||
|
simulado.id = @usuario.id
|
||||||
|
simulado.assign_attributes(usuario_params_update)
|
||||||
|
simulado.recarregar_permissoes!
|
||||||
|
|
||||||
|
return false if simulado.ativo? && simulado.pode?('admin.perfis') && simulado.pode?('admin.usuarios_gerenciar')
|
||||||
|
|
||||||
|
User.ultimo_administrador?(@usuario)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ⚠️ CAMPOS DE ACESSO SÓ PARA QUEM PODE MEXER NELES.
|
||||||
|
# `UserPolicy#update?` libera editar a PRÓPRIA ficha (nome, senha) — é o que
|
||||||
|
# permite alguém trocar a própria senha. Sem o filtro abaixo, essa mesma
|
||||||
|
# permissão deixava qualquer usuário logado enviar `user[role]=admin` na edição
|
||||||
|
# do próprio cadastro e se promover. Quem decide papel e perfil é
|
||||||
|
# `UserPolicy#alterar_acesso?` (nunca sobre si mesmo).
|
||||||
|
CAMPOS_DE_ACESSO = %i[role perfil_acesso_id ativo].freeze
|
||||||
|
|
||||||
def usuario_params
|
def usuario_params
|
||||||
params.require(:user).permit(:nome, :email, :password, :password_confirmation,
|
permitidos = [:nome, :email, :password, :password_confirmation, :pin_code]
|
||||||
:role, :pin_code, :ativo)
|
permitidos += CAMPOS_DE_ACESSO if pode_alterar_acesso?
|
||||||
|
params.require(:user).permit(permitidos)
|
||||||
end
|
end
|
||||||
|
|
||||||
def usuario_params_update
|
def usuario_params_update
|
||||||
permitted = [:nome, :email, :role, :pin_code, :ativo]
|
permitidos = [:nome, :email, :pin_code]
|
||||||
permitted += [:password, :password_confirmation] if params[:user][:password].present?
|
permitidos += CAMPOS_DE_ACESSO if pode_alterar_acesso?
|
||||||
params.require(:user).permit(permitted)
|
permitidos += [:password, :password_confirmation] if params[:user][:password].present?
|
||||||
|
params.require(:user).permit(permitidos)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def pode_alterar_acesso?
|
||||||
|
if @usuario&.persisted?
|
||||||
|
policy(@usuario).alterar_acesso?
|
||||||
|
else
|
||||||
|
# Criação: não há "si mesmo" para se promover, basta poder gerenciar.
|
||||||
|
current_user.pode?('admin.usuarios_gerenciar')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
helper_method :pode_alterar_acesso?
|
||||||
end
|
end
|
||||||
|
|||||||
74
app/controllers/admin/variaveis_personalizadas_controller.rb
Normal file
74
app/controllers/admin/variaveis_personalizadas_controller.rb
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# app/controllers/admin/variaveis_personalizadas_controller.rb
|
||||||
|
#
|
||||||
|
# CRUD das {{variaveis}} que o ADM cria. Uma vez cadastradas, aparecem no editor
|
||||||
|
# de mensagem ao lado das variáveis do sistema e valem para qualquer evento.
|
||||||
|
module Admin
|
||||||
|
class VariaveisPersonalizadasController < ApplicationController
|
||||||
|
include Auditavel
|
||||||
|
|
||||||
|
before_action :set_variavel, only: %i[edit update destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
authorize VariavelPersonalizada
|
||||||
|
@variaveis = policy_scope(VariavelPersonalizada).ordenadas
|
||||||
|
# Mostrar o catálogo do sistema junto evita o erro mais comum: cadastrar
|
||||||
|
# {{data}} sem saber que já existe (o model recusa, mas melhor avisar antes).
|
||||||
|
@do_sistema = Notificacao::CatalogoVariaveis.por_origem.except(:personalizada)
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@variavel = VariavelPersonalizada.new(ativo: true)
|
||||||
|
authorize @variavel
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@variavel = VariavelPersonalizada.new(variavel_params)
|
||||||
|
authorize @variavel
|
||||||
|
|
||||||
|
if @variavel.save
|
||||||
|
auditar!(:criar, @variavel, dados_novos: { chave: @variavel.chave, valor: @variavel.valor })
|
||||||
|
redirect_to admin_variaveis_path, notice: "Variável #{@variavel.marcador} criada."
|
||||||
|
else
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit; end
|
||||||
|
|
||||||
|
def update
|
||||||
|
anterior = @variavel.valor
|
||||||
|
|
||||||
|
if @variavel.update(variavel_params)
|
||||||
|
auditar!(:editar, @variavel,
|
||||||
|
dados_anteriores: { valor: anterior },
|
||||||
|
dados_novos: { valor: @variavel.valor })
|
||||||
|
redirect_to admin_variaveis_path, notice: "Variável #{@variavel.marcador} atualizada."
|
||||||
|
else
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
marcador = @variavel.marcador
|
||||||
|
@variavel.destroy
|
||||||
|
auditar!(:excluir, @variavel, dados_anteriores: { chave: @variavel.chave })
|
||||||
|
|
||||||
|
# A variável some, mas o texto que a usava continua lá: sem valor, o
|
||||||
|
# renderizador troca por vazio. Avisar é melhor do que a mensagem sair com
|
||||||
|
# um buraco que ninguém relaciona a esta exclusão.
|
||||||
|
redirect_to admin_variaveis_path,
|
||||||
|
alert: "Variável #{marcador} excluída. Mensagens que a usavam passam a mostrar espaço em branco no lugar."
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_variavel
|
||||||
|
@variavel = VariavelPersonalizada.find(params[:id])
|
||||||
|
authorize @variavel
|
||||||
|
end
|
||||||
|
|
||||||
|
def variavel_params
|
||||||
|
params.require(:variavel_personalizada).permit(:chave, :valor, :descricao, :ativo)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,14 +7,31 @@ class ApplicationController < ActionController::Base
|
|||||||
before_action :set_tema
|
before_action :set_tema
|
||||||
before_action :preparar_splash
|
before_action :preparar_splash
|
||||||
|
|
||||||
# Pundit: redireciona se não autorizado
|
# Pundit: redireciona se não autorizado.
|
||||||
rescue_from Pundit::NotAuthorizedError do |e|
|
#
|
||||||
flash[:alert] = "Você não tem permissão para realizar esta ação."
|
# ⚠️ NÃO redirecione para `root_path` aqui: a raiz é o dashboard, e um perfil
|
||||||
redirect_to root_path
|
# sem `dashboard.ver` entraria em loop (nega → raiz → nega). O destino é a
|
||||||
|
# primeira tela que a PESSOA pode abrir; quem não pode abrir nenhuma vai para a
|
||||||
|
# tela de sem-acesso, que explica a quem pedir.
|
||||||
|
rescue_from Pundit::NotAuthorizedError do |_e|
|
||||||
|
flash[:alert] = 'Você não tem permissão para acessar essa área.'
|
||||||
|
redirect_to destino_permitido
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
# Rota inicial deste usuário (ver User#home_rota). `sem_acesso_path` é o fim de
|
||||||
|
# linha: perfil sem nenhuma permissão.
|
||||||
|
def destino_permitido
|
||||||
|
rota = current_user&.home_rota
|
||||||
|
return sem_acesso_path if rota.nil?
|
||||||
|
|
||||||
|
# Evita loop quando a própria tela negada é a home calculada.
|
||||||
|
destino = public_send(rota)
|
||||||
|
destino == request.path ? sem_acesso_path : destino
|
||||||
|
end
|
||||||
|
helper_method :destino_permitido
|
||||||
|
|
||||||
def set_tema
|
def set_tema
|
||||||
@tema = current_user&.tema_preferido || 'dark'
|
@tema = current_user&.tema_preferido || 'dark'
|
||||||
end
|
end
|
||||||
@@ -28,8 +45,11 @@ class ApplicationController < ActionController::Base
|
|||||||
@mostrar_splash = session.delete(:mostrar_splash)
|
@mostrar_splash = session.delete(:mostrar_splash)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Mesma regra do Users::SessionsController: o destino é a primeira tela que o
|
||||||
|
# PERFIL da pessoa permite, não uma rota fixa por papel.
|
||||||
def after_sign_in_path_for(resource)
|
def after_sign_in_path_for(resource)
|
||||||
session[:mostrar_splash] = true
|
session[:mostrar_splash] = true
|
||||||
resource.motorista? ? motorista_dashboard_path : dashboard_path
|
rota = resource.home_rota
|
||||||
|
rota ? public_send(rota) : sem_acesso_path
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ class ConsolidacaoEntregasController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def bloquear_se_finalizada
|
def bloquear_se_finalizada
|
||||||
return unless @consolidacao.finalizada? && current_user.operador?
|
return unless @consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada')
|
||||||
redirect_to @consolidacao, alert: 'Consolidação finalizada — apenas admin/gerente podem editar.'
|
redirect_to @consolidacao, alert: 'Consolidação finalizada — apenas admin/gerente podem editar.'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class ConsolidacoesController < ApplicationController
|
|||||||
# filtros da tela. Com ?detalhe=NOME sai só o extrato daquele motorista (com
|
# filtros da tela. Com ?detalhe=NOME sai só o extrato daquele motorista (com
|
||||||
# espaço de assinatura).
|
# espaço de assinatura).
|
||||||
def totais_pdf
|
def totais_pdf
|
||||||
authorize Consolidacao, :index?
|
authorize Consolidacao, :exportar_pdf?
|
||||||
|
|
||||||
inicio, fim = periodo_totais
|
inicio, fim = periodo_totais
|
||||||
totais = Analytics::TotaisPorMotorista.new(
|
totais = Analytics::TotaisPorMotorista.new(
|
||||||
@@ -245,7 +245,7 @@ class ConsolidacoesController < ApplicationController
|
|||||||
|
|
||||||
# POST /consolidacoes/:id/finalizar
|
# POST /consolidacoes/:id/finalizar
|
||||||
def finalizar
|
def finalizar
|
||||||
authorize @consolidacao, :update?
|
authorize @consolidacao, :finalizar?
|
||||||
|
|
||||||
if @consolidacao.finalizar!(current_user)
|
if @consolidacao.finalizar!(current_user)
|
||||||
auditar!(:finalizar, @consolidacao)
|
auditar!(:finalizar, @consolidacao)
|
||||||
@@ -262,7 +262,7 @@ class ConsolidacoesController < ApplicationController
|
|||||||
# não dá para consolidar (não 100% classificados) e finaliza a consolidação. Os
|
# não dá para consolidar (não 100% classificados) e finaliza a consolidação. Os
|
||||||
# veículos consolidados/prontos seguem no fechamento; os pendentes ficam de fora.
|
# veículos consolidados/prontos seguem no fechamento; os pendentes ficam de fora.
|
||||||
def finalizar_parcial
|
def finalizar_parcial
|
||||||
authorize @consolidacao, :update?
|
authorize @consolidacao, :finalizar_parcial?
|
||||||
|
|
||||||
arquivados = @consolidacao.arquivar_veiculos_pendentes!(current_user)
|
arquivados = @consolidacao.arquivar_veiculos_pendentes!(current_user)
|
||||||
if @consolidacao.finalizar!(current_user)
|
if @consolidacao.finalizar!(current_user)
|
||||||
@@ -278,7 +278,7 @@ class ConsolidacoesController < ApplicationController
|
|||||||
|
|
||||||
# POST /consolidacoes/:id/arquivar
|
# POST /consolidacoes/:id/arquivar
|
||||||
def arquivar
|
def arquivar
|
||||||
authorize @consolidacao, :destroy?
|
authorize @consolidacao, :arquivar?
|
||||||
@consolidacao.arquivar!(current_user)
|
@consolidacao.arquivar!(current_user)
|
||||||
auditar!(:arquivar, @consolidacao)
|
auditar!(:arquivar, @consolidacao)
|
||||||
redirect_to consolidacoes_path, notice: 'Consolidação arquivada.'
|
redirect_to consolidacoes_path, notice: 'Consolidação arquivada.'
|
||||||
@@ -292,7 +292,7 @@ class ConsolidacoesController < ApplicationController
|
|||||||
|
|
||||||
# POST /consolidacoes/:id/reativar — restaura uma consolidação arquivada
|
# POST /consolidacoes/:id/reativar — restaura uma consolidação arquivada
|
||||||
def reativar
|
def reativar
|
||||||
authorize @consolidacao, :destroy?
|
authorize @consolidacao, :reativar?
|
||||||
@consolidacao.reativar!(current_user)
|
@consolidacao.reativar!(current_user)
|
||||||
auditar!(:reativar, @consolidacao)
|
auditar!(:reativar, @consolidacao)
|
||||||
redirect_to consolidacoes_path, notice: 'Consolidação reativada.'
|
redirect_to consolidacoes_path, notice: 'Consolidação reativada.'
|
||||||
@@ -350,7 +350,7 @@ class ConsolidacoesController < ApplicationController
|
|||||||
# consolidação (reversível). Ele sai dos totais/pagamento/gate, mas os dados
|
# consolidação (reversível). Ele sai dos totais/pagamento/gate, mas os dados
|
||||||
# ficam guardados e podem ser restaurados. Não arquiva motorista já pago.
|
# ficam guardados e podem ser restaurados. Não arquiva motorista já pago.
|
||||||
def arquivar_motorista
|
def arquivar_motorista
|
||||||
authorize @consolidacao, :update?
|
authorize @consolidacao, :gerir_motoristas?
|
||||||
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
|
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
|
||||||
|
|
||||||
if cm.pago?
|
if cm.pago?
|
||||||
@@ -367,7 +367,7 @@ class ConsolidacoesController < ApplicationController
|
|||||||
|
|
||||||
# POST /consolidacoes/:id/desarquivar_motorista — restaura um motorista arquivado.
|
# POST /consolidacoes/:id/desarquivar_motorista — restaura um motorista arquivado.
|
||||||
def desarquivar_motorista
|
def desarquivar_motorista
|
||||||
authorize @consolidacao, :update?
|
authorize @consolidacao, :gerir_motoristas?
|
||||||
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
|
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
|
||||||
|
|
||||||
cm.desarquivar!(current_user)
|
cm.desarquivar!(current_user)
|
||||||
@@ -382,7 +382,7 @@ class ConsolidacoesController < ApplicationController
|
|||||||
# como consolidacao_entregas se liga ao motorista por STRING (motorista_nome,
|
# como consolidacao_entregas se liga ao motorista por STRING (motorista_nome,
|
||||||
# sem FK/cascade), apagamos os lançamentos dele à mão antes de remover o registro.
|
# sem FK/cascade), apagamos os lançamentos dele à mão antes de remover o registro.
|
||||||
def excluir_motorista
|
def excluir_motorista
|
||||||
authorize @consolidacao, :destroy?
|
authorize @consolidacao, :excluir_motorista?
|
||||||
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
|
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
|
||||||
|
|
||||||
if cm.pago?
|
if cm.pago?
|
||||||
@@ -407,7 +407,7 @@ class ConsolidacoesController < ApplicationController
|
|||||||
# consolidação (ou restaura, se estava arquivado). Útil para refazer rápido
|
# consolidação (ou restaura, se estava arquivado). Útil para refazer rápido
|
||||||
# depois de excluir/arquivar por erro de precificação.
|
# depois de excluir/arquivar por erro de precificação.
|
||||||
def adicionar_motorista
|
def adicionar_motorista
|
||||||
authorize @consolidacao, :update?
|
authorize @consolidacao, :gerir_motoristas?
|
||||||
nome = params[:motorista_nome].to_s.strip
|
nome = params[:motorista_nome].to_s.strip
|
||||||
|
|
||||||
if nome.blank?
|
if nome.blank?
|
||||||
@@ -435,7 +435,7 @@ class ConsolidacoesController < ApplicationController
|
|||||||
|
|
||||||
# GET /consolidacoes/:id/gerar_pdf_relatorio?motorista=X
|
# GET /consolidacoes/:id/gerar_pdf_relatorio?motorista=X
|
||||||
def gerar_pdf_relatorio
|
def gerar_pdf_relatorio
|
||||||
authorize @consolidacao, :show?
|
authorize @consolidacao, :exportar_pdf?
|
||||||
motorista = params[:motorista]
|
motorista = params[:motorista]
|
||||||
|
|
||||||
pdf = Pdf::RelatorioMotoristaPdf.new(consolidacao: @consolidacao, motorista: motorista)
|
pdf = Pdf::RelatorioMotoristaPdf.new(consolidacao: @consolidacao, motorista: motorista)
|
||||||
@@ -449,7 +449,7 @@ class ConsolidacoesController < ApplicationController
|
|||||||
|
|
||||||
# GET /consolidacoes/:id/gerar_pdf_financeiro — relatório financeiro da consolidação
|
# GET /consolidacoes/:id/gerar_pdf_financeiro — relatório financeiro da consolidação
|
||||||
def gerar_pdf_financeiro
|
def gerar_pdf_financeiro
|
||||||
authorize @consolidacao, :show?
|
authorize @consolidacao, :exportar_pdf?
|
||||||
|
|
||||||
pdf = Pdf::RelatorioFinanceiroConsolidacaoPdf.new(consolidacao: @consolidacao)
|
pdf = Pdf::RelatorioFinanceiroConsolidacaoPdf.new(consolidacao: @consolidacao)
|
||||||
auditar!(:editar, @consolidacao, dados_novos: { pdf: 'financeiro' })
|
auditar!(:editar, @consolidacao, dados_novos: { pdf: 'financeiro' })
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ class DashboardController < ApplicationController
|
|||||||
MAX_DIAS_GRAFICO = 92 # limita a granularidade diária do gráfico (≈ 1 trimestre)
|
MAX_DIAS_GRAFICO = 92 # limita a granularidade diária do gráfico (≈ 1 trimestre)
|
||||||
|
|
||||||
def index
|
def index
|
||||||
skip_authorization
|
|
||||||
|
|
||||||
# Motorista tem painel próprio — não enxerga o dashboard administrativo
|
# Motorista tem painel próprio — não enxerga o dashboard administrativo
|
||||||
return redirect_to(motorista_dashboard_path) if current_user.motorista?
|
return redirect_to(motorista_dashboard_path) if current_user.motorista?
|
||||||
|
|
||||||
|
authorize :dashboard, :ver?
|
||||||
|
|
||||||
|
# Custo, pagamento e ticket médio são permissão à parte: um perfil de
|
||||||
|
# acompanhamento vê a operação sem ver quanto ela custa. Antes o controller
|
||||||
|
# usava skip_authorization e o papel `externo` enxergava tudo.
|
||||||
|
@ver_financeiro = current_user.pode?('dashboard.financeiro')
|
||||||
|
|
||||||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||||||
carregar_dados_dashboard
|
carregar_dados_dashboard
|
||||||
end
|
end
|
||||||
@@ -15,9 +20,11 @@ class DashboardController < ApplicationController
|
|||||||
# GET /dashboard/relatorio_financeiro?inicio=&fim=&operacoes[]=
|
# GET /dashboard/relatorio_financeiro?inicio=&fim=&operacoes[]=
|
||||||
# Relatório financeiro do período (PDF) — mesmos números da tela.
|
# Relatório financeiro do período (PDF) — mesmos números da tela.
|
||||||
def relatorio_financeiro
|
def relatorio_financeiro
|
||||||
skip_authorization
|
|
||||||
return redirect_to(motorista_dashboard_path) if current_user.motorista?
|
return redirect_to(motorista_dashboard_path) if current_user.motorista?
|
||||||
|
|
||||||
|
authorize :dashboard, :financeiro?
|
||||||
|
@ver_financeiro = true
|
||||||
|
|
||||||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||||||
carregar_dados_dashboard
|
carregar_dados_dashboard
|
||||||
|
|
||||||
@@ -58,9 +65,10 @@ class DashboardController < ApplicationController
|
|||||||
# PLANEJADA: é o único recorte sem checkout, então planned_date é o eixo
|
# PLANEJADA: é o único recorte sem checkout, então planned_date é o eixo
|
||||||
# possível. Mesmo recorte do card, para o total bater.
|
# possível. Mesmo recorte do card, para o total bater.
|
||||||
def pendentes
|
def pendentes
|
||||||
skip_authorization
|
|
||||||
return redirect_to(motorista_dashboard_path) if current_user.motorista?
|
return redirect_to(motorista_dashboard_path) if current_user.motorista?
|
||||||
|
|
||||||
|
authorize :dashboard, :pendentes?
|
||||||
|
|
||||||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||||||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||||
@operacao_filtro = Operacao.sanitizar(params[:operacoes])
|
@operacao_filtro = Operacao.sanitizar(params[:operacoes])
|
||||||
@@ -179,12 +187,13 @@ class DashboardController < ApplicationController
|
|||||||
@consolidacoes_abertas = @consolidacoes_mes.where(status: :rascunho).count
|
@consolidacoes_abertas = @consolidacoes_mes.where(status: :rascunho).count
|
||||||
@consolidacoes_fechadas = @consolidacoes_mes.where(status: :finalizada).count
|
@consolidacoes_fechadas = @consolidacoes_mes.where(status: :finalizada).count
|
||||||
|
|
||||||
# Pagamentos: pago vs pendente entre as consolidações FINALIZADAS do período
|
# Pagamentos e visão financeira só são consultados por quem pode vê-los: sem a
|
||||||
# (medido por motorista — fonte: consolidacao_motoristas).
|
# permissão, o dado não chega à página (a view esconde os blocos, mas o certo
|
||||||
|
# é nem carregar — é consulta cara e é informação de custo).
|
||||||
|
if @ver_financeiro
|
||||||
carregar_dados_pagamentos
|
carregar_dados_pagamentos
|
||||||
|
|
||||||
# Visão financeira por operação (KPIs + gráficos)
|
|
||||||
carregar_dados_financeiro
|
carregar_dados_financeiro
|
||||||
|
end
|
||||||
|
|
||||||
# Histórico estimado mais recente
|
# Histórico estimado mais recente
|
||||||
@historico_recente = HistoricoEstimado.order(created_at: :desc).limit(5)
|
@historico_recente = HistoricoEstimado.order(created_at: :desc).limit(5)
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ class OperacoesDashboardController < ApplicationController
|
|||||||
# Baixa a planilha Entregas (.xlsx) da operação já preenchida com o resultado
|
# Baixa a planilha Entregas (.xlsx) da operação já preenchida com o resultado
|
||||||
# do rastreio — o modelo que é entregue ao cliente.
|
# do rastreio — o modelo que é entregue ao cliente.
|
||||||
def baixar_planilha
|
def baixar_planilha
|
||||||
authorize :dashboard, :operacoes?
|
# Chave própria: aqui o dado do cliente SAI do sistema (.xlsx), o que é
|
||||||
|
# diferente de olhar os números na tela.
|
||||||
|
authorize :dashboard, :exportar?
|
||||||
|
|
||||||
tabela = params[:operacao].to_s
|
tabela = params[:operacao].to_s
|
||||||
unless Operacao.valida?(tabela)
|
unless Operacao.valida?(tabela)
|
||||||
|
|||||||
12
app/controllers/paginas_controller.rb
Normal file
12
app/controllers/paginas_controller.rb
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# app/controllers/paginas_controller.rb
|
||||||
|
#
|
||||||
|
# Telas do sistema que não pertencem a nenhum recurso.
|
||||||
|
class PaginasController < ApplicationController
|
||||||
|
# Quem chega aqui está autenticado, mas o perfil de acesso não libera nenhuma
|
||||||
|
# tela. Sem esta página, o "acesso negado" mandaria de volta para a raiz — que
|
||||||
|
# também nega — e a pessoa ficaria num loop de redirecionamento.
|
||||||
|
def sem_acesso
|
||||||
|
skip_authorization
|
||||||
|
@perfil = current_user.perfil_acesso
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -20,14 +20,13 @@ class Users::SessionsController < Devise::SessionsController
|
|||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
|
# Para onde a pessoa vai depois de entrar. NÃO é mais "motorista → painel,
|
||||||
|
# resto → dashboard": com perfis de acesso, alguém pode não ter o dashboard e
|
||||||
|
# cairia direto numa tela negada. `home_rota` devolve a primeira tela permitida.
|
||||||
def after_sign_in_path_for(resource)
|
def after_sign_in_path_for(resource)
|
||||||
session[:mostrar_splash] = true # dispara a animação de abertura uma vez
|
session[:mostrar_splash] = true # dispara a animação de abertura uma vez
|
||||||
case resource.role
|
rota = resource.home_rota
|
||||||
when 'motorista'
|
rota ? public_send(rota) : sem_acesso_path
|
||||||
motorista_dashboard_path
|
|
||||||
else
|
|
||||||
dashboard_path
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def after_sign_out_path_for(resource_or_scope)
|
def after_sign_out_path_for(resource_or_scope)
|
||||||
|
|||||||
@@ -78,7 +78,13 @@ class EventoNotificacao < ApplicationRecord
|
|||||||
t&.utilizavel? ? t : nil
|
t&.utilizavel? ? t : nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def variaveis = Notificacao::Variaveis.para(gatilho)
|
# Todas as variáveis do sistema, agrupadas por origem e já marcadas com
|
||||||
|
# `aplicavel:` para este gatilho — a lista curta de antes obrigava deploy para
|
||||||
|
# o ADM escrever qualquer número no texto.
|
||||||
|
def variaveis = Notificacao::CatalogoVariaveis.por_origem(gatilho)
|
||||||
|
|
||||||
|
# Só os nomes que ESTE gatilho preenche (usado pela invariante dos specs).
|
||||||
|
def variaveis_do_contexto = Notificacao::Variaveis.nomes(gatilho)
|
||||||
|
|
||||||
# Contatos que devem receber este evento, sem repetição — a mesma pessoa pode
|
# Contatos que devem receber este evento, sem repetição — a mesma pessoa pode
|
||||||
# estar em dois grupos assinantes.
|
# estar em dois grupos assinantes.
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class MensagemTemplate < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def preview(dados = nil)
|
def preview(dados = nil)
|
||||||
dados ||= Notificacao::Variaveis.amostra(evento_notificacao&.gatilho)
|
dados ||= Notificacao::CatalogoVariaveis.amostra(evento_notificacao&.gatilho)
|
||||||
r = renderizador(dados)
|
r = renderizador(dados)
|
||||||
email? ? r.html : r.texto
|
email? ? r.html : r.texto
|
||||||
end
|
end
|
||||||
|
|||||||
62
app/models/perfil_acesso.rb
Normal file
62
app/models/perfil_acesso.rb
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# app/models/perfil_acesso.rb
|
||||||
|
#
|
||||||
|
# O conjunto de permissões que o ADM monta na tela e atribui a um usuário.
|
||||||
|
# O catálogo do que PODE ser marcado é código (Permissao::TODAS); aqui fica só a
|
||||||
|
# escolha do ADM.
|
||||||
|
class PerfilAcesso < ApplicationRecord
|
||||||
|
# Nome da tabela fixado: o Inflector pt-BR resolveria `perfil_acesso` como
|
||||||
|
# "perfil_acessos" (mesma pegadinha de ConsolidacaoMotorista e das tabelas de
|
||||||
|
# notificação). A tabela real é `perfis_acesso`.
|
||||||
|
self.table_name = 'perfis_acesso'
|
||||||
|
|
||||||
|
has_many :users, foreign_key: :perfil_acesso_id, dependent: :nullify, inverse_of: :perfil_acesso
|
||||||
|
|
||||||
|
validates :nome, presence: true
|
||||||
|
validates :nome, uniqueness: { case_sensitive: false }
|
||||||
|
|
||||||
|
before_validation :normalizar_permissoes
|
||||||
|
|
||||||
|
scope :ativos, -> { where(ativo: true) }
|
||||||
|
scope :ordenados, -> { order(Arel.sql('LOWER(nome)')) }
|
||||||
|
scope :de_sistema, -> { where(sistema: true) }
|
||||||
|
|
||||||
|
# Chaves que ainda existem no catálogo. Uma permissão removida do código não
|
||||||
|
# pode travar um perfil salvo meses atrás — ela simplesmente deixa de valer.
|
||||||
|
def permissoes_validas
|
||||||
|
Permissao.filtrar(permissoes)
|
||||||
|
end
|
||||||
|
|
||||||
|
def pode?(chave)
|
||||||
|
permissoes_validas.include?(chave.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Perfil de sistema é editável e desativável, mas não apagável (apagar deixaria
|
||||||
|
# usuários órfãos e a tela sem ponto de partida). Perfil em uso também não sai:
|
||||||
|
# o usuário cairia no fallback do papel sem ninguém perceber.
|
||||||
|
def apagavel?
|
||||||
|
!sistema? && users.none?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Este perfil ainda consegue reabrir a porta do sistema? É o que a trava do
|
||||||
|
# último administrador pergunta antes de deixar salvar.
|
||||||
|
def administra_acesso?
|
||||||
|
ativo? && pode?('admin.perfis') && pode?('admin.usuarios_gerenciar')
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_permissoes
|
||||||
|
permissoes_validas.size
|
||||||
|
end
|
||||||
|
|
||||||
|
# Um perfil que não enxerga nada existe (é uma forma de suspender acesso sem
|
||||||
|
# desativar a conta), mas a tela precisa avisar.
|
||||||
|
def vazio?
|
||||||
|
permissoes_validas.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalizar_permissoes
|
||||||
|
self.nome = nome.to_s.strip
|
||||||
|
self.permissoes = Permissao.filtrar(permissoes)
|
||||||
|
end
|
||||||
|
end
|
||||||
245
app/models/permissao.rb
Normal file
245
app/models/permissao.rb
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# app/models/permissao.rb
|
||||||
|
#
|
||||||
|
# Catálogo das permissões do sistema — fonte ÚNICA de "o que dá para liberar".
|
||||||
|
# Não é tabela: permissão é código (cada chave corresponde a um `authorize` real),
|
||||||
|
# então versioná-la no banco só criaria duas verdades. O que o ADM cadastra é o
|
||||||
|
# PERFIL (PerfilAcesso), que guarda uma lista destas chaves.
|
||||||
|
#
|
||||||
|
# REGRA DE GRANULARIDADE: uma chave por TELA (`ver`) e uma chave a mais só quando
|
||||||
|
# a ação tem régua diferente do "ver" — foi assim que o sistema já se comportava
|
||||||
|
# (ex.: ver consolidação x registrar pagamento). Quebrar mais fino do que isso
|
||||||
|
# encheria a tela de perfil de caixinhas que ninguém sabe responder.
|
||||||
|
#
|
||||||
|
# ⚠️ Toda chave nova precisa (1) entrar aqui, (2) ser usada por alguma policy ou
|
||||||
|
# view e (3) entrar em PADRAO_POR_ROLE se algum papel legado já tinha aquele
|
||||||
|
# acesso — senão o fallback silenciosamente tira acesso de quem tinha.
|
||||||
|
module Permissao
|
||||||
|
# Ordem em que os grupos aparecem na tela de perfil.
|
||||||
|
GRUPOS = {
|
||||||
|
'dashboard' => 'Dashboard e operações',
|
||||||
|
'consolidacao' => 'Consolidações e pagamento',
|
||||||
|
'notificacao' => 'Notificações',
|
||||||
|
'admin' => 'Administração'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# chave => { grupo, label (o que o ADM lê), descricao (o efeito real) }
|
||||||
|
TODAS = {
|
||||||
|
# ── Dashboard ────────────────────────────────────────────
|
||||||
|
'dashboard.ver' => {
|
||||||
|
grupo: 'dashboard', label: 'Ver o dashboard',
|
||||||
|
descricao: 'Tela inicial com os números do período.'
|
||||||
|
},
|
||||||
|
'dashboard.financeiro' => {
|
||||||
|
grupo: 'dashboard', label: 'Ver custos e pagamentos',
|
||||||
|
descricao: 'Bloco financeiro, relatório em PDF e a lista de entregas em aberto.'
|
||||||
|
},
|
||||||
|
'dashboard.operacoes' => {
|
||||||
|
grupo: 'dashboard', label: 'Ver o dashboard de operações',
|
||||||
|
descricao: 'Entregas por operação, insucessos, mapa e planilha espelho.'
|
||||||
|
},
|
||||||
|
'dashboard.exportar' => {
|
||||||
|
grupo: 'dashboard', label: 'Baixar a planilha da operação',
|
||||||
|
descricao: 'Download do .xlsx que é entregue ao cliente — é dado saindo do sistema.'
|
||||||
|
},
|
||||||
|
'dashboard.metricas' => {
|
||||||
|
grupo: 'dashboard', label: 'Consultar as métricas pela API',
|
||||||
|
descricao: 'Endpoint interno /api/v1/dashboard/metricas.'
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── Consolidações ────────────────────────────────────────
|
||||||
|
'consolidacao.ver' => {
|
||||||
|
grupo: 'consolidacao', label: 'Ver consolidações',
|
||||||
|
descricao: 'Lista, arquivadas, detalhe e prévia do extrato.'
|
||||||
|
},
|
||||||
|
'consolidacao.criar' => {
|
||||||
|
grupo: 'consolidacao', label: 'Criar consolidação',
|
||||||
|
descricao: 'Nova consolidação e nota avulsa.'
|
||||||
|
},
|
||||||
|
'consolidacao.editar' => {
|
||||||
|
grupo: 'consolidacao', label: 'Classificar e lançar',
|
||||||
|
descricao: 'Wizard, classificação das entregas, apontamentos e veículos.'
|
||||||
|
},
|
||||||
|
'consolidacao.editar_finalizada' => {
|
||||||
|
grupo: 'consolidacao', label: 'Mexer depois de finalizada',
|
||||||
|
descricao: 'Alterar uma consolidação já fechada — muda valor que o motorista vai receber.'
|
||||||
|
},
|
||||||
|
'consolidacao.finalizar' => {
|
||||||
|
grupo: 'consolidacao', label: 'Finalizar consolidação',
|
||||||
|
descricao: 'Fechar o período (inclusive o fechamento parcial).'
|
||||||
|
},
|
||||||
|
'consolidacao.arquivar' => {
|
||||||
|
grupo: 'consolidacao', label: 'Arquivar e reativar',
|
||||||
|
descricao: 'Tirar da lista sem apagar, e restaurar.'
|
||||||
|
},
|
||||||
|
'consolidacao.excluir' => {
|
||||||
|
grupo: 'consolidacao', label: 'Excluir de vez',
|
||||||
|
descricao: 'Apagar consolidação ou remover um motorista definitivamente.'
|
||||||
|
},
|
||||||
|
'consolidacao.registrar_pagamento' => {
|
||||||
|
grupo: 'consolidacao', label: 'Registrar pagamento',
|
||||||
|
descricao: 'Marcar motorista como pago (forma de pagamento e nota fiscal).'
|
||||||
|
},
|
||||||
|
'consolidacao.cancelar_pagamento' => {
|
||||||
|
grupo: 'consolidacao', label: 'Estornar pagamento',
|
||||||
|
descricao: 'Desfazer um pagamento já registrado.'
|
||||||
|
},
|
||||||
|
'consolidacao.gerir_motoristas' => {
|
||||||
|
grupo: 'consolidacao', label: 'Adicionar e arquivar motorista',
|
||||||
|
descricao: 'Quem entra e quem sai do fechamento (sem excluir).'
|
||||||
|
},
|
||||||
|
'consolidacao.exportar_pdf' => {
|
||||||
|
grupo: 'consolidacao', label: 'Baixar relatórios em PDF',
|
||||||
|
descricao: 'Extrato do motorista, relatório individual, financeiro e totais.'
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── Notificações ─────────────────────────────────────────
|
||||||
|
'notificacao.contatos' => {
|
||||||
|
grupo: 'notificacao', label: 'Ver contatos e grupos',
|
||||||
|
descricao: 'Quem recebe as mensagens.'
|
||||||
|
},
|
||||||
|
'notificacao.contatos_gerenciar' => {
|
||||||
|
grupo: 'notificacao', label: 'Criar e editar contatos/grupos',
|
||||||
|
descricao: 'Cadastro de destinatários e de grupos de WhatsApp.'
|
||||||
|
},
|
||||||
|
'notificacao.contatos_excluir' => {
|
||||||
|
grupo: 'notificacao', label: 'Excluir contatos/grupos',
|
||||||
|
descricao: 'Remoção definitiva de destinatário.'
|
||||||
|
},
|
||||||
|
'notificacao.eventos' => {
|
||||||
|
grupo: 'notificacao', label: 'Ver eventos',
|
||||||
|
descricao: 'Lista dos avisos configurados e seus gatilhos.'
|
||||||
|
},
|
||||||
|
'notificacao.eventos_gerenciar' => {
|
||||||
|
grupo: 'notificacao', label: 'Criar e editar eventos',
|
||||||
|
descricao: 'Gatilho, grupos assinantes e o editor de mensagem.'
|
||||||
|
},
|
||||||
|
'notificacao.disparar_manual' => {
|
||||||
|
grupo: 'notificacao', label: 'Disparar mensagem agora',
|
||||||
|
descricao: 'Botão que envia de verdade, em nome da empresa.'
|
||||||
|
},
|
||||||
|
'notificacao.variaveis' => {
|
||||||
|
grupo: 'notificacao', label: 'Criar variáveis de mensagem',
|
||||||
|
descricao: 'Cadastra {{variaveis}} próprias usadas nos textos de aviso.'
|
||||||
|
},
|
||||||
|
'notificacao.envios' => {
|
||||||
|
grupo: 'notificacao', label: 'Ver o histórico de envios',
|
||||||
|
descricao: 'O que saiu, para quem, e o que falhou.'
|
||||||
|
},
|
||||||
|
'notificacao.credenciais' => {
|
||||||
|
grupo: 'notificacao', label: 'Configurar e-mail e WhatsApp',
|
||||||
|
descricao: 'Credenciais de SMTP/Twilio e limites de envio.'
|
||||||
|
},
|
||||||
|
'notificacao.whatsapp_sessao' => {
|
||||||
|
grupo: 'notificacao', label: 'Parear o WhatsApp',
|
||||||
|
descricao: 'Ler o QR Code, testar e desconectar a sessão.'
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── Administração ────────────────────────────────────────
|
||||||
|
'admin.usuarios' => {
|
||||||
|
grupo: 'admin', label: 'Ver usuários',
|
||||||
|
descricao: 'Lista de quem tem acesso ao sistema.'
|
||||||
|
},
|
||||||
|
'admin.usuarios_gerenciar' => {
|
||||||
|
grupo: 'admin', label: 'Criar, editar e desativar usuários',
|
||||||
|
descricao: 'Inclui definir o tipo de conta e o perfil de acesso de cada um.'
|
||||||
|
},
|
||||||
|
'admin.perfis' => {
|
||||||
|
grupo: 'admin', label: 'Gerenciar perfis de acesso',
|
||||||
|
descricao: 'Criar perfis e marcar o que cada um enxerga — esta tela.'
|
||||||
|
},
|
||||||
|
'admin.configuracoes' => {
|
||||||
|
grupo: 'admin', label: 'Ver configurações',
|
||||||
|
descricao: 'Tabela de preços e parâmetros do sistema.'
|
||||||
|
},
|
||||||
|
'admin.configuracoes_editar' => {
|
||||||
|
grupo: 'admin', label: 'Alterar preços',
|
||||||
|
descricao: 'Muda o valor de todo fechamento feito daqui para a frente.'
|
||||||
|
},
|
||||||
|
'admin.auditoria' => {
|
||||||
|
grupo: 'admin', label: 'Ver a auditoria',
|
||||||
|
descricao: 'Registro de quem fez o quê no sistema.'
|
||||||
|
},
|
||||||
|
'admin.edicao_lancamento' => {
|
||||||
|
grupo: 'admin', label: 'Corrigir lançamento no SimpliRoute',
|
||||||
|
descricao: 'Escreve na API do rastreio — altera o dado de origem.'
|
||||||
|
},
|
||||||
|
'admin.planilha_simpli_route' => {
|
||||||
|
grupo: 'admin', label: 'Gerar planilha de carga',
|
||||||
|
descricao: 'Download do .xlsx de importação do SimpliRoute.'
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# O que cada papel legado podia fazer ANTES dos perfis existirem. É o fallback
|
||||||
|
# de quem ainda não tem perfil (usuário recém-criado, objeto não persistido nos
|
||||||
|
# specs) e a receita dos perfis pré-prontos criados na migration de dados.
|
||||||
|
#
|
||||||
|
# ⚠️ Reproduz o comportamento atual de propósito — com duas exceções
|
||||||
|
# deliberadas, que são justamente o que o ADM pediu para consertar:
|
||||||
|
# • `externo` perde `dashboard.financeiro` (hoje enxerga custo e pagamento
|
||||||
|
# porque o DashboardController não passa pelo Pundit);
|
||||||
|
# • `operador` não recebe `admin.auditoria` (hoje a URL responde para ele por
|
||||||
|
# causa do `policy_class: ApplicationPolicy`, mas o menu nunca ofereceu).
|
||||||
|
PADRAO_POR_ROLE = {
|
||||||
|
'admin' => :todas,
|
||||||
|
'gerente' => %w[
|
||||||
|
dashboard.ver dashboard.financeiro dashboard.operacoes dashboard.exportar dashboard.metricas
|
||||||
|
consolidacao.ver consolidacao.criar consolidacao.editar consolidacao.editar_finalizada
|
||||||
|
consolidacao.finalizar consolidacao.arquivar consolidacao.excluir
|
||||||
|
consolidacao.registrar_pagamento consolidacao.cancelar_pagamento
|
||||||
|
consolidacao.gerir_motoristas consolidacao.exportar_pdf
|
||||||
|
notificacao.contatos notificacao.contatos_gerenciar notificacao.envios
|
||||||
|
admin.usuarios admin.configuracoes admin.auditoria admin.planilha_simpli_route
|
||||||
|
].freeze,
|
||||||
|
'operador' => %w[
|
||||||
|
dashboard.ver dashboard.financeiro dashboard.operacoes dashboard.exportar dashboard.metricas
|
||||||
|
consolidacao.ver consolidacao.criar consolidacao.editar consolidacao.finalizar
|
||||||
|
consolidacao.gerir_motoristas consolidacao.exportar_pdf
|
||||||
|
].freeze,
|
||||||
|
'externo' => %w[dashboard.ver dashboard.operacoes].freeze,
|
||||||
|
# Motorista não usa permissão: o que ele acessa é a área própria dele, que
|
||||||
|
# continua governada pelo tipo de conta (Motorista::DashboardController).
|
||||||
|
'motorista' => [].freeze
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def chaves
|
||||||
|
@chaves ||= TODAS.keys.freeze
|
||||||
|
end
|
||||||
|
|
||||||
|
def existe?(chave)
|
||||||
|
TODAS.key?(chave.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
def label(chave)
|
||||||
|
TODAS.dig(chave.to_s, :label) || chave.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def descricao(chave)
|
||||||
|
TODAS.dig(chave.to_s, :descricao)
|
||||||
|
end
|
||||||
|
|
||||||
|
def grupo_label(grupo)
|
||||||
|
GRUPOS[grupo.to_s] || grupo.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
# { 'dashboard' => [['dashboard.ver', {...}], ...], ... } na ordem de GRUPOS —
|
||||||
|
# é como a tela de perfil desenha as seções.
|
||||||
|
def por_grupo
|
||||||
|
@por_grupo ||= GRUPOS.keys.index_with { |g| TODAS.select { |_, cfg| cfg[:grupo] == g }.to_a }.freeze
|
||||||
|
end
|
||||||
|
|
||||||
|
# Chaves do papel legado. `:todas` (admin) vira a lista inteira.
|
||||||
|
def do_role(role)
|
||||||
|
padrao = PADRAO_POR_ROLE[role.to_s]
|
||||||
|
return chaves if padrao == :todas
|
||||||
|
|
||||||
|
padrao || []
|
||||||
|
end
|
||||||
|
|
||||||
|
# Descarta o que não existe no catálogo. Uma chave removida do código não pode
|
||||||
|
# travar o cadastro de um perfil salvo meses atrás — ela simplesmente some.
|
||||||
|
def filtrar(lista)
|
||||||
|
Array(lista).map(&:to_s).uniq.select { |chave| existe?(chave) }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -6,6 +6,10 @@ class User < ApplicationRecord
|
|||||||
:rememberable,
|
:rememberable,
|
||||||
:validatable
|
:validatable
|
||||||
|
|
||||||
|
# Perfil de acesso: nulo é válido (motorista não usa; e quem ainda não foi
|
||||||
|
# migrado cai no padrão do papel — ver #permissoes_efetivas).
|
||||||
|
belongs_to :perfil_acesso, optional: true, inverse_of: :users
|
||||||
|
|
||||||
# ── Roles ──────────────────────────────────────────────────
|
# ── Roles ──────────────────────────────────────────────────
|
||||||
# externo: acesso somente-leitura ao dashboard (ex.: gerências de outras áreas
|
# externo: acesso somente-leitura ao dashboard (ex.: gerências de outras áreas
|
||||||
# que só precisam visualizar os indicadores). Login normal por e-mail/senha.
|
# que só precisam visualizar os indicadores). Login normal por e-mail/senha.
|
||||||
@@ -57,12 +61,112 @@ class User < ApplicationRecord
|
|||||||
ROLES_LABEL[role] || role.humanize
|
ROLES_LABEL[role] || role.humanize
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Conta desativada não entra — nem por e-mail/senha.
|
||||||
|
#
|
||||||
|
# Só o login por PIN checava `ativo?` (Users::SessionsController), então
|
||||||
|
# desativar um gerente não impedia nada, apesar do formulário prometer
|
||||||
|
# "usuários inativos não conseguem fazer login". Devise chama este método em
|
||||||
|
# todo login e em toda request de sessão existente, então desativar alguém já
|
||||||
|
# derruba a sessão aberta dele.
|
||||||
|
def active_for_authentication?
|
||||||
|
super && ativo?
|
||||||
|
end
|
||||||
|
|
||||||
|
def inactive_message
|
||||||
|
ativo? ? super : :conta_desativada
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Permissões ──────────────────────────────────────────────
|
||||||
|
# O que o usuário pode fazer sai do PERFIL DE ACESSO (PerfilAcesso), que o ADM
|
||||||
|
# monta na tela. Enquanto ele não tem perfil, vale o padrão do papel legado
|
||||||
|
# (Permissao::PADRAO_POR_ROLE) — é o que mantém o sistema funcionando durante a
|
||||||
|
# migração e o que faz um usuário recém-criado não ficar sem nada.
|
||||||
|
def pode?(chave)
|
||||||
|
permissoes_efetivas.include?(chave.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set (não Array): `pode?` é chamado dezenas de vezes por render de tela — uma
|
||||||
|
# vez por item de menu e por botão.
|
||||||
|
#
|
||||||
|
# ⚠️ A primeira linha existe para NÃO tocar o banco quando não há perfil: os
|
||||||
|
# specs de policy usam `build(:admin)` (objeto sem id), e `perfil_acesso`
|
||||||
|
# dispararia consulta com id nulo.
|
||||||
|
def permissoes_efetivas
|
||||||
|
@permissoes_efetivas ||=
|
||||||
|
if perfil_acesso_id.nil?
|
||||||
|
Set.new(Permissao.do_role(role))
|
||||||
|
else
|
||||||
|
Set.new(perfil_acesso&.permissoes_validas || Permissao.do_role(role))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# A coluna nasce numa migration própria. Se o código subir antes de `db:migrate`
|
||||||
|
# rodar, `perfil_acesso_id` não existe e TODA tela quebraria por NoMethodError —
|
||||||
|
# aqui o sistema simplesmente continua no padrão do papel até a coluna chegar.
|
||||||
|
# Pode sair depois que a migration estiver aplicada em produção.
|
||||||
|
def perfil_acesso_id
|
||||||
|
return nil unless self.class.column_names.include?('perfil_acesso_id')
|
||||||
|
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
# Trocar o perfil no meio da request (tela de usuários) tem que refletir.
|
||||||
|
def recarregar_permissoes!
|
||||||
|
@permissoes_efetivas = nil
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
# Primeira tela que este usuário pode abrir, na ordem em que o menu as mostra.
|
||||||
|
#
|
||||||
|
# Existe para não haver LOOP: a raiz do app é o dashboard e o tratamento de
|
||||||
|
# "acesso negado" redireciona para a raiz — um perfil sem `dashboard.ver`
|
||||||
|
# entraria em nega → raiz → nega para sempre. `nil` significa "não pode abrir
|
||||||
|
# nada", e quem chama mostra a tela de sem-acesso.
|
||||||
|
HOME_POR_PERMISSAO = [
|
||||||
|
['dashboard.ver', :dashboard_path],
|
||||||
|
['dashboard.operacoes', :operacoes_dashboard_path],
|
||||||
|
['consolidacao.ver', :consolidacoes_path],
|
||||||
|
['admin.usuarios', :admin_usuarios_path],
|
||||||
|
['admin.perfis', :admin_perfis_path],
|
||||||
|
['admin.configuracoes', :admin_configuracoes_path],
|
||||||
|
['notificacao.contatos', :admin_contatos_path],
|
||||||
|
['notificacao.eventos', :admin_eventos_path],
|
||||||
|
['notificacao.envios', :admin_envios_path]
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
def home_rota
|
||||||
|
return :motorista_dashboard_path if motorista?
|
||||||
|
|
||||||
|
HOME_POR_PERMISSAO.find { |chave, _| pode?(chave) }&.last
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mantidos porque a navbar e vários controllers já os chamam — agora são só
|
||||||
|
# apelidos das chaves correspondentes.
|
||||||
def pode_ver_config?
|
def pode_ver_config?
|
||||||
admin? || gerente?
|
pode?('admin.configuracoes')
|
||||||
end
|
end
|
||||||
|
|
||||||
def pode_consolidar?
|
def pode_consolidar?
|
||||||
admin? || gerente? || operador?
|
pode?('consolidacao.ver')
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Trava do último administrador ───────────────────────────
|
||||||
|
# Quem, ATIVO, ainda consegue reabrir a porta do sistema (mexer em perfis e em
|
||||||
|
# usuários). Sem isto, o ADM consegue se trancar para fora: a tela que controla
|
||||||
|
# acesso é justamente a que ele perderia, e a volta só existiria por console.
|
||||||
|
#
|
||||||
|
# `ignorando` simula "e se esta pessoa for desativada, excluída ou trocada de
|
||||||
|
# perfil?" — passe o usuário que está sendo alterado.
|
||||||
|
def self.administradores_de_acesso(ignorando: nil)
|
||||||
|
ativos.includes(:perfil_acesso)
|
||||||
|
.reject { |u| ignorando && u.id == ignorando.id }
|
||||||
|
.select { |u| u.pode?('admin.perfis') && u.pode?('admin.usuarios_gerenciar') }
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.ultimo_administrador?(usuario)
|
||||||
|
return false unless usuario.pode?('admin.perfis') && usuario.pode?('admin.usuarios_gerenciar')
|
||||||
|
|
||||||
|
administradores_de_acesso(ignorando: usuario).empty?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Token de login rápido via QR Code (extrato) — Fase 7/8
|
# Token de login rápido via QR Code (extrato) — Fase 7/8
|
||||||
|
|||||||
46
app/models/variavel_personalizada.rb
Normal file
46
app/models/variavel_personalizada.rb
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# app/models/variavel_personalizada.rb
|
||||||
|
#
|
||||||
|
# Variável criada pelo ADM, com valor fixo, usável em qualquer mensagem.
|
||||||
|
class VariavelPersonalizada < ApplicationRecord
|
||||||
|
# Nome da tabela fixado: o Inflector pt-BR não resolve variavel → variaveis.
|
||||||
|
self.table_name = 'variaveis_personalizadas'
|
||||||
|
|
||||||
|
# Mesmo formato que o renderizador aceita em {{...}}: letras minúsculas,
|
||||||
|
# números e _ . Sem isso, o ADM cadastraria "Telefone Suporte" e a variável
|
||||||
|
# nunca seria substituída — falha muda dentro da mensagem enviada.
|
||||||
|
FORMATO_CHAVE = /\A[a-z][a-z0-9_]*\z/
|
||||||
|
|
||||||
|
before_validation :normalizar
|
||||||
|
|
||||||
|
validates :chave, presence: true, uniqueness: { case_sensitive: false },
|
||||||
|
format: { with: FORMATO_CHAVE,
|
||||||
|
message: 'use apenas letras minúsculas, números e _ (ex.: telefone_suporte)' }
|
||||||
|
validates :valor, presence: true
|
||||||
|
validate :nao_colidir_com_variavel_do_sistema
|
||||||
|
|
||||||
|
scope :ativas, -> { where(ativo: true) }
|
||||||
|
scope :ordenadas, -> { order(:chave) }
|
||||||
|
|
||||||
|
def marcador = "{{#{chave}}}"
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalizar
|
||||||
|
self.chave = chave.to_s.strip.downcase.tr(' ', '_')
|
||||||
|
self.valor = valor.to_s.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
# Deixar o ADM criar {{valor}} ou {{motorista}} faria a variável dele
|
||||||
|
# sobrescrever (ou ser sobrescrita por) a do sistema, dependendo da ordem —
|
||||||
|
# exatamente o tipo de bug que não aparece no preview e aparece no envio.
|
||||||
|
def nao_colidir_com_variavel_do_sistema
|
||||||
|
return if chave.blank?
|
||||||
|
|
||||||
|
reservadas = Notificacao::CatalogoVariaveis::SISTEMA.keys +
|
||||||
|
Notificacao::CatalogoVariaveis::CONTEXTO.keys +
|
||||||
|
Notificacao::CatalogoVariaveis::DADOS.keys
|
||||||
|
return unless reservadas.include?(chave)
|
||||||
|
|
||||||
|
errors.add(:chave, "já existe no sistema — escolha outro nome (ex.: #{chave}_empresa)")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -15,7 +15,15 @@ class ApplicationPolicy
|
|||||||
def edit? = update?
|
def edit? = update?
|
||||||
def destroy? = user.admin?
|
def destroy? = user.admin?
|
||||||
|
|
||||||
# Helpers de papel disponíveis para todas as policies (delegam para o User)
|
# Régua padrão de TODAS as policies: pergunta ao perfil de acesso do usuário
|
||||||
|
# (User#pode?), não ao papel dele. As chaves estão em Permissao::TODAS.
|
||||||
|
def pode?(chave)
|
||||||
|
user.pode?(chave)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helpers de papel — LEGADO. Continuam aqui porque `admin?` ainda distingue
|
||||||
|
# tipo de conta em pontos que não são permissão (ex.: escopo de listagem).
|
||||||
|
# Regra nova não deve usá-los: use `pode?`.
|
||||||
def admin?
|
def admin?
|
||||||
user.admin?
|
user.admin?
|
||||||
end
|
end
|
||||||
|
|||||||
16
app/policies/auditoria_log_policy.rb
Normal file
16
app/policies/auditoria_log_policy.rb
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# app/policies/auditoria_log_policy.rb
|
||||||
|
#
|
||||||
|
# Existe para a auditoria parar de cair no default genérico da ApplicationPolicy
|
||||||
|
# (`policy_class: ApplicationPolicy` no controller): a régua de lá é a de
|
||||||
|
# consolidação, e quem podia consolidar entrava na auditoria pela URL, embora o
|
||||||
|
# menu nunca oferecesse.
|
||||||
|
class AuditoriaLogPolicy < ApplicationPolicy
|
||||||
|
def index? = pode?('admin.auditoria')
|
||||||
|
def show? = index?
|
||||||
|
|
||||||
|
class Scope < Scope
|
||||||
|
def resolve
|
||||||
|
user.pode?('admin.auditoria') ? scope.all : scope.none
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,22 +1,7 @@
|
|||||||
# app/policies/configuracao_notificacao_policy.rb
|
# app/policies/configuracao_notificacao_policy.rb
|
||||||
#
|
#
|
||||||
# Mais restrito que ConfiguracaoPolicy (que libera index? para gerente): estas
|
# Credenciais de SMTP/Twilio e limites de envio.
|
||||||
# são credenciais de envio — senha de e-mail e token do Twilio —, então só ADM.
|
|
||||||
#
|
|
||||||
# ⚠️ Cada action nova do controller precisa de um método `<action>?` aqui,
|
|
||||||
# senão o `authorize` estoura.
|
|
||||||
class ConfiguracaoNotificacaoPolicy < ApplicationPolicy
|
class ConfiguracaoNotificacaoPolicy < ApplicationPolicy
|
||||||
def show?
|
def show? = pode?('notificacao.credenciais')
|
||||||
admin?
|
def update? = show?
|
||||||
end
|
|
||||||
|
|
||||||
def update?
|
|
||||||
admin?
|
|
||||||
end
|
|
||||||
|
|
||||||
class Scope < Scope
|
|
||||||
def resolve
|
|
||||||
scope.all
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
# app/policies/configuracao_policy.rb
|
# app/policies/configuracao_policy.rb
|
||||||
class ConfiguracaoPolicy < ApplicationPolicy
|
class ConfiguracaoPolicy < ApplicationPolicy
|
||||||
def index?
|
def index? = pode?('admin.configuracoes')
|
||||||
admin_ou_gerente?
|
def show? = index?
|
||||||
end
|
|
||||||
|
|
||||||
def edit?
|
# Alterar preço muda o valor de todo fechamento daqui para a frente — chave
|
||||||
admin?
|
# própria, separada de só olhar a tabela.
|
||||||
end
|
def edit? = pode?('admin.configuracoes_editar')
|
||||||
|
def update? = edit?
|
||||||
def update?
|
|
||||||
admin?
|
|
||||||
end
|
|
||||||
|
|
||||||
class Scope < Scope
|
|
||||||
def resolve
|
|
||||||
scope.all
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,20 +1,42 @@
|
|||||||
# app/policies/consolidacao_policy.rb
|
# app/policies/consolidacao_policy.rb
|
||||||
class ConsolidacaoPolicy < ApplicationPolicy
|
class ConsolidacaoPolicy < ApplicationPolicy
|
||||||
def index? = user.pode_consolidar?
|
def index? = pode?('consolidacao.ver')
|
||||||
def arquivadas? = user.pode_consolidar?
|
def arquivadas? = index?
|
||||||
def show? = user.pode_consolidar?
|
def show? = index?
|
||||||
def create? = user.pode_consolidar?
|
def create? = pode?('consolidacao.criar')
|
||||||
def update? = user.pode_consolidar? && !record_finalizada_para_operador?
|
def new? = create?
|
||||||
def destroy? = user.admin? || user.gerente?
|
|
||||||
|
|
||||||
# Registrar/estornar pagamento: mesma régua de arquivar/excluir.
|
# Editar uma consolidação JÁ FINALIZADA é outra permissão: mexer depois do
|
||||||
def registrar_pagamento? = user.admin? || user.gerente?
|
# fechamento muda o valor que o motorista vai receber. Antes isso era "operador
|
||||||
def cancelar_pagamento? = registrar_pagamento?
|
# não pode, gerente pode" cravado no código.
|
||||||
|
def update?
|
||||||
|
pode?('consolidacao.editar') && (!record_finalizada? || pode?('consolidacao.editar_finalizada'))
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit? = update?
|
||||||
|
|
||||||
|
def finalizar? = pode?('consolidacao.finalizar')
|
||||||
|
def finalizar_parcial? = finalizar?
|
||||||
|
|
||||||
|
def arquivar? = pode?('consolidacao.arquivar')
|
||||||
|
def reativar? = arquivar?
|
||||||
|
|
||||||
|
def destroy? = pode?('consolidacao.excluir')
|
||||||
|
def excluir_motorista? = destroy?
|
||||||
|
|
||||||
|
# Pagamento: registrar e estornar são chaves distintas — estornar desfaz algo
|
||||||
|
# que já foi comunicado ao motorista.
|
||||||
|
def registrar_pagamento? = pode?('consolidacao.registrar_pagamento')
|
||||||
|
def cancelar_pagamento? = pode?('consolidacao.cancelar_pagamento')
|
||||||
|
|
||||||
|
# Quem entra e quem sai do fechamento (sem excluir de vez).
|
||||||
|
def gerir_motoristas? = pode?('consolidacao.gerir_motoristas') && update?
|
||||||
|
|
||||||
|
def exportar_pdf? = pode?('consolidacao.exportar_pdf')
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Operador não mexe em consolidação finalizada
|
def record_finalizada?
|
||||||
def record_finalizada_para_operador?
|
record.respond_to?(:finalizada?) && record.finalizada?
|
||||||
record.respond_to?(:finalizada?) && record.finalizada? && user.operador?
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
# app/policies/contato_policy.rb
|
# app/policies/contato_policy.rb
|
||||||
#
|
#
|
||||||
# Contatos e grupos são a lista de quem recebe mensagem em nome da empresa —
|
# Contatos e grupos (GrupoContatoPolicy herda esta) — quem RECEBE as mensagens.
|
||||||
# admin e gerente administram, operador não.
|
|
||||||
#
|
|
||||||
# ⚠️ Cada action nova do controller precisa de um `<action>?` aqui, senão o
|
|
||||||
# `authorize` estoura.
|
|
||||||
class ContatoPolicy < ApplicationPolicy
|
class ContatoPolicy < ApplicationPolicy
|
||||||
def index? = admin_ou_gerente?
|
def index? = pode?('notificacao.contatos')
|
||||||
def show? = admin_ou_gerente?
|
def show? = index?
|
||||||
def create? = admin_ou_gerente?
|
|
||||||
|
def create? = pode?('notificacao.contatos_gerenciar')
|
||||||
def new? = create?
|
def new? = create?
|
||||||
def update? = admin_ou_gerente?
|
def update? = create?
|
||||||
def edit? = update?
|
def edit? = update?
|
||||||
def destroy? = admin?
|
|
||||||
|
# Excluir destinatário é separado de editar: some do histórico de quem recebe.
|
||||||
|
def destroy? = pode?('notificacao.contatos_excluir')
|
||||||
|
|
||||||
|
# Grupos do WhatsApp carregados sob demanda pelo form.
|
||||||
|
def grupos_whatsapp? = create?
|
||||||
|
|
||||||
class Scope < Scope
|
class Scope < Scope
|
||||||
def resolve = scope.all
|
def resolve
|
||||||
|
user.pode?('notificacao.contatos') ? scope.all : scope.none
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
# app/policies/dashboard_policy.rb
|
# app/policies/dashboard_policy.rb
|
||||||
class DashboardPolicy < Struct.new(:user, :dashboard)
|
#
|
||||||
def metricas?
|
# Policy headless (`authorize :dashboard, :x?`). Deixou de ser Struct para herdar
|
||||||
user.admin? || user.gerente? || user.operador?
|
# o `pode?` da ApplicationPolicy.
|
||||||
end
|
class DashboardPolicy < ApplicationPolicy
|
||||||
|
def ver? = pode?('dashboard.ver')
|
||||||
|
def index? = ver?
|
||||||
|
|
||||||
def index?
|
# Custos, pagamentos, relatório financeiro e entregas em aberto. Era o buraco
|
||||||
user.admin? || user.gerente?
|
# do `externo`: o controller usava skip_authorization e ele via tudo.
|
||||||
end
|
def financeiro? = pode?('dashboard.financeiro')
|
||||||
|
def pendentes? = financeiro?
|
||||||
|
|
||||||
# Dashboard de operações: todos menos motorista (mesma regra do dashboard HTML).
|
def operacoes? = pode?('dashboard.operacoes')
|
||||||
def operacoes?
|
def exportar? = pode?('dashboard.exportar')
|
||||||
!user.motorista?
|
def metricas? = pode?('dashboard.metricas')
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
# app/policies/edicao_lancamento_policy.rb
|
# app/policies/edicao_lancamento_policy.rb
|
||||||
#
|
#
|
||||||
# Correção de lançamento grava DIRETO na base de produção do SimpliRoute —
|
# Policy headless (`authorize :edicao_lancamento`). Escreve na API do SimpliRoute:
|
||||||
# operação sensível e irreversível. Restrita a admin.
|
# altera o dado de ORIGEM do rastreio, então é chave própria.
|
||||||
# Policy "headless": autorizada com o símbolo :edicao_lancamento (sem model).
|
|
||||||
class EdicaoLancamentoPolicy < ApplicationPolicy
|
class EdicaoLancamentoPolicy < ApplicationPolicy
|
||||||
# ⚠️ Uma entrada por action do controller: o Pundit chama "<action>?" e, se o
|
def show? = pode?('admin.edicao_lancamento')
|
||||||
# método não existir, levanta NoMethodError (500) — foi o que aconteceu ao
|
def buscar? = show?
|
||||||
# adicionar `carregar` sem passar por aqui. Ação nova no controller ⇒ linha
|
def carregar? = show?
|
||||||
# nova aqui.
|
def atualizar? = show?
|
||||||
def show? = admin?
|
def historico? = show?
|
||||||
def buscar? = admin?
|
|
||||||
def carregar? = admin?
|
|
||||||
def atualizar? = admin?
|
|
||||||
def historico? = admin?
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
# app/policies/evento_notificacao_policy.rb
|
# app/policies/evento_notificacao_policy.rb
|
||||||
#
|
|
||||||
# Eventos definem o que a empresa dispara e para quem — inclusive o botão de
|
|
||||||
# disparo manual, que manda mensagem de verdade. Só ADM.
|
|
||||||
class EventoNotificacaoPolicy < ApplicationPolicy
|
class EventoNotificacaoPolicy < ApplicationPolicy
|
||||||
def index? = admin?
|
def index? = pode?('notificacao.eventos')
|
||||||
def show? = admin?
|
def show? = index?
|
||||||
def create? = admin?
|
|
||||||
def new? = create?
|
|
||||||
def update? = admin?
|
|
||||||
def edit? = update?
|
|
||||||
def disparar? = admin?
|
|
||||||
|
|
||||||
# Evento de sistema não se apaga: o código dispara por `chave` e ficaria
|
def create? = pode?('notificacao.eventos_gerenciar')
|
||||||
# chamando no vazio. A tela oferece desativar.
|
def new? = create?
|
||||||
|
def update? = create?
|
||||||
|
def edit? = update?
|
||||||
|
|
||||||
|
# Disparar manda mensagem DE VERDADE, em nome da empresa — chave própria,
|
||||||
|
# separada de configurar o evento.
|
||||||
|
def disparar? = pode?('notificacao.disparar_manual')
|
||||||
|
|
||||||
|
# Evento de sistema não é apagável: o código dispara por ele.
|
||||||
def destroy?
|
def destroy?
|
||||||
admin? && record.respond_to?(:apagavel?) && record.apagavel?
|
create? && record.respond_to?(:apagavel?) && record.apagavel?
|
||||||
end
|
end
|
||||||
|
|
||||||
class Scope < Scope
|
class Scope < Scope
|
||||||
def resolve = scope.all
|
def resolve
|
||||||
|
user.pode?('notificacao.eventos') ? scope.all : scope.none
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
# app/policies/notificacao_envio_policy.rb
|
# app/policies/notificacao_envio_policy.rb
|
||||||
#
|
#
|
||||||
# Log de envios: o corpo das mensagens passa por aqui, então leitura é de
|
# Histórico do que saiu (e do que falhou).
|
||||||
# admin/gerente. Nada é editável — o log é registro, não cadastro.
|
|
||||||
class NotificacaoEnvioPolicy < ApplicationPolicy
|
class NotificacaoEnvioPolicy < ApplicationPolicy
|
||||||
def index? = admin_ou_gerente?
|
def index? = pode?('notificacao.envios')
|
||||||
def show? = admin_ou_gerente?
|
def show? = index?
|
||||||
|
|
||||||
class Scope < Scope
|
class Scope < Scope
|
||||||
def resolve = scope.all
|
def resolve
|
||||||
|
user.pode?('notificacao.envios') ? scope.all : scope.none
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
24
app/policies/perfil_acesso_policy.rb
Normal file
24
app/policies/perfil_acesso_policy.rb
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# app/policies/perfil_acesso_policy.rb
|
||||||
|
#
|
||||||
|
# Quem monta perfil decide o acesso de todo mundo — é a permissão mais sensível
|
||||||
|
# do sistema, por isso ela é uma chave própria (`admin.perfis`) e não vem junto
|
||||||
|
# de "gerenciar usuários".
|
||||||
|
class PerfilAcessoPolicy < ApplicationPolicy
|
||||||
|
def index? = user.pode?('admin.perfis')
|
||||||
|
def show? = index?
|
||||||
|
def create? = index?
|
||||||
|
def new? = create?
|
||||||
|
def update? = index?
|
||||||
|
def edit? = update?
|
||||||
|
|
||||||
|
# Perfil de sistema ou em uso não sai (ver PerfilAcesso#apagavel?).
|
||||||
|
def destroy?
|
||||||
|
index? && record.respond_to?(:apagavel?) && record.apagavel?
|
||||||
|
end
|
||||||
|
|
||||||
|
class Scope < Scope
|
||||||
|
def resolve
|
||||||
|
user.pode?('admin.perfis') ? scope.all : scope.none
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
# app/policies/planilha_simpliroute_policy.rb
|
# app/policies/planilha_simpli_route_policy.rb
|
||||||
#
|
#
|
||||||
# Geração da planilha de carga do SimpliRoute — tarefa de preparação de operação.
|
# Policy headless (`authorize :planilha_simpli_route`).
|
||||||
# Liberada para admin e gerente (quem prepara as cargas). Policy "headless"
|
|
||||||
# (autorizada com o símbolo :planilha_simpliroute).
|
|
||||||
class PlanilhaSimpliRoutePolicy < ApplicationPolicy
|
class PlanilhaSimpliRoutePolicy < ApplicationPolicy
|
||||||
def show? = admin_ou_gerente?
|
def show? = pode?('admin.planilha_simpli_route')
|
||||||
def baixar? = admin_ou_gerente?
|
def baixar? = show?
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,44 +1,35 @@
|
|||||||
# app/policies/user_policy.rb
|
# app/policies/user_policy.rb
|
||||||
class UserPolicy < ApplicationPolicy
|
class UserPolicy < ApplicationPolicy
|
||||||
def index?
|
def index? = pode?('admin.usuarios')
|
||||||
admin_ou_gerente?
|
|
||||||
end
|
|
||||||
|
|
||||||
def show?
|
# Cada um enxerga a própria ficha (é assim que a tela de perfil pessoal
|
||||||
admin? || record == user
|
# funciona), além de quem administra usuários.
|
||||||
end
|
def show? = pode?('admin.usuarios') || record == user
|
||||||
|
|
||||||
def create?
|
def create? = pode?('admin.usuarios_gerenciar')
|
||||||
admin?
|
def new? = create?
|
||||||
end
|
|
||||||
|
|
||||||
def new?
|
# ⚠️ `record == user` CONTINUA aqui de propósito: é o que permite alguém editar
|
||||||
create?
|
# o próprio cadastro (nome, senha). O que NÃO pode é essa mesma pessoa mudar o
|
||||||
end
|
# próprio papel/perfil e se promover — isso é barrado em #alterar_acesso?, que
|
||||||
|
# o controller usa para decidir quais campos aceita.
|
||||||
|
def update? = pode?('admin.usuarios_gerenciar') || record == user
|
||||||
|
def edit? = update?
|
||||||
|
|
||||||
def update?
|
def destroy? = create? && record != user
|
||||||
admin? || record == user
|
def toggle_ativo? = create? && record != user
|
||||||
end
|
|
||||||
|
|
||||||
def edit?
|
# Pode mexer no TIPO DE CONTA e no PERFIL DE ACESSO de alguém? Nunca no
|
||||||
update?
|
# próprio: senão qualquer usuário se promove editando a própria ficha.
|
||||||
end
|
def alterar_acesso? = pode?('admin.usuarios_gerenciar') && record != user
|
||||||
|
|
||||||
def destroy?
|
|
||||||
admin? && record != user
|
|
||||||
end
|
|
||||||
|
|
||||||
def toggle_ativo?
|
|
||||||
admin?
|
|
||||||
end
|
|
||||||
|
|
||||||
# admin? e admin_ou_gerente? vêm da ApplicationPolicy.
|
|
||||||
|
|
||||||
class Scope < Scope
|
class Scope < Scope
|
||||||
def resolve
|
def resolve
|
||||||
|
# Continua por papel: filtrar por permissão exigiria carregar o perfil de
|
||||||
|
# cada linha (N+1) para responder algo que a policy já cobre na ação.
|
||||||
if user.admin?
|
if user.admin?
|
||||||
scope.all
|
scope.all
|
||||||
elsif user.gerente?
|
elsif user.pode?('admin.usuarios')
|
||||||
scope.where.not(role: :admin)
|
scope.where.not(role: :admin)
|
||||||
else
|
else
|
||||||
scope.where(id: user.id)
|
scope.where(id: user.id)
|
||||||
|
|||||||
19
app/policies/variavel_personalizada_policy.rb
Normal file
19
app/policies/variavel_personalizada_policy.rb
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# app/policies/variavel_personalizada_policy.rb
|
||||||
|
#
|
||||||
|
# Variável personalizada entra no texto de mensagens enviadas em nome da empresa
|
||||||
|
# — mesma régua de quem configura evento.
|
||||||
|
class VariavelPersonalizadaPolicy < ApplicationPolicy
|
||||||
|
def index? = pode?('notificacao.variaveis')
|
||||||
|
def show? = index?
|
||||||
|
def create? = index?
|
||||||
|
def new? = create?
|
||||||
|
def update? = index?
|
||||||
|
def edit? = update?
|
||||||
|
def destroy? = index?
|
||||||
|
|
||||||
|
class Scope < Scope
|
||||||
|
def resolve
|
||||||
|
user.pode?('notificacao.variaveis') ? scope.all : scope.none
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
# app/policies/whatsapp_sessao_policy.rb
|
# app/policies/whatsapp_sessao_policy.rb
|
||||||
#
|
#
|
||||||
# Parear o WhatsApp por QR é ligar o número da empresa a este servidor, e
|
# Policy headless (`authorize :whatsapp_sessao`). Parear o WhatsApp é o que
|
||||||
# desconectar derruba o canal para todo mundo. Só ADM.
|
# habilita mandar mensagem em nome da empresa.
|
||||||
class WhatsappSessaoPolicy < ApplicationPolicy
|
class WhatsappSessaoPolicy < ApplicationPolicy
|
||||||
def show? = admin?
|
def show? = pode?('notificacao.whatsapp_sessao')
|
||||||
def desconectar? = admin?
|
def desconectar? = show?
|
||||||
def testar? = admin?
|
def testar? = show?
|
||||||
|
|
||||||
class Scope < Scope
|
|
||||||
def resolve = scope.all
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
196
app/services/notificacao/catalogo_variaveis.rb
Normal file
196
app/services/notificacao/catalogo_variaveis.rb
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# app/services/notificacao/catalogo_variaveis.rb
|
||||||
|
#
|
||||||
|
# TODAS as variáveis que o editor de mensagem oferece, de quatro origens:
|
||||||
|
#
|
||||||
|
# :contexto — vêm com o disparo (motorista, consolidação, valor…). Só têm
|
||||||
|
# valor nos gatilhos que as produzem; em outro gatilho saem
|
||||||
|
# vazias, e a tela avisa isso.
|
||||||
|
# :sistema — não dependem de contexto nenhum (empresa, data, hora, mês).
|
||||||
|
# :dados — CONSULTAM O BANCO na hora do envio (entregas do mês, total a
|
||||||
|
# pagar…). Resolvidas sob demanda: só a variável realmente
|
||||||
|
# escrita no template vira query.
|
||||||
|
# :personalizada — cadastradas pelo ADM (VariavelPersonalizada), valor fixo.
|
||||||
|
#
|
||||||
|
# Antes, `Notificacao::Variaveis::POR_GATILHO` amarrava a lista ao gatilho: o ADM
|
||||||
|
# via 6 variáveis e nada mais, e qualquer texto com número (“entregas do mês”)
|
||||||
|
# precisava de deploy. Aqui a lista é aberta — e o que não se aplica é MARCADO em
|
||||||
|
# vez de escondido.
|
||||||
|
#
|
||||||
|
# ⚠️ Um `resolver` NUNCA pode levantar: notificação não derruba fechamento. O
|
||||||
|
# ResolvedorVariaveis engole e devolve vazio.
|
||||||
|
module Notificacao
|
||||||
|
module CatalogoVariaveis
|
||||||
|
ORIGENS = {
|
||||||
|
contexto: 'Do que aconteceu',
|
||||||
|
sistema: 'Empresa e data',
|
||||||
|
dados: 'Números do sistema',
|
||||||
|
personalizada: 'Suas variáveis'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# ── Sistema: sempre disponíveis ───────────────────────────
|
||||||
|
SISTEMA = {
|
||||||
|
'empresa' => {
|
||||||
|
descricao: 'Nome da empresa', amostra: 'Reem Transportes',
|
||||||
|
resolver: -> { Configuracao.valor('empresa_nome').presence || 'Reem Transportes' }
|
||||||
|
},
|
||||||
|
'data' => {
|
||||||
|
descricao: 'Data de hoje (dd/mm/aaaa)', amostra: '26/08/2026',
|
||||||
|
resolver: -> { Date.current.strftime('%d/%m/%Y') }
|
||||||
|
},
|
||||||
|
'hora' => {
|
||||||
|
descricao: 'Hora do envio', amostra: '14:35',
|
||||||
|
resolver: -> { Time.current.strftime('%H:%M') }
|
||||||
|
},
|
||||||
|
'dia_semana' => {
|
||||||
|
descricao: 'Dia da semana por extenso', amostra: 'quarta-feira',
|
||||||
|
resolver: -> { I18n.l(Date.current, format: '%A') rescue Date::DAYNAMES[Date.current.wday] }
|
||||||
|
},
|
||||||
|
'mes' => {
|
||||||
|
descricao: 'Mês por extenso', amostra: 'agosto',
|
||||||
|
resolver: -> { I18n.l(Date.current, format: '%B') rescue Date::MONTHNAMES[Date.current.month] }
|
||||||
|
},
|
||||||
|
'mes_ano' => {
|
||||||
|
descricao: 'Mês/ano (mm/aaaa)', amostra: '08/2026',
|
||||||
|
resolver: -> { Date.current.strftime('%m/%Y') }
|
||||||
|
},
|
||||||
|
'link_sistema' => {
|
||||||
|
descricao: 'Endereço do sistema', amostra: 'https://app.reemtransportes.com.br',
|
||||||
|
resolver: -> { Configuracao.valor('url_sistema').presence || ENV.fetch('APP_URL', '') }
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# ── Contexto: quem produz é o gatilho ─────────────────────
|
||||||
|
# `gatilhos:` diz em quais a variável tem valor — é o que a tela usa para
|
||||||
|
# avisar "esta sai vazia aqui".
|
||||||
|
CONTEXTO = {
|
||||||
|
'contato' => { descricao: 'Primeiro nome de quem recebe', amostra: 'Carlos', gatilhos: :todos },
|
||||||
|
'quem' => { descricao: 'Quem executou a ação no sistema', amostra: 'Ana Paula', gatilhos: :todos },
|
||||||
|
'motorista' => { descricao: 'Nome do motorista', amostra: 'Carlos Matheus Pimentel' },
|
||||||
|
'consolidacao' => { descricao: 'Nome da consolidação', amostra: 'UBS NORTE 01–14/08' },
|
||||||
|
'periodo' => { descricao: 'Período do fechamento', amostra: '01/08/2026 a 14/08/2026' },
|
||||||
|
'valor' => { descricao: 'Valor do motorista', amostra: 'R$ 5.060,00' },
|
||||||
|
'entregas' => { descricao: 'Entregas atendidas no fechamento', amostra: '280' },
|
||||||
|
'link_painel' => { descricao: 'Link do painel do motorista', amostra: 'https://app.reem.com.br/motorista' },
|
||||||
|
'o_que_mudou' => { descricao: 'O que foi alterado', amostra: 'Desconto de R$ 120,00 (avaria)' },
|
||||||
|
'operacao' => { descricao: 'Operação afetada', amostra: 'UBS NORTE AGO 2026' },
|
||||||
|
'nf' => { descricao: 'Nota fiscal', amostra: '85382' },
|
||||||
|
'entregues' => { descricao: 'Notas entregues (resumo)', amostra: '4.851' },
|
||||||
|
'recusas' => { descricao: 'Notas não entregues (resumo)', amostra: '122' },
|
||||||
|
'pendentes' => { descricao: 'Notas em aberto (resumo)', amostra: '0' },
|
||||||
|
'retentativas' => { descricao: 'Visitas além da primeira (resumo)', amostra: '4' },
|
||||||
|
'fora_operacao' => { descricao: 'Notas fora da operação (resumo)', amostra: '2' }
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# Quais variáveis de contexto cada gatilho realmente preenche.
|
||||||
|
POR_GATILHO = {
|
||||||
|
'manual' => [],
|
||||||
|
'consolidacao_finalizada' => %w[motorista consolidacao periodo valor entregas link_painel],
|
||||||
|
'pagamento_efetuado' => %w[motorista consolidacao periodo valor entregas link_painel],
|
||||||
|
'valor_alterado' => %w[motorista consolidacao periodo valor entregas link_painel o_que_mudou],
|
||||||
|
'operacao_alterada' => %w[operacao o_que_mudou nf],
|
||||||
|
'agendado' => %w[operacao periodo entregues recusas pendentes retentativas fora_operacao]
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# ── Dados: consultam o banco na hora do envio ─────────────
|
||||||
|
# Cada uma responde a uma pergunta que o ADM hoje só conseguiria escrevendo à
|
||||||
|
# mão — e que ficaria desatualizada na mensagem seguinte.
|
||||||
|
DADOS = {
|
||||||
|
'entregas_mes' => {
|
||||||
|
descricao: 'Entregas atendidas no mês corrente', amostra: '4.977',
|
||||||
|
resolver: -> { Entrega.atendidas.da_conta_gade.no_periodo_checkout(Date.current.beginning_of_month, Date.current).count }
|
||||||
|
},
|
||||||
|
'entregas_hoje' => {
|
||||||
|
descricao: 'Entregas atendidas hoje', amostra: '213',
|
||||||
|
resolver: -> { Entrega.atendidas.da_conta_gade.no_periodo_checkout(Date.current, Date.current).count }
|
||||||
|
},
|
||||||
|
'motoristas_ativos_mes' => {
|
||||||
|
descricao: 'Motoristas com entrega no mês', amostra: '17',
|
||||||
|
resolver: -> { Entrega.motoristas_ativos(inicio: Date.current.beginning_of_month, fim: Date.current).size }
|
||||||
|
},
|
||||||
|
'consolidacoes_abertas' => {
|
||||||
|
descricao: 'Consolidações em rascunho', amostra: '3',
|
||||||
|
resolver: -> { Consolidacao.ativas.rascunho.count }
|
||||||
|
},
|
||||||
|
'total_a_pagar_mes' => {
|
||||||
|
descricao: 'Total a pagar no mês (fechado e não pago)', amostra: 'R$ 72.508,00',
|
||||||
|
resolver: lambda {
|
||||||
|
totais = Analytics::TotaisPorMotorista.new(inicio: Date.current.beginning_of_month,
|
||||||
|
fim: Date.current.end_of_month)
|
||||||
|
Formatador.moeda(totais.total_pendente)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'total_pago_mes' => {
|
||||||
|
descricao: 'Total já pago no mês', amostra: 'R$ 21.300,00',
|
||||||
|
resolver: lambda {
|
||||||
|
totais = Analytics::TotaisPorMotorista.new(inicio: Date.current.beginning_of_month,
|
||||||
|
fim: Date.current.end_of_month)
|
||||||
|
Formatador.moeda(totais.total_pago)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'total_consolidado_mes' => {
|
||||||
|
descricao: 'Total consolidado no mês', amostra: 'R$ 93.808,00',
|
||||||
|
resolver: lambda {
|
||||||
|
totais = Analytics::TotaisPorMotorista.new(inicio: Date.current.beginning_of_month,
|
||||||
|
fim: Date.current.end_of_month)
|
||||||
|
Formatador.moeda(totais.total_geral)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
# Todas as variáveis conhecidas, incluindo as que o ADM cadastrou.
|
||||||
|
# { chave => { origem:, descricao:, amostra: } }
|
||||||
|
def todas
|
||||||
|
base = {}
|
||||||
|
SISTEMA.each { |c, m| base[c] = { origem: :sistema, descricao: m[:descricao], amostra: m[:amostra] } }
|
||||||
|
CONTEXTO.each { |c, m| base[c] = { origem: :contexto, descricao: m[:descricao], amostra: m[:amostra] } }
|
||||||
|
DADOS.each { |c, m| base[c] = { origem: :dados, descricao: m[:descricao], amostra: m[:amostra] } }
|
||||||
|
personalizadas.each do |v|
|
||||||
|
base[v.chave] = { origem: :personalizada, descricao: v.descricao.presence || 'Sua variável', amostra: v.valor }
|
||||||
|
end
|
||||||
|
base
|
||||||
|
end
|
||||||
|
|
||||||
|
# A variável tem valor NESTE gatilho? Contexto de outro gatilho sai vazio —
|
||||||
|
# a tela mostra, em vez de esconder a variável (foi o pedido: liberdade com
|
||||||
|
# aviso, não lista curta).
|
||||||
|
def aplicavel?(chave, gatilho)
|
||||||
|
meta = CONTEXTO[chave.to_s]
|
||||||
|
return true if meta.nil? # sistema/dados/personalizada: sempre
|
||||||
|
return true if meta[:gatilhos] == :todos
|
||||||
|
|
||||||
|
Array(POR_GATILHO[gatilho.to_s]).include?(chave.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
def origem(chave)
|
||||||
|
todas.dig(chave.to_s, :origem)
|
||||||
|
end
|
||||||
|
|
||||||
|
def existe?(chave) = todas.key?(chave.to_s)
|
||||||
|
|
||||||
|
# Agrupadas para a tela do editor, na ordem de ORIGENS.
|
||||||
|
def por_origem(gatilho = nil)
|
||||||
|
agrupadas = ORIGENS.keys.index_with { |o| [] }
|
||||||
|
todas.each do |chave, meta|
|
||||||
|
agrupadas[meta[:origem]] << meta.merge(chave: chave, aplicavel: gatilho.nil? || aplicavel?(chave, gatilho))
|
||||||
|
end
|
||||||
|
agrupadas
|
||||||
|
end
|
||||||
|
|
||||||
|
# Contexto do PREVIEW: valores de amostra, nunca de envio real.
|
||||||
|
def amostra(gatilho = nil)
|
||||||
|
todas.filter_map { |chave, meta|
|
||||||
|
[chave, meta[:amostra].to_s] if gatilho.nil? || aplicavel?(chave, gatilho)
|
||||||
|
}.to_h
|
||||||
|
end
|
||||||
|
|
||||||
|
def personalizadas
|
||||||
|
return [] unless VariavelPersonalizada.table_exists?
|
||||||
|
|
||||||
|
VariavelPersonalizada.ativas.to_a
|
||||||
|
rescue StandardError
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -93,7 +93,25 @@ module Notificacao
|
|||||||
# string — o custo por destinatário é irrelevante perto do envio em si.
|
# string — o custo por destinatário é irrelevante perto do envio em si.
|
||||||
def dados_para(contato, user)
|
def dados_para(contato, user)
|
||||||
nome = contato&.primeiro_nome.presence || user&.nome.to_s.split.first
|
nome = contato&.primeiro_nome.presence || user&.nome.to_s.split.first
|
||||||
@dados.merge('contato' => nome.to_s)
|
dados_completos.merge('contato' => nome.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
# O contexto do gatilho + as variáveis que o TEMPLATE usa e o gatilho não
|
||||||
|
# conhece ({{entregas_mes}}, {{telefone_suporte}}, {{hora}}…).
|
||||||
|
#
|
||||||
|
# Resolvido UMA vez por disparo, não por destinatário: `{{entregas_mes}}` é
|
||||||
|
# uma query, e o valor é o mesmo para todo mundo — só `{{contato}}` muda.
|
||||||
|
def dados_completos
|
||||||
|
@dados_completos ||= ResolvedorVariaveis.completar(textos_do_disparo, @dados)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Tudo que será interpolado: os blocos dos dois templates (o evento pode ter
|
||||||
|
# WhatsApp e e-mail com textos diferentes), o assunto e o corpo padrão.
|
||||||
|
def textos_do_disparo
|
||||||
|
[@assunto, @corpo,
|
||||||
|
@evento.template_utilizavel('whatsapp')&.blocos,
|
||||||
|
@evento.template_utilizavel('email')&.blocos,
|
||||||
|
@evento.template_utilizavel('email')&.assunto]
|
||||||
end
|
end
|
||||||
|
|
||||||
# O template do editor vence; sem template utilizável, cai no texto que o
|
# O template do editor vence; sem template utilizável, cai no texto que o
|
||||||
|
|||||||
31
app/services/notificacao/formatador.rb
Normal file
31
app/services/notificacao/formatador.rb
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# app/services/notificacao/formatador.rb
|
||||||
|
#
|
||||||
|
# Formatação dos valores que entram numa mensagem. Existe porque o mesmo `moeda`
|
||||||
|
# estava copiado em `Notificacao::Gatilhos` e em `NotificacaoService` — e agora o
|
||||||
|
# catálogo de variáveis também precisa dele, o que faria três cópias.
|
||||||
|
#
|
||||||
|
# Métodos de módulo (sem estado): é chamado de model, job, service e view.
|
||||||
|
module Notificacao
|
||||||
|
module Formatador
|
||||||
|
module_function
|
||||||
|
|
||||||
|
# R$ 1.234,56 — separador de milhar incluído (o `number_to_currency` do Rails
|
||||||
|
# depende de locale carregado, e isto roda também dentro de job).
|
||||||
|
def moeda(valor)
|
||||||
|
inteiro, decimais = format('%.2f', valor.to_f).split('.')
|
||||||
|
inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
|
||||||
|
"R$ #{inteiro},#{decimais}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def periodo(inicio, fim)
|
||||||
|
return '' if inicio.blank? || fim.blank?
|
||||||
|
|
||||||
|
"#{inicio.to_date.strftime('%d/%m/%Y')} a #{fim.to_date.strftime('%d/%m/%Y')}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Números grandes ficam ilegíveis sem separador numa mensagem de WhatsApp.
|
||||||
|
def numero(valor)
|
||||||
|
valor.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -121,13 +121,8 @@ module Notificacao
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
# Moeda BR com separador de milhar. Duplica o helper do NotificacaoService de
|
# Formatação vive em Notificacao::Formatador (era copiada aqui e no
|
||||||
# propósito: este módulo é chamado de dentro de models e jobs, onde puxar o
|
# NotificacaoService; com o catálogo de variáveis seriam três cópias).
|
||||||
# service inteiro só para formatar número seria acoplamento à toa.
|
def moeda(valor) = Formatador.moeda(valor)
|
||||||
def moeda(valor)
|
|
||||||
inteiro, decimais = format('%.2f', valor.to_f).split('.')
|
|
||||||
inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
|
|
||||||
"R$ #{inteiro},#{decimais}"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
90
app/services/notificacao/resolvedor_variaveis.rb
Normal file
90
app/services/notificacao/resolvedor_variaveis.rb
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# app/services/notificacao/resolvedor_variaveis.rb
|
||||||
|
#
|
||||||
|
# Completa o contexto de um disparo com as variáveis que o template REALMENTE
|
||||||
|
# usa. É o que permite escrever {{entregas_mes}} ou {{telefone_suporte}} numa
|
||||||
|
# mensagem sem que o gatilho precise saber dessas variáveis.
|
||||||
|
#
|
||||||
|
# SOB DEMANDA, de propósito: `{{entregas_mes}}` é uma query no espelho de
|
||||||
|
# rastreio. Resolver o catálogo inteiro em todo disparo faria dezenas de
|
||||||
|
# consultas para preencher variáveis que ninguém escreveu.
|
||||||
|
#
|
||||||
|
# Precedência (a primeira que responde vence):
|
||||||
|
# 1. o que o gatilho já mandou (contexto real do acontecimento);
|
||||||
|
# 2. variável de sistema (empresa, data, hora…);
|
||||||
|
# 3. variável de dados (consulta ao banco);
|
||||||
|
# 4. variável personalizada do ADM.
|
||||||
|
#
|
||||||
|
# ⚠️ NADA aqui levanta. Uma variável que falha vira string vazia — a mesma regra
|
||||||
|
# do renderizador, e pelo mesmo motivo: mensagem com buraco é ruim, disparo
|
||||||
|
# derrubado por causa de um texto é pior.
|
||||||
|
module Notificacao
|
||||||
|
module ResolvedorVariaveis
|
||||||
|
MARCADOR = /\{\{\s*(\w+)\s*\}\}/
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
# textos: tudo que será interpolado (blocos do template, assunto, corpo).
|
||||||
|
# dados: o contexto que o gatilho produziu.
|
||||||
|
def completar(textos, dados = {})
|
||||||
|
contexto = (dados || {}).transform_keys(&:to_s)
|
||||||
|
faltantes = usadas(textos) - contexto.keys
|
||||||
|
|
||||||
|
faltantes.each_with_object(contexto.dup) do |chave, resultado|
|
||||||
|
valor = resolver(chave)
|
||||||
|
resultado[chave] = valor unless valor.nil?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Chaves {{assim}} encontradas em qualquer texto (String, Array ou Hash de
|
||||||
|
# blocos — o template é jsonb aninhado).
|
||||||
|
def usadas(textos)
|
||||||
|
varrer(textos).scan(MARCADOR).flatten.uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolver(chave)
|
||||||
|
chave = chave.to_s
|
||||||
|
valor = valor_de_sistema(chave) || valor_de_dados(chave) || valor_personalizado(chave)
|
||||||
|
valor&.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── privados de módulo ──────────────────────────────────────
|
||||||
|
def varrer(objeto)
|
||||||
|
case objeto
|
||||||
|
when String then objeto
|
||||||
|
when Hash then objeto.values.map { |v| varrer(v) }.join(' ')
|
||||||
|
when Array then objeto.map { |v| varrer(v) }.join(' ')
|
||||||
|
else ''
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def valor_de_sistema(chave)
|
||||||
|
meta = CatalogoVariaveis::SISTEMA[chave]
|
||||||
|
meta && executar(meta[:resolver], chave)
|
||||||
|
end
|
||||||
|
|
||||||
|
def valor_de_dados(chave)
|
||||||
|
meta = CatalogoVariaveis::DADOS[chave]
|
||||||
|
meta && executar(meta[:resolver], chave)
|
||||||
|
end
|
||||||
|
|
||||||
|
def valor_personalizado(chave)
|
||||||
|
return nil unless VariavelPersonalizada.table_exists?
|
||||||
|
|
||||||
|
VariavelPersonalizada.ativas.find_by(chave: chave)&.valor
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[Variaveis] personalizada #{chave}: #{e.class}: #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def executar(bloco, chave)
|
||||||
|
return nil if bloco.nil?
|
||||||
|
|
||||||
|
resultado = bloco.call
|
||||||
|
# Número cru numa mensagem fica sem separador de milhar ("4977").
|
||||||
|
resultado.is_a?(Numeric) ? Formatador.numero(resultado) : resultado.to_s
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[Variaveis] #{chave}: #{e.class}: #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -181,10 +181,6 @@ class NotificacaoService
|
|||||||
"#{evt[:cta]} https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
|
"#{evt[:cta]} https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Moeda BR com separador de milhar: R$ 1.234,56
|
# Ver Notificacao::Formatador — implementação única.
|
||||||
def moeda(valor)
|
def moeda(valor) = Notificacao::Formatador.moeda(valor)
|
||||||
inteiro, decimais = format('%.2f', valor.to_f).split('.')
|
|
||||||
inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
|
|
||||||
"R$ #{inteiro},#{decimais}"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -82,22 +82,54 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# ── Variáveis ─────────────────────────────────────── %>
|
<%# ── 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">
|
<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 font-semibold text-gray-400 uppercase tracking-wider mb-3">Variáveis</p>
|
||||||
<p class="text-xs text-gray-500 mb-3">
|
<p class="text-xs text-gray-500 mb-3">
|
||||||
Clique num campo da mensagem e depois numa variável para inseri-la ali.
|
Clique num campo da mensagem e depois numa variável para inseri-la ali.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<%# 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">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
<% @variaveis.each do |nome, meta| %>
|
<% itens.each do |v| %>
|
||||||
<button type="button" title="<%= meta[:descricao] %> (ex.: <%= meta[:amostra] %>)"
|
<button type="button"
|
||||||
class="chip-variavel px-2.5 py-1 rounded-full bg-[#f97316]/10 border border-[#f97316]/30
|
title="<%= v[:descricao] %> (ex.: <%= v[:amostra] %>)<%= ' — não existe neste gatilho: sai em branco' unless v[:aplicavel] %>"
|
||||||
text-xs font-mono text-[#f97316] hover:bg-[#f97316]/20 hover:border-[#f97316]
|
class="chip-variavel px-2.5 py-1 rounded-full text-xs font-mono transition-colors cursor-pointer
|
||||||
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="<%= nome %>">
|
data-variavel="<%= v[:chave] %>">
|
||||||
<%= "{{#{nome}}}" %>
|
{{<%= v[:chave] %>}}<%= ' ·' unless v[:aplicavel] %>
|
||||||
</button>
|
</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -312,20 +344,29 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Variáveis: insere no cursor do último campo focado ─────
|
// ── Inserção no cursor do último campo focado ──────────────
|
||||||
document.querySelectorAll('.chip-variavel').forEach(function (chip) {
|
// Serve para variável e para emoji: a mecânica é a mesma (recortar o valor
|
||||||
chip.addEventListener('click', function () {
|
// na posição do cursor e reemitir `input` para o JSON do editor atualizar).
|
||||||
var texto = '{{' + chip.dataset.variavel + '}}';
|
function inserirNoCampo(texto) {
|
||||||
if (!ultimoCampo) { alert('Clique primeiro no campo onde a variável deve entrar.'); return; }
|
if (!ultimoCampo) { alert('Clique primeiro no campo onde o texto deve entrar.'); return; }
|
||||||
var ini = ultimoCampo.selectionStart != null ? ultimoCampo.selectionStart : ultimoCampo.value.length;
|
var ini = ultimoCampo.selectionStart != null ? ultimoCampo.selectionStart : ultimoCampo.value.length;
|
||||||
var fim = ultimoCampo.selectionEnd != null ? ultimoCampo.selectionEnd : ini;
|
var fim = ultimoCampo.selectionEnd != null ? ultimoCampo.selectionEnd : ini;
|
||||||
ultimoCampo.value = ultimoCampo.value.slice(0, ini) + texto + ultimoCampo.value.slice(fim);
|
ultimoCampo.value = ultimoCampo.value.slice(0, ini) + texto + ultimoCampo.value.slice(fim);
|
||||||
ultimoCampo.dispatchEvent(new Event('input', { bubbles: true }));
|
ultimoCampo.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
ultimoCampo.focus();
|
ultimoCampo.focus();
|
||||||
ultimoCampo.selectionStart = ultimoCampo.selectionEnd = ini + texto.length;
|
ultimoCampo.selectionStart = ultimoCampo.selectionEnd = ini + texto.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.chip-variavel').forEach(function (chip) {
|
||||||
|
chip.addEventListener('click', function () {
|
||||||
|
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 ──
|
// ── Preview: renderizado no servidor, pelo mesmo código do envio ──
|
||||||
var token = document.querySelector('meta[name="csrf-token"]');
|
var token = document.querySelector('meta[name="csrf-token"]');
|
||||||
document.getElementById('btn-preview').addEventListener('click', function () {
|
document.getElementById('btn-preview').addEventListener('click', function () {
|
||||||
|
|||||||
83
app/views/admin/perfis_acesso/_form.html.erb
Normal file
83
app/views/admin/perfis_acesso/_form.html.erb
Normal 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 %>
|
||||||
10
app/views/admin/perfis_acesso/edit.html.erb
Normal file
10
app/views/admin/perfis_acesso/edit.html.erb
Normal 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>
|
||||||
70
app/views/admin/perfis_acesso/index.html.erb
Normal file
70
app/views/admin/perfis_acesso/index.html.erb
Normal 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 %>
|
||||||
8
app/views/admin/perfis_acesso/new.html.erb
Normal file
8
app/views/admin/perfis_acesso/new.html.erb
Normal 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>
|
||||||
@@ -40,9 +40,18 @@
|
|||||||
placeholder: 'João da Silva' %>
|
placeholder: 'João da Silva' %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# Perfil %>
|
<%# 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>
|
<div>
|
||||||
<%= f.label :role, 'Perfil de acesso', class: 'block text-sm font-medium text-gray-300 mb-1.5' %>
|
<%= f.label :role, 'Tipo de conta', class: 'block text-sm font-medium text-gray-300 mb-1.5' %>
|
||||||
<%= f.select :role,
|
<%= f.select :role,
|
||||||
options_for_select(role_options_for_select, usuario.role),
|
options_for_select(role_options_for_select, usuario.role),
|
||||||
{},
|
{},
|
||||||
@@ -51,6 +60,27 @@
|
|||||||
transition-colors cursor-pointer' %>
|
transition-colors cursor-pointer' %>
|
||||||
</div>
|
</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 %>
|
<%# Ativo toggle %>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -142,10 +172,16 @@
|
|||||||
const fieldsPin = document.getElementById('fields-pin');
|
const fieldsPin = document.getElementById('fields-pin');
|
||||||
if (!roleSelect || !fieldsEmail || !fieldsPin) return;
|
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() {
|
function toggleFields() {
|
||||||
const motorista = roleSelect.value === 'motorista';
|
const motorista = roleSelect.value === 'motorista';
|
||||||
fieldsEmail.classList.toggle('hidden', motorista);
|
fieldsEmail.classList.toggle('hidden', motorista);
|
||||||
fieldsPin.classList.toggle('hidden', !motorista);
|
fieldsPin.classList.toggle('hidden', !motorista);
|
||||||
|
if (campoPerfil) campoPerfil.classList.toggle('hidden', motorista);
|
||||||
}
|
}
|
||||||
|
|
||||||
roleSelect.addEventListener('change', toggleFields);
|
roleSelect.addEventListener('change', toggleFields);
|
||||||
|
|||||||
@@ -29,7 +29,8 @@
|
|||||||
<tr class="border-b border-white/10">
|
<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">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">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-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>
|
<th class="px-6 py-4 text-right text-xs font-semibold text-gray-400 uppercase tracking-wider">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -56,6 +57,22 @@
|
|||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<%= badge_role(usuario.role) %>
|
<%= badge_role(usuario.role) %>
|
||||||
</td>
|
</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">
|
<td class="px-6 py-4">
|
||||||
<%= badge_status_usuario(usuario.ativo?) %>
|
<%= badge_status_usuario(usuario.ativo?) %>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
55
app/views/admin/variaveis_personalizadas/_form.html.erb
Normal file
55
app/views/admin/variaveis_personalizadas/_form.html.erb
Normal 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 %>
|
||||||
8
app/views/admin/variaveis_personalizadas/edit.html.erb
Normal file
8
app/views/admin/variaveis_personalizadas/edit.html.erb
Normal 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>
|
||||||
76
app/views/admin/variaveis_personalizadas/index.html.erb
Normal file
76
app/views/admin/variaveis_personalizadas/index.html.erb
Normal 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>
|
||||||
6
app/views/admin/variaveis_personalizadas/new.html.erb
Normal file
6
app/views/admin/variaveis_personalizadas/new.html.erb
Normal 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>
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<td class="px-4 py-3 text-right font-bold <%= c.desconto? ? 'text-red-400' : 'text-white' %>">
|
<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">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<span><%= c.desconto? ? '-' : '' %><%= moeda(c.valor_aplicado) %></span>
|
<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),
|
<%= button_to remover_apontamento_consolidacao_consolidacao_entregas_path(@consolidacao, id: c.id, motorista: @motorista),
|
||||||
method: :delete,
|
method: :delete,
|
||||||
form: { data: { turbo_confirm: 'Remover este apontamento manual?' } },
|
form: { data: { turbo_confirm: 'Remover este apontamento manual?' } },
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<%# Fechar por veículo — filtra a tela por carro, mostra o estado (pronto/
|
<%# Fechar por veículo — filtra a tela por carro, mostra o estado (pronto/
|
||||||
consolidado/arquivado) e permite CONSOLIDAR o veículo selecionado.
|
consolidado/arquivado) e permite CONSOLIDAR o veículo selecionado.
|
||||||
Só aparece quando o motorista usou mais de um veículo no período. %>
|
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 %>
|
<% if @veiculos_status.size > 1 %>
|
||||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-3 mb-3">
|
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-3 mb-3">
|
||||||
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
controllers existentes são reaproveitados; só o gatilho mudou.
|
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;
|
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. %>
|
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"
|
<div class="order-3 relative"
|
||||||
data-controller="ferramentas apontamento-manual apontamento-termo"
|
data-controller="ferramentas apontamento-manual apontamento-termo"
|
||||||
data-apontamento-manual-apontar-manual-url-value="<%= apontar_manual_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
data-apontamento-manual-apontar-manual-url-value="<%= apontar_manual_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
||||||
|
|||||||
@@ -167,7 +167,7 @@
|
|||||||
|
|
||||||
<%# ── Apontamentos manuais (notas que chegaram por fora) ─────────────── %>
|
<%# ── Apontamentos manuais (notas que chegaram por fora) ─────────────── %>
|
||||||
<% if @apontamentos.present? %>
|
<% 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>
|
<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">
|
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl overflow-hidden">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
<%# Caixa de ferramentas do Passo 1 — concentra "Apontamento manual" (busca pela
|
<%# Caixa de ferramentas do Passo 1 — concentra "Apontamento manual" (busca pela
|
||||||
NF) e "Adicionar motorista" num único botão com menu suspenso. %>
|
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"
|
<div class="relative mb-4"
|
||||||
data-controller="ferramentas apontamento adicionar-motorista"
|
data-controller="ferramentas apontamento adicionar-motorista"
|
||||||
data-apontamento-buscar-url-value="<%= buscar_nf_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
data-apontamento-buscar-url-value="<%= buscar_nf_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%# Lista de motoristas %>
|
<%# 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">
|
<div class="space-y-3">
|
||||||
<% @motoristas.each do |m| %>
|
<% @motoristas.each do |m| %>
|
||||||
<%# Container relativo: o card é um link; o botão de arquivar fica FORA da
|
<%# Container relativo: o card é um link; o botão de arquivar fica FORA da
|
||||||
|
|||||||
@@ -106,7 +106,9 @@
|
|||||||
</details>
|
</details>
|
||||||
<% end %>
|
<% 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),
|
<%= link_to rotulo(:imprimir, 'Relatório', cor: nil),
|
||||||
dashboard_relatorio_financeiro_path(
|
dashboard_relatorio_financeiro_path(
|
||||||
inicio: @periodo_inicio.strftime('%Y-%m-%d'),
|
inicio: @periodo_inicio.strftime('%Y-%m-%d'),
|
||||||
@@ -114,6 +116,7 @@
|
|||||||
operacoes: @operacao_filtro),
|
operacoes: @operacao_filtro),
|
||||||
data: { turbo: false },
|
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' %>
|
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -147,6 +150,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# Card destacado: total consolidado e pago (consolidações finalizadas do período) %>
|
<%# 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">
|
<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-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>
|
<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>
|
<span class="text-gray-400">pago</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%# Card 2: Total de entregas %>
|
<%# Card 2: Total de entregas %>
|
||||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||||
@@ -203,6 +208,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# KPIs FINANCEIROS (consolidações finalizadas, já com filtro de operação) %>
|
<%# 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="grid grid-cols-[repeat(auto-fit,minmax(13rem,1fr))] gap-4">
|
||||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
<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>
|
<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>
|
<p class="text-gray-400 text-xs mt-1">pendente</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%# GRÁFICO + MOTORISTAS (lado a lado no desktop) %>
|
<%# GRÁFICO + MOTORISTAS (lado a lado no desktop) %>
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||||
@@ -244,7 +251,9 @@
|
|||||||
<canvas id="grafico-diario"></canvas>
|
<canvas id="grafico-diario"></canvas>
|
||||||
</div>
|
</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 class="flex flex-wrap items-center justify-between gap-2 mt-6 mb-4 pt-6 border-t border-white/5">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-white">Pagamentos por dia</h2>
|
<h2 class="text-lg font-semibold text-white">Pagamentos por dia</h2>
|
||||||
@@ -273,6 +282,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# Top motoristas — DUAS ABAS sobre o mesmo ranking:
|
<%# Top motoristas — DUAS ABAS sobre o mesmo ranking:
|
||||||
@@ -320,6 +330,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if @ver_financeiro %>
|
||||||
<%# ── FINANCEIRO POR OPERAÇÃO ───────────────────────────── %>
|
<%# ── FINANCEIRO POR OPERAÇÃO ───────────────────────────── %>
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||||
|
|
||||||
@@ -472,7 +483,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# Por operação %>
|
<% end %>
|
||||||
|
|
||||||
|
<%# Por operação (contagem de entregas — não é custo, fica para todos) %>
|
||||||
<% if @por_operacao.any? %>
|
<% if @por_operacao.any? %>
|
||||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
<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>
|
<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>
|
</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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const ctx = document.getElementById('grafico-pagamentos-dia');
|
const ctx = document.getElementById('grafico-pagamentos-dia');
|
||||||
@@ -795,7 +810,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const ctx = document.getElementById('grafico-pagamentos');
|
const ctx = document.getElementById('grafico-pagamentos');
|
||||||
@@ -839,7 +854,12 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const moedaTick = (v) => 'R$ ' + Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 0 });
|
const moedaTick = (v) => 'R$ ' + Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 0 });
|
||||||
@@ -905,3 +925,4 @@
|
|||||||
})();
|
})();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<% end %>
|
||||||
|
|||||||
@@ -34,14 +34,17 @@
|
|||||||
<%# Navegação %>
|
<%# Navegação %>
|
||||||
<nav class="px-3 py-4 space-y-1 flex-1 overflow-y-auto">
|
<nav class="px-3 py-4 space-y-1 flex-1 overflow-y-auto">
|
||||||
|
|
||||||
<%# Dashboard — admin/gerente/operador/externo (todos menos motorista) %>
|
<%# Cada item pergunta por PERMISSÃO, não por papel: o menu é o espelho do
|
||||||
<% unless current_user.motorista? %>
|
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 %>
|
<%= 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 %>
|
<%= nav_link_to 'Operações', operacoes_dashboard_path, icon: :operacoes %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%# Consolidações — só quem pode consolidar (exclui o externo) %>
|
<% if current_user.pode?('consolidacao.ver') %>
|
||||||
<% if current_user.pode_consolidar? %>
|
|
||||||
<%= nav_link_to 'Consolidações', consolidacoes_path, icon: :consolidacoes %>
|
<%= nav_link_to 'Consolidações', consolidacoes_path, icon: :consolidacoes %>
|
||||||
<%= nav_link_to 'Arquivadas', arquivadas_consolidacoes_path, icon: :arquivadas %>
|
<%= nav_link_to 'Arquivadas', arquivadas_consolidacoes_path, icon: :arquivadas %>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -51,29 +54,55 @@
|
|||||||
<%= nav_link_to 'Meu Painel', motorista_dashboard_path, icon: :painel %>
|
<%= nav_link_to 'Meu Painel', motorista_dashboard_path, icon: :painel %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%# Admin/Gerente — Configurações %>
|
<%# Administração — a seção inteira some quando nenhum item dela está liberado. %>
|
||||||
<% if current_user.pode_ver_config? %>
|
<% 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]">
|
<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>
|
<p class="px-3 text-xs text-gray-600 uppercase tracking-wider mb-2">Administração</p>
|
||||||
|
<% if current_user.pode?('admin.usuarios') %>
|
||||||
<%= nav_link_to 'Usuários', admin_usuarios_path, icon: :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 %>
|
<%= 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 %>
|
<%= 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 %>
|
<%= nav_link_to 'Planilha SimpliRoute', admin_planilha_simpli_route_path, icon: :planilha %>
|
||||||
<% if current_user.admin? %>
|
<% end %>
|
||||||
|
<% if current_user.pode?('admin.edicao_lancamento') %>
|
||||||
<%= nav_link_to 'Editar Lançamento', admin_edicao_lancamento_path, icon: :editar_lancamento %>
|
<%= nav_link_to 'Editar Lançamento', admin_edicao_lancamento_path, icon: :editar_lancamento %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# Notificações: quem recebe (grupos/contatos) é admin+gerente; ligar o
|
<% end %>
|
||||||
WhatsApp e criar eventos manda mensagem em nome da empresa — só ADM.
|
|
||||||
Mesmo recorte das policies. %>
|
<%# 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]">
|
<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>
|
<p class="px-3 text-xs text-gray-600 uppercase tracking-wider mb-2">Notificações</p>
|
||||||
|
<% if current_user.pode?('notificacao.contatos') %>
|
||||||
<%= nav_link_to 'Contatos', admin_contatos_path, icon: :usuarios %>
|
<%= nav_link_to 'Contatos', admin_contatos_path, icon: :usuarios %>
|
||||||
<%= nav_link_to 'Grupos', admin_grupos_path, icon: :tag %>
|
<%= 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 %>
|
<%= nav_link_to 'Envios', admin_envios_path, icon: :historico %>
|
||||||
<% if current_user.admin? %>
|
<% end %>
|
||||||
|
<% if current_user.pode?('notificacao.eventos') %>
|
||||||
<%= nav_link_to 'Eventos', admin_eventos_path, icon: :diario %>
|
<%= 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 %>
|
<%= nav_link_to 'WhatsApp', admin_whatsapp_sessao_path, icon: :telefone %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
20
app/views/paginas/sem_acesso.html.erb
Normal file
20
app/views/paginas/sem_acesso.html.erb
Normal 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>
|
||||||
@@ -6,4 +6,7 @@ ActiveSupport::Inflector.inflections(:en) do |inflect|
|
|||||||
# Sem esta linha, ConfiguracaoNotificacao procuraria a tabela
|
# Sem esta linha, ConfiguracaoNotificacao procuraria a tabela
|
||||||
# "configuracao_notificacaos" (o irregular acima só casa no FIM da palavra).
|
# "configuracao_notificacaos" (o irregular acima só casa no FIM da palavra).
|
||||||
inflect.irregular 'notificacao', 'notificacoes'
|
inflect.irregular 'notificacao', 'notificacoes'
|
||||||
|
# Sem isto, "perfis".singularize vira "perfi" e as rotas de perfil de acesso
|
||||||
|
# gerariam helpers como `new_admin_perfi_path`.
|
||||||
|
inflect.irregular 'perfil', 'perfis'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,3 +7,9 @@ pt-BR:
|
|||||||
formats:
|
formats:
|
||||||
default: "%d/%m/%Y %H:%M"
|
default: "%d/%m/%Y %H:%M"
|
||||||
short: "%d/%m %H:%M"
|
short: "%d/%m %H:%M"
|
||||||
|
|
||||||
|
# Mensagem de login recusado para conta desativada (User#inactive_message).
|
||||||
|
# Sem esta chave, o Devise mostraria o símbolo cru "conta_desativada".
|
||||||
|
devise:
|
||||||
|
failure:
|
||||||
|
conta_desativada: "Sua conta está desativada. Fale com um administrador."
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ Rails.application.routes.draw do
|
|||||||
# Root — redireciona por role
|
# Root — redireciona por role
|
||||||
root 'dashboard#index'
|
root 'dashboard#index'
|
||||||
|
|
||||||
|
# Fim de linha para quem está autenticado mas o perfil não libera nenhuma tela.
|
||||||
|
# Existe para o "acesso negado" ter para onde ir sem cair em loop com a raiz.
|
||||||
|
get '/sem-acesso', to: 'paginas#sem_acesso', as: :sem_acesso
|
||||||
|
|
||||||
# Dashboard principal (admin/gerente/operador)
|
# Dashboard principal (admin/gerente/operador)
|
||||||
get '/dashboard', to: 'dashboard#index', as: :dashboard
|
get '/dashboard', to: 'dashboard#index', as: :dashboard
|
||||||
get '/dashboard/relatorio_financeiro', to: 'dashboard#relatorio_financeiro', as: :dashboard_relatorio_financeiro
|
get '/dashboard/relatorio_financeiro', to: 'dashboard#relatorio_financeiro', as: :dashboard_relatorio_financeiro
|
||||||
@@ -99,6 +103,12 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
resources :auditoria_logs, only: [:index]
|
resources :auditoria_logs, only: [:index]
|
||||||
|
|
||||||
|
# Perfis de acesso: o ADM monta o conjunto de permissões e atribui ao
|
||||||
|
# usuário. Rota `perfis` (e não `perfis_acesso`) pelo mesmo motivo de
|
||||||
|
# `grupos`/`eventos` mais abaixo — nome de rota curto evita o sufixo `_index`
|
||||||
|
# que o Inflector cria quando singular == plural.
|
||||||
|
resources :perfis, except: [:show], controller: 'perfis_acesso'
|
||||||
|
|
||||||
# SMTP e WhatsApp (Twilio) gravados no banco — só admin.
|
# SMTP e WhatsApp (Twilio) gravados no banco — só admin.
|
||||||
# Sem rotas `testar_*`: os botões de teste são submits do próprio form
|
# Sem rotas `testar_*`: os botões de teste são submits do próprio form
|
||||||
# (params[:acao]), para o teste usar exatamente o que está na tela.
|
# (params[:acao]), para o teste usar exatamente o que está na tela.
|
||||||
@@ -135,6 +145,9 @@ Rails.application.routes.draw do
|
|||||||
# faria o cadastro de contato inteiro esperar o timeout.
|
# faria o cadastro de contato inteiro esperar o timeout.
|
||||||
collection { get :grupos_whatsapp }
|
collection { get :grupos_whatsapp }
|
||||||
end
|
end
|
||||||
|
# Variáveis {{assim}} criadas pelo ADM, usáveis em qualquer mensagem.
|
||||||
|
resources :variaveis, except: [:show], controller: 'variaveis_personalizadas'
|
||||||
|
|
||||||
resources :eventos, except: [:show], controller: 'eventos_notificacao' do
|
resources :eventos, except: [:show], controller: 'eventos_notificacao' do
|
||||||
member do
|
member do
|
||||||
post :disparar # gatilho `manual` — manda mensagem de verdade
|
post :disparar # gatilho `manual` — manda mensagem de verdade
|
||||||
|
|||||||
31
db/migrate/20260826000001_create_perfis_acesso.rb
Normal file
31
db/migrate/20260826000001_create_perfis_acesso.rb
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Perfil de acesso: o conjunto de permissões que o ADM monta na tela e atribui a
|
||||||
|
# um usuário.
|
||||||
|
#
|
||||||
|
# Antes, quem-vê-o-quê era o papel fixo em `users.role` e um punhado de `if
|
||||||
|
# admin? || gerente?` espalhado pelas policies: tirar as Configurações de UM
|
||||||
|
# gerente exigia editar código e fazer deploy.
|
||||||
|
#
|
||||||
|
# `permissoes` é jsonb (lista de chaves de Permissao::TODAS) e não tabela filha
|
||||||
|
# de propósito: é um conjunto pequeno, sempre lido junto com o perfil, e nunca
|
||||||
|
# consultado ao contrário ("quem tem tal permissão?" se responde em memória). A
|
||||||
|
# validação de quais chaves existem fica no model — o catálogo é código, muda com
|
||||||
|
# deploy, e o banco não pode travar um perfil salvo com uma chave que saiu.
|
||||||
|
class CreatePerfisAcesso < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
create_table :perfis_acesso do |t|
|
||||||
|
t.string :nome, null: false
|
||||||
|
t.string :descricao
|
||||||
|
t.jsonb :permissoes, null: false, default: []
|
||||||
|
# sistema: nasce com o app (Administrador, Gerente, ...). Editável e
|
||||||
|
# desativável, mas não apagável — mesma regra de EventoNotificacao#apagavel?
|
||||||
|
t.boolean :sistema, null: false, default: false
|
||||||
|
t.boolean :ativo, null: false, default: true
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
# Nome é o que identifica o perfil na tela; duplicar só gera erro humano.
|
||||||
|
add_index :perfis_acesso, 'LOWER(nome)', unique: true,
|
||||||
|
name: 'index_perfis_acesso_nome_unico'
|
||||||
|
end
|
||||||
|
end
|
||||||
17
db/migrate/20260826000002_add_perfil_acesso_to_users.rb
Normal file
17
db/migrate/20260826000002_add_perfil_acesso_to_users.rb
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Liga o usuário ao perfil de acesso.
|
||||||
|
#
|
||||||
|
# NULO É VÁLIDO e não é descuido: motorista não usa perfil (o que ele acessa é a
|
||||||
|
# área própria, governada pelo tipo de conta), e um usuário sem perfil cai no
|
||||||
|
# padrão do papel legado (Permissao::PADRAO_POR_ROLE) — é o que mantém o sistema
|
||||||
|
# funcionando entre o deploy do código e a migration de dados.
|
||||||
|
class AddPerfilAcessoToUsers < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
# ⚠️ `to_table:` é OBRIGATÓRIO. Com `foreign_key: true` o Rails pluraliza o
|
||||||
|
# nome da referência para achar a tabela alvo, e o Inflector não fala
|
||||||
|
# português: `perfil_acesso` viraria "perfil_acessos". A tabela real é
|
||||||
|
# `perfis_acesso` (plural no primeiro termo) — mesma pegadinha documentada em
|
||||||
|
# 20260824000002_create_notificacao_eventos.rb.
|
||||||
|
add_reference :users, :perfil_acesso, null: true,
|
||||||
|
foreign_key: { to_table: :perfis_acesso }
|
||||||
|
end
|
||||||
|
end
|
||||||
78
db/migrate/20260826000003_semear_perfis_acesso.rb
Normal file
78
db/migrate/20260826000003_semear_perfis_acesso.rb
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Cria os perfis pré-prontos e atribui a cada usuário existente o perfil do papel
|
||||||
|
# que ele já tinha. Sem isso, o dia seguinte ao deploy seria "todo mundo no
|
||||||
|
# fallback" — funciona, mas a tela de perfis nasceria vazia e o ADM não teria de
|
||||||
|
# onde partir para montar o dele.
|
||||||
|
#
|
||||||
|
# ⚠️ A lista de chaves está DUPLICADA aqui e em Permissao::PADRAO_POR_ROLE, de
|
||||||
|
# propósito: migration não pode depender de model. Se o catálogo mudar daqui a
|
||||||
|
# seis meses, o histórico do banco não pode mudar junto — quem já rodou esta
|
||||||
|
# migration continua com o perfil que foi criado naquele dia.
|
||||||
|
#
|
||||||
|
# Idempotente (o teste e a produção migram em momentos diferentes, e a migration
|
||||||
|
# pode encontrar um banco onde o ADM já criou perfis à mão).
|
||||||
|
class SemearPerfisAcesso < ActiveRecord::Migration[7.1]
|
||||||
|
TODAS = %w[
|
||||||
|
dashboard.ver dashboard.financeiro dashboard.operacoes dashboard.exportar dashboard.metricas
|
||||||
|
consolidacao.ver consolidacao.criar consolidacao.editar consolidacao.editar_finalizada
|
||||||
|
consolidacao.finalizar consolidacao.arquivar consolidacao.excluir
|
||||||
|
consolidacao.registrar_pagamento consolidacao.cancelar_pagamento
|
||||||
|
consolidacao.gerir_motoristas consolidacao.exportar_pdf
|
||||||
|
notificacao.contatos notificacao.contatos_gerenciar notificacao.contatos_excluir
|
||||||
|
notificacao.eventos notificacao.eventos_gerenciar notificacao.disparar_manual
|
||||||
|
notificacao.variaveis notificacao.envios notificacao.credenciais notificacao.whatsapp_sessao
|
||||||
|
admin.usuarios admin.usuarios_gerenciar admin.perfis
|
||||||
|
admin.configuracoes admin.configuracoes_editar admin.auditoria
|
||||||
|
admin.edicao_lancamento admin.planilha_simpli_route
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
GERENTE = %w[
|
||||||
|
dashboard.ver dashboard.financeiro dashboard.operacoes dashboard.exportar dashboard.metricas
|
||||||
|
consolidacao.ver consolidacao.criar consolidacao.editar consolidacao.editar_finalizada
|
||||||
|
consolidacao.finalizar consolidacao.arquivar consolidacao.excluir
|
||||||
|
consolidacao.registrar_pagamento consolidacao.cancelar_pagamento
|
||||||
|
consolidacao.gerir_motoristas consolidacao.exportar_pdf
|
||||||
|
notificacao.contatos notificacao.contatos_gerenciar notificacao.envios
|
||||||
|
admin.usuarios admin.configuracoes admin.auditoria admin.planilha_simpli_route
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
OPERADOR = %w[
|
||||||
|
dashboard.ver dashboard.financeiro dashboard.operacoes dashboard.exportar dashboard.metricas
|
||||||
|
consolidacao.ver consolidacao.criar consolidacao.editar consolidacao.finalizar
|
||||||
|
consolidacao.gerir_motoristas consolidacao.exportar_pdf
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
EXTERNO = %w[dashboard.ver dashboard.operacoes].freeze
|
||||||
|
|
||||||
|
# nome, descrição, permissões, role legada que recebe este perfil
|
||||||
|
PERFIS = [
|
||||||
|
['Administrador', 'Acesso total ao sistema.', :todas, 0],
|
||||||
|
['Gerente', 'Opera e paga; administra usuários, preços e destinatários.', GERENTE, 1],
|
||||||
|
['Operador', 'Monta e finaliza consolidações; não paga nem administra.', OPERADOR, 2],
|
||||||
|
['Externo', 'Somente leitura dos indicadores de operação.', EXTERNO, 4]
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
def up
|
||||||
|
PERFIS.each do |nome, descricao, permissoes, role|
|
||||||
|
lista = (permissoes == :todas ? TODAS : permissoes)
|
||||||
|
|
||||||
|
id = select_value("SELECT id FROM perfis_acesso WHERE LOWER(nome) = #{quote(nome.downcase)}")
|
||||||
|
if id.nil?
|
||||||
|
execute(<<~SQL.squish)
|
||||||
|
INSERT INTO perfis_acesso (nome, descricao, permissoes, sistema, ativo, created_at, updated_at)
|
||||||
|
VALUES (#{quote(nome)}, #{quote(descricao)}, #{quote(lista.to_json)}::jsonb, true, true, NOW(), NOW())
|
||||||
|
SQL
|
||||||
|
id = select_value("SELECT id FROM perfis_acesso WHERE LOWER(nome) = #{quote(nome.downcase)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Só quem ainda não tem perfil: se o ADM já ajustou alguém à mão entre o
|
||||||
|
# deploy e a migration, a escolha dele vence.
|
||||||
|
execute("UPDATE users SET perfil_acesso_id = #{id}, updated_at = NOW() " \
|
||||||
|
"WHERE role = #{role} AND perfil_acesso_id IS NULL")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
execute('UPDATE users SET perfil_acesso_id = NULL')
|
||||||
|
execute('DELETE FROM perfis_acesso WHERE sistema = true')
|
||||||
|
end
|
||||||
|
end
|
||||||
22
db/migrate/20260826000004_create_variaveis_personalizadas.rb
Normal file
22
db/migrate/20260826000004_create_variaveis_personalizadas.rb
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Variáveis que o ADM cria na tela e usa em qualquer mensagem: {{telefone_suporte}},
|
||||||
|
# {{horario_atendimento}}, {{site}}…
|
||||||
|
#
|
||||||
|
# Antes, toda informação fixa que precisasse aparecer numa mensagem era digitada
|
||||||
|
# de novo em cada template — e mudar o telefone do suporte significava abrir um
|
||||||
|
# por um. Aqui a chave é única e o valor mora num lugar só.
|
||||||
|
class CreateVariaveisPersonalizadas < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
create_table :variaveis_personalizadas do |t|
|
||||||
|
t.string :chave, null: false # sem {{ }}: telefone_suporte
|
||||||
|
t.string :valor, null: false, default: ''
|
||||||
|
t.string :descricao # o que a tela mostra ao lado
|
||||||
|
t.boolean :ativo, null: false, default: true
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
# A chave é o que aparece no texto ({{chave}}); duas iguais tornariam o
|
||||||
|
# resultado imprevisível.
|
||||||
|
add_index :variaveis_personalizadas, :chave, unique: true
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,6 +10,8 @@ FactoryBot.define do
|
|||||||
factory :gerente do role { :gerente } end
|
factory :gerente do role { :gerente } end
|
||||||
factory :operador do role { :operador } end
|
factory :operador do role { :operador } end
|
||||||
|
|
||||||
|
factory :externo do role { :externo } end
|
||||||
|
|
||||||
factory :motorista do
|
factory :motorista do
|
||||||
role { :motorista }
|
role { :motorista }
|
||||||
# email é NOT NULL + único no banco; motorista real não tem e-mail, então
|
# email é NOT NULL + único no banco; motorista real não tem e-mail, então
|
||||||
@@ -21,6 +23,12 @@ FactoryBot.define do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
factory :perfil_acesso do
|
||||||
|
sequence(:nome) { |n| "Perfil #{n}" }
|
||||||
|
# Um conjunto pequeno e realista: ver e fechar, sem pagar nem administrar.
|
||||||
|
permissoes { %w[consolidacao.ver consolidacao.editar consolidacao.finalizar] }
|
||||||
|
end
|
||||||
|
|
||||||
factory :configuracao do
|
factory :configuracao do
|
||||||
chave { 'empresa_nome' }
|
chave { 'empresa_nome' }
|
||||||
valor { 'Reem Transporte' }
|
valor { 'Reem Transporte' }
|
||||||
@@ -53,6 +61,12 @@ FactoryBot.define do
|
|||||||
valor_aplicado { 15.0 }
|
valor_aplicado { 15.0 }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
factory :variavel_personalizada do
|
||||||
|
sequence(:chave) { |n| "variavel_#{n}" }
|
||||||
|
valor { 'Valor de teste' }
|
||||||
|
ativo { true }
|
||||||
|
end
|
||||||
|
|
||||||
factory :grupo_contato do
|
factory :grupo_contato do
|
||||||
sequence(:nome) { |n| "Grupo #{n}" }
|
sequence(:nome) { |n| "Grupo #{n}" }
|
||||||
ativo { true }
|
ativo { true }
|
||||||
|
|||||||
60
spec/models/perfil_acesso_spec.rb
Normal file
60
spec/models/perfil_acesso_spec.rb
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe PerfilAcesso do
|
||||||
|
it 'descarta chave que não existe mais no catálogo ao salvar' do
|
||||||
|
perfil = create(:perfil_acesso, permissoes: ['consolidacao.ver', 'permissao.aposentada'])
|
||||||
|
|
||||||
|
expect(perfil.permissoes).to eq(['consolidacao.ver'])
|
||||||
|
expect(perfil).to be_pode('consolidacao.ver')
|
||||||
|
expect(perfil).not_to be_pode('permissao.aposentada')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'não aceita dois perfis com o mesmo nome' do
|
||||||
|
create(:perfil_acesso, nome: 'Financeiro')
|
||||||
|
expect(build(:perfil_acesso, nome: 'financeiro')).not_to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#apagavel?' do
|
||||||
|
it 'perfil de sistema não é apagável' do
|
||||||
|
expect(build(:perfil_acesso, sistema: true)).not_to be_apagavel
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'perfil em uso não é apagável (o usuário cairia no fallback sem avisar)' do
|
||||||
|
perfil = create(:perfil_acesso)
|
||||||
|
create(:operador, perfil_acesso: perfil)
|
||||||
|
|
||||||
|
expect(perfil.reload).not_to be_apagavel
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'perfil livre é apagável' do
|
||||||
|
expect(create(:perfil_acesso)).to be_apagavel
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'permissões do usuário' do
|
||||||
|
it 'o perfil vence o padrão do papel' do
|
||||||
|
# Gerente por papel veria Configurações; o perfil tira só isso.
|
||||||
|
perfil = create(:perfil_acesso, permissoes: Permissao.do_role('gerente') - ['admin.configuracoes'])
|
||||||
|
usuario = create(:gerente, perfil_acesso: perfil)
|
||||||
|
|
||||||
|
expect(usuario.pode?('consolidacao.registrar_pagamento')).to be(true)
|
||||||
|
expect(usuario.pode?('admin.configuracoes')).to be(false)
|
||||||
|
expect(usuario.pode_ver_config?).to be(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sem perfil, vale o padrão do papel (e não consulta o banco)' do
|
||||||
|
usuario = build(:gerente) # não persistido, como nos specs de policy
|
||||||
|
|
||||||
|
expect(usuario.pode_consolidar?).to be(true)
|
||||||
|
expect(usuario.pode_ver_config?).to be(true)
|
||||||
|
expect(usuario.pode?('admin.perfis')).to be(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'perfil vazio tira tudo sem precisar desativar a conta' do
|
||||||
|
usuario = create(:operador, perfil_acesso: create(:perfil_acesso, permissoes: []))
|
||||||
|
|
||||||
|
expect(usuario.pode?('consolidacao.ver')).to be(false)
|
||||||
|
expect(usuario.pode_consolidar?).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
73
spec/models/permissao_spec.rb
Normal file
73
spec/models/permissao_spec.rb
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
# O catálogo é a fonte única de "o que dá para liberar". Estas travas existem
|
||||||
|
# porque um erro aqui não aparece na tela: uma chave escrita errado no
|
||||||
|
# PADRAO_POR_ROLE simplesmente não bate com nada e tira acesso de todo um papel
|
||||||
|
# em silêncio.
|
||||||
|
RSpec.describe Permissao do
|
||||||
|
describe 'catálogo' do
|
||||||
|
it 'toda chave segue o padrão area.acao e pertence a um grupo declarado' do
|
||||||
|
described_class::TODAS.each do |chave, cfg|
|
||||||
|
expect(chave.count('.')).to eq(1), "#{chave} fora do padrão area.acao"
|
||||||
|
expect(described_class::GRUPOS).to have_key(cfg[:grupo]), "#{chave}: grupo #{cfg[:grupo]} não existe"
|
||||||
|
expect(cfg[:label]).to be_present
|
||||||
|
expect(cfg[:descricao]).to be_present, "#{chave} sem descrição (é o texto que o ADM lê na tela)"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'por_grupo cobre todas as chaves' do
|
||||||
|
total = described_class.por_grupo.values.sum(&:size)
|
||||||
|
expect(total).to eq(described_class.chaves.size)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'filtrar descarta chave inexistente e repetida' do
|
||||||
|
expect(described_class.filtrar(['consolidacao.ver', 'nao.existe', 'consolidacao.ver']))
|
||||||
|
.to eq(['consolidacao.ver'])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'PADRAO_POR_ROLE' do
|
||||||
|
it 'só usa chaves que existem no catálogo' do
|
||||||
|
described_class::PADRAO_POR_ROLE.each do |role, lista|
|
||||||
|
next if lista == :todas
|
||||||
|
|
||||||
|
lista.each do |chave|
|
||||||
|
expect(described_class).to be_existe(chave), "#{role} referencia chave inexistente: #{chave}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'admin recebe o catálogo inteiro' do
|
||||||
|
expect(described_class.do_role('admin')).to match_array(described_class.chaves)
|
||||||
|
end
|
||||||
|
|
||||||
|
# O fallback é o que mantém o comportamento de hoje para quem ainda não tem
|
||||||
|
# perfil. Se estes pares mudarem, alguém perde acesso no dia do deploy.
|
||||||
|
it 'reproduz pode_consolidar? e pode_ver_config? de cada papel' do
|
||||||
|
{
|
||||||
|
'admin' => [true, true],
|
||||||
|
'gerente' => [true, true],
|
||||||
|
'operador' => [true, false],
|
||||||
|
'motorista' => [false, false],
|
||||||
|
'externo' => [false, false]
|
||||||
|
}.each do |role, (consolidar, config)|
|
||||||
|
chaves = described_class.do_role(role)
|
||||||
|
expect(chaves.include?('consolidacao.ver')).to eq(consolidar), "#{role}: consolidar"
|
||||||
|
expect(chaves.include?('admin.configuracoes')).to eq(config), "#{role}: config"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# As duas mudanças de comportamento deliberadas desta entrega.
|
||||||
|
it 'externo não enxerga o financeiro (era o furo do skip_authorization)' do
|
||||||
|
expect(described_class.do_role('externo')).not_to include('dashboard.financeiro')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'operador não enxerga a auditoria (o menu nunca ofereceu)' do
|
||||||
|
expect(described_class.do_role('operador')).not_to include('admin.auditoria')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'motorista não usa permissão — o acesso dele é por tipo de conta' do
|
||||||
|
expect(described_class.do_role('motorista')).to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
27
spec/models/variavel_personalizada_spec.rb
Normal file
27
spec/models/variavel_personalizada_spec.rb
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe VariavelPersonalizada do
|
||||||
|
it 'normaliza a chave (minúscula, sem espaço)' do
|
||||||
|
v = create(:variavel_personalizada, chave: ' Telefone Suporte ', valor: '4002-8922')
|
||||||
|
expect(v.chave).to eq('telefone_suporte')
|
||||||
|
expect(v.marcador).to eq('{{telefone_suporte}}')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'recusa chave com formato que o renderizador não substituiria' do
|
||||||
|
expect(build(:variavel_personalizada, chave: '2telefone')).not_to be_valid
|
||||||
|
expect(build(:variavel_personalizada, chave: 'telefone-suporte')).not_to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
# Deixar o ADM criar {{valor}} faria a variável dele brigar com a do sistema —
|
||||||
|
# e o preview não mostraria o problema, só o envio real.
|
||||||
|
it 'recusa chave que já existe no sistema' do
|
||||||
|
v = build(:variavel_personalizada, chave: 'valor')
|
||||||
|
expect(v).not_to be_valid
|
||||||
|
expect(v.errors[:chave].join).to include('já existe no sistema')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'não aceita duas variáveis com a mesma chave' do
|
||||||
|
create(:variavel_personalizada, chave: 'site', valor: 'a')
|
||||||
|
expect(build(:variavel_personalizada, chave: 'SITE', valor: 'b')).not_to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
101
spec/requests/admin/perfis_acesso_spec.rb
Normal file
101
spec/requests/admin/perfis_acesso_spec.rb
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
# Perfis de acesso: quem entra na tela, e as travas que impedem o ADM de se
|
||||||
|
# trancar para fora do próprio sistema.
|
||||||
|
RSpec.describe 'Admin::PerfisAcesso', type: :request do
|
||||||
|
# Perfil com acesso total, para o usuário que administra nos testes.
|
||||||
|
let(:perfil_admin) { create(:perfil_acesso, nome: 'Administrador', permissoes: Permissao.chaves) }
|
||||||
|
let(:administrador) { create(:admin, perfil_acesso: perfil_admin) }
|
||||||
|
|
||||||
|
describe 'quem entra' do
|
||||||
|
it 'exige login' do
|
||||||
|
get admin_perfis_path
|
||||||
|
expect(response).to redirect_to(new_user_session_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'nega quem não tem admin.perfis' do
|
||||||
|
sign_in create(:gerente, perfil_acesso: create(:perfil_acesso, permissoes: ['consolidacao.ver']))
|
||||||
|
get admin_perfis_path
|
||||||
|
expect(response).to have_http_status(:redirect)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'permite quem tem admin.perfis' do
|
||||||
|
sign_in administrador
|
||||||
|
get admin_perfis_path
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'criar e editar' do
|
||||||
|
before { sign_in administrador }
|
||||||
|
|
||||||
|
it 'cria perfil só com as chaves do catálogo' do
|
||||||
|
post admin_perfis_path, params: {
|
||||||
|
perfil_acesso: { nome: 'Financeiro', descricao: 'Paga',
|
||||||
|
ativo: '1', permissoes: ['consolidacao.ver', 'inventada.chave'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
perfil = PerfilAcesso.find_by(nome: 'Financeiro')
|
||||||
|
expect(perfil.permissoes).to eq(['consolidacao.ver'])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'desmarcar tudo realmente zera as permissões' do
|
||||||
|
perfil = create(:perfil_acesso, permissoes: ['consolidacao.ver'])
|
||||||
|
|
||||||
|
# O form manda um valor vazio quando nenhuma caixa está marcada; sem ele o
|
||||||
|
# parâmetro sumiria e "desmarcar tudo" viraria "não mexeu".
|
||||||
|
patch admin_perfil_path(perfil), params: {
|
||||||
|
perfil_acesso: { nome: perfil.nome, ativo: '1', permissoes: [''] }
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(perfil.reload.permissoes).to eq([])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'trava do último administrador' do
|
||||||
|
before { sign_in administrador }
|
||||||
|
|
||||||
|
it 'impede tirar admin.perfis do único perfil que administra' do
|
||||||
|
patch admin_perfil_path(perfil_admin), params: {
|
||||||
|
perfil_acesso: { nome: perfil_admin.nome, ativo: '1',
|
||||||
|
permissoes: Permissao.chaves - ['admin.perfis'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(perfil_admin.reload.permissoes).to include('admin.perfis')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'libera quando outra pessoa mantém o acesso' do
|
||||||
|
outro = create(:perfil_acesso, nome: 'Admin 2', permissoes: Permissao.chaves)
|
||||||
|
create(:admin, perfil_acesso: outro)
|
||||||
|
|
||||||
|
patch admin_perfil_path(perfil_admin), params: {
|
||||||
|
perfil_acesso: { nome: perfil_admin.nome, ativo: '1',
|
||||||
|
permissoes: Permissao.chaves - ['admin.perfis'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(perfil_admin.reload.permissoes).not_to include('admin.perfis')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'exclusão' do
|
||||||
|
before { sign_in administrador }
|
||||||
|
|
||||||
|
it 'não exclui perfil de sistema' do
|
||||||
|
perfil = create(:perfil_acesso, sistema: true)
|
||||||
|
expect { delete admin_perfil_path(perfil) }.not_to change(PerfilAcesso, :count)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'não exclui perfil em uso' do
|
||||||
|
perfil = create(:perfil_acesso)
|
||||||
|
create(:operador, perfil_acesso: perfil)
|
||||||
|
|
||||||
|
expect { delete admin_perfil_path(perfil) }.not_to change(PerfilAcesso, :count)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'exclui perfil livre' do
|
||||||
|
perfil = create(:perfil_acesso)
|
||||||
|
expect { delete admin_perfil_path(perfil) }.to change(PerfilAcesso, :count).by(-1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
89
spec/requests/controle_de_acesso_spec.rb
Normal file
89
spec/requests/controle_de_acesso_spec.rb
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
# As três falhas de acesso que existiam antes dos perfis. Cada exemplo aqui é um
|
||||||
|
# furo real que foi fechado — se algum voltar a passar "verde ao contrário", o
|
||||||
|
# sistema regrediu para o estado em que qualquer usuário virava admin.
|
||||||
|
RSpec.describe 'Controle de acesso', type: :request do
|
||||||
|
describe 'auto-promoção a administrador' do
|
||||||
|
it 'ignora role e perfil enviados por quem edita a PRÓPRIA ficha' do
|
||||||
|
operador = create(:operador)
|
||||||
|
perfil_total = create(:perfil_acesso, permissoes: Permissao.chaves)
|
||||||
|
sign_in operador
|
||||||
|
|
||||||
|
patch admin_usuario_path(operador), params: {
|
||||||
|
user: { nome: 'Nome Novo', role: 'admin', perfil_acesso_id: perfil_total.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
operador.reload
|
||||||
|
expect(operador.nome).to eq('Nome Novo') # o que ele PODE mudar, mudou
|
||||||
|
expect(operador.role).to eq('operador') # o que ele não pode, não mudou
|
||||||
|
expect(operador.perfil_acesso_id).to be_nil
|
||||||
|
expect(operador.pode?('admin.usuarios_gerenciar')).to be(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'quem administra usuários continua podendo trocar o perfil de outra pessoa' do
|
||||||
|
perfil_admin = create(:perfil_acesso, permissoes: Permissao.chaves)
|
||||||
|
admin = create(:admin, perfil_acesso: perfil_admin)
|
||||||
|
alvo = create(:operador)
|
||||||
|
novo = create(:perfil_acesso, nome: 'Financeiro', permissoes: ['consolidacao.ver'])
|
||||||
|
sign_in admin
|
||||||
|
|
||||||
|
patch admin_usuario_path(alvo), params: { user: { nome: alvo.nome, perfil_acesso_id: novo.id } }
|
||||||
|
|
||||||
|
expect(alvo.reload.perfil_acesso_id).to eq(novo.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'papel externo no dashboard' do
|
||||||
|
it 'entra no dashboard mas não vê custo nem pagamento' do
|
||||||
|
sign_in create(:externo)
|
||||||
|
|
||||||
|
get dashboard_path
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).not_to include('Consolidado / Pago')
|
||||||
|
expect(response.body).not_to include('Ticket médio')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'não baixa o relatório financeiro nem abre as entregas em aberto' do
|
||||||
|
sign_in create(:externo)
|
||||||
|
|
||||||
|
get dashboard_relatorio_financeiro_path
|
||||||
|
expect(response).to have_http_status(:redirect)
|
||||||
|
|
||||||
|
get dashboard_pendentes_path
|
||||||
|
expect(response).to have_http_status(:redirect)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'gerente continua vendo o financeiro' do
|
||||||
|
sign_in create(:gerente)
|
||||||
|
|
||||||
|
get dashboard_path
|
||||||
|
expect(response.body).to include('Consolidado / Pago')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'conta desativada' do
|
||||||
|
it 'não faz login por e-mail e senha' do
|
||||||
|
usuario = create(:gerente, ativo: false, password: 'Gade@2026!')
|
||||||
|
|
||||||
|
post user_session_path, params: { user: { email: usuario.email, password: 'Gade@2026!' } }
|
||||||
|
|
||||||
|
expect(response).not_to redirect_to(dashboard_path)
|
||||||
|
expect(controller.current_user).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'sem nenhuma permissão' do
|
||||||
|
it 'cai na tela de sem-acesso em vez de entrar em loop com a raiz' do
|
||||||
|
usuario = create(:operador, perfil_acesso: create(:perfil_acesso, permissoes: []))
|
||||||
|
sign_in usuario
|
||||||
|
|
||||||
|
get dashboard_path
|
||||||
|
expect(response).to redirect_to(sem_acesso_path)
|
||||||
|
|
||||||
|
get sem_acesso_path
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include('não tem acesso')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
83
spec/services/notificacao/resolvedor_variaveis_spec.rb
Normal file
83
spec/services/notificacao/resolvedor_variaveis_spec.rb
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
# O resolvedor é o que permite escrever qualquer variável numa mensagem sem que o
|
||||||
|
# gatilho conheça essa variável. As travas aqui protegem duas coisas: não
|
||||||
|
# sobrescrever o contexto real do acontecimento, e nunca derrubar um disparo.
|
||||||
|
RSpec.describe Notificacao::ResolvedorVariaveis do
|
||||||
|
describe '.usadas' do
|
||||||
|
it 'acha variáveis dentro dos blocos aninhados do template' do
|
||||||
|
blocos = [
|
||||||
|
{ 'tipo' => 'cabecalho', 'titulo' => 'Olá {{contato}}' },
|
||||||
|
{ 'tipo' => 'tabela', 'linhas' => [{ 'rotulo' => 'Valor', 'valor' => '{{valor}}' }] }
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(described_class.usadas([blocos, 'Assunto {{empresa}}']))
|
||||||
|
.to match_array(%w[contato valor empresa])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.completar' do
|
||||||
|
it 'não sobrescreve o que o gatilho já mandou' do
|
||||||
|
dados = described_class.completar(['{{empresa}} {{valor}}'],
|
||||||
|
{ 'empresa' => 'Contexto Ltda', 'valor' => 'R$ 10,00' })
|
||||||
|
|
||||||
|
expect(dados['empresa']).to eq('Contexto Ltda')
|
||||||
|
expect(dados['valor']).to eq('R$ 10,00')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'resolve variável de sistema' do
|
||||||
|
dados = described_class.completar(['{{data}}'], {})
|
||||||
|
expect(dados['data']).to eq(Date.current.strftime('%d/%m/%Y'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'resolve variável personalizada do ADM' do
|
||||||
|
create(:variavel_personalizada, chave: 'telefone_suporte', valor: '(11) 4002-8922')
|
||||||
|
|
||||||
|
dados = described_class.completar(['Ligue {{telefone_suporte}}'], {})
|
||||||
|
expect(dados['telefone_suporte']).to eq('(11) 4002-8922')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'ignora variável desativada (a mensagem sai sem ela)' do
|
||||||
|
create(:variavel_personalizada, chave: 'promo', valor: 'X', ativo: false)
|
||||||
|
|
||||||
|
expect(described_class.completar(['{{promo}}'], {})).not_to have_key('promo')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'variável inexistente não vira valor inventado' do
|
||||||
|
expect(described_class.completar(['{{nao_existe}}'], {})).not_to have_key('nao_existe')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Notificação não pode derrubar fechamento: se a consulta falhar, o disparo
|
||||||
|
# segue e a variável sai vazia.
|
||||||
|
it 'engole erro de uma variável de dados' do
|
||||||
|
allow(Entrega).to receive(:atendidas).and_raise(ActiveRecord::StatementInvalid, 'boom')
|
||||||
|
|
||||||
|
expect { described_class.completar(['{{entregas_mes}}'], {}) }.not_to raise_error
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'só consulta o banco para a variável realmente escrita' do
|
||||||
|
expect(Entrega).not_to receive(:atendidas)
|
||||||
|
|
||||||
|
described_class.completar(['Sem número aqui, só {{empresa}}'], {})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe Notificacao::CatalogoVariaveis do
|
||||||
|
it 'marca a variável de outro gatilho como não aplicável (em vez de escondê-la)' do
|
||||||
|
expect(described_class.aplicavel?('valor', 'pagamento_efetuado')).to be(true)
|
||||||
|
expect(described_class.aplicavel?('valor', 'operacao_alterada')).to be(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'variável de dados e de sistema valem em qualquer gatilho' do
|
||||||
|
expect(described_class.aplicavel?('entregas_mes', 'manual')).to be(true)
|
||||||
|
expect(described_class.aplicavel?('empresa', 'operacao_alterada')).to be(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'inclui as variáveis criadas pelo ADM' do
|
||||||
|
create(:variavel_personalizada, chave: 'horario_atendimento', valor: '8h às 18h')
|
||||||
|
|
||||||
|
expect(described_class.todas).to have_key('horario_atendimento')
|
||||||
|
expect(described_class.por_origem[:personalizada].map { |v| v[:chave] }).to include('horario_atendimento')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user