From bfa474262fb70ce967049168d88992cd933d4ce7f2ebd8c614cec2a687b2149e Mon Sep 17 00:00:00 2001 From: victor Date: Fri, 28 Aug 2026 16:38:51 -0300 Subject: [PATCH] =?UTF-8?q?,=20Corre=C3=A7oes=20UX=20consolida=C3=A7oes=20?= =?UTF-8?q?e=20de=20backa-end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/consolidacoes_controller.rb | 31 ++++ app/controllers/dashboard_controller.rb | 80 ++++++++++- .../controllers/selecao_lote_controller.js | 50 +++++++ .../analytics/totais_por_motorista.rb | 12 ++ app/views/consolidacoes/_lista.html.erb | 135 +++++++++++++----- app/views/dashboard/index.html.erb | 18 ++- config/routes.rb | 4 + 7 files changed, 290 insertions(+), 40 deletions(-) create mode 100644 app/javascript/controllers/selecao_lote_controller.js diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb index 740dfd4..b06ca59 100644 --- a/app/controllers/consolidacoes_controller.rb +++ b/app/controllers/consolidacoes_controller.rb @@ -284,6 +284,37 @@ class ConsolidacoesController < ApplicationController redirect_to consolidacoes_path, notice: 'Consolidação arquivada.' end + # POST /consolidacoes/arquivar_em_lote — arquiva as consolidações marcadas na + # lista. Mesma regra do arquivar de uma só (nenhuma trava extra): arquivar é + # REVERSÍVEL (Arquivadas → Reativar), então o lote não precisa de confirmação + # mais dura do que a de uma — o que ele precisa é dizer QUANTAS foram. + # + # Ignora em silêncio id que não está mais ativo (alguém arquivou pela outra + # aba enquanto a lista estava aberta): o resultado que o operador queria — "essas + # saem da lista" — já é o que está na tela, e erro aqui só assustaria à toa. + def arquivar_em_lote + authorize Consolidacao, :arquivar? + + ids = Array(params[:ids]).map(&:to_i).reject(&:zero?) + if ids.empty? + return redirect_back fallback_location: consolidacoes_path, + alert: 'Marque ao menos uma consolidação para arquivar.' + end + + alvos = Consolidacao.ativas.where(id: ids).to_a + alvos.each do |c| + c.arquivar!(current_user) + auditar!(:arquivar, c, dados_novos: { em_lote: true, total_do_lote: alvos.size }) + end + + aviso = case alvos.size + when 0 then 'Nenhuma das consolidações marcadas continuava ativa.' + when 1 then "1 consolidação arquivada. Você pode restaurar em \"Arquivadas\"." + else "#{alvos.size} consolidações arquivadas. Você pode restaurar em \"Arquivadas\"." + end + redirect_back fallback_location: consolidacoes_path, notice: aviso + end + # GET /consolidacoes/arquivadas — histórico de consolidações arquivadas def arquivadas authorize Consolidacao diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index ab41249..7995509 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -2,6 +2,26 @@ class DashboardController < ApplicationController MAX_DIAS_GRAFICO = 92 # limita a granularidade diária do gráfico (≈ 1 trimestre) + # Tipos lançados EM LOTE: uma linha só de consolidacao_entregas representa N + # entregas (quantidade = N, valor_aplicado = N × preço — ver + # Consolidacao#adicionar_termos!). Contá-los por tracking_id valeria 1. + TIPOS_EM_LOTE = %w[termo termo_especial].freeze + + # Cor de cada tipo no donut "Composição do custo". Fica AQUI, junto de quem + # monta @fin_por_tipo, para label, valor e cor viajarem no MESMO hash: a view + # tinha uma lista fixa de 5 cores para os 7 tipos de + # ConsolidacaoEntrega::TIPO_CORES e o Chart.js reciclava o array — "Entrega de + # Termo" saía com a mesma laranja de "Entrega Normal", no mesmo gráfico. + # `desconto` NÃO entra: ver o comentário em #carregar_dados_financeiro. + CORES_TIPO = { + 'entrega_normal' => '#f97316', + 'retirada' => '#9a3412', + 'bonus' => '#fed7aa', + 'extraordinaria' => '#9333ea', + 'termo' => '#2563eb', + 'termo_especial' => '#06b6d4' + }.freeze + def index # Motorista tem painel próprio — não enxerga o dashboard administrativo return redirect_to(motorista_dashboard_path) if current_user.motorista? @@ -247,11 +267,26 @@ class DashboardController < ApplicationController consols = Consolidacao.where(id: (ids_periodo + ids_pagos).uniq).to_a ids = consols.map(&:id) cms = ConsolidacaoMotorista.ativos.where(consolidacao_id: ids) - ces = ConsolidacaoEntrega.where(consolidacao_id: ids) + + # ⚠️ O MESMO RECORTE DE `cms`, NO NÍVEL DA LINHA. Motorista arquivado sai do + # dinheiro (ConsolidacaoMotorista.ativos), mas as linhas dele CONTINUAM em + # consolidacao_entregas: `arquivar_motorista` é reversível e preserva tudo — + # quem apaga é o `excluir_motorista`. Sem este EXISTS, o card "Custo total" + # mostrava R$ de um conjunto e "N entregas classificadas" de outro, o ticket + # médio dividia um pelo outro e o donut de composição não fechava com o total. + # + # O par é (consolidacao_id, motorista_nome), não só o nome: o mesmo motorista + # pode estar ativo numa consolidação e arquivado em outra, ambas no período. + ces = ConsolidacaoEntrega.where(consolidacao_id: ids).where(<<~SQL.squish) + EXISTS (SELECT 1 FROM consolidacao_motoristas cm + WHERE cm.consolidacao_id = consolidacao_entregas.consolidacao_id + AND cm.motorista_nome = consolidacao_entregas.motorista_nome + AND cm.arquivado_em IS NULL) + SQL # KPIs @fin_custo_total = cms.sum(:valor_total) - @fin_qtd_entregas = ces.distinct.count(:tracking_id) + @fin_qtd_entregas = contar_entregas(ces) @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) @@ -266,12 +301,21 @@ class DashboardController < ApplicationController end @fin_por_operacao = por_op.sort_by { |_, v| -v } - # Composição por tipo (normal/retirada/bônus/desconto) + # Composição por tipo (normal/retirada/bônus/…) + # + # ⚠️ DESCONTO NÃO É FATIA. No dinheiro ele SUBTRAI (ConsolidacaoMotorista + # #recalcular_valor e Consolidacao#recalcular_motorista! usam + # `-valor_aplicado`). Como fatia positiva de um DONUT — que se lê como + # "partes de um todo" — a soma das fatias ficava maior que o Custo total + # mostrado logo acima, e a diferença era exatamente o desconto. Ele sai do + # gráfico e vira a linha "já abatidos" embaixo: a divergência é legítima, + # então é explicada no lugar onde ela aparece (diretriz 1). soma_tipo = ces.group(:tipo).sum(:valor_aplicado) - @fin_por_tipo = ConsolidacaoEntrega::TIPO_CORES.filter_map do |tipo, cfg| + @fin_descontos = soma_tipo['desconto'].to_f + @fin_por_tipo = CORES_TIPO.filter_map do |tipo, cor| valor = soma_tipo[tipo].to_f next if valor.zero? - { label: cfg[:label], valor: valor } + { label: ConsolidacaoEntrega::TIPO_CORES.dig(tipo, :label) || tipo, valor: valor, cor: cor } end # Custo por motorista (todos) @@ -288,13 +332,37 @@ class DashboardController < ApplicationController # Partimos de @fin_por_motorista (que vem de `cms`, já .ativos) e só buscamos # a quantidade: assim a aba lista exatamente os motoristas do fechamento — # arquivado não aparece — e a soma bate com o KPI "Custo total" do topo. - qtd_por_motorista = ces.group(:motorista_nome).distinct.count(:tracking_id) + qtd_por_motorista = contar_entregas_por_motorista(ces) @ranking_consolidado = @fin_por_motorista.map do |nome, valor| { nome: nome, valor: valor, entregas: qtd_por_motorista[nome].to_i } end end + # Quantas ENTREGAS existem num conjunto de linhas de consolidacao_entregas. + # São DUAS correções na mesma conta, e as duas precisam conviver: + # + # • DISTINCT tracking_id — a MESMA entrega vira várias linhas quando tem mais + # de um pilar (Normal + Bônus + Retirada = 3 linhas). Contar linhas inflava + # o número de entregas. Esta parte já estava certa. + # • termo soma `quantidade` — um lote de termo é UMA linha com quantidade N + # (Consolidacao#adicionar_termos!), então por tracking_id um lote de 20 + # valia 1: o custo entrava inteiro no numerador e 1 no denominador, e o + # ticket médio saía 20× maior. É a mesma unidade que + # Analytics::TotaisPorMotorista já usa ("um termo com quantidade 5 vale 5"). + def contar_entregas(escopo) + escopo.where.not(tipo: TIPOS_EM_LOTE).distinct.count(:tracking_id) + + escopo.where(tipo: TIPOS_EM_LOTE).sum(:quantidade) + end + + # Mesma conta de #contar_entregas, quebrada por motorista — o ranking e o KPI + # do topo têm que somar a mesma coisa, senão viram dois números na mesma tela. + def contar_entregas_por_motorista(escopo) + normais = escopo.where.not(tipo: TIPOS_EM_LOTE).group(:motorista_nome).distinct.count(:tracking_id) + lotes = escopo.where(tipo: TIPOS_EM_LOTE).group(:motorista_nome).sum(:quantidade) + normais.merge(lotes) { |_nome, a, b| a + b } + end + # `atendidas` já vem filtrado por checkout no período (concluídas + falhadas). # Agrupa pela DATA REAL (DATE(checkout)) — DATE() sobre timestamp não faz # conversão de fuso. diff --git a/app/javascript/controllers/selecao_lote_controller.js b/app/javascript/controllers/selecao_lote_controller.js new file mode 100644 index 0000000..d715669 --- /dev/null +++ b/app/javascript/controllers/selecao_lote_controller.js @@ -0,0 +1,50 @@ +import { Controller } from "@hotwired/stimulus" + +// Seleção em massa da lista de consolidações (?visao=lista): marca várias e +// arquiva de uma vez. +// +// POR QUE ELE É TÃO PEQUENO: o formulário é HTML de verdade — os checkboxes +// carregam `name="ids[]"` e `form="form-arquivar-lote"`, e o botão é um submit +// comum. Este controller só cuida do CONFORTO (marcar todas, contar, desabilitar +// quando não há nada marcado). Se o JS não carregar — e já carregou 404 em +// produção, ver README de 28/08/2026 — marcar na mão e enviar continua +// funcionando, e o botão continua habilitado porque quem desabilita é ele. +// +// "Selecionar todas" marca TODAS as da lista filtrada, inclusive as que o +// carrossel escondeu na página seguinte: o carrossel só põe a classe `hidden`, +// os campos continuam no DOM e são enviados. É o que se espera de "em lote" — +// e o contador diz o número, então não há surpresa no envio. +export default class extends Controller { + static targets = ["todos", "item", "contador", "botao"] + + connect() { + this.atualizar() + } + + alternarTodos() { + this.itemTargets.forEach((c) => { c.checked = this.todosTarget.checked }) + this.atualizar() + } + + atualizar() { + const marcados = this.itemTargets.filter((c) => c.checked).length + const total = this.itemTargets.length + + if (this.hasTodosTarget) { + this.todosTarget.checked = total > 0 && marcados === total + // Estado "algumas": o quadradinho vira traço em vez de mentir que está + // tudo (ou nada) marcado. + this.todosTarget.indeterminate = marcados > 0 && marcados < total + } + + if (this.hasContadorTarget) { + this.contadorTarget.textContent = + marcados === 0 ? "Marque as que quer arquivar" + : `${marcados} de ${total} selecionada${marcados > 1 ? "s" : ""}` + this.contadorTarget.classList.toggle("text-gray-500", marcados === 0) + this.contadorTarget.classList.toggle("text-white", marcados > 0) + } + + if (this.hasBotaoTarget) this.botaoTarget.disabled = marcados === 0 + } +} diff --git a/app/services/analytics/totais_por_motorista.rb b/app/services/analytics/totais_por_motorista.rb index fed4c8b..1fda88c 100644 --- a/app/services/analytics/totais_por_motorista.rb +++ b/app/services/analytics/totais_por_motorista.rb @@ -124,8 +124,20 @@ module Analytics if ids.empty? {} else + # O EXISTS espelha o `ConsolidacaoMotorista.ativos` de `registros`, e por + # PAR (consolidação, motorista) — filtrar só por nome não basta: o + # mesmo motorista pode estar ativo numa consolidação do período e + # arquivado em outra, e aí os lançamentos da arquivada entravam nesta + # contagem enquanto o R$ da mesma linha os excluía. Arquivar motorista + # é reversível e NÃO apaga consolidacao_entregas (só o excluir apaga). ConsolidacaoEntrega.where(consolidacao_id: ids, motorista_nome: registros.map(&:motorista_nome).uniq) + .where(<<~SQL.squish) + EXISTS (SELECT 1 FROM consolidacao_motoristas cm + WHERE cm.consolidacao_id = consolidacao_entregas.consolidacao_id + AND cm.motorista_nome = consolidacao_entregas.motorista_nome + AND cm.arquivado_em IS NULL) + SQL .group(:motorista_nome) .sum(:quantidade) end diff --git a/app/views/consolidacoes/_lista.html.erb b/app/views/consolidacoes/_lista.html.erb index 5186478..2975a72 100644 --- a/app/views/consolidacoes/_lista.html.erb +++ b/app/views/consolidacoes/_lista.html.erb @@ -1,40 +1,111 @@ <%# app/views/consolidacoes/_lista.html.erb — visão por CONSOLIDAÇÃO %> +<% pode_arquivar = policy(Consolidacao).arquivar? %> + <% if @consolidacoes.any? %> - <%# Em celular/monitor pequeno a lista vira carrossel de 10 em 10; em tela - grande aparece inteira (ver carrossel_controller.js). %> -
-
- <% @consolidacoes.each do |c| %> - <%= link_to consolidacao_path(c), - data: { carrossel_target: 'item' }, - class: 'block bg-[#1a1a1a] border border-[#2a2a2a] hover:border-orange-500 rounded-xl p-5 transition-colors' do %> -
-
-
-

<%= c.nome %>

- <%= badge_status(c.status) %> - <% if c.finalizada? %><%= badge_pagamento(c.status_pagamento) %><% end %> -
-

- <%= icone :calendario %> <%= l c.data_inicio, format: :short %> → <%= l c.data_fim, format: :short %> - · <%= icone :pessoa %> <%= c.consolidacao_motoristas.count %> motorista(s) - · Criada por <%= c.criador.nome_display %> -

- <% if c.operacoes.present? %> -

<%= icone :pasta %> <%= c.operacoes_label.join(' · ') %>

- <% end %> -
-
-

<%= moeda(c.valor_total) %>

-

<%= c.consolidacao_entregas.count %> entregas classificadas

-
-
+ <%# ── Seleção em massa ────────────────────────────────────────────────── + Arquivar uma a uma custava três passos por consolidação (abrir, arquivar, + voltar para o filtro) — inviável para fechar um mês inteiro de uma vez. + + A linha aparece SEMPRE (não só depois de marcar algo): um controle que só + existe depois de descobrir que ele existe não é descoberto. Ela só some + para quem não tem `consolidacao.arquivar`. + + O `data-controller` envolve a linha E a lista porque os checkboxes dos + cards são targets deste mesmo controller. %> +
+ + <% if pode_arquivar %> + <%= form_with url: arquivar_em_lote_consolidacoes_path, method: :post, + id: 'form-arquivar-lote', + data: { turbo_confirm: 'Arquivar as consolidações marcadas? Elas saem da lista, mas dá para restaurar em "Arquivadas".' }, + class: 'flex flex-wrap items-center gap-3 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl px-4 py-3 mb-4' do %> + + + + <%# Texto inicial NEUTRO de propósito: com o JS fora do ar ele nunca é + substituído, e "Nenhuma selecionada" fixo na tela seria mentira. %> + + Marque as que quer arquivar + + + <%# Começa HABILITADO. Se o JS não carregar (já aconteceu: os assets + ficaram 404 em produção), um botão que só o JS habilita deixaria a + função inalcançável — a marcação manual continua funcionando, e o + controller devolve um aviso quando nada foi marcado. %> + <% end %> <% end %> -
- <%= render 'shared/carrossel_controles', - anterior: 'Consolidações anteriores', proximo: 'Próximas consolidações' %> + <%# Em celular/monitor pequeno a lista vira carrossel de 10 em 10; em tela + grande aparece inteira (ver carrossel_controller.js). %> +
<% else %>
diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 83ba15a..31ae3b4 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -348,9 +348,20 @@ <%# Composição por tipo (rosca) %>

<%= icone :composicao, espaco: false %> Composição do custo

-

Por tipo de lançamento

+

Por tipo de lançamento, antes dos descontos

<% if @fin_por_tipo.any? %>
+ <%# A rosca soma os pilares que ACRESCENTAM valor; o desconto subtrai e + não cabe como fatia (ver DashboardController#carregar_dados_financeiro). + Quando existe desconto, a diferença entre a soma das fatias e o + "Custo total" do topo é exatamente esta linha — dita aqui, no lugar + onde ela aparece, e não em documentação (diretriz 1). %> + <% if @fin_descontos.to_f.positive? %> +

+ − <%= moeda(@fin_descontos) %> + em descontos, já abatidos no Custo total. +

+ <% end %> <% else %>
<%= icone :composicao, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %>

Sem dados no período

<% end %> @@ -916,7 +927,10 @@ 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', '#9333ea']; + // A cor vem junto do label (DashboardController::CORES_TIPO). Era uma lista + // fixa de 5 cores aqui para 7 tipos possíveis: o Chart.js reciclava e + // "Entrega de Termo" saía com a mesma laranja de "Entrega Normal". + const cores = <%= raw @fin_por_tipo.map { |h| h[:cor] }.to_json %>; new Chart(ctx, { type: 'doughnut', data: { labels: labels, datasets: [{ data: valores, backgroundColor: cores, borderColor: '#1a1a1a', borderWidth: 3 }] }, diff --git a/config/routes.rb b/config/routes.rb index a2f792f..365bf7e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -50,6 +50,10 @@ Rails.application.routes.draw do post :avulsa # cria consolidação avulsa a partir de uma NF get :arquivadas # Histórico — consolidações arquivadas + # Arquivamento em lote a partir da própria lista (?visao=lista). Coleção e + # não membro: o alvo é o CONJUNTO selecionado, não uma consolidação. + post :arquivar_em_lote + # PDF do modo de visualização "Por motorista" (/consolidacoes?visao= # motoristas): o total de cada um no período e as consolidações que # compõem o valor.