diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb index b8fe1cd..30a09fa 100644 --- a/app/controllers/consolidacoes_controller.rb +++ b/app/controllers/consolidacoes_controller.rb @@ -40,6 +40,47 @@ class ConsolidacoesController < ApplicationController end end + # GET /consolidacoes/totais?inicio=&fim=&motorista=&rascunhos=1 + # + # "Quanto cada motorista somou no período". A lista de consolidações mostra um + # card por CONSOLIDAÇÃO, mas o pagamento é por MOTORISTA: para saber quanto um + # motorista fechou no mês era preciso abrir consolidação por consolidação e + # somar à mão. Aqui o total vem pronto e, clicando no motorista (?motorista=), + # a tela abre as consolidações que compõem aquele valor. + def totais + authorize Consolidacao, :index? + + @inicio, @fim = periodo_totais + @incluir_rascunhos = params[:rascunhos] == '1' + @motorista = params[:motorista].presence + @totais = Analytics::TotaisPorMotorista.new( + inicio: @inicio, fim: @fim, incluir_rascunhos: @incluir_rascunhos + ) + # Drill-down: as consolidações do motorista clicado (a tabela geral continua + # na tela, então o service é montado sem recorte de motorista). + @detalhe = @motorista ? @totais.consolidacoes_de(@motorista) : nil + end + + # GET /consolidacoes/totais_pdf — o mesmo relatório em PDF. Com ?motorista= + # sai só o extrato daquele motorista (com espaço de assinatura). + def totais_pdf + authorize Consolidacao, :index? + + inicio, fim = periodo_totais + totais = Analytics::TotaisPorMotorista.new( + inicio: inicio, fim: fim, + motorista: params[:motorista].presence, + incluir_rascunhos: params[:rascunhos] == '1' + ) + + nome = params[:motorista].presence&.parameterize(separator: '_') + arquivo = ['totais', nome, inicio.strftime('%Y%m%d'), fim.strftime('%Y%m%d')].compact.join('_') + send_data Pdf::TotaisMotoristaPdf.new(totais: totais, detalhar: params[:detalhar] != '0').render, + filename: "#{arquivo}.pdf", + type: 'application/pdf', + disposition: 'attachment' + end + # GET /consolidacoes/new def new authorize Consolidacao @@ -449,6 +490,24 @@ class ConsolidacoesController < ApplicationController private + # Período do relatório de totais. Default: o MÊS CORRENTE INTEIRO — o fim é o + # último dia do mês, não "hoje", porque o recorte exige a consolidação inteira + # dentro da faixa (Consolidacao.no_periodo) e um fechamento que vai até 31/08 + # sumiria da tela até o dia 31 chegar. + def periodo_totais + inicio = parse_data_totais(params[:inicio]) || Date.current.beginning_of_month + fim = parse_data_totais(params[:fim]) || Date.current.end_of_month + inicio, fim = fim, inicio if fim < inicio + [inicio, fim] + end + + def parse_data_totais(str) + return nil if str.blank? + Date.parse(str) + rescue ArgumentError, TypeError + nil + end + # Acha o User motorista pelo nome para gerar o QR Code de acesso. O # motorista_nome vem dos dados externos de entrega e o users.nome é digitado # no admin — então toleramos diferenças de caixa e de espaços (extras/duplos). diff --git a/app/controllers/motorista/dashboard_controller.rb b/app/controllers/motorista/dashboard_controller.rb index 6a1e903..86bb26c 100644 --- a/app/controllers/motorista/dashboard_controller.rb +++ b/app/controllers/motorista/dashboard_controller.rb @@ -54,8 +54,60 @@ module Motorista .includes(:consolidacao) end + # GET /motorista/totais?inicio=&fim= + # + # "Quanto eu fechei neste período e de onde vem esse valor". O card do painel + # mostra só o total acumulado; aqui o motorista escolhe o período, vê o total + # FECHADO (consolidações finalizadas) e a lista de consolidações que compõem + # o valor — a mesma conta que o admin vê em /consolidacoes/totais, montada + # pelo mesmo service para os dois números não poderem divergir. + def totais + @inicio, @fim = periodo_totais + @totais = totais_do_periodo + @consolidacoes = @totais.consolidacoes_de(current_user.nome) + end + + # GET /motorista/totais/pdf — o extrato do período em PDF (com assinatura), + # para o motorista guardar ou mandar por WhatsApp. + def totais_pdf + inicio, fim = periodo_totais + totais = Analytics::TotaisPorMotorista.new( + inicio: inicio, fim: fim, motorista: current_user.nome + ) + + arquivo = ['meu_total', current_user.nome.parameterize(separator: '_'), + inicio.strftime('%Y%m%d'), fim.strftime('%Y%m%d')].join('_') + send_data Pdf::TotaisMotoristaPdf.new(totais: totais).render, + filename: "#{arquivo}.pdf", + type: 'application/pdf', + disposition: 'attachment' + end + private + def totais_do_periodo + Analytics::TotaisPorMotorista.new( + inicio: @inicio, fim: @fim, motorista: current_user.nome + ) + end + + # Default: mês corrente INTEIRO (fim = último dia do mês). O recorte pede a + # consolidação inteira dentro da faixa, então terminar em "hoje" esconderia + # um fechamento que vai até o fim do mês. Mesma regra do relatório do admin. + def periodo_totais + inicio = parse_data(params[:inicio]) || Date.current.beginning_of_month + fim = parse_data(params[:fim]) || Date.current.end_of_month + inicio, fim = fim, inicio if fim < inicio + [inicio, fim] + end + + def parse_data(str) + return nil if str.blank? + Date.parse(str) + rescue ArgumentError, TypeError + nil + end + def garantir_motorista return if current_user.motorista? redirect_to dashboard_path, alert: 'Área exclusiva dos motoristas.' diff --git a/app/services/analytics/totais_por_motorista.rb b/app/services/analytics/totais_por_motorista.rb new file mode 100644 index 0000000..3112401 --- /dev/null +++ b/app/services/analytics/totais_por_motorista.rb @@ -0,0 +1,111 @@ +# app/services/analytics/totais_por_motorista.rb +# +# Quanto cada motorista somou no período — e QUAIS consolidações formam esse +# total. É a fonte única das duas telas de "total por motorista": +# +# • admin → /consolidacoes/totais (todos os motoristas + drill-down) +# • motorista→ /motorista/totais (só ele, mesma conta, mesmo PDF) +# +# RECORTE (igual ao filtro da lista de consolidações): entram as consolidações +# ATIVAS cujo período está DENTRO da faixa escolhida (data_inicio >= inicio e +# data_fim <= fim). Não é sobreposição de propósito — uma consolidação que +# atravessa a borda do filtro entraria inteira e o "total do período" ficaria +# maior que o período. +# +# Só motoristas ATIVOS (não arquivados) contam: é o mesmo conjunto que a +# consolidação paga (ConsolidacaoMotorista.ativos), então o total desta tela +# bate com a soma dos fechamentos. +module Analytics + class TotaisPorMotorista + def initialize(inicio:, fim:, motorista: nil, incluir_rascunhos: false) + @inicio = inicio.to_date + @fim = fim.to_date + @motorista = motorista.presence + @incluir_rascunhos = incluir_rascunhos + end + + attr_reader :inicio, :fim, :motorista + + # ConsolidacaoMotorista do recorte, com a consolidação já carregada (as duas + # telas mostram nome/período/status dela em seguida). + def registros + @registros ||= begin + base = Consolidacao.ativas.no_periodo(@inicio, @fim) + # Só o que já foi FECHADO conta como total do período; rascunho ainda + # muda de valor. `merge` (e não where(consolidacoes: {status: ...})) + # porque status é enum: quem sabe traduzir :finalizada é o próprio model. + base = base.finalizada unless @incluir_rascunhos + + escopo = ConsolidacaoMotorista.ativos + .joins(:consolidacao) + .merge(base) + .includes(:consolidacao) + escopo = escopo.where(motorista_nome: @motorista) if @motorista + escopo.to_a + end + end + + # Uma linha por motorista, do maior total para o menor. + def linhas + @linhas ||= registros.group_by(&:motorista_nome).map do |nome, cms| + pagos = cms.select(&:pago?) + pendentes = cms.reject(&:pago?) + { + motorista: nome, + consolidacoes: cms.size, + lancamentos: lancamentos_por_motorista[nome].to_i, + valor_total: soma(cms), + valor_pago: soma(pagos), + valor_pendente: soma(pendentes), + pagas: pagos.size, + pendentes: pendentes.size + } + end.sort_by { |l| -l[:valor_total] } + end + + # Consolidações que compõem o total de UM motorista (o drill-down), da mais + # recente para a mais antiga. + def consolidacoes_de(nome) + registros.select { |cm| cm.motorista_nome == nome } + .sort_by { |cm| [cm.consolidacao.data_fim, cm.consolidacao.data_inicio] } + .reverse + end + + def motoristas + linhas.map { |l| l[:motorista] } + end + + def total_geral = soma(registros) + def total_pago = soma(registros.select(&:pago?)) + def total_pendente = soma(registros.reject(&:pago?)) + def total_consolidacoes = registros.map(&:consolidacao_id).uniq.size + def vazio? = registros.empty? + + def periodo_label + "#{@inicio.strftime('%d/%m/%Y')} a #{@fim.strftime('%d/%m/%Y')}" + end + + private + + def soma(cms) + cms.sum { |cm| cm.valor_total || 0 } + end + + # Lançamentos classificados por motorista (soma da quantidade — um termo com + # quantidade 5 vale 5). Uma query só para todas as consolidações do recorte, + # em vez de uma por linha da tabela. + def lancamentos_por_motorista + @lancamentos_por_motorista ||= begin + ids = registros.map(&:consolidacao_id).uniq + if ids.empty? + {} + else + ConsolidacaoEntrega.where(consolidacao_id: ids, + motorista_nome: registros.map(&:motorista_nome).uniq) + .group(:motorista_nome) + .sum(:quantidade) + end + end + end + end +end diff --git a/app/services/pdf/totais_motorista_pdf.rb b/app/services/pdf/totais_motorista_pdf.rb new file mode 100644 index 0000000..c9e44b8 --- /dev/null +++ b/app/services/pdf/totais_motorista_pdf.rb @@ -0,0 +1,130 @@ +# app/services/pdf/totais_motorista_pdf.rb +# +# Relatório "total por motorista no período" — o mesmo documento serve às duas +# telas, porque os números vêm do mesmo Analytics::TotaisPorMotorista: +# +# • admin (todos os motoristas): resumo por motorista + as consolidações que +# compõem o total de cada um; +# • motorista (um só): o total fechado dele no período + as consolidações que +# formam esse valor, com espaço de assinatura para servir de comprovante. +# +module Pdf + class TotaisMotoristaPdf < BasePdf + VERDE = '22C55E' + AMBAR = 'D97706' + + # totais: Analytics::TotaisPorMotorista já montado (ele conhece o recorte). + # detalhar: inclui, para cada motorista, a lista de consolidações. Sempre + # ligado quando o relatório é de um motorista só. + def initialize(totais:, detalhar: true, titulo: nil) + super() + @t = totais + @detalhar = detalhar || @t.motorista.present? + @titulo = titulo + end + + private + + def corpo + secao(@titulo || (@t.motorista ? 'MEU TOTAL NO PERÍODO' : 'TOTAL POR MOTORISTA')) + + @pdf.text "Período: #{@t.periodo_label}", size: 11, style: :bold + @pdf.fill_color CINZA + @pdf.text(@t.motorista ? "Motorista: #{@t.motorista}" : "Motoristas: #{@t.linhas.size}", size: 10) + @pdf.text "Consolidações no período: #{@t.total_consolidacoes}", size: 10 + @pdf.fill_color '000000' + + secao 'TOTAIS' + kpis + + if @t.vazio? + @pdf.fill_color CINZA + @pdf.text 'Nenhuma consolidação fechada neste período.', size: 10 + @pdf.fill_color '000000' + return + end + + unless @t.motorista + secao 'RESUMO POR MOTORISTA' + tabela_resumo + end + + return unless @detalhar + + @t.linhas.each do |linha| + @pdf.start_new_page if @pdf.cursor < 160 + secao "COMPOSIÇÃO — #{linha[:motorista].upcase}" + tabela_composicao(@t.consolidacoes_de(linha[:motorista])) + end + + assinaturas(motorista: @t.motorista) if @t.motorista + end + + def kpis + linhas = [ + ['Total do período', moeda(@t.total_geral)], + ['Pago', moeda(@t.total_pago)], + ['A receber', moeda(@t.total_pendente)] + ] + @pdf.table(linhas, width: 300, cell_style: { size: 11, padding: [6, 10] }) do |t| + t.columns(0).font_style = :bold + t.columns(1).align = :right + t.row(1).text_color = VERDE + t.row(2).text_color = AMBAR + t.cells.borders = [:bottom] + t.cells.border_color = 'DDDDDD' + end + end + + def tabela_resumo + linhas = [['Motorista', 'Consol.', 'Lanç.', 'Pago', 'A receber', 'Total']] + @t.linhas.each do |l| + linhas << [l[:motorista], l[:consolidacoes].to_s, l[:lancamentos].to_s, + moeda(l[:valor_pago]), moeda(l[:valor_pendente]), moeda(l[:valor_total])] + end + linhas << ['TOTAL', @t.total_consolidacoes.to_s, '', + moeda(@t.total_pago), moeda(@t.total_pendente), moeda(@t.total_geral)] + + @pdf.table(linhas, header: true, width: @pdf.bounds.width, + column_widths: { 1 => 50, 2 => 50, 3 => 80, 4 => 80, 5 => 85 }, + cell_style: { size: 8, padding: [4, 6] }) do |t| + t.row(0).background_color = PRETO + t.row(0).text_color = 'FFFFFF' + t.row(0).font_style = :bold + t.columns(1..5).align = :right + t.row(-1).font_style = :bold + t.row(1..-1).borders = [:bottom] + t.row(1..-1).border_color = 'DDDDDD' + end + end + + # As consolidações que compõem o valor — é o "clicar no motorista" da tela, + # em papel. + def tabela_composicao(cms) + linhas = [['Consolidação', 'Período', 'Situação', 'Pago em', 'Valor']] + cms.each do |cm| + c = cm.consolidacao + linhas << [ + c.nome, + "#{c.data_inicio.strftime('%d/%m/%y')}–#{c.data_fim.strftime('%d/%m/%y')}", + cm.pago? ? 'Pago' : 'A receber', + cm.pago? ? cm.pago_em.strftime('%d/%m/%Y') : '—', + moeda(cm.valor_total) + ] + end + linhas << ['TOTAL', '', '', '', moeda(cms.sum { |cm| cm.valor_total || 0 })] + + @pdf.table(linhas, header: true, width: @pdf.bounds.width, + column_widths: { 1 => 90, 2 => 70, 3 => 70, 4 => 80 }, + cell_style: { size: 8, padding: [4, 6] }) do |t| + t.row(0).background_color = PRETO + t.row(0).text_color = 'FFFFFF' + t.row(0).font_style = :bold + t.columns(4).align = :right + t.row(-1).font_style = :bold + t.row(1..-1).borders = [:bottom] + t.row(1..-1).border_color = 'DDDDDD' + end + end + end +end diff --git a/app/views/consolidacoes/index.html.erb b/app/views/consolidacoes/index.html.erb index 47b5202..e8eaf82 100644 --- a/app/views/consolidacoes/index.html.erb +++ b/app/views/consolidacoes/index.html.erb @@ -6,6 +6,8 @@

<%= icone :consolidacoes, espaco: false %> Consolidações

+ <%= link_to rotulo(:motorista, 'Totais por motorista', cor: nil), totais_consolidacoes_path, + class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %> <%= link_to rotulo(:arquivadas, 'Arquivadas', cor: nil), arquivadas_consolidacoes_path, class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %> <% if policy(Consolidacao).create? %> diff --git a/app/views/consolidacoes/totais.html.erb b/app/views/consolidacoes/totais.html.erb new file mode 100644 index 0000000..2e5ad16 --- /dev/null +++ b/app/views/consolidacoes/totais.html.erb @@ -0,0 +1,191 @@ +<%# app/views/consolidacoes/totais.html.erb — Total por motorista no período %> +<% content_for :title, 'Totais por motorista' %> + +
+
+

<%= icone :motorista, espaco: false %> Totais por motorista

+

+ Consolidações <%= @incluir_rascunhos ? 'de qualquer status' : 'finalizadas' %> + com período dentro de <%= l @inicio, format: :short %> → <%= l @fim, format: :short %> +

+
+
+ <%= link_to rotulo(:consolidacoes, 'Consolidações', cor: nil), consolidacoes_path, + class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %> + <%= link_to rotulo(:baixar, 'Baixar relatório (PDF)', cor: nil), + totais_pdf_consolidacoes_path(inicio: @inicio, fim: @fim, rascunhos: (@incluir_rascunhos ? '1' : nil), motorista: @motorista), + data: { turbo: false }, + class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %> +
+
+ +<%# ── Filtros ──────────────────────────────────────────── %> +<%= form_with url: totais_consolidacoes_path, method: :get, + class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-6 flex flex-wrap items-center gap-3' do |f| %> + <%# O motorista clicado viaja junto para o filtro não fechar o drill-down. %> + <%= hidden_field_tag :motorista, @motorista if @motorista %> + + <%= f.date_field :inicio, value: @inicio.strftime('%Y-%m-%d'), + class: 'flex-1 basis-36 max-w-[13rem] min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + <%= f.date_field :fim, value: @fim.strftime('%Y-%m-%d'), + class: 'flex-1 basis-36 max-w-[13rem] min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + + + +
+ <%= f.submit 'Filtrar', class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-lg cursor-pointer px-5 py-2.5 whitespace-nowrap' %> + <%= link_to 'Limpar', totais_consolidacoes_path, class: 'px-4 py-2.5 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg whitespace-nowrap' %> +
+<% end %> + +<%# ── KPIs do período ──────────────────────────────────── %> +
+
+

<%= icone :dinheiro %> Total do período

+

<%= moeda(@totais.total_geral) %>

+

<%= @totais.total_consolidacoes %> consolidação(ões) · <%= @totais.linhas.size %> motorista(s)

+
+
+

<%= icone :sucesso, cor: 'text-green-400' %> Pago

+

<%= moeda(@totais.total_pago) %>

+
+
+

<%= icone :parcial, cor: 'text-amber-400' %> A pagar

+

<%= moeda(@totais.total_pendente) %>

+
+
+

<%= icone :ticket %> Média por motorista

+

+ <%= moeda(@totais.linhas.any? ? @totais.total_geral / @totais.linhas.size : 0) %> +

+
+
+ +<% if @totais.vazio? %> +
+

<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>

+

Nenhuma consolidação <%= @incluir_rascunhos ? '' : 'finalizada ' %>com período dentro dessa faixa.

+

Amplie o período ou marque "Incluir rascunhos".

+
+<% else %> + <%# ── Tabela: total por motorista (clique abre a composição) ── %> +
+
+ + + + + + + + + + + + + <% @totais.linhas.each do |l| %> + <% aberto = @motorista == l[:motorista] %> + + <%# O nome é um LINK de verdade (e não um ): abre/fecha + a composição daquele motorista e continua acessível pelo teclado. %> + + + + + + + + <% end %> + + + + + + + + + + + +
MotoristaConsolidaçõesLançamentosPagoA pagarTotal
+ <%= link_to totais_consolidacoes_path(inicio: @inicio, fim: @fim, + rascunhos: (@incluir_rascunhos ? '1' : nil), + motorista: (aberto ? nil : l[:motorista])), + class: 'text-white font-semibold hover:text-orange-500 flex items-center gap-1' do %> + <%= icone(aberto ? :expandir : :avancar, cor: 'text-gray-500', espaco: false) %><%= l[:motorista] %> + <% end %> + <%= l[:consolidacoes] %><%= l[:lancamentos] %><%= moeda(l[:valor_pago]) %><%= moeda(l[:valor_pendente]) %><%= moeda(l[:valor_total]) %>
TOTAL<%= @totais.total_consolidacoes %><%= moeda(@totais.total_pago) %><%= moeda(@totais.total_pendente) %><%= moeda(@totais.total_geral) %>
+
+
+

+ <%= icone :dica %> Clique num motorista para ver as consolidações que compõem o total dele. +

+ <%# PDF sem a composição de cada motorista — para períodos com muita gente, + quando só o resumo interessa. %> + <%= link_to 'Baixar só o resumo (PDF)', + totais_pdf_consolidacoes_path(inicio: @inicio, fim: @fim, rascunhos: (@incluir_rascunhos ? '1' : nil), detalhar: '0'), + data: { turbo: false }, + class: 'text-gray-400 hover:text-orange-500 text-xs underline whitespace-nowrap' %> +
+
+ + <%# ── Drill-down: as consolidações que compõem o total ── %> + <% if @detalhe %> +
+
+
+

+ <%= icone :composicao, espaco: false %> Composição de <%= @motorista %> +

+

+ <%= @detalhe.size %> consolidação(ões) no período · + <%= moeda(@detalhe.sum { |cm| cm.valor_total || 0 }) %> +

+
+
+ <%= link_to rotulo(:baixar, 'PDF deste motorista', cor: nil), + totais_pdf_consolidacoes_path(inicio: @inicio, fim: @fim, rascunhos: (@incluir_rascunhos ? '1' : nil), motorista: @motorista), + data: { turbo: false }, + class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-4 py-2.5 rounded-lg flex items-center' %> + <%= link_to rotulo(:fechar, 'Fechar', cor: nil), + totais_consolidacoes_path(inicio: @inicio, fim: @fim, rascunhos: (@incluir_rascunhos ? '1' : nil)), + class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-2.5 rounded-lg flex items-center' %> +
+
+ + <% if @detalhe.empty? %> +

Nenhuma consolidação desse motorista neste período.

+ <% end %> + +
+ <% @detalhe.each do |cm| %> + <% c = cm.consolidacao %> + <%= link_to consolidacao_path(c), + class: 'block bg-[#0a0a0a] border border-[#2a2a2a] hover:border-orange-500 rounded-xl p-4 transition-colors' do %> +
+
+
+ <%= c.nome %> + <%= badge_status(c.status) %> + <%= badge_pagamento(cm.pago? ? :pago : :pendente) %> +
+

+ <%= icone :calendario %> <%= l c.data_inicio, format: :short %> → <%= l c.data_fim, format: :short %> + <% if cm.pago? %> + · <%= icone :dinheiro, cor: 'text-green-400' %> Pago em <%= l cm.pago_em.to_date, format: :short %> + <% if cm.forma_pagamento.present? %>via <%= cm.forma_pagamento.humanize %><% end %> + <% if cm.nota_fiscal.present? %>· NF <%= cm.nota_fiscal %><% end %> + <% end %> +

+
+

<%= moeda(cm.valor_total) %>

+
+ <% end %> + <% end %> +
+
+ <% end %> +<% end %> diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 5e6e8f7..3a2e6f3 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -43,12 +43,14 @@ <%# Consolidações — só quem pode consolidar (exclui o externo) %> <% if current_user.pode_consolidar? %> <%= nav_link_to 'Consolidações', consolidacoes_path, icon: :consolidacoes %> + <%= nav_link_to 'Totais por motorista', totais_consolidacoes_path, icon: :motorista %> <%= nav_link_to 'Arquivadas', arquivadas_consolidacoes_path, icon: :arquivadas %> <% end %> <%# Motorista %> <% if current_user.motorista? %> <%= nav_link_to 'Meu Painel', motorista_dashboard_path, icon: :painel %> + <%= nav_link_to 'Meu total do período', motorista_totais_path, icon: :dinheiro %> <% end %> <%# Admin/Gerente — Configurações %> diff --git a/app/views/motorista/dashboard/index.html.erb b/app/views/motorista/dashboard/index.html.erb index af8d8d3..bdde49c 100644 --- a/app/views/motorista/dashboard/index.html.erb +++ b/app/views/motorista/dashboard/index.html.erb @@ -51,14 +51,18 @@

- <%# Card 2 — Valor Consolidado (BRANCO) %> -
+ <%# Card 2 — Valor Consolidado (BRANCO). Clicável: leva ao total POR PERÍODO, + com as consolidações que compõem o valor e o PDF do período. %> + <%= link_to motorista_totais_path, class: 'block bg-white rounded-2xl p-6 hover:ring-2 hover:ring-orange-500 transition' do %>

<%= icone :sucesso, cor: 'text-green-600', espaco: false %> Valor fechado para pagamento

<%= moeda(@valor_consolidado) %>

Soma dos pagamentos já confirmados pela empresa

-
+

+ <%= icone :avancar, cor: 'text-orange-600' %> Ver por período e baixar o PDF +

+ <% end %>
<%# ── Lista de consolidações ──────────────────────────── %> diff --git a/app/views/motorista/dashboard/totais.html.erb b/app/views/motorista/dashboard/totais.html.erb new file mode 100644 index 0000000..c161ca1 --- /dev/null +++ b/app/views/motorista/dashboard/totais.html.erb @@ -0,0 +1,95 @@ +<%# app/views/motorista/dashboard/totais.html.erb — Meu total no período %> +<% content_for :title, 'Meu total no período' %> + +
+ +
+

Meu total no período

+ <%= link_to rotulo(:voltar, 'Voltar', cor: nil), motorista_dashboard_path, + class: 'text-gray-300 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-xl min-h-[48px] flex items-center' %> +
+ + <%# ── Período ──────────────────────────────────────────── %> + <%= form_with url: motorista_totais_path, method: :get, + class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl p-4 mb-5 flex flex-wrap items-end gap-3' do |f| %> +
+ + <%= f.date_field :inicio, value: @inicio.strftime('%Y-%m-%d'), + class: 'w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-xl px-3 py-3 text-base' %> +
+
+ + <%= f.date_field :fim, value: @fim.strftime('%Y-%m-%d'), + class: 'w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-xl px-3 py-3 text-base' %> +
+ <%= f.submit 'Ver', class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-xl cursor-pointer px-6 py-3 min-h-[48px]' %> + <% end %> + + <%# ── Total fechado ────────────────────────────────────── %> +
+

+ <%= icone :sucesso, cor: 'text-green-600', espaco: false %> Total fechado no período +

+

<%= icone :calendario, cor: 'text-gray-500' %> <%= l @inicio, format: :short %> a <%= l @fim, format: :short %>

+

<%= moeda(@totais.total_geral) %>

+
+ <%= icone :dinheiro, cor: 'text-green-700' %> Já pago: <%= moeda(@totais.total_pago) %> + <%= icone :parcial, cor: 'text-amber-700' %> A receber: <%= moeda(@totais.total_pendente) %> +
+

+ Soma de <%= @consolidacoes.size %> fechamento(s) da empresa neste período. +

+
+ + <%= link_to rotulo(:baixar, 'Baixar relatório em PDF', cor: nil), + motorista_totais_pdf_path(inicio: @inicio, fim: @fim), + data: { turbo: false }, + class: 'w-full bg-orange-500 hover:bg-orange-600 text-black font-bold text-base px-6 py-4 rounded-xl min-h-[52px] flex items-center justify-center mb-6' %> + + <%# ── O que compõe o valor ─────────────────────────────── %> +

<%= icone :composicao, espaco: false %> O que forma esse valor

+

Cada fechamento abaixo entra na soma acima.

+ + <% if @consolidacoes.any? %> +
+ <% @consolidacoes.each do |cm| %> + <% c = cm.consolidacao %> +
+
+
+
+

<%= c.nome %>

+ <%= badge_pagamento(cm.pago? ? :pago : :pendente) %> +
+

+ <%= icone :calendario %> <%= l c.data_inicio, format: :short %> a <%= l c.data_fim, format: :short %> +

+

<%= moeda(cm.valor_total) %>

+ <% if cm.pago? %> +

+ <%= icone :dinheiro, cor: 'text-green-400' %> Pago em <%= l cm.pago_em.to_date, format: :short %> + <% if cm.forma_pagamento.present? %>via <%= cm.forma_pagamento.humanize %><% end %> +

+ <% if cm.nota_fiscal.present? %> +

<%= icone :nota_fiscal %> Nota Fiscal: <%= cm.nota_fiscal %>

+ <% end %> + <% else %> +

<%= icone :parcial, cor: 'text-amber-400' %> Ainda não pago

+ <% end %> +
+ <%= link_to rotulo(:baixar, 'Extrato deste fechamento', cor: nil), + gerar_pdf_extrato_consolidacao_path(c, motorista: current_user.nome), + data: { turbo: false }, + class: 'w-full md:w-auto bg-[#0a0a0a] border border-[#2a2a2a] hover:border-orange-500 text-white font-bold text-base px-5 py-4 rounded-xl min-h-[52px] flex items-center justify-center text-center' %> +
+
+ <% end %> +
+ <% else %> +
+

<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>

+

Nenhum fechamento neste período.

+

Escolha outro período acima — o fechamento aparece aqui quando a empresa finaliza a consolidação.

+
+ <% end %> +
diff --git a/config/routes.rb b/config/routes.rb index ec50104..4c90e09 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -27,6 +27,10 @@ Rails.application.routes.draw do # Painel motorista get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard + # Total fechado do motorista num período + as consolidações que compõem o + # valor (mesma conta do relatório do admin) e o PDF do extrato do período. + get '/motorista/totais', to: 'motorista/dashboard#totais', as: :motorista_totais + get '/motorista/totais/pdf', to: 'motorista/dashboard#totais_pdf', as: :motorista_totais_pdf # Login motorista — PIN de 4 dígitos + acesso via QR Code do extrato get '/motorista/login', to: 'motorista/sessoes#new', as: :motorista_login @@ -42,6 +46,12 @@ Rails.application.routes.draw do get :buscar_nf # JSON — busca a NF na rastreio (nota avulsa) post :avulsa # cria consolidação avulsa a partir de uma NF get :arquivadas # Histórico — consolidações arquivadas + + # Relatório "total por motorista no período": soma o que cada motorista + # fechou na faixa e, ao clicar num deles (?motorista=), mostra as + # consolidações que compõem o valor. `totais_pdf` é o mesmo relatório em PDF. + get :totais + get :totais_pdf end member do diff --git a/spec/requests/totais_por_motorista_spec.rb b/spec/requests/totais_por_motorista_spec.rb new file mode 100644 index 0000000..0f9b151 --- /dev/null +++ b/spec/requests/totais_por_motorista_spec.rb @@ -0,0 +1,90 @@ +require 'rails_helper' + +# Rotas do relatório "total por motorista": quem entra, o que a tela mostra e o +# PDF. O drill-down (?motorista=) é o que responde "de onde vem esse valor". +RSpec.describe 'Totais por motorista', type: :request do + def consolidacao_finalizada(motorista:, valor:, nome: 'Fechamento Junho') + c = create(:consolidacao, nome: nome, status: :finalizada, + data_inicio: Date.new(2026, 6, 1), data_fim: Date.new(2026, 6, 30)) + create(:consolidacao_entrega, consolidacao: c, motorista_nome: motorista, valor_aplicado: valor) + create(:consolidacao_motorista, consolidacao: c, motorista_nome: motorista) + c + end + + let(:periodo) { { inicio: '2026-06-01', fim: '2026-06-30' } } + + describe 'admin — /consolidacoes/totais' do + it 'exige login' do + get totais_consolidacoes_path + expect(response).to redirect_to(new_user_session_path) + end + + it 'bloqueia motorista (Pundit)' do + sign_in create(:motorista) + get totais_consolidacoes_path + expect(response).to redirect_to(root_path) + end + + it 'mostra o total do motorista no período' do + consolidacao_finalizada(motorista: 'Carlos', valor: 120.0) + sign_in create(:gerente) + + get totais_consolidacoes_path(periodo) + + expect(response).to have_http_status(:ok) + expect(response.body).to include('Carlos') + end + + it 'abre a composição do motorista clicado' do + consolidacao_finalizada(motorista: 'Carlos', valor: 120.0, nome: 'Fechamento Junho') + sign_in create(:gerente) + + get totais_consolidacoes_path(periodo.merge(motorista: 'Carlos')) + + expect(response.body).to include('Composição de Carlos') + expect(response.body).to include('Fechamento Junho') + end + + it 'gera o PDF' do + consolidacao_finalizada(motorista: 'Carlos', valor: 120.0) + sign_in create(:gerente) + + get totais_pdf_consolidacoes_path(periodo) + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq('application/pdf') + end + end + + describe 'motorista — /motorista/totais' do + it 'mostra só o total dele e as consolidações que o compõem' do + motorista = create(:motorista, nome: 'Carlos') + consolidacao_finalizada(motorista: 'Carlos', valor: 120.0, nome: 'Fechamento Junho') + consolidacao_finalizada(motorista: 'Pedro', valor: 999.0, nome: 'Fechamento do Pedro') + sign_in motorista + + get motorista_totais_path(periodo) + + expect(response).to have_http_status(:ok) + expect(response.body).to include('Fechamento Junho') + expect(response.body).not_to include('Fechamento do Pedro') + end + + it 'gera o PDF do período' do + motorista = create(:motorista, nome: 'Carlos') + consolidacao_finalizada(motorista: 'Carlos', valor: 120.0) + sign_in motorista + + get motorista_totais_pdf_path(periodo) + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq('application/pdf') + end + + it 'não é acessível por quem não é motorista' do + sign_in create(:gerente) + get motorista_totais_path + expect(response).to redirect_to(dashboard_path) + end + end +end diff --git a/spec/services/analytics/totais_por_motorista_spec.rb b/spec/services/analytics/totais_por_motorista_spec.rb new file mode 100644 index 0000000..7b2e245 --- /dev/null +++ b/spec/services/analytics/totais_por_motorista_spec.rb @@ -0,0 +1,92 @@ +require 'rails_helper' + +# Trava a conta do relatório "total por motorista" — a mesma que alimenta +# /consolidacoes/totais (admin) e /motorista/totais (motorista). As duas telas +# usam ESTE service justamente para não poderem divergir. +RSpec.describe Analytics::TotaisPorMotorista do + let(:inicio) { Date.new(2026, 6, 1) } + let(:fim) { Date.new(2026, 6, 30) } + + # Uma consolidação com um motorista e `qtd` lançamentos de `valor` cada. As + # entregas vêm ANTES do ConsolidacaoMotorista porque é o before_save dele que + # calcula valor_total a partir delas. + def consolidar(motorista:, valor: 100.0, qtd: 1, status: :finalizada, + data_inicio: Date.new(2026, 6, 1), data_fim: Date.new(2026, 6, 30), + pago: false, arquivado: false) + c = create(:consolidacao, status: status, data_inicio: data_inicio, data_fim: data_fim) + qtd.times { create(:consolidacao_entrega, consolidacao: c, motorista_nome: motorista, valor_aplicado: valor) } + cm = create(:consolidacao_motorista, consolidacao: c, motorista_nome: motorista) + cm.update!(pago_em: Time.current, forma_pagamento: 'pix') if pago + cm.arquivar!(nil) if arquivado + [c, cm] + end + + subject(:totais) { described_class.new(inicio: inicio, fim: fim) } + + it 'soma por motorista só as consolidações finalizadas do período' do + consolidar(motorista: 'Carlos', valor: 100.0, qtd: 2) # 200 + consolidar(motorista: 'Carlos', valor: 50.0, qtd: 1) # 50 + consolidar(motorista: 'Pedro', valor: 30.0, qtd: 1) # 30 + consolidar(motorista: 'Carlos', valor: 999.0, status: :rascunho) # fora (rascunho) + consolidar(motorista: 'Carlos', valor: 999.0, # fora (período) + data_inicio: Date.new(2026, 7, 1), data_fim: Date.new(2026, 7, 31)) + + expect(totais.linhas.map { |l| [l[:motorista], l[:valor_total]] }) + .to eq([['Carlos', 250.0], ['Pedro', 30.0]]) + expect(totais.total_geral).to eq(280.0) + expect(totais.total_consolidacoes).to eq(3) + end + + it 'inclui rascunhos quando pedido' do + consolidar(motorista: 'Carlos', valor: 100.0) + consolidar(motorista: 'Carlos', valor: 40.0, status: :rascunho) + + com_rascunho = described_class.new(inicio: inicio, fim: fim, incluir_rascunhos: true) + expect(totais.total_geral).to eq(100.0) + expect(com_rascunho.total_geral).to eq(140.0) + end + + it 'separa pago de a receber' do + consolidar(motorista: 'Carlos', valor: 100.0, pago: true) + consolidar(motorista: 'Carlos', valor: 60.0) + + linha = totais.linhas.first + expect(linha[:valor_pago]).to eq(100.0) + expect(linha[:valor_pendente]).to eq(60.0) + expect(totais.total_pago).to eq(100.0) + expect(totais.total_pendente).to eq(60.0) + end + + it 'ignora motorista arquivado (mesmo conjunto que a consolidação paga)' do + consolidar(motorista: 'Carlos', valor: 100.0) + consolidar(motorista: 'Carlos', valor: 70.0, arquivado: true) + + expect(totais.total_geral).to eq(100.0) + end + + it 'lista as consolidações que compõem o total do motorista' do + c1, = consolidar(motorista: 'Carlos', valor: 100.0) + c2, = consolidar(motorista: 'Carlos', valor: 60.0, data_fim: Date.new(2026, 6, 15)) + consolidar(motorista: 'Pedro', valor: 30.0) + + composicao = totais.consolidacoes_de('Carlos') + expect(composicao.map(&:consolidacao_id)).to match_array([c1.id, c2.id]) + expect(composicao.sum(&:valor_total)).to eq(160.0) + end + + it 'recortado por motorista, só enxerga o dele (visão do painel do motorista)' do + consolidar(motorista: 'Carlos', valor: 100.0) + consolidar(motorista: 'Pedro', valor: 30.0) + + meu = described_class.new(inicio: inicio, fim: fim, motorista: 'Carlos') + expect(meu.total_geral).to eq(100.0) + expect(meu.linhas.size).to eq(1) + end + + it 'período sem fechamento fica vazio (e não quebra)' do + vazio = described_class.new(inicio: Date.new(2026, 1, 1), fim: Date.new(2026, 1, 31)) + expect(vazio).to be_vazio + expect(vazio.total_geral).to eq(0) + expect(vazio.linhas).to eq([]) + end +end