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

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) %>
<% titulo = local_assigns[:titulo] %>
<%# 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>

View File

@@ -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] %>
<% 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 %>
<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>
<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 <%= i.zero? ? 'pr-3' : 'pl-4 text-right whitespace-nowrap' %>"><%= c %></th>
<% end %>
</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 %>
</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>
</table>
</div>
<% end %>

View File

@@ -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,22 +182,34 @@
</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 %>
<%# 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}" %>
<% 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>
<% 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>
</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) ──