Ajustes de UX

This commit is contained in:
2026-06-30 18:05:20 -03:00
parent 96d01aa52a
commit 8d0d262bf8
4 changed files with 63 additions and 21 deletions

View File

@@ -15,11 +15,12 @@ class OperacoesDashboardController < ApplicationController
@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 @filtros, @chips = cross_filtros
@data_filtro = parse_data(params[:f_data]) # clique no gráfico "Entregas por Dia"
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, filtros: @filtros) @metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro)
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]
@@ -29,25 +30,29 @@ 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], filtros: @filtros) if @operacao @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao
end end
end end
private private
def montar(tabelas, inicio: nil, fim: nil, filtros: {}) def montar(tabelas, inicio: nil, fim: nil, filtros: {}, data: nil)
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros) Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data)
end end
# Cross-filter por clique nas tabelas. Cada param vira um filtro de coluna e um # 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)]. # "chip" removível na tela. Retorna [filtros (coluna=>valor), chips (p/ a view)].
CROSS = { CROSS = {
'f_driver' => { coluna: 'driver', rotulo: 'Motorista' }, 'f_driver' => { coluna: 'driver', rotulo: 'Motorista' },
'f_sts' => { coluna: 'contact_name', rotulo: 'Unidade' }, 'f_sts' => { coluna: 'contact_name', rotulo: 'Unidade' },
'f_status' => { coluna: 'status_gade', rotulo: 'Status' }, 'f_status' => { coluna: 'status_gade', rotulo: 'Status' },
'f_obs' => { coluna: 'observation', rotulo: 'Observação' } 'f_obs' => { coluna: 'observation', rotulo: 'Observação' },
'f_resultado' => { coluna: 'status', rotulo: 'Resultado' }
}.freeze }.freeze
# Rótulos amigáveis para o filtro de Resultado (clique no donut "Insucessos %").
RESULTADO_LABEL = { 'completed' => 'Completas', 'failed' => 'Falhas' }.freeze
def cross_filtros def cross_filtros
filtros = {} filtros = {}
chips = [] chips = []
@@ -55,7 +60,12 @@ class OperacoesDashboardController < ApplicationController
valor = params[param].to_s.strip valor = params[param].to_s.strip
next if valor.empty? next if valor.empty?
filtros[cfg[:coluna]] = valor 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 end
[filtros, chips] [filtros, chips]
end end

View File

@@ -24,11 +24,12 @@ module Analytics
# filtros: Hash de cross-filter (coluna => valor) aplicado em memória — clicar # 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 # numa célula da tabela (motorista/STS/status/observação) refiltra TODO o
# dashboard por aquele valor. Ex.: { 'driver' => 'Carlos' }. # 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)) @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? } @filtros = (filtros || {}).reject { |_, v| v.to_s.strip.empty? }
@data = data&.to_date # filtro por DIA (clique no gráfico "Entregas por Dia")
end end
def operacoes_label def operacoes_label
@@ -37,11 +38,11 @@ module Analytics
# Linhas após o cross-filter — base de TODAS as agregações/KPIs. # Linhas após o cross-filter — base de TODAS as agregações/KPIs.
def linhas def linhas
@linhas ||= if @filtros.empty? @linhas ||= begin
registros base = @filtros.empty? ? registros
else : registros.select { |r| @filtros.all? { |col, val| r[col].to_s == val.to_s } }
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
end end
# Taxas (úteis no comparativo). # Taxas (úteis no comparativo).

View File

@@ -72,12 +72,15 @@
</aside> </aside>
<%# Botão flutuante (desktop) — esconde/mostra a sidebar do site inteiro. <%# 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"> <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()" <button id="sidebar-toggle-btn" type="button" onclick="toggleSidebarCollapse()"
class="flex items-center justify-center w-7 h-16 bg-[#1a1a1a] border border-[#2a2a2a] class="flex items-center justify-center rounded-r-lg shadow-lg transition-all duration-200
hover:border-orange-500 text-orange-500 rounded-r-lg shadow-lg w-1.5 h-14 bg-orange-500/60 text-transparent
opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity duration-200" 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"> title="Esconder/mostrar menu" aria-label="Esconder ou mostrar o menu">
<span id="sidebar-toggle-icon" class="text-lg leading-none">◀</span> <span id="sidebar-toggle-icon" class="text-lg leading-none">◀</span>
</button> </button>

View File

@@ -110,7 +110,7 @@
<span class="text-xs text-gray-500 uppercase tracking-wider">Filtros ativos:</span> <span class="text-xs text-gray-500 uppercase tracking-wider">Filtros ativos:</span>
<% @chips.each do |chip| %> <% @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"> <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])), <%= link_to '✕', operacoes_dashboard_path(request.query_parameters.except(chip[:param])),
data: { turbo: false }, class: 'hover:text-white font-bold', title: 'Remover filtro' %> data: { turbo: false }, class: 'hover:text-white font-bold', title: 'Remover filtro' %>
</span> </span>
@@ -269,6 +269,19 @@
const COR_A = '#f97316'; // comparativo: A = laranja da marca const COR_A = '#f97316'; // comparativo: A = laranja da marca
const COR_B = '#3b82f6'; // comparativo: B = azul (contraste) 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) ── // ── Charts (donut + barras + comparativo) ──
function initCharts() { function initCharts() {
if (typeof Chart === 'undefined') return; if (typeof Chart === 'undefined') return;
@@ -285,7 +298,15 @@
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 } },
onHover: cursorPonteiro,
onClick: function (e, els) {
if (!els.length) return;
aplicarFiltroDash({ f_resultado: els[0].index === 0 ? 'completed' : 'failed' });
}
}
}); });
}); });
@@ -302,6 +323,13 @@
}, },
options: { options: {
responsive: true, maintainAspectRatio: false, 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: { scales: {
x: { stacked: true, ticks: { color: '#6b7280', maxTicksLimit: 12 }, grid: { color: 'rgba(255,255,255,0.04)' } }, 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)' } } y: { stacked: true, beginAtZero: true, ticks: { color: '#6b7280' }, grid: { color: 'rgba(255,255,255,0.04)' } }