From 5bb5d74e22ce5eeeb02572a7c6c0dc68499071f7e141eb0e4a18709a4ccaf07c Mon Sep 17 00:00:00 2001 From: victor Date: Wed, 26 Aug 2026 17:24:25 -0300 Subject: [PATCH] =?UTF-8?q?Implanda=C3=A7=C3=A3o=20da=20customiza=C3=A7?= =?UTF-8?q?=C3=A3o=20das=20permiss=C3=B5es=20dos=20usuarios=20e=20das=20no?= =?UTF-8?q?tifica=C3=A7oes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/auditoria_logs_controller.rb | 5 +- .../admin/mensagem_templates_controller.rb | 2 +- .../admin/perfis_acesso_controller.rb | 110 ++++++++ app/controllers/admin/usuarios_controller.rb | 64 ++++- .../variaveis_personalizadas_controller.rb | 74 ++++++ app/controllers/application_controller.rb | 30 ++- .../consolidacao_entregas_controller.rb | 2 +- app/controllers/consolidacoes_controller.rb | 22 +- app/controllers/dashboard_controller.rb | 29 ++- .../operacoes_dashboard_controller.rb | 4 +- app/controllers/paginas_controller.rb | 12 + app/controllers/users/sessions_controller.rb | 11 +- app/models/evento_notificacao.rb | 8 +- app/models/mensagem_template.rb | 2 +- app/models/perfil_acesso.rb | 62 +++++ app/models/permissao.rb | 245 ++++++++++++++++++ app/models/user.rb | 108 +++++++- app/models/variavel_personalizada.rb | 46 ++++ app/policies/application_policy.rb | 10 +- app/policies/auditoria_log_policy.rb | 16 ++ .../configuracao_notificacao_policy.rb | 21 +- app/policies/configuracao_policy.rb | 22 +- app/policies/consolidacao_policy.rb | 46 +++- app/policies/contato_policy.rb | 30 ++- app/policies/dashboard_policy.rb | 24 +- app/policies/edicao_lancamento_policy.rb | 19 +- app/policies/evento_notificacao_policy.rb | 30 ++- app/policies/notificacao_envio_policy.rb | 11 +- app/policies/perfil_acesso_policy.rb | 24 ++ app/policies/planilha_simpli_route_policy.rb | 10 +- app/policies/user_policy.rb | 49 ++-- app/policies/variavel_personalizada_policy.rb | 19 ++ app/policies/whatsapp_sessao_policy.rb | 14 +- .../notificacao/catalogo_variaveis.rb | 196 ++++++++++++++ app/services/notificacao/despachante.rb | 20 +- app/services/notificacao/formatador.rb | 31 +++ app/services/notificacao/gatilhos.rb | 11 +- .../notificacao/resolvedor_variaveis.rb | 90 +++++++ app/services/notificacao_service.rb | 8 +- .../admin/mensagem_templates/edit.html.erb | 81 ++++-- app/views/admin/perfis_acesso/_form.html.erb | 83 ++++++ app/views/admin/perfis_acesso/edit.html.erb | 10 + app/views/admin/perfis_acesso/index.html.erb | 70 +++++ app/views/admin/perfis_acesso/new.html.erb | 8 + app/views/admin/usuarios/_form.html.erb | 56 +++- app/views/admin/usuarios/index.html.erb | 19 +- .../variaveis_personalizadas/_form.html.erb | 55 ++++ .../variaveis_personalizadas/edit.html.erb | 8 + .../variaveis_personalizadas/index.html.erb | 76 ++++++ .../variaveis_personalizadas/new.html.erb | 6 + .../consolidacao_entregas/revisar.html.erb | 2 +- .../consolidacao_entregas/validar.html.erb | 4 +- app/views/consolidacoes/show.html.erb | 2 +- app/views/consolidacoes/wizard.html.erb | 4 +- app/views/dashboard/index.html.erb | 33 ++- app/views/layouts/_navbar.html.erb | 65 +++-- app/views/paginas/sem_acesso.html.erb | 20 ++ config/initializers/inflections.rb | 3 + config/locales/pt-BR.yml | 6 + config/routes.rb | 13 + .../20260826000001_create_perfis_acesso.rb | 31 +++ ...260826000002_add_perfil_acesso_to_users.rb | 17 ++ .../20260826000003_semear_perfis_acesso.rb | 78 ++++++ ...6000004_create_variaveis_personalizadas.rb | 22 ++ spec/factories.rb | 14 + spec/models/perfil_acesso_spec.rb | 60 +++++ spec/models/permissao_spec.rb | 73 ++++++ spec/models/variavel_personalizada_spec.rb | 27 ++ spec/requests/admin/perfis_acesso_spec.rb | 101 ++++++++ spec/requests/controle_de_acesso_spec.rb | 89 +++++++ .../notificacao/resolvedor_variaveis_spec.rb | 83 ++++++ 71 files changed, 2487 insertions(+), 269 deletions(-) create mode 100644 app/controllers/admin/perfis_acesso_controller.rb create mode 100644 app/controllers/admin/variaveis_personalizadas_controller.rb create mode 100644 app/controllers/paginas_controller.rb create mode 100644 app/models/perfil_acesso.rb create mode 100644 app/models/permissao.rb create mode 100644 app/models/variavel_personalizada.rb create mode 100644 app/policies/auditoria_log_policy.rb create mode 100644 app/policies/perfil_acesso_policy.rb create mode 100644 app/policies/variavel_personalizada_policy.rb create mode 100644 app/services/notificacao/catalogo_variaveis.rb create mode 100644 app/services/notificacao/formatador.rb create mode 100644 app/services/notificacao/resolvedor_variaveis.rb create mode 100644 app/views/admin/perfis_acesso/_form.html.erb create mode 100644 app/views/admin/perfis_acesso/edit.html.erb create mode 100644 app/views/admin/perfis_acesso/index.html.erb create mode 100644 app/views/admin/perfis_acesso/new.html.erb create mode 100644 app/views/admin/variaveis_personalizadas/_form.html.erb create mode 100644 app/views/admin/variaveis_personalizadas/edit.html.erb create mode 100644 app/views/admin/variaveis_personalizadas/index.html.erb create mode 100644 app/views/admin/variaveis_personalizadas/new.html.erb create mode 100644 app/views/paginas/sem_acesso.html.erb create mode 100644 db/migrate/20260826000001_create_perfis_acesso.rb create mode 100644 db/migrate/20260826000002_add_perfil_acesso_to_users.rb create mode 100644 db/migrate/20260826000003_semear_perfis_acesso.rb create mode 100644 db/migrate/20260826000004_create_variaveis_personalizadas.rb create mode 100644 spec/models/perfil_acesso_spec.rb create mode 100644 spec/models/permissao_spec.rb create mode 100644 spec/models/variavel_personalizada_spec.rb create mode 100644 spec/requests/admin/perfis_acesso_spec.rb create mode 100644 spec/requests/controle_de_acesso_spec.rb create mode 100644 spec/services/notificacao/resolvedor_variaveis_spec.rb diff --git a/app/controllers/admin/auditoria_logs_controller.rb b/app/controllers/admin/auditoria_logs_controller.rb index f8a95a4..3c1d1d6 100644 --- a/app/controllers/admin/auditoria_logs_controller.rb +++ b/app/controllers/admin/auditoria_logs_controller.rb @@ -1,7 +1,10 @@ # app/controllers/admin/auditoria_logs_controller.rb class Admin::AuditoriaLogsController < ApplicationController 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 = @logs.por_user(params[:user_id]) if params[:user_id].present? diff --git a/app/controllers/admin/mensagem_templates_controller.rb b/app/controllers/admin/mensagem_templates_controller.rb index 5b8d63b..3e9e9d8 100644 --- a/app/controllers/admin/mensagem_templates_controller.rb +++ b/app/controllers/admin/mensagem_templates_controller.rb @@ -36,7 +36,7 @@ class Admin::MensagemTemplatesController < ApplicationController assunto: params[:assunto], blocos: blocos_do_form, ativo: true) 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), assunto: provisorio.renderizador(dados).interpolar(provisorio.assunto) } end diff --git a/app/controllers/admin/perfis_acesso_controller.rb b/app/controllers/admin/perfis_acesso_controller.rb new file mode 100644 index 0000000..c8d737f --- /dev/null +++ b/app/controllers/admin/perfis_acesso_controller.rb @@ -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 diff --git a/app/controllers/admin/usuarios_controller.rb b/app/controllers/admin/usuarios_controller.rb index 3366537..1203051 100644 --- a/app/controllers/admin/usuarios_controller.rb +++ b/app/controllers/admin/usuarios_controller.rb @@ -4,7 +4,7 @@ class Admin::UsuariosController < ApplicationController def index 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 end @@ -36,6 +36,13 @@ class Admin::UsuariosController < ApplicationController def update 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) 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.' @@ -47,6 +54,10 @@ class Admin::UsuariosController < ApplicationController def destroy authorize @usuario + if User.ultimo_administrador?(@usuario) + return redirect_to(admin_usuarios_path, alert: MENSAGEM_ULTIMO_ADMIN) + end + if @usuario == current_user redirect_to admin_usuarios_path, alert: 'Você não pode excluir sua própria conta.' return @@ -58,7 +69,11 @@ class Admin::UsuariosController < ApplicationController end 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) status = @usuario.ativo? ? 'ativado' : 'desativado' 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 + 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 @usuario = User.find(params[:id]) 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 - params.require(:user).permit(:nome, :email, :password, :password_confirmation, - :role, :pin_code, :ativo) + permitidos = [:nome, :email, :password, :password_confirmation, :pin_code] + permitidos += CAMPOS_DE_ACESSO if pode_alterar_acesso? + params.require(:user).permit(permitidos) end def usuario_params_update - permitted = [:nome, :email, :role, :pin_code, :ativo] - permitted += [:password, :password_confirmation] if params[:user][:password].present? - params.require(:user).permit(permitted) + permitidos = [:nome, :email, :pin_code] + permitidos += CAMPOS_DE_ACESSO if pode_alterar_acesso? + permitidos += [:password, :password_confirmation] if params[:user][:password].present? + params.require(:user).permit(permitidos) 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 diff --git a/app/controllers/admin/variaveis_personalizadas_controller.rb b/app/controllers/admin/variaveis_personalizadas_controller.rb new file mode 100644 index 0000000..dfd235d --- /dev/null +++ b/app/controllers/admin/variaveis_personalizadas_controller.rb @@ -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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 76e68e7..b733145 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -7,14 +7,31 @@ class ApplicationController < ActionController::Base before_action :set_tema before_action :preparar_splash - # 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." - redirect_to root_path + # Pundit: redireciona se não autorizado. + # + # ⚠️ NÃO redirecione para `root_path` aqui: a raiz é o dashboard, e um perfil + # 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 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 @tema = current_user&.tema_preferido || 'dark' end @@ -28,8 +45,11 @@ class ApplicationController < ActionController::Base @mostrar_splash = session.delete(:mostrar_splash) 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) session[:mostrar_splash] = true - resource.motorista? ? motorista_dashboard_path : dashboard_path + rota = resource.home_rota + rota ? public_send(rota) : sem_acesso_path end end diff --git a/app/controllers/consolidacao_entregas_controller.rb b/app/controllers/consolidacao_entregas_controller.rb index df04cc8..ce980ab 100644 --- a/app/controllers/consolidacao_entregas_controller.rb +++ b/app/controllers/consolidacao_entregas_controller.rb @@ -433,7 +433,7 @@ class ConsolidacaoEntregasController < ApplicationController end 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.' end diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb index 46cbaea..740dfd4 100644 --- a/app/controllers/consolidacoes_controller.rb +++ b/app/controllers/consolidacoes_controller.rb @@ -66,7 +66,7 @@ class ConsolidacoesController < ApplicationController # filtros da tela. Com ?detalhe=NOME sai só o extrato daquele motorista (com # espaço de assinatura). def totais_pdf - authorize Consolidacao, :index? + authorize Consolidacao, :exportar_pdf? inicio, fim = periodo_totais totais = Analytics::TotaisPorMotorista.new( @@ -245,7 +245,7 @@ class ConsolidacoesController < ApplicationController # POST /consolidacoes/:id/finalizar def finalizar - authorize @consolidacao, :update? + authorize @consolidacao, :finalizar? if @consolidacao.finalizar!(current_user) 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 # veículos consolidados/prontos seguem no fechamento; os pendentes ficam de fora. def finalizar_parcial - authorize @consolidacao, :update? + authorize @consolidacao, :finalizar_parcial? arquivados = @consolidacao.arquivar_veiculos_pendentes!(current_user) if @consolidacao.finalizar!(current_user) @@ -278,7 +278,7 @@ class ConsolidacoesController < ApplicationController # POST /consolidacoes/:id/arquivar def arquivar - authorize @consolidacao, :destroy? + authorize @consolidacao, :arquivar? @consolidacao.arquivar!(current_user) auditar!(:arquivar, @consolidacao) redirect_to consolidacoes_path, notice: 'Consolidação arquivada.' @@ -292,7 +292,7 @@ class ConsolidacoesController < ApplicationController # POST /consolidacoes/:id/reativar — restaura uma consolidação arquivada def reativar - authorize @consolidacao, :destroy? + authorize @consolidacao, :reativar? @consolidacao.reativar!(current_user) auditar!(:reativar, @consolidacao) 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 # ficam guardados e podem ser restaurados. Não arquiva motorista já pago. def arquivar_motorista - authorize @consolidacao, :update? + authorize @consolidacao, :gerir_motoristas? cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id]) if cm.pago? @@ -367,7 +367,7 @@ class ConsolidacoesController < ApplicationController # POST /consolidacoes/:id/desarquivar_motorista — restaura um motorista arquivado. def desarquivar_motorista - authorize @consolidacao, :update? + authorize @consolidacao, :gerir_motoristas? cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id]) cm.desarquivar!(current_user) @@ -382,7 +382,7 @@ class ConsolidacoesController < ApplicationController # 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. def excluir_motorista - authorize @consolidacao, :destroy? + authorize @consolidacao, :excluir_motorista? cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id]) if cm.pago? @@ -407,7 +407,7 @@ class ConsolidacoesController < ApplicationController # consolidação (ou restaura, se estava arquivado). Útil para refazer rápido # depois de excluir/arquivar por erro de precificação. def adicionar_motorista - authorize @consolidacao, :update? + authorize @consolidacao, :gerir_motoristas? nome = params[:motorista_nome].to_s.strip if nome.blank? @@ -435,7 +435,7 @@ class ConsolidacoesController < ApplicationController # GET /consolidacoes/:id/gerar_pdf_relatorio?motorista=X def gerar_pdf_relatorio - authorize @consolidacao, :show? + authorize @consolidacao, :exportar_pdf? motorista = params[: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 def gerar_pdf_financeiro - authorize @consolidacao, :show? + authorize @consolidacao, :exportar_pdf? pdf = Pdf::RelatorioFinanceiroConsolidacaoPdf.new(consolidacao: @consolidacao) auditar!(:editar, @consolidacao, dados_novos: { pdf: 'financeiro' }) diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 5a46284..ab41249 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -3,11 +3,16 @@ class DashboardController < ApplicationController MAX_DIAS_GRAFICO = 92 # limita a granularidade diária do gráfico (≈ 1 trimestre) def index - skip_authorization - # Motorista tem painel próprio — não enxerga o dashboard administrativo 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 carregar_dados_dashboard end @@ -15,9 +20,11 @@ class DashboardController < ApplicationController # GET /dashboard/relatorio_financeiro?inicio=&fim=&operacoes[]= # Relatório financeiro do período (PDF) — mesmos números da tela. def relatorio_financeiro - skip_authorization return redirect_to(motorista_dashboard_path) if current_user.motorista? + authorize :dashboard, :financeiro? + @ver_financeiro = true + @periodo_inicio, @periodo_fim = periodo_selecionado carregar_dados_dashboard @@ -58,9 +65,10 @@ class DashboardController < ApplicationController # PLANEJADA: é o único recorte sem checkout, então planned_date é o eixo # possível. Mesmo recorte do card, para o total bater. def pendentes - skip_authorization return redirect_to(motorista_dashboard_path) if current_user.motorista? + authorize :dashboard, :pendentes? + @periodo_inicio, @periodo_fim = periodo_selecionado @operacoes_agrupadas = Operacao.agrupadas_por_mes @operacao_filtro = Operacao.sanitizar(params[:operacoes]) @@ -179,12 +187,13 @@ class DashboardController < ApplicationController @consolidacoes_abertas = @consolidacoes_mes.where(status: :rascunho).count @consolidacoes_fechadas = @consolidacoes_mes.where(status: :finalizada).count - # Pagamentos: pago vs pendente entre as consolidações FINALIZADAS do período - # (medido por motorista — fonte: consolidacao_motoristas). - carregar_dados_pagamentos - - # Visão financeira por operação (KPIs + gráficos) - carregar_dados_financeiro + # Pagamentos e visão financeira só são consultados por quem pode vê-los: sem a + # 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_financeiro + end # Histórico estimado mais recente @historico_recente = HistoricoEstimado.order(created_at: :desc).limit(5) diff --git a/app/controllers/operacoes_dashboard_controller.rb b/app/controllers/operacoes_dashboard_controller.rb index 31c53a4..a6eb15e 100644 --- a/app/controllers/operacoes_dashboard_controller.rb +++ b/app/controllers/operacoes_dashboard_controller.rb @@ -82,7 +82,9 @@ class OperacoesDashboardController < ApplicationController # Baixa a planilha Entregas (.xlsx) da operação já preenchida com o resultado # do rastreio — o modelo que é entregue ao cliente. 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 unless Operacao.valida?(tabela) diff --git a/app/controllers/paginas_controller.rb b/app/controllers/paginas_controller.rb new file mode 100644 index 0000000..e5eea05 --- /dev/null +++ b/app/controllers/paginas_controller.rb @@ -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 diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 64a30bd..a79afb5 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -20,14 +20,13 @@ class Users::SessionsController < Devise::SessionsController 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) session[:mostrar_splash] = true # dispara a animação de abertura uma vez - case resource.role - when 'motorista' - motorista_dashboard_path - else - dashboard_path - end + rota = resource.home_rota + rota ? public_send(rota) : sem_acesso_path end def after_sign_out_path_for(resource_or_scope) diff --git a/app/models/evento_notificacao.rb b/app/models/evento_notificacao.rb index ec2634e..bccd9bf 100644 --- a/app/models/evento_notificacao.rb +++ b/app/models/evento_notificacao.rb @@ -78,7 +78,13 @@ class EventoNotificacao < ApplicationRecord t&.utilizavel? ? t : nil 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 # estar em dois grupos assinantes. diff --git a/app/models/mensagem_template.rb b/app/models/mensagem_template.rb index 1070ce9..199eeb9 100644 --- a/app/models/mensagem_template.rb +++ b/app/models/mensagem_template.rb @@ -39,7 +39,7 @@ class MensagemTemplate < ApplicationRecord end def preview(dados = nil) - dados ||= Notificacao::Variaveis.amostra(evento_notificacao&.gatilho) + dados ||= Notificacao::CatalogoVariaveis.amostra(evento_notificacao&.gatilho) r = renderizador(dados) email? ? r.html : r.texto end diff --git a/app/models/perfil_acesso.rb b/app/models/perfil_acesso.rb new file mode 100644 index 0000000..9de88d6 --- /dev/null +++ b/app/models/perfil_acesso.rb @@ -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 diff --git a/app/models/permissao.rb b/app/models/permissao.rb new file mode 100644 index 0000000..ca9833b --- /dev/null +++ b/app/models/permissao.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 19363f1..be5d55c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,6 +6,10 @@ class User < ApplicationRecord :rememberable, :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 ────────────────────────────────────────────────── # externo: acesso somente-leitura ao dashboard (ex.: gerências de outras áreas # que só precisam visualizar os indicadores). Login normal por e-mail/senha. @@ -57,12 +61,112 @@ class User < ApplicationRecord ROLES_LABEL[role] || role.humanize 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? - admin? || gerente? + pode?('admin.configuracoes') end 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 # Token de login rápido via QR Code (extrato) — Fase 7/8 diff --git a/app/models/variavel_personalizada.rb b/app/models/variavel_personalizada.rb new file mode 100644 index 0000000..30a9b3b --- /dev/null +++ b/app/models/variavel_personalizada.rb @@ -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 diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index 348a51a..dd5b95b 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -15,7 +15,15 @@ class ApplicationPolicy def edit? = update? 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? user.admin? end diff --git a/app/policies/auditoria_log_policy.rb b/app/policies/auditoria_log_policy.rb new file mode 100644 index 0000000..417c968 --- /dev/null +++ b/app/policies/auditoria_log_policy.rb @@ -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 diff --git a/app/policies/configuracao_notificacao_policy.rb b/app/policies/configuracao_notificacao_policy.rb index 04b85c6..3f4133e 100644 --- a/app/policies/configuracao_notificacao_policy.rb +++ b/app/policies/configuracao_notificacao_policy.rb @@ -1,22 +1,7 @@ # app/policies/configuracao_notificacao_policy.rb # -# Mais restrito que ConfiguracaoPolicy (que libera index? para gerente): estas -# 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 `?` aqui, -# senão o `authorize` estoura. +# Credenciais de SMTP/Twilio e limites de envio. class ConfiguracaoNotificacaoPolicy < ApplicationPolicy - def show? - admin? - end - - def update? - admin? - end - - class Scope < Scope - def resolve - scope.all - end - end + def show? = pode?('notificacao.credenciais') + def update? = show? end diff --git a/app/policies/configuracao_policy.rb b/app/policies/configuracao_policy.rb index 5241764..76fe2c5 100644 --- a/app/policies/configuracao_policy.rb +++ b/app/policies/configuracao_policy.rb @@ -1,20 +1,10 @@ # app/policies/configuracao_policy.rb class ConfiguracaoPolicy < ApplicationPolicy - def index? - admin_ou_gerente? - end + def index? = pode?('admin.configuracoes') + def show? = index? - def edit? - admin? - end - - def update? - admin? - end - - class Scope < Scope - def resolve - scope.all - end - end + # Alterar preço muda o valor de todo fechamento daqui para a frente — chave + # própria, separada de só olhar a tabela. + def edit? = pode?('admin.configuracoes_editar') + def update? = edit? end diff --git a/app/policies/consolidacao_policy.rb b/app/policies/consolidacao_policy.rb index 5104f43..90f2193 100644 --- a/app/policies/consolidacao_policy.rb +++ b/app/policies/consolidacao_policy.rb @@ -1,20 +1,42 @@ # app/policies/consolidacao_policy.rb class ConsolidacaoPolicy < ApplicationPolicy - def index? = user.pode_consolidar? - def arquivadas? = user.pode_consolidar? - def show? = user.pode_consolidar? - def create? = user.pode_consolidar? - def update? = user.pode_consolidar? && !record_finalizada_para_operador? - def destroy? = user.admin? || user.gerente? + def index? = pode?('consolidacao.ver') + def arquivadas? = index? + def show? = index? + def create? = pode?('consolidacao.criar') + def new? = create? - # Registrar/estornar pagamento: mesma régua de arquivar/excluir. - def registrar_pagamento? = user.admin? || user.gerente? - def cancelar_pagamento? = registrar_pagamento? + # Editar uma consolidação JÁ FINALIZADA é outra permissão: mexer depois do + # fechamento muda o valor que o motorista vai receber. Antes isso era "operador + # 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 - # Operador não mexe em consolidação finalizada - def record_finalizada_para_operador? - record.respond_to?(:finalizada?) && record.finalizada? && user.operador? + def record_finalizada? + record.respond_to?(:finalizada?) && record.finalizada? end end diff --git a/app/policies/contato_policy.rb b/app/policies/contato_policy.rb index aa0adbb..7088c8f 100644 --- a/app/policies/contato_policy.rb +++ b/app/policies/contato_policy.rb @@ -1,20 +1,24 @@ # app/policies/contato_policy.rb # -# Contatos e grupos são a lista de quem recebe mensagem em nome da empresa — -# admin e gerente administram, operador não. -# -# ⚠️ Cada action nova do controller precisa de um `?` aqui, senão o -# `authorize` estoura. +# Contatos e grupos (GrupoContatoPolicy herda esta) — quem RECEBE as mensagens. class ContatoPolicy < ApplicationPolicy - def index? = admin_ou_gerente? - def show? = admin_ou_gerente? - def create? = admin_ou_gerente? - def new? = create? - def update? = admin_ou_gerente? - def edit? = update? - def destroy? = admin? + def index? = pode?('notificacao.contatos') + def show? = index? + + def create? = pode?('notificacao.contatos_gerenciar') + def new? = create? + def update? = create? + def edit? = update? + + # 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 - def resolve = scope.all + def resolve + user.pode?('notificacao.contatos') ? scope.all : scope.none + end end end diff --git a/app/policies/dashboard_policy.rb b/app/policies/dashboard_policy.rb index d3591de..f3687c5 100644 --- a/app/policies/dashboard_policy.rb +++ b/app/policies/dashboard_policy.rb @@ -1,15 +1,17 @@ # app/policies/dashboard_policy.rb -class DashboardPolicy < Struct.new(:user, :dashboard) - def metricas? - user.admin? || user.gerente? || user.operador? - end +# +# Policy headless (`authorize :dashboard, :x?`). Deixou de ser Struct para herdar +# o `pode?` da ApplicationPolicy. +class DashboardPolicy < ApplicationPolicy + def ver? = pode?('dashboard.ver') + def index? = ver? - def index? - user.admin? || user.gerente? - end + # Custos, pagamentos, relatório financeiro e entregas em aberto. Era o buraco + # do `externo`: o controller usava skip_authorization e ele via tudo. + def financeiro? = pode?('dashboard.financeiro') + def pendentes? = financeiro? - # Dashboard de operações: todos menos motorista (mesma regra do dashboard HTML). - def operacoes? - !user.motorista? - end + def operacoes? = pode?('dashboard.operacoes') + def exportar? = pode?('dashboard.exportar') + def metricas? = pode?('dashboard.metricas') end diff --git a/app/policies/edicao_lancamento_policy.rb b/app/policies/edicao_lancamento_policy.rb index 5ab7ed7..c03dd32 100644 --- a/app/policies/edicao_lancamento_policy.rb +++ b/app/policies/edicao_lancamento_policy.rb @@ -1,16 +1,11 @@ # app/policies/edicao_lancamento_policy.rb # -# Correção de lançamento grava DIRETO na base de produção do SimpliRoute — -# operação sensível e irreversível. Restrita a admin. -# Policy "headless": autorizada com o símbolo :edicao_lancamento (sem model). +# Policy headless (`authorize :edicao_lancamento`). Escreve na API do SimpliRoute: +# altera o dado de ORIGEM do rastreio, então é chave própria. class EdicaoLancamentoPolicy < ApplicationPolicy - # ⚠️ Uma entrada por action do controller: o Pundit chama "?" e, se o - # método não existir, levanta NoMethodError (500) — foi o que aconteceu ao - # adicionar `carregar` sem passar por aqui. Ação nova no controller ⇒ linha - # nova aqui. - def show? = admin? - def buscar? = admin? - def carregar? = admin? - def atualizar? = admin? - def historico? = admin? + def show? = pode?('admin.edicao_lancamento') + def buscar? = show? + def carregar? = show? + def atualizar? = show? + def historico? = show? end diff --git a/app/policies/evento_notificacao_policy.rb b/app/policies/evento_notificacao_policy.rb index f648b18..2e3bfd0 100644 --- a/app/policies/evento_notificacao_policy.rb +++ b/app/policies/evento_notificacao_policy.rb @@ -1,23 +1,25 @@ # 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 - def index? = admin? - def show? = admin? - def create? = admin? - def new? = create? - def update? = admin? - def edit? = update? - def disparar? = admin? + def index? = pode?('notificacao.eventos') + def show? = index? - # Evento de sistema não se apaga: o código dispara por `chave` e ficaria - # chamando no vazio. A tela oferece desativar. + def create? = pode?('notificacao.eventos_gerenciar') + 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? - admin? && record.respond_to?(:apagavel?) && record.apagavel? + create? && record.respond_to?(:apagavel?) && record.apagavel? end class Scope < Scope - def resolve = scope.all + def resolve + user.pode?('notificacao.eventos') ? scope.all : scope.none + end end end diff --git a/app/policies/notificacao_envio_policy.rb b/app/policies/notificacao_envio_policy.rb index 852fd1d..eb50e2a 100644 --- a/app/policies/notificacao_envio_policy.rb +++ b/app/policies/notificacao_envio_policy.rb @@ -1,12 +1,13 @@ # app/policies/notificacao_envio_policy.rb # -# Log de envios: o corpo das mensagens passa por aqui, então leitura é de -# admin/gerente. Nada é editável — o log é registro, não cadastro. +# Histórico do que saiu (e do que falhou). class NotificacaoEnvioPolicy < ApplicationPolicy - def index? = admin_ou_gerente? - def show? = admin_ou_gerente? + def index? = pode?('notificacao.envios') + def show? = index? class Scope < Scope - def resolve = scope.all + def resolve + user.pode?('notificacao.envios') ? scope.all : scope.none + end end end diff --git a/app/policies/perfil_acesso_policy.rb b/app/policies/perfil_acesso_policy.rb new file mode 100644 index 0000000..de040db --- /dev/null +++ b/app/policies/perfil_acesso_policy.rb @@ -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 diff --git a/app/policies/planilha_simpli_route_policy.rb b/app/policies/planilha_simpli_route_policy.rb index 69adf9b..d5d233a 100644 --- a/app/policies/planilha_simpli_route_policy.rb +++ b/app/policies/planilha_simpli_route_policy.rb @@ -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. -# Liberada para admin e gerente (quem prepara as cargas). Policy "headless" -# (autorizada com o símbolo :planilha_simpliroute). +# Policy headless (`authorize :planilha_simpli_route`). class PlanilhaSimpliRoutePolicy < ApplicationPolicy - def show? = admin_ou_gerente? - def baixar? = admin_ou_gerente? + def show? = pode?('admin.planilha_simpli_route') + def baixar? = show? end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 09dcddd..8c54205 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -1,44 +1,35 @@ # app/policies/user_policy.rb class UserPolicy < ApplicationPolicy - def index? - admin_ou_gerente? - end + def index? = pode?('admin.usuarios') - def show? - admin? || record == user - end + # Cada um enxerga a própria ficha (é assim que a tela de perfil pessoal + # funciona), além de quem administra usuários. + def show? = pode?('admin.usuarios') || record == user - def create? - admin? - end + def create? = pode?('admin.usuarios_gerenciar') + def new? = create? - def new? - create? - end + # ⚠️ `record == user` CONTINUA aqui de propósito: é o que permite alguém editar + # o próprio cadastro (nome, senha). O que NÃO pode é essa mesma pessoa mudar o + # 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? - admin? || record == user - end + def destroy? = create? && record != user + def toggle_ativo? = create? && record != user - def edit? - update? - end - - def destroy? - admin? && record != user - end - - def toggle_ativo? - admin? - end - - # admin? e admin_ou_gerente? vêm da ApplicationPolicy. + # Pode mexer no TIPO DE CONTA e no PERFIL DE ACESSO de alguém? Nunca no + # próprio: senão qualquer usuário se promove editando a própria ficha. + def alterar_acesso? = pode?('admin.usuarios_gerenciar') && record != user class Scope < Scope 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? scope.all - elsif user.gerente? + elsif user.pode?('admin.usuarios') scope.where.not(role: :admin) else scope.where(id: user.id) diff --git a/app/policies/variavel_personalizada_policy.rb b/app/policies/variavel_personalizada_policy.rb new file mode 100644 index 0000000..a39b44c --- /dev/null +++ b/app/policies/variavel_personalizada_policy.rb @@ -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 diff --git a/app/policies/whatsapp_sessao_policy.rb b/app/policies/whatsapp_sessao_policy.rb index 43efe7a..571b7d0 100644 --- a/app/policies/whatsapp_sessao_policy.rb +++ b/app/policies/whatsapp_sessao_policy.rb @@ -1,13 +1,9 @@ # app/policies/whatsapp_sessao_policy.rb # -# Parear o WhatsApp por QR é ligar o número da empresa a este servidor, e -# desconectar derruba o canal para todo mundo. Só ADM. +# Policy headless (`authorize :whatsapp_sessao`). Parear o WhatsApp é o que +# habilita mandar mensagem em nome da empresa. class WhatsappSessaoPolicy < ApplicationPolicy - def show? = admin? - def desconectar? = admin? - def testar? = admin? - - class Scope < Scope - def resolve = scope.all - end + def show? = pode?('notificacao.whatsapp_sessao') + def desconectar? = show? + def testar? = show? end diff --git a/app/services/notificacao/catalogo_variaveis.rb b/app/services/notificacao/catalogo_variaveis.rb new file mode 100644 index 0000000..af6306a --- /dev/null +++ b/app/services/notificacao/catalogo_variaveis.rb @@ -0,0 +1,196 @@ +# app/services/notificacao/catalogo_variaveis.rb +# +# TODAS as variáveis que o editor de mensagem oferece, de quatro origens: +# +# :contexto — vêm com o disparo (motorista, consolidação, valor…). Só têm +# valor nos gatilhos que as produzem; em outro gatilho saem +# vazias, e a tela avisa isso. +# :sistema — não dependem de contexto nenhum (empresa, data, hora, mês). +# :dados — CONSULTAM O BANCO na hora do envio (entregas do mês, total a +# pagar…). Resolvidas sob demanda: só a variável realmente +# escrita no template vira query. +# :personalizada — cadastradas pelo ADM (VariavelPersonalizada), valor fixo. +# +# Antes, `Notificacao::Variaveis::POR_GATILHO` amarrava a lista ao gatilho: o ADM +# via 6 variáveis e nada mais, e qualquer texto com número (“entregas do mês”) +# precisava de deploy. Aqui a lista é aberta — e o que não se aplica é MARCADO em +# vez de escondido. +# +# ⚠️ Um `resolver` NUNCA pode levantar: notificação não derruba fechamento. O +# ResolvedorVariaveis engole e devolve vazio. +module Notificacao + module CatalogoVariaveis + ORIGENS = { + contexto: 'Do que aconteceu', + sistema: 'Empresa e data', + dados: 'Números do sistema', + personalizada: 'Suas variáveis' + }.freeze + + # ── Sistema: sempre disponíveis ─────────────────────────── + SISTEMA = { + 'empresa' => { + descricao: 'Nome da empresa', amostra: 'Reem Transportes', + resolver: -> { Configuracao.valor('empresa_nome').presence || 'Reem Transportes' } + }, + 'data' => { + descricao: 'Data de hoje (dd/mm/aaaa)', amostra: '26/08/2026', + resolver: -> { Date.current.strftime('%d/%m/%Y') } + }, + 'hora' => { + descricao: 'Hora do envio', amostra: '14:35', + resolver: -> { Time.current.strftime('%H:%M') } + }, + 'dia_semana' => { + descricao: 'Dia da semana por extenso', amostra: 'quarta-feira', + resolver: -> { I18n.l(Date.current, format: '%A') rescue Date::DAYNAMES[Date.current.wday] } + }, + 'mes' => { + descricao: 'Mês por extenso', amostra: 'agosto', + resolver: -> { I18n.l(Date.current, format: '%B') rescue Date::MONTHNAMES[Date.current.month] } + }, + 'mes_ano' => { + descricao: 'Mês/ano (mm/aaaa)', amostra: '08/2026', + resolver: -> { Date.current.strftime('%m/%Y') } + }, + 'link_sistema' => { + descricao: 'Endereço do sistema', amostra: 'https://app.reemtransportes.com.br', + resolver: -> { Configuracao.valor('url_sistema').presence || ENV.fetch('APP_URL', '') } + } + }.freeze + + # ── Contexto: quem produz é o gatilho ───────────────────── + # `gatilhos:` diz em quais a variável tem valor — é o que a tela usa para + # avisar "esta sai vazia aqui". + CONTEXTO = { + 'contato' => { descricao: 'Primeiro nome de quem recebe', amostra: 'Carlos', gatilhos: :todos }, + 'quem' => { descricao: 'Quem executou a ação no sistema', amostra: 'Ana Paula', gatilhos: :todos }, + 'motorista' => { descricao: 'Nome do motorista', amostra: 'Carlos Matheus Pimentel' }, + 'consolidacao' => { descricao: 'Nome da consolidação', amostra: 'UBS NORTE 01–14/08' }, + 'periodo' => { descricao: 'Período do fechamento', amostra: '01/08/2026 a 14/08/2026' }, + 'valor' => { descricao: 'Valor do motorista', amostra: 'R$ 5.060,00' }, + 'entregas' => { descricao: 'Entregas atendidas no fechamento', amostra: '280' }, + 'link_painel' => { descricao: 'Link do painel do motorista', amostra: 'https://app.reem.com.br/motorista' }, + 'o_que_mudou' => { descricao: 'O que foi alterado', amostra: 'Desconto de R$ 120,00 (avaria)' }, + 'operacao' => { descricao: 'Operação afetada', amostra: 'UBS NORTE AGO 2026' }, + 'nf' => { descricao: 'Nota fiscal', amostra: '85382' }, + 'entregues' => { descricao: 'Notas entregues (resumo)', amostra: '4.851' }, + 'recusas' => { descricao: 'Notas não entregues (resumo)', amostra: '122' }, + 'pendentes' => { descricao: 'Notas em aberto (resumo)', amostra: '0' }, + 'retentativas' => { descricao: 'Visitas além da primeira (resumo)', amostra: '4' }, + 'fora_operacao' => { descricao: 'Notas fora da operação (resumo)', amostra: '2' } + }.freeze + + # Quais variáveis de contexto cada gatilho realmente preenche. + POR_GATILHO = { + 'manual' => [], + 'consolidacao_finalizada' => %w[motorista consolidacao periodo valor entregas link_painel], + 'pagamento_efetuado' => %w[motorista consolidacao periodo valor entregas link_painel], + 'valor_alterado' => %w[motorista consolidacao periodo valor entregas link_painel o_que_mudou], + 'operacao_alterada' => %w[operacao o_que_mudou nf], + 'agendado' => %w[operacao periodo entregues recusas pendentes retentativas fora_operacao] + }.freeze + + # ── Dados: consultam o banco na hora do envio ───────────── + # Cada uma responde a uma pergunta que o ADM hoje só conseguiria escrevendo à + # mão — e que ficaria desatualizada na mensagem seguinte. + DADOS = { + 'entregas_mes' => { + descricao: 'Entregas atendidas no mês corrente', amostra: '4.977', + resolver: -> { Entrega.atendidas.da_conta_gade.no_periodo_checkout(Date.current.beginning_of_month, Date.current).count } + }, + 'entregas_hoje' => { + descricao: 'Entregas atendidas hoje', amostra: '213', + resolver: -> { Entrega.atendidas.da_conta_gade.no_periodo_checkout(Date.current, Date.current).count } + }, + 'motoristas_ativos_mes' => { + descricao: 'Motoristas com entrega no mês', amostra: '17', + resolver: -> { Entrega.motoristas_ativos(inicio: Date.current.beginning_of_month, fim: Date.current).size } + }, + 'consolidacoes_abertas' => { + descricao: 'Consolidações em rascunho', amostra: '3', + resolver: -> { Consolidacao.ativas.rascunho.count } + }, + 'total_a_pagar_mes' => { + descricao: 'Total a pagar no mês (fechado e não pago)', amostra: 'R$ 72.508,00', + resolver: lambda { + totais = Analytics::TotaisPorMotorista.new(inicio: Date.current.beginning_of_month, + fim: Date.current.end_of_month) + Formatador.moeda(totais.total_pendente) + } + }, + 'total_pago_mes' => { + descricao: 'Total já pago no mês', amostra: 'R$ 21.300,00', + resolver: lambda { + totais = Analytics::TotaisPorMotorista.new(inicio: Date.current.beginning_of_month, + fim: Date.current.end_of_month) + Formatador.moeda(totais.total_pago) + } + }, + 'total_consolidado_mes' => { + descricao: 'Total consolidado no mês', amostra: 'R$ 93.808,00', + resolver: lambda { + totais = Analytics::TotaisPorMotorista.new(inicio: Date.current.beginning_of_month, + fim: Date.current.end_of_month) + Formatador.moeda(totais.total_geral) + } + } + }.freeze + + module_function + + # Todas as variáveis conhecidas, incluindo as que o ADM cadastrou. + # { chave => { origem:, descricao:, amostra: } } + def todas + base = {} + SISTEMA.each { |c, m| base[c] = { origem: :sistema, descricao: m[:descricao], amostra: m[:amostra] } } + CONTEXTO.each { |c, m| base[c] = { origem: :contexto, descricao: m[:descricao], amostra: m[:amostra] } } + DADOS.each { |c, m| base[c] = { origem: :dados, descricao: m[:descricao], amostra: m[:amostra] } } + personalizadas.each do |v| + base[v.chave] = { origem: :personalizada, descricao: v.descricao.presence || 'Sua variável', amostra: v.valor } + end + base + end + + # A variável tem valor NESTE gatilho? Contexto de outro gatilho sai vazio — + # a tela mostra, em vez de esconder a variável (foi o pedido: liberdade com + # aviso, não lista curta). + def aplicavel?(chave, gatilho) + meta = CONTEXTO[chave.to_s] + return true if meta.nil? # sistema/dados/personalizada: sempre + return true if meta[:gatilhos] == :todos + + Array(POR_GATILHO[gatilho.to_s]).include?(chave.to_s) + end + + def origem(chave) + todas.dig(chave.to_s, :origem) + end + + def existe?(chave) = todas.key?(chave.to_s) + + # Agrupadas para a tela do editor, na ordem de ORIGENS. + def por_origem(gatilho = nil) + agrupadas = ORIGENS.keys.index_with { |o| [] } + todas.each do |chave, meta| + agrupadas[meta[:origem]] << meta.merge(chave: chave, aplicavel: gatilho.nil? || aplicavel?(chave, gatilho)) + end + agrupadas + end + + # Contexto do PREVIEW: valores de amostra, nunca de envio real. + def amostra(gatilho = nil) + todas.filter_map { |chave, meta| + [chave, meta[:amostra].to_s] if gatilho.nil? || aplicavel?(chave, gatilho) + }.to_h + end + + def personalizadas + return [] unless VariavelPersonalizada.table_exists? + + VariavelPersonalizada.ativas.to_a + rescue StandardError + [] + end + end +end diff --git a/app/services/notificacao/despachante.rb b/app/services/notificacao/despachante.rb index 841cb27..9e3f80e 100644 --- a/app/services/notificacao/despachante.rb +++ b/app/services/notificacao/despachante.rb @@ -93,7 +93,25 @@ module Notificacao # string — o custo por destinatário é irrelevante perto do envio em si. def dados_para(contato, user) 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 # O template do editor vence; sem template utilizável, cai no texto que o diff --git a/app/services/notificacao/formatador.rb b/app/services/notificacao/formatador.rb new file mode 100644 index 0000000..96e9472 --- /dev/null +++ b/app/services/notificacao/formatador.rb @@ -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 diff --git a/app/services/notificacao/gatilhos.rb b/app/services/notificacao/gatilhos.rb index bd89e21..7cf8f8a 100644 --- a/app/services/notificacao/gatilhos.rb +++ b/app/services/notificacao/gatilhos.rb @@ -121,13 +121,8 @@ module Notificacao nil end - # Moeda BR com separador de milhar. Duplica o helper do NotificacaoService de - # propósito: este módulo é chamado de dentro de models e jobs, onde puxar o - # service inteiro só para formatar número seria acoplamento à toa. - def moeda(valor) - inteiro, decimais = format('%.2f', valor.to_f).split('.') - inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse - "R$ #{inteiro},#{decimais}" - end + # Formatação vive em Notificacao::Formatador (era copiada aqui e no + # NotificacaoService; com o catálogo de variáveis seriam três cópias). + def moeda(valor) = Formatador.moeda(valor) end end diff --git a/app/services/notificacao/resolvedor_variaveis.rb b/app/services/notificacao/resolvedor_variaveis.rb new file mode 100644 index 0000000..7fbc362 --- /dev/null +++ b/app/services/notificacao/resolvedor_variaveis.rb @@ -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 diff --git a/app/services/notificacao_service.rb b/app/services/notificacao_service.rb index 67b72b4..88b1157 100644 --- a/app/services/notificacao_service.rb +++ b/app/services/notificacao_service.rb @@ -181,10 +181,6 @@ class NotificacaoService "#{evt[:cta]} https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista" end - # Moeda BR com separador de milhar: R$ 1.234,56 - def moeda(valor) - inteiro, decimais = format('%.2f', valor.to_f).split('.') - inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse - "R$ #{inteiro},#{decimais}" - end + # Ver Notificacao::Formatador — implementação única. + def moeda(valor) = Notificacao::Formatador.moeda(valor) end diff --git a/app/views/admin/mensagem_templates/edit.html.erb b/app/views/admin/mensagem_templates/edit.html.erb index a6bf97a..bccbf0a 100644 --- a/app/views/admin/mensagem_templates/edit.html.erb +++ b/app/views/admin/mensagem_templates/edit.html.erb @@ -82,22 +82,54 @@ <%# ── 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. %> +
+

Emojis

+
+ <% %w[✅ ❌ ⚠️ 📌 📢 💰 💵 🧾 📅 🕒 🚚 📦 📍 👤 👍 🙏 ⭐ 🔴 🟢 🟡 ➡️ 📄 📷 🔔 ✍️ 😀 🎉].each do |emoji| %> + + <% end %> +
+
+

Variáveis

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

-
- <% @variaveis.each do |nome, meta| %> - - <% end %> -
+ + <%# 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? %> +

+ <%= Notificacao::CatalogoVariaveis::ORIGENS[origem] %> +

+
+ <% itens.each do |v| %> + + <% end %> +
+ <% end %> + +

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

@@ -312,20 +344,29 @@ }); } - // ── Variáveis: insere no cursor do último campo focado ───── + // ── Inserção no cursor do último campo focado ────────────── + // Serve para variável e para emoji: a mecânica é a mesma (recortar o valor + // na posição do cursor e reemitir `input` para o JSON do editor atualizar). + function inserirNoCampo(texto) { + if (!ultimoCampo) { alert('Clique primeiro no campo onde o texto deve entrar.'); return; } + var ini = ultimoCampo.selectionStart != null ? ultimoCampo.selectionStart : ultimoCampo.value.length; + var fim = ultimoCampo.selectionEnd != null ? ultimoCampo.selectionEnd : ini; + ultimoCampo.value = ultimoCampo.value.slice(0, ini) + texto + ultimoCampo.value.slice(fim); + ultimoCampo.dispatchEvent(new Event('input', { bubbles: true })); + ultimoCampo.focus(); + ultimoCampo.selectionStart = ultimoCampo.selectionEnd = ini + texto.length; + } + document.querySelectorAll('.chip-variavel').forEach(function (chip) { chip.addEventListener('click', function () { - var texto = '{{' + chip.dataset.variavel + '}}'; - if (!ultimoCampo) { alert('Clique primeiro no campo onde a variável deve entrar.'); return; } - var ini = ultimoCampo.selectionStart != null ? ultimoCampo.selectionStart : ultimoCampo.value.length; - var fim = ultimoCampo.selectionEnd != null ? ultimoCampo.selectionEnd : ini; - ultimoCampo.value = ultimoCampo.value.slice(0, ini) + texto + ultimoCampo.value.slice(fim); - ultimoCampo.dispatchEvent(new Event('input', { bubbles: true })); - ultimoCampo.focus(); - ultimoCampo.selectionStart = ultimoCampo.selectionEnd = ini + texto.length; + inserirNoCampo('{{' + chip.dataset.variavel + '}}'); }); }); + document.querySelectorAll('.chip-emoji').forEach(function (chip) { + chip.addEventListener('click', function () { inserirNoCampo(chip.dataset.emoji); }); + }); + // ── Preview: renderizado no servidor, pelo mesmo código do envio ── var token = document.querySelector('meta[name="csrf-token"]'); document.getElementById('btn-preview').addEventListener('click', function () { diff --git a/app/views/admin/perfis_acesso/_form.html.erb b/app/views/admin/perfis_acesso/_form.html.erb new file mode 100644 index 0000000..0ce7ff5 --- /dev/null +++ b/app/views/admin/perfis_acesso/_form.html.erb @@ -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? %> +
+

<%= icone :erro, cor: 'text-red-400' %> Não foi possível salvar

+
    + <% perfil.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %> +
+
+ <% end %> + + <%# ── Identificação ──────────────────────────────────── %> +
+
+ <%= 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]' %> +
+ +
+ <%= 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]' %> +
+ +
+
+ <%= f.label :ativo, 'Perfil ativo', class: 'block text-sm font-medium text-gray-300' %> +

Desativado, quem estiver nele perde os acessos marcados aqui.

+
+ <%= f.check_box :ativo, class: 'w-5 h-5 accent-orange-500' %> +
+ + <% if perfil.sistema? %> +

+ <%= icone :alerta, cor: 'text-amber-400' %> Perfil que vem com o sistema: pode ser editado e desativado, mas não excluído. +

+ <% end %> +
+ + <%# ── 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| %> +
+
+

<%= Permissao.grupo_label(grupo) %>

+ <%= itens.count { |chave, _| marcadas.include?(chave) } %> de <%= itens.size %> +
+ +
+ <% itens.each do |chave, cfg| %> + + <% end %> +
+
+ <% end %> + +
+ <%= 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' %> +
+<% end %> diff --git a/app/views/admin/perfis_acesso/edit.html.erb b/app/views/admin/perfis_acesso/edit.html.erb new file mode 100644 index 0000000..7452162 --- /dev/null +++ b/app/views/admin/perfis_acesso/edit.html.erb @@ -0,0 +1,10 @@ +<% content_for :title, "Perfil #{@perfil.nome}" %> +
+

+ <%= icone :usuarios, espaco: false %> <%= @perfil.nome %> +

+

+ <%= @perfil.users.count %> usuário(s) neste perfil — o que você mudar aqui vale para todos eles. +

+ <%= render 'form', perfil: @perfil %> +
diff --git a/app/views/admin/perfis_acesso/index.html.erb b/app/views/admin/perfis_acesso/index.html.erb new file mode 100644 index 0000000..215ce04 --- /dev/null +++ b/app/views/admin/perfis_acesso/index.html.erb @@ -0,0 +1,70 @@ +<%# app/views/admin/perfis_acesso/index.html.erb %> +<% content_for :title, 'Perfis de acesso' %> + +
+
+

+ <%= icone :usuarios, espaco: false %> Perfis de acesso +

+

+ O perfil define o que a pessoa enxerga e pode fazer. Mudou a regra, muda aqui — vale para todos do perfil. +

+
+ <%= 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 %> +
+ +<% if @perfis.any? %> +
+ <% @perfis.each do |perfil| %> +
+
+
+
+

<%= perfil.nome %>

+ <% if perfil.sistema? %> + Do sistema + <% end %> + <% unless perfil.ativo? %> + Desativado + <% end %> + <% if perfil.vazio? %> + Sem nenhum acesso + <% end %> +
+ <% if perfil.descricao.present? %> +

<%= perfil.descricao %>

+ <% end %> +

+ <%= icone :pessoa %> <%= perfil.users.size %> usuário(s) + · <%= icone :chave %> <%= perfil.total_permissoes %> de <%= Permissao.chaves.size %> permissões +

+
+ +
+ <%= 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 %> +
+
+
+ <% end %> +
+<% else %> +
+

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

+

Nenhum perfil cadastrado ainda.

+

Enquanto não houver perfil, cada pessoa acessa conforme o tipo de conta dela.

+
+<% end %> diff --git a/app/views/admin/perfis_acesso/new.html.erb b/app/views/admin/perfis_acesso/new.html.erb new file mode 100644 index 0000000..fb64b19 --- /dev/null +++ b/app/views/admin/perfis_acesso/new.html.erb @@ -0,0 +1,8 @@ +<% content_for :title, 'Novo perfil de acesso' %> +
+

+ <%= icone :usuarios, espaco: false %> Novo perfil de acesso +

+

Marque o que este perfil enxerga. Você atribui o perfil a cada pessoa na tela de Usuários.

+ <%= render 'form', perfil: @perfil %> +
diff --git a/app/views/admin/usuarios/_form.html.erb b/app/views/admin/usuarios/_form.html.erb index d0f8573..aeb2400 100644 --- a/app/views/admin/usuarios/_form.html.erb +++ b/app/views/admin/usuarios/_form.html.erb @@ -40,16 +40,46 @@ placeholder: 'João da Silva' %> - <%# Perfil %> -
- <%= f.label :role, 'Perfil de acesso', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> - <%= f.select :role, - options_for_select(role_options_for_select, usuario.role), - {}, - class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white - focus:outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316] - transition-colors cursor-pointer' %> -
+ <%# 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? %> +
+ <%= f.label :role, 'Tipo de conta', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> + <%= f.select :role, + options_for_select(role_options_for_select, usuario.role), + {}, + class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + focus:outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316] + transition-colors cursor-pointer' %> +
+ +
+ <%= 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' %> +

+ Define o que a pessoa enxerga. + <%= link_to 'Gerenciar perfis', admin_perfis_path, class: 'text-orange-500 hover:underline' %> +

+
+ <% else %> +
+

Tipo de conta

+

<%= usuario.role_label %>

+

Só quem administra usuários altera tipo de conta e perfil de acesso.

+
+ <% end %> <%# Ativo toggle %>
@@ -142,10 +172,16 @@ const fieldsPin = document.getElementById('fields-pin'); if (!roleSelect || !fieldsEmail || !fieldsPin) return; + // Motorista não usa perfil de acesso: o que ele enxerga é o painel próprio, + // governado pelo tipo de conta. Deixar o select à mostra sugeriria que dá + // para liberar telas administrativas para ele. + const campoPerfil = document.getElementById('campo-perfil-acesso'); + function toggleFields() { const motorista = roleSelect.value === 'motorista'; fieldsEmail.classList.toggle('hidden', motorista); fieldsPin.classList.toggle('hidden', !motorista); + if (campoPerfil) campoPerfil.classList.toggle('hidden', motorista); } roleSelect.addEventListener('change', toggleFields); diff --git a/app/views/admin/usuarios/index.html.erb b/app/views/admin/usuarios/index.html.erb index 1c0e520..d68d9dd 100644 --- a/app/views/admin/usuarios/index.html.erb +++ b/app/views/admin/usuarios/index.html.erb @@ -29,7 +29,8 @@ Nome E-mail / PIN - Perfil + Tipo de conta + Perfil de acesso Status Ações @@ -56,6 +57,22 @@ <%= badge_role(usuario.role) %> + <%# 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. %> + + <% if usuario.motorista? %> + + <% elsif usuario.perfil_acesso %> + <%= usuario.perfil_acesso.nome %> + <% unless usuario.perfil_acesso.ativo? %> + perfil desativado + <% end %> + <% else %> + Padrão do tipo de conta + <% end %> + <%= badge_status_usuario(usuario.ativo?) %> diff --git a/app/views/admin/variaveis_personalizadas/_form.html.erb b/app/views/admin/variaveis_personalizadas/_form.html.erb new file mode 100644 index 0000000..8e9275f --- /dev/null +++ b/app/views/admin/variaveis_personalizadas/_form.html.erb @@ -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? %> +
+
    + <% variavel.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %> +
+
+ <% end %> + +
+
+ <%= 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]' %> +

+ Só letras minúsculas, números e _. Na mensagem você escreve + {{<%= variavel.chave.presence || 'telefone_suporte' %>}}. +

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

É este texto que aparece no lugar da variável, em toda mensagem que a usar.

+
+ +
+ <%= 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]' %> +
+ +
+
+ <%= f.label :ativo, 'Variável ativa', class: 'block text-sm font-medium text-gray-300' %> +

Desativada, some do editor e sai vazia nas mensagens que já a usam.

+
+ <%= f.check_box :ativo, class: 'w-5 h-5 accent-orange-500' %> +
+
+ +
+ <%= 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' %> +
+<% end %> diff --git a/app/views/admin/variaveis_personalizadas/edit.html.erb b/app/views/admin/variaveis_personalizadas/edit.html.erb new file mode 100644 index 0000000..89d6b46 --- /dev/null +++ b/app/views/admin/variaveis_personalizadas/edit.html.erb @@ -0,0 +1,8 @@ +<% content_for :title, "Variável #{@variavel.chave}" %> +
+

+ <%= icone :tag, espaco: false %> <%= @variavel.marcador %> +

+

O novo valor passa a valer no próximo disparo de toda mensagem que usa esta variável.

+ <%= render 'form', variavel: @variavel %> +
diff --git a/app/views/admin/variaveis_personalizadas/index.html.erb b/app/views/admin/variaveis_personalizadas/index.html.erb new file mode 100644 index 0000000..4a582b6 --- /dev/null +++ b/app/views/admin/variaveis_personalizadas/index.html.erb @@ -0,0 +1,76 @@ +<% content_for :title, 'Variáveis de mensagem' %> + +
+
+

<%= icone :tag, espaco: false %> Variáveis de mensagem

+

+ Escreva {{assim}} no texto e o sistema troca pelo valor na hora do envio. +

+
+ <%= 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 %> +
+ +<%# ── As suas ─────────────────────────────────────────── %> +<% if @variaveis.any? %> +
+ <% @variaveis.each do |v| %> +
+
+
+ <%= v.marcador %> + <% unless v.ativo? %> + Desativada + <% end %> +
+

<%= v.valor %>

+ <% if v.descricao.present? %> +

<%= v.descricao %>

+ <% end %> +
+
+ <%= 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 %> +
+
+ <% end %> +
+<% else %> +
+

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

+

Você ainda não criou nenhuma variável.

+

Boas primeiras: telefone do suporte, horário de atendimento, endereço da base.

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

Já disponíveis no sistema

+

Use à vontade nas mensagens — não precisam ser cadastradas.

+ +
+ <% @do_sistema.each do |origem, itens| %> + <% next if itens.empty? %> +
+
+

<%= Notificacao::CatalogoVariaveis::ORIGENS[origem] %>

+
+
+ <% itens.each do |v| %> +
+ {{<%= v[:chave] %>}} +

<%= v[:descricao] %>

+
+ <% end %> +
+
+ <% end %> +
diff --git a/app/views/admin/variaveis_personalizadas/new.html.erb b/app/views/admin/variaveis_personalizadas/new.html.erb new file mode 100644 index 0000000..eb750e0 --- /dev/null +++ b/app/views/admin/variaveis_personalizadas/new.html.erb @@ -0,0 +1,6 @@ +<% content_for :title, 'Nova variável' %> +
+

<%= icone :tag, espaco: false %> Nova variável

+

Um texto fixo que você reaproveita em qualquer mensagem — mudou aqui, mudou em todas.

+ <%= render 'form', variavel: @variavel %> +
diff --git a/app/views/consolidacao_entregas/revisar.html.erb b/app/views/consolidacao_entregas/revisar.html.erb index b4c93d9..6fb1a2c 100644 --- a/app/views/consolidacao_entregas/revisar.html.erb +++ b/app/views/consolidacao_entregas/revisar.html.erb @@ -80,7 +80,7 @@
<%= c.desconto? ? '-' : '' %><%= moeda(c.valor_aplicado) %> - <% if c.manual? && !(@consolidacao.finalizada? && current_user.operador?) %> + <% if c.manual? && !(@consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada')) %> <%= button_to remover_apontamento_consolidacao_consolidacao_entregas_path(@consolidacao, id: c.id, motorista: @motorista), method: :delete, form: { data: { turbo_confirm: 'Remover este apontamento manual?' } }, diff --git a/app/views/consolidacao_entregas/validar.html.erb b/app/views/consolidacao_entregas/validar.html.erb index 31eb1b9..f019502 100644 --- a/app/views/consolidacao_entregas/validar.html.erb +++ b/app/views/consolidacao_entregas/validar.html.erb @@ -56,7 +56,7 @@ <%# Fechar por veículo — filtra a tela por carro, mostra o estado (pronto/ consolidado/arquivado) e permite CONSOLIDAR o veículo selecionado. Só aparece quando o motorista usou mais de um veículo no período. %> - <% pode_fechar_veiculo = !(@consolidacao.finalizada? && current_user.operador?) %> + <% pode_fechar_veiculo = !(@consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada')) %> <% if @veiculos_status.size > 1 %>
@@ -196,7 +196,7 @@ controllers existentes são reaproveitados; só o gatilho mudou. Fica DENTRO da barra de ações (order-2) para não custar uma linha própria; os modais são `fixed`, então não afetam o layout da faixa. %> - <% unless @consolidacao.finalizada? && current_user.operador? %> + <% unless @consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada') %>
<% if @apontamentos.present? %> - <% pode_editar = !(@consolidacao.finalizada? && current_user.operador?) %> + <% pode_editar = !(@consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada')) %>

<%= icone :adicionar, espaco: false %> Apontamentos manuais

diff --git a/app/views/consolidacoes/wizard.html.erb b/app/views/consolidacoes/wizard.html.erb index b5c50cd..2dbb1e3 100644 --- a/app/views/consolidacoes/wizard.html.erb +++ b/app/views/consolidacoes/wizard.html.erb @@ -33,7 +33,7 @@ <%# Caixa de ferramentas do Passo 1 — concentra "Apontamento manual" (busca pela NF) e "Adicionar motorista" num único botão com menu suspenso. %> - <% unless @consolidacao.finalizada? && current_user.operador? %> + <% unless @consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada') %>
<%# Lista de motoristas %> - <% pode_arquivar_motorista = !(@consolidacao.finalizada? && current_user.operador?) %> + <% pode_arquivar_motorista = !(@consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada')) %>
<% @motoristas.each do |m| %> <%# Container relativo: o card é um link; o botão de arquivar fica FORA da diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 6d3aa67..18b88ed 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -106,7 +106,9 @@ <% end %> - <%# Imprimir relatório financeiro do período/operação atuais %> + <%# Imprimir relatório financeiro do período/operação atuais — só para quem + pode ver custo (permissão `dashboard.financeiro`). %> + <% if @ver_financeiro %> <%= link_to rotulo(:imprimir, 'Relatório', cor: nil), dashboard_relatorio_financeiro_path( inicio: @periodo_inicio.strftime('%Y-%m-%d'), @@ -114,6 +116,7 @@ operacoes: @operacao_filtro), data: { turbo: false }, class: 'pl-4 pr-4 py-2.5 bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-xl text-sm whitespace-nowrap' %> + <% end %>
@@ -147,6 +150,7 @@
<%# Card destacado: total consolidado e pago (consolidações finalizadas do período) %> + <% if @ver_financeiro %>

<%= icone :consolidado_pago, espaco: false %> Consolidado / Pago

<%= moeda(@fin_custo_total) %>

@@ -156,6 +160,7 @@ pago
+ <% end %> <%# Card 2: Total de entregas %>
@@ -203,6 +208,7 @@
<%# KPIs FINANCEIROS (consolidações finalizadas, já com filtro de operação) %> + <% if @ver_financeiro %>

<%= icone :dinheiro, espaco: false %> Custo total

@@ -225,6 +231,7 @@

pendente

+ <% end %> <%# GRÁFICO + MOTORISTAS (lado a lado no desktop) %>
@@ -244,7 +251,9 @@
- <%# 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 %>

Pagamentos por dia

@@ -273,6 +282,7 @@ <% end %>
<% end %> + <% end %>
<%# Top motoristas — DUAS ABAS sobre o mesmo ranking: @@ -320,6 +330,7 @@ + <% if @ver_financeiro %> <%# ── FINANCEIRO POR OPERAÇÃO ───────────────────────────── %>
@@ -472,7 +483,9 @@
- <%# Por operação %> + <% end %> + + <%# Por operação (contagem de entregas — não é custo, fica para todos) %> <% if @por_operacao.any? %>

<%= icone :operacao, espaco: false %> Entregas por Local

@@ -719,7 +732,9 @@ })(); -<%# 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 %> -<%# Rosca de pagamentos (pago x a pagar, por valor) %> +<%# Rosca de pagamentos (pago x a pagar, por valor) — mesmo bloco de permissão. %> -<%# ── 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 %> +<% end %> diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 5e6e8f7..31f641b 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -34,14 +34,17 @@ <%# Navegação %>