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

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

View File

@@ -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?

View File

@@ -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

View 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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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' })

View File

@@ -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)

View File

@@ -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)

View 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

View File

@@ -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)

View File

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

View File

@@ -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

View 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
View 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

View File

@@ -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

View 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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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)

View 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

View File

@@ -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

View 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 0114/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

View File

@@ -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

View 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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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 () {

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,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);

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -80,7 +80,7 @@
<td class="px-4 py-3 text-right font-bold <%= c.desconto? ? 'text-red-400' : 'text-white' %>"> <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?' } },

View File

@@ -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) %>"

View File

@@ -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">

View File

@@ -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

View File

@@ -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 %>

View File

@@ -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>

View File

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

View File

@@ -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

View File

@@ -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."

View File

@@ -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

View 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

View 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

View 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

View 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

View File

@@ -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 }

View 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

View 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

View 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

View 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

View 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

View 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