Correção e Adição do filtro ao vivo
This commit is contained in:
BIN
Imagens para implantação /Imagem colada (6).png
Normal file
BIN
Imagens para implantação /Imagem colada (6).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
BIN
Imagens para implantação /Imagem colada (7).png
Normal file
BIN
Imagens para implantação /Imagem colada (7).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -14,11 +14,12 @@ class OperacoesDashboardController < ApplicationController
|
||||
@modo = MODOS.include?(params[:modo]) ? params[:modo] : 'operacao'
|
||||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||
@tabelas_validas = Operacao.nomes_validos
|
||||
@filtros, @chips = cross_filtros
|
||||
|
||||
case @modo
|
||||
when 'global'
|
||||
# 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'
|
||||
# Comparar: cada operação inteira (sem filtro de data — só vale no Global).
|
||||
@op_a = Operacao.sanitizar([params[:op_a]]).first || @tabelas_validas[0]
|
||||
@@ -28,14 +29,35 @@ class OperacoesDashboardController < ApplicationController
|
||||
else
|
||||
# Operação única: a operação inteira (a data vem dos próprios dados).
|
||||
@operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
|
||||
@metricas = montar([@operacao]) if @operacao
|
||||
@metricas = montar([@operacao], filtros: @filtros) if @operacao
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def montar(tabelas, inicio: nil, fim: nil)
|
||||
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim)
|
||||
def montar(tabelas, inicio: nil, fim: nil, filtros: {})
|
||||
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
|
||||
|
||||
# Faixa de datas do filtro (params inicio/fim). Default: início do mês → hoje.
|
||||
|
||||
@@ -20,16 +20,39 @@ module Analytics
|
||||
# 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ó é
|
||||
# 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))
|
||||
@inicio = inicio&.to_date
|
||||
@fim = fim&.to_date
|
||||
@filtros = (filtros || {}).reject { |_, v| v.to_s.strip.empty? }
|
||||
end
|
||||
|
||||
def operacoes_label
|
||||
@tabelas.map { |t| Operacao.label(t) }.join(', ')
|
||||
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
|
||||
# período natural da operação no cabeçalho/painel.
|
||||
def data_inicio
|
||||
@@ -51,15 +74,15 @@ module Analytics
|
||||
|
||||
# ── KPIs ─────────────────────────────────────────────────────
|
||||
def total
|
||||
registros.size
|
||||
linhas.size
|
||||
end
|
||||
|
||||
def sucesso
|
||||
registros.count { |r| r['status'] == 'completed' }
|
||||
linhas.count { |r| r['status'] == 'completed' }
|
||||
end
|
||||
|
||||
def recusas
|
||||
registros.count { |r| Entrega::STATUS_FALHA.include?(r['status']) }
|
||||
linhas.count { |r| Entrega::STATUS_FALHA.include?(r['status']) }
|
||||
end
|
||||
|
||||
# Em aberto: nem concluídas nem falhadas.
|
||||
@@ -79,7 +102,7 @@ module Analytics
|
||||
|
||||
# Falhas agrupadas por observação (motivo), % sobre o total de falhas.
|
||||
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
|
||||
falhas.group_by { |r| r['observation'].presence || 'SEM OBSERVAÇÃO' }
|
||||
.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/...).
|
||||
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 } }
|
||||
.sort_by { |h| -h[:total] }
|
||||
end
|
||||
|
||||
# NFs por motorista (todas as entregas), desc.
|
||||
def por_motorista
|
||||
contagem(registros, 'driver')
|
||||
contagem(linhas, 'driver')
|
||||
end
|
||||
|
||||
# Entregas CONCLUÍDAS por unidade (contact_name), desc.
|
||||
def por_sts
|
||||
contagem(registros.select { |r| r['status'] == 'completed' }, 'contact_name')
|
||||
contagem(linhas.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|
|
||||
linhas.each do |r|
|
||||
d = data_de(r['checkout'])
|
||||
next unless d
|
||||
if r['status'] == 'completed'
|
||||
@@ -126,7 +149,7 @@ module Analytics
|
||||
# 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|
|
||||
@pontos_mapa ||= linhas.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'])
|
||||
@@ -142,7 +165,7 @@ module Analytics
|
||||
private
|
||||
|
||||
def datas_checkout
|
||||
@datas_checkout ||= registros.filter_map { |r| data_de(r['checkout']) }
|
||||
@datas_checkout ||= linhas.filter_map { |r| data_de(r['checkout']) }
|
||||
end
|
||||
|
||||
def contagem(rows, coluna)
|
||||
|
||||
56
app/views/operacoes_dashboard/_comparativo.html.erb
Normal file
56
app/views/operacoes_dashboard/_comparativo.html.erb
Normal 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">Δ (A−B)</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>
|
||||
@@ -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] %>
|
||||
<% filtravel = local_assigns.fetch(:filtravel, false) %>
|
||||
<% ins = metricas.insucessos_pct %>
|
||||
<% pd = metricas.por_dia %>
|
||||
|
||||
@@ -46,8 +48,8 @@
|
||||
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>
|
||||
<span class="text-orange-400">● Completas <%= ins[:pct_completed] %>%</span>
|
||||
<span class="text-red-400">● Falhas <%= ins[:pct_failed] %>%</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -57,7 +59,8 @@
|
||||
<%= 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.' %>
|
||||
vazio: 'Nenhuma falha no período.',
|
||||
filtro: (filtravel ? 'f_obs' : nil) %>
|
||||
</div>
|
||||
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
@@ -66,7 +69,8 @@
|
||||
cabecalhos: ['Status', 'Quantidade'],
|
||||
linhas: metricas.por_status_gade.map { |h| [h[:status], h[:total]] },
|
||||
vazio: 'Sem dados.',
|
||||
total: metricas.total %>
|
||||
total: metricas.total,
|
||||
filtro: (filtravel ? 'f_status' : nil) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,7 +83,8 @@
|
||||
cabecalhos: ['Motorista', 'Notas fiscais'],
|
||||
linhas: metricas.por_motorista.map { |h| [h[:nome], h[:total]] },
|
||||
vazio: 'Sem dados.',
|
||||
total: metricas.total %>
|
||||
total: metricas.total,
|
||||
filtro: (filtravel ? 'f_driver' : nil) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,7 +95,8 @@
|
||||
cabecalhos: ['Unidade', 'NFs entregues'],
|
||||
linhas: metricas.por_sts.map { |h| [h[:nome], h[:total]] },
|
||||
vazio: 'Sem dados.',
|
||||
total: metricas.sucesso %>
|
||||
total: metricas.sucesso,
|
||||
filtro: (filtravel ? 'f_sts' : nil) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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] %>
|
||||
<% filtro = local_assigns[:filtro] %>
|
||||
<% base = request.query_parameters %>
|
||||
<% if linhas.blank? %>
|
||||
<p class="text-center py-6 text-gray-500 text-sm"><%= vazio %></p>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<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>
|
||||
<th class="pb-2 font-medium <%= i.zero? ? 'pr-3' : 'pl-4 text-right whitespace-nowrap' %>"><%= c %></th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% 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| %>
|
||||
<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 %>
|
||||
</tr>
|
||||
<% end %>
|
||||
@@ -23,10 +41,11 @@
|
||||
<% 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>
|
||||
<td class="pt-2 pr-3">Total</td>
|
||||
<td class="pt-2 pl-4 text-right whitespace-nowrap" colspan="<%= cabecalhos.size - 1 %>"><%= total_rodape %></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
<% end %>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -101,6 +101,23 @@
|
||||
</div>
|
||||
<% 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 ───────────────────── %>
|
||||
<% if @modo == 'operacao' %>
|
||||
<form method="get" action="<%= operacoes_dashboard_path %>" data-turbo="false" class="flex flex-wrap items-center gap-2">
|
||||
@@ -120,7 +137,7 @@
|
||||
</form>
|
||||
|
||||
<% if @metricas %>
|
||||
<%= render 'painel', metricas: @metricas %>
|
||||
<%= render 'painel', metricas: @metricas, filtravel: true %>
|
||||
<%= render 'mapa', metricas: @metricas %>
|
||||
<% end %>
|
||||
|
||||
@@ -130,7 +147,7 @@
|
||||
<%= @tabelas_validas.size %> operações agregadas no período.
|
||||
</div>
|
||||
<% if @metricas %>
|
||||
<%= render 'painel', metricas: @metricas %>
|
||||
<%= render 'painel', metricas: @metricas, filtravel: true %>
|
||||
<%= render 'mapa', metricas: @metricas %>
|
||||
<% end %>
|
||||
|
||||
@@ -140,8 +157,8 @@
|
||||
<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">
|
||||
<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-orange-500">
|
||||
<% @operacoes_agrupadas.each do |titulo, ops| %>
|
||||
<optgroup label="<%= titulo %>">
|
||||
<% ops.each do |op| %>
|
||||
@@ -152,8 +169,8 @@
|
||||
</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">
|
||||
<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-blue-500">
|
||||
<% @operacoes_agrupadas.each do |titulo, ops| %>
|
||||
<optgroup label="<%= titulo %>">
|
||||
<% ops.each do |op| %>
|
||||
@@ -165,7 +182,18 @@
|
||||
</div>
|
||||
</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>
|
||||
<% if @metricas_a %>
|
||||
<%= render 'painel', metricas: @metricas_a, titulo: "A · #{@metricas_a.operacoes_label}" %>
|
||||
@@ -181,6 +209,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</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() {
|
||||
if (typeof Chart === 'undefined') return;
|
||||
|
||||
document.querySelectorAll('canvas.dash-donut').forEach(function (ctx) {
|
||||
Chart.getChart(ctx)?.destroy();
|
||||
new Chart(ctx, {
|
||||
@@ -242,13 +278,14 @@
|
||||
labels: ['Completas', 'Falhas'],
|
||||
datasets: [{
|
||||
data: [Number(ctx.dataset.completed), Number(ctx.dataset.failed)],
|
||||
backgroundColor: ['#06b6d4', '#6366f1'],
|
||||
backgroundColor: [COR_OK, COR_FAIL],
|
||||
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, {
|
||||
@@ -256,8 +293,8 @@
|
||||
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' }
|
||||
{ label: 'Completas', data: JSON.parse(ctx.dataset.completed), backgroundColor: COR_OK },
|
||||
{ label: 'Falhas', data: JSON.parse(ctx.dataset.failed), backgroundColor: COR_FAIL }
|
||||
]
|
||||
},
|
||||
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) ──
|
||||
|
||||
@@ -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]])
|
||||
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
|
||||
before { allow(metricas).to receive(:registros).and_return([]) }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user