diff --git a/Imagens para implantação /Imagem colada (6).png b/Imagens para implantação /Imagem colada (6).png new file mode 100644 index 0000000..7f85bb8 Binary files /dev/null and b/Imagens para implantação /Imagem colada (6).png differ diff --git a/Imagens para implantação /Imagem colada (7).png b/Imagens para implantação /Imagem colada (7).png new file mode 100644 index 0000000..636c66d Binary files /dev/null and b/Imagens para implantação /Imagem colada (7).png differ diff --git a/app/controllers/operacoes_dashboard_controller.rb b/app/controllers/operacoes_dashboard_controller.rb index 4322b5d..f565f46 100644 --- a/app/controllers/operacoes_dashboard_controller.rb +++ b/app/controllers/operacoes_dashboard_controller.rb @@ -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. diff --git a/app/services/analytics/operacao_metricas.rb b/app/services/analytics/operacao_metricas.rb index d11d418..35a126b 100644 --- a/app/services/analytics/operacao_metricas.rb +++ b/app/services/analytics/operacao_metricas.rb @@ -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 || '—' } - .map { |st, rows| { status: st, total: rows.size } } - .sort_by { |h| -h[:total] } + 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) diff --git a/app/views/operacoes_dashboard/_comparativo.html.erb b/app/views/operacoes_dashboard/_comparativo.html.erb new file mode 100644 index 0000000..73f523e --- /dev/null +++ b/app/views/operacoes_dashboard/_comparativo.html.erb @@ -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: '%' } + ] +%> +
+
+

Comparativo

+
+ ● A · <%= label_a %> (<%= a.total %>) + ● B · <%= label_b %> (<%= b.total %>) +
+
+ + <%# Tabela comparativa (verde = melhor desempenho na linha) %> +
+ + + + + + + + + + + <% 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] %> + + + + + + + <% end %> + +
MétricaABΔ (A−B)
<%= l[:nome] %><%= l[:a] %><%= l[:suf] %><%= l[:b] %><%= l[:suf] %><%= delta > 0 ? "+#{delta}" : delta %><%= l[:suf] %>
+
+ + <%# Barras agrupadas A x B %> +
+ +
+
diff --git a/app/views/operacoes_dashboard/_painel.html.erb b/app/views/operacoes_dashboard/_painel.html.erb index ec2e4b5..8b966c8 100644 --- a/app/views/operacoes_dashboard/_painel.html.erb +++ b/app/views/operacoes_dashboard/_painel.html.erb @@ -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] %>">
- ● Completas <%= ins[:pct_completed] %>% - ● Falhas <%= ins[:pct_failed] %>% + ● Completas <%= ins[:pct_completed] %>% + ● Falhas <%= ins[:pct_failed] %>%
<% end %> @@ -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) %>
@@ -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) %>
@@ -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) %> @@ -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) %> diff --git a/app/views/operacoes_dashboard/_tabela_simples.html.erb b/app/views/operacoes_dashboard/_tabela_simples.html.erb index a0a15bc..28338d9 100644 --- a/app/views/operacoes_dashboard/_tabela_simples.html.erb +++ b/app/views/operacoes_dashboard/_tabela_simples.html.erb @@ -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? %>

<%= vazio %>

<% else %> - - - - <% cabecalhos.each_with_index do |c, i| %> - - <% end %> - - - - <% linhas.each do |celulas| %> - - <% celulas.each_with_index do |cel, i| %> - +
+
<%= c %>
<%= cel %>
+ + + <% cabecalhos.each_with_index do |c, i| %> + <% end %> + + + <% linhas.each do |celulas| %> + <% ativo = filtro && base[filtro].to_s == celulas.first.to_s %> + + <% celulas.each_with_index do |cel, i| %> + + <% end %> + + <% end %> + + <% if total_rodape %> + + + + + + <% end %> - - <% if total_rodape %> - - - - - - - <% end %> -
<%= c %>
+ <% 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 %> +
Total<%= total_rodape %>
Total<%= total_rodape %>
+ + <% end %> diff --git a/app/views/operacoes_dashboard/index.html.erb b/app/views/operacoes_dashboard/index.html.erb index ca62c2c..be0285b 100644 --- a/app/views/operacoes_dashboard/index.html.erb +++ b/app/views/operacoes_dashboard/index.html.erb @@ -101,6 +101,23 @@ <% else %> + <%# ── Filtros ativos (cross-filter por clique nas tabelas) ── %> + <% if @chips.any? && %w[operacao global].include?(@modo) %> +
+ Filtros ativos: + <% @chips.each do |chip| %> + + <%= chip[:rotulo] %>: <%= chip[:valor] %> + <%= link_to '✕', operacoes_dashboard_path(request.query_parameters.except(chip[:param])), + data: { turbo: false }, class: 'hover:text-white font-bold', title: 'Remover filtro' %> + + <% 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' %> +
+ <% end %> + <%# ── Seletor de operação por modo ───────────────────── %> <% if @modo == 'operacao' %>
@@ -120,7 +137,7 @@
<% 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. <% if @metricas %> - <%= render 'painel', metricas: @metricas %> + <%= render 'painel', metricas: @metricas, filtravel: true %> <%= render 'mapa', metricas: @metricas %> <% end %> @@ -140,8 +157,8 @@
- - <% @operacoes_agrupadas.each do |titulo, ops| %> <% ops.each do |op| %> @@ -152,8 +169,8 @@
- - <% @operacoes_agrupadas.each do |titulo, ops| %> <% ops.each do |op| %> @@ -165,22 +182,34 @@
-
-
- <% if @metricas_a %> - <%= render 'painel', metricas: @metricas_a, titulo: "A · #{@metricas_a.operacoes_label}" %> - <% else %> -

Selecione a operação A.

- <% 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) %> +
+ + 🔎 Ver detalhe completo de cada operação + +
+
+ <% if @metricas_a %> + <%= render 'painel', metricas: @metricas_a, titulo: "A · #{@metricas_a.operacoes_label}" %> + <% else %> +

Selecione a operação A.

+ <% end %> +
+
+ <% if @metricas_b %> + <%= render 'painel', metricas: @metricas_b, titulo: "B · #{@metricas_b.operacoes_label}" %> + <% else %> +

Selecione a operação B.

+ <% end %> +
-
- <% if @metricas_b %> - <%= render 'painel', metricas: @metricas_b, titulo: "B · #{@metricas_b.operacoes_label}" %> - <% else %> -

Selecione a operação B.

- <% end %> -
-
+ <% end %> <% end %>
@@ -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) ── diff --git a/spec/services/analytics/operacao_metricas_spec.rb b/spec/services/analytics/operacao_metricas_spec.rb index 238cfaf..ae1c4a7 100644 --- a/spec/services/analytics/operacao_metricas_spec.rb +++ b/spec/services/analytics/operacao_metricas_spec.rb @@ -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([]) }