teste #3
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)' } }
|
||||||
|
|||||||
Reference in New Issue
Block a user