teste #3
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'
|
@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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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),
|
||||||
<% titulo = local_assigns[:titulo] %>
|
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 %>
|
<% 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>
|
||||||
|
|||||||
@@ -1,32 +1,51 @@
|
|||||||
<%# 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 %>
|
||||||
<table class="w-full text-sm">
|
<div class="overflow-x-auto">
|
||||||
<thead>
|
<table class="w-full text-sm">
|
||||||
<tr class="text-gray-400 text-left border-b border-white/5">
|
<thead>
|
||||||
<% cabecalhos.each_with_index do |c, i| %>
|
<tr class="text-gray-400 text-left border-b border-white/5">
|
||||||
<th class="pb-2 font-medium <%= 'text-right' if i.positive? %>"><%= c %></th>
|
<% cabecalhos.each_with_index do |c, i| %>
|
||||||
<% end %>
|
<th class="pb-2 font-medium <%= i.zero? ? 'pr-3' : 'pl-4 text-right whitespace-nowrap' %>"><%= c %></th>
|
||||||
</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 %>
|
<% end %>
|
||||||
</tr>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% linhas.each do |celulas| %>
|
||||||
|
<% 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 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 %>
|
||||||
|
</tbody>
|
||||||
|
<% if total_rodape %>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="text-white font-bold">
|
||||||
|
<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 %>
|
<% end %>
|
||||||
</tbody>
|
</table>
|
||||||
<% if total_rodape %>
|
</div>
|
||||||
<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 %>
|
<% end %>
|
||||||
|
|||||||
@@ -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,22 +182,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
<%# Comparativo direto A x B (tabela + barras agrupadas) %>
|
||||||
<div>
|
<% if @metricas_a && @metricas_b %>
|
||||||
<% if @metricas_a %>
|
<%= render 'comparativo', a: @metricas_a, b: @metricas_b,
|
||||||
<%= render 'painel', metricas: @metricas_a, titulo: "A · #{@metricas_a.operacoes_label}" %>
|
label_a: @metricas_a.operacoes_label, label_b: @metricas_b.operacoes_label %>
|
||||||
<% else %>
|
<% end %>
|
||||||
<p class="text-gray-500 text-sm">Selecione a operação A.</p>
|
|
||||||
<% 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}" %>
|
||||||
|
<% 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>
|
</div>
|
||||||
<div>
|
</details>
|
||||||
<% 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 %>
|
||||||
<% 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) ──
|
||||||
|
|||||||
@@ -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([]) }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user