Correção e Adição do filtro ao vivo

This commit is contained in:
2026-06-29 20:50:07 -03:00
parent 7f21a16327
commit 6b899ebafa
9 changed files with 279 additions and 74 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -14,11 +14,12 @@ class OperacoesDashboardController < ApplicationController
@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
@filtros, @chips = cross_filtros
case @modo case @modo
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) @metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros)
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]
@@ -28,14 +29,35 @@ class OperacoesDashboardController < ApplicationController
else else
# Operação única: a operação inteira (a data vem dos próprios dados). # Operação única: a operação inteira (a data vem dos próprios dados).
@operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first @operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
@metricas = montar([@operacao]) if @operacao @metricas = montar([@operacao], filtros: @filtros) if @operacao
end end
end end
private private
def montar(tabelas, inicio: nil, fim: nil) def montar(tabelas, inicio: nil, fim: nil, filtros: {})
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim) Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros)
end
# Cross-filter por clique nas tabelas. Cada param vira um filtro de coluna e um
# "chip" removível na tela. Retorna [filtros (coluna=>valor), chips (p/ a view)].
CROSS = {
'f_driver' => { coluna: 'driver', rotulo: 'Motorista' },
'f_sts' => { coluna: 'contact_name', rotulo: 'Unidade' },
'f_status' => { coluna: 'status_gade', rotulo: 'Status' },
'f_obs' => { coluna: 'observation', rotulo: 'Observação' }
}.freeze
def cross_filtros
filtros = {}
chips = []
CROSS.each do |param, cfg|
valor = params[param].to_s.strip
next if valor.empty?
filtros[cfg[:coluna]] = valor
chips << { param: param, valor: valor, rotulo: cfg[:rotulo] }
end
[filtros, chips]
end end
# Faixa de datas do filtro (params inicio/fim). Default: início do mês → hoje. # Faixa de datas do filtro (params inicio/fim). Default: início do mês → hoje.

View File

@@ -20,16 +20,39 @@ module Analytics
# inicio/fim são OPCIONAIS: quando nil, não há filtro de data e o conjunto é a # inicio/fim são OPCIONAIS: quando nil, não há filtro de data e o conjunto é a
# própria operação inteira (visão de operação única). O filtro de período só é # própria operação inteira (visão de operação única). O filtro de período só é
# usado na visão Global. # usado na visão Global.
def initialize(tabelas:, inicio: nil, fim: nil) #
# filtros: Hash de cross-filter (coluna => valor) aplicado em memória — clicar
# numa célula da tabela (motorista/STS/status/observação) refiltra TODO o
# dashboard por aquele valor. Ex.: { 'driver' => 'Carlos' }.
def initialize(tabelas:, inicio: nil, fim: nil, filtros: {})
@tabelas = Operacao.sanitizar(Array(tabelas)) @tabelas = Operacao.sanitizar(Array(tabelas))
@inicio = inicio&.to_date @inicio = inicio&.to_date
@fim = fim&.to_date @fim = fim&.to_date
@filtros = (filtros || {}).reject { |_, v| v.to_s.strip.empty? }
end end
def operacoes_label def operacoes_label
@tabelas.map { |t| Operacao.label(t) }.join(', ') @tabelas.map { |t| Operacao.label(t) }.join(', ')
end end
# Linhas após o cross-filter — base de TODAS as agregações/KPIs.
def linhas
@linhas ||= if @filtros.empty?
registros
else
registros.select { |r| @filtros.all? { |col, val| r[col].to_s == val.to_s } }
end
end
# Taxas (úteis no comparativo).
def taxa_sucesso
pct(sucesso, total)
end
def taxa_insucesso
pct(recusas, total)
end
# Faixa real das entregas carregadas (pela data de checkout) — para exibir o # Faixa real das entregas carregadas (pela data de checkout) — para exibir o
# período natural da operação no cabeçalho/painel. # período natural da operação no cabeçalho/painel.
def data_inicio def data_inicio
@@ -51,15 +74,15 @@ module Analytics
# ── KPIs ───────────────────────────────────────────────────── # ── KPIs ─────────────────────────────────────────────────────
def total def total
registros.size linhas.size
end end
def sucesso def sucesso
registros.count { |r| r['status'] == 'completed' } linhas.count { |r| r['status'] == 'completed' }
end end
def recusas def recusas
registros.count { |r| Entrega::STATUS_FALHA.include?(r['status']) } linhas.count { |r| Entrega::STATUS_FALHA.include?(r['status']) }
end end
# Em aberto: nem concluídas nem falhadas. # Em aberto: nem concluídas nem falhadas.
@@ -79,7 +102,7 @@ module Analytics
# Falhas agrupadas por observação (motivo), % sobre o total de falhas. # Falhas agrupadas por observação (motivo), % sobre o total de falhas.
def indices_falha def indices_falha
falhas = registros.select { |r| Entrega::STATUS_FALHA.include?(r['status']) } falhas = linhas.select { |r| Entrega::STATUS_FALHA.include?(r['status']) }
total_falhas = falhas.size total_falhas = falhas.size
falhas.group_by { |r| r['observation'].presence || 'SEM OBSERVAÇÃO' } falhas.group_by { |r| r['observation'].presence || 'SEM OBSERVAÇÃO' }
.map { |obs, rows| { observation: obs, total: rows.size, pct: pct(rows.size, total_falhas) } } .map { |obs, rows| { observation: obs, total: rows.size, pct: pct(rows.size, total_falhas) } }
@@ -88,25 +111,25 @@ module Analytics
# Contagem por status da tabela gade (RECORRENTE/NOVO/...). # Contagem por status da tabela gade (RECORRENTE/NOVO/...).
def por_status_gade def por_status_gade
registros.group_by { |r| r['status_gade'].presence || '—' } linhas.group_by { |r| r['status_gade'].presence || '—' }
.map { |st, rows| { status: st, total: rows.size } } .map { |st, rows| { status: st, total: rows.size } }
.sort_by { |h| -h[:total] } .sort_by { |h| -h[:total] }
end end
# NFs por motorista (todas as entregas), desc. # NFs por motorista (todas as entregas), desc.
def por_motorista def por_motorista
contagem(registros, 'driver') contagem(linhas, 'driver')
end end
# Entregas CONCLUÍDAS por unidade (contact_name), desc. # Entregas CONCLUÍDAS por unidade (contact_name), desc.
def por_sts def por_sts
contagem(registros.select { |r| r['status'] == 'completed' }, 'contact_name') contagem(linhas.select { |r| r['status'] == 'completed' }, 'contact_name')
end end
# Por dia (DATE do checkout): completas vs falhas — gráfico de barras. # Por dia (DATE do checkout): completas vs falhas — gráfico de barras.
def por_dia def por_dia
por_data = Hash.new { |h, k| h[k] = { completed: 0, failed: 0 } } por_data = Hash.new { |h, k| h[k] = { completed: 0, failed: 0 } }
registros.each do |r| linhas.each do |r|
d = data_de(r['checkout']) d = data_de(r['checkout'])
next unless d next unless d
if r['status'] == 'completed' if r['status'] == 'completed'
@@ -126,7 +149,7 @@ module Analytics
# Pontos [lat, lng] das entregas concluídas para o heatmap. # Pontos [lat, lng] das entregas concluídas para o heatmap.
# Prefere as coordenadas de CHECKOUT (saída); cai para as de CHECKIN. # Prefere as coordenadas de CHECKOUT (saída); cai para as de CHECKIN.
def pontos_mapa def pontos_mapa
@pontos_mapa ||= registros.filter_map do |r| @pontos_mapa ||= linhas.filter_map do |r|
next unless r['status'] == 'completed' next unless r['status'] == 'completed'
lat = num(r['checkout_latitude']) || num(r['latitude']) lat = num(r['checkout_latitude']) || num(r['latitude'])
lng = num(r['checkout_longitude']) || num(r['longitude']) lng = num(r['checkout_longitude']) || num(r['longitude'])
@@ -142,7 +165,7 @@ module Analytics
private private
def datas_checkout def datas_checkout
@datas_checkout ||= registros.filter_map { |r| data_de(r['checkout']) } @datas_checkout ||= linhas.filter_map { |r| data_de(r['checkout']) }
end end
def contagem(rows, coluna) def contagem(rows, coluna)

View File

@@ -0,0 +1,56 @@
<%# locals: a, b (Analytics::OperacaoMetricas), label_a, label_b %>
<%
linhas_cmp = [
{ nome: 'Total de Entregas', a: a.total, b: b.total, maior_melhor: true, suf: '' },
{ nome: 'Sucesso', a: a.sucesso, b: b.sucesso, maior_melhor: true, suf: '' },
{ nome: 'Recusas', a: a.recusas, b: b.recusas, maior_melhor: false, suf: '' },
{ nome: 'Pendentes', a: a.pendentes, b: b.pendentes, maior_melhor: false, suf: '' },
{ nome: 'Taxa de Sucesso', a: a.taxa_sucesso, b: b.taxa_sucesso, maior_melhor: true, suf: '%' },
{ nome: 'Taxa de Insucesso', a: a.taxa_insucesso, b: b.taxa_insucesso, maior_melhor: false, suf: '%' }
]
%>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h2 class="text-lg font-bold text-white">Comparativo</h2>
<div class="flex flex-wrap items-center gap-4 text-xs">
<span class="text-orange-400">● A · <%= label_a %> <span class="text-gray-500">(<%= a.total %>)</span></span>
<span class="text-blue-400">● B · <%= label_b %> <span class="text-gray-500">(<%= b.total %>)</span></span>
</div>
</div>
<%# Tabela comparativa (verde = melhor desempenho na linha) %>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-gray-400 text-left border-b border-white/5">
<th class="pb-2 font-medium pr-3">Métrica</th>
<th class="pb-2 font-medium pl-4 text-right text-orange-400">A</th>
<th class="pb-2 font-medium pl-4 text-right text-blue-400">B</th>
<th class="pb-2 font-medium pl-4 text-right">Δ (AB)</th>
</tr>
</thead>
<tbody>
<% linhas_cmp.each do |l| %>
<% delta = (l[:a] - l[:b]).round(2) %>
<% melhor_a = l[:maior_melhor] ? l[:a] > l[:b] : l[:a] < l[:b] %>
<% melhor_b = l[:maior_melhor] ? l[:b] > l[:a] : l[:b] < l[:a] %>
<tr class="border-b border-white/5">
<td class="py-2 pr-3 text-gray-300"><%= l[:nome] %></td>
<td class="py-2 pl-4 text-right whitespace-nowrap font-semibold <%= melhor_a ? 'text-green-400' : 'text-white' %>"><%= l[:a] %><%= l[:suf] %></td>
<td class="py-2 pl-4 text-right whitespace-nowrap font-semibold <%= melhor_b ? 'text-green-400' : 'text-white' %>"><%= l[:b] %><%= l[:suf] %></td>
<td class="py-2 pl-4 text-right whitespace-nowrap text-gray-400"><%= delta > 0 ? "+#{delta}" : delta %><%= l[:suf] %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<%# Barras agrupadas A x B %>
<div class="relative h-72">
<canvas class="cmp-bars"
data-label-a="<%= label_a %>"
data-label-b="<%= label_b %>"
data-a="<%= [a.total, a.sucesso, a.recusas, a.pendentes].to_json %>"
data-b="<%= [b.total, b.sucesso, b.recusas, b.pendentes].to_json %>"></canvas>
</div>
</div>

View File

@@ -1,5 +1,7 @@
<%# locals: metricas (Analytics::OperacaoMetricas), titulo (opcional) %> <%# locals: metricas (Analytics::OperacaoMetricas), titulo (opcional),
filtravel (opcional, default false) — torna as tabelas clicáveis p/ cross-filter %>
<% titulo = local_assigns[:titulo] %> <% titulo = local_assigns[:titulo] %>
<% filtravel = local_assigns.fetch(:filtravel, false) %>
<% ins = metricas.insucessos_pct %> <% ins = metricas.insucessos_pct %>
<% pd = metricas.por_dia %> <% pd = metricas.por_dia %>
@@ -46,8 +48,8 @@
data-failed="<%= ins[:failed] %>"></canvas> data-failed="<%= ins[:failed] %>"></canvas>
</div> </div>
<div class="flex justify-center gap-4 mt-3 text-xs"> <div class="flex justify-center gap-4 mt-3 text-xs">
<span class="text-cyan-400">● Completas <%= ins[:pct_completed] %>%</span> <span class="text-orange-400">● Completas <%= ins[:pct_completed] %>%</span>
<span class="text-indigo-400">● Falhas <%= ins[:pct_failed] %>%</span> <span class="text-red-400">● Falhas <%= ins[:pct_failed] %>%</span>
</div> </div>
<% end %> <% end %>
</div> </div>
@@ -57,7 +59,8 @@
<%= render 'tabela_simples', <%= render 'tabela_simples',
cabecalhos: ['Observação', 'Total', '%'], cabecalhos: ['Observação', 'Total', '%'],
linhas: metricas.indices_falha.map { |h| [h[:observation], h[:total], "#{h[:pct]}%"] }, linhas: metricas.indices_falha.map { |h| [h[:observation], h[:total], "#{h[:pct]}%"] },
vazio: 'Nenhuma falha no período.' %> vazio: 'Nenhuma falha no período.',
filtro: (filtravel ? 'f_obs' : nil) %>
</div> </div>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6"> <div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
@@ -66,7 +69,8 @@
cabecalhos: ['Status', 'Quantidade'], cabecalhos: ['Status', 'Quantidade'],
linhas: metricas.por_status_gade.map { |h| [h[:status], h[:total]] }, linhas: metricas.por_status_gade.map { |h| [h[:status], h[:total]] },
vazio: 'Sem dados.', vazio: 'Sem dados.',
total: metricas.total %> total: metricas.total,
filtro: (filtravel ? 'f_status' : nil) %>
</div> </div>
</div> </div>
@@ -79,7 +83,8 @@
cabecalhos: ['Motorista', 'Notas fiscais'], cabecalhos: ['Motorista', 'Notas fiscais'],
linhas: metricas.por_motorista.map { |h| [h[:nome], h[:total]] }, linhas: metricas.por_motorista.map { |h| [h[:nome], h[:total]] },
vazio: 'Sem dados.', vazio: 'Sem dados.',
total: metricas.total %> total: metricas.total,
filtro: (filtravel ? 'f_driver' : nil) %>
</div> </div>
</div> </div>
@@ -90,7 +95,8 @@
cabecalhos: ['Unidade', 'NFs entregues'], cabecalhos: ['Unidade', 'NFs entregues'],
linhas: metricas.por_sts.map { |h| [h[:nome], h[:total]] }, linhas: metricas.por_sts.map { |h| [h[:nome], h[:total]] },
vazio: 'Sem dados.', vazio: 'Sem dados.',
total: metricas.sucesso %> total: metricas.sucesso,
filtro: (filtravel ? 'f_sts' : nil) %>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,21 +1,39 @@
<%# locals: cabecalhos (Array), linhas (Array de Array), vazio (String), total (opcional) %> <%# locals:
cabecalhos (Array), linhas (Array de Array), vazio (String),
total (opcional) — linha de rodapé "Total"
filtro (opcional) — nome do param (ex.: 'f_driver') que torna a 1ª coluna
clicável para cross-filter %>
<% total_rodape = local_assigns[:total] %> <% total_rodape = local_assigns[:total] %>
<% filtro = local_assigns[:filtro] %>
<% base = request.query_parameters %>
<% if linhas.blank? %> <% if linhas.blank? %>
<p class="text-center py-6 text-gray-500 text-sm"><%= vazio %></p> <p class="text-center py-6 text-gray-500 text-sm"><%= vazio %></p>
<% else %> <% else %>
<div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="text-gray-400 text-left border-b border-white/5"> <tr class="text-gray-400 text-left border-b border-white/5">
<% cabecalhos.each_with_index do |c, i| %> <% cabecalhos.each_with_index do |c, i| %>
<th class="pb-2 font-medium <%= 'text-right' if i.positive? %>"><%= c %></th> <th class="pb-2 font-medium <%= i.zero? ? 'pr-3' : 'pl-4 text-right whitespace-nowrap' %>"><%= c %></th>
<% end %> <% end %>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% linhas.each do |celulas| %> <% linhas.each do |celulas| %>
<tr class="border-b border-white/5"> <% ativo = filtro && base[filtro].to_s == celulas.first.to_s %>
<tr class="border-b border-white/5 <%= 'bg-orange-500/10' if ativo %>">
<% celulas.each_with_index do |cel, i| %> <% celulas.each_with_index do |cel, i| %>
<td class="py-2 <%= i.positive? ? 'text-right font-semibold text-white' : 'text-gray-300' %>"><%= cel %></td> <td class="py-2 align-top <%= i.zero? ? 'pr-3 text-gray-300' : 'pl-4 text-right whitespace-nowrap font-semibold text-white' %>">
<% if i.zero? && filtro %>
<%= link_to cel,
operacoes_dashboard_path(base.merge(filtro => cel.to_s)),
data: { turbo: false },
title: "Filtrar por #{cel}",
class: "inline-flex items-center gap-1 hover:text-orange-400 #{'text-orange-400 font-semibold' if ativo}" %>
<% else %>
<%= cel %>
<% end %>
</td>
<% end %> <% end %>
</tr> </tr>
<% end %> <% end %>
@@ -23,10 +41,11 @@
<% if total_rodape %> <% if total_rodape %>
<tfoot> <tfoot>
<tr class="text-white font-bold"> <tr class="text-white font-bold">
<td class="pt-2">Total</td> <td class="pt-2 pr-3">Total</td>
<td class="pt-2 text-right" colspan="<%= cabecalhos.size - 1 %>"><%= total_rodape %></td> <td class="pt-2 pl-4 text-right whitespace-nowrap" colspan="<%= cabecalhos.size - 1 %>"><%= total_rodape %></td>
</tr> </tr>
</tfoot> </tfoot>
<% end %> <% end %>
</table> </table>
</div>
<% end %> <% end %>

View File

@@ -101,6 +101,23 @@
</div> </div>
<% else %> <% else %>
<%# ── Filtros ativos (cross-filter por clique nas tabelas) ── %>
<% if @chips.any? && %w[operacao global].include?(@modo) %>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-gray-500 uppercase tracking-wider">Filtros ativos:</span>
<% @chips.each do |chip| %>
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-300 text-xs">
<%= chip[:rotulo] %>: <strong class="text-white"><%= chip[:valor] %></strong>
<%= link_to '✕', operacoes_dashboard_path(request.query_parameters.except(chip[:param])),
data: { turbo: false }, class: 'hover:text-white font-bold', title: 'Remover filtro' %>
</span>
<% end %>
<%= link_to 'Limpar tudo',
operacoes_dashboard_path(request.query_parameters.except(*@chips.map { |c| c[:param] })),
data: { turbo: false }, class: 'text-xs text-gray-400 hover:text-orange-400 underline' %>
</div>
<% end %>
<%# ── Seletor de operação por modo ───────────────────── %> <%# ── Seletor de operação por modo ───────────────────── %>
<% if @modo == 'operacao' %> <% if @modo == 'operacao' %>
<form method="get" action="<%= operacoes_dashboard_path %>" data-turbo="false" class="flex flex-wrap items-center gap-2"> <form method="get" action="<%= operacoes_dashboard_path %>" data-turbo="false" class="flex flex-wrap items-center gap-2">
@@ -120,7 +137,7 @@
</form> </form>
<% if @metricas %> <% if @metricas %>
<%= render 'painel', metricas: @metricas %> <%= render 'painel', metricas: @metricas, filtravel: true %>
<%= render 'mapa', metricas: @metricas %> <%= render 'mapa', metricas: @metricas %>
<% end %> <% end %>
@@ -130,7 +147,7 @@
<%= @tabelas_validas.size %> operações agregadas no período. <%= @tabelas_validas.size %> operações agregadas no período.
</div> </div>
<% if @metricas %> <% if @metricas %>
<%= render 'painel', metricas: @metricas %> <%= render 'painel', metricas: @metricas, filtravel: true %>
<%= render 'mapa', metricas: @metricas %> <%= render 'mapa', metricas: @metricas %>
<% end %> <% end %>
@@ -140,8 +157,8 @@
<input type="hidden" name="inicio" value="<%= ini_iso %>"> <input type="hidden" name="inicio" value="<%= ini_iso %>">
<input type="hidden" name="fim" value="<%= fim_iso %>"> <input type="hidden" name="fim" value="<%= fim_iso %>">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label class="text-cyan-400 text-sm font-semibold">A:</label> <label class="text-orange-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"> <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-orange-500">
<% @operacoes_agrupadas.each do |titulo, ops| %> <% @operacoes_agrupadas.each do |titulo, ops| %>
<optgroup label="<%= titulo %>"> <optgroup label="<%= titulo %>">
<% ops.each do |op| %> <% ops.each do |op| %>
@@ -152,8 +169,8 @@
</select> </select>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label class="text-indigo-400 text-sm font-semibold">B:</label> <label class="text-blue-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"> <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-blue-500">
<% @operacoes_agrupadas.each do |titulo, ops| %> <% @operacoes_agrupadas.each do |titulo, ops| %>
<optgroup label="<%= titulo %>"> <optgroup label="<%= titulo %>">
<% ops.each do |op| %> <% ops.each do |op| %>
@@ -165,7 +182,18 @@
</div> </div>
</form> </form>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6"> <%# Comparativo direto A x B (tabela + barras agrupadas) %>
<% if @metricas_a && @metricas_b %>
<%= render 'comparativo', a: @metricas_a, b: @metricas_b,
label_a: @metricas_a.operacoes_label, label_b: @metricas_b.operacoes_label %>
<% end %>
<%# Detalhe completo de cada operação (recolhível) %>
<details class="bg-[#1a1a1a] rounded-2xl border border-white/5">
<summary class="cursor-pointer px-6 py-4 text-sm text-gray-300 hover:text-white select-none">
🔎 Ver detalhe completo de cada operação
</summary>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 p-4 pt-0">
<div> <div>
<% if @metricas_a %> <% if @metricas_a %>
<%= render 'painel', metricas: @metricas_a, titulo: "A · #{@metricas_a.operacoes_label}" %> <%= render 'painel', metricas: @metricas_a, titulo: "A · #{@metricas_a.operacoes_label}" %>
@@ -181,6 +209,7 @@
<% end %> <% end %>
</div> </div>
</div> </div>
</details>
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
@@ -231,9 +260,16 @@
}); });
} }
// ── Charts (donut + barras) ── // ── Cores da marca (laranja + vermelho p/ falha) ──
const COR_OK = '#f97316'; // laranja Reem (completas)
const COR_FAIL = '#ef4444'; // vermelho (falhas), igual ao card de Recusas
const COR_A = '#f97316'; // comparativo: A = laranja da marca
const COR_B = '#3b82f6'; // comparativo: B = azul (contraste)
// ── Charts (donut + barras + comparativo) ──
function initCharts() { function initCharts() {
if (typeof Chart === 'undefined') return; if (typeof Chart === 'undefined') return;
document.querySelectorAll('canvas.dash-donut').forEach(function (ctx) { document.querySelectorAll('canvas.dash-donut').forEach(function (ctx) {
Chart.getChart(ctx)?.destroy(); Chart.getChart(ctx)?.destroy();
new Chart(ctx, { new Chart(ctx, {
@@ -242,13 +278,14 @@
labels: ['Completas', 'Falhas'], labels: ['Completas', 'Falhas'],
datasets: [{ datasets: [{
data: [Number(ctx.dataset.completed), Number(ctx.dataset.failed)], data: [Number(ctx.dataset.completed), Number(ctx.dataset.failed)],
backgroundColor: ['#06b6d4', '#6366f1'], backgroundColor: [COR_OK, COR_FAIL],
borderColor: '#1a1a1a', borderWidth: 2 borderColor: '#1a1a1a', borderWidth: 2
}] }]
}, },
options: { responsive: true, maintainAspectRatio: false, cutout: '62%', plugins: { legend: { display: false } } } options: { responsive: true, maintainAspectRatio: false, cutout: '62%', plugins: { legend: { display: false } } }
}); });
}); });
document.querySelectorAll('canvas.dash-bars').forEach(function (ctx) { document.querySelectorAll('canvas.dash-bars').forEach(function (ctx) {
Chart.getChart(ctx)?.destroy(); Chart.getChart(ctx)?.destroy();
new Chart(ctx, { new Chart(ctx, {
@@ -256,8 +293,8 @@
data: { data: {
labels: JSON.parse(ctx.dataset.labels), labels: JSON.parse(ctx.dataset.labels),
datasets: [ datasets: [
{ label: 'Completas', data: JSON.parse(ctx.dataset.completed), backgroundColor: '#06b6d4' }, { label: 'Completas', data: JSON.parse(ctx.dataset.completed), backgroundColor: COR_OK },
{ label: 'Falhas', data: JSON.parse(ctx.dataset.failed), backgroundColor: '#6366f1' } { label: 'Falhas', data: JSON.parse(ctx.dataset.failed), backgroundColor: COR_FAIL }
] ]
}, },
options: { options: {
@@ -270,6 +307,29 @@
} }
}); });
}); });
// Comparativo A x B (barras agrupadas)
document.querySelectorAll('canvas.cmp-bars').forEach(function (ctx) {
Chart.getChart(ctx)?.destroy();
new Chart(ctx, {
type: 'bar',
data: {
labels: ['Total', 'Sucesso', 'Recusas', 'Pendentes'],
datasets: [
{ label: 'A · ' + (ctx.dataset.labelA || 'A'), data: JSON.parse(ctx.dataset.a), backgroundColor: COR_A },
{ label: 'B · ' + (ctx.dataset.labelB || 'B'), data: JSON.parse(ctx.dataset.b), backgroundColor: COR_B }
]
},
options: {
responsive: true, maintainAspectRatio: false,
scales: {
x: { ticks: { color: '#6b7280' }, grid: { color: 'rgba(255,255,255,0.04)' } },
y: { beginAtZero: true, ticks: { color: '#6b7280' }, grid: { color: 'rgba(255,255,255,0.04)' } }
},
plugins: { legend: { labels: { color: '#9ca3af' } } }
}
});
});
} }
// ── Mapa de calor (Leaflet) ── // ── Mapa de calor (Leaflet) ──

View File

@@ -73,6 +73,25 @@ RSpec.describe Analytics::OperacaoMetricas do
expect(metricas.pontos_mapa).to eq([[-23.5, -46.6], [-23.6, -46.7], [-23.4, -46.5]]) expect(metricas.pontos_mapa).to eq([[-23.5, -46.6], [-23.6, -46.7], [-23.4, -46.5]])
end end
context 'com cross-filter (filtros)' do
subject(:filtrada) { described_class.new(tabelas: [], filtros: { 'driver' => 'Carlos' }) }
before { allow(filtrada).to receive(:registros).and_return(rows) }
it 'restringe todas as agregações ao valor filtrado' do
expect(filtrada.total).to eq(3)
expect(filtrada.sucesso).to eq(2)
expect(filtrada.recusas).to eq(1)
expect(filtrada.por_motorista).to eq([{ nome: 'Carlos', total: 3 }])
end
it 'ignora filtro com valor em branco' do
vazio = described_class.new(tabelas: [], filtros: { 'driver' => '' })
allow(vazio).to receive(:registros).and_return(rows)
expect(vazio.total).to eq(6)
end
end
context 'sem registros' do context 'sem registros' do
before { allow(metricas).to receive(:registros).and_return([]) } before { allow(metricas).to receive(:registros).and_return([]) }