teste #3

Merged
victor merged 20 commits from teste into main 2026-07-02 15:31:51 -03:00
35 changed files with 848 additions and 0 deletions
Showing only changes of commit 11531479f8 - Show all commits

View File

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 414 KiB

View File

Before

Width:  |  Height:  |  Size: 698 KiB

After

Width:  |  Height:  |  Size: 698 KiB

View File

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

View File

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

View File

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

View File

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 478 KiB

After

Width:  |  Height:  |  Size: 478 KiB

View File

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 196 KiB

View File

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 234 KiB

View File

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 280 KiB

View File

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View File

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 253 KiB

View File

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

View File

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@@ -0,0 +1,53 @@
# app/controllers/operacoes_dashboard_controller.rb
#
# Dashboard de análise de ENTREGAS por operação (separado do dashboard
# financeiro). O operador escolhe uma operação (UBS Norte, EMAD, ...) e vê os
# KPIs/insucessos/STS/mapa; também há os modos "global" (todas no período) e
# "comparar" (uma operação vs outra). Os números vêm de Analytics::OperacaoMetricas.
class OperacoesDashboardController < ApplicationController
MODOS = %w[operacao global comparar].freeze
def index
authorize :dashboard, :operacoes?
@periodo_inicio, @periodo_fim = periodo_selecionado
@modo = MODOS.include?(params[:modo]) ? params[:modo] : 'operacao'
@operacoes_agrupadas = Operacao.agrupadas_por_mes
@tabelas_validas = Operacao.nomes_validos
case @modo
when 'global'
@metricas = montar(@tabelas_validas)
when 'comparar'
@op_a = Operacao.sanitizar([params[:op_a]]).first || @tabelas_validas[0]
@op_b = Operacao.sanitizar([params[:op_b]]).first || @tabelas_validas[1]
@metricas_a = montar([@op_a]) if @op_a
@metricas_b = montar([@op_b]) if @op_b
else
@operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
@metricas = montar([@operacao]) if @operacao
end
end
private
def montar(tabelas)
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: @periodo_inicio, fim: @periodo_fim)
end
# Faixa de datas do filtro (params inicio/fim). Default: início do mês → hoje.
# Espelha DashboardController#periodo_selecionado.
def periodo_selecionado
inicio = parse_data(params[:inicio]) || Date.current.beginning_of_month
fim = parse_data(params[:fim]) || Date.current
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
end

View File

@@ -7,4 +7,9 @@ class DashboardPolicy < Struct.new(:user, :dashboard)
def index?
user.admin? || user.gerente?
end
# Dashboard de operações: todos menos motorista (mesma regra do dashboard HTML).
def operacoes?
!user.motorista?
end
end

View File

@@ -0,0 +1,221 @@
# app/services/analytics/operacao_metricas.rb
#
# Núcleo de dados do "Dashboard de Operações".
#
# Espelha a query de gestão do cliente: pega o ÚLTIMO status de cada NF em
# db_reem_simplerout_2026 (ROW_NUMBER por reference_id, checkout desc) e faz
# INNER JOIN com a(s) tabela(s) de operação (gade_entregas_*) por
# reference_id::text = nota_fiscal. Depois agrega tudo em Ruby para alimentar os
# painéis (KPIs, insucessos %, índices de falha, status, motoristas, STS, por dia
# e o mapa de calor).
#
# SEGURANÇA: os nomes das tabelas de operação só entram no SQL depois de passar
# pela whitelist (Operacao.sanitizar) + connection.quote_table_name — mesmo padrão
# anti-injection usado em Entrega.da_operacoes. As tabelas são SOMENTE LEITURA.
module Analytics
class OperacaoMetricas
# Centro padrão do mapa quando não há coordenadas (Grande São Paulo).
SP_CENTRO = [-23.55, -46.63].freeze
def initialize(tabelas:, inicio:, fim:)
@tabelas = Operacao.sanitizar(Array(tabelas))
@inicio = inicio.to_date
@fim = fim.to_date
end
def operacoes_label
@tabelas.map { |t| Operacao.label(t) }.join(', ')
end
# Linhas cruas (Array de Hash com chave string) — base de todas as agregações.
def registros
@registros ||= carregar
end
def vazio?
registros.empty?
end
# ── KPIs ─────────────────────────────────────────────────────
def total
registros.size
end
def sucesso
registros.count { |r| r['status'] == 'completed' }
end
def recusas
registros.count { |r| Entrega::STATUS_FALHA.include?(r['status']) }
end
# Em aberto: nem concluídas nem falhadas.
def pendentes
total - sucesso - recusas
end
# Donut "Insucessos %": completas vs falhas (sobre o total).
def insucessos_pct
{
completed: sucesso,
failed: recusas,
pct_completed: pct(sucesso, total),
pct_failed: pct(recusas, total)
}
end
# Falhas agrupadas por observação (motivo), % sobre o total de falhas.
def indices_falha
falhas = registros.select { |r| Entrega::STATUS_FALHA.include?(r['status']) }
total_falhas = falhas.size
falhas.group_by { |r| r['observation'].presence || 'SEM OBSERVAÇÃO' }
.map { |obs, rows| { observation: obs, total: rows.size, pct: pct(rows.size, total_falhas) } }
.sort_by { |h| -h[:total] }
end
# Contagem por status da tabela gade (RECORRENTE/NOVO/...).
def por_status_gade
registros.group_by { |r| r['status_gade'].presence || '—' }
.map { |st, rows| { status: st, total: rows.size } }
.sort_by { |h| -h[:total] }
end
# NFs por motorista (todas as entregas), desc.
def por_motorista
contagem(registros, 'driver')
end
# Entregas CONCLUÍDAS por unidade (contact_name), desc.
def por_sts
contagem(registros.select { |r| r['status'] == 'completed' }, 'contact_name')
end
# Por dia (DATE do checkout): completas vs falhas — gráfico de barras.
def por_dia
por_data = Hash.new { |h, k| h[k] = { completed: 0, failed: 0 } }
registros.each do |r|
d = data_de(r['checkout'])
next unless d
if r['status'] == 'completed'
por_data[d][:completed] += 1
elsif Entrega::STATUS_FALHA.include?(r['status'])
por_data[d][:failed] += 1
end
end
datas = por_data.keys.sort
{
labels: datas.map { |d| d.strftime('%d/%m/%Y') },
completed: datas.map { |d| por_data[d][:completed] },
failed: datas.map { |d| por_data[d][:failed] }
}
end
# Pontos [lat, lng] das entregas concluídas para o heatmap.
# Prefere as coordenadas de CHECKOUT (saída); cai para as de CHECKIN.
def pontos_mapa
@pontos_mapa ||= registros.filter_map do |r|
next unless r['status'] == 'completed'
lat = num(r['checkout_latitude']) || num(r['latitude'])
lng = num(r['checkout_longitude']) || num(r['longitude'])
next if lat.nil? || lng.nil? || (lat.zero? && lng.zero?)
[lat, lng]
end
end
def centro_mapa
pontos_mapa.first || SP_CENTRO
end
private
def contagem(rows, coluna)
rows.group_by { |r| r[coluna].presence || '—' }
.map { |nome, grupo| { nome: nome, total: grupo.size } }
.sort_by { |h| -h[:total] }
end
def pct(parte, total)
return 0.0 if total.to_i.zero?
(parte.to_f / total * 100).round(2)
end
def num(valor)
return nil if valor.nil? || valor.to_s.strip.empty?
Float(valor)
rescue ArgumentError, TypeError
nil
end
def data_de(valor)
return nil if valor.nil?
return valor.to_date if valor.respond_to?(:to_date)
Date.parse(valor.to_s)
rescue ArgumentError, TypeError
nil
end
def carregar
return [] if @tabelas.empty?
conn = ActiveRecord::Base.connection
unions = @tabelas.map do |tabela|
gade = conn.quote_table_name(tabela)
label = conn.quote(Operacao.label(tabela))
<<~SQL.strip
SELECT #{label} AS operacao,
r.driver, r.status, r.observation, r.contact_name,
r.checkout, r.planned_date,
r.latitude, r.longitude,
r.checkout_latitude, r.checkout_longitude,
g.status AS status_gade
FROM ultimo r
INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal
WHERE r.rn = 1 AND (#{periodo_sql(conn)})
SQL
end
sql = <<~SQL
WITH ultimo AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout DESC NULLS LAST) AS rn
FROM #{conn.quote_table_name(Entrega.table_name)}
WHERE reference_id IS NOT NULL #{conta_sql(conn)}
)
#{unions.join("\nUNION ALL\n")}
SQL
conn.select_all(sql).to_a
end
# Concluídas/falhas pela DATA REAL (checkout); pendentes (sem checkout) pela
# data planejada. Espelha Entrega.no_periodo_checkout + no_periodo.
def periodo_sql(conn)
ini = conn.quote(@inicio)
fim_excl = conn.quote(@fim + 1)
fim_dia = conn.quote(@fim.end_of_day)
<<~SQL.strip
(r.checkout >= #{ini} AND r.checkout < #{fim_excl})
OR (r.checkout IS NULL AND r.planned_date >= #{ini} AND r.planned_date <= #{fim_dia})
SQL
end
# Replica Entrega.da_conta_gade como fragmento "AND (account_id ...)".
# Lista separada por vírgula em DB_EXISTING_ACCOUNT_ID; "all"/vazio = sem filtro.
def conta_sql(conn)
contas = ENV.fetch('DB_EXISTING_ACCOUNT_ID', '95907').to_s.strip
return '' if contas.empty? || contas.casecmp?('all')
valores = contas.split(',', -1).map(&:strip)
inclui_null = valores.any?(&:empty?)
lista = valores.reject(&:empty?).map { |v| conn.quote(v) }
condicoes = []
condicoes << "account_id IN (#{lista.join(', ')})" if lista.any?
condicoes << 'account_id IS NULL' if inclui_null
return '' if condicoes.empty?
"AND (#{condicoes.join(' OR ')})"
end
end
end

View File

@@ -37,6 +37,7 @@
<%# Dashboard — admin/gerente/operador/externo (todos menos motorista) %>
<% unless current_user.motorista? %>
<%= nav_link_to '📊 Dashboard', dashboard_path %>
<%= nav_link_to '📈 Operações', operacoes_dashboard_path %>
<% end %>
<%# Consolidações — só quem pode consolidar (exclui o externo) %>

View File

@@ -0,0 +1,15 @@
<%# locals: metricas (Analytics::OperacaoMetricas) %>
<% pontos = metricas.pontos_mapa %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<div class="flex items-center justify-between mb-4">
<p class="text-gray-400 text-sm font-medium">Mapa de Calor de Entregas</p>
<span class="text-xs text-gray-500"><%= pontos.size %> pontos</span>
</div>
<% if pontos.empty? %>
<p class="text-center py-8 text-gray-500 text-sm">Sem coordenadas para exibir no período.</p>
<% else %>
<div class="mapa-calor h-96 rounded-xl overflow-hidden"
data-pontos="<%= pontos.to_json %>"
data-centro="<%= metricas.centro_mapa.to_json %>"></div>
<% end %>
</div>

View File

@@ -0,0 +1,110 @@
<%# locals: metricas (Analytics::OperacaoMetricas), titulo (opcional) %>
<% titulo = local_assigns[:titulo] %>
<% ins = metricas.insucessos_pct %>
<% pd = metricas.por_dia %>
<div class="space-y-4">
<% if titulo.present? %>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-white"><%= titulo %></h2>
<span class="text-xs text-gray-500"><%= metricas.total %> entregas</span>
</div>
<% end %>
<%# ── KPIs ─────────────────────────────────────────────── %>
<div class="grid grid-cols-2 xl:grid-cols-4 gap-4">
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-2">📦 Total de Entregas</p>
<p class="text-4xl font-black text-white"><%= metricas.total %></p>
</div>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-2">✅ Total de Sucesso</p>
<p class="text-4xl font-black text-green-400"><%= metricas.sucesso %></p>
</div>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-2">❌ Total de Recusas</p>
<p class="text-4xl font-black text-red-400"><%= metricas.recusas %></p>
</div>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-2">⏳ Entregas Pendentes</p>
<p class="text-4xl font-black text-yellow-400"><%= metricas.pendentes %></p>
</div>
</div>
<%# ── Insucessos % + Índices de falha + Status ─────────── %>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-4">Insucessos %</p>
<% if metricas.total.zero? %>
<p class="text-center py-8 text-gray-500 text-sm">Sem dados no período.</p>
<% else %>
<div class="relative h-56">
<canvas class="dash-donut"
data-completed="<%= ins[:completed] %>"
data-failed="<%= ins[:failed] %>"></canvas>
</div>
<div class="flex justify-center gap-4 mt-3 text-xs">
<span class="text-cyan-400">● Completas <%= ins[:pct_completed] %>%</span>
<span class="text-indigo-400">● Falhas <%= ins[:pct_failed] %>%</span>
</div>
<% end %>
</div>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-4">Índices de Falha</p>
<%= render 'tabela_simples',
cabecalhos: ['Observação', 'Total', '%'],
linhas: metricas.indices_falha.map { |h| [h[:observation], h[:total], "#{h[:pct]}%"] },
vazio: 'Nenhuma falha no período.' %>
</div>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-4">Por Status</p>
<%= render 'tabela_simples',
cabecalhos: ['Status', 'Quantidade'],
linhas: metricas.por_status_gade.map { |h| [h[:status], h[:total]] },
vazio: 'Sem dados.',
total: metricas.total %>
</div>
</div>
<%# ── Motoristas + STS ─────────────────────────────────── %>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-4">Notas fiscais por Motorista</p>
<div class="max-h-80 overflow-y-auto">
<%= render 'tabela_simples',
cabecalhos: ['Motorista', 'Notas fiscais'],
linhas: metricas.por_motorista.map { |h| [h[:nome], h[:total]] },
vazio: 'Sem dados.',
total: metricas.total %>
</div>
</div>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-4">Entregas por STS (Unidade)</p>
<div class="max-h-80 overflow-y-auto">
<%= render 'tabela_simples',
cabecalhos: ['Unidade', 'NFs entregues'],
linhas: metricas.por_sts.map { |h| [h[:nome], h[:total]] },
vazio: 'Sem dados.',
total: metricas.sucesso %>
</div>
</div>
</div>
<%# ── Entregas por dia ─────────────────────────────────── %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-4">Entregas por Dia</p>
<% if pd[:labels].empty? %>
<p class="text-center py-8 text-gray-500 text-sm">Sem dados no período.</p>
<% else %>
<div class="relative h-72">
<canvas class="dash-bars"
data-labels="<%= pd[:labels].to_json %>"
data-completed="<%= pd[:completed].to_json %>"
data-failed="<%= pd[:failed].to_json %>"></canvas>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,32 @@
<%# locals: cabecalhos (Array), linhas (Array de Array), vazio (String), total (opcional) %>
<% total_rodape = local_assigns[:total] %>
<% if linhas.blank? %>
<p class="text-center py-6 text-gray-500 text-sm"><%= vazio %></p>
<% else %>
<table class="w-full text-sm">
<thead>
<tr class="text-gray-400 text-left border-b border-white/5">
<% cabecalhos.each_with_index do |c, i| %>
<th class="pb-2 font-medium <%= 'text-right' if i.positive? %>"><%= c %></th>
<% end %>
</tr>
</thead>
<tbody>
<% linhas.each do |celulas| %>
<tr class="border-b border-white/5">
<% celulas.each_with_index do |cel, i| %>
<td class="py-2 <%= i.positive? ? 'text-right font-semibold text-white' : 'text-gray-300' %>"><%= cel %></td>
<% end %>
</tr>
<% end %>
</tbody>
<% if total_rodape %>
<tfoot>
<tr class="text-white font-bold">
<td class="pt-2">Total</td>
<td class="pt-2 text-right" colspan="<%= cabecalhos.size - 1 %>"><%= total_rodape %></td>
</tr>
</tfoot>
<% end %>
</table>
<% end %>

View File

@@ -0,0 +1,286 @@
<%# app/views/operacoes_dashboard/index.html.erb %>
<%# Dashboard de análise de entregas por operação (KPIs, insucessos, STS, mapa). %>
<% content_for :head do %>
<meta name="turbo-cache-control" content="no-cache">
<%# Flatpickr (date range) — CSS no <head> %>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/themes/dark.css">
<%# Leaflet (mapa de calor) %>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
.flatpickr-calendar { background: #1a1a1a; border: 1px solid rgba(255,255,255,.1); box-shadow: 0 10px 30px rgba(0,0,0,.5); }
.flatpickr-months, .flatpickr-weekdays, .flatpickr-weekday { background: #1a1a1a; color: #9ca3af; }
.flatpickr-current-month, .flatpickr-monthDropdown-months, .numInput { color: #fff; }
.flatpickr-day { color: #e5e7eb; }
.flatpickr-day:hover { background: #2a2a2a; border-color: #2a2a2a; }
.flatpickr-day.today { border-color: #f97316; }
.flatpickr-day.selected, .flatpickr-day.startRange, .flatpickr-day.endRange,
.flatpickr-day.selected:hover, .flatpickr-day.startRange:hover, .flatpickr-day.endRange:hover {
background: #f97316; border-color: #f97316; color: #0a0a0a;
}
.flatpickr-day.inRange { background: rgba(249,115,22,.18); border-color: rgba(249,115,22,.18); box-shadow: -5px 0 0 rgba(249,115,22,.18), 5px 0 0 rgba(249,115,22,.18); }
.flatpickr-months .flatpickr-prev-month svg, .flatpickr-months .flatpickr-next-month svg { fill: #f97316; }
/* O mapa do Leaflet usa fundo claro — moldura escura ao redor */
.leaflet-container { background: #1a1a1a; }
</style>
<% end %>
<% hoje = Date.current %>
<% atalhos = {
'Hoje' => [hoje, hoje],
'7 dias' => [hoje - 6, hoje],
'Este mês' => [hoje.beginning_of_month, hoje],
'30 dias' => [hoje - 29, hoje]
} %>
<% ini_iso = @periodo_inicio.strftime('%Y-%m-%d') %>
<% fim_iso = @periodo_fim.strftime('%Y-%m-%d') %>
<%# Parâmetros de operação a preservar ao trocar de modo/período %>
<% ctx_ops = { operacao: @operacao, op_a: @op_a, op_b: @op_b }.compact %>
<div class="space-y-6">
<%# ── Cabeçalho ─────────────────────────────────────────── %>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-white">Dashboard de Operações</h1>
<p class="text-gray-400 text-sm mt-0.5">
<%= @periodo_inicio.strftime('%d/%m/%Y') %> <%= @periodo_fim.strftime('%d/%m/%Y') %>
· Atualizado às <%= Time.now.strftime('%H:%M') %>
</p>
</div>
<%# Filtro de período (flatpickr) + atalhos %>
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-orange-500">📅</span>
<input type="text" id="periodo-range" readonly placeholder="Selecione o período"
value="<%= @periodo_inicio.strftime('%d/%m/%Y') %> até <%= @periodo_fim.strftime('%d/%m/%Y') %>"
class="cursor-pointer pl-4 pr-10 py-2.5 bg-[#1a1a1a] border border-white/10 rounded-xl
text-white text-sm w-full sm:w-[240px] focus:outline-none focus:border-orange-500 placeholder-gray-500">
</div>
<div class="flex flex-wrap items-center gap-1">
<% atalhos.each do |label, (ini, fim)| %>
<% ativo = @periodo_inicio == ini && @periodo_fim == fim %>
<%= link_to label,
operacoes_dashboard_path(ctx_ops.merge(modo: @modo, inicio: ini.strftime('%Y-%m-%d'), fim: fim.strftime('%Y-%m-%d'))),
data: { turbo: false },
class: "px-3 py-2.5 rounded-xl text-sm whitespace-nowrap border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %>
<% end %>
</div>
</div>
</div>
<%# ── Abas de modo ──────────────────────────────────────── %>
<% modos = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global', 'comparar' => '⚖️ Comparar' } %>
<div class="flex flex-wrap items-center gap-2 border-b border-white/5 pb-3">
<% modos.each do |m, label| %>
<% ativo = @modo == m %>
<%= link_to label,
operacoes_dashboard_path(ctx_ops.merge(modo: m, inicio: ini_iso, fim: fim_iso)),
data: { turbo: false },
class: "px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap #{ativo ? 'bg-orange-500 text-black' : 'bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500'}" %>
<% end %>
</div>
<% if @tabelas_validas.blank? %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-10 text-center text-gray-400">
Nenhuma operação encontrada no banco (tabelas <code class="text-orange-400">gade_entregas_*</code>).
</div>
<% else %>
<%# ── Seletor de operação por modo ───────────────────── %>
<% if @modo == 'operacao' %>
<form method="get" action="<%= operacoes_dashboard_path %>" data-turbo="false" class="flex flex-wrap items-center gap-2">
<input type="hidden" name="modo" value="operacao">
<input type="hidden" name="inicio" value="<%= ini_iso %>">
<input type="hidden" name="fim" value="<%= fim_iso %>">
<label class="text-gray-400 text-sm">Operação:</label>
<select name="operacao" class="auto-submit bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500 min-w-[240px]">
<% @operacoes_agrupadas.each do |titulo, ops| %>
<optgroup label="<%= titulo %>">
<% ops.each do |op| %>
<option value="<%= op[:tabela] %>" <%= 'selected' if op[:tabela] == @operacao %>><%= op[:label] %></option>
<% end %>
</optgroup>
<% end %>
</select>
</form>
<% if @metricas %>
<%= render 'painel', metricas: @metricas %>
<%= render 'mapa', metricas: @metricas %>
<% end %>
<% elsif @modo == 'global' %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4 text-sm text-gray-300">
🌐 Visão <strong class="text-white">Global</strong> — todas as
<%= @tabelas_validas.size %> operações agregadas no período.
</div>
<% if @metricas %>
<%= render 'painel', metricas: @metricas %>
<%= render 'mapa', metricas: @metricas %>
<% end %>
<% else # comparar %>
<form method="get" action="<%= operacoes_dashboard_path %>" data-turbo="false" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<input type="hidden" name="modo" value="comparar">
<input type="hidden" name="inicio" value="<%= ini_iso %>">
<input type="hidden" name="fim" value="<%= fim_iso %>">
<div class="flex items-center gap-2">
<label class="text-cyan-400 text-sm font-semibold">A:</label>
<select name="op_a" class="auto-submit flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-cyan-500">
<% @operacoes_agrupadas.each do |titulo, ops| %>
<optgroup label="<%= titulo %>">
<% ops.each do |op| %>
<option value="<%= op[:tabela] %>" <%= 'selected' if op[:tabela] == @op_a %>><%= op[:label] %></option>
<% end %>
</optgroup>
<% end %>
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-indigo-400 text-sm font-semibold">B:</label>
<select name="op_b" class="auto-submit flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-indigo-500">
<% @operacoes_agrupadas.each do |titulo, ops| %>
<optgroup label="<%= titulo %>">
<% ops.each do |op| %>
<option value="<%= op[:tabela] %>" <%= 'selected' if op[:tabela] == @op_b %>><%= op[:label] %></option>
<% end %>
</optgroup>
<% end %>
</select>
</div>
</form>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div>
<% if @metricas_a %>
<%= render 'painel', metricas: @metricas_a, titulo: "A · #{@metricas_a.operacoes_label}" %>
<% else %>
<p class="text-gray-500 text-sm">Selecione a operação A.</p>
<% end %>
</div>
<div>
<% if @metricas_b %>
<%= render 'painel', metricas: @metricas_b, titulo: "B · #{@metricas_b.operacoes_label}" %>
<% else %>
<p class="text-gray-500 text-sm">Selecione a operação B.</p>
<% end %>
</div>
</div>
<% end %>
<% end %>
</div>
<%# ── Scripts: flatpickr, Chart.js, Leaflet ───────────────── %>
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/pt.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script>
(function () {
const ISO = '<%= ini_iso %>';
const ISO_FIM = '<%= fim_iso %>';
// ── Flatpickr (período) ──
function initPeriodo() {
const input = document.getElementById('periodo-range');
if (!input || typeof flatpickr === 'undefined' || input._flatpickr) return;
const toISO = (d) =>
d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const parseISO = (s) => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); };
flatpickr(input, {
mode: 'range',
locale: Object.assign({}, flatpickr.l10ns.pt, { rangeSeparator: ' até ' }),
dateFormat: 'd/m/Y',
defaultDate: [parseISO(ISO), parseISO(ISO_FIM)],
maxDate: 'today',
onClose: function (selectedDates) {
if (selectedDates.length === 0) return;
const ini = toISO(selectedDates[0]);
const fim = toISO(selectedDates[selectedDates.length - 1]);
const url = new URL(window.location.href);
url.searchParams.set('inicio', ini);
url.searchParams.set('fim', fim);
window.location.href = url.toString();
}
});
}
// ── Selects que submetem o form ao mudar ──
function initAutoSubmit() {
document.querySelectorAll('select.auto-submit').forEach(function (s) {
if (s.dataset.bound) return;
s.dataset.bound = '1';
s.addEventListener('change', function () { s.form.requestSubmit(); });
});
}
// ── Charts (donut + barras) ──
function initCharts() {
if (typeof Chart === 'undefined') return;
document.querySelectorAll('canvas.dash-donut').forEach(function (ctx) {
Chart.getChart(ctx)?.destroy();
new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Completas', 'Falhas'],
datasets: [{
data: [Number(ctx.dataset.completed), Number(ctx.dataset.failed)],
backgroundColor: ['#06b6d4', '#6366f1'],
borderColor: '#1a1a1a', borderWidth: 2
}]
},
options: { responsive: true, maintainAspectRatio: false, cutout: '62%', plugins: { legend: { display: false } } }
});
});
document.querySelectorAll('canvas.dash-bars').forEach(function (ctx) {
Chart.getChart(ctx)?.destroy();
new Chart(ctx, {
type: 'bar',
data: {
labels: JSON.parse(ctx.dataset.labels),
datasets: [
{ label: 'Completas', data: JSON.parse(ctx.dataset.completed), backgroundColor: '#06b6d4' },
{ label: 'Falhas', data: JSON.parse(ctx.dataset.failed), backgroundColor: '#6366f1' }
]
},
options: {
responsive: true, maintainAspectRatio: false,
scales: {
x: { stacked: true, ticks: { color: '#6b7280', maxTicksLimit: 12 }, grid: { color: 'rgba(255,255,255,0.04)' } },
y: { stacked: true, beginAtZero: true, ticks: { color: '#6b7280' }, grid: { color: 'rgba(255,255,255,0.04)' } }
},
plugins: { legend: { labels: { color: '#9ca3af' } } }
}
});
});
}
// ── Mapa de calor (Leaflet) ──
function initMapas() {
if (typeof L === 'undefined') return;
document.querySelectorAll('.mapa-calor').forEach(function (el) {
if (el._leaflet_id) return;
const pontos = JSON.parse(el.dataset.pontos || '[]');
const centro = JSON.parse(el.dataset.centro || '[-23.55,-46.63]');
const mapa = L.map(el).setView(centro, 11);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap', maxZoom: 19
}).addTo(mapa);
if (pontos.length && L.heatLayer) {
L.heatLayer(pontos, { radius: 18, blur: 22, maxZoom: 14 }).addTo(mapa);
}
setTimeout(function () { mapa.invalidateSize(); }, 200);
});
}
function initTudo() { initPeriodo(); initAutoSubmit(); initCharts(); initMapas(); }
initTudo();
document.addEventListener('turbo:load', initTudo);
})();
</script>

View File

@@ -16,6 +16,9 @@ Rails.application.routes.draw do
get '/dashboard', to: 'dashboard#index', as: :dashboard
get '/dashboard/relatorio_financeiro', to: 'dashboard#relatorio_financeiro', as: :dashboard_relatorio_financeiro
# Dashboard de análise de entregas por operação (UBS Norte, EMAD, ...)
get '/dashboard/operacoes', to: 'operacoes_dashboard#index', as: :operacoes_dashboard
# Painel motorista
get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard

View File

@@ -0,0 +1,26 @@
require 'rails_helper'
RSpec.describe 'Dashboard de Operações', type: :request do
it 'redireciona usuário não autenticado para o login' do
get operacoes_dashboard_path
expect(response).to redirect_to(new_user_session_path)
end
it 'bloqueia motorista (Pundit redireciona para a raiz)' do
sign_in create(:motorista)
get operacoes_dashboard_path
expect(response).to redirect_to(root_path)
end
it 'permite operador (renderiza a página)' do
sign_in create(:operador)
get operacoes_dashboard_path
expect(response).to have_http_status(:ok)
end
it 'permite gerente' do
sign_in create(:gerente)
get operacoes_dashboard_path(modo: 'global')
expect(response).to have_http_status(:ok)
end
end

View File

@@ -0,0 +1,96 @@
require 'rails_helper'
RSpec.describe Analytics::OperacaoMetricas do
# Linhas simuladas (mesma forma do retorno de select_all) — evita depender das
# tabelas externas no teste. Stubamos #registros para focar nas agregações.
let(:rows) do
[
row(status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 10:00:00', clat: '-23.5', clng: '-46.6'),
row(status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 11:00:00', lat: '-23.6', lng: '-46.7'),
row(status: 'completed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'RECORRENTE', checkout: '2026-06-23 09:00:00', clat: '-23.4', clng: '-46.5'),
row(status: 'failed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-23 12:00:00'),
row(status: 'failed', driver: 'Carlos', sts: 'STS PERUS', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-22 13:00:00'),
row(status: 'pending', driver: 'Marcos', sts: 'STS PERUS', gade: 'NOVO', planned: '2026-06-24')
]
end
subject(:metricas) do
described_class.new(tabelas: [], inicio: Date.new(2026, 6, 1), fim: Date.new(2026, 6, 30))
end
before do
# Isola das tabelas externas: não toca o catálogo nem db_reem_simplerout_2026.
allow(Operacao).to receive(:sanitizar).and_return([])
allow(metricas).to receive(:registros).and_return(rows)
end
it 'conta KPIs (total/sucesso/recusas/pendentes)' do
expect(metricas.total).to eq(6)
expect(metricas.sucesso).to eq(3)
expect(metricas.recusas).to eq(2)
expect(metricas.pendentes).to eq(1)
end
it 'calcula insucessos %' do
ins = metricas.insucessos_pct
expect(ins[:completed]).to eq(3)
expect(ins[:failed]).to eq(2)
expect(ins[:pct_completed]).to eq(50.0)
expect(ins[:pct_failed]).to eq(33.33)
end
it 'agrupa índices de falha por observação' do
expect(metricas.indices_falha).to eq([{ observation: 'ÓBITO', total: 2, pct: 100.0 }])
end
it 'agrupa por status_gade' do
expect(metricas.por_status_gade).to contain_exactly(
{ status: 'RECORRENTE', total: 3 },
{ status: 'NOVO', total: 3 }
)
end
it 'conta NFs por motorista (todas), desc' do
expect(metricas.por_motorista.first).to eq({ nome: 'Carlos', total: 3 })
expect(metricas.por_motorista.map { |h| h[:nome] }).to eq(%w[Carlos Pedro Marcos])
end
it 'conta entregas concluídas por STS (contact_name)' do
expect(metricas.por_sts).to eq([
{ nome: 'STS PERUS', total: 2 },
{ nome: 'STS PIRITUBA', total: 1 }
])
end
it 'agrupa por dia (completas x falhas)' do
pd = metricas.por_dia
expect(pd[:labels]).to eq(['22/06/2026', '23/06/2026'])
expect(pd[:completed]).to eq([2, 1])
expect(pd[:failed]).to eq([1, 1])
end
it 'monta pontos do mapa (prefere checkout, cai p/ checkin)' do
expect(metricas.pontos_mapa).to eq([[-23.5, -46.6], [-23.6, -46.7], [-23.4, -46.5]])
end
context 'sem registros' do
before { allow(metricas).to receive(:registros).and_return([]) }
it 'não divide por zero' do
expect(metricas.total).to eq(0)
expect(metricas.insucessos_pct[:pct_failed]).to eq(0.0)
expect(metricas.indices_falha).to eq([])
expect(metricas.pontos_mapa).to eq([])
expect(metricas.centro_mapa).to eq(described_class::SP_CENTRO)
end
end
def row(status:, driver:, sts:, gade:, obs: nil, checkout: nil, planned: nil, lat: nil, lng: nil, clat: nil, clng: nil)
{
'status' => status, 'driver' => driver, 'contact_name' => sts, 'status_gade' => gade,
'observation' => obs, 'checkout' => checkout, 'planned_date' => planned,
'latitude' => lat, 'longitude' => lng,
'checkout_latitude' => clat, 'checkout_longitude' => clng
}
end
end