teste #3

Merged
victor merged 20 commits from teste into main 2026-07-02 15:31:51 -03:00
4 changed files with 63 additions and 21 deletions
Showing only changes of commit 8d0d262bf8 - Show all commits

View File

@@ -15,11 +15,12 @@ class OperacoesDashboardController < ApplicationController
@operacoes_agrupadas = Operacao.agrupadas_por_mes
@tabelas_validas = Operacao.nomes_validos
@filtros, @chips = cross_filtros
@data_filtro = parse_data(params[:f_data]) # clique no gráfico "Entregas por Dia"
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, filtros: @filtros)
@metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro)
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]
@@ -29,25 +30,29 @@ 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], filtros: @filtros) if @operacao
@metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao
end
end
private
def montar(tabelas, inicio: nil, fim: nil, filtros: {})
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros)
def montar(tabelas, inicio: nil, fim: nil, filtros: {}, data: nil)
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data)
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' }
'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' },
'f_resultado' => { coluna: 'status', rotulo: 'Resultado' }
}.freeze
# Rótulos amigáveis para o filtro de Resultado (clique no donut "Insucessos %").
RESULTADO_LABEL = { 'completed' => 'Completas', 'failed' => 'Falhas' }.freeze
def cross_filtros
filtros = {}
chips = []
@@ -55,7 +60,12 @@ class OperacoesDashboardController < ApplicationController
valor = params[param].to_s.strip
next if valor.empty?
filtros[cfg[:coluna]] = valor
chips << { param: param, valor: valor, rotulo: cfg[:rotulo] }
display = param == 'f_resultado' ? (RESULTADO_LABEL[valor] || valor) : valor
chips << { param: param, valor: valor, display: display, rotulo: cfg[:rotulo] }
end
# Filtro por DIA (clique no gráfico "Entregas por Dia") — tratado à parte (data).
if (dia = parse_data(params[:f_data]))
chips << { param: 'f_data', valor: params[:f_data].to_s, display: dia.strftime('%d/%m/%Y'), rotulo: 'Dia' }
end
[filtros, chips]
end

View File

@@ -24,11 +24,12 @@ module Analytics
# 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: {})
def initialize(tabelas:, inicio: nil, fim: nil, filtros: {}, data: nil)
@tabelas = Operacao.sanitizar(Array(tabelas))
@inicio = inicio&.to_date
@fim = fim&.to_date
@filtros = (filtros || {}).reject { |_, v| v.to_s.strip.empty? }
@data = data&.to_date # filtro por DIA (clique no gráfico "Entregas por Dia")
end
def operacoes_label
@@ -37,11 +38,11 @@ module Analytics
# 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
@linhas ||= begin
base = @filtros.empty? ? registros
: registros.select { |r| @filtros.all? { |col, val| r[col].to_s == val.to_s } }
@data ? base.select { |r| data_de(r['checkout']) == @data } : base
end
end
# Taxas (úteis no comparativo).

View File

@@ -72,12 +72,15 @@
</aside>
<%# Botão flutuante (desktop) — esconde/mostra a sidebar do site inteiro.
Fica invisível e só aparece quando o mouse chega perto (zona de hover). %>
Em repouso mostra uma ALÇA fina laranja (indicador de que dá p/ recolher);
ao chegar perto com o mouse, ela expande no botão com a seta. %>
<div id="sidebar-edge" class="hidden md:flex group fixed top-1/2 -translate-y-1/2 z-40 h-32 w-12 items-center">
<button id="sidebar-toggle-btn" type="button" onclick="toggleSidebarCollapse()"
class="flex items-center justify-center w-7 h-16 bg-[#1a1a1a] border border-[#2a2a2a]
hover:border-orange-500 text-orange-500 rounded-r-lg shadow-lg
opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity duration-200"
class="flex items-center justify-center rounded-r-lg shadow-lg transition-all duration-200
w-1.5 h-14 bg-orange-500/60 text-transparent
group-hover:w-7 group-hover:h-16 group-hover:bg-[#1a1a1a] group-hover:text-orange-500
group-hover:border group-hover:border-orange-500/70
focus:w-7 focus:h-16 focus:bg-[#1a1a1a] focus:text-orange-500"
title="Esconder/mostrar menu" aria-label="Esconder ou mostrar o menu">
<span id="sidebar-toggle-icon" class="text-lg leading-none">◀</span>
</button>

View File

@@ -110,7 +110,7 @@
<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>
<%= chip[:rotulo] %>: <strong class="text-white"><%= chip[:display] || 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>
@@ -269,6 +269,19 @@
const COR_A = '#f97316'; // comparativo: A = laranja da marca
const COR_B = '#3b82f6'; // comparativo: B = azul (contraste)
// Cross-filter por clique nos gráficos: recarrega a página com o filtro,
// preservando os demais parâmetros (mesma lógica dos links das tabelas).
function aplicarFiltroDash(novos) {
const url = new URL(window.location.href);
Object.keys(novos).forEach(function (k) {
const v = novos[k];
if (v === null || v === '') url.searchParams.delete(k);
else url.searchParams.set(k, v);
});
window.location.assign(url.toString());
}
function cursorPonteiro(e, els) { e.native.target.style.cursor = els.length ? 'pointer' : 'default'; }
// ── Charts (donut + barras + comparativo) ──
function initCharts() {
if (typeof Chart === 'undefined') return;
@@ -285,7 +298,15 @@
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 } },
onHover: cursorPonteiro,
onClick: function (e, els) {
if (!els.length) return;
aplicarFiltroDash({ f_resultado: els[0].index === 0 ? 'completed' : 'failed' });
}
}
});
});
@@ -302,6 +323,13 @@
},
options: {
responsive: true, maintainAspectRatio: false,
onHover: cursorPonteiro,
onClick: function (e, els) {
if (!els.length) return;
const el = els[0];
const iso = String(this.data.labels[el.index]).split('/').reverse().join('-');
aplicarFiltroDash({ f_data: iso, f_resultado: el.datasetIndex === 0 ? 'completed' : 'failed' });
},
scales: {
x: { stacked: true, ticks: { color: '#6b7280', maxTicksLimit: 12 }, grid: { color: 'rgba(255,255,255,0.04)' } },
y: { stacked: true, beginAtZero: true, ticks: { color: '#6b7280' }, grid: { color: 'rgba(255,255,255,0.04)' } }