diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb index c988e79..46cbaea 100644 --- a/app/controllers/consolidacoes_controller.rb +++ b/app/controllers/consolidacoes_controller.rb @@ -33,8 +33,17 @@ class ConsolidacoesController < ApplicationController # Filtros @consolidacoes = @consolidacoes.where(status: params[:status]) if params[:status].present? @consolidacoes = @consolidacoes.where('nome ILIKE ?', "%#{params[:nome]}%") if params[:nome].present? - @consolidacoes = @consolidacoes.where('data_inicio >= ?', params[:inicio]) if params[:inicio].present? - @consolidacoes = @consolidacoes.where('data_fim <= ?', params[:fim]) if params[:fim].present? + # Datas: a consolidação entra se CRUZA a faixa — mesmo critério do modo "Por + # motorista" e do dashboard financeiro. Com o critério antigo (caber inteira + # dentro do filtro), um fechamento de 01/08 a 31/08 sumia da lista ao filtrar + # 01/08–26/08 e as duas abas da MESMA tela contavam coisas diferentes. + if params[:inicio].present? && params[:fim].present? + @consolidacoes = @consolidacoes.cruzando_periodo(params[:inicio], params[:fim]) + elsif params[:inicio].present? + @consolidacoes = @consolidacoes.where('data_fim >= ?', params[:inicio]) + elsif params[:fim].present? + @consolidacoes = @consolidacoes.where('data_inicio <= ?', params[:fim]) + end if params[:motorista].present? ids = ConsolidacaoMotorista.where('motorista_nome ILIKE ?', "%#{params[:motorista]}%") @@ -63,7 +72,7 @@ class ConsolidacoesController < ApplicationController totais = Analytics::TotaisPorMotorista.new( inicio: inicio, fim: fim, motorista: params[:detalhe].presence, - status: params[:status].presence || 'todos', + status: params[:status].presence || 'finalizada', busca: params[:motorista].presence ) @@ -484,24 +493,26 @@ class ConsolidacoesController < ApplicationController private - # Modo "Por motorista": os totais saem do MESMO recorte da lista (período, - # status e busca por motorista), para as duas visões nunca contarem coisas - # diferentes. `detalhe` é o motorista aberto (o clique na linha). + # Modo "Por motorista": mesmo período/busca da lista, mas o recorte de datas e + # o default de status são os do DASHBOARD FINANCEIRO — é com ele que o operador + # compara o número (ver Analytics::TotaisPorMotorista). Sem status na URL vale + # "Consolidado" (só finalizadas): "Geral" (com rascunhos) mostrava um valor + # maior que o do dashboard e parecia erro de conta. + # `detalhe` é o motorista aberto (o clique na linha). def montar_totais @inicio, @fim = periodo_totais + @status_totais = params[:status].presence || 'finalizada' @totais = Analytics::TotaisPorMotorista.new( inicio: @inicio, fim: @fim, - status: params[:status].presence || 'todos', + status: @status_totais, busca: params[:motorista].presence ) @detalhe_motorista = params[:detalhe].presence @detalhe = @detalhe_motorista ? @totais.consolidacoes_de(@detalhe_motorista) : nil end - # 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. + # Período do relatório de totais. Default: o MÊS CORRENTE INTEIRO (o mês é a + # unidade de fechamento; "até hoje" recortaria o mês pela metade sem motivo). 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 diff --git a/app/controllers/motorista/dashboard_controller.rb b/app/controllers/motorista/dashboard_controller.rb index 828a024..474b083 100644 --- a/app/controllers/motorista/dashboard_controller.rb +++ b/app/controllers/motorista/dashboard_controller.rb @@ -105,9 +105,8 @@ module Motorista [inicio.to_date, fim.to_date] end - # Default: mês corrente INTEIRO (fim = último dia do mês). O recorte exige 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 lado do admin. + # Default: mês corrente INTEIRO (fim = último dia do mês) — o mês é a unidade + # de fechamento. Mesma regra do lado do admin. def periodo_selecionado inicio = parse_data(params[:inicio]) || Date.current.beginning_of_month fim = parse_data(params[:fim]) || Date.current.end_of_month diff --git a/app/javascript/controllers/carrossel_controller.js b/app/javascript/controllers/carrossel_controller.js new file mode 100644 index 0000000..74e6dbd --- /dev/null +++ b/app/javascript/controllers/carrossel_controller.js @@ -0,0 +1,68 @@ +// app/javascript/controllers/carrossel_controller.js +// Stimulus — paginação em "carrossel" para listas longas no celular (painel do +// motorista). Os itens já vêm todos no HTML; aqui só mostramos uma PÁGINA por +// vez, com setas e o indicador "1 / 3". +// +// Por que no cliente e não com ?pg= no servidor: a lista divide a tela com os +// cards de valor e o filtro de período — recarregar a página a cada seta faria o +// motorista perder a posição e piscar a tela inteira num 3G. +// +// Sem itens suficientes para duas páginas, os controles somem sozinhos (nada de +// seta morta ocupando espaço). + +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["item", "controles", "indicador", "anterior", "proximo"] + static values = { tamanho: { type: Number, default: 10 } } + + connect() { + this.pagina = 0 + this.render() + } + + proxima() { this.ir(this.pagina + 1) } + anterior() { this.ir(this.pagina - 1) } + + ir(destino) { + this.pagina = Math.min(Math.max(destino, 0), this.totalPaginas - 1) + this.render() + // Volta ao topo da lista: sem isso, na 2ª página o motorista continua vendo + // o fim da anterior e parece que nada mudou. + this.element.scrollIntoView({ behavior: "smooth", block: "start" }) + } + + get totalPaginas() { + return Math.max(Math.ceil(this.itemTargets.length / this.tamanhoValue), 1) + } + + render() { + const inicio = this.pagina * this.tamanhoValue + const fim = inicio + this.tamanhoValue + + this.itemTargets.forEach((item, i) => { + item.classList.toggle("hidden", i < inicio || i >= fim) + }) + + if (!this.hasControlesTarget) return + + this.controlesTarget.classList.toggle("hidden", this.totalPaginas < 2) + if (this.hasIndicadorTarget) { + // "1–10 de 12" e não "página 1 de 2": o motorista quer saber quantos + // fechamentos existem, não quantas páginas a tela tem. + const total = this.itemTargets.length + this.indicadorTarget.textContent = `${inicio + 1}–${Math.min(fim, total)} de ${total}` + } + if (this.hasAnteriorTarget) this.alternar(this.anteriorTarget, this.pagina === 0) + if (this.hasProximoTarget) this.alternar(this.proximoTarget, this.pagina >= this.totalPaginas - 1) + } + + // Botão de ponta: desabilitado de verdade (não só apagado), para o toque não + // "funcionar sem fazer nada". + alternar(botao, desligado) { + if (!botao) return + botao.disabled = desligado + botao.classList.toggle("opacity-30", desligado) + botao.classList.toggle("pointer-events-none", desligado) + } +} diff --git a/app/models/consolidacao.rb b/app/models/consolidacao.rb index bb0a9bb..96e057e 100644 --- a/app/models/consolidacao.rb +++ b/app/models/consolidacao.rb @@ -33,8 +33,18 @@ class Consolidacao < ApplicationRecord # ── Escopos ───────────────────────────────────────────────── scope :recentes, -> { order(created_at: :desc) } + # ⚠️ `no_periodo` exige a consolidação INTEIRA dentro da faixa. Não é o recorte + # das telas (elas usam `cruzando_periodo`, abaixo) — um fechamento de 01/08 a + # 31/08 sumia ao filtrar 01/08–26/08. Só use se o "caber dentro" for mesmo o + # que se quer. scope :no_periodo, ->(i, f) { where('data_inicio >= ? AND data_fim <= ?', i, f) } + # Consolidações que CRUZAM o período (não precisam caber inteiras dentro dele). + # É o recorte do dashboard financeiro (DashboardController#carregar_dados_ + # financeiro) e, por isso, o de qualquer tela que precise bater com ele: um + # fechamento de 01/08 a 31/08 tem que aparecer ao filtrar 01/08–26/08. + scope :cruzando_periodo, ->(i, f) { where('data_inicio <= ? AND data_fim >= ?', f, i) } + # ── Filtro por status de pagamento (derivado dos motoristas) ─ # pago_em NÃO-nulo conta como pago; COUNT(pago_em) ignora os NULL. scope :pagamento_pago, -> { diff --git a/app/services/analytics/totais_por_motorista.rb b/app/services/analytics/totais_por_motorista.rb index dc1b1ec..fed4c8b 100644 --- a/app/services/analytics/totais_por_motorista.rb +++ b/app/services/analytics/totais_por_motorista.rb @@ -7,11 +7,18 @@ # lista, com drill-down no motorista clicado) # • motorista→ /motorista (o painel, já recortado nele — 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. +# RECORTE — É O MESMO DO DASHBOARD FINANCEIRO, de propósito +# (DashboardController#carregar_dados_financeiro). Os dois números são lidos lado +# a lado pelo operador; quando divergem, a leitura é "o sistema está errado". +# Entram as consolidações ATIVAS que: +# • CRUZAM o período (data_inicio <= fim AND data_fim >= inicio) — um +# fechamento de 01/08 a 31/08 aparece ao filtrar 01/08–26/08; OU +# • tiveram algum PAGAMENTO dentro do período (pagar em agosto um fechamento +# de julho tem que aparecer em agosto). +# +# STATUS: por padrão só as FINALIZADAS — é o "Consolidado" do dashboard, o valor +# que já fechou. 'todos' inclui os rascunhos em andamento (a visão "Geral"), que +# ainda mudam de valor. # # Só motoristas ATIVOS (não arquivados) contam: é o mesmo conjunto que a # consolidação paga (ConsolidacaoMotorista.ativos), então o total desta tela @@ -36,15 +43,12 @@ module Analytics # telas mostram nome/período/status dela em seguida). def registros @registros ||= begin - base = Consolidacao.ativas.no_periodo(@inicio, @fim) - # Por padrão 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 traduz :finalizada é o model. + base = Consolidacao.ativas + # `public_send` sobre o enum: quem traduz :finalizada/:rascunho é o model. base = base.public_send(@status) if %w[finalizada rascunho].include?(@status) escopo = ConsolidacaoMotorista.ativos - .joins(:consolidacao) - .merge(base) + .where(consolidacao_id: ids_do_periodo(base)) .includes(:consolidacao) escopo = escopo.where(motorista_nome: @motorista) if @motorista escopo = escopo.where('motorista_nome ILIKE ?', "%#{@busca}%") if @busca @@ -94,6 +98,19 @@ module Analytics private + # As consolidações do período: as que cruzam a faixa + as que receberam + # pagamento dentro dela. Mesmo par de conjuntos do dashboard financeiro. + def ids_do_periodo(base) + intervalo = @inicio.beginning_of_day..@fim.end_of_day + + cruzam = base.cruzando_periodo(@inicio, @fim).ids + pagas = ConsolidacaoMotorista.ativos + .where(pago_em: intervalo) + .where(consolidacao_id: base.select(:id)) + .distinct.pluck(:consolidacao_id) + (cruzam + pagas).uniq + end + def soma(cms) cms.sum { |cm| cm.valor_total || 0 } end diff --git a/app/views/consolidacoes/_por_motorista.html.erb b/app/views/consolidacoes/_por_motorista.html.erb index 27408cd..bba2c91 100644 --- a/app/views/consolidacoes/_por_motorista.html.erb +++ b/app/views/consolidacoes/_por_motorista.html.erb @@ -3,14 +3,16 @@ ali mesmo (sem sair da tela), as consolidações que compõem o valor dele. %> <%# Link desta mesma tela preservando filtros — usado pelas linhas e pelos PDFs. %> -<% ctx = { nome: params[:nome], motorista: params[:motorista], status: params[:status], +<% ctx = { nome: params[:nome], motorista: params[:motorista], status: @status_totais, inicio: @inicio.strftime('%Y-%m-%d'), fim: @fim.strftime('%Y-%m-%d'), visao: 'motoristas' }.compact_blank %> <%# ── Resumo do período ────────────────────────────────── %>
<%= icone :dinheiro %> Total do período
++ <%= icone :dinheiro %> <%= @status_totais == 'finalizada' ? 'Consolidado no período' : 'Total do período' %> +
<%= moeda(@totais.total_geral) %>
<%= @totais.total_consolidacoes %> consolidação(ões) · <%= @totais.linhas.size %> motorista(s)
- <%= icone :dica %> Entram as consolidações - <%= params[:status].presence ? "com status #{params[:status]}" : 'de qualquer status' %> - cujo período está dentro de <%= l @inicio, format: :short %> → <%= l @fim, format: :short %>. - Motoristas arquivados na consolidação não entram no total. + <%= icone :dica %> + <% if @status_totais == 'finalizada' %> + Consolidado = só consolidações finalizadas — + é o mesmo número do card "Consolidado / Pago" do Dashboard no mesmo período. + Troque para Geral para somar também os rascunhos em andamento. + <% elsif @status_totais == 'rascunho' %> + Mostrando só rascunhos — valores ainda em aberto, que mudam até o fechamento. + <% else %> + Geral = finalizadas + rascunhos, por isso fica acima do + "Consolidado / Pago" do Dashboard. Troque para Consolidado para comparar com ele. + <% end %> + Entram as consolidações que cruzam <%= l @inicio, format: :short %> → <%= l @fim, format: :short %> e as pagas dentro do período; + motoristas arquivados não entram no total.
<% end %> diff --git a/app/views/consolidacoes/index.html.erb b/app/views/consolidacoes/index.html.erb index 0dfb198..6bac899 100644 --- a/app/views/consolidacoes/index.html.erb +++ b/app/views/consolidacoes/index.html.erb @@ -111,9 +111,20 @@ class: 'flex-1 basis-48 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5 focus:border-orange-500 focus:outline-none' %> <% end %> - <%= f.select :status, - options_for_select([['Todos os status', ''], ['Rascunho', 'rascunho'], ['Finalizada', 'finalizada']], params[:status]), - {}, class: 'flex-1 basis-40 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + <% if @visao == 'motoristas' %> + <%# "Consolidado" x "Geral" — a pergunta que o número responde muda com isso: + Consolidado é o valor JÁ FECHADO (o mesmo do card "Consolidado / Pago" do + Dashboard); Geral soma também os rascunhos, que ainda mudam de valor. %> + <%= f.select :status, + options_for_select([['Consolidado (fechadas)', 'finalizada'], + ['Geral (com rascunhos)', 'todos'], + ['Só rascunhos', 'rascunho']], @status_totais), + {}, class: 'flex-1 basis-52 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + <% else %> + <%= f.select :status, + options_for_select([['Todos os status', ''], ['Rascunho', 'rascunho'], ['Finalizada', 'finalizada']], params[:status]), + {}, class: 'flex-1 basis-40 min-w-0 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + <% end %> <%# "Pagamento" é status DA CONSOLIDAÇÃO (todos os motoristas dela); no modo Por motorista cada linha já mostra pago/a receber, então o filtro sairia diff --git a/app/views/motorista/dashboard/index.html.erb b/app/views/motorista/dashboard/index.html.erb index 5aae57c..1083ead 100644 --- a/app/views/motorista/dashboard/index.html.erb +++ b/app/views/motorista/dashboard/index.html.erb @@ -103,35 +103,57 @@ <% end %> - <%# ── Card secundário: estimativa (ainda não fechada) ──── %> -<%= icone :dinheiro, cor: nil, espaco: false %> Estimativa das entregas
- <%= l @estimado_inicio, format: :short %> a <%= l @estimado_fim, format: :short %> + <%# ── Card secundário: entregas do período ────────────── + REGRA: valor estimado e valor fechado NUNCA aparecem juntos. + Quando a empresa já fechou o período, o número certo é o de cima; mostrar + ao lado uma estimativa (entregas × preço, sem descontos/bônus e sem as + regras de classificação) fazia o motorista ver dois valores diferentes para + o mesmo mês — e o estimado costuma ser MAIOR, o que vira cobrança. + Com fechamento: só a CONTAGEM de entregas (o trabalho, sem prometer valor). + Sem fechamento: a prévia em R$, que é a única informação que ele tem. %> + <% if @consolidacoes.any? %> +<%= icone :caminhao, espaco: false %> Suas entregas no período
+ <%= l @estimado_inicio, format: :short %> a <%= l @estimado_fim, format: :short %> +<%= @entregas_mes %>
+ <%# "Atendidas" = concluídas + sem sucesso: a empresa paga as duas, porque + em ambas o motorista foi até o local. %> ++ <%= @entregas_concluidas %> entregues · <%= @entregas_falhadas %> sem sucesso (pagas também) +
++ <%= icone :sucesso, cor: 'text-green-500' %> O valor deste período já foi fechado pela empresa — é o do card branco acima. +
<%= moeda(@valor_estimado) %>
- <%# "Atendidas" = concluídas + sem sucesso. O rótulo NÃO pode voltar a dizer - "feitas e confirmadas": o valor acima inclui as visitas sem sucesso, que - a empresa também paga (o motorista foi ao local). Sem a segunda linha o - motorista não consegue conferir a conta. %> -<%= @entregas_mes %> entregas atendidas
-- <%= @entregas_concluidas %> entregues · <%= @entregas_falhadas %> sem sucesso (pagas também) -
-- <%= icone :alerta, cor: nil %> Valor aproximado. O valor certo é o de cima, quando a empresa fecha o pagamento. -
-<%= icone :dinheiro, cor: nil, espaco: false %> Prévia (ainda não fechado)
+ <%= l @estimado_inicio, format: :short %> a <%= l @estimado_fim, format: :short %> +<%= moeda(@valor_estimado) %>
+<%= @entregas_mes %> entregas atendidas
++ <%= @entregas_concluidas %> entregues · <%= @entregas_falhadas %> sem sucesso (pagas também) +
++ <%= icone :alerta, cor: nil %> Conta simples (entregas × valor da entrega). O valor certo sai quando a empresa fechar o período — bônus, retiradas e descontos entram só no fechamento. +
+Cada fechamento abaixo entra na soma do card branco.
<% if @consolidacoes.any? %> +<%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %>
diff --git a/spec/requests/totais_por_motorista_spec.rb b/spec/requests/totais_por_motorista_spec.rb index ed5d5be..529d420 100644 --- a/spec/requests/totais_por_motorista_spec.rb +++ b/spec/requests/totais_por_motorista_spec.rb @@ -32,6 +32,24 @@ RSpec.describe 'Visão por motorista', type: :request do expect(response.body).to include('R$ 120,00') end + # O default tem que ser o MESMO recorte do dashboard ("Consolidado / Pago"), + # senão o operador compara as duas telas e vê valores diferentes. + it 'sem status na URL mostra o CONSOLIDADO (rascunho fica de fora)' do + consolidacao_finalizada(motorista: 'Carlos', valor: 120.0) + rascunho = create(:consolidacao, status: :rascunho, + data_inicio: Date.new(2026, 6, 1), data_fim: Date.new(2026, 6, 30)) + create(:consolidacao_entrega, consolidacao: rascunho, motorista_nome: 'Carlos', valor_aplicado: 500.0) + create(:consolidacao_motorista, consolidacao: rascunho, motorista_nome: 'Carlos') + sign_in create(:gerente) + + get consolidacoes_path(periodo.merge(visao: 'motoristas')) + expect(response.body).to include('R$ 120,00') + expect(response.body).not_to include('R$ 620,00') + + get consolidacoes_path(periodo.merge(visao: 'motoristas', status: 'todos')) + expect(response.body).to include('R$ 620,00') + end + it 'só abre a composição do motorista clicado' do consolidacao_finalizada(motorista: 'Carlos', valor: 120.0, nome: 'Fechamento Junho') consolidacao_finalizada(motorista: 'Pedro', valor: 90.0, nome: 'Fechamento do Pedro') diff --git a/spec/services/analytics/totais_por_motorista_spec.rb b/spec/services/analytics/totais_por_motorista_spec.rb index 155a2b0..724c9e6 100644 --- a/spec/services/analytics/totais_por_motorista_spec.rb +++ b/spec/services/analytics/totais_por_motorista_spec.rb @@ -28,7 +28,7 @@ RSpec.describe Analytics::TotaisPorMotorista do 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) + consolidar(motorista: 'Carlos', valor: 999.0, # fora (não cruza) data_inicio: Date.new(2026, 7, 1), data_fim: Date.new(2026, 7, 31)) expect(totais.linhas.map { |l| [l[:motorista], l[:valor_total]] }) @@ -37,6 +37,29 @@ RSpec.describe Analytics::TotaisPorMotorista do expect(totais.total_consolidacoes).to eq(3) end + # ── Recorte de datas: o MESMO do dashboard financeiro ─────────────────── + # Quando estes dois casos divergiam do dashboard, o operador via dois valores + # diferentes para o mesmo motorista no mesmo período e lia como erro de conta. + it 'inclui a consolidação que CRUZA o período (não precisa caber inteira)' do + # 20/05 a 20/06: começa antes do filtro, termina dentro. + consolidar(motorista: 'Carlos', valor: 100.0, + data_inicio: Date.new(2026, 5, 20), data_fim: Date.new(2026, 6, 20)) + # 20/06 a 20/07: começa dentro, termina depois. + consolidar(motorista: 'Carlos', valor: 40.0, + data_inicio: Date.new(2026, 6, 20), data_fim: Date.new(2026, 7, 20)) + + expect(totais.total_geral).to eq(140.0) + end + + it 'inclui a consolidação de outro período PAGA dentro do período' do + _, cm = consolidar(motorista: 'Carlos', valor: 80.0, + data_inicio: Date.new(2026, 4, 1), data_fim: Date.new(2026, 4, 30)) + cm.update!(pago_em: Time.zone.local(2026, 6, 10, 12, 0), forma_pagamento: 'pix') + + expect(totais.total_geral).to eq(80.0) + expect(totais.total_pago).to eq(80.0) + end + it 'respeita o filtro de status (o mesmo select da lista)' do consolidar(motorista: 'Carlos', valor: 100.0) consolidar(motorista: 'Carlos', valor: 40.0, status: :rascunho)