Compare commits

..

1 Commits

Author SHA256 Message Date
cb47d1c294 Merge pull request 'teste' (#5) from teste into main
Reviewed-on: #5
2026-07-03 10:52:51 -03:00
361 changed files with 56937 additions and 26167 deletions

View File

@@ -4,35 +4,10 @@
# ============================================================ # ============================================================
# Ambiente Rails # Ambiente Rails
# ⚠️ NO SERVIDOR DE PRODUÇÃO use `production` (modo development expõe páginas de RAILS_ENV=development
# erro com código-fonte/stack, o rack-mini-profiler e desliga otimizações).
# Use `development` só na sua máquina local.
RAILS_ENV=production
# Fuso horário do sistema operacional dentro dos containers.
# Já é o padrão no Dockerfile — só mexa se a operação mudar de estado.
# ⚠️ Isto define o FUSO, não a HORA. Se a data/hora do servidor está errada
# (atrasa, ou volta errada depois de reboot/deploy), o problema é o relógio
# do HOST: rode uma vez `sudo bash deploy/ntp-seguro.sh`.
TZ=America/Sao_Paulo
# ── Porta publicada no host ───────────────────────────────
# A porta DENTRO do container é sempre 3000; esta é a do servidor.
# Neste branch (teste) o padrão do docker-compose.yml JÁ É 3001, porque a 3000
# está ocupada por outro stack no mesmo NAS. Esta linha só é necessária para
# usar uma porta diferente da padrão.
# ⚠️ Tem que casar com o destino do proxy reverso (DSM / Cloudflare).
PORTA_APP=3001
# ── Banco de dados (seu PostgreSQL já existente) ───────────── # ── Banco de dados (seu PostgreSQL já existente) ─────────────
# ⚠️ SÓ O `DATABASE_URL` É LIDO. Este projeto não tem config/database.yml — o # Aponte DB_HOST para o IP ou hostname do seu servidor PostgreSQL
# Rails monta a conexão a partir desta URL, e nenhum código lê DB_HOST,
# DB_NAME, DB_USER ou DB_PASSWORD (elas ficam abaixo como documentação dos
# valores que compõem a URL). Se editar só as DB_*, nada muda.
# ⚠️ Senha com caractere especial precisa ser escapada na URL:
# @ → %40 # → %23 / → %2F : → %3A
# Deixar o valor de exemplo aqui derruba o boot com
# "URI::InvalidURIError: ... SEU_IP".
DATABASE_URL=postgresql://postgres:senha_segura@SEU_IP:5432/logistica_db DATABASE_URL=postgresql://postgres:senha_segura@SEU_IP:5432/logistica_db
DB_HOST=SEU_IP # ex: 192.168.1.100 ou db.gade.com.br DB_HOST=SEU_IP # ex: 192.168.1.100 ou db.gade.com.br
DB_PORT=5432 DB_PORT=5432
@@ -40,14 +15,6 @@ DB_NAME=logistica_db # nome do banco NOVO que o sistema vai criar
DB_USER=postgres DB_USER=postgres
DB_PASSWORD=senha_segura DB_PASSWORD=senha_segura
# ── SimpliRoute — API de escrita (edição de lançamentos pelo ADM) ─────
# A base de rastreio (DB_EXISTING_TABLE) é só LEITURA e sincroniza a partir
# do SimpliRoute. Para CORRIGIR um lançamento (status/motivo/comentário) o
# sistema grava direto na API do SimpliRoute usando este token.
# Pegue em: SimpliRoute → Configurações → API. NUNCA versione o token real.
SIMPLIROUTE_TOKEN=
SIMPLIROUTE_BASE_URL=https://api.simpliroute.com
# ── Tabela existente (apenas leitura — NÃO modificar) ──────── # ── Tabela existente (apenas leitura — NÃO modificar) ────────
DB_EXISTING_TABLE=db_reem_simplerout_2026 DB_EXISTING_TABLE=db_reem_simplerout_2026
DB_EXISTING_SCHEMA=public DB_EXISTING_SCHEMA=public
@@ -62,20 +29,6 @@ DB_EXISTING_ACCOUNT_ID=all
# Gere com: docker-compose exec app bundle exec rails secret # Gere com: docker-compose exec app bundle exec rails secret
SECRET_KEY_BASE=gere_com_rails_secret_e_cole_aqui SECRET_KEY_BASE=gere_com_rails_secret_e_cole_aqui
# Força HTTPS: redireciona HTTP→HTTPS e marca cookies como "Secure".
# Deixe "true" no servidor (atrás do Cloudflare). Em desenvolvimento local
# deixe vazio/false para não forçar https em localhost.
FORCE_SSL=true
# ══════════════════════════════════════════════════════════════
# NOTIFICAÇÕES (WhatsApp e e-mail)
#
# ⚠️ Estas variáveis são apenas FALLBACK. A configuração preferencial fica no
# BANCO e é editada pelo ADM em Configurações → Notificações e E-mail
# (/admin/configuracao_notificacao), valendo sem reiniciar o container.
# Enquanto a tela não for preenchida e ativada, valem os valores abaixo.
# ══════════════════════════════════════════════════════════════
# ── Twilio — WhatsApp (opcional, Fase 8) ───────────────────── # ── Twilio — WhatsApp (opcional, Fase 8) ─────────────────────
TWILIO_ACCOUNT_SID= TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN= TWILIO_AUTH_TOKEN=
@@ -88,42 +41,6 @@ SMTP_USERNAME=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_DOMAIN=gade.com.br SMTP_DOMAIN=gade.com.br
# ── Criptografia dos segredos gravados pela tela (opcional) ──
# A senha do SMTP e o token do Twilio salvos no banco são cifrados com uma
# chave derivada do SECRET_KEY_BASE. Se você trocar o SECRET_KEY_BASE, esses
# dois valores viram ilegíveis (o sistema volta a usar o .env e a tela avisa
# para redigitar). Defina NOTIFICACAO_SECRET com uma string longa e FIXA para
# desacoplar os segredos da rotação do SECRET_KEY_BASE.
# ⚠️ Se mudar depois de já ter salvo, os valores gravados também se perdem.
NOTIFICACAO_SECRET=
# ── App ─────────────────────────────────────────────────────── # ── App ───────────────────────────────────────────────────────
# ⚠️ SÓ O HOST, sem "https://". O código monta os links como APP_HOST=localhost:3000
# "https://#{APP_HOST}/motorista" (consolidacao_mailer.rb,
# notificacao_service.rb, gatilhos.rb): com o esquema aqui sai
# "https://https://..." e todo link de e-mail e de WhatsApp quebra.
# O docker-compose.yml já traz o host do ambiente como padrão — esta linha só
# é necessária para apontar para outro endereço.
APP_HOST=teste.reemtransportes.com.br
APP_NAME=Reem Logística APP_NAME=Reem Logística
# Hosts extras aceitos além do APP_HOST, separados por vírgula (só vale com
# RAILS_ENV=production, onde o Rails checa o cabeçalho Host).
# Use quando o sistema também for acessado por IP:porta na rede interna —
# sem isso o Rails responde "Blocked hosts: <ip>:<porta>".
HOSTS_PERMITIDOS=100.75.222.23
# ── WhatsApp (ponte própria — sessão pareada por QR code) ──────────────────
# Token compartilhado entre o Rails e o container `whatsapp`. Qualquer string
# longa e aleatória: `openssl rand -hex 32`. Sem ele a ponte recusa TUDO.
WHATSAPP_TOKEN=troque_por_uma_string_aleatoria_longa
# URL da ponte dentro da rede do compose (não mexa, a menos que troque o nome
# do serviço no docker-compose.yml).
# (o nome antigo `BAILEYS_URL` ainda é aceito, para .env já em produção)
WHATSAPP_URL=http://whatsapp:3001
# ── Erros na tela ────────────────────────────────────────────────────────
# Mostra a página de debug do Rails (parâmetros, SESSÃO, cookies, IP, trace).
# Deixe SEMPRE vazia/false em qualquer servidor: esse painel vai para a tela de
# quem provocou o erro. Ligue só na sua máquina, quando estiver depurando.
ERROS_DETALHADOS=false

71
.gitignore vendored Normal file
View File

@@ -0,0 +1,71 @@
# ============================
# CREDENCIAIS — NUNCA versionar
# ============================
.env
.env.local
.env.*.local
config/application.yml
config/database.yml
docker-compose.override.yml
*.pem
*.key
*.p12
config/master.key
config/credentials/*.key
# ============================
# Ferramentas de IA — não versionar
# ============================
.claude/
CLAUDE.md
.cursor/
.cursorrules
.aider*
.github/copilot-instructions.md
# Rails
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
/storage/*
!/storage/.keep
/public/assets
/public/packs
/public/packs-test
/.bundle
/vendor/bundle
# Node / NPM
/node_modules
/yarn-error.log
yarn-debug.log*
.yarn-integrity
package-lock.json
# Sprockets / Assets
/public/assets
# Docker
.env.docker
# IDEs
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# Byebug
.byebug_history
# RSpec
/spec/reports
/spec/examples.txt
/coverage
# Misc
*.orig
*.bak

View File

@@ -1,22 +1,6 @@
FROM ruby:3.2.2-slim FROM ruby:3.2.2-slim
# Fuso horário do container.
#
# POR QUE ISSO IMPORTA (caso real): a imagem sobe em UTC. O Rails até mostra a
# hora certa (`config.time_zone = "America/Sao_Paulo"`), mas TUDO que é do
# sistema operacional continuava 3h adiantado — timestamp de log, `date` nos
# scripts de bin/ e, o pior, o CRON: `every "*/30 8-18"` no config/schedule.rb
# rodava das 05h às 15h de Brasília, não das 08h às 18h.
#
# O cron do Debian lê `/etc/localtime`, não a variável TZ — por isso o symlink
# além do ENV. Os dois juntos cobrem processo Ruby e daemon de cron.
ENV TZ=America/Sao_Paulo
ENV DEBIAN_FRONTEND=noninteractive
# Dependências do sistema # Dependências do sistema
# tzdata: explícito de propósito. O zoneinfo já vem na imagem hoje, mas se um
# rebuild futuro pegar uma base enxuta o symlink acima quebra em silêncio e a
# hora volta a ficar errada — que é exatamente o bug que estamos fechando.
RUN apt-get update -qq && apt-get install -y \ RUN apt-get update -qq && apt-get install -y \
build-essential \ build-essential \
libpq-dev \ libpq-dev \
@@ -25,10 +9,6 @@ RUN apt-get update -qq && apt-get install -y \
curl \ curl \
git \ git \
libvips \ libvips \
cron \
tzdata \
&& ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime \
&& echo "$TZ" > /etc/timezone \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Diretório da app # Diretório da app
@@ -41,14 +21,9 @@ RUN bundle install --jobs 4 --retry 3
# Copia o restante do código # Copia o restante do código
COPY . . COPY . .
# Assets NÃO são pré-compilados aqui, e a linha comentada foi removida para # Pré-compila assets (em produção)
# ninguém "descomentar para resolver": o compose monta o projeto por cima # RUN bundle exec rails assets:precompile
# (`volumes: - ".:/app"`), então o public/assets gerado na imagem some no boot.
# Quem pré-compila é bin/docker-boot, com o código que está de fato rodando.
EXPOSE 3000 EXPOSE 3000
# O boot real (migrations, cron, servidor) mora em bin/docker-boot — ver os CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]
# comentários lá. CMD aqui é só o fallback de quem roda a imagem sem o compose.
# (via `bash` — ver o motivo no docker-compose.yml)
CMD ["bash", "bin/docker-boot"]

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,276 @@
<!DOCTYPE html>
<html lang="pt-BR" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-param" content="authenticity_token">
<meta name="csrf-token" content="G-9RRM76mXTElY86HJ18dlR93xoYQyO4uD1MzgEGhSZLg9p1i8wFt73pl0AHSNGSxXOBT0R5D0q_c_iovOAGZQ">
<title>Reem Logística</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
preto: '#0a0a0a',
'preto-card': '#1a1a1a',
laranja: '#f97316',
'laranja-escuro': '#ea580c',
branco: '#ffffff',
}
}
}
}
}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
/* Scrollbar tema escuro */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0a0a0a; }
::-webkit-scrollbar-thumb { background: #f97316; border-radius: 3px; }
/* Loading spinner laranja */
.spinner {
border: 3px solid #1a1a1a;
border-top-color: #f97316;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
<script type="importmap" data-turbo-track="reload">{
"imports": {
"application": "/assets/application-09caced1.js",
"@hotwired/turbo-rails": "/assets/turbo.min-86bf8853.js",
"@hotwired/stimulus": "/assets/stimulus.min-7ea3d58b.js",
"@hotwired/stimulus-loading": "/assets/stimulus-loading-25917588.js",
"controllers/application": "/assets/controllers/application-75a69c37.js",
"controllers": "/assets/controllers/index-dfefe98d.js",
"controllers/validacao_controller": "/assets/controllers/validacao_controller-3ce614b7.js"
}
}</script>
<link rel="modulepreload" href="/assets/application-09caced1.js">
<link rel="modulepreload" href="/assets/turbo.min-86bf8853.js">
<link rel="modulepreload" href="/assets/stimulus.min-7ea3d58b.js">
<link rel="modulepreload" href="/assets/stimulus-loading-25917588.js">
<link rel="modulepreload" href="/assets/controllers/application-75a69c37.js">
<link rel="modulepreload" href="/assets/controllers/index-dfefe98d.js">
<link rel="modulepreload" href="/assets/controllers/validacao_controller-3ce614b7.js">
<script type="module">import "application"</script>
</head>
<body class="bg-[#0a0a0a] text-white min-h-screen">
<div id="sidebar-overlay"
class="fixed inset-0 bg-black/70 z-30 hidden md:hidden"
onclick="toggleSidebar()"></div>
<aside id="sidebar"
class="fixed top-0 left-0 h-full w-64 bg-[#111111] border-r border-[#2a2a2a] z-40
transform -translate-x-full md:translate-x-0 transition-transform duration-300">
<div class="flex items-center gap-3 px-6 py-5 border-b border-[#2a2a2a]">
<div class="w-9 h-9 bg-orange-500 rounded-lg flex items-center justify-center font-black text-black text-lg">G</div>
<div>
<p class="font-bold text-white text-sm leading-tight">Reem Transporte</p>
<p class="text-orange-500 text-xs">Logística</p>
</div>
</div>
<div class="px-6 py-4 border-b border-[#2a2a2a]">
<p class="text-xs text-gray-500 mb-1">Logado como</p>
<p class="text-white font-semibold text-sm truncate">Administrador Gade</p>
<span class="inline-block mt-1 px-2 py-0.5 text-xs rounded-full
bg-orange-500 text-black">
Administrador
</span>
</div>
<nav class="px-3 py-4 space-y-1 flex-1 overflow-y-auto">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/dashboard">📊 Dashboard</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/consolidacoes">📦 Consolidações</a>
<div class="pt-4 mt-4 border-t border-[#2a2a2a]">
<p class="px-3 text-xs text-gray-600 uppercase tracking-wider mb-2">Administração</p>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/usuarios">👥 Usuários</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/configuracoes">⚙️ Configurações</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/auditoria_logs">🔍 Auditoria</a>
</div>
</nav>
<div class="px-6 py-4 border-t border-[#2a2a2a]">
<a data-turbo-method="delete" data-turbo-confirm="Deseja sair?" class="flex items-center gap-2 text-gray-400 hover:text-red-400 text-sm transition-colors" href="/auth/logout">
<span>🚪</span> Sair
</a> </div>
</aside>
<header class="md:hidden fixed top-0 left-0 right-0 z-30 bg-[#111111] border-b border-[#2a2a2a] px-4 py-3 flex items-center justify-between">
<button onclick="toggleSidebar()"
class="text-orange-500 text-2xl min-h-[48px] min-w-[48px] flex items-center justify-center">
</button>
<div class="flex items-center gap-2">
<div class="w-7 h-7 bg-orange-500 rounded flex items-center justify-center font-black text-black text-sm">G</div>
<span class="font-bold text-white text-sm">Reem Logística</span>
</div>
<div class="w-12"></div>
</header>
<div class="h-14 md:hidden"></div>
<script>
function toggleSidebar() {
const s = document.getElementById('sidebar');
const o = document.getElementById('sidebar-overlay');
s.classList.toggle('-translate-x-full');
o.classList.toggle('hidden');
}
</script>
<main class="ml-0 md:ml-64 p-4 md:p-8 min-h-screen">
<div class="max-w-2xl mx-auto space-y-6">
<div>
<h1 class="text-2xl font-bold text-white">
Novo Usuário
</h1>
<p class="text-gray-400 text-sm mt-0.5">
Preencha os dados para criar um novo acesso
</p>
</div>
<form class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-8 space-y-6" action="/admin/usuarios" accept-charset="UTF-8" method="post"><input type="hidden" name="authenticity_token" value="ENsNJddbt-MKLOBei4dMU8xI5qs3plOiQMzqfepJ4wk7d_oooUwVJUOzd7xxU_Kjqz8jnh7JOzPwpymLh0pwVQ" autocomplete="off" />
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_nome">Nome completo</label>
<input class="w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1
focus:ring-[#f97316] transition-colors" placeholder="João da Silva" type="text" name="user[nome]" id="user_nome" />
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_role">Perfil de acesso</label>
<select 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" name="user[role]" id="user_role"><option value="admin">Administrador</option>
<option value="gerente">Gerente</option>
<option value="operador">Operador</option>
<option selected="selected" value="motorista">Motorista</option></select>
</div>
<div class="flex items-center">
<div class="flex-1">
<label class="block text-sm font-medium text-gray-300" for="user_ativo">Usuário ativo</label>
<p class="text-gray-500 text-xs mt-0.5">Usuários inativos não conseguem fazer login</p>
</div>
<label class="relative inline-flex items-center cursor-pointer ml-4">
<input name="user[ativo]" type="hidden" value="0" autocomplete="off" /><input class="sr-only peer" type="checkbox" value="1" checked="checked" name="user[ativo]" id="user_ativo" />
<div class="w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:border-gray-300 after:border after:rounded-full
after:h-5 after:w-5 after:transition-all peer-checked:bg-[#f97316]"></div>
</label>
</div>
</div>
<div id="fields-email" class="space-y-6">
<div class="border-t border-white/5 pt-6">
<p class="text-sm font-medium text-gray-300 mb-4">Credenciais de acesso</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_email">E-mail</label>
<input class="w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1
focus:ring-[#f97316] transition-colors" placeholder="usuario@gade.com" type="email" value="" name="user[email]" id="user_email" />
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_password">Senha</label>
<input class="w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1
focus:ring-[#f97316] transition-colors" placeholder="••••••••" type="password" name="user[password]" id="user_password" />
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_password_confirmation">Confirmar senha</label>
<input class="w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1
focus:ring-[#f97316] transition-colors" placeholder="••••••••" type="password" name="user[password_confirmation]" id="user_password_confirmation" />
</div>
</div>
</div>
</div>
<div id="fields-pin" class="hidden border-t border-white/5 pt-6">
<p class="text-sm font-medium text-gray-300 mb-4">PIN de acesso</p>
<div class="max-w-xs">
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_pin_code">PIN (4 dígitos)</label>
<input maxlength="4" pattern="\d{4}" inputmode="numeric" class="w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1
focus:ring-[#f97316] transition-colors text-center text-2xl tracking-[0.5em] font-bold" placeholder="0000" size="4" type="text" name="user[pin_code]" id="user_pin_code" />
<p class="text-gray-500 text-xs mt-1.5">Deve ser único entre todos os motoristas</p>
</div>
</div>
<div class="flex items-center justify-between pt-2 border-t border-white/5">
<a class="px-6 py-3 text-gray-400 hover:text-white border border-white/10
hover:border-white/20 rounded-xl transition-colors" href="/admin/usuarios">Cancelar</a>
<input type="submit" name="commit" value="Criar Usuário" class="px-8 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold
rounded-xl transition-colors cursor-pointer min-h-[48px]" data-disable-with="Criar Usuário" />
</div>
</form></div>
<script>
// IIFE: evita "Identifier already declared" quando o Turbo re-executa o script
// ao navegar (o que antes abortava o toggle e escondia o campo de PIN).
(function () {
const roleSelect = document.querySelector('select[name="user[role]"]');
const fieldsEmail = document.getElementById('fields-email');
const fieldsPin = document.getElementById('fields-pin');
if (!roleSelect || !fieldsEmail || !fieldsPin) return;
function toggleFields() {
const motorista = roleSelect.value === 'motorista';
fieldsEmail.classList.toggle('hidden', motorista);
fieldsPin.classList.toggle('hidden', !motorista);
}
roleSelect.addEventListener('change', toggleFields);
toggleFields(); // estado inicial
})();
</script>
</main>
<script>
setTimeout(() => {
['flash-notice', 'flash-alert'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.transition = 'opacity 0.5s', el.style.opacity = '0',
setTimeout(() => el.remove(), 500);
});
}, 4000);
</script>
<script async nonce="" type="text/javascript" id="mini-profiler" src="/mini-profiler-resources/includes.js?v=e0bcc9ce0ae3bb5d6b736b6f282f601f" data-css-url="/mini-profiler-resources/includes.css?v=e0bcc9ce0ae3bb5d6b736b6f282f601f" data-version="e0bcc9ce0ae3bb5d6b736b6f282f601f" data-path="/mini-profiler-resources/" data-current-id="1ahgpnmdas9x6acftfbr" data-ids="1ahgpnmdas9x6acftfbr,ho1l1mkabtvdcaskpjm7" data-horizontal-position="left" data-vertical-position="top" data-trivial="false" data-children="false" data-max-traces="20" data-controls="false" data-total-sql-count="false" data-authorized="true" data-toggle-shortcut="alt+p" data-start-hidden="false" data-collapse-results="true" data-html-container="body" data-hidden-custom-fields="" data-turbo-permanent="false"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,252 @@
<!DOCTYPE html>
<html lang="pt-BR" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-param" content="authenticity_token">
<meta name="csrf-token" content="V0huiUnnR6iuZZliHnfydw0_28pJd5ho66yiE1jO186V5ffx6xm7oL5TuqTEwVv9v-9pxKKkTDbXwLQRAdLL3w">
<title>teste | Reem Logística</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
preto: '#0a0a0a',
'preto-card': '#1a1a1a',
laranja: '#f97316',
'laranja-escuro': '#ea580c',
branco: '#ffffff',
}
}
}
}
}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
/* Scrollbar tema escuro */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0a0a0a; }
::-webkit-scrollbar-thumb { background: #f97316; border-radius: 3px; }
/* Loading spinner laranja */
.spinner {
border: 3px solid #1a1a1a;
border-top-color: #f97316;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
<script type="importmap" data-turbo-track="reload">{
"imports": {
"application": "/assets/application-09caced1.js",
"@hotwired/turbo-rails": "/assets/turbo.min-86bf8853.js",
"@hotwired/stimulus": "/assets/stimulus.min-7ea3d58b.js",
"@hotwired/stimulus-loading": "/assets/stimulus-loading-25917588.js",
"controllers/application": "/assets/controllers/application-75a69c37.js",
"controllers": "/assets/controllers/index-dfefe98d.js",
"controllers/validacao_controller": "/assets/controllers/validacao_controller-3ce614b7.js"
}
}</script>
<link rel="modulepreload" href="/assets/application-09caced1.js">
<link rel="modulepreload" href="/assets/turbo.min-86bf8853.js">
<link rel="modulepreload" href="/assets/stimulus.min-7ea3d58b.js">
<link rel="modulepreload" href="/assets/stimulus-loading-25917588.js">
<link rel="modulepreload" href="/assets/controllers/application-75a69c37.js">
<link rel="modulepreload" href="/assets/controllers/index-dfefe98d.js">
<link rel="modulepreload" href="/assets/controllers/validacao_controller-3ce614b7.js">
<script type="module">import "application"</script>
</head>
<body class="bg-[#0a0a0a] text-white min-h-screen">
<div id="sidebar-overlay"
class="fixed inset-0 bg-black/70 z-30 hidden md:hidden"
onclick="toggleSidebar()"></div>
<aside id="sidebar"
class="fixed top-0 left-0 h-full w-64 bg-[#111111] border-r border-[#2a2a2a] z-40
transform -translate-x-full md:translate-x-0 transition-transform duration-300">
<div class="flex items-center gap-3 px-6 py-5 border-b border-[#2a2a2a]">
<div class="w-9 h-9 bg-orange-500 rounded-lg flex items-center justify-center font-black text-black text-lg">G</div>
<div>
<p class="font-bold text-white text-sm leading-tight">Reem Transporte</p>
<p class="text-orange-500 text-xs">Logística</p>
</div>
</div>
<div class="px-6 py-4 border-b border-[#2a2a2a]">
<p class="text-xs text-gray-500 mb-1">Logado como</p>
<p class="text-white font-semibold text-sm truncate">Administrador Gade</p>
<span class="inline-block mt-1 px-2 py-0.5 text-xs rounded-full
bg-orange-500 text-black">
Administrador
</span>
</div>
<nav class="px-3 py-4 space-y-1 flex-1 overflow-y-auto">
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/dashboard">📊 Dashboard</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/consolidacoes">📦 Consolidações</a>
<div class="pt-4 mt-4 border-t border-[#2a2a2a]">
<p class="px-3 text-xs text-gray-600 uppercase tracking-wider mb-2">Administração</p>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/usuarios">👥 Usuários</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/configuracoes">⚙️ Configurações</a>
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/auditoria_logs">🔍 Auditoria</a>
</div>
</nav>
<div class="px-6 py-4 border-t border-[#2a2a2a]">
<a data-turbo-method="delete" data-turbo-confirm="Deseja sair?" class="flex items-center gap-2 text-gray-400 hover:text-red-400 text-sm transition-colors" href="/auth/logout">
<span>🚪</span> Sair
</a> </div>
</aside>
<header class="md:hidden fixed top-0 left-0 right-0 z-30 bg-[#111111] border-b border-[#2a2a2a] px-4 py-3 flex items-center justify-between">
<button onclick="toggleSidebar()"
class="text-orange-500 text-2xl min-h-[48px] min-w-[48px] flex items-center justify-center">
</button>
<div class="flex items-center gap-2">
<div class="w-7 h-7 bg-orange-500 rounded flex items-center justify-center font-black text-black text-sm">G</div>
<span class="font-bold text-white text-sm">Reem Logística</span>
</div>
<div class="w-12"></div>
</header>
<div class="h-14 md:hidden"></div>
<script>
function toggleSidebar() {
const s = document.getElementById('sidebar');
const o = document.getElementById('sidebar-overlay');
s.classList.toggle('-translate-x-full');
o.classList.toggle('hidden');
}
</script>
<main class="ml-0 md:ml-64 p-4 md:p-8 min-h-screen">
<div class="max-w-4xl mx-auto">
<div class="mb-6">
<a class="text-gray-500 hover:text-orange-500 text-sm" href="/consolidacoes">← Consolidações</a>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mt-2">
<div>
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-white">teste</h1>
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold bg-yellow-500 text-black">📝 Rascunho</span>
</div>
<p class="text-gray-400 text-sm mt-1">
📅 01/02/2026 → 28/02/2026
· Criada por Administrador Gade
</p>
</div>
<div class="flex gap-2">
<a class="bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg min-h-[48px] flex items-center" href="/consolidacoes/3/wizard">✏️ Continuar validação</a>
<form class="button_to" method="post" action="/consolidacoes/3/arquivar"><button data-turbo-confirm="Arquivar esta consolidação? Ela não será excluída, apenas movida para o arquivo." class="text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-lg min-h-[48px]" type="submit">📁 Arquivar</button><input type="hidden" name="authenticity_token" value="bFgW-DhUfHVabQb6YBBZhcI6kTfr6RY-m2AcIg74Too_85JbSI2x1yKmq7VymjoA3QnfDb2v-F5VSGRHh8Q_DQ" autocomplete="off" /></form>
</div>
</div>
</div>
<div class="bg-[#1a1a1a] border border-orange-500 rounded-xl p-6 mb-6 text-center">
<p class="text-gray-400 text-sm">Valor total da consolidação</p>
<p class="text-orange-500 font-black text-4xl">R$ 5265,00</p>
</div>
<h2 class="text-white font-bold text-lg mb-3">👥 Motoristas</h2>
<div class="space-y-3">
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<p class="text-white font-bold text-lg">Bruno Corgozinho</p>
<p class="text-orange-500 font-black text-xl">R$ 5265,00</p>
</div>
<div class="flex flex-wrap gap-2">
<button onclick="abrirPreview('/consolidacoes/3/preview_holerite?motorista=Bruno+Corgozinho')"
class="bg-[#0a0a0a] hover:bg-[#2a2a2a] text-white border border-orange-500 font-semibold px-4 py-3 rounded-lg text-sm min-h-[48px]">
👁️ Ver antes de gerar
</button>
<a class="bg-orange-800 hover:bg-orange-700 text-white font-semibold px-4 py-3 rounded-lg text-sm min-h-[48px] flex items-center" href="/consolidacoes/3/gerar_pdf_relatorio?motorista=Bruno+Corgozinho">📄 Relatório Individual</a>
<a class="bg-orange-500 hover:bg-orange-600 text-black font-bold px-4 py-3 rounded-lg text-sm min-h-[48px] flex items-center" href="/consolidacoes/3/gerar_pdf_holerite?motorista=Bruno+Corgozinho">🧾 Gerar Holerite</a>
</div>
</div>
</div>
</div>
</div>
<div id="modal-preview" class="fixed inset-0 bg-black/80 z-50 hidden items-center justify-center p-4">
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl max-w-2xl w-full max-h-[85vh] flex flex-col">
<div class="flex items-center justify-between px-6 py-4 border-b border-[#2a2a2a]">
<h3 class="text-white font-bold">👁️ Pré-visualização do Holerite</h3>
<button onclick="fecharPreview()" class="text-gray-400 hover:text-white text-2xl min-w-[44px] min-h-[44px]"></button>
</div>
<div id="modal-preview-conteudo" class="overflow-y-auto p-6 flex-1">
<div class="flex justify-center py-10"><div class="spinner w-10 h-10"></div></div>
</div>
<div class="flex gap-3 px-6 py-4 border-t border-[#2a2a2a]">
<a id="modal-confirmar" href="#"
class="flex-1 bg-orange-500 hover:bg-orange-600 text-black font-bold py-3 rounded-lg text-center min-h-[48px] flex items-center justify-center">
✓ Confirmar e baixar PDF
</a>
<button onclick="fecharPreview()"
class="px-6 py-3 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg min-h-[48px]">
← Voltar
</button>
</div>
</div>
</div>
<script>
async function abrirPreview(url) {
const modal = document.getElementById('modal-preview');
const conteudo = document.getElementById('modal-preview-conteudo');
modal.classList.remove('hidden');
modal.classList.add('flex');
conteudo.innerHTML = '<div class="flex justify-center py-10"><div class="spinner w-10 h-10"></div></div>';
const resp = await fetch(url);
conteudo.innerHTML = await resp.text();
// O link de confirmar aponta para o PDF do mesmo motorista
document.getElementById('modal-confirmar').href =
url.replace('preview_holerite', 'gerar_pdf_holerite');
}
function fecharPreview() {
const modal = document.getElementById('modal-preview');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
</script>
</main>
<script>
setTimeout(() => {
['flash-notice', 'flash-alert'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.transition = 'opacity 0.5s', el.style.opacity = '0',
setTimeout(() => el.remove(), 500);
});
}, 4000);
</script>
<script async nonce="" type="text/javascript" id="mini-profiler" src="/mini-profiler-resources/includes.js?v=e0bcc9ce0ae3bb5d6b736b6f282f601f" data-css-url="/mini-profiler-resources/includes.css?v=e0bcc9ce0ae3bb5d6b736b6f282f601f" data-version="e0bcc9ce0ae3bb5d6b736b6f282f601f" data-path="/mini-profiler-resources/" data-current-id="5vfdctp5mnyu0tdtx94a" data-ids="5vfdctp5mnyu0tdtx94a,md2esz0jc4j57ce236e8" data-horizontal-position="left" data-vertical-position="top" data-trivial="false" data-children="false" data-max-traces="20" data-controls="false" data-total-sql-count="false" data-authorized="true" data-toggle-shortcut="alt+p" data-start-hidden="false" data-collapse-results="true" data-html-container="body" data-hidden-custom-fields="" data-turbo-permanent="false"></script>
</body>
</html>

18
Gemfile
View File

@@ -26,14 +26,6 @@ gem "dotenv-rails"
# PDF # PDF
gem "prawn" gem "prawn"
gem "prawn-table" gem "prawn-table"
# Planilhas (XLSX) — geração da carga de importação do SimpliRoute
gem "caxlsx"
# Leitura do .xlsx do PLANO do SimpliRoute (reserva do romaneio, quando a API não
# responde ou o plano do dia ainda não foi publicado). A roo pina rubyzip < 3.0;
# o único outro consumidor é o selenium-webdriver (grupo de teste, aceita >= 1.2.2,
# < 4.0), então o bundler rebaixa o rubyzip sem quebrar nada.
gem "roo", "~> 2.10"
gem "rqrcode" # QR code no holerite (login rápido motorista) gem "rqrcode" # QR code no holerite (login rápido motorista)
gem "chunky_png" # renderiza QR como PNG para embed no Prawn gem "chunky_png" # renderiza QR como PNG para embed no Prawn
@@ -52,19 +44,11 @@ group :development, :test do
gem "rspec-rails" gem "rspec-rails"
gem "factory_bot_rails" gem "factory_bot_rails"
gem "faker" gem "faker"
# Lê o PDF gerado (páginas e texto) nos specs. Sem ele, o único jeito de testar
# um PDF do Prawn seria olhar o arquivo: as fontes TTF são subsetadas, então o
# texto não é "grepável" no binário.
gem "pdf-inspector", require: false
end end
group :development do group :development do
gem "web-console" gem "web-console"
# require: false → não monta o middleware automaticamente (evita vazar o gem "rack-mini-profiler"
# cookie __profilin e a rota /mini-profiler-resources em produção). Para usar
# numa investigação de performance, rode com RACK_MINI_PROFILER=1 ou
# adicione `require "rack-mini-profiler"` temporariamente.
gem "rack-mini-profiler", require: false
gem "annotate" gem "annotate"
end end

View File

@@ -1,7 +1,6 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
Ascii85 (2.0.1)
actioncable (7.1.6) actioncable (7.1.6)
actionpack (= 7.1.6) actionpack (= 7.1.6)
activesupport (= 7.1.6) activesupport (= 7.1.6)
@@ -83,7 +82,6 @@ GEM
tzinfo (~> 2.0) tzinfo (~> 2.0)
addressable (2.9.0) addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0) public_suffix (>= 2.0.2, < 8.0)
afm (1.0.0)
annotate (3.2.0) annotate (3.2.0)
activerecord (>= 3.2, < 8.0) activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
@@ -102,11 +100,6 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
caxlsx (4.5.0)
htmlentities (~> 4.3, >= 4.3.4)
marcel (~> 1.0)
nokogiri (~> 1.10, >= 1.10.4)
rubyzip (>= 2.4, < 4)
cgi (0.5.1) cgi (0.5.1)
chronic (0.10.2) chronic (0.10.2)
chunky_png (1.4.0) chunky_png (1.4.0)
@@ -146,8 +139,6 @@ GEM
net-http (~> 0.5) net-http (~> 0.5)
globalid (1.3.0) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
hashery (2.1.2)
htmlentities (4.4.2)
i18n (1.14.8) i18n (1.14.8)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (2.0.2) image_processing (2.0.2)
@@ -201,13 +192,6 @@ GEM
orm_adapter (0.5.0) orm_adapter (0.5.0)
pagy (9.4.0) pagy (9.4.0)
pdf-core (0.10.0) pdf-core (0.10.0)
pdf-inspector (1.3.0)
pdf-reader (>= 1.0, < 3.0.a)
pdf-reader (2.16.0)
Ascii85 (>= 1.0, < 3.0, != 2.0.0)
afm (>= 0.2.1, < 2)
hashery (~> 2.0)
ttfunk
pg (1.6.3-x86_64-linux) pg (1.6.3-x86_64-linux)
pp (0.6.3) pp (0.6.3)
prettyprint prettyprint
@@ -285,9 +269,6 @@ GEM
actionpack (>= 7.0) actionpack (>= 7.0)
railties (>= 7.0) railties (>= 7.0)
rexml (3.4.4) rexml (3.4.4)
roo (2.10.1)
nokogiri (~> 1)
rubyzip (>= 1.3.0, < 3.0.0)
rqrcode (3.2.0) rqrcode (3.2.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 2.0) rqrcode_core (~> 2.0)
@@ -309,7 +290,7 @@ GEM
rspec-mocks (~> 3.13) rspec-mocks (~> 3.13)
rspec-support (~> 3.13) rspec-support (~> 3.13)
rspec-support (3.13.7) rspec-support (3.13.7)
rubyzip (2.4.1) rubyzip (3.4.0)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.44.0) selenium-webdriver (4.44.0)
base64 (~> 0.2) base64 (~> 0.2)
@@ -363,7 +344,6 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
annotate annotate
capybara capybara
caxlsx
chunky_png chunky_png
debug debug
devise devise
@@ -374,7 +354,6 @@ DEPENDENCIES
importmap-rails importmap-rails
jbuilder jbuilder
pagy (~> 9.0) pagy (~> 9.0)
pdf-inspector
pg (~> 1.1) pg (~> 1.1)
prawn prawn
prawn-table prawn-table
@@ -383,7 +362,6 @@ DEPENDENCIES
pundit pundit
rack-mini-profiler rack-mini-profiler
rails (~> 7.1.0) rails (~> 7.1.0)
roo (~> 2.10)
rqrcode rqrcode
rspec-rails rspec-rails
selenium-webdriver selenium-webdriver

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,799 @@
%PDF-1.3
%ÿÿÿÿ
1 0 obj
<< /Creator <feff0050007200610077006e>
/Producer <feff0050007200610077006e>
>>
endobj
2 0 obj
<< /Pages 3 0 R
/Type /Catalog
>>
endobj
3 0 obj
<< /Count 1
/Kids [5 0 R]
/Type /Pages
>>
endobj
4 0 obj
<< /Length 8085
>>
stream
q
/DeviceRGB cs
0.03922 0.03922 0.03922 scn
0.0 771.89 595.28 70.0 re
f
0.97647 0.45098 0.08627 scn
0.0 771.89 8.0 70.0 re
f
1.0 1.0 1.0 scn
BT
50.0 807.53 Td
/F2.0 20 Tf
[<5245454d205452414e53504f52> 20 <5445>] TJ
ET
0.97647 0.45098 0.08627 scn
BT
50.0 790.71 Td
/F1.0 10 Tf
[<53697374656d6120646520436f6e74726f6c6520646520437573746f73204c6f67ed737469636f73>] TJ
ET
0.0 0.0 0.0 scn
0.97647 0.45098 0.08627 scn
BT
40.0 723.556 Td
/F2.0 13 Tf
[<484f4c45524954452044452050> 100 <41> 50 <47414d454e54> 40 <4f209720454e545245474153>] TJ
ET
/DeviceRGB CS
0.97647 0.45098 0.08627 SCN
40.0 717.42 m
555.28 717.42 l
S
0.0 0.0 0.0 scn
0.95294 0.95686 0.96471 scn
40.0 687.52 120.0 21.9 re
f
0.0 0.0 0.0 scn
0.95294 0.95686 0.96471 scn
40.0 665.62 120.0 21.9 re
f
0.0 0.0 0.0 scn
0.95294 0.95686 0.96471 scn
40.0 643.72 120.0 21.9 re
f
0.0 0.0 0.0 scn
0.95294 0.95686 0.96471 scn
40.0 621.82 120.0 21.9 re
f
0.0 0.0 0.0 scn
1 w
0.0 0.0 0.0 SCN
40.0 709.42 m
160.0 709.42 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
40.0 687.52 m
160.0 687.52 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
40.0 709.92 m
40.0 687.02 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
160.0 709.92 m
160.0 687.02 l
S
[] 0 d
1 w
0.97647 0.45098 0.08627 SCN
BT
48.0 694.408 Td
/F2.0 10 Tf
[<4d6f746f7269737461>] TJ
ET
1 w
0.0 0.0 0.0 SCN
160.0 709.42 m
555.28 709.42 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
160.0 687.52 m
555.28 687.52 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
160.0 709.92 m
160.0 687.02 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
555.28 709.92 m
555.28 687.02 l
S
[] 0 d
1 w
0.97647 0.45098 0.08627 SCN
BT
168.0 694.612 Td
/F1.0 10 Tf
[<4272> -15 <756e6f20436f72676f7a696e686f>] TJ
ET
1 w
0.0 0.0 0.0 SCN
40.0 687.52 m
160.0 687.52 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
40.0 665.62 m
160.0 665.62 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
40.0 688.02 m
40.0 665.12 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
160.0 688.02 m
160.0 665.12 l
S
[] 0 d
1 w
0.97647 0.45098 0.08627 SCN
BT
48.0 672.508 Td
/F2.0 10 Tf
[<436f6e736f6c696461e7e36f>] TJ
ET
1 w
0.0 0.0 0.0 SCN
160.0 687.52 m
555.28 687.52 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
160.0 665.62 m
555.28 665.62 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
160.0 688.02 m
160.0 665.12 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
555.28 688.02 m
555.28 665.12 l
S
[] 0 d
1 w
0.97647 0.45098 0.08627 SCN
BT
168.0 672.712 Td
/F1.0 10 Tf
[<7465737465>] TJ
ET
1 w
0.0 0.0 0.0 SCN
40.0 665.62 m
160.0 665.62 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
40.0 643.72 m
160.0 643.72 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
40.0 666.12 m
40.0 643.22 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
160.0 666.12 m
160.0 643.22 l
S
[] 0 d
1 w
0.97647 0.45098 0.08627 SCN
BT
48.0 650.608 Td
/F2.0 10 Tf
[<50> 30 <6572ed6f646f>] TJ
ET
1 w
0.0 0.0 0.0 SCN
160.0 665.62 m
555.28 665.62 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
160.0 643.72 m
555.28 643.72 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
160.0 666.12 m
160.0 643.22 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
555.28 666.12 m
555.28 643.22 l
S
[] 0 d
1 w
0.97647 0.45098 0.08627 SCN
BT
168.0 650.812 Td
/F1.0 10 Tf
[<30312f30322f3230323620612031362f30362f32303236>] TJ
ET
1 w
0.0 0.0 0.0 SCN
40.0 643.72 m
160.0 643.72 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
40.0 621.82 m
160.0 621.82 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
40.0 644.22 m
40.0 621.32 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
160.0 644.22 m
160.0 621.32 l
S
[] 0 d
1 w
0.97647 0.45098 0.08627 SCN
BT
48.0 628.708 Td
/F2.0 10 Tf
[<456d697373e36f>] TJ
ET
1 w
0.0 0.0 0.0 SCN
160.0 643.72 m
555.28 643.72 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
160.0 621.82 m
555.28 621.82 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
160.0 644.22 m
160.0 621.32 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
555.28 644.22 m
555.28 621.32 l
S
[] 0 d
1 w
0.97647 0.45098 0.08627 SCN
BT
168.0 628.912 Td
/F1.0 10 Tf
[<31362f30362f32303236>] TJ
ET
0.97647 0.45098 0.08627 scn
BT
40.0 598.486 Td
/F2.0 13 Tf
[<5052> 20 <4f> 50 <56454e54> 40 <4f53204520444553434f4e54> 40 <4f53>] TJ
ET
0.97647 0.45098 0.08627 SCN
40.0 592.35 m
555.28 592.35 l
S
0.0 0.0 0.0 scn
0.03922 0.03922 0.03922 scn
40.0 560.45 189.0039 23.9 re
f
0.0 0.0 0.0 scn
0.03922 0.03922 0.03922 scn
229.0039 560.45 148.87117 23.9 re
f
0.0 0.0 0.0 scn
0.03922 0.03922 0.03922 scn
377.87507 560.45 177.40493 23.9 re
f
0.0 0.0 0.0 scn
1 w
0.0 0.0 0.0 SCN
40.0 584.35 m
229.0039 584.35 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
40.0 560.45 m
229.0039 560.45 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
40.0 584.85 m
40.0 559.95 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
229.0039 584.85 m
229.0039 559.95 l
S
[] 0 d
1 w
0.97647 0.45098 0.08627 SCN
1.0 1.0 1.0 scn
BT
48.0 568.338 Td
/F2.0 10 Tf
[<446573637269e7e36f>] TJ
ET
0.0 0.0 0.0 scn
1 w
0.0 0.0 0.0 SCN
229.0039 584.35 m
377.87507 584.35 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
229.0039 560.45 m
377.87507 560.45 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
229.0039 584.85 m
229.0039 559.95 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
377.87507 584.85 m
377.87507 559.95 l
S
[] 0 d
1 w
0.97647 0.45098 0.08627 SCN
1.0 1.0 1.0 scn
BT
353.65507 568.338 Td
/F2.0 10 Tf
[<517464>] TJ
ET
0.0 0.0 0.0 scn
1 w
0.0 0.0 0.0 SCN
377.87507 584.35 m
555.28 584.35 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
377.87507 560.45 m
555.28 560.45 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
377.87507 584.85 m
377.87507 559.95 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
555.28 584.85 m
555.28 559.95 l
S
[] 0 d
1 w
0.97647 0.45098 0.08627 SCN
1.0 1.0 1.0 scn
BT
523.87 568.338 Td
/F2.0 10 Tf
[<56> 60 <616c6f72>] TJ
ET
0.0 0.0 0.0 scn
1 w
0.0 0.0 0.0 SCN
40.0 560.45 m
229.0039 560.45 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
40.0 536.89 m
229.0039 536.89 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
40.0 560.95 m
40.0 536.39 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
229.0039 560.95 m
229.0039 536.39 l
S
[] 0 d
1 w
0.97647 0.45098 0.08627 SCN
BT
48.0 544.642 Td
/F1.0 10 Tf
[<456e7472656761204e6f72> -25 <6d616c>] TJ
ET
1 w
0.0 0.0 0.0 SCN
229.0039 560.45 m
377.87507 560.45 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
229.0039 536.89 m
377.87507 536.89 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
229.0039 560.95 m
229.0039 536.39 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
377.87507 560.95 m
377.87507 536.39 l
S
[] 0 d
1 w
0.97647 0.45098 0.08627 SCN
BT
359.75507 544.642 Td
/F1.0 10 Tf
[<3239>] TJ
ET
1 w
0.0 0.0 0.0 SCN
377.87507 560.45 m
555.28 560.45 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
377.87507 536.89 m
555.28 536.89 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
377.87507 560.95 m
377.87507 536.39 l
S
[] 0 d
1 w
0.0 0.0 0.0 SCN
555.28 560.95 m
555.28 536.39 l
S
[] 0 d
1 w
0.97647 0.45098 0.08627 SCN
BT
493.52 544.642 Td
/F1.0 10 Tf
[<2b205224203433352c3030>] TJ
ET
0.97647 0.45098 0.08627 scn
48.0 520.89 m
547.28 520.89 l
551.69828 520.89 555.28 517.30828 555.28 512.89 c
555.28 478.89 l
555.28 474.47172 551.69828 470.89 547.28 470.89 c
48.0 470.89 l
43.58172 470.89 40.0 474.47172 40.0 478.89 c
40.0 512.89 l
40.0 517.30828 43.58172 520.89 48.0 520.89 c
h
f
0.0 0.0 0.0 scn
BT
56.0 503.71 Td
/F2.0 10 Tf
[<56> 80 <414c4f52204ccd51> 10 <5549444f20412052454345424552>] TJ
ET
BT
56.0 481.094 Td
/F2.0 22 Tf
[<5224203433352c3030>] TJ
ET
0.97647 0.45098 0.08627 scn
BT
40.0 433.556 Td
/F2.0 13 Tf
[<41> 40 <434553534f2052c15049444f2041> 40 <4f205345552050> 100 <41494e454c>] TJ
ET
0.97647 0.45098 0.08627 SCN
40.0 427.42 m
555.28 427.42 l
S
0.0 0.0 0.0 scn
q
110.0 0.0 0.0 110.0 40.0 309.42 cm
/I1 Do
Q
BT
170.0 397.958 Td
/F1.0 9 Tf
[<457363616e656965206573746520515220436f646520636f6d20612063e26d6572> 10 <6120646f2063656c756c617220706172> 10 <6120656e7472> 10 <6172>] TJ
ET
BT
170.0 387.554 Td
/F1.0 9 Tf
[<6e6f20736575207061696e656c20652061636f6d70616e686172207365757320706167616d656e746f73> 15 <2e>] TJ
ET
BT
170.0 366.746 Td
/F1.0 9 Tf
[<4f75206163657373653a206c6f63616c686f73743a333030302f6d6f746f72> -15 <69737461>] TJ
ET
BT
170.0 356.342 Td
/F1.0 9 Tf
[<6520757365207365752050494e20646520342064ed6769746f73> 15 <2e>] TJ
ET
0.0 0.0 0.0 SCN
0.7 w
40.0 244.42 m
277.64 244.42 l
S
317.64 244.42 m
555.28 244.42 l
S
0.0 0.0 0.0 scn
BT
119.391 229.958 Td
/F2.0 9 Tf
[<4272756e6f20436f72> 15 <676f7a696e686f>] TJ
ET
BT
399.722 229.958 Td
/F2.0 9 Tf
[<5265656d2054> 80 <72616e73706f72> -20 <7465>] TJ
ET
0.41961 0.44706 0.50196 scn
BT
116.784 216.676 Td
/F1.0 8 Tf
[<417373696e61747572> 10 <6120646f204d6f746f72> -15 <69737461>] TJ
ET
BT
386.3 216.676 Td
/F1.0 8 Tf
[<417373696e61747572> 10 <6120646f2041646d696e69737472> 10 <61646f72>] TJ
ET
0.0 0.0 0.0 scn
/Stamp1 Do
Q
endstream
endobj
5 0 obj
<< /ArtBox [0 0 595.28 841.89]
/BleedBox [0 0 595.28 841.89]
/Contents 4 0 R
/CropBox [0 0 595.28 841.89]
/MediaBox [0 0 595.28 841.89]
/Parent 3 0 R
/Resources << /Font << /F1.0 7 0 R
/F2.0 6 0 R
>>
/ProcSet [/PDF /Text /ImageB /ImageC /ImageI]
/XObject << /I1 8 0 R
/Stamp1 9 0 R
>>
>>
/TrimBox [0 0 595.28 841.89]
/Type /Page
>>
endobj
6 0 obj
<< /BaseFont /Helvetica-Bold
/Encoding /WinAnsiEncoding
/Subtype /Type1
/Type /Font
>>
endobj
7 0 obj
<< /BaseFont /Helvetica
/Encoding /WinAnsiEncoding
/Subtype /Type1
/Type /Font
>>
endobj
8 0 obj
<< /BitsPerComponent 1
/ColorSpace /DeviceGray
/DecodeParms [<< /BitsPerComponent 1
/Colors 1
/Columns 220
/Predictor 15
>>]
/Filter [/FlateDecode]
/Height 220
/Length 788
/Subtype /Image
/Type /XObject
/Width 220
>>
stream
xœí˜ÁåHDq<(ÿͰ€}Y½×ýsøÃiVêhéWEIè×ü÷Q¿Î‡ãÿÍßo¶™qÓq,Â8ODÿ,;}¼Ï)<29>i‰}-.Úl¹i§¸dôu?þïânà/†jâ8îeÈèyì§Í΃‡ÝÑaí
ñ²MŸã÷ä=d±ºâ¥ÅMfÑ™'³ª¢n®,.ÚtçÈ ²z=.8Òâ&nÉ¢ܱ¹ÄKeõëÇ~Úloh¢ñ³G‡ßÞ½ê'•§ÆKÖ©Kóéw¦ãB}â#,ÞénÖ'ùJÕå¹ùóÛæìÆ0ZGˆüÈg¹ÇŒÝU¾=i…d°_Fäã…»‰[H‡Lä'Ï…•®÷ÍØB™ám2¯†bÞU_>öã&étœ<74>zf(^€¾ýíc·)À@±=FQ¦'%[±iÆì\ÙíùxïYÅ­ £W\·_üõtâ¼Ê V×®3ç
Ì«}¥È$^9¯£.žVþ4=Ü>yvk8Éj¤VΦ֤=¥‰ªå¦›–¦•Õú<âj#ŸÈ¬˜3¼Ã“~õù´ûy<C3BB>¦ÿüÅfÿT…”@Ѝ!<1A> ÛM©ÉÁ¤{<7B>ÛV9!rnž:
lsÛ¿~ì§M¢i'$ÐÔ
”Pn\j·VÀ-íú¡X˜o<>!ë71Ôˆ Dñ™j&ľ¸Œ^øò±Ÿ7ƒ\­
leuT(—޶ÙWÌ;IR,I¦dàjÿœI؇y
Æj/ƒˆ E³Ðí0úf}R+Þ¥`Úo™‰ ÐF½Z+·Z0Õ¸BK+ÙI7[åÛt´Õ¦5f3çËþnlK¬—wÔ¯íÉÊ»s¶x¶5Ÿ„>
=96eõù>y<>]ü-}—"<22>ÒØ«~ê“—3âÂ@êÓ’~ïÑ.†¨
ZIyÍÛàIÊa•ûަÜcËR*!Ò›³;#½Cô£|jMìÆVÀ
éweuºoB¸¾ª©>y‰
BL¡—I[<5B>l•û¤»†TVÍàs¹€\¾:¯ü›ÿãsê<73>
endstream
endobj
9 0 obj
<< /BBox [0 0 595.28 841.89]
/Length 363
/Resources << /Font << /F1.0 7 0 R
>>
>>
/Subtype /Form
/Type /XObject
>>
stream
q
/DeviceRGB cs
0.0 0.0 0.0 scn
/DeviceRGB CS
0.0 0.0 0.0 SCN
0.7 w
0 J
0 j
[] 0 d
/DeviceRGB cs
0.41961 0.44706 0.50196 scn
BT
171.876 74.256 Td
/F1.0 8 Tf
[<476572> 10 <61646f20656d2031362f30362f323032362031373a323720b7205265656d2054> 120 <72> 10 <616e73706f72> -40 <7465209720646f63756d656e746f20696e746572> -25 <6e6f>] TJ
ET
/DeviceRGB cs
0.0 0.0 0.0 scn
Q
endstream
endobj
xref
0 10
0000000000 65535 f
0000000015 00000 n
0000000109 00000 n
0000000158 00000 n
0000000215 00000 n
0000008352 00000 n
0000008699 00000 n
0000008801 00000 n
0000008898 00000 n
0000009936 00000 n
trailer
<< /Info 1 0 R
/Root 2 0 R
/Size 10

2729
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +0,0 @@
/* app/assets/tailwind/application.css
*
* ESTE ARQUIVO EXISTE PARA O DEPLOY NÃO QUEBRAR. Leia antes de apagar.
*
* A gem `tailwindcss-rails` está no Gemfile e pendura a tarefa `tailwindcss:build`
* dentro de `assets:precompile`. Ela procura ESTE caminho fixo e, se não achar,
* aborta com:
*
* Specified input file `./app/assets/tailwind/application.css` does not exist.
* Tasks: TOP => assets:precompile => tailwindcss:build
*
* E o `assets:precompile` morre INTEIRO nessa linha — nenhum arquivo chega em
* public/assets. O efeito na tela é traiçoeiro: o Propshaft, sem manifesto, cai
* no resolvedor dinâmico e continua montando URLs com o digest certo, então a
* página abre normal e CADA /assets/*.js devolve 404 em silêncio. O sistema fica
* sem JavaScript nenhum: prévia do PDF, edição inline da tabela, busca e
* carrossel todos mortos, sem erro visível. Foi o que aconteceu de 27 a
* 28/08/2026 e custou três rodadas de diagnóstico.
*
* POR QUE ESTÁ VAZIO: o CSS do sistema hoje vem do CDN do Tailwind, carregado em
* app/views/layouts/application.html.erb. Sem `@import "tailwindcss"` aqui, o
* build termina na hora e gera um arquivo vazio — nada é servido a mais e o
* deploy não perde tempo montando um CSS que ninguém carrega.
*
* DÍVIDA CONHECIDA (uma das duas, quando houver espaço):
* a) migrar do CDN para o Tailwind local — o próprio Tailwind desaconselha o
* CDN em produção, e ele deixa o sistema dependendo de rede externa. Neste
* caso, troque este comentário por `@import "tailwindcss";` e o
* <script src="https://cdn.tailwindcss.com"> por stylesheet_link_tag; ou
* b) remover `tailwindcss-rails` do Gemfile, que hoje é dependência morta.
* Aí este arquivo some junto.
*/

View File

@@ -1,10 +1,7 @@
# app/controllers/admin/auditoria_logs_controller.rb # app/controllers/admin/auditoria_logs_controller.rb
class Admin::AuditoriaLogsController < ApplicationController class Admin::AuditoriaLogsController < ApplicationController
def index def index
# Policy própria (AuditoriaLogPolicy). Com `policy_class: ApplicationPolicy` authorize AuditoriaLog, policy_class: ApplicationPolicy
# a régua era a de consolidação: quem podia consolidar abria a auditoria pela
# URL, mesmo sem o item no menu.
authorize AuditoriaLog
@logs = AuditoriaLog.recentes.limit(200) @logs = AuditoriaLog.recentes.limit(200)
@logs = @logs.por_user(params[:user_id]) if params[:user_id].present? @logs = @logs.por_user(params[:user_id]) if params[:user_id].present?

View File

@@ -1,108 +0,0 @@
# app/controllers/admin/configuracao_notificacoes_controller.rb
#
# Tela única (singleton) de SMTP + WhatsApp. Só admin — ver
# ConfiguracaoNotificacaoPolicy.
class Admin::ConfiguracaoNotificacoesController < ApplicationController
before_action :set_config
CAMPOS = %i[
smtp_ativo smtp_address smtp_port smtp_username smtp_password smtp_domain
smtp_autenticacao remetente_email remetente_nome
email_notificacoes_ativo email_admin
whatsapp_ativo whatsapp_provedor whatsapp_intervalo_segundos whatsapp_admin
twilio_account_sid twilio_auth_token twilio_from
baileys_url baileys_token
].freeze
def show
authorize @config
end
# PATCH /admin/configuracao_notificacao
#
# Os botões "enviar teste" são submits DESTE MESMO form, diferenciados por
# params[:acao]. Assim o teste sempre usa o que está na tela — evita o
# clássico "digitei a senha nova, cliquei em testar, e ele testou a antiga".
def update
authorize @config
antes = resumo
unless @config.update(config_params)
flash.now[:alert] = @config.errors.full_messages.to_sentence
return render(:show, status: :unprocessable_entity)
end
auditar!(antes)
case params[:acao]
when 'testar_email'
redirecionar(Notificacao::TesteEmail.new(@config, usuario: current_user).executar)
when 'testar_whatsapp'
redirecionar(Notificacao::TesteWhatsapp.new(@config).executar)
else
redirect_to admin_configuracao_notificacao_path,
notice: 'Configurações de notificação salvas.'
end
end
private
def set_config
@config = ConfiguracaoNotificacao.instancia
end
def redirecionar(resultado)
redirect_to admin_configuracao_notificacao_path, **resultado.para_flash
end
def config_params
permitidos = params.require(:configuracao_notificacao).permit(*CAMPOS)
# Campo de senha em branco significa "mantenha a que já está gravada" — a
# tela nunca exibe o valor, então salvar sem redigitar não pode apagá-lo.
permitidos.delete(:smtp_password) if permitidos[:smtp_password].blank?
permitidos.delete(:twilio_auth_token) if permitidos[:twilio_auth_token].blank?
permitidos.delete(:baileys_token) if permitidos[:baileys_token].blank?
permitidos
end
# ⚠️ NUNCA colocar senha ou token aqui: AuditoriaLog#dados_novos é exibido em
# /admin/auditoria_logs, que gerente também acessa. Só o booleano de
# "está definida ou não".
def resumo
{
smtp_ativo: @config.smtp_ativo,
smtp_address: @config.smtp_address,
smtp_port: @config.smtp_port,
smtp_username: @config.smtp_username,
smtp_domain: @config.smtp_domain,
smtp_autenticacao: @config.smtp_autenticacao,
remetente_email: @config.remetente_email,
remetente_nome: @config.remetente_nome,
email_admin: @config.email_admin,
email_notificacoes_ativo: @config.email_notificacoes_ativo,
whatsapp_ativo: @config.whatsapp_ativo,
whatsapp_provedor: @config.whatsapp_provedor,
whatsapp_intervalo: @config.whatsapp_intervalo_segundos,
twilio_account_sid: @config.twilio_account_sid,
twilio_from: @config.twilio_from,
whatsapp_admin: @config.whatsapp_admin,
baileys_url: @config.baileys_url,
smtp_password_definida: @config.smtp_password?,
twilio_auth_token_definido: @config.twilio_auth_token?,
baileys_token_definido: @config.baileys_token?
}
end
def auditar!(antes)
AuditoriaLog.registrar(
user: current_user,
acao: 'editar_notificacoes',
entidade: 'ConfiguracaoNotificacao',
entidade_id: @config.id,
dados_anteriores: antes,
dados_novos: resumo,
request: request
)
end
end

View File

@@ -1,90 +0,0 @@
# app/controllers/admin/contatos_controller.rb
#
# Cadastro manual de quem recebe as mensagens: nome, WhatsApp e/ou e-mail, e o
# grupo. O telefone é normalizado para E.164 no model — o ADM digita como quiser.
class Admin::ContatosController < ApplicationController
before_action :set_contato, only: %i[edit update destroy]
before_action :carregar_grupos, only: %i[new create edit update]
def index
authorize Contato
@grupos = GrupoContato.ordenados
escopo = policy_scope(Contato).includes(:grupo_contato)
escopo = escopo.where(grupo_contato_id: params[:grupo_id]) if params[:grupo_id].present?
escopo = escopo.where('LOWER(nome) LIKE :q OR telefone LIKE :q OR LOWER(email) LIKE :q',
q: "%#{params[:q].to_s.strip.downcase}%") if params[:q].present?
@contatos = escopo.ordenados
end
# GET /admin/contatos/grupos_whatsapp (JSON)
def grupos_whatsapp
authorize Contato, :create?
resposta = Notificacao::ClienteWhatsapp.padrao.grupos
if resposta.ok?
render json: { grupos: resposta.dados['grupos'] || [] }
else
render json: { erro: resposta.erro, grupos: [] }, status: :ok
end
end
def new
@contato = Contato.new(grupo_contato_id: params[:grupo_id], tipo: params[:tipo].presence || 'pessoa')
authorize @contato
end
def create
@contato = Contato.new(contato_params)
authorize @contato
if @contato.save
registrar("Cadastrou contato #{@contato.nome}")
redirect_to admin_contatos_path, notice: 'Contato cadastrado.'
else
render :new, status: :unprocessable_entity
end
end
def edit
authorize @contato
end
def update
authorize @contato
if @contato.update(contato_params)
registrar("Editou contato #{@contato.nome}")
redirect_to admin_contatos_path, notice: 'Contato atualizado.'
else
render :edit, status: :unprocessable_entity
end
end
def destroy
authorize @contato
nome = @contato.nome
@contato.destroy
registrar("Excluiu contato #{nome}")
redirect_to admin_contatos_path, notice: 'Contato removido.'
end
private
def set_contato
@contato = Contato.find(params[:id])
end
def carregar_grupos
@grupos = GrupoContato.ativos.ordenados
end
def contato_params
params.require(:contato).permit(:nome, :telefone, :email, :grupo_contato_id, :ativo,
:observacao, :tipo, :whatsapp_grupo_jid, :whatsapp_grupo_nome)
end
def registrar(detalhes)
AuditoriaLog.registrar(user: current_user, acao: 'contato', entidade: 'Contato',
dados_novos: { detalhes: detalhes }, request: request)
end
end

View File

@@ -1,519 +0,0 @@
# app/controllers/admin/edicao_lancamentos_controller.rb
#
# Correção de lançamentos do SimpliRoute pelo ADM. Busca a entrega pela NF (na
# base de rastreio local), resolve o id numérico na API e grava a alteração
# DIRETO na API do SimpliRoute — registrando tudo no AuditoriaLog.
#
# ⚠️ A base de rastreio local (Entrega) é só leitura e sincroniza depois: a
# alteração só reflete no painel na próxima sincronização.
class Admin::EdicaoLancamentosController < ApplicationController
before_action :garantir_configurado
# Campos que o ADM pode editar (fase 1). checkout_observation = UUID do motivo.
CAMPOS_EDITAVEIS = %w[
status checkout_observation checkout_comment notes
checkout_time checkout_latitude checkout_longitude
].freeze
STATUS_VALIDOS = %w[pending partial completed failed canceled].freeze
def show
authorize :edicao_lancamento
@motivos = motivos_seguros
end
# GET /admin/edicao_lancamento/buscar?nf=79774 → JSON com TODOS os lançamentos
# daquela NF.
#
# ⚠️ Uma NF pode ter mais de um lançamento: quando o plano é duplicado no
# SimpliRoute nasce uma visita nova (outro tracking_id) com a mesma NF, e a
# antiga — a que tem o motorista/checkout — continua existindo. Por isso a
# busca devolve a LISTA e o ADM escolhe qual editar; assumir "1 NF = 1
# lançamento" escondia justamente o lançamento antigo.
#
# Com um único lançamento já vem o card montado, para não custar um segundo
# round-trip no caso comum.
#
# Aceita ?de=&ate= para varrer um período (a API só filtra por UM dia, então
# varremos dia a dia). Sem período, usa as datas que o espelho conhece.
def buscar
authorize :edicao_lancamento
nf = params[:nf].to_s.strip
return render_erro('Informe o número da NF.') if nf.blank?
entregas = Entrega.por_nf(nf).to_a
datas = datas_de_busca(entregas)
return render_erro(sem_datas_msg(nf, entregas)) if datas.empty?
falhas = []
ocorrencias = ocorrencias_da_nf(nf, entregas, datas, falhas)
unica = ocorrencias.first if ocorrencias.one? && ocorrencias.first[:id].present?
render json: {
ok: true,
ocorrencias: ocorrencias,
visita: (unica && payload_seguro(unica, entregas, falhas)),
periodo: { de: datas.first, ate: datas.last, dias: datas.size },
# Dias que a API não respondeu. Vão para a tela: uma busca que devolve
# menos do que existe TEM que dizer que devolveu menos.
falhas: falhas,
motivos: motivos_seguros
}
rescue SimpliRoute::NotFound => e
render_erro(e.message)
rescue SimpliRoute::Error => e
render_erro("Erro ao consultar o SimpliRoute: #{e.message}", status: :bad_gateway)
end
# GET /admin/edicao_lancamento/carregar?visit_id=871407488&tracking_id=SR...
# Monta o card de UM lançamento escolhido na lista de ocorrências.
def carregar
authorize :edicao_lancamento
id = params[:visit_id].to_s.strip
return render_erro('Lançamento não identificado.') if id.blank?
entrega = Entrega.find_by(tracking_id: params[:tracking_id]) if params[:tracking_id].present?
render json: { ok: true, visita: payload_visita(id, entrega), motivos: motivos_seguros }
rescue SimpliRoute::NotFound => e
render_erro(e.message)
rescue SimpliRoute::Error => e
render_erro("Erro ao consultar o SimpliRoute: #{e.message}", status: :bad_gateway)
end
# PATCH /admin/edicao_lancamento/atualizar
# HTML: fluxo de form clássico (fallback). JSON: salvar-por-campo do card
# (click-to-edit) — o fetch manda visit_id + só o campo editado.
def atualizar
authorize :edicao_lancamento
id = params[:visit_id].to_s.strip
return responder_erro('Visita não identificada.') if id.blank?
anterior = client.visita(id)
attrs = mudancas(anterior)
# Confirmar um valor idêntico não é erro no inline edit — só não há o que fazer.
if attrs.empty?
return respond_to do |format|
format.json { render json: { ok: true, campos: [], visita: {} } }
format.html { redirect_to admin_edicao_lancamento_path, alert: 'Nenhuma alteração informada.' }
end
end
if attrs['status'].present? && STATUS_VALIDOS.exclude?(attrs['status'])
return responder_erro('Status inválido.')
end
atualizada = client.atualizar_visita(id, attrs)
AuditoriaLog.registrar(
user: current_user,
acao: 'editar',
entidade: 'SimpliRoute::Visita',
entidade_id: id,
dados_anteriores: anterior.slice(*attrs.keys),
dados_novos: atualizada.slice(*attrs.keys),
request: request
)
# Mudança na operação feita POR DENTRO do sistema. A maioria das mudanças
# vem de fora e é pega pelo DetectarMudancasOperacaoJob; esta chega na hora
# e sabe exatamente qual NF e o que mudou.
Notificacao::Gatilhos.operacao_alterada(
operacao: anterior['title'].presence || 'SimpliRoute',
o_que_mudou: descrever_mudanca(anterior, atualizada, attrs.keys),
nf: anterior['reference']
)
respond_to do |format|
format.json do
render json: { ok: true, campos: attrs.keys, visita: atualizada.slice(*CAMPOS_EDITAVEIS) }
end
format.html do
redirect_to admin_edicao_lancamento_path,
notice: "Lançamento da NF #{atualizada['reference']} atualizado no SimpliRoute. " \
'O painel refletirá na próxima sincronização.'
end
end
rescue SimpliRoute::Error => e
responder_erro("Não foi possível atualizar: #{e.message}", status: :bad_gateway)
end
# GET /admin/edicao_lancamento/historico?visit_id=123 → JSON com as DUAS fontes:
# o histórico da API do SimpliRoute (best-effort, costuma vir vazio) e a nossa
# trilha do AuditoriaLog (quem editou, quando, de→para).
def historico
authorize :edicao_lancamento
id = params[:visit_id].to_s.strip
interno = AuditoriaLog.por_entidade('SimpliRoute::Visita')
.where(entidade_id: id)
.includes(:user).recentes.limit(50)
.map do |log|
{
usuario: log.user&.nome_display || '—',
acao: log.acao,
quando: log.created_at.in_time_zone.strftime('%d/%m/%Y %H:%M'),
de: log.dados_anteriores,
para: log.dados_novos
}
end
render json: { ok: true, api: client.historico(id), interno: interno }
end
private
# "status: pending → completed; observação: … → ÓBITO" — o texto que vai para
# a variável {{o_que_mudou}} do editor de blocos.
def descrever_mudanca(anterior, atualizada, campos)
Array(campos).filter_map do |campo|
de = anterior[campo].to_s.strip
para = atualizada[campo].to_s.strip
next if de == para
"#{campo}: #{de.presence || '(vazio)'}#{para.presence || '(vazio)'}"
end.join('; ').presence || 'lançamento atualizado'
end
def client
@client ||= SimpliRoute::Client.new
end
# Máximo de dias varridos numa busca por período. A API só filtra por UM dia,
# então cada dia é uma chamada — com `search` cada uma custa ~0,6 s e elas vão
# em lotes paralelos, o que torna dois meses viável; o teto existe para a tela
# não virar uma varredura sem fim.
MAX_DIAS_VARREDURA = 62
# Quais dias consultar na API:
# • com ?de=/?ate= — o período pedido (o SimpliRoute duplica plano para
# outra data, e aí o espelho não tem como saber qual é);
# • sem período — as datas que o espelho conhece (barato: 1 ou 2 chamadas).
def datas_de_busca(entregas)
de = data_param(:de)
ate = data_param(:ate)
if de || ate
inicio, fim = [de || ate, ate || de].minmax
(inicio..fim).first(MAX_DIAS_VARREDURA)
else
entregas.filter_map { |e| e.planned_date&.to_date }.uniq.sort
end
end
def data_param(chave)
valor = params[chave].to_s.strip
return nil if valor.blank?
Date.parse(valor)
rescue Date::Error
nil
end
# Sem datas não há o que consultar — e o motivo muda a saída para o ADM.
def sem_datas_msg(nf, entregas)
if entregas.empty?
"NF #{nf} não encontrada na base de rastreio. Informe um período para procurar direto no SimpliRoute."
else
"NF #{nf} está na base de rastreio mas sem data planejada. Informe um período para procurar na API."
end
end
# Todos os lançamentos da NF, cruzando as DUAS fontes:
# • espelho de rastreio (Entrega) — traz motorista/veículo;
# • API do SimpliRoute, nas datas varridas — pega a visita duplicada que a
# sincronização ainda não trouxe para o espelho.
def ocorrencias_da_nf(nf, entregas, datas, falhas)
trackings = entregas.map { |e| e.tracking_id.to_s }
do_dia = visitas_dos_dias(datas, nf, falhas)
visitas = do_dia.select { |v| v['reference'].to_s == nf || trackings.include?(v['tracking_id'].to_s) }
.uniq { |v| v['id'] }
visitas = com_tracking_id(visitas)
achadas = visitas.map { |v| ocorrencia_da_api(v, entrega_de(entregas, v['tracking_id'])) }
# Lançamento que existe no espelho mas não apareceu na API (data sem plano,
# visita removida): entra na lista como não editável, para o ADM ver que
# ele existe em vez de sumir silenciosamente — que é o bug que estamos
# corrigindo.
vistos = visitas.map { |v| v['tracking_id'].to_s }
orfas = entregas.reject { |e| vistos.include?(e.tracking_id.to_s) }
.map { |e| ocorrencia_do_espelho(e, datas.include?(e.planned_date&.to_date)) }
# Mais recente primeiro (a data vem como texto ISO da API e como timestamp
# do espelho — os 10 primeiros caracteres normalizam as duas).
(achadas + orfas).sort_by { |o| [o[:data].to_s[0, 10], o[:id].to_i] }.reverse
end
# Dias consultados de uma vez. Cada chamada abre a própria conexão HTTP (ver
# SimpliRoute::Client#requisicao) e não toca o banco, então dá para
# paralelizar em lotes; o lote pequeno evita martelar a API do SimpliRoute.
MAX_PARALELO_VARREDURA = 6
# Passa a NF como `search` — sem isso cada dia baixaria ~3,8 MB (o dia inteiro)
# e um mês seria inviável. Ver a nota em SimpliRoute::Client#visitas_da_data.
def visitas_dos_dias(datas, nf, falhas)
mutex = Mutex.new
datas.each_slice(MAX_PARALELO_VARREDURA).flat_map do |lote|
lote.map { |data| Thread.new { visitas_do_dia(data, nf, falhas, mutex) } }.flat_map(&:value)
end
end
# Um dia que a API não responde NÃO pode derrubar a busca inteira: registra a
# falha e segue com os outros dias. (Foi assim que uma NF com visitas em duas
# datas passou a não devolver nada em vez de devolver o que deu certo.)
def visitas_do_dia(data, nf, falhas, mutex)
client.visitas_da_data(data, busca: nf)
rescue SimpliRoute::Error => e
mutex.synchronize { falhas << { data: data.strftime('%d/%m/%Y'), erro: e.message } }
[]
end
# Abrir o card do lançamento único é conveniência: se falhar, a lista ainda
# tem que aparecer.
def payload_seguro(ocorrencia, entregas, falhas)
payload_visita(ocorrencia[:id], entrega_de(entregas, ocorrencia[:tracking_id]))
rescue SimpliRoute::Error => e
falhas << { data: ocorrencia[:data].to_s[0, 10], erro: "não foi possível abrir o lançamento: #{e.message}" }
nil
end
def entrega_de(entregas, tracking_id)
return nil if tracking_id.blank?
entregas.find { |e| e.tracking_id.to_s == tracking_id.to_s }
end
# Quantas visitas da lista vale a pena detalhar (1 GET cada). Uma NF com mais
# que isso é dado estranho, não plano duplicado.
MAX_DETALHES_LISTA = 5
# A lista-por-data nem sempre traz o `tracking_id`, e é ele que liga a visita
# ao espelho — de onde vêm motorista e veículo. Sem esse casamento a NF
# duplicada aparece com as duas linhas sem motorista, que é exatamente o dado
# que distingue uma da outra. Quando faltar, busca a visita completa.
def com_tracking_id(visitas)
visitas.each_with_index.map do |visita, i|
next visita if visita['tracking_id'].present? || i >= MAX_DETALHES_LISTA
detalhar(visita)
end
end
# Best-effort: se o detalhe falhar, segue com o que a lista deu.
def detalhar(visita)
client.visita(visita['id']).presence || visita
rescue SimpliRoute::Error
visita
end
# Resumo de um lançamento para a lista de escolha (não carrega fotos/detalhe —
# isso só acontece quando o ADM abre um).
def ocorrencia_da_api(visita, entrega)
{
id: visita['id'],
tracking_id: visita['tracking_id'],
titulo: visita['title'],
endereco: visita['address'],
status: visita['status'],
data: visita['planned_date'],
checkout_time: visita['checkout_time'],
motorista: entrega&.driver,
veiculo: entrega&.vehicle,
editavel: true,
# Sem linha no espelho não há como saber motorista/veículo: a API não
# devolve esses campos na visita. O ADM diferencia pelo status/checkout.
no_painel: entrega.present?,
aviso: entrega ? nil : 'Ainda não sincronizado no painel — motorista e veículo indisponíveis'
}
end
def ocorrencia_do_espelho(entrega, data_varrida)
{
id: nil,
tracking_id: entrega.tracking_id,
titulo: entrega.local,
endereco: entrega.address,
status: entrega.status,
data: entrega.planned_date,
checkout_time: entrega.checkout,
motorista: entrega.driver,
veiculo: entrega.vehicle,
editavel: false,
no_painel: true,
aviso: (if data_varrida
'Só no painel — não está entre as visitas desta data no SimpliRoute, então não dá para editar'
else
'Fora do período consultado — inclua a data deste lançamento no período para poder editá-lo'
end)
}
end
# Estado completo de UMA visita para o card (inclui a galeria de fotos, que
# custa uma chamada extra ao POD). `entrega` pode ser nil quando a visita
# ainda não sincronizou no espelho.
def payload_visita(id, entrega)
visita = client.visita(id)
# Segunda fonte de imagens (POD). Não é obrigatória: se falhar, vem {}.
detalhe = client.detalhe_visita(id)
{
id: id,
tracking_id: visita['tracking_id'],
nf: visita['reference'],
titulo: visita['title'],
endereco: visita['address'],
status: visita['status'],
checkout_observation: visita['checkout_observation'],
checkout_comment: visita['checkout_comment'],
notes: visita['notes'],
checkout_time: visita['checkout_time'],
checkout_latitude: visita['checkout_latitude'],
checkout_longitude: visita['checkout_longitude'],
planned_date: visita['planned_date'],
contato: visita['contact_name'],
telefone: visita['contact_phone'],
# Galeria unificada — TODAS as fotos do lançamento, de todas as fontes,
# cada uma etiquetada para o ADM saber o que está conferindo.
fotos: fotos_do_lancamento(entrega, visita, detalhe),
motorista: entrega&.driver,
veiculo: entrega&.vehicle
}
end
# Monta o hash de PATCH só com os campos que vieram no form E que realmente
# mudaram em relação ao estado atual — PATCH mínimo, sem sobrescrever à toa.
def mudancas(anterior)
CAMPOS_EDITAVEIS.each_with_object({}) do |campo, memo|
next unless params.key?(campo)
novo = normalizar(campo, params[campo])
next if equivalente?(campo, novo, anterior[campo])
memo[campo] = novo
end
end
# Evita PATCH falso: nil/"" são equivalentes; datas comparadas por instante;
# lat/long por valor numérico (o reparse difere do texto original da API).
def equivalente?(campo, novo, atual)
return true if novo.to_s == atual.to_s
case campo
when 'checkout_time'
a = (Time.zone.parse(novo.to_s) rescue nil)
b = (Time.zone.parse(atual.to_s) rescue nil)
a.present? && b.present? && a.to_i == b.to_i
when 'checkout_latitude', 'checkout_longitude'
novo.is_a?(Float) && atual.present? && (novo - atual.to_f).abs < 1e-9
else
false
end
end
# Normaliza valores por campo. Campos em branco viram nil (permite LIMPAR o
# motivo/comentário). checkout_time aceita datetime-local e vira ISO8601.
def normalizar(campo, valor)
valor = valor.to_s.strip
return nil if valor.blank?
case campo
when 'checkout_latitude', 'checkout_longitude'
Float(valor) rescue valor
when 'checkout_time'
(Time.zone.parse(valor)&.iso8601 rescue nil) || valor
else
valor
end
end
# Lista de motivos; nunca quebra a tela se a API estiver fora.
def motivos_seguros
client.observations
rescue SimpliRoute::Error
[]
end
def render_erro(msg, status: :unprocessable_entity)
render json: { ok: false, erro: msg }, status: status
end
# Erro do atualizar nos dois formatos (JSON p/ inline edit, HTML p/ fallback).
def responder_erro(msg, status: :unprocessable_entity)
respond_to do |format|
format.json { render json: { ok: false, erro: msg }, status: status }
format.html { redirect_to admin_edicao_lancamento_path, alert: msg }
end
end
# Só aceita URL http(s) — evita injetar lixo no <img src> do card (mesmo
# filtro do foto_url do OperacaoMetricas).
def url_imagem(valor)
url = valor.to_s.strip
url.match?(%r{\Ahttps?://}i) ? url : nil
end
# Campos de foto customizados do SimpliRoute (extra_field_values). É AQUI que
# ficam as fotos reais tiradas pelo motorista — o array `pictures` só traz uma
# imagem genérica. Mapa: campo da API => [rótulo p/ o ADM, categoria de cor].
# Campos fora deste mapa ainda aparecem (rótulo derivado do nome do campo).
FOTOS_EXTRA = {
'foto_prova_visita' => ['Prova da visita', 'prova'],
'foto_nf' => ['Nota Fiscal', 'nf'],
'foto_termo' => ['Termo', 'termo'],
'foto_relatorio2' => ['Relatório', 'relatorio']
}.freeze
# Reúne TODAS as imagens do lançamento numa lista única e etiquetada:
# 1. espelho de rastreio -> foto da fachada (mesma do mapa de operações)
# 2. extra_field_values -> fotos reais do motorista (foto_nf, prova, etc.)
# 3. pictures[]/signature -> imagens genéricas + assinatura
#
# A mesma foto pode vir de mais de uma fonte, então deduplica por URL mantendo
# a primeira etiqueta (a ordem abaixo é a mais informativa).
def fotos_do_lancamento(entrega, visita, detalhe)
brutas = []
brutas << [entrega.try(:foto_da_fachada), 'Fachada', 'fachada', 'Rastreio']
# Fotos customizadas — o /detail/ é a fonte mais completa; cai p/ a visita.
extras = detalhe['extra_field_values'].presence || visita['extra_field_values'] || {}
extras.each do |campo, valor|
rotulo, cat = FOTOS_EXTRA[campo] || [rotulo_do_campo(campo), 'extra']
brutas << [valor, rotulo, cat, 'SimpliRoute']
end
[[visita, 'Visita'], [detalhe, 'Comprovante']].each do |fonte, origem|
Array(fonte['pictures']).each { |u| brutas << [u, 'Comprovante', 'entrega', origem] }
brutas << [fonte['signature'], 'Assinatura', 'assinatura', origem]
end
vistas = Set.new
brutas.filter_map do |valor, rotulo, cat, origem|
url = url_imagem(valor)
next if url.nil? || !vistas.add?(url)
{ url: url, rotulo: rotulo, cat: cat, origem: origem }
end
end
# "foto_relatorio2" -> "Relatorio2". Fallback p/ campos de foto não mapeados.
def rotulo_do_campo(campo)
campo.to_s.sub(/\Afoto_?/, '').tr('_', ' ').strip.capitalize.presence || campo.to_s
end
# Sem token não há o que fazer — avisa e volta.
def garantir_configurado
return if SimpliRoute.configurado?
msg = 'Integração com o SimpliRoute não configurada (defina SIMPLIROUTE_TOKEN no servidor).'
respond_to do |format|
format.html { redirect_to dashboard_path, alert: msg }
format.json { render json: { ok: false, erro: msg }, status: :service_unavailable }
end
end
end

View File

@@ -1,140 +0,0 @@
# app/controllers/admin/eventos_notificacao_controller.rb
#
# Cadastro dos eventos e de quais grupos assinam cada um.
#
# O ADM cria quantos eventos quiser, mas o GATILHO sai de EventoNotificacao::
# GATILHOS — gatilho é código. `manual` cobre o aviso pontual (o texto é
# digitado na hora do disparo) e `agendado` cobre o resumo periódico.
class Admin::EventosNotificacaoController < ApplicationController
before_action :set_evento, only: %i[edit update destroy disparar]
before_action :carregar_grupos, only: %i[new create edit update]
def index
authorize EventoNotificacao
@eventos = policy_scope(EventoNotificacao)
.ordenados.includes(assinaturas: :grupo_contato)
end
def new
@evento = EventoNotificacao.new(gatilho: 'manual', ativo: true, hora: 8)
authorize @evento
montar_assinaturas
end
def create
@evento = EventoNotificacao.new(evento_params)
authorize @evento
if @evento.save
salvar_assinaturas
registrar("Criou evento de notificação #{@evento.nome}")
redirect_to admin_eventos_path, notice: 'Evento criado.'
else
montar_assinaturas
render :new, status: :unprocessable_entity
end
end
def edit
authorize @evento
montar_assinaturas
end
def update
authorize @evento
# `chave` é o que o código usa para achar o evento no disparo. Deixar o ADM
# renomear a chave de um evento de sistema quebraria o gatilho em silêncio.
permitidos = @evento.sistema? ? evento_params.except(:chave, :gatilho) : evento_params
if @evento.update(permitidos)
salvar_assinaturas
registrar("Editou evento de notificação #{@evento.nome}")
redirect_to admin_eventos_path, notice: 'Evento atualizado.'
else
montar_assinaturas
render :edit, status: :unprocessable_entity
end
end
def destroy
authorize @evento
nome = @evento.nome
@evento.destroy
registrar("Excluiu evento de notificação #{nome}")
redirect_to admin_eventos_path, notice: 'Evento removido.'
end
# Disparo manual: manda mensagem DE VERDADE para todos os grupos assinantes.
# O texto é digitado na hora — o editor de blocos ainda não existe.
def disparar
authorize @evento, :disparar?
corpo = params[:corpo].to_s.strip
# Com mensagem montada no editor o texto é opcional: o template vence e o
# corpo digitado só serve de reserva para o canal que ainda não tem um.
com_template = MensagemTemplate::CANAIS.any? { |c| @evento.template_utilizavel(c) }
if corpo.blank? && !com_template
return redirect_to(admin_eventos_path, alert: 'Escreva a mensagem ou monte o template antes de disparar.')
end
Notificacao::Despachante.disparar(chave: @evento.chave,
dados: Notificacao::Variaveis.comuns_reais,
assunto: params[:assunto].presence || @evento.nome,
corpo: corpo)
registrar("Disparou manualmente o evento #{@evento.nome}")
redirect_to admin_envios_path,
notice: 'Disparo agendado. Acompanhe o resultado aqui.'
end
private
def set_evento
@evento = EventoNotificacao.find(params[:id])
end
def carregar_grupos
@grupos = GrupoContato.ativos.ordenados
end
# Uma linha por grupo ativo, marcada quando já existe assinatura. É o que faz
# a tela mostrar TODOS os grupos com checkbox, e não só os já assinados.
def montar_assinaturas
existentes = @evento.assinaturas.index_by(&:grupo_contato_id)
@linhas_assinatura = @grupos.map do |grupo|
{ grupo: grupo, assinatura: existentes[grupo.id] }
end
end
def salvar_assinaturas
# ActionController::Parameters, indexado pela id do grupo como STRING (é
# assim que o form serializa: assinaturas[12][ativo]). Nada de `to_h` aqui:
# em params não permitidos ele levanta.
marcadas = params.fetch(:assinaturas, {})
GrupoContato.ativos.find_each do |grupo|
dados = marcadas[grupo.id.to_s]
atual = @evento.assinaturas.find_by(grupo_contato_id: grupo.id)
if dados.present? && dados[:ativo] == '1'
canal = GrupoEventoAssinatura::CANAIS.include?(dados[:canal]) ? dados[:canal] : 'ambos'
atual ? atual.update(canal: canal, ativo: true)
: @evento.assinaturas.create(grupo_contato: grupo, canal: canal, ativo: true)
elsif atual
atual.destroy
end
end
end
def evento_params
params.require(:evento_notificacao)
.permit(:nome, :chave, :gatilho, :descricao, :ativo, :notificar_envolvido,
:frequencia, :hora, :dia_semana)
end
def registrar(detalhes)
AuditoriaLog.registrar(user: current_user, acao: 'evento_notificacao',
entidade: 'EventoNotificacao',
dados_novos: { detalhes: detalhes }, request: request)
end
end

View File

@@ -1,74 +0,0 @@
# app/controllers/admin/grupos_contato_controller.rb
#
# Grupos de destinatários. É o grupo que assina os eventos, então cadastrar
# alguém novo é escolher o grupo — não repetir a configuração pessoa por pessoa.
class Admin::GruposContatoController < ApplicationController
before_action :set_grupo, only: %i[edit update destroy]
def index
authorize GrupoContato
@grupos = policy_scope(GrupoContato).ordenados
.includes(:contatos, assinaturas: :evento_notificacao)
end
def new
@grupo = GrupoContato.new
authorize @grupo
end
def create
@grupo = GrupoContato.new(grupo_params)
authorize @grupo
if @grupo.save
registrar("Criou grupo de contatos #{@grupo.nome}")
redirect_to admin_grupos_path, notice: 'Grupo criado.'
else
render :new, status: :unprocessable_entity
end
end
def edit
authorize @grupo
end
def update
authorize @grupo
if @grupo.update(grupo_params)
registrar("Editou grupo de contatos #{@grupo.nome}")
redirect_to admin_grupos_path, notice: 'Grupo atualizado.'
else
render :edit, status: :unprocessable_entity
end
end
def destroy
authorize @grupo
# `dependent: :nullify`: os contatos NÃO são apagados junto — ficam sem
# grupo e param de receber, o que é reversível. Apagar pessoas porque o
# grupo saiu seria destrutivo demais para um clique.
nome = @grupo.nome
orfaos = @grupo.contatos.count
@grupo.destroy
registrar("Excluiu grupo de contatos #{nome} (#{orfaos} contato(s) ficaram sem grupo)")
redirect_to admin_grupos_path,
notice: "Grupo removido. #{orfaos} contato(s) ficaram sem grupo e não recebem mais."
end
private
def set_grupo
@grupo = GrupoContato.find(params[:id])
end
def grupo_params
params.require(:grupo_contato).permit(:nome, :descricao, :ativo)
end
def registrar(detalhes)
AuditoriaLog.registrar(user: current_user, acao: 'grupo_contato', entidade: 'GrupoContato',
dados_novos: { detalhes: detalhes }, request: request)
end
end

View File

@@ -1,75 +0,0 @@
# app/controllers/admin/mensagem_templates_controller.rb
#
# Editor de blocos: monta o corpo de UM evento em UM canal.
#
# O preview roda no SERVIDOR, chamando o mesmo Notificacao::Renderizador do
# envio. Uma segunda implementação em JavaScript ficaria mais rápida e
# inevitavelmente divergiria do que é enviado de verdade — e o preview existe
# justamente para prometer o contrário.
class Admin::MensagemTemplatesController < ApplicationController
before_action :carregar
def edit
authorize @evento, :update?
end
def update
authorize @evento, :update?
@template.assign_attributes(atributos)
if @template.save
AuditoriaLog.registrar(user: current_user, acao: 'mensagem_template',
entidade: 'MensagemTemplate',
dados_novos: { detalhes: "Editou a mensagem de #{@evento.nome} (#{@canal})" },
request: request)
redirect_to template_admin_evento_path(@evento, canal: @canal), notice: 'Mensagem salva.'
else
render :edit, status: :unprocessable_entity
end
end
# Renderiza o que está NA TELA (ainda não salvo) com os valores de amostra.
def preview
authorize @evento, :update?
provisorio = MensagemTemplate.new(evento_notificacao: @evento, canal: @canal,
assunto: params[:assunto], blocos: blocos_do_form, ativo: true)
provisorio.validate # dispara a limpeza dos blocos antes de renderizar
dados = Notificacao::CatalogoVariaveis.amostra(@evento.gatilho)
render json: { html: corpo_do_preview(provisorio, dados),
assunto: provisorio.renderizador(dados).interpolar(provisorio.assunto) }
end
private
def carregar
@evento = EventoNotificacao.find(params[:id])
@canal = MensagemTemplate::CANAIS.include?(params[:canal]) ? params[:canal] : 'whatsapp'
@template = @evento.template(@canal)
@variaveis = @evento.variaveis
end
def atributos
{ assunto: params[:assunto].to_s.strip,
blocos: blocos_do_form,
ativo: params[:ativo] == '1' }
end
# O editor serializa o estado num campo hidden. JSON inválido vira lista
# vazia: salvar lixo seria pior do que salvar nada, e o model ainda descarta
# bloco fora do catálogo.
def blocos_do_form
JSON.parse(params[:blocos].to_s)
rescue JSON::ParserError
[]
end
# E-mail: HTML já montado e escapado pelo renderizador — vai para a tela como
# está, dentro de um iframe sandbox na view. WhatsApp: texto puro, que a view
# escapa.
def corpo_do_preview(template, dados)
r = template.renderizador(dados)
template.email? ? r.html : r.texto
end
end

View File

@@ -1,23 +0,0 @@
# app/controllers/admin/notificacao_envios_controller.rb
#
# Log de envios — responde "o contato recebeu?". Sem esta tela, a resposta só
# existia no log do Rails, porque o disparo engole a exceção de propósito (um
# SMTP fora do ar não pode travar o fechamento de um pagamento).
class Admin::NotificacaoEnviosController < ApplicationController
POR_PAGINA = 50
def index
authorize NotificacaoEnvio
escopo = policy_scope(NotificacaoEnvio).includes(:evento_notificacao, :contato, :user)
escopo = escopo.where(status: params[:status]) if NotificacaoEnvio::STATUS.include?(params[:status])
escopo = escopo.where(canal: params[:canal]) if NotificacaoEnvio::CANAIS.include?(params[:canal])
@total_falhas = escopo.falhados.count
begin
@pagy, @envios = pagy(escopo.recentes, limit: POR_PAGINA)
rescue Pagy::OverflowError
@pagy, @envios = pagy(escopo.recentes, limit: POR_PAGINA, page: 1)
end
end
end

View File

@@ -1,110 +0,0 @@
# app/controllers/admin/perfis_acesso_controller.rb
#
# CRUD dos perfis de acesso. É aqui que o ADM decide o que cada função enxerga,
# sem depender de deploy: o catálogo do que pode ser marcado é código
# (Permissao::TODAS), a escolha é dado (PerfilAcesso#permissoes).
module Admin
class PerfisAcessoController < ApplicationController
include Auditavel
before_action :set_perfil, only: %i[edit update destroy]
def index
authorize PerfilAcesso
@perfis = policy_scope(PerfilAcesso).ordenados.includes(:users)
end
def new
@perfil = PerfilAcesso.new(ativo: true, permissoes: [])
authorize @perfil
end
def create
@perfil = PerfilAcesso.new(perfil_params)
authorize @perfil
if @perfil.save
auditar!(:criar, @perfil, dados_novos: { nome: @perfil.nome, permissoes: @perfil.permissoes })
redirect_to admin_perfis_path, notice: "Perfil #{@perfil.nome} criado."
else
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
anteriores = @perfil.permissoes
# Ordem importa: aplicamos os valores em memória para a trava conferir o
# que o ADM ESTÁ salvando, não o que está no banco.
@perfil.assign_attributes(perfil_params)
if travaria_o_sistema?
@perfil.errors.add(:base, mensagem_da_trava)
return render(:edit, status: :unprocessable_entity)
end
if @perfil.save
auditar!(:editar, @perfil,
dados_anteriores: { permissoes: anteriores },
dados_novos: { permissoes: @perfil.permissoes, ativo: @perfil.ativo })
redirect_to admin_perfis_path, notice: "Perfil #{@perfil.nome} atualizado."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
unless @perfil.apagavel?
return redirect_to(admin_perfis_path, alert: motivo_para_nao_apagar)
end
@perfil.destroy
auditar!(:excluir, @perfil, dados_anteriores: { nome: @perfil.nome })
redirect_to admin_perfis_path, notice: 'Perfil excluído.'
end
private
def set_perfil
@perfil = PerfilAcesso.find(params[:id])
authorize @perfil
end
def perfil_params
dados = params.require(:perfil_acesso).permit(:nome, :descricao, :ativo, permissoes: [])
# O form manda um campo vazio para o caso de NENHUMA caixa marcada (senão o
# parâmetro some e "desmarcar tudo" viraria "não mexer em nada").
dados[:permissoes] = Permissao.filtrar(dados[:permissoes])
dados
end
# ── Trava do último administrador ────────────────────────
# Mesma regra da tela de usuários (User.administradores_de_acesso): salvar um
# perfil que perde `admin.perfis`/`admin.usuarios_gerenciar` (ou é desativado)
# não pode deixar o sistema sem quem conserte.
def travaria_o_sistema?
return false if @perfil.administra_acesso? # continua podendo: nada a checar
return false unless @perfil.users.ativos.exists? # perfil vazio não tira acesso de ninguém
# Quem está NESTE perfil perderá o acesso; sobra alguém fora dele?
# Usuário sem perfil conta: cai no padrão do papel (admin legado administra).
restantes = User.administradores_de_acesso.reject { |u| u.perfil_acesso_id == @perfil.id }
restantes.empty?
end
def mensagem_da_trava
'Esta alteração deixaria o sistema sem nenhum usuário ativo capaz de gerenciar ' \
'perfis e usuários. Garanta que outra pessoa mantenha esse acesso antes de salvar.'
end
def motivo_para_nao_apagar
if @perfil.sistema?
'Perfis que vêm com o sistema não podem ser excluídos — desative-o ou edite as permissões.'
else
"Este perfil está em uso por #{@perfil.users.count} usuário(s). Mova essas pessoas para outro perfil antes de excluir."
end
end
end
end

View File

@@ -1,43 +0,0 @@
# app/controllers/admin/planilhas_simpliroute_controller.rb
#
# Gera a planilha de carga do SimpliRoute (.xlsx) a partir de UMA operação
# vigente. Lat/long são preenchidas com o histórico do mês anterior
# (SimpliRoute::PlanilhaCarga). Somente leitura das bases.
class Admin::PlanilhasSimpliRouteController < ApplicationController
XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'.freeze
def show
authorize :planilha_simpli_route
@operacoes_agrupadas = Operacao.agrupadas_por_mes
end
# GET /admin/planilha_simpli_route/baixar?tabela=gade_entregas_...
def baixar
authorize :planilha_simpli_route
tabela = params[:tabela].to_s
unless Operacao.valida?(tabela)
return redirect_to(admin_planilha_simpli_route_path, alert: 'Selecione uma operação válida.')
end
carga = SimpliRoute::PlanilhaCarga.new(tabela)
linhas = carga.linhas
if linhas.empty?
return redirect_to(admin_planilha_simpli_route_path,
alert: "A operação #{carga.label} não tem registros.")
end
binario = SimpliRoute::PlanilhaCargaXlsx.new(linhas).gerar
send_data binario,
filename: "carga_simpliroute_#{nome_arquivo(carga.label)}.xlsx",
type: XLSX_MIME,
disposition: 'attachment'
end
private
def nome_arquivo(label)
label.to_s.parameterize(separator: '_').presence || 'operacao'
end
end

View File

@@ -1,482 +0,0 @@
# app/controllers/admin/romaneios_controller.rb
#
# Romaneio "CONTROLE DE ENTREGA": importa o plano do dia, deixa o operador revisar a
# tabela com a prévia do PDF real ao lado, e imprime uma folha A4 paisagem por
# veículo.
#
# As bases externas (espelho de rastreio e gade_entregas_*) continuam SOMENTE
# LEITURA — o que gravamos são as tabelas `romaneio*`, nossas.
# FileUtils chegava aqui de carona no que o Rails carrega. Explícito porque o
# upload do logo depende dele: sem o require, o dia em que o Rails parar de
# carregá-lo o erro é um NameError no meio da gravação.
require 'fileutils'
class Admin::RomaneiosController < ApplicationController
include Auditavel
# ⚠️ TODA ACTION DE COLEÇÃO PRECISA ENTRAR NESTE `except`. `carregar_romaneio`
# faz `Romaneio.find(params[:id])`, e numa rota de coleção não existe `:id` —
# o find levanta RecordNotFound, que o Rails traduz em **404**. Não é erro de
# rota: o roteador casa certinho e a action simplesmente nunca roda.
#
# Foi o bug de 28/08/2026: `atualizar_logo` foi adicionada ao `collection` do
# routes.rb mas esquecida aqui, e clicar em "Trocar logo" devolvia
# `POST /admin/romaneios/atualizar_logo 404 (Not Found)`. Como o 404 parece
# "rota errada", a busca foi para todo lado menos para cá — e o sintoma que o
# operador via era outro ("subi o logo e o PDF não mudou"), porque o upload
# nunca chegava a acontecer.
before_action :carregar_romaneio, except: %i[index create importar_planilha atualizar_logo]
def index
authorize Romaneio
# includes: a listagem mostra a contagem de veículos de cada romaneio.
@romaneios = Romaneio.recentes.includes(:romaneio_veiculos).limit(50)
@operacoes_agrupadas = Operacao.agrupadas_por_mes
@data_padrao = Date.current
# Os planos para o operador ESCOLHER em vez de adivinhar a data.
#
# Sem período: os 5 mais recentes — a ferramenta é usada logo depois de
# lançar uma operação, então o que ele quer está sempre entre os últimos, e
# o caso comum fica em zero clique.
# Com período: TODOS os planos da faixa. É assim que se alcança um plano
# antigo sem saber o dia dele — e é o mesmo recorte da tela do SimpliRoute,
# para o operador não ter que traduzir nada entre as duas.
@plano_inicio = params[:plano_inicio].presence
@plano_fim = params[:plano_fim].presence
@planos = SimpliRoute::Client.new.planos(limite: 5, inicio: @plano_inicio, fim: @plano_fim)
@planos_filtrados = @plano_inicio.present? && @plano_fim.present?
# O logo escolhido sumiu do disco? A tela precisa dizer — foi exatamente
# assim que o PDF saiu com o logo antigo sem ninguém entender (28/08/2026).
_, @logo_problema = Pdf::RomaneioPdf.logo_configurado
# Nome ORIGINAL do logo escolhido (a descricao), para a tela mostrar o mesmo
# que o operador enviou; nil = ainda usando o logo da GADE do repositório.
@logo_atual = Configuracao.find_by(chave: 'romaneio_logo')&.descricao
end
def show
authorize @romaneio
@veiculos = @romaneio.romaneio_veiculos.ordenados.to_a
@veiculo = veiculo_selecionado
@linhas = @romaneio.linhas_vigentes.do_veiculo(@veiculo).to_a if @veiculo
@removidas = @romaneio.linhas_removidas.to_a
@colunas_ausentes = colunas_ausentes_da_operacao
# Mesma lista da tela de criação: o vínculo manual da operação acontece aqui
# também, porque é aqui que se descobre que APARELHO saiu vazio.
@operacoes_agrupadas = Operacao.agrupadas_por_mes
# A prévia aberta é ESTADO NA URL (?previa=1), não estado de navegador. Ver o
# comentário em _previa.html.erb: hash (#previa) não sobrevive ao Turbo e
# checkbox não sobrevive à troca de folha.
@previa = params[:previa].present?
end
# Caminho A: busca o plano no SimpliRoute (API, com queda para o espelho).
def create
authorize Romaneio
@romaneio = preparar_romaneio
return if performed?
# `@plano_escolhido` vem de #preparar_romaneio quando o operador clicou num
# plano da lista; sem ele o caminho continua sendo por data.
# Os DOIS: a data já foi resolvida em #preparar_romaneio (não paga a chamada
# de novo) e ainda destrava a queda para o espelho local se a API cair no
# meio; o plano é quem delimita as rotas.
plano = Romaneios::PlanoDoDia.new(data: @romaneio.planned_date, plano: @plano_escolhido)
importar(plano.linhas, origem: plano.origem)
rescue SimpliRoute::Error => e
# NotFound herda de Error: a mensagem dele já diz para enviar o .xlsx.
descartar_romaneio_vazio
redirect_to admin_romaneios_path, alert: e.message
end
# Caminho B: upload do .xlsx do plano, para quando o plano do dia ainda não foi
# publicado ou a API não responde.
def importar_planilha
authorize Romaneio
@romaneio = preparar_romaneio
return if performed?
arquivo = params[:arquivo]
if arquivo.blank?
descartar_romaneio_vazio
return redirect_to(admin_romaneios_path, alert: 'Selecione a planilha do plano (.xlsx).')
end
importar(Romaneios::PlanoPlanilha.new(arquivo).linhas, origem: 'planilha')
rescue Romaneios::PlanoPlanilha::Erro => e
descartar_romaneio_vazio
redirect_to admin_romaneios_path, alert: e.message
end
# ── Logo do cabeçalho ──────────────────────────────────────────────────────
# O romaneio é documento do CLIENTE, não da Reem: quando a operação muda de
# contratante, trocar o logo não pode exigir deploy.
#
# PNG/JPG apenas porque é o que o Prawn embute — um SVG aceito aqui sairia como
# folha SEM logo, sem erro nenhum, e ninguém descobriria antes de imprimir.
# ⚠️ O FORMATO É DECIDIDO PELO CONTEÚDO DO ARQUIVO, não pelo `content_type`
# que o navegador manda. O content_type é entrada do usuário: Windows e alguns
# navegadores mandam `application/octet-stream` para um PNG perfeitamente
# válido, e a versão antiga (`LOGO_TIPOS[arquivo.content_type]`) RECUSAVA esse
# arquivo dizendo "precisa ser PNG ou JPG" — para um PNG. Assinatura de arquivo
# não mente.
#
# A lista é curta porque quem manda aqui é o Prawn: ele embute **PNG e JPEG**,
# e só. Formato que ele não embute não pode ser aceito "para ver no que dá" —
# viraria um PDF quebrado na hora de imprimir, ou um logo que some sem aviso.
# Os outros formatos são RECONHECIDOS mesmo assim, para a mensagem poder dizer
# o que a pessoa mandou e o que fazer, em vez de repetir "PNG ou JPG".
ASSINATURAS = [
["\x89PNG\r\n\x1a\n".b, '.png', 'PNG', true],
["\xFF\xD8\xFF".b, '.jpg', 'JPEG', true],
['RIFF'.b, nil, 'WebP', false], # confirmado abaixo pelo 'WEBP'
['GIF8'.b, nil, 'GIF', false],
['BM'.b, nil, 'BMP', false],
['%PDF'.b, nil, 'PDF', false],
['<?xml'.b, nil, 'SVG', false],
['<svg'.b, nil, 'SVG', false]
].freeze
LOGO_TAMANHO_MAX = 2.megabytes
def atualizar_logo
authorize Romaneio
arquivo = params[:logo]
return redirect_to(admin_romaneios_path, alert: 'Escolha o arquivo do logo (PNG ou JPG).') if arquivo.blank?
# `params[:logo]` só é um arquivo se o <form> for multipart. Sem isto, um
# form mal montado manda uma String e a linha seguinte estoura com
# NoMethodError — página 500 em vez de "escolha o arquivo".
unless arquivo.respond_to?(:read) && arquivo.respond_to?(:size)
return redirect_to(admin_romaneios_path,
alert: 'O arquivo não chegou. Escolha o logo e envie de novo.')
end
if arquivo.size > LOGO_TAMANHO_MAX
return redirect_to(admin_romaneios_path,
alert: 'Logo acima de 2 MB. Reduza a imagem antes de enviar.')
end
# Lê tudo UMA vez: o mesmo conteúdo serve para identificar o formato e para
# gravar. Reler o upload depois de `read` devolveria vazio.
conteudo = arquivo.read.to_s.b
extensao, formato, aceito = identificar_imagem(conteudo)
if extensao.nil?
recado = if formato
"O arquivo é #{formato}, e o PDF só embute PNG ou JPG. " \
'Salve o logo como PNG (qualquer editor exporta) e envie de novo.'
else
'Não reconheci esse arquivo como imagem. O logo precisa ser PNG ou JPG.'
end
return redirect_to(admin_romaneios_path, alert: recado)
end
# `aceito` existe para o dia em que alguém adicionar conversão: hoje só
# chega aqui o que o Prawn embute.
return redirect_to(admin_romaneios_path, alert: 'Formato não suportado.') unless aceito
# Nome GERADO por nós: o nome que vem do navegador é entrada do usuário e não
# entra em caminho de arquivo. O original fica só na descrição, para a tela
# mostrar "Logo: LOGOGADE.png" como o operador reconhece.
nome = "logo-#{Time.current.strftime('%Y%m%d%H%M%S')}#{extensao}"
destino = Pdf::RomaneioPdf.pasta_logos
# ⚠️ A GRAVAÇÃO PODE FALHAR, E FALHAR AQUI NÃO É "bug do Rails": `storage/`
# mora no share SMB do NAS, onde permissão de pasta se perde na cópia (o
# mesmo motivo que fez o bin/docker-boot ser chamado com `bash` explícito).
# Sem este rescue, um EACCES vira a página 500 crua na cara do operador, sem
# dizer o que houve nem o que fazer — foi o que aconteceu em 28/08/2026 ao
# clicar em "Trocar logo".
begin
FileUtils.mkdir_p(destino)
File.binwrite(destino.join(nome), conteudo)
rescue SystemCallError => e
Rails.logger.error("[romaneio] falha ao gravar logo em #{destino}: #{e.class}: #{e.message}")
return redirect_to(admin_romaneios_path,
alert: "Não consegui gravar o logo em #{destino} (#{e.class}). " \
'A pasta existe e o container tem permissão de escrita nela? ' \
'O erro completo está no log do container.')
end
config = Configuracao.find_or_initialize_by(chave: 'romaneio_logo')
config.update!(valor: nome, descricao: arquivo.original_filename.to_s.first(120),
updated_by: current_user&.id)
redirect_to admin_romaneios_path,
notice: "Logo atualizado: #{arquivo.original_filename}. Vale para os próximos PDFs."
end
# Rebusca o plano e reconcilia com o que já está editado (a regra está em
# Romaneios::Importador).
def reimportar
authorize @romaneio
# Reimportar com arquivo em mãos usa o arquivo; sem arquivo, volta ao
# SimpliRoute — inclusive para um romaneio que nasceu de planilha (é justamente
# assim que o operador troca o plano B pelo A quando a API volta).
if params[:arquivo].present?
plano = Romaneios::PlanoPlanilha.new(params[:arquivo])
linhas = plano.linhas
origem = 'planilha'
else
plano = Romaneios::PlanoDoDia.new(data: @romaneio.planned_date)
linhas = plano.linhas
origem = plano.origem
end
resultado = aplicar(linhas, origem: origem)
auditar!(:reimportar, @romaneio, dados_novos: resultado.to_h)
redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]),
notice: "Plano reimportado — #{resultado.resumo}."
rescue SimpliRoute::Error, Romaneios::PlanoPlanilha::Erro => e
redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]), alert: e.message
end
# Vincula a operação do mês à mão e preenche APARELHO/TELEFONES/NOME a partir
# dela. É a saída para quando o casamento pelo nome do plano não acontece — sem
# isso o único caminho era reimportar, que depende da API responder ou de o
# operador ainda ter o .xlsx do plano.
def vincular_operacao
authorize @romaneio
resultado = Romaneios::VinculadorDeOperacao.new(romaneio: @romaneio,
tabela: params[:operacao_tabela]).aplicar!
auditar!(:vincular_operacao, @romaneio,
dados_novos: resultado.to_h.merge(operacao_tabela: @romaneio.operacao_tabela))
# Sem a coluna STATUS a operação não sabe quem é NOVO: o vínculo deu certo e
# mesmo assim ninguém recebe aparelho. Dizer isso aqui evita a leitura de que
# "vinculei e continua vazio, logo o sistema está errado".
aviso = if resultado.colunas_ausentes.include?('status')
" Atenção: esta operação não tem a coluna STATUS, então APARELHO continua vazio e precisa ser preenchido à mão."
else
''
end
redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]),
notice: "Operação #{@romaneio.operacao_label} vinculada — #{resultado.resumo}.#{aviso}"
rescue ArgumentError
redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]),
alert: 'Selecione uma operação válida.'
rescue Romaneios::VinculadorDeOperacao::Conflito => e
redirect_to admin_romaneio_path(@romaneio, veiculo: params[:veiculo]),
alert: "#{e.message} Abra o romaneio de " \
"#{l e.existente.planned_date, format: '%d/%m/%Y'} (#{e.existente.rotulo_plano.presence || 'sem rótulo'}) " \
'em vez de duplicar o documento do dia.'
end
# Com ?veiculo= sai INLINE (é a prévia do editor, uma folha só, sub-100ms);
# sem veiculo sai o romaneio inteiro como download.
def pdf
authorize @romaneio
veiculo = params[:veiculo].to_s
binario = Pdf::RomaneioPdf.new(romaneio: @romaneio,
veiculos: veiculo.presence && [veiculo]).render
# A prévia é regerada a cada edição: cache do navegador aqui devolveria o PDF
# anterior e o operador acharia que a alteração não gravou.
expires_now if veiculo.present?
send_data binario, type: 'application/pdf',
disposition: veiculo.present? ? 'inline' : 'attachment',
filename: nome_do_arquivo(veiculo)
end
# JSON — grava UM campo de UMA parada. `valor: null` restaura o que o plano
# trouxe (e destrava o campo para a próxima reimportação).
def atualizar_linha
authorize @romaneio
linha = @romaneio.romaneio_linhas.find(params[:linha_id])
campo = params[:campo].to_s
unless RomaneioLinha::CAMPOS_EDITAVEIS.include?(campo)
return render json: { ok: false, erro: 'Campo não editável.' }, status: :unprocessable_entity
end
if params[:valor].nil?
linha[campo] = linha.valor_do_plano(campo)
linha.desmarcar_editado!(campo)
else
linha[campo] = params[:valor].to_s
linha.marcar_editado!(campo)
end
linha.save!
# Devolvemos o valor GRAVADO e o JS o reescreve no input: a gravação é
# last-write-wins, e assim uma sobreposição de duas abas fica visível em vez
# de silenciosa.
render json: { ok: true, valor: linha[campo], editado: linha.editado?(campo) }
end
def atualizar_veiculo
authorize @romaneio
registro = @romaneio.romaneio_veiculos.find_by!(veiculo: params[:veiculo].to_s)
registro.update!(motorista: params[:motorista].to_s)
render json: { ok: true, valor: registro.motorista }
end
def atualizar_rotulo
authorize @romaneio
@romaneio.update!(rotulo_plano: params[:rotulo_plano].to_s)
render json: { ok: true, valor: @romaneio.rotulo_plano }
end
def destroy
authorize @romaneio
# Auditar ANTES: depois do destroy não há mais atributos para registrar.
auditar!(:excluir, @romaneio, dados_anteriores: @romaneio.attributes)
@romaneio.destroy!
redirect_to admin_romaneios_path, notice: 'Romaneio excluído.'
end
private
def carregar_romaneio
@romaneio = Romaneio.find(params[:id])
end
# Quais colunas a operação NÃO tem. Perguntamos ao serviço em vez de deduzir de
# "a coluna APARELHO saiu toda vazia": vazio também é o resultado legítimo de uma
# operação só com pacientes recorrentes, e um aviso que erra o motivo é pior do
# que nenhum.
def colunas_ausentes_da_operacao
return [] if @romaneio.operacao_tabela.blank?
Romaneios::EnriquecimentoGade.new(@romaneio.operacao_tabela).colunas_ausentes
rescue ArgumentError
# Operação removida do banco depois que o romaneio foi criado.
Romaneios::EnriquecimentoGade::COLUNAS.keys
end
# Acha (ou cria) o romaneio da data + operação. `find_or_create_by!` é o que faz
# "importar de novo o mesmo dia" cair na reconciliação em vez de criar um segundo
# romaneio — a chave única (planned_date, operacao_tabela) é a garantia disso.
def preparar_romaneio
# ── Plano escolhido na lista (caminho normal desde 28/08/2026) ──────────
# O plano resolve de uma vez os três campos que antes eram digitados: a
# DATA (perguntada à API, porque a janela do plano não a revela), o NOME do
# plano (que ia colado à mão e é o que descobre a operação do mês) e as
# rotas, que delimitam o romaneio quando dois planos caem no mesmo dia.
@plano_escolhido = nil
data = params[:planned_date].presence
if params[:plano_id].present?
@plano_escolhido = SimpliRoute::Client.new.plano(params[:plano_id])
if @plano_escolhido.nil?
redirect_to admin_romaneios_path,
alert: 'Plano não encontrado no SimpliRoute — recarregue a página e escolha de novo.'
return nil
end
# A data REAL do plano, não a janela dele. Sem isso o romaneio nasceria
# com `planned_date` errado e o "Reimportar" (que é por data) buscaria o
# dia errado depois.
resolvida = Romaneios::PlanoDoDia.new(plano: @plano_escolhido).data_resolvida
if resolvida.nil?
redirect_to admin_romaneios_path,
alert: "Não consegui descobrir o dia do plano \"#{@plano_escolhido['name']}\". " \
'Informe a data ou envie a planilha do plano (.xlsx).'
return nil
end
data = resolvida.to_s
params[:rotulo_plano] = @plano_escolhido['name'] if params[:rotulo_plano].blank?
end
if data.blank?
redirect_to admin_romaneios_path, alert: 'Escolha um plano ou informe a data.'
return nil
end
tabela = params[:operacao_tabela].to_s
if tabela.present? && !Operacao.valida?(tabela)
redirect_to admin_romaneios_path, alert: 'Selecione uma operação válida.'
return nil
end
# Sem operação escolhida, tenta deduzi-la do NOME DO PLANO ("ENTREGAS EMAD
# 09.2026" → gade_entregas_emad_set_2026).
#
# POR QUE ISSO É IMPORTANTE E NÃO CONVENIÊNCIA: é a operação que diz quem é
# paciente NOVO. Importar sem ela produz um romaneio de aparência normal com a
# coluna APARELHO vazia para TODOS — o erro só aparece no papel, com aparelho
# não entregue. A operação não é campo opcional na prática; era só difícil de
# acertar, porque o nome do plano e o nome da tabela nunca são iguais.
#
# Empate/nenhum casamento devolve nil e segue sem operação: vincular a errada
# preencheria APARELHO e TELEFONE com dados de OUTRO mês, sem ninguém
# desconfiar. A tela avisa quando ficou sem.
@operacao_deduzida = nil
if tabela.blank? && params[:rotulo_plano].present?
tabela = Romaneios::CasadorDeOperacao.casar(params[:rotulo_plano]).to_s
@operacao_deduzida = tabela.presence
end
data = Date.parse(data)
Romaneio.find_or_create_by!(planned_date: data, operacao_tabela: tabela) do |r|
r.rotulo_plano = params[:rotulo_plano].presence || Romaneio.rotulo_sugerido(tabela, data)
r.importado_por = current_user
end
rescue Date::Error
redirect_to admin_romaneios_path, alert: 'Data do plano inválida.'
nil
end
# Formato pela ASSINATURA do arquivo. Devolve [extensao, nome_do_formato,
# aceito?] — com nome preenchido mesmo para o que não serve, para a mensagem
# poder dizer "é WebP" em vez de "não é PNG ou JPG".
def identificar_imagem(conteudo)
achado = ASSINATURAS.find { |assinatura, _, _, _| conteudo.start_with?(assinatura) }
return [nil, nil, false] if achado.nil?
_, extensao, formato, aceito = achado
# RIFF é contêiner (WebP, WAV, AVI): o que diz que é WebP são os bytes 8..11.
return [nil, nil, false] if formato == 'WebP' && conteudo[8, 4] != 'WEBP'
[extensao, formato, aceito]
end
def importar(linhas, origem:)
resultado = aplicar(linhas, origem: origem)
auditar!(:criar, @romaneio, dados_novos: resultado.to_h)
# Dizer que a operação foi deduzida, e qual: dedução silenciosa que acerta é
# invisível, mas dedução silenciosa que erra é indefensável — o operador
# precisa ver de qual mês vieram os dados de APARELHO.
aviso = if @operacao_deduzida
" Operação #{Operacao.label(@operacao_deduzida)} vinculada pelo nome do plano."
else
''
end
redirect_to admin_romaneio_path(@romaneio),
notice: "Plano importado — #{resultado.resumo}.#{aviso}"
end
def aplicar(linhas, origem:)
Romaneios::Importador.new(romaneio: @romaneio, linhas: linhas,
usuario: current_user, origem: origem).aplicar!
end
# Um romaneio recém-criado que não chegou a receber nenhuma parada é lixo: some
# da lista para o operador não achar que a importação funcionou.
def descartar_romaneio_vazio
@romaneio&.destroy! if @romaneio&.persisted? && @romaneio.romaneio_linhas.none?
end
def veiculo_selecionado
pedido = params[:veiculo].to_s
return pedido if @veiculos.any? { |v| v.veiculo == pedido }
@veiculos.first&.veiculo
end
def nome_do_arquivo(veiculo)
partes = ['romaneio', @romaneio.rotulo_plano.presence || @romaneio.planned_date.to_s, veiculo]
"#{partes.compact_blank.join('_').parameterize(separator: '_')}.pdf"
end
end

View File

@@ -4,7 +4,7 @@ class Admin::UsuariosController < ApplicationController
def index def index
authorize User authorize User
@usuarios = policy_scope(User).includes(:perfil_acesso).order(:nome).limit(200) @usuarios = policy_scope(User).order(:nome).limit(200)
@roles = User.roles.keys @roles = User.roles.keys
end end
@@ -36,13 +36,6 @@ class Admin::UsuariosController < ApplicationController
def update def update
authorize @usuario authorize @usuario
# Trava do último administrador: mudar perfil ou desativar a única pessoa que
# ainda administra acesso deixaria o sistema sem quem conserte.
if desligaria_o_ultimo_administrador?
@usuario.errors.add(:base, MENSAGEM_ULTIMO_ADMIN)
return render(:edit, status: :unprocessable_entity)
end
if @usuario.update(usuario_params_update) if @usuario.update(usuario_params_update)
AuditoriaLog.registrar(user: current_user, acao: 'editar_usuario', entidade: 'User', dados_novos: { detalhes: "Editou usuário #{@usuario.email}" }, request: request) AuditoriaLog.registrar(user: current_user, acao: 'editar_usuario', entidade: 'User', dados_novos: { detalhes: "Editou usuário #{@usuario.email}" }, request: request)
redirect_to admin_usuarios_path, notice: 'Usuário atualizado.' redirect_to admin_usuarios_path, notice: 'Usuário atualizado.'
@@ -54,10 +47,6 @@ class Admin::UsuariosController < ApplicationController
def destroy def destroy
authorize @usuario authorize @usuario
if User.ultimo_administrador?(@usuario)
return redirect_to(admin_usuarios_path, alert: MENSAGEM_ULTIMO_ADMIN)
end
if @usuario == current_user if @usuario == current_user
redirect_to admin_usuarios_path, alert: 'Você não pode excluir sua própria conta.' redirect_to admin_usuarios_path, alert: 'Você não pode excluir sua própria conta.'
return return
@@ -69,11 +58,7 @@ class Admin::UsuariosController < ApplicationController
end end
def toggle_ativo def toggle_ativo
authorize @usuario, :toggle_ativo? authorize @usuario, :update?
if @usuario.ativo? && User.ultimo_administrador?(@usuario)
return redirect_to(admin_usuarios_path, alert: MENSAGEM_ULTIMO_ADMIN)
end
@usuario.update!(ativo: !@usuario.ativo) @usuario.update!(ativo: !@usuario.ativo)
status = @usuario.ativo? ? 'ativado' : 'desativado' status = @usuario.ativo? ? 'ativado' : 'desativado'
AuditoriaLog.registrar(user: current_user, acao: "usuario_#{status}", entidade: 'User', dados_novos: { detalhes: "#{@usuario.email} foi #{status}" }, request: request) AuditoriaLog.registrar(user: current_user, acao: "usuario_#{status}", entidade: 'User', dados_novos: { detalhes: "#{@usuario.email} foi #{status}" }, request: request)
@@ -82,53 +67,18 @@ class Admin::UsuariosController < ApplicationController
private private
MENSAGEM_ULTIMO_ADMIN = 'Esta alteração deixaria o sistema sem nenhum usuário ativo capaz de ' \
'gerenciar perfis e usuários. Dê esse acesso a outra pessoa antes.'.freeze
def set_usuario def set_usuario
@usuario = User.find(params[:id]) @usuario = User.find(params[:id])
end end
# Aplica as mudanças em memória e pergunta: sobra alguém administrando?
def desligaria_o_ultimo_administrador?
simulado = @usuario.dup
simulado.id = @usuario.id
simulado.assign_attributes(usuario_params_update)
simulado.recarregar_permissoes!
return false if simulado.ativo? && simulado.pode?('admin.perfis') && simulado.pode?('admin.usuarios_gerenciar')
User.ultimo_administrador?(@usuario)
end
# ⚠️ CAMPOS DE ACESSO SÓ PARA QUEM PODE MEXER NELES.
# `UserPolicy#update?` libera editar a PRÓPRIA ficha (nome, senha) — é o que
# permite alguém trocar a própria senha. Sem o filtro abaixo, essa mesma
# permissão deixava qualquer usuário logado enviar `user[role]=admin` na edição
# do próprio cadastro e se promover. Quem decide papel e perfil é
# `UserPolicy#alterar_acesso?` (nunca sobre si mesmo).
CAMPOS_DE_ACESSO = %i[role perfil_acesso_id ativo].freeze
def usuario_params def usuario_params
permitidos = [:nome, :email, :password, :password_confirmation, :pin_code] params.require(:user).permit(:nome, :email, :password, :password_confirmation,
permitidos += CAMPOS_DE_ACESSO if pode_alterar_acesso? :role, :pin_code, :ativo)
params.require(:user).permit(permitidos)
end end
def usuario_params_update def usuario_params_update
permitidos = [:nome, :email, :pin_code] permitted = [:nome, :email, :role, :pin_code, :ativo]
permitidos += CAMPOS_DE_ACESSO if pode_alterar_acesso? permitted += [:password, :password_confirmation] if params[:user][:password].present?
permitidos += [:password, :password_confirmation] if params[:user][:password].present? params.require(:user).permit(permitted)
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
end end
helper_method :pode_alterar_acesso?
end

View File

@@ -1,74 +0,0 @@
# app/controllers/admin/variaveis_personalizadas_controller.rb
#
# CRUD das {{variaveis}} que o ADM cria. Uma vez cadastradas, aparecem no editor
# de mensagem ao lado das variáveis do sistema e valem para qualquer evento.
module Admin
class VariaveisPersonalizadasController < ApplicationController
include Auditavel
before_action :set_variavel, only: %i[edit update destroy]
def index
authorize VariavelPersonalizada
@variaveis = policy_scope(VariavelPersonalizada).ordenadas
# Mostrar o catálogo do sistema junto evita o erro mais comum: cadastrar
# {{data}} sem saber que já existe (o model recusa, mas melhor avisar antes).
@do_sistema = Notificacao::CatalogoVariaveis.por_origem.except(:personalizada)
end
def new
@variavel = VariavelPersonalizada.new(ativo: true)
authorize @variavel
end
def create
@variavel = VariavelPersonalizada.new(variavel_params)
authorize @variavel
if @variavel.save
auditar!(:criar, @variavel, dados_novos: { chave: @variavel.chave, valor: @variavel.valor })
redirect_to admin_variaveis_path, notice: "Variável #{@variavel.marcador} criada."
else
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
anterior = @variavel.valor
if @variavel.update(variavel_params)
auditar!(:editar, @variavel,
dados_anteriores: { valor: anterior },
dados_novos: { valor: @variavel.valor })
redirect_to admin_variaveis_path, notice: "Variável #{@variavel.marcador} atualizada."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
marcador = @variavel.marcador
@variavel.destroy
auditar!(:excluir, @variavel, dados_anteriores: { chave: @variavel.chave })
# A variável some, mas o texto que a usava continua lá: sem valor, o
# renderizador troca por vazio. Avisar é melhor do que a mensagem sair com
# um buraco que ninguém relaciona a esta exclusão.
redirect_to admin_variaveis_path,
alert: "Variável #{marcador} excluída. Mensagens que a usavam passam a mostrar espaço em branco no lugar."
end
private
def set_variavel
@variavel = VariavelPersonalizada.find(params[:id])
authorize @variavel
end
def variavel_params
params.require(:variavel_personalizada).permit(:chave, :valor, :descricao, :ativo)
end
end
end

View File

@@ -1,80 +0,0 @@
# app/controllers/admin/whatsapp_sessoes_controller.rb
#
# Pareamento do WhatsApp por QR code (ponte Baileys, container `whatsapp`).
#
# ⚠️ Canal NÃO OFICIAL: parear liga o número da empresa a este servidor por uma
# sessão do WhatsApp Web obtida por engenharia reversa. Está fora dos Termos
# do WhatsApp e a Meta pode banir o número — use um chip dedicado. A tela
# avisa isso em texto, não só no código.
class Admin::WhatsappSessoesController < ApplicationController
def show
authorize :whatsapp_sessao, :show?
@config = ConfiguracaoNotificacao.instancia
@cliente = Notificacao::ClienteWhatsapp.padrao
@estado = consultar
end
# Polling da tela enquanto o QR não é lido — o QR expira em ~20s e a ponte
# gera outro, então a página precisa buscar o novo sozinha.
def status
authorize :whatsapp_sessao, :show?
render json: consultar
end
def testar
authorize :whatsapp_sessao, :testar?
destino = ConfiguracaoNotificacao.normalizar_telefone(params[:numero])
if destino.blank?
return redirect_to(admin_whatsapp_sessao_path, alert: 'Informe um número válido (DDD + número).')
end
resposta = Notificacao::ClienteWhatsapp.padrao.enviar(
para: destino,
texto: "Teste do Reem Notas ✅\nSe você recebeu isto, o WhatsApp está conectado."
)
if resposta.ok?
redirect_to admin_whatsapp_sessao_path, notice: "Mensagem de teste enviada para #{destino}."
else
redirect_to admin_whatsapp_sessao_path, alert: "Falhou: #{resposta.erro}"
end
end
def desconectar
authorize :whatsapp_sessao, :desconectar?
resposta = Notificacao::ClienteWhatsapp.padrao.desconectar
AuditoriaLog.registrar(user: current_user, acao: 'whatsapp_desconectar',
entidade: 'ConfiguracaoNotificacao',
dados_novos: { detalhes: 'Desconectou a sessão do WhatsApp' },
request: request)
if resposta.ok?
ConfiguracaoNotificacao.instancia.update_columns(whatsapp_numero_conectado: nil,
whatsapp_conectado_em: nil)
redirect_to admin_whatsapp_sessao_path, notice: 'Sessão encerrada. Leia o QR para conectar de novo.'
else
redirect_to admin_whatsapp_sessao_path, alert: "Não foi possível desconectar: #{resposta.erro}"
end
end
private
# Consulta a ponte e memoriza o número conectado no banco — assim a tela de
# configuração mostra quem está pareado mesmo com a ponte fora do ar.
def consultar
resposta = Notificacao::ClienteWhatsapp.padrao.status
return { 'conectado' => false, 'erro' => resposta.erro } unless resposta.ok?
dados = resposta.dados || {}
if dados['conectado'] && dados['numero'].present?
config = ConfiguracaoNotificacao.instancia
if config.whatsapp_numero_conectado != dados['numero']
config.update_columns(whatsapp_numero_conectado: dados['numero'],
whatsapp_conectado_em: Time.current)
end
end
dados
end
end

View File

@@ -7,31 +7,14 @@ class ApplicationController < ActionController::Base
before_action :set_tema before_action :set_tema
before_action :preparar_splash before_action :preparar_splash
# Pundit: redireciona se não autorizado. # Pundit: redireciona se não autorizado
# rescue_from Pundit::NotAuthorizedError do |e|
# ⚠️ NÃO redirecione para `root_path` aqui: a raiz é o dashboard, e um perfil flash[:alert] = "Você não tem permissão para realizar esta ação."
# sem `dashboard.ver` entraria em loop (nega → raiz → nega). O destino é a redirect_to root_path
# primeira tela que a PESSOA pode abrir; quem não pode abrir nenhuma vai para a
# tela de sem-acesso, que explica a quem pedir.
rescue_from Pundit::NotAuthorizedError do |_e|
flash[:alert] = 'Você não tem permissão para acessar essa área.'
redirect_to destino_permitido
end end
private private
# Rota inicial deste usuário (ver User#home_rota). `sem_acesso_path` é o fim de
# linha: perfil sem nenhuma permissão.
def destino_permitido
rota = current_user&.home_rota
return sem_acesso_path if rota.nil?
# Evita loop quando a própria tela negada é a home calculada.
destino = public_send(rota)
destino == request.path ? sem_acesso_path : destino
end
helper_method :destino_permitido
def set_tema def set_tema
@tema = current_user&.tema_preferido || 'dark' @tema = current_user&.tema_preferido || 'dark'
end end
@@ -45,11 +28,8 @@ class ApplicationController < ActionController::Base
@mostrar_splash = session.delete(:mostrar_splash) @mostrar_splash = session.delete(:mostrar_splash)
end end
# Mesma regra do Users::SessionsController: o destino é a primeira tela que o
# PERFIL da pessoa permite, não uma rota fixa por papel.
def after_sign_in_path_for(resource) def after_sign_in_path_for(resource)
session[:mostrar_splash] = true session[:mostrar_splash] = true
rota = resource.home_rota resource.motorista? ? motorista_dashboard_path : dashboard_path
rota ? public_send(rota) : sem_acesso_path
end end
end end

View File

@@ -64,8 +64,7 @@ class ConsolidacaoEntregasController < ApplicationController
bonus: Configuracao.preco_bonus, bonus: Configuracao.preco_bonus,
desconto: Configuracao.preco_desconto, desconto: Configuracao.preco_desconto,
extraordinaria: Configuracao.preco_extraordinaria, extraordinaria: Configuracao.preco_extraordinaria,
termo: Configuracao.preco_termo, termo: Configuracao.preco_termo
termo_especial: Configuracao.preco_termo_especial
} }
end end
@@ -268,38 +267,25 @@ class ConsolidacaoEntregasController < ApplicationController
end end
# POST /consolidacoes/:consolidacao_id/consolidacao_entregas/apontar_termo # POST /consolidacoes/:consolidacao_id/consolidacao_entregas/apontar_termo
# ENTREGA DE TERMO — lança lotes de termos para o motorista. Não há NF nem # ENTREGA DE TERMO — lança um lote de N termos para o motorista. Não há NF nem
# código de rastreio: só as quantidades (normal e especial), cada tipo a um # código de rastreio: só a quantidade, a um preço fixo configurável.
# preço fixo configurável.
def apontar_termo def apontar_termo
authorize @consolidacao, :update? authorize @consolidacao, :update?
qtd_normal = params[:quantidade].to_i
qtd_especial = params[:quantidade_especial].to_i
raise ArgumentError, 'Informe ao menos um termo (normal ou especial).' if qtd_normal < 1 && qtd_especial < 1
motorista = nil
ActiveRecord::Base.transaction do
{ 'termo' => qtd_normal, 'termo_especial' => qtd_especial }.each do |tipo, qtd|
next if qtd < 1
motorista = @consolidacao.adicionar_termos!( motorista = @consolidacao.adicionar_termos!(
motorista: params[:motorista].to_s.strip, motorista: params[:motorista].to_s.strip,
quantidade: qtd, quantidade: params[:quantidade],
tipo: tipo,
vehicle: params[:vehicle], # veículo escolhido no lançamento (Q3) vehicle: params[:vehicle], # veículo escolhido no lançamento (Q3)
user: current_user user: current_user
) )
end
end
auditar!(:editar, @consolidacao, auditar!(:editar, @consolidacao,
dados_novos: { entrega_termo: qtd_normal, entrega_termo_especial: qtd_especial, motorista: motorista }) dados_novos: { entrega_termo: params[:quantidade].to_i, motorista: motorista })
respond_to do |format| respond_to do |format|
format.json { render json: resumo_json(motorista).merge(motorista: motorista) } format.json { render json: resumo_json(motorista).merge(motorista: motorista) }
format.html do format.html do
redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: motorista), redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: motorista),
notice: "#{qtd_normal + qtd_especial} entrega(s) de termo adicionada(s) para #{motorista}." notice: "#{params[:quantidade].to_i} entrega(s) de termo adicionada(s) para #{motorista}."
end end
end end
rescue ArgumentError => e rescue ArgumentError => e
@@ -433,7 +419,7 @@ class ConsolidacaoEntregasController < ApplicationController
end end
def bloquear_se_finalizada def bloquear_se_finalizada
return unless @consolidacao.finalizada? && !current_user.pode?('consolidacao.editar_finalizada') return unless @consolidacao.finalizada? && current_user.operador?
redirect_to @consolidacao, alert: 'Consolidação finalizada — apenas admin/gerente podem editar.' redirect_to @consolidacao, alert: 'Consolidação finalizada — apenas admin/gerente podem editar.'
end end
@@ -548,9 +534,8 @@ class ConsolidacaoEntregasController < ApplicationController
# Conta lançamentos manuais como ENTREGAS: cada termo vale `quantidade` (lote # Conta lançamentos manuais como ENTREGAS: cada termo vale `quantidade` (lote
# numa só linha); os demais apontamentos manuais valem 1 por nota (tracking). # numa só linha); os demais apontamentos manuais valem 1 por nota (tracking).
def contar_manuais(rel_manual) def contar_manuais(rel_manual)
tipos_termo = ConsolidacaoEntrega.tipos.values_at(:termo, :termo_especial) rel_manual.termo.sum(:quantidade) +
rel_manual.where(tipo: tipos_termo).sum(:quantidade) + rel_manual.where.not(tipo: ConsolidacaoEntrega.tipos[:termo]).distinct.count(:tracking_id)
rel_manual.where.not(tipo: tipos_termo).distinct.count(:tracking_id)
end end
# `vehicles` (opcional, string ou array): quando presente, TODO o resumo # `vehicles` (opcional, string ou array): quando presente, TODO o resumo

View File

@@ -13,37 +13,17 @@ class ConsolidacoesController < ApplicationController
# Formas de pagamento aceitas (o select da UI usa esta mesma lista). # Formas de pagamento aceitas (o select da UI usa esta mesma lista).
FORMAS_PAGAMENTO = %w[pix transferencia dinheiro].freeze FORMAS_PAGAMENTO = %w[pix transferencia dinheiro].freeze
# GET /consolidacoes?visao=lista|motoristas — a MESMA tela em dois modos de # GET /consolidacoes — lista com filtros
# visualização, com os mesmos filtros:
#
# lista → um card por CONSOLIDAÇÃO (o de sempre);
# motoristas → um card por MOTORISTA, com o total dele no período e, ao
# clicar, as consolidações que compõem aquele valor.
#
# São duas leituras do mesmo recorte: o trabalho é organizado por consolidação,
# mas o pagamento é por motorista — antes, para saber quanto um motorista tinha
# fechado no mês, era preciso abrir consolidação por consolidação e somar à mão.
def index def index
authorize Consolidacao authorize Consolidacao
@visao = params[:visao] == 'motoristas' ? 'motoristas' : 'lista'
@consolidacoes = Consolidacao.ativas.recentes.includes(:criador) @consolidacoes = Consolidacao.ativas.recentes.includes(:criador)
# Filtros # Filtros
@consolidacoes = @consolidacoes.where(status: params[:status]) if params[:status].present? @consolidacoes = @consolidacoes.where(status: params[:status]) if params[:status].present?
@consolidacoes = @consolidacoes.where('nome ILIKE ?', "%#{params[:nome]}%") if params[:nome].present? @consolidacoes = @consolidacoes.where('nome ILIKE ?', "%#{params[:nome]}%") if params[:nome].present?
# Datas: a consolidação entra se CRUZA a faixa — mesmo critério do modo "Por @consolidacoes = @consolidacoes.where('data_inicio >= ?', params[:inicio]) if params[:inicio].present?
# motorista" e do dashboard financeiro. Com o critério antigo (caber inteira @consolidacoes = @consolidacoes.where('data_fim <= ?', params[:fim]) if params[:fim].present?
# dentro do filtro), um fechamento de 01/08 a 31/08 sumia da lista ao filtrar
# 01/0826/08 e as duas abas da MESMA tela contavam coisas diferentes.
if params[:inicio].present? && params[:fim].present?
@consolidacoes = @consolidacoes.cruzando_periodo(params[:inicio], params[:fim])
elsif params[:inicio].present?
@consolidacoes = @consolidacoes.where('data_fim >= ?', params[:inicio])
elsif params[:fim].present?
@consolidacoes = @consolidacoes.where('data_inicio <= ?', params[:fim])
end
if params[:motorista].present? if params[:motorista].present?
ids = ConsolidacaoMotorista.where('motorista_nome ILIKE ?', "%#{params[:motorista]}%") ids = ConsolidacaoMotorista.where('motorista_nome ILIKE ?', "%#{params[:motorista]}%")
@@ -58,30 +38,6 @@ class ConsolidacoesController < ApplicationController
if %w[pago pendente parcial].include?(params[:pagamento]) if %w[pago pendente parcial].include?(params[:pagamento])
@consolidacoes = @consolidacoes.public_send("pagamento_#{params[:pagamento]}") @consolidacoes = @consolidacoes.public_send("pagamento_#{params[:pagamento]}")
end end
montar_totais if @visao == 'motoristas'
end
# GET /consolidacoes/totais_pdf — o modo "Por motorista" em PDF, com os mesmos
# filtros da tela. Com ?detalhe=NOME sai só o extrato daquele motorista (com
# espaço de assinatura).
def totais_pdf
authorize Consolidacao, :exportar_pdf?
inicio, fim = periodo_totais
totais = Analytics::TotaisPorMotorista.new(
inicio: inicio, fim: fim,
motorista: params[:detalhe].presence,
status: params[:status].presence || 'finalizada',
busca: params[:motorista].presence
)
nome = params[:detalhe].presence&.parameterize(separator: '_')
arquivo = ['totais', nome, inicio.strftime('%Y%m%d'), fim.strftime('%Y%m%d')].compact.join('_')
send_data Pdf::TotaisMotoristaPdf.new(totais: totais, detalhar: params[:detalhar] != '0').render,
filename: "#{arquivo}.pdf",
type: 'application/pdf',
disposition: 'attachment'
end end
# GET /consolidacoes/new # GET /consolidacoes/new
@@ -245,7 +201,7 @@ class ConsolidacoesController < ApplicationController
# POST /consolidacoes/:id/finalizar # POST /consolidacoes/:id/finalizar
def finalizar def finalizar
authorize @consolidacao, :finalizar? authorize @consolidacao, :update?
if @consolidacao.finalizar!(current_user) if @consolidacao.finalizar!(current_user)
auditar!(:finalizar, @consolidacao) auditar!(:finalizar, @consolidacao)
@@ -262,7 +218,7 @@ class ConsolidacoesController < ApplicationController
# não dá para consolidar (não 100% classificados) e finaliza a consolidação. Os # não dá para consolidar (não 100% classificados) e finaliza a consolidação. Os
# veículos consolidados/prontos seguem no fechamento; os pendentes ficam de fora. # veículos consolidados/prontos seguem no fechamento; os pendentes ficam de fora.
def finalizar_parcial def finalizar_parcial
authorize @consolidacao, :finalizar_parcial? authorize @consolidacao, :update?
arquivados = @consolidacao.arquivar_veiculos_pendentes!(current_user) arquivados = @consolidacao.arquivar_veiculos_pendentes!(current_user)
if @consolidacao.finalizar!(current_user) if @consolidacao.finalizar!(current_user)
@@ -278,43 +234,12 @@ class ConsolidacoesController < ApplicationController
# POST /consolidacoes/:id/arquivar # POST /consolidacoes/:id/arquivar
def arquivar def arquivar
authorize @consolidacao, :arquivar? authorize @consolidacao, :destroy?
@consolidacao.arquivar!(current_user) @consolidacao.arquivar!(current_user)
auditar!(:arquivar, @consolidacao) auditar!(:arquivar, @consolidacao)
redirect_to consolidacoes_path, notice: 'Consolidação arquivada.' redirect_to consolidacoes_path, notice: 'Consolidação arquivada.'
end end
# POST /consolidacoes/arquivar_em_lote — arquiva as consolidações marcadas na
# lista. Mesma regra do arquivar de uma só (nenhuma trava extra): arquivar é
# REVERSÍVEL (Arquivadas → Reativar), então o lote não precisa de confirmação
# mais dura do que a de uma — o que ele precisa é dizer QUANTAS foram.
#
# Ignora em silêncio id que não está mais ativo (alguém arquivou pela outra
# aba enquanto a lista estava aberta): o resultado que o operador queria — "essas
# saem da lista" — já é o que está na tela, e erro aqui só assustaria à toa.
def arquivar_em_lote
authorize Consolidacao, :arquivar?
ids = Array(params[:ids]).map(&:to_i).reject(&:zero?)
if ids.empty?
return redirect_back fallback_location: consolidacoes_path,
alert: 'Marque ao menos uma consolidação para arquivar.'
end
alvos = Consolidacao.ativas.where(id: ids).to_a
alvos.each do |c|
c.arquivar!(current_user)
auditar!(:arquivar, c, dados_novos: { em_lote: true, total_do_lote: alvos.size })
end
aviso = case alvos.size
when 0 then 'Nenhuma das consolidações marcadas continuava ativa.'
when 1 then "1 consolidação arquivada. Você pode restaurar em \"Arquivadas\"."
else "#{alvos.size} consolidações arquivadas. Você pode restaurar em \"Arquivadas\"."
end
redirect_back fallback_location: consolidacoes_path, notice: aviso
end
# GET /consolidacoes/arquivadas — histórico de consolidações arquivadas # GET /consolidacoes/arquivadas — histórico de consolidações arquivadas
def arquivadas def arquivadas
authorize Consolidacao authorize Consolidacao
@@ -323,7 +248,7 @@ class ConsolidacoesController < ApplicationController
# POST /consolidacoes/:id/reativar — restaura uma consolidação arquivada # POST /consolidacoes/:id/reativar — restaura uma consolidação arquivada
def reativar def reativar
authorize @consolidacao, :reativar? authorize @consolidacao, :destroy?
@consolidacao.reativar!(current_user) @consolidacao.reativar!(current_user)
auditar!(:reativar, @consolidacao) auditar!(:reativar, @consolidacao)
redirect_to consolidacoes_path, notice: 'Consolidação reativada.' redirect_to consolidacoes_path, notice: 'Consolidação reativada.'
@@ -381,7 +306,7 @@ class ConsolidacoesController < ApplicationController
# consolidação (reversível). Ele sai dos totais/pagamento/gate, mas os dados # consolidação (reversível). Ele sai dos totais/pagamento/gate, mas os dados
# ficam guardados e podem ser restaurados. Não arquiva motorista já pago. # ficam guardados e podem ser restaurados. Não arquiva motorista já pago.
def arquivar_motorista def arquivar_motorista
authorize @consolidacao, :gerir_motoristas? authorize @consolidacao, :update?
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id]) cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
if cm.pago? if cm.pago?
@@ -398,7 +323,7 @@ class ConsolidacoesController < ApplicationController
# POST /consolidacoes/:id/desarquivar_motorista — restaura um motorista arquivado. # POST /consolidacoes/:id/desarquivar_motorista — restaura um motorista arquivado.
def desarquivar_motorista def desarquivar_motorista
authorize @consolidacao, :gerir_motoristas? authorize @consolidacao, :update?
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id]) cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
cm.desarquivar!(current_user) cm.desarquivar!(current_user)
@@ -413,7 +338,7 @@ class ConsolidacoesController < ApplicationController
# como consolidacao_entregas se liga ao motorista por STRING (motorista_nome, # como consolidacao_entregas se liga ao motorista por STRING (motorista_nome,
# sem FK/cascade), apagamos os lançamentos dele à mão antes de remover o registro. # sem FK/cascade), apagamos os lançamentos dele à mão antes de remover o registro.
def excluir_motorista def excluir_motorista
authorize @consolidacao, :excluir_motorista? authorize @consolidacao, :destroy?
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id]) cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
if cm.pago? if cm.pago?
@@ -438,7 +363,7 @@ class ConsolidacoesController < ApplicationController
# consolidação (ou restaura, se estava arquivado). Útil para refazer rápido # consolidação (ou restaura, se estava arquivado). Útil para refazer rápido
# depois de excluir/arquivar por erro de precificação. # depois de excluir/arquivar por erro de precificação.
def adicionar_motorista def adicionar_motorista
authorize @consolidacao, :gerir_motoristas? authorize @consolidacao, :update?
nome = params[:motorista_nome].to_s.strip nome = params[:motorista_nome].to_s.strip
if nome.blank? if nome.blank?
@@ -466,7 +391,7 @@ class ConsolidacoesController < ApplicationController
# GET /consolidacoes/:id/gerar_pdf_relatorio?motorista=X # GET /consolidacoes/:id/gerar_pdf_relatorio?motorista=X
def gerar_pdf_relatorio def gerar_pdf_relatorio
authorize @consolidacao, :exportar_pdf? authorize @consolidacao, :show?
motorista = params[:motorista] motorista = params[:motorista]
pdf = Pdf::RelatorioMotoristaPdf.new(consolidacao: @consolidacao, motorista: motorista) pdf = Pdf::RelatorioMotoristaPdf.new(consolidacao: @consolidacao, motorista: motorista)
@@ -480,7 +405,7 @@ class ConsolidacoesController < ApplicationController
# GET /consolidacoes/:id/gerar_pdf_financeiro — relatório financeiro da consolidação # GET /consolidacoes/:id/gerar_pdf_financeiro — relatório financeiro da consolidação
def gerar_pdf_financeiro def gerar_pdf_financeiro
authorize @consolidacao, :exportar_pdf? authorize @consolidacao, :show?
pdf = Pdf::RelatorioFinanceiroConsolidacaoPdf.new(consolidacao: @consolidacao) pdf = Pdf::RelatorioFinanceiroConsolidacaoPdf.new(consolidacao: @consolidacao)
auditar!(:editar, @consolidacao, dados_novos: { pdf: 'financeiro' }) auditar!(:editar, @consolidacao, dados_novos: { pdf: 'financeiro' })
@@ -524,40 +449,6 @@ class ConsolidacoesController < ApplicationController
private private
# Modo "Por motorista": mesmo período/busca da lista, mas o recorte de datas e
# o default de status são os do DASHBOARD FINANCEIRO — é com ele que o operador
# compara o número (ver Analytics::TotaisPorMotorista). Sem status na URL vale
# "Consolidado" (só finalizadas): "Geral" (com rascunhos) mostrava um valor
# maior que o do dashboard e parecia erro de conta.
# `detalhe` é o motorista aberto (o clique na linha).
def montar_totais
@inicio, @fim = periodo_totais
@status_totais = params[:status].presence || 'finalizada'
@totais = Analytics::TotaisPorMotorista.new(
inicio: @inicio, fim: @fim,
status: @status_totais,
busca: params[:motorista].presence
)
@detalhe_motorista = params[:detalhe].presence
@detalhe = @detalhe_motorista ? @totais.consolidacoes_de(@detalhe_motorista) : nil
end
# Período do relatório de totais. Default: o MÊS CORRENTE INTEIRO (o mês é a
# unidade de fechamento; "até hoje" recortaria o mês pela metade sem motivo).
def periodo_totais
inicio = parse_data_totais(params[:inicio]) || Date.current.beginning_of_month
fim = parse_data_totais(params[:fim]) || Date.current.end_of_month
inicio, fim = fim, inicio if fim < inicio
[inicio, fim]
end
def parse_data_totais(str)
return nil if str.blank?
Date.parse(str)
rescue ArgumentError, TypeError
nil
end
# Acha o User motorista pelo nome para gerar o QR Code de acesso. O # Acha o User motorista pelo nome para gerar o QR Code de acesso. O
# motorista_nome vem dos dados externos de entrega e o users.nome é digitado # motorista_nome vem dos dados externos de entrega e o users.nome é digitado
# no admin — então toleramos diferenças de caixa e de espaços (extras/duplos). # no admin — então toleramos diferenças de caixa e de espaços (extras/duplos).

View File

@@ -2,37 +2,12 @@
class DashboardController < ApplicationController class DashboardController < ApplicationController
MAX_DIAS_GRAFICO = 92 # limita a granularidade diária do gráfico (≈ 1 trimestre) MAX_DIAS_GRAFICO = 92 # limita a granularidade diária do gráfico (≈ 1 trimestre)
# Tipos lançados EM LOTE: uma linha só de consolidacao_entregas representa N
# entregas (quantidade = N, valor_aplicado = N × preço — ver
# Consolidacao#adicionar_termos!). Contá-los por tracking_id valeria 1.
TIPOS_EM_LOTE = %w[termo termo_especial].freeze
# Cor de cada tipo no donut "Composição do custo". Fica AQUI, junto de quem
# monta @fin_por_tipo, para label, valor e cor viajarem no MESMO hash: a view
# tinha uma lista fixa de 5 cores para os 7 tipos de
# ConsolidacaoEntrega::TIPO_CORES e o Chart.js reciclava o array — "Entrega de
# Termo" saía com a mesma laranja de "Entrega Normal", no mesmo gráfico.
# `desconto` NÃO entra: ver o comentário em #carregar_dados_financeiro.
CORES_TIPO = {
'entrega_normal' => '#f97316',
'retirada' => '#9a3412',
'bonus' => '#fed7aa',
'extraordinaria' => '#9333ea',
'termo' => '#2563eb',
'termo_especial' => '#06b6d4'
}.freeze
def index def index
skip_authorization
# Motorista tem painel próprio — não enxerga o dashboard administrativo # Motorista tem painel próprio — não enxerga o dashboard administrativo
return redirect_to(motorista_dashboard_path) if current_user.motorista? return redirect_to(motorista_dashboard_path) if current_user.motorista?
authorize :dashboard, :ver?
# Custo, pagamento e ticket médio são permissão à parte: um perfil de
# acompanhamento vê a operação sem ver quanto ela custa. Antes o controller
# usava skip_authorization e o papel `externo` enxergava tudo.
@ver_financeiro = current_user.pode?('dashboard.financeiro')
@periodo_inicio, @periodo_fim = periodo_selecionado @periodo_inicio, @periodo_fim = periodo_selecionado
carregar_dados_dashboard carregar_dados_dashboard
end end
@@ -40,11 +15,9 @@ class DashboardController < ApplicationController
# GET /dashboard/relatorio_financeiro?inicio=&fim=&operacoes[]= # GET /dashboard/relatorio_financeiro?inicio=&fim=&operacoes[]=
# Relatório financeiro do período (PDF) — mesmos números da tela. # Relatório financeiro do período (PDF) — mesmos números da tela.
def relatorio_financeiro def relatorio_financeiro
skip_authorization
return redirect_to(motorista_dashboard_path) if current_user.motorista? return redirect_to(motorista_dashboard_path) if current_user.motorista?
authorize :dashboard, :financeiro?
@ver_financeiro = true
@periodo_inicio, @periodo_fim = periodo_selecionado @periodo_inicio, @periodo_fim = periodo_selecionado
carregar_dados_dashboard carregar_dados_dashboard
@@ -77,41 +50,6 @@ class DashboardController < ApplicationController
disposition: 'attachment' disposition: 'attachment'
end end
# GET /dashboard/pendentes?inicio=&fim=&operacoes[]=
# Quais são as entregas EM ABERTO do período — o card do dashboard mostrava só
# o número e não havia tela nenhuma listando as linhas por trás dele.
#
# Em aberto = nem concluída nem falhada (Entrega.pendentes), pela data
# PLANEJADA: é o único recorte sem checkout, então planned_date é o eixo
# possível. Mesmo recorte do card, para o total bater.
def pendentes
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])
escopo = Entrega.da_conta_gade.pendentes.no_periodo(@periodo_inicio, @periodo_fim)
escopo = escopo.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
@total_em_aberto = escopo.count
begin
@pagy, @em_aberto = pagy(escopo.order(planned_date: :desc), limit: 50)
rescue Pagy::OverflowError
@pagy, @em_aberto = pagy(escopo.order(planned_date: :desc), limit: 50, page: 1)
end
# Contexto de cada NF listada: em que operação ela está (se estiver) e
# quantas visitas o rastreio tem para ela. As duas respostas juntas dizem por
# que a entrega ficou em aberto — e por que o dashboard de Operações não a
# mostra: ou é nota fora da operação, ou tem outra visita que a substituiu.
nfs = @em_aberto.map { |e| e.reference_id.to_s }.reject(&:empty?)
@operacao_por_nf = Operacao.por_notas(nfs)
@visitas_por_nf = nfs.any? ? Entrega.where(reference_id: nfs).group(:reference_id).count : {}
end
private private
# Faixa de datas vinda do filtro de calendário (params inicio/fim). # Faixa de datas vinda do filtro de calendário (params inicio/fim).
@@ -138,47 +76,32 @@ class DashboardController < ApplicationController
@operacoes_agrupadas = Operacao.agrupadas_por_mes @operacoes_agrupadas = Operacao.agrupadas_por_mes
@operacao_filtro = Operacao.sanitizar(params[:operacoes]) @operacao_filtro = Operacao.sanitizar(params[:operacoes])
# ATENDIDAS pela DATA REAL (checkout) — base de toda a análise financeira. # PAGAS pela DATA REAL (checkout) — base de toda a análise financeira por data.
# pagas = Entrega.da_conta_gade.pagas.no_periodo_checkout(@periodo_inicio, @periodo_fim)
# Atendida = o motorista FOI ao local e fechou a visita, com sucesso pagas = pagas.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
# (`completed`) OU insucesso (`failed`). É exatamente o conjunto que a
# consolidação considera elegível e paga (ver Entrega::STATUS_ATENDIDO,
# Entrega.contar_atendidas e Consolidacao#veiculos_do_motorista), por isso o
# dashboard usa o MESMO recorte — usava só `pagas` (concluídas) e mostrava
# um valor menor que o do fechamento.
atendidas = Entrega.da_conta_gade.atendidas.no_periodo_checkout(@periodo_inicio, @periodo_fim)
atendidas = atendidas.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
# PENDENTES (em aberto, nem concluídas nem falhadas) pela data planejada # PENDENTES (em aberto, nem concluídas nem falhadas) pela data planejada.
# é o único recorte sem checkout, então planned_date é o eixo correto aqui.
pendentes = Entrega.da_conta_gade.pendentes.no_periodo(@periodo_inicio, @periodo_fim) pendentes = Entrega.da_conta_gade.pendentes.no_periodo(@periodo_inicio, @periodo_fim)
pendentes = pendentes.da_operacoes(@operacao_filtro) if @operacao_filtro.present? pendentes = pendentes.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
# Quebra do total entre sucesso e insucesso (ambos já dentro de `atendidas`). # FALHADAS (insucesso) pela data planejada — também não têm checkout.
# ⚠️ Falhada TEM checkout: o motorista foi ao local e fechou a visita com falhadas = Entrega.da_conta_gade.falhadas.no_periodo(@periodo_inicio, @periodo_fim)
# motivo de insucesso. Por isso o eixo é checkout, igual às concluídas — antes falhadas = falhadas.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
# as falhas eram filtradas por planned_date e caíam no período errado.
@entregas_pagas = atendidas.concluidas.count
@entregas_falhadas = atendidas.falhadas.count
@entregas_atendidas = @entregas_pagas + @entregas_falhadas
@entregas_pendentes = pendentes.count
@total_entregas = @entregas_atendidas + @entregas_pendentes
# Quantas NOTAS FISCAIS distintas estão por trás das visitas atendidas. É o # Totais gerais
# número que o dashboard de Operações mostra (e que o cliente paga/confere @entregas_pagas = pagas.count
# nos documentos); aqui contamos VISITAS, porque é por ida ao local que o @entregas_pendentes = pendentes.count
# motorista recebe. Exibir os dois lado a lado evita a leitura de que uma das @entregas_falhadas = falhadas.count
# telas está errada quando elas divergem — a diferença são retentativas. @total_entregas = @entregas_pagas + @entregas_pendentes + @entregas_falhadas
@notas_atendidas = atendidas.distinct.count(:reference_id)
# Configurações de preço # Configurações de preço
config = Configuracao.mapa_de_precos config = Configuracao.mapa_de_precos
# Valor estimado total # Valor estimado total
@valor_estimado = (@entregas_atendidas * config[:entrega]).round(2) @valor_estimado = (@entregas_pagas * config[:entrega]).round(2)
# Por motorista (todos) # Por motorista (todos)
@motoristas = atendidas.group(:driver) @motoristas = pagas.group(:driver)
.count .count
.sort_by { |_, v| -v } .sort_by { |_, v| -v }
.map do |driver, qtd| .map do |driver, qtd|
@@ -190,14 +113,14 @@ class DashboardController < ApplicationController
end end
# Por local de entrega (contact_name) # Por local de entrega (contact_name)
@por_operacao = atendidas.group(:contact_name) @por_operacao = pagas.group(:contact_name)
.count .count
.sort_by { |_, v| -v } .sort_by { |_, v| -v }
.first(6) .first(6)
.to_h .to_h
# Evolução diária no período (para Chart.js) — por data real (checkout) # Evolução diária no período (para Chart.js) — por data real (checkout)
@grafico_diario = build_grafico_diario(atendidas, config[:entrega]) @grafico_diario = build_grafico_diario(pagas, config[:entrega])
# Consolidações cujo período (data_inicio..data_fim) cruza o período do dashboard # Consolidações cujo período (data_inicio..data_fim) cruza o período do dashboard
# — independe de quando foram criadas (created_at). # — independe de quando foram criadas (created_at).
@@ -207,13 +130,12 @@ class DashboardController < ApplicationController
@consolidacoes_abertas = @consolidacoes_mes.where(status: :rascunho).count @consolidacoes_abertas = @consolidacoes_mes.where(status: :rascunho).count
@consolidacoes_fechadas = @consolidacoes_mes.where(status: :finalizada).count @consolidacoes_fechadas = @consolidacoes_mes.where(status: :finalizada).count
# Pagamentos e visão financeira só são consultados por quem pode vê-los: sem a # Pagamentos: pago vs pendente entre as consolidações FINALIZADAS do período
# permissão, o dado não chega à página (a view esconde os blocos, mas o certo # (medido por motorista — fonte: consolidacao_motoristas).
# é nem carregar — é consulta cara e é informação de custo).
if @ver_financeiro
carregar_dados_pagamentos carregar_dados_pagamentos
# Visão financeira por operação (KPIs + gráficos)
carregar_dados_financeiro carregar_dados_financeiro
end
# Histórico estimado mais recente # Histórico estimado mais recente
@historico_recente = HistoricoEstimado.order(created_at: :desc).limit(5) @historico_recente = HistoricoEstimado.order(created_at: :desc).limit(5)
@@ -243,9 +165,6 @@ class DashboardController < ApplicationController
@pagamentos_feitos = pagos.includes(:consolidacao, :pagador).order(pago_em: :desc) @pagamentos_feitos = pagos.includes(:consolidacao, :pagador).order(pago_em: :desc)
@pagamentos_pendentes = pendentes.includes(:consolidacao).order(consolidacao_id: :desc) @pagamentos_pendentes = pendentes.includes(:consolidacao).order(consolidacao_id: :desc)
# Fluxo de pagamentos por dia (valor pago + acumulado) para o gráfico
@grafico_pagamentos_dia = build_grafico_pagamentos(pagos)
end end
# Visão financeira (custo) das consolidações finalizadas do período, já com o # Visão financeira (custo) das consolidações finalizadas do período, já com o
@@ -267,26 +186,11 @@ class DashboardController < ApplicationController
consols = Consolidacao.where(id: (ids_periodo + ids_pagos).uniq).to_a consols = Consolidacao.where(id: (ids_periodo + ids_pagos).uniq).to_a
ids = consols.map(&:id) ids = consols.map(&:id)
cms = ConsolidacaoMotorista.ativos.where(consolidacao_id: ids) cms = ConsolidacaoMotorista.ativos.where(consolidacao_id: ids)
ces = ConsolidacaoEntrega.where(consolidacao_id: ids)
# ⚠️ O MESMO RECORTE DE `cms`, NO NÍVEL DA LINHA. Motorista arquivado sai do
# dinheiro (ConsolidacaoMotorista.ativos), mas as linhas dele CONTINUAM em
# consolidacao_entregas: `arquivar_motorista` é reversível e preserva tudo —
# quem apaga é o `excluir_motorista`. Sem este EXISTS, o card "Custo total"
# mostrava R$ de um conjunto e "N entregas classificadas" de outro, o ticket
# médio dividia um pelo outro e o donut de composição não fechava com o total.
#
# O par é (consolidacao_id, motorista_nome), não só o nome: o mesmo motorista
# pode estar ativo numa consolidação e arquivado em outra, ambas no período.
ces = ConsolidacaoEntrega.where(consolidacao_id: ids).where(<<~SQL.squish)
EXISTS (SELECT 1 FROM consolidacao_motoristas cm
WHERE cm.consolidacao_id = consolidacao_entregas.consolidacao_id
AND cm.motorista_nome = consolidacao_entregas.motorista_nome
AND cm.arquivado_em IS NULL)
SQL
# KPIs # KPIs
@fin_custo_total = cms.sum(:valor_total) @fin_custo_total = cms.sum(:valor_total)
@fin_qtd_entregas = contar_entregas(ces) @fin_qtd_entregas = ces.distinct.count(:tracking_id)
@fin_ticket_medio = @fin_qtd_entregas.zero? ? 0 : (@fin_custo_total / @fin_qtd_entregas) @fin_ticket_medio = @fin_qtd_entregas.zero? ? 0 : (@fin_custo_total / @fin_qtd_entregas)
@fin_pago = cms.pagos.sum(:valor_total) @fin_pago = cms.pagos.sum(:valor_total)
@fin_pendente = cms.pendentes.sum(:valor_total) @fin_pendente = cms.pendentes.sum(:valor_total)
@@ -301,83 +205,33 @@ class DashboardController < ApplicationController
end end
@fin_por_operacao = por_op.sort_by { |_, v| -v } @fin_por_operacao = por_op.sort_by { |_, v| -v }
# Composição por tipo (normal/retirada/bônus/) # Composição por tipo (normal/retirada/bônus/desconto)
#
# ⚠️ DESCONTO NÃO É FATIA. No dinheiro ele SUBTRAI (ConsolidacaoMotorista
# #recalcular_valor e Consolidacao#recalcular_motorista! usam
# `-valor_aplicado`). Como fatia positiva de um DONUT — que se lê como
# "partes de um todo" — a soma das fatias ficava maior que o Custo total
# mostrado logo acima, e a diferença era exatamente o desconto. Ele sai do
# gráfico e vira a linha "já abatidos" embaixo: a divergência é legítima,
# então é explicada no lugar onde ela aparece (diretriz 1).
soma_tipo = ces.group(:tipo).sum(:valor_aplicado) soma_tipo = ces.group(:tipo).sum(:valor_aplicado)
@fin_descontos = soma_tipo['desconto'].to_f @fin_por_tipo = ConsolidacaoEntrega::TIPO_CORES.filter_map do |tipo, cfg|
@fin_por_tipo = CORES_TIPO.filter_map do |tipo, cor|
valor = soma_tipo[tipo].to_f valor = soma_tipo[tipo].to_f
next if valor.zero? next if valor.zero?
{ label: ConsolidacaoEntrega::TIPO_CORES.dig(tipo, :label) || tipo, valor: valor, cor: cor } { label: cfg[:label], valor: valor }
end end
# Custo por motorista (todos) # Custo por motorista (todos)
@fin_por_motorista = cms.group(:motorista_nome).sum(:valor_total) @fin_por_motorista = cms.group(:motorista_nome).sum(:valor_total)
.sort_by { |_, v| -v } .sort_by { |_, v| -v }
# Ranking CONSOLIDADO (aba "Consolidado" do card Motoristas): o valor REAL
# fechado por motorista + quantas entregas entraram nesse fechamento.
#
# ⚠️ A quantidade é DISTINCT tracking_id, NÃO a contagem de linhas: a mesma
# entrega pode ter vários pilares (Normal + Bônus + Retirada = 3 linhas em
# consolidacao_entregas), e contar linhas inflaria o número de entregas.
#
# Partimos de @fin_por_motorista (que vem de `cms`, já .ativos) e só buscamos
# a quantidade: assim a aba lista exatamente os motoristas do fechamento —
# arquivado não aparece — e a soma bate com o KPI "Custo total" do topo.
qtd_por_motorista = contar_entregas_por_motorista(ces)
@ranking_consolidado = @fin_por_motorista.map do |nome, valor|
{ nome: nome, valor: valor, entregas: qtd_por_motorista[nome].to_i }
end
end end
# Quantas ENTREGAS existem num conjunto de linhas de consolidacao_entregas. # `pagas` já vem filtrado por checkout no período. Agrupa pela DATA REAL
# São DUAS correções na mesma conta, e as duas precisam conviver: # (DATE(checkout)) — DATE() sobre timestamp não faz conversão de fuso.
# def build_grafico_diario(pagas, preco_entrega)
# • DISTINCT tracking_id — a MESMA entrega vira várias linhas quando tem mais
# de um pilar (Normal + Bônus + Retirada = 3 linhas). Contar linhas inflava
# o número de entregas. Esta parte já estava certa.
# • termo soma `quantidade` — um lote de termo é UMA linha com quantidade N
# (Consolidacao#adicionar_termos!), então por tracking_id um lote de 20
# valia 1: o custo entrava inteiro no numerador e 1 no denominador, e o
# ticket médio saía 20× maior. É a mesma unidade que
# Analytics::TotaisPorMotorista já usa ("um termo com quantidade 5 vale 5").
def contar_entregas(escopo)
escopo.where.not(tipo: TIPOS_EM_LOTE).distinct.count(:tracking_id) +
escopo.where(tipo: TIPOS_EM_LOTE).sum(:quantidade)
end
# Mesma conta de #contar_entregas, quebrada por motorista — o ranking e o KPI
# do topo têm que somar a mesma coisa, senão viram dois números na mesma tela.
def contar_entregas_por_motorista(escopo)
normais = escopo.where.not(tipo: TIPOS_EM_LOTE).group(:motorista_nome).distinct.count(:tracking_id)
lotes = escopo.where(tipo: TIPOS_EM_LOTE).group(:motorista_nome).sum(:quantidade)
normais.merge(lotes) { |_nome, a, b| a + b }
end
# `atendidas` já vem filtrado por checkout no período (concluídas + falhadas).
# Agrupa pela DATA REAL (DATE(checkout)) — DATE() sobre timestamp não faz
# conversão de fuso.
def build_grafico_diario(atendidas, preco_entrega)
dias = (@periodo_inicio..@periodo_fim).to_a dias = (@periodo_inicio..@periodo_fim).to_a
if dias.size <= MAX_DIAS_GRAFICO if dias.size <= MAX_DIAS_GRAFICO
# Granularidade diária (períodos curtos) # Granularidade diária (períodos curtos)
contagem = atendidas.group("DATE(checkout)").count.transform_keys(&:to_s) contagem = pagas.group("DATE(checkout)").count.transform_keys(&:to_s)
labels = dias.map { |d| d.strftime('%d/%m') } labels = dias.map { |d| d.strftime('%d/%m') }
valores = dias.map { |d| ((contagem[d.to_s] || 0) * preco_entrega).round(2) } valores = dias.map { |d| ((contagem[d.to_s] || 0) * preco_entrega).round(2) }
qtds = dias.map { |d| contagem[d.to_s] || 0 } qtds = dias.map { |d| contagem[d.to_s] || 0 }
else else
# Período longo: agrega por mês para o gráfico não ficar vazio nem poluído. # Período longo: agrega por mês para o gráfico não ficar vazio nem poluído.
contagem = atendidas.group("TO_CHAR(checkout, 'YYYY-MM')").count contagem = pagas.group("TO_CHAR(checkout, 'YYYY-MM')").count
meses = meses_no_periodo meses = meses_no_periodo
labels = meses.map { |m| Date.strptime(m, '%Y-%m').strftime('%m/%Y') } labels = meses.map { |m| Date.strptime(m, '%Y-%m').strftime('%m/%Y') }
valores = meses.map { |m| ((contagem[m] || 0) * preco_entrega).round(2) } valores = meses.map { |m| ((contagem[m] || 0) * preco_entrega).round(2) }
@@ -387,28 +241,6 @@ class DashboardController < ApplicationController
{ labels: labels, valores: valores, qtds: qtds } { labels: labels, valores: valores, qtds: qtds }
end end
# Valor pago por dia (pela data do pagamento) + acumulado no período,
# para o gráfico de fluxo de pagamentos.
def build_grafico_pagamentos(pagos)
dias = (@periodo_inicio..@periodo_fim).to_a
if dias.size <= MAX_DIAS_GRAFICO
soma = pagos.group("DATE(pago_em)").sum(:valor_total).transform_keys(&:to_s)
labels = dias.map { |d| d.strftime('%d/%m') }
chaves = dias.map(&:to_s)
else
soma = pagos.group("TO_CHAR(pago_em, 'YYYY-MM')").sum(:valor_total)
meses = meses_no_periodo
labels = meses.map { |m| Date.strptime(m, '%Y-%m').strftime('%m/%Y') }
chaves = meses
end
valores = chaves.map { |k| soma[k].to_f.round(2) }
acumulado = 0
{ labels: labels, valores: valores,
acumulado: valores.map { |v| (acumulado += v).round(2) } }
end
# Meses ("YYYY-MM") do início ao fim do período selecionado, inclusive. # Meses ("YYYY-MM") do início ao fim do período selecionado, inclusive.
def meses_no_periodo def meses_no_periodo
meses = [] meses = []

View File

@@ -3,24 +3,35 @@ module Motorista
class DashboardController < ApplicationController class DashboardController < ApplicationController
before_action :garantir_motorista before_action :garantir_motorista
# GET /motorista?inicio=&fim= # GET /motorista
#
# UMA tela só: o motorista escolhe o período e vê, para aquele recorte, o que
# já foi FECHADO pela empresa, o que ainda é ESTIMATIVA e de onde vem cada
# centavo (os fechamentos que compõem o total). Antes o total ficava numa
# tela e a composição em outra — e nenhuma das duas tinha período.
def index def index
nome = current_user.nome nome = current_user.nome
@inicio, @fim = periodo_selecionado # Card 1 — Valor ESTIMADO (mês atual): entregas pagas × preco_entrega
@faixa_total = faixa_completa(nome) # para o atalho "Tudo" @estimado_inicio = Date.current.beginning_of_month
@estimado_fim = Date.current
entregas_mes = Entrega.pagas.da_conta_gade
.do_motorista(nome)
.no_periodo(@estimado_inicio, @estimado_fim)
.count
@valor_estimado = entregas_mes * Configuracao.preco_entrega
@entregas_mes = entregas_mes
carregar_estimado(nome) # Card 2 — Valor CONSOLIDADO: soma das consolidações finalizadas
carregar_fechado(nome) @minhas_consolidacoes = Consolidacao.ativas
.joins(:consolidacao_motoristas)
.where(consolidacao_motoristas: { motorista_nome: nome, arquivado_em: nil })
.recentes
.distinct
@valor_consolidado = ConsolidacaoMotorista.ativos
.joins(:consolidacao)
.where(motorista_nome: nome,
consolidacoes: { status: :finalizada, deleted_at: nil })
.sum(:valor_total)
# Aviso in-app: pagamentos confirmados nos últimos 7 dias (notificação na # Aviso in-app: pagamentos confirmados nos últimos 7 dias (notificação na
# própria página do motorista, além de WhatsApp/email). Independe do # própria página do motorista, além de WhatsApp/email).
# período escolhido — é um aviso, não um relatório.
@pagamentos_recentes = ConsolidacaoMotorista @pagamentos_recentes = ConsolidacaoMotorista
.joins(:consolidacao) .joins(:consolidacao)
.where(motorista_nome: nome, .where(motorista_nome: nome,
@@ -31,96 +42,8 @@ module Motorista
.includes(:consolidacao) .includes(:consolidacao)
end end
# GET /motorista/totais/pdf?inicio=&fim= — o extrato do período (total,
# composição e assinaturas), para guardar ou mandar por WhatsApp. Mesmo
# service da tela, então o papel e o painel nunca divergem.
def totais_pdf
inicio, fim = periodo_selecionado
totais = Analytics::TotaisPorMotorista.new(
inicio: inicio, fim: fim, motorista: current_user.nome
)
arquivo = ['meu_total', current_user.nome.parameterize(separator: '_'),
inicio.strftime('%Y%m%d'), fim.strftime('%Y%m%d')].join('_')
send_data Pdf::TotaisMotoristaPdf.new(totais: totais).render,
filename: "#{arquivo}.pdf",
type: 'application/pdf',
disposition: 'attachment'
end
private private
# Card ESTIMADO: entregas ATENDIDAS × preco_entrega dentro do período.
#
# Atendida = o motorista FOI ao local e fechou a visita, com sucesso
# ('completed') OU insucesso ('failed') — no insucesso o deslocamento
# aconteceu e teve custo, então a empresa paga os dois. É o MESMO recorte do
# dashboard administrativo e da consolidação (ver Entrega::STATUS_ATENDIDO e
# Entrega.contar_atendidas): usava `pagas` (só concluídas) e o motorista via
# um valor MENOR que o do fechamento, sem entender de onde vinha a diferença.
#
# ⚠️ O eixo é o CHECKOUT (data real da visita), não planned_date: falhada TEM
# checkout, e uma entrega planejada em 31/07 mas fechada em 01/08 pertence a
# agosto — igual ao que a consolidação considera elegível.
#
# O fim é limitado a HOJE: estimar entregas de dias que ainda não
# aconteceram não faz sentido (o período pode ir até o fim do mês).
def carregar_estimado(nome)
@estimado_inicio = @inicio
@estimado_fim = [@fim, Date.current].min
if @estimado_fim < @estimado_inicio
@entregas_concluidas = @entregas_falhadas = @entregas_mes = 0
@valor_estimado = 0
return
end
atendidas = Entrega.atendidas.da_conta_gade
.do_motorista(nome)
.no_periodo_checkout(@estimado_inicio, @estimado_fim)
@entregas_concluidas = atendidas.concluidas.count
@entregas_falhadas = atendidas.falhadas.count
@entregas_mes = @entregas_concluidas + @entregas_falhadas
@valor_estimado = @entregas_mes * Configuracao.preco_entrega
end
# Card FECHADO + composição: o mesmo service do modo "Por motorista" do
# admin, recortado neste motorista — os dois lados mostram o mesmo número.
def carregar_fechado(nome)
@totais = Analytics::TotaisPorMotorista.new(inicio: @inicio, fim: @fim, motorista: nome)
@consolidacoes = @totais.consolidacoes_de(nome)
@valor_consolidado = @totais.total_geral
end
# Primeira e última data de fechamento do motorista — alimenta o atalho
# "Tudo" com datas reais em vez de uma faixa inventada.
def faixa_completa(nome)
escopo = Consolidacao.ativas.finalizada
.joins(:consolidacao_motoristas)
.where(consolidacao_motoristas: { motorista_nome: nome, arquivado_em: nil })
inicio = escopo.minimum(:data_inicio)
fim = escopo.maximum(:data_fim)
return nil if inicio.nil? || fim.nil?
[inicio.to_date, fim.to_date]
end
# Default: mês corrente INTEIRO (fim = último dia do mês) — o mês é a unidade
# de fechamento. Mesma regra do lado do admin.
def periodo_selecionado
inicio = parse_data(params[:inicio]) || Date.current.beginning_of_month
fim = parse_data(params[:fim]) || Date.current.end_of_month
inicio, fim = fim, inicio if fim < inicio
[inicio, fim]
end
def parse_data(str)
return nil if str.blank?
Date.parse(str)
rescue ArgumentError, TypeError
nil
end
def garantir_motorista def garantir_motorista
return if current_user.motorista? return if current_user.motorista?
redirect_to dashboard_path, alert: 'Área exclusiva dos motoristas.' redirect_to dashboard_path, alert: 'Área exclusiva dos motoristas.'

View File

@@ -7,16 +7,10 @@
class OperacoesDashboardController < ApplicationController class OperacoesDashboardController < ApplicationController
MODOS = %w[operacao global comparar].freeze MODOS = %w[operacao global comparar].freeze
# Linhas por página da tabela espelho (planilha da operação)
ESPELHO_POR_PAGINA = 15
XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'.freeze
def index def index
authorize :dashboard, :operacoes? authorize :dashboard, :operacoes?
@periodo_inicio, @periodo_fim = periodo_selecionado @periodo_inicio, @periodo_fim = periodo_selecionado
@periodo_explicito = periodo_explicito?
@modo = MODOS.include?(params[:modo]) ? params[:modo] : 'operacao' @modo = MODOS.include?(params[:modo]) ? params[:modo] : 'operacao'
@operacoes_agrupadas = Operacao.agrupadas_por_mes @operacoes_agrupadas = Operacao.agrupadas_por_mes
@tabelas_validas = Operacao.nomes_validos @tabelas_validas = Operacao.nomes_validos
@@ -27,7 +21,6 @@ class OperacoesDashboardController < ApplicationController
when 'global' when 'global'
# Global: agrega TODAS as operações dentro da faixa de datas escolhida. # Global: agrega TODAS as operações dentro da faixa de datas escolhida.
@metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro) @metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro)
@fora_operacao = Analytics::NotasForaOperacao.new(inicio: @periodo_inicio, fim: @periodo_fim)
when 'comparar' when 'comparar'
# Comparar: cada operação inteira (sem filtro de data — só vale no Global). # Comparar: cada operação inteira (sem filtro de data — só vale no Global).
@op_a = Operacao.sanitizar([params[:op_a]]).first || @tabelas_validas[0] @op_a = Operacao.sanitizar([params[:op_a]]).first || @tabelas_validas[0]
@@ -35,95 +28,14 @@ class OperacoesDashboardController < ApplicationController
@metricas_a = montar([@op_a]) if @op_a @metricas_a = montar([@op_a]) if @op_a
@metricas_b = montar([@op_b]) if @op_b @metricas_b = montar([@op_b]) if @op_b
else else
# Operação única: o recorte é SEMPRE a operação inteira, mesmo com # Operação única: a operação inteira (a data vem dos próprios dados).
# inicio/fim na URL. É o que confere com os documentos físicos e com a
# planilha entregue ao cliente (Analytics::PlanilhaEntregas também não
# filtra data). Recortar pelo período derrubava as entregas que a operação
# executou fora do mês do nome dela — EMAD AGO 2026 rodou de 30/07 a
# 12/08, e "01/08 → hoje" mostrava 1146 das 2021 NFs.
#
# O período escolhido continua tendo função aqui: alimenta as "Notas fora
# da operação" e aparece no cabeçalho como REFERÊNCIA (quantas NFs da
# operação caíram na faixa), para comparar com o dashboard financeiro sem
# mexer no número que o cliente confere.
@operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
@metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao
@fora_operacao = Analytics::NotasForaOperacao.new(inicio: @periodo_inicio, fim: @periodo_fim) if @periodo_explicito
end
end
# Planilha da operação (tabela espelho): página própria com busca e paginação.
# Aceita os mesmos parâmetros do index (operacao/modo global/período/cross-
# filters), então o botão no dashboard preserva o contexto atual.
def planilha
authorize :dashboard, :operacoes?
@periodo_inicio, @periodo_fim = periodo_selecionado
@periodo_explicito = periodo_explicito?
@modo = params[:modo] == 'global' ? 'global' : 'operacao'
@operacoes_agrupadas = Operacao.agrupadas_por_mes
@tabelas_validas = Operacao.nomes_validos
@filtros, @chips = cross_filtros
@data_filtro = parse_data(params[:f_data])
if @modo == 'global'
@metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro)
else
# Mesma regra do #index: operação = operação inteira (a planilha espelho
# tem que bater com a que é entregue ao cliente).
@operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first @operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
@metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao
end end
montar_tabela_espelho if @metricas
end
# GET /dashboard/operacoes/planilha/baixar?operacao=gade_entregas_...
# Baixa a planilha Entregas (.xlsx) da operação já preenchida com o resultado
# do rastreio — o modelo que é entregue ao cliente.
def baixar_planilha
# 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)
return redirect_to(operacoes_planilha_path, alert: 'Selecione uma operação válida.')
end
planilha = Analytics::PlanilhaEntregas.new(tabela)
if planilha.linhas.empty?
return redirect_to(operacoes_planilha_path(operacao: tabela),
alert: "A operação #{planilha.label} não tem registros.")
end
send_data Analytics::PlanilhaEntregasXlsx.new(planilha).gerar,
filename: "Entregas #{planilha.label} #{Date.current.strftime('%m.%Y')}.xlsx",
type: XLSX_MIME,
disposition: 'attachment'
end end
private private
# Tabela espelho da planilha da operação: busca textual (q) + paginação (pg)
# sobre as linhas já cross-filtradas. Ordena pelas mais recentes (checkout;
# sem checkout vai para o fim).
def montar_tabela_espelho
@busca = params[:q].to_s.strip
linhas = @metricas.buscar(@busca).sort_by { |r| r['checkout'].to_s }.reverse
@espelho_total = linhas.size
@espelho_paginas = [(@espelho_total / ESPELHO_POR_PAGINA.to_f).ceil, 1].max
@espelho_pagina = params[:pg].to_i.clamp(1, @espelho_paginas)
@espelho_linhas = linhas[(@espelho_pagina - 1) * ESPELHO_POR_PAGINA, ESPELHO_POR_PAGINA] || []
end
# Período escolhido pelo operador (veio na URL) x default da tela. No modo
# Operação ele não recorta os KPIs — ver o comentário no #index.
def periodo_explicito?
params[:inicio].present? || params[:fim].present?
end
def montar(tabelas, inicio: nil, fim: nil, filtros: {}, data: nil) def montar(tabelas, inicio: nil, fim: nil, filtros: {}, data: nil)
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data) Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data)
end end

Some files were not shown because too many files have changed in this diff Show More