, Correçoes UX consolidaçoes e de backa-end

This commit is contained in:
2026-08-28 16:38:51 -03:00
parent b6fe6f796f
commit bfa474262f
7 changed files with 290 additions and 40 deletions

View File

@@ -284,6 +284,37 @@ class ConsolidacoesController < ApplicationController
redirect_to consolidacoes_path, notice: 'Consolidação arquivada.' redirect_to consolidacoes_path, notice: 'Consolidação arquivada.'
end 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 # GET /consolidacoes/arquivadas — histórico de consolidações arquivadas
def arquivadas def arquivadas
authorize Consolidacao authorize Consolidacao

View File

@@ -2,6 +2,26 @@
class DashboardController < ApplicationController class DashboardController < ApplicationController
MAX_DIAS_GRAFICO = 92 # limita a granularidade diária do gráfico (≈ 1 trimestre) 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 def index
# Motorista tem painel próprio — não enxerga o dashboard administrativo # Motorista tem painel próprio — não enxerga o dashboard administrativo
return redirect_to(motorista_dashboard_path) if current_user.motorista? 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 consols = Consolidacao.where(id: (ids_periodo + ids_pagos).uniq).to_a
ids = consols.map(&:id) ids = consols.map(&:id)
cms = ConsolidacaoMotorista.ativos.where(consolidacao_id: ids) 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 # KPIs
@fin_custo_total = cms.sum(:valor_total) @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_ticket_medio = @fin_qtd_entregas.zero? ? 0 : (@fin_custo_total / @fin_qtd_entregas)
@fin_pago = cms.pagos.sum(:valor_total) @fin_pago = cms.pagos.sum(:valor_total)
@fin_pendente = cms.pendentes.sum(:valor_total) @fin_pendente = cms.pendentes.sum(:valor_total)
@@ -266,12 +301,21 @@ class DashboardController < ApplicationController
end end
@fin_por_operacao = por_op.sort_by { |_, v| -v } @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) 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 valor = soma_tipo[tipo].to_f
next if valor.zero? next if valor.zero?
{ label: cfg[:label], valor: valor } { label: ConsolidacaoEntrega::TIPO_CORES.dig(tipo, :label) || tipo, valor: valor, cor: cor }
end end
# Custo por motorista (todos) # 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 # 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 — # 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. # 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| @ranking_consolidado = @fin_por_motorista.map do |nome, valor|
{ nome: nome, valor: valor, entregas: qtd_por_motorista[nome].to_i } { nome: nome, valor: valor, entregas: qtd_por_motorista[nome].to_i }
end end
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). # `atendidas` já vem filtrado por checkout no período (concluídas + falhadas).
# Agrupa pela DATA REAL (DATE(checkout)) — DATE() sobre timestamp não faz # Agrupa pela DATA REAL (DATE(checkout)) — DATE() sobre timestamp não faz
# conversão de fuso. # conversão de fuso.

View File

@@ -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
}
}

View File

@@ -124,8 +124,20 @@ module Analytics
if ids.empty? if ids.empty?
{} {}
else 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, ConsolidacaoEntrega.where(consolidacao_id: ids,
motorista_nome: registros.map(&:motorista_nome).uniq) 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) .group(:motorista_nome)
.sum(:quantidade) .sum(:quantidade)
end end

View File

@@ -1,13 +1,82 @@
<%# app/views/consolidacoes/_lista.html.erb — visão por CONSOLIDAÇÃO %> <%# app/views/consolidacoes/_lista.html.erb — visão por CONSOLIDAÇÃO %>
<% pode_arquivar = policy(Consolidacao).arquivar? %>
<% if @consolidacoes.any? %> <% if @consolidacoes.any? %>
<%# ── 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. %>
<div data-controller="selecao-lote">
<% 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 %>
<label class="flex items-center gap-2 cursor-pointer min-h-[48px] text-sm text-gray-300 hover:text-white">
<%# Sem `name`: este checkbox comanda os outros, não é dado do form. %>
<input type="checkbox" class="w-5 h-5 accent-orange-500"
data-selecao-lote-target="todos"
data-action="change->selecao-lote#alternarTodos">
Selecionar todas
</label>
<%# Texto inicial NEUTRO de propósito: com o JS fora do ar ele nunca é
substituído, e "Nenhuma selecionada" fixo na tela seria mentira. %>
<span data-selecao-lote-target="contador" class="text-sm text-gray-500">
Marque as que quer arquivar
</span>
<%# 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. %>
<button type="submit" data-selecao-lote-target="botao"
class="ml-auto min-h-[48px] px-4 py-2.5 rounded-lg whitespace-nowrap
text-sm font-semibold border border-[#2a2a2a] text-gray-300
hover:border-orange-500 hover:text-white transition-colors
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-[#2a2a2a]">
<%= icone :arquivadas %> Arquivar selecionadas
</button>
<% end %>
<% end %>
<%# Em celular/monitor pequeno a lista vira carrossel de 10 em 10; em tela <%# Em celular/monitor pequeno a lista vira carrossel de 10 em 10; em tela
grande aparece inteira (ver carrossel_controller.js). %> grande aparece inteira (ver carrossel_controller.js). %>
<div data-controller="carrossel" data-carrossel-tamanho-value="10" class="relative sm:px-16"> <div data-controller="carrossel" data-carrossel-tamanho-value="10" class="relative sm:px-16">
<div class="space-y-3"> <div class="space-y-3">
<% @consolidacoes.each do |c| %> <% @consolidacoes.each do |c| %>
<%# O card deixou de ser UM <a> só: com o link envolvendo tudo, clicar no
checkbox navegava para a consolidação em vez de marcar. Agora a
caixinha é irmã do link, e o link cobre o resto do card. %>
<div data-carrossel-target="item"
class="flex items-stretch bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl
hover:border-orange-500 transition-colors overflow-hidden">
<% if pode_arquivar %>
<%# Alvo de toque grande (diretriz 3): a área clicável é a coluna
inteira, não os 20px do quadradinho. `form=` porque o checkbox
mora FORA do <form> — assim o formulário não precisa envolver a
lista (e o carrossel) inteira. %>
<label class="flex items-center pl-5 pr-3 cursor-pointer shrink-0 hover:bg-white/5"
aria-label="Selecionar <%= c.nome %>">
<input type="checkbox" name="ids[]" value="<%= c.id %>"
form="form-arquivar-lote"
data-selecao-lote-target="item"
data-action="change->selecao-lote#atualizar"
class="w-5 h-5 accent-orange-500">
</label>
<% end %>
<%= link_to consolidacao_path(c), <%= link_to consolidacao_path(c),
data: { carrossel_target: 'item' }, class: "flex-1 min-w-0 block p-5 #{'pl-0' if pode_arquivar}" do %>
class: 'block bg-[#1a1a1a] border border-[#2a2a2a] hover:border-orange-500 rounded-xl p-5 transition-colors' do %>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div> <div>
<div class="flex items-center gap-3 mb-1"> <div class="flex items-center gap-3 mb-1">
@@ -30,12 +99,14 @@
</div> </div>
</div> </div>
<% end %> <% end %>
</div>
<% end %> <% end %>
</div> </div>
<%= render 'shared/carrossel_controles', <%= render 'shared/carrossel_controles',
anterior: 'Consolidações anteriores', proximo: 'Próximas consolidações' %> anterior: 'Consolidações anteriores', proximo: 'Próximas consolidações' %>
</div> </div>
</div>
<% else %> <% else %>
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-12 text-center"> <div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-12 text-center">
<p class="mb-4"><%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %></p> <p class="mb-4"><%= icone :vazio, tamanho: 'w-12 h-12', cor: 'text-gray-600', espaco: false %></p>

View File

@@ -348,9 +348,20 @@
<%# Composição por tipo (rosca) %> <%# Composição por tipo (rosca) %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6"> <div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<h2 class="text-lg font-semibold text-white mb-1 flex items-center gap-2"><%= icone :composicao, espaco: false %> Composição do custo</h2> <h2 class="text-lg font-semibold text-white mb-1 flex items-center gap-2"><%= icone :composicao, espaco: false %> Composição do custo</h2>
<p class="text-gray-400 text-sm mb-4">Por tipo de lançamento</p> <p class="text-gray-400 text-sm mb-4">Por tipo de lançamento, antes dos descontos</p>
<% if @fin_por_tipo.any? %> <% if @fin_por_tipo.any? %>
<div class="relative h-64"><canvas id="grafico-composicao-tipo"></canvas></div> <div class="relative h-64"><canvas id="grafico-composicao-tipo"></canvas></div>
<%# 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? %>
<p class="text-gray-400 text-xs mt-4 pt-3 border-t border-white/5">
<span class="text-gray-300 font-semibold"> <%= moeda(@fin_descontos) %></span>
em descontos, já abatidos no Custo total.
</p>
<% end %>
<% else %> <% else %>
<div class="text-center py-8 text-gray-400"><div class="mb-2"><%= icone :composicao, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %></div><p class="text-sm">Sem dados no período</p></div> <div class="text-center py-8 text-gray-400"><div class="mb-2"><%= icone :composicao, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %></div><p class="text-sm">Sem dados no período</p></div>
<% end %> <% end %>
@@ -916,7 +927,10 @@
Chart.getChart(ctx)?.destroy(); Chart.getChart(ctx)?.destroy();
const labels = <%= raw @fin_por_tipo.map { |h| h[:label] }.to_json %>; 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 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, { new Chart(ctx, {
type: 'doughnut', type: 'doughnut',
data: { labels: labels, datasets: [{ data: valores, backgroundColor: cores, borderColor: '#1a1a1a', borderWidth: 3 }] }, data: { labels: labels, datasets: [{ data: valores, backgroundColor: cores, borderColor: '#1a1a1a', borderWidth: 3 }] },

View File

@@ -50,6 +50,10 @@ Rails.application.routes.draw do
post :avulsa # cria consolidação avulsa a partir de uma NF post :avulsa # cria consolidação avulsa a partir de uma NF
get :arquivadas # Histórico — consolidações arquivadas 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= # PDF do modo de visualização "Por motorista" (/consolidacoes?visao=
# motoristas): o total de cada um no período e as consolidações que # motoristas): o total de cada um no período e as consolidações que
# compõem o valor. # compõem o valor.