Implantação Gráficos novos (Chart.js, padrão IIFE)

️ Custo por operação
This commit is contained in:
2026-06-17 17:03:22 -03:00
parent 50166686f8
commit a01af23691
3 changed files with 224 additions and 2 deletions

View File

@@ -33,8 +33,13 @@ class DashboardController < ApplicationController
end
def carregar_dados_dashboard
# Filtro de operação (multi-seleção) — aplicado a todo o dashboard.
@operacoes = Operacao.todas
@operacao_filtro = Operacao.sanitizar(params[:operacoes])
# Entregas do período (tabela read-only) — restritas à conta Gade
entregas = Entrega.da_conta_gade.no_periodo(@periodo_inicio, @periodo_fim)
entregas = entregas.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
# Totais gerais
@total_entregas = entregas.count
@@ -76,6 +81,7 @@ class DashboardController < ApplicationController
# — independe de quando foram criadas (created_at).
@consolidacoes_mes = Consolidacao.ativas
.where('data_inicio <= ? AND data_fim >= ?', @periodo_fim, @periodo_inicio)
@consolidacoes_mes = @consolidacoes_mes.com_operacoes(@operacao_filtro) if @operacao_filtro.present?
@consolidacoes_abertas = @consolidacoes_mes.where(status: :rascunho).count
@consolidacoes_fechadas = @consolidacoes_mes.where(status: :finalizada).count
@@ -83,6 +89,9 @@ class DashboardController < ApplicationController
# (medido por motorista — fonte: consolidacao_motoristas).
carregar_dados_pagamentos
# Visão financeira por operação (KPIs + gráficos)
carregar_dados_financeiro
# Histórico estimado mais recente
@historico_recente = HistoricoEstimado.order(created_at: :desc).limit(5)
end
@@ -96,6 +105,9 @@ class DashboardController < ApplicationController
.joins(:consolidacao)
.where(consolidacoes: { status: :finalizada, deleted_at: nil })
.where(pago_em: intervalo)
if @operacao_filtro.present?
pagos = pagos.where(consolidacao_id: Consolidacao.com_operacoes(@operacao_filtro).select(:id))
end
@pag_pago_qtd = pagos.count
@pag_pago_valor = pagos.sum(:valor_total)
@@ -110,6 +122,48 @@ class DashboardController < ApplicationController
@pagamentos_pendentes = pendentes.includes(:consolidacao).order(consolidacao_id: :desc)
end
# Visão financeira (custo) das consolidações finalizadas do período, já com o
# filtro de operação aplicado.
def carregar_dados_financeiro
consols = Consolidacao.ativas.where(status: :finalizada)
.where('data_inicio <= ? AND data_fim >= ?', @periodo_fim, @periodo_inicio)
consols = consols.com_operacoes(@operacao_filtro) if @operacao_filtro.present?
consols = consols.to_a
ids = consols.map(&:id)
cms = ConsolidacaoMotorista.where(consolidacao_id: ids)
ces = ConsolidacaoEntrega.where(consolidacao_id: ids)
# KPIs
@fin_custo_total = cms.sum(:valor_total)
@fin_qtd_entregas = ces.distinct.count(:tracking_id)
@fin_ticket_medio = @fin_qtd_entregas.zero? ? 0 : (@fin_custo_total / @fin_qtd_entregas)
@fin_pago = cms.pagos.sum(:valor_total)
@fin_pendente = cms.pendentes.sum(:valor_total)
# Custo por operação — rateia o valor da consolidação igualmente entre suas
# operações (mantém o total consistente; exato no caso de 1 operação).
por_op = Hash.new(0)
consols.each do |c|
ops = c.operacoes.presence || ['(sem operação)']
fatia = c.valor_total / ops.size
ops.each { |o| por_op[o == '(sem operação)' ? o : Operacao.label(o)] += fatia }
end
@fin_por_operacao = por_op.sort_by { |_, v| -v }
# Composição por tipo (normal/retirada/bônus/desconto)
soma_tipo = ces.group(:tipo).sum(:valor_aplicado)
@fin_por_tipo = ConsolidacaoEntrega::TIPO_CORES.filter_map do |tipo, cfg|
valor = soma_tipo[tipo].to_f
next if valor.zero?
{ label: cfg[:label], valor: valor }
end
# Custo por motorista (top 10)
@fin_por_motorista = cms.group(:motorista_nome).sum(:valor_total)
.sort_by { |_, v| -v }.first(10)
end
def build_grafico_diario(entregas, preco_entrega)
dias = (@periodo_inicio..@periodo_fim).to_a

View File

@@ -48,6 +48,16 @@ class Consolidacao < ApplicationRecord
.select(:consolidacao_id))
}
# Consolidações que contêm QUALQUER das operações informadas (jsonb).
# SEGURANÇA: os nomes passam por whitelist (Operacao.sanitizar) e por quote.
scope :com_operacoes, ->(ops) {
validas = Operacao.sanitizar(ops)
next all if validas.empty?
lista = validas.map { |o| connection.quote(o) }.join(',')
where("EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(operacoes, '[]'::jsonb)) e WHERE e IN (#{lista}))")
}
# ── Métodos ─────────────────────────────────────────────────
def arquivar!(user)

View File

@@ -30,6 +30,25 @@
</div>
<input type="hidden" name="inicio" id="inicio-hidden" value="<%= @periodo_inicio.strftime('%Y-%m-%d') %>">
<input type="hidden" name="fim" id="fim-hidden" value="<%= @periodo_fim.strftime('%Y-%m-%d') %>">
<%# Filtro de operação — multi-seleção; submete o form ao mudar %>
<% if @operacoes.any? %>
<details class="relative" id="operacao-dropdown">
<summary class="cursor-pointer list-none pl-4 pr-4 py-2.5 bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm whitespace-nowrap hover:border-orange-500">
🗂️ <%= @operacao_filtro.present? ? "#{@operacao_filtro.size} operação(ões)" : 'Todas as operações' %>
</summary>
<div class="absolute right-0 mt-2 z-30 w-72 max-h-72 overflow-y-auto bg-[#1a1a1a] border border-white/10 rounded-xl p-2 shadow-xl">
<% @operacoes.each do |op| %>
<label class="flex items-center gap-2 text-white text-sm hover:bg-[#0a0a0a] rounded px-2 py-2 cursor-pointer">
<input type="checkbox" name="operacoes[]" value="<%= op[:tabela] %>"
<%= 'checked' if @operacao_filtro.include?(op[:tabela]) %>
class="op-filtro rounded text-orange-500 bg-[#0a0a0a] border-white/10">
<%= op[:label] %>
</label>
<% end %>
</div>
</details>
<% end %>
</form>
</div>
@@ -80,6 +99,30 @@
</div>
</div>
<%# KPIs FINANCEIROS (consolidações finalizadas, já com filtro de operação) %>
<div class="grid grid-cols-2 xl:grid-cols-4 gap-4">
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-2">💰 Custo total</p>
<p class="text-3xl font-black text-white"><%= moeda(@fin_custo_total) %></p>
<p class="text-gray-500 text-xs mt-1"><%= @fin_qtd_entregas %> entregas classificadas</p>
</div>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-2">🎫 Ticket médio</p>
<p class="text-3xl font-black text-white"><%= moeda(@fin_ticket_medio) %></p>
<p class="text-gray-500 text-xs mt-1">por entrega</p>
</div>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-2">✅ Pago</p>
<p class="text-3xl font-black text-green-400"><%= moeda(@fin_pago) %></p>
<p class="text-gray-500 text-xs mt-1">já confirmado</p>
</div>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-2">🕗 A pagar</p>
<p class="text-3xl font-black text-yellow-500"><%= moeda(@fin_pendente) %></p>
<p class="text-gray-500 text-xs mt-1">pendente</p>
</div>
</div>
<%# GRÁFICO + MOTORISTAS (lado a lado no desktop) %>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
@@ -87,8 +130,8 @@
<div class="xl:col-span-2 bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-lg font-semibold text-white">Evolução Diária</h2>
<p class="text-gray-400 text-sm">Valor estimado por dia no período</p>
<h2 class="text-lg font-semibold text-white">Evolução do custo</h2>
<p class="text-gray-400 text-sm">Valor estimado por dia no período<%= ' · operação filtrada' if @operacao_filtro.present? %></p>
</div>
<div class="flex items-center gap-2 text-xs text-gray-500">
<span class="inline-block w-3 h-0.5 bg-[#f97316] rounded"></span> Valor (R$)
@@ -138,6 +181,43 @@
</div>
</div>
<%# ── FINANCEIRO POR OPERAÇÃO ───────────────────────────── %>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
<%# Custo por operação (barra) %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<h2 class="text-lg font-semibold text-white mb-1">🏷️ Custo por operação</h2>
<p class="text-gray-400 text-sm mb-4">Valor consolidado no período</p>
<% if @fin_por_operacao.any? %>
<div class="relative h-64"><canvas id="grafico-custo-operacao"></canvas></div>
<% else %>
<div class="text-center py-8 text-gray-500"><div class="text-3xl mb-2">🏷️</div><p class="text-sm">Sem dados no período</p></div>
<% end %>
</div>
<%# Composição por tipo (rosca) %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<h2 class="text-lg font-semibold text-white mb-1">🧩 Composição do custo</h2>
<p class="text-gray-400 text-sm mb-4">Por tipo de lançamento</p>
<% if @fin_por_tipo.any? %>
<div class="relative h-64"><canvas id="grafico-composicao-tipo"></canvas></div>
<% else %>
<div class="text-center py-8 text-gray-500"><div class="text-3xl mb-2">🧩</div><p class="text-sm">Sem dados no período</p></div>
<% end %>
</div>
<%# Custo por motorista (barra) %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<h2 class="text-lg font-semibold text-white mb-1">👤 Custo por motorista</h2>
<p class="text-gray-400 text-sm mb-4">Top 10 no período</p>
<% if @fin_por_motorista.any? %>
<div class="relative h-64"><canvas id="grafico-custo-motorista"></canvas></div>
<% else %>
<div class="text-center py-8 text-gray-500"><div class="text-3xl mb-2">👤</div><p class="text-sm">Sem dados no período</p></div>
<% end %>
</div>
</div>
<%# ── PAGAMENTOS (pago x pendente) ──────────────────────── %>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
@@ -299,6 +379,17 @@
})();
</script>
<%# Filtro de operação — submete o form ao marcar/desmarcar %>
<script>
(function () {
document.querySelectorAll('.op-filtro').forEach(function (c) {
c.addEventListener('change', function () {
document.getElementById('dashboard-filtro-form').requestSubmit();
});
});
})();
</script>
<%# Chart.js via CDN %>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
@@ -422,3 +513,70 @@
});
})();
</script>
<%# ── Gráficos financeiros por operação ─────────────────────── %>
<script>
(function () {
const moedaTick = (v) => 'R$ ' + Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 0 });
const moedaFull = (v) => 'R$ ' + Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const tooltipBase = {
backgroundColor: '#0a0a0a', borderColor: '#f97316', borderWidth: 1,
titleColor: '#9ca3af', bodyColor: '#ffffff', padding: 12
};
const eixoCinza = { grid: { color: 'rgba(255,255,255,0.04)', drawBorder: false }, ticks: { color: '#6b7280', font: { size: 11 } } };
function barraHorizontal(id, labels, valores, cor) {
const ctx = document.getElementById(id);
if (!ctx || typeof Chart === 'undefined') return;
Chart.getChart(ctx)?.destroy();
new Chart(ctx, {
type: 'bar',
data: { labels: labels, datasets: [{ data: valores, backgroundColor: cor, borderRadius: 4 }] },
options: {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: Object.assign({}, tooltipBase, { callbacks: { label: (c) => ' ' + moedaFull(c.parsed.x) } })
},
scales: {
x: Object.assign({}, eixoCinza, { ticks: Object.assign({}, eixoCinza.ticks, { callback: moedaTick }) }),
y: eixoCinza
}
}
});
}
// 1) Custo por operação
barraHorizontal('grafico-custo-operacao',
<%= raw @fin_por_operacao.map { |l, _| l }.to_json %>,
<%= raw @fin_por_operacao.map { |_, v| v.to_f }.to_json %>,
'#f97316');
// 2) Custo por motorista
barraHorizontal('grafico-custo-motorista',
<%= raw @fin_por_motorista.map { |n, _| n }.to_json %>,
<%= raw @fin_por_motorista.map { |_, v| v.to_f }.to_json %>,
'#9a3412');
// 3) Composição por tipo (rosca)
(function () {
const ctx = document.getElementById('grafico-composicao-tipo');
if (!ctx || typeof Chart === 'undefined') return;
Chart.getChart(ctx)?.destroy();
const labels = <%= raw @fin_por_tipo.map { |h| h[:label] }.to_json %>;
const valores = <%= raw @fin_por_tipo.map { |h| h[:valor] }.to_json %>;
const cores = ['#f97316', '#9a3412', '#fed7aa', '#374151'];
new Chart(ctx, {
type: 'doughnut',
data: { labels: labels, datasets: [{ data: valores, backgroundColor: cores, borderColor: '#1a1a1a', borderWidth: 3 }] },
options: {
responsive: true, maintainAspectRatio: false, cutout: '60%',
plugins: {
legend: { position: 'bottom', labels: { color: '#9ca3af', font: { size: 11 }, padding: 12 } },
tooltip: Object.assign({}, tooltipBase, { callbacks: { label: (c) => ' ' + c.label + ': ' + moedaFull(c.parsed) } })
}
}
});
})();
})();
</script>