Compare commits
2 Commits
732c6daae8
...
1fa8cd59e8
| Author | SHA256 | Date | |
|---|---|---|---|
| 1fa8cd59e8 | |||
| bb1dead9fd |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -20,7 +20,9 @@ services:
|
||||
volumes:
|
||||
- ".:/app"
|
||||
- "bundle_cache:/usr/local/bundle"
|
||||
command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -b 0.0.0.0"
|
||||
# db:prepare = cria o banco se não existir, roda migrations pendentes e
|
||||
# só faz seed se o banco acabou de ser criado (idempotente, seguro no boot).
|
||||
command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails db:prepare && bundle exec rails s -b 0.0.0.0"
|
||||
# O banco PostgreSQL já existe externamente.
|
||||
# Configure DB_HOST no .env com o IP/hostname do seu servidor PostgreSQL.
|
||||
|
||||
|
||||
28
lib/tasks/diagnostico.rake
Normal file
28
lib/tasks/diagnostico.rake
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace :reem do
|
||||
desc "Diagnóstico do dashboard: verifica dados da tabela externa por conta/período"
|
||||
task diagnostico: :environment do
|
||||
ini = ENV['INICIO'] ? Date.parse(ENV['INICIO']) : Date.current.beginning_of_month
|
||||
fim = ENV['FIM'] ? Date.parse(ENV['FIM']) : Date.current
|
||||
|
||||
puts "================ DIAGNÓSTICO REEM ================"
|
||||
puts "Período analisado: #{ini} → #{fim}"
|
||||
puts "DB_EXISTING_ACCOUNT_ID: #{ENV.fetch('DB_EXISTING_ACCOUNT_ID', '95907 (default)')}"
|
||||
puts "-------------------------------------------------"
|
||||
puts "Entregas no período (com filtro Gade): #{Entrega.da_conta_gade.no_periodo(ini, fim).count}"
|
||||
puts "Entregas no período (SEM filtro conta): #{Entrega.no_periodo(ini, fim).count}"
|
||||
puts "Pagas no período (com filtro Gade): #{Entrega.da_conta_gade.pagas.no_periodo(ini, fim).count}"
|
||||
puts "-------------------------------------------------"
|
||||
puts "account_ids presentes na tabela:"
|
||||
Entrega.group(:account_id).count.each { |acc, qtd| puts " account_id=#{acc.inspect} → #{qtd} linhas" }
|
||||
puts "-------------------------------------------------"
|
||||
puts "planned_date mais antiga: #{Entrega.minimum(:planned_date)}"
|
||||
puts "planned_date mais recente: #{Entrega.maximum(:planned_date)}"
|
||||
puts "================================================="
|
||||
puts "Leitura:"
|
||||
puts " • 'SEM filtro' > 0 e 'com filtro Gade' = 0 → ajuste DB_EXISTING_ACCOUNT_ID no .env"
|
||||
puts " • ambos = 0 → não há dados nesse período (use as datas acima)"
|
||||
rescue => e
|
||||
warn "[diagnostico] Falha: #{e.class} - #{e.message}"
|
||||
raise
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,7 @@ RSpec.describe ConsolidacaoPolicy do
|
||||
let(:consolidacao) { build(:consolidacao, status: :rascunho) }
|
||||
let(:consolidacao_finalizada) { build(:consolidacao, status: :finalizada) }
|
||||
|
||||
permissions :index?, :show?, :create? do
|
||||
permissions :index?, :show?, :create?, :new? do
|
||||
it 'libera para admin, gerente e operador' do
|
||||
%i[admin gerente operador].each do |papel|
|
||||
expect(subject).to permit(build(papel), Consolidacao)
|
||||
|
||||
40
spec/policies/user_policy_spec.rb
Normal file
40
spec/policies/user_policy_spec.rb
Normal file
@@ -0,0 +1,40 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe UserPolicy do
|
||||
subject { described_class }
|
||||
|
||||
let(:outro) { build(:operador) }
|
||||
|
||||
permissions :index? do
|
||||
it 'libera para admin e gerente, nega operador/motorista' do
|
||||
expect(subject).to permit(build(:admin), User)
|
||||
expect(subject).to permit(build(:gerente), User)
|
||||
expect(subject).not_to permit(build(:operador), User)
|
||||
expect(subject).not_to permit(build(:motorista), User)
|
||||
end
|
||||
end
|
||||
|
||||
permissions :new?, :create? do
|
||||
it 'só admin cria usuários' do
|
||||
expect(subject).to permit(build(:admin), User)
|
||||
expect(subject).not_to permit(build(:gerente), User)
|
||||
end
|
||||
end
|
||||
|
||||
permissions :update? do
|
||||
it 'admin edita qualquer um; cada um edita a si mesmo' do
|
||||
eu = build(:operador)
|
||||
expect(subject).to permit(build(:admin), outro)
|
||||
expect(subject).to permit(eu, eu)
|
||||
expect(subject).not_to permit(build(:operador), outro)
|
||||
end
|
||||
end
|
||||
|
||||
permissions :destroy? do
|
||||
it 'admin exclui outros, mas não a si mesmo' do
|
||||
admin = build(:admin)
|
||||
expect(subject).to permit(admin, outro)
|
||||
expect(subject).not_to permit(admin, admin)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user