Corrige 3 erros de runtime + filtro de faixa de datas no dashboard

Erros corrigidos (reportados em prints):
- Logout dava "No route matches [GET] /auth/logout": link usava method:
  :delete (rails-ujs), que o Turbo ignora -> agora data-turbo-method
- /admin/usuarios estourava NoMethodError 'admin_ou_gerente?'/'admin?' na
  UserPolicy -> adicionados helpers que delegam para o User
- Nova consolidação estourava NoMethodError 'new?' na ConsolidacaoPolicy ->
  ApplicationPolicy passa a definir new? (= create?) e edit? (= update?)

Dashboard:
- Substitui a navegação por mês por um filtro de calendário com faixa de
  datas (Flatpickr, range mode) que auto-submete inicio/fim
- Controller passa a consultar por período (Entrega.no_periodo) em vez de
  do_mes; gráfico diário limitado a ~1 trimestre

Specs: UserPolicy + new? na ConsolidacaoPolicy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 22:27:57 -03:00
parent 732c6daae8
commit bb1dead9fd
7 changed files with 151 additions and 62 deletions

View File

@@ -1,35 +1,44 @@
# app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController
MAX_DIAS_GRAFICO = 92 # limita a granularidade diária do gráfico (≈ 1 trimestre)
def index
skip_authorization
# Motorista tem painel próprio — não enxerga o dashboard administrativo
return redirect_to(motorista_dashboard_path) if current_user.motorista?
@data_referencia = parse_data(params[:data])
@mes_inicio = @data_referencia.beginning_of_month
@mes_fim = @data_referencia.end_of_month
@periodo_inicio, @periodo_fim = periodo_selecionado
carregar_dados_dashboard
end
private
# Parse tolerante: data inválida/ausente cai no dia atual em vez de estourar 500.
# Faixa de datas vinda do filtro de calendário (params inicio/fim).
# Default: do início do mês corrente até hoje. Datas inválidas caem no default
# e a ordem é normalizada caso venham invertidas.
def periodo_selecionado
inicio = parse_data(params[:inicio]) || Date.current.beginning_of_month
fim = parse_data(params[:fim]) || Date.current
inicio, fim = fim, inicio if fim < inicio
[inicio, fim]
end
# Parse tolerante: data inválida/ausente vira nil (para o default assumir).
def parse_data(str)
return Date.current if str.blank?
return nil if str.blank?
Date.parse(str)
rescue ArgumentError, TypeError
Date.current
nil
end
def carregar_dados_dashboard
# Entregas do mês atual (da tabela read-only) — restritas à conta Gade
entregas_mes = Entrega.da_conta_gade.do_mes(@data_referencia)
# Entregas do período (tabela read-only) — restritas à conta Gade
entregas = Entrega.da_conta_gade.no_periodo(@periodo_inicio, @periodo_fim)
# Totais gerais
@total_entregas = entregas_mes.count
@entregas_pagas = entregas_mes.pagas.count
@total_entregas = entregas.count
@entregas_pagas = entregas.pagas.count
@entregas_pendentes = @total_entregas - @entregas_pagas
# Configurações de preço
@@ -39,12 +48,12 @@ class DashboardController < ApplicationController
@valor_estimado = (@entregas_pagas * config[:entrega]).round(2)
# Por motorista (top 10)
@motoristas = entregas_mes.pagas
.group(:driver)
.count
.sort_by { |_, v| -v }
.first(10)
.map do |driver, qtd|
@motoristas = entregas.pagas
.group(:driver)
.count
.sort_by { |_, v| -v }
.first(10)
.map do |driver, qtd|
{
nome: driver,
entregas: qtd,
@@ -53,18 +62,18 @@ class DashboardController < ApplicationController
end
# Por operação (route_id → contact_name)
@por_operacao = entregas_mes.pagas
.group(:contact_name)
.count
.sort_by { |_, v| -v }
.first(6)
.to_h
@por_operacao = entregas.pagas
.group(:contact_name)
.count
.sort_by { |_, v| -v }
.first(6)
.to_h
# Evolução diária do mês (para Chart.js)
@grafico_diario = build_grafico_diario(entregas_mes, config[:entrega])
# Evolução diária no período (para Chart.js)
@grafico_diario = build_grafico_diario(entregas, config[:entrega])
# Consolidações do mês
@consolidacoes_mes = Consolidacao.ativas.where(created_at: @mes_inicio.beginning_of_day..@mes_fim.end_of_day)
# Consolidações criadas no período
@consolidacoes_mes = Consolidacao.ativas.where(created_at: @periodo_inicio.beginning_of_day..@periodo_fim.end_of_day)
@consolidacoes_abertas = @consolidacoes_mes.where(status: :rascunho).count
@consolidacoes_fechadas = @consolidacoes_mes.where(status: :finalizada).count
@@ -73,16 +82,18 @@ class DashboardController < ApplicationController
end
def build_grafico_diario(entregas, preco_entrega)
dias = ((@mes_inicio)..[@mes_fim, Date.today].min).map(&:to_s)
# Em períodos muito longos, agrupa o gráfico de forma a não gerar centenas de pontos.
dias = (@periodo_inicio..@periodo_fim).to_a
return { labels: [], valores: [], qtds: [] } if dias.size > MAX_DIAS_GRAFICO
contagem = entregas.pagas
.group("DATE(planned_date)")
.count
.transform_keys { |k| k.to_s }
.transform_keys(&:to_s)
labels = dias.map { |d| Date.parse(d).strftime('%d/%m') }
valores = dias.map { |d| ((contagem[d] || 0) * preco_entrega).round(2) }
qtds = dias.map { |d| contagem[d] || 0 }
labels = dias.map { |d| d.strftime('%d/%m') }
valores = dias.map { |d| ((contagem[d.to_s] || 0) * preco_entrega).round(2) }
qtds = dias.map { |d| contagem[d.to_s] || 0 }
{ labels: labels, valores: valores, qtds: qtds }
end

View File

@@ -10,7 +10,9 @@ class ApplicationPolicy
def index? = user.pode_consolidar? || user.admin?
def show? = user.pode_consolidar? || user.admin?
def create? = user.pode_consolidar? || user.admin?
def new? = create?
def update? = user.pode_consolidar? || user.admin?
def edit? = update?
def destroy? = user.admin?
class Scope

View File

@@ -32,6 +32,19 @@ class UserPolicy < ApplicationPolicy
admin?
end
private
# Helpers de papel usados pelas permissões acima (delegam para o User)
def admin?
user.admin?
end
def admin_ou_gerente?
user.admin? || user.gerente?
end
public
class Scope < Scope
def resolve
if user.admin?

View File

@@ -3,39 +3,27 @@
<div class="space-y-6">
<%# Header + filtro de mês %>
<%# Header + filtro por faixa de datas %>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-white">Dashboard</h1>
<p class="text-gray-400 text-sm mt-0.5">
<%= @data_referencia.strftime('%B de %Y').capitalize %> · Atualizado às <%= Time.now.strftime('%H:%M') %>
<%= @periodo_inicio.strftime('%d/%m/%Y') %> <%= @periodo_fim.strftime('%d/%m/%Y') %>
· Atualizado às <%= Time.now.strftime('%H:%M') %>
</p>
</div>
<%# Navegação de mês %>
<div class="flex items-center gap-2">
<%= link_to dashboard_path(data: (@data_referencia - 1.month).to_s),
class: 'p-2.5 bg-[#1a1a1a] hover:bg-white/10 border border-white/10 rounded-xl
text-gray-400 hover:text-white transition-colors' do %>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
<% end %>
<div class="px-4 py-2.5 bg-[#1a1a1a] border border-white/10 rounded-xl text-white font-medium min-w-[140px] text-center">
<%= @data_referencia.strftime('%b %Y').capitalize %>
<%# Filtro de calendário — seleciona uma faixa de datas %>
<form id="dashboard-filtro-form" method="get" action="<%= dashboard_path %>" class="flex items-center gap-2">
<div class="relative">
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-orange-500">📅</span>
<input type="text" id="periodo-range" readonly placeholder="Selecione o período"
class="cursor-pointer pl-4 pr-10 py-2.5 bg-[#1a1a1a] border border-white/10 rounded-xl
text-white text-sm w-[240px] focus:outline-none focus:border-orange-500 placeholder-gray-500">
</div>
<% unless @data_referencia >= Date.today.beginning_of_month %>
<%= link_to dashboard_path(data: (@data_referencia + 1.month).to_s),
class: 'p-2.5 bg-[#1a1a1a] hover:bg-white/10 border border-white/10 rounded-xl
text-gray-400 hover:text-white transition-colors' do %>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<% end %>
<% end %>
</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') %>">
</form>
</div>
<%# CARDS GRANDES — KPIs %>
@@ -81,7 +69,7 @@
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-2">🚗 Motoristas</p>
<p class="text-4xl font-black text-white mb-1"><%= @motoristas.count %></p>
<p class="text-gray-500 text-sm">com entregas no mês</p>
<p class="text-gray-500 text-sm">com entregas no período</p>
</div>
</div>
@@ -93,7 +81,7 @@
<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 mês</p>
<p class="text-gray-400 text-sm">Valor estimado por dia no período</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$)
@@ -137,7 +125,7 @@
<% else %>
<div class="text-center py-8 text-gray-500">
<div class="text-3xl mb-2">🚚</div>
<p class="text-sm">Nenhuma entrega paga neste mês</p>
<p class="text-sm">Nenhuma entrega paga neste período</p>
</div>
<% end %>
</div>
@@ -162,6 +150,41 @@
</div>
<%# Flatpickr — date range picker (CDN, mesmo padrão do Chart.js) %>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/pt.js"></script>
<script>
(function () {
const input = document.getElementById('periodo-range');
if (!input || typeof flatpickr === 'undefined') return;
const inicio = document.getElementById('inicio-hidden').value;
const fim = document.getElementById('fim-hidden').value;
// formata Date local → YYYY-MM-DD (evita deslocamento de fuso do toISOString)
const toISO = (d) =>
d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
flatpickr(input, {
mode: 'range',
locale: 'pt',
dateFormat: 'd/m/Y',
defaultDate: [inicio, fim],
maxDate: 'today',
onClose: function (selectedDates) {
if (selectedDates.length === 2) {
document.getElementById('inicio-hidden').value = toISO(selectedDates[0]);
document.getElementById('fim-hidden').value = toISO(selectedDates[1]);
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>

View File

@@ -60,7 +60,7 @@
<%# Footer da sidebar — sair %>
<div class="px-6 py-4 border-t border-[#2a2a2a]">
<%= link_to destroy_user_session_path, method: :delete,
<%= link_to destroy_user_session_path, data: { turbo_method: :delete, turbo_confirm: 'Deseja sair?' },
class: "flex items-center gap-2 text-gray-400 hover:text-red-400 text-sm transition-colors" do %>
<span>🚪</span> Sair
<% end %>