Compare commits

...

11 Commits

29 changed files with 1048 additions and 107 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -1136,3 +1136,75 @@ app/views/layouts/_navbar.html.erb # link "📈 Operações"
</details>
<details open>
<summary><strong>🆕 Atualização 30/06/2026 — Mapa, dashboard clicável, validação por veículo e UX da sidebar</strong></summary>
Rodada de melhorias no **Dashboard de Operações**, na tela de **Validação do motorista**
(consolidação) e no **layout do site inteiro**.
### 🗺️ Mapa de Operações
- **Busca no mapa** — campo 🔍 que filtra os pontos por **NF, nome do paciente ou veículo**,
troca para a visão "Pontos", dá zoom nos resultados (busca client-side) e mostra contador /
"Nenhum resultado".
- **Mostra TODAS as entregas** — antes só `completed`; agora **sucesso + insucesso** (óbito e
demais pilares de falha), via `Entrega::STATUS_ATENDIDO`. Pino **laranja = sucesso**, **azul =
insucesso**; o popup mostra o status e o **motivo** (`observation`). A **foto da fachada** é
mantida para todos os casos.
- **Campo veículo** — coluna `vehicle` (de `Entrega`) adicionada à query, ao card e à busca.
- **Modo escuro mais detalhado e com mais contraste** — o tile escuro passou de CARTO `dark_all`
(minimalista) para **OpenStreetMap completo invertido** (`filter: invert(1) hue-rotate(180deg)
brightness(.95) contrast(1.05)` na classe `.tiles-escuro-contraste`): mesmo detalhe de ruas/nomes
do mapa claro, porém escuro e legível. Claro/Satélite ficam intactos.
### 📊 Dashboard de Operações (painéis)
- **Gráficos clicáveis (cross-filter)** — além das tabelas, agora o **donut "Insucessos %"**
(clique em Completas/Falhas → filtra por `status`) e as barras **"Entregas por Dia"** (clique →
filtra por **dia** + **resultado** da barra) refiltram **todo** o dashboard. Novos filtros
`f_resultado` (coluna `status`) e `f_data` (por data de checkout, tratado à parte no serviço via
`@data`). Chips removíveis + "Limpar tudo", igual às tabelas.
- **Falha em azul** — a cor de insucesso mudou de vermelho `#ef4444` para **azul `#3b82f6`** nos
KPIs e gráficos (mais contraste no tema escuro).
- **Hover nos cards de KPI** — ao passar o mouse o card destaca, o número aumenta e aparece o
**% do total** (no card Total, o detalhamento ✅/●/⏳).
- **Respiro no scroll das tabelas** — `pr-2` nos contêineres roláveis (Motoristas / STS) para a
barra de scroll não colar no texto.
### 🚚 Validação do motorista (consolidação)
- **Filtro de veículos multi-seleção** — antes "um carro de cada vez"; agora dá pra **somar 2+
veículos**. A lista, a barra de progresso, o **valor total (R$)** e a **contagem por pilar**
passam a refletir só os veículos selecionados. Chip selecionado fica **laranja** (prioridade
sobre o verde de "fechado") para a seleção ficar sempre visível.
- **Lupa de busca na listagem de notas** — campo 🔍 sticky no topo da lista que filtra os cards
por **NF, local, endereço ou veículo**; "Selecionar todos" passa a marcar **apenas os visíveis**
no filtro.
- **Barra fixa mais compacta** — paddings/margens reduzidos (`p-3 mb-3`) para melhor aproveitamento
em **monitores pequenos**.
- **Ocultar/mostrar o resumo lateral** — botão 📊 que esconde o resumo e dá **largura total** à
lista (`lg:col-span-3 → lg:col-span-4`).
### 🧭 Layout global (site inteiro)
- **Recolher/expandir a sidebar do menu principal** — botão flutuante de seta (desktop) que esconde
o menu e expande o conteúdo (`md:ml-64 → 0`). Estado **lembrado** entre páginas (`localStorage` +
classe `.sidebar-collapsed` no `<html>`, aplicada antes de pintar → sem "piscar").
- **Indicador discreto** — em repouso o botão é uma **alça fina laranja** sempre visível na borda;
ao chegar perto com o mouse (ou foco por teclado) ela **expande** no botão com a seta ◀/▶.
### 📂 Arquivos
```
app/services/analytics/operacao_metricas.rb # veículo, sucesso+insucesso, filtro por dia (@data)
app/controllers/operacoes_dashboard_controller.rb # CROSS f_resultado + filtro f_data
app/views/operacoes_dashboard/index.html.erb # busca/dark map, pinos, gráficos clicáveis, KPIs
app/views/operacoes_dashboard/_painel.html.erb # hover KPIs, cor azul, pr-2 no scroll
app/views/operacoes_dashboard/_mapa.html.erb # campo de busca no mapa
app/controllers/consolidacao_entregas_controller.rb # filtro multi-veículo + resumo por escopo
app/views/consolidacao_entregas/validar.html.erb # chips multi, lupa, barra compacta, toggle resumo
app/javascript/controllers/validacao_controller.js # veiculos[] , busca, toggleResumo
app/views/layouts/application.html.erb # CSS sidebar recolhível + script anti-flash
app/views/layouts/_navbar.html.erb # botão flutuante + alça indicadora
```
> **Sem migration** — segue lendo as tabelas existentes (`db_reem_simplerout_2026` read-only e
> `gade_entregas_*`). ⚠️ Em produção, **reiniciar o Puma** após o deploy (cache de classes/views).
</details>

View File

@@ -88,6 +88,7 @@ class ConsolidacaoEntregasController < ApplicationController
motorista_nome: params[:motorista],
tipo: params[:tipo],
valor_aplicado: valor_classificacao(params[:tipo]),
vehicle: veiculo_do_tracking(params[:tracking_id]), # snapshot p/ fechar por veículo
created_by: current_user.id
)
ativo = true
@@ -132,11 +133,13 @@ class ConsolidacaoEntregasController < ApplicationController
.destroy_all
else
# ADICIONA o pilar às entregas (não remove os demais já marcados).
valor = valor_classificacao(tipo)
valor = valor_classificacao(tipo)
veiculos = veiculos_dos_trackings(tracking_ids) # tracking_id => vehicle (snapshot)
tracking_ids.each do |tid|
ce = @consolidacao.consolidacao_entregas.find_or_initialize_by(tracking_id: tid, tipo: tipo)
ce.assign_attributes(motorista_nome: motorista,
valor_aplicado: valor, created_by: current_user.id)
ce.vehicle = veiculos[tid] if ce.vehicle.blank?
ce.save!
end
end
@@ -246,6 +249,7 @@ class ConsolidacaoEntregasController < ApplicationController
nf: params[:nf],
obs: params[:obs],
valor_extra: valor_extraordinaria_param,
vehicle: params[:vehicle], # veículo escolhido no lançamento (Q3)
user: current_user
)
auditar!(:editar, @consolidacao,
@@ -271,6 +275,7 @@ class ConsolidacaoEntregasController < ApplicationController
motorista = @consolidacao.adicionar_termos!(
motorista: params[:motorista].to_s.strip,
quantidade: params[:quantidade],
vehicle: params[:vehicle], # veículo escolhido no lançamento (Q3)
user: current_user
)
auditar!(:editar, @consolidacao,
@@ -315,12 +320,104 @@ class ConsolidacaoEntregasController < ApplicationController
end
end
# POST /consolidacoes/:consolidacao_id/consolidacao_entregas/fechar_veiculo
# Consolida (fecha) UM veículo do motorista. Exige que o veículo esteja 100%
# classificado. O pagamento continua sendo por motorista (não é pago aqui).
def fechar_veiculo
authorize @consolidacao, :update?
motorista = params[:motorista]
veiculo = params[:vehicle].to_s
return render_veiculo_erro('Veículo/motorista inválido.') unless motorista_na_consolidacao?(motorista) && veiculo.present?
status = veiculos_status(motorista).find { |v| v[:vehicle] == veiculo }
return render_veiculo_erro('Classifique todas as entregas deste veículo antes de consolidar.') if status && !status[:completo]
cv = @consolidacao.consolidacao_veiculos.find_or_create_by!(motorista_nome: motorista, vehicle: veiculo)
cv.fechar!(current_user)
auditar!(:editar, @consolidacao, dados_novos: { fechar_veiculo: veiculo, motorista: motorista })
responder_veiculo(motorista, "Veículo #{veiculo} consolidado.")
end
# POST .../reabrir_veiculo — reabre um veículo já consolidado (reversível).
def reabrir_veiculo
authorize @consolidacao, :update?
motorista = params[:motorista]
cv = @consolidacao.consolidacao_veiculos.find_by(motorista_nome: motorista, vehicle: params[:vehicle].to_s)
return render_veiculo_erro('Veículo não encontrado.') unless cv
cv.reabrir!(current_user)
auditar!(:editar, @consolidacao, dados_novos: { reabrir_veiculo: cv.vehicle, motorista: motorista })
responder_veiculo(motorista, "Veículo #{cv.vehicle} reaberto.")
end
# POST .../arquivar_veiculo — tira um veículo do gate de finalização (reversível).
# As entregas já classificadas dele continuam somando no pagamento do motorista.
def arquivar_veiculo
authorize @consolidacao, :update?
motorista = params[:motorista]
veiculo = params[:vehicle].to_s
return render_veiculo_erro('Veículo/motorista inválido.') unless motorista_na_consolidacao?(motorista) && veiculo.present?
cv = @consolidacao.consolidacao_veiculos.find_or_create_by!(motorista_nome: motorista, vehicle: veiculo)
cv.arquivar!(current_user)
auditar!(:editar, @consolidacao, dados_novos: { arquivar_veiculo: veiculo, motorista: motorista })
responder_veiculo(motorista, "Veículo #{veiculo} arquivado — fora da finalização.")
end
# POST .../desarquivar_veiculo — restaura um veículo arquivado.
def desarquivar_veiculo
authorize @consolidacao, :update?
motorista = params[:motorista]
cv = @consolidacao.consolidacao_veiculos.find_by(motorista_nome: motorista, vehicle: params[:vehicle].to_s)
return render_veiculo_erro('Veículo não encontrado.') unless cv
cv.desarquivar!(current_user)
auditar!(:editar, @consolidacao, dados_novos: { desarquivar_veiculo: cv.vehicle, motorista: motorista })
responder_veiculo(motorista, "Veículo #{cv.vehicle} restaurado.")
end
private
def set_consolidacao
@consolidacao = Consolidacao.find(params[:consolidacao_id])
end
# Resposta padrão das ações de veículo: JSON com o resumo atualizado (barra +
# chips com o novo estado) ou redirect de volta pra tela de validação.
def responder_veiculo(motorista, aviso)
respond_to do |format|
format.json { render json: resumo_json(motorista, vehicles: params[:vehicle_sel]) }
format.html do
redirect_to validar_consolidacao_consolidacao_entregas_path(
@consolidacao, motorista: motorista, vehicle: veiculos_param(params[:vehicle_sel]).presence
), notice: aviso
end
end
end
def render_veiculo_erro(msg)
respond_to do |format|
format.json { render json: { ok: false, erro: msg }, status: :unprocessable_entity }
format.html do
redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: params[:motorista]),
alert: msg
end
end
end
# Veículo (snapshot da rastreio) de UM tracking_id. nil para sintéticos.
def veiculo_do_tracking(tracking_id)
Entrega.da_conta_gade.find_by(tracking_id: tracking_id)&.vehicle
end
# Mapa tracking_id => vehicle para um lote (uma query só).
def veiculos_dos_trackings(ids)
return {} if ids.blank?
Entrega.da_conta_gade.where(tracking_id: ids).pluck(:tracking_id, :vehicle).to_h
end
def bloquear_se_finalizada
return unless @consolidacao.finalizada? && current_user.operador?
redirect_to @consolidacao, alert: 'Consolidação finalizada — apenas admin/gerente podem editar.'
@@ -367,26 +464,9 @@ class ConsolidacaoEntregasController < ApplicationController
# { vehicle:, eligible:, classificadas:, fechado: }. Fonte única do filtro de
# carros e do acompanhamento (chips). 2 queries: elegíveis (com a coluna do
# veículo) e as já classificadas (não-manuais).
# Status por veículo do motorista (fonte única no model: Consolidacao#veiculos_do_motorista).
def veiculos_status(motorista)
return [] unless motorista.present?
rel = Entrega.atendidas.da_conta_gade
.no_periodo_checkout(@consolidacao.data_inicio, @consolidacao.data_fim)
.do_motorista(motorista)
rel = rel.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.present?
rel = rel.da_operacoes(@consolidacao.operacoes) if @consolidacao.operacoes.present?
por_veiculo = rel.pluck(:tracking_id, :vehicle).group_by { |(_id, v)| v.presence || SEM_VEICULO }
classificadas = @consolidacao.consolidacao_entregas
.where(motorista_nome: motorista, manual: false)
.distinct.pluck(:tracking_id).to_set
por_veiculo.map do |veiculo, linhas|
ids = linhas.map(&:first)
feito = ids.count { |id| classificadas.include?(id) }
{ vehicle: veiculo, eligible: ids.size, classificadas: feito,
fechado: ids.size.positive? && feito >= ids.size }
end.sort_by { |v| v[:vehicle] }
@consolidacao.veiculos_do_motorista(motorista)
end
# Valida que a entrega pertence ao motorista informado e ao período/veículo da

View File

@@ -3,7 +3,7 @@ class ConsolidacoesController < ApplicationController
include Auditavel
before_action :set_consolidacao,
only: %i[show edit update destroy finalizar arquivar reativar wizard
only: %i[show edit update destroy finalizar finalizar_parcial arquivar reativar wizard
registrar_pagamento cancelar_pagamento
arquivar_motorista desarquivar_motorista
excluir_motorista adicionar_motorista
@@ -159,6 +159,10 @@ class ConsolidacoesController < ApplicationController
@apontamentos = @consolidacao.consolidacao_entregas.apontamentos
.order(:motorista_nome, :tracking_id)
.group_by { |ce| [ce.motorista_nome, ce.tracking_id] }
# Veículos consolidados por motorista (breakdown informativo no card).
@veiculos_por_motorista = @consolidacao.consolidacao_veiculos.ativos
.order(:vehicle)
.group_by(&:motorista_nome)
end
# GET /consolidacoes/:id/wizard — PASSO 1: selecionar motorista
@@ -177,14 +181,20 @@ class ConsolidacoesController < ApplicationController
.apontamentos
.where(motorista_nome: cm.motorista_nome)
.distinct.count(:tracking_id)
# Status por veículo (breakdown do card): total usados, consolidados (🔒),
# pendentes (não dá p/ consolidar ainda) e prontos (100%, falta consolidar).
vinfo = @consolidacao.veiculos_do_motorista(cm.motorista_nome).reject { |v| v[:arquivado] }
{
registro: cm,
total: total,
classificadas: classificadas,
apontamentos: apontamentos,
# Espelha o gate do servidor (todas_entregas_classificadas?): um motorista
# só com apontamento manual tem 0 entregas elegíveis e já está "completo".
completo: total.zero? || classificadas >= total
registro: cm,
total: total,
classificadas: classificadas,
apontamentos: apontamentos,
veiculos_total: vinfo.size,
veiculos_fechados: vinfo.count { |v| v[:fechado] },
veiculos_prontos: vinfo.count { |v| !v[:fechado] && v[:completo] },
veiculos_pendentes: vinfo.select { |v| !v[:fechado] && !v[:completo] && v[:eligible].positive? }
.map { |v| v[:vehicle] },
completo: @consolidacao.motorista_completo?(cm.motorista_nome)
}
end
end
@@ -199,7 +209,26 @@ class ConsolidacoesController < ApplicationController
redirect_to @consolidacao, notice: 'Consolidação finalizada! Motoristas notificados.'
else
redirect_to wizard_consolidacao_path(@consolidacao),
alert: 'Não é possível finalizar: existem entregas não classificadas.'
alert: 'Não é possível finalizar: ainda há entregas não classificadas. Classifique-as ou arquive o veículo pendente.'
end
end
# POST /consolidacoes/:id/finalizar_parcial
# Atalho "finalizar só os veículos consolidados": arquiva os veículos que ainda
# não dá para consolidar (não 100% classificados) e finaliza a consolidação. Os
# veículos consolidados/prontos seguem no fechamento; os pendentes ficam de fora.
def finalizar_parcial
authorize @consolidacao, :update?
arquivados = @consolidacao.arquivar_veiculos_pendentes!(current_user)
if @consolidacao.finalizar!(current_user)
auditar!(:finalizar, @consolidacao, dados_novos: { finalizar_parcial: true, veiculos_arquivados: arquivados })
NotificacaoService.notificar_finalizacao(@consolidacao)
redirect_to @consolidacao,
notice: "Consolidação finalizada. #{arquivados.size} veículo(s) pendente(s) ficaram de fora. Motoristas notificados."
else
redirect_to wizard_consolidacao_path(@consolidacao),
alert: 'Não foi possível finalizar: ainda há entregas pendentes fora dos veículos arquivados.'
end
end
@@ -320,6 +349,7 @@ class ConsolidacoesController < ApplicationController
nome = cm.motorista_nome
ActiveRecord::Base.transaction do
@consolidacao.consolidacao_entregas.where(motorista_nome: nome).delete_all
@consolidacao.consolidacao_veiculos.where(motorista_nome: nome).delete_all
cm.destroy!
end
# cm já não existe — atualiza o total da consolidação direto (soma dos ativos).

View File

@@ -15,11 +15,12 @@ class OperacoesDashboardController < ApplicationController
@operacoes_agrupadas = Operacao.agrupadas_por_mes
@tabelas_validas = Operacao.nomes_validos
@filtros, @chips = cross_filtros
@data_filtro = parse_data(params[:f_data]) # clique no gráfico "Entregas por Dia"
case @modo
when 'global'
# Global: agrega TODAS as operações dentro da faixa de datas escolhida.
@metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros)
@metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro)
when 'comparar'
# Comparar: cada operação inteira (sem filtro de data — só vale no Global).
@op_a = Operacao.sanitizar([params[:op_a]]).first || @tabelas_validas[0]
@@ -29,25 +30,29 @@ class OperacoesDashboardController < ApplicationController
else
# Operação única: a operação inteira (a data vem dos próprios dados).
@operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
@metricas = montar([@operacao], filtros: @filtros) if @operacao
@metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao
end
end
private
def montar(tabelas, inicio: nil, fim: nil, filtros: {})
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros)
def montar(tabelas, inicio: nil, fim: nil, filtros: {}, data: nil)
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data)
end
# Cross-filter por clique nas tabelas. Cada param vira um filtro de coluna e um
# "chip" removível na tela. Retorna [filtros (coluna=>valor), chips (p/ a view)].
CROSS = {
'f_driver' => { coluna: 'driver', rotulo: 'Motorista' },
'f_sts' => { coluna: 'contact_name', rotulo: 'Unidade' },
'f_status' => { coluna: 'status_gade', rotulo: 'Status' },
'f_obs' => { coluna: 'observation', rotulo: 'Observação' }
'f_driver' => { coluna: 'driver', rotulo: 'Motorista' },
'f_sts' => { coluna: 'contact_name', rotulo: 'Unidade' },
'f_status' => { coluna: 'status_gade', rotulo: 'Status' },
'f_obs' => { coluna: 'observation', rotulo: 'Observação' },
'f_resultado' => { coluna: 'status', rotulo: 'Resultado' }
}.freeze
# Rótulos amigáveis para o filtro de Resultado (clique no donut "Insucessos %").
RESULTADO_LABEL = { 'completed' => 'Completas', 'failed' => 'Falhas' }.freeze
def cross_filtros
filtros = {}
chips = []
@@ -55,7 +60,12 @@ class OperacoesDashboardController < ApplicationController
valor = params[param].to_s.strip
next if valor.empty?
filtros[cfg[:coluna]] = valor
chips << { param: param, valor: valor, rotulo: cfg[:rotulo] }
display = param == 'f_resultado' ? (RESULTADO_LABEL[valor] || valor) : valor
chips << { param: param, valor: valor, display: display, rotulo: cfg[:rotulo] }
end
# Filtro por DIA (clique no gráfico "Entregas por Dia") — tratado à parte (data).
if (dia = parse_data(params[:f_data]))
chips << { param: 'f_data', valor: params[:f_data].to_s, display: dia.strftime('%d/%m/%Y'), rotulo: 'Dia' }
end
[filtros, chips]
end

View File

@@ -7,7 +7,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["modal", "motorista", "nf", "obs", "erro", "tipoCheck", "valorExtra"]
static targets = ["modal", "motorista", "nf", "obs", "erro", "tipoCheck", "valorExtra", "veiculo"]
static values = { apontarManualUrl: String }
abrir() {
@@ -52,7 +52,8 @@ export default class extends Controller {
nf: this.nfTarget.value.trim(),
obs: this.obsTarget.value.trim(),
tipos: tipos,
valor: valor
valor: valor,
vehicle: this.hasVeiculoTarget ? this.veiculoTarget.value : ""
})
})
const data = await resp.json()

View File

@@ -6,7 +6,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["modal", "quantidade", "erro"]
static targets = ["modal", "quantidade", "erro", "veiculo"]
static values = { apontarTermoUrl: String, motorista: String }
abrir() {
@@ -55,7 +55,11 @@ export default class extends Controller {
"Accept": "application/json",
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ motorista: motorista, quantidade: quantidade })
body: JSON.stringify({
motorista: motorista,
quantidade: quantidade,
vehicle: this.hasVeiculoTarget ? this.veiculoTarget.value : ""
})
})
const data = await resp.json()
if (!resp.ok || data.ok === false) {

View File

@@ -7,7 +7,9 @@ import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [
"linha", "checkbox", "selecionarTodos", "busca", "buscaVazia",
"resumo", "listaCol", "btnResumoLabel",
"toggles", "barra", "barraManuais", "percentual", "chipVeiculo",
"consolidarBtn", "consolidarHint",
"contadorClassificadas", "contadorTotal", "contadorManuais", "manuaisInfo",
"qtdNormal", "qtdRetirada", "qtdBonus", "qtdDesconto", "qtdExtraordinaria",
"qtdTermo", "qtdManuais", "valorTotal"
@@ -64,6 +66,17 @@ export default class extends Controller {
}
}
// ── Oculta/mostra o resumo lateral (lista ocupa a largura toda) ──
toggleResumo() {
const escondido = this.resumoTarget.classList.toggle("hidden")
if (this.hasListaColTarget) {
this.listaColTarget.classList.toggle("lg:col-span-4", escondido)
this.listaColTarget.classList.toggle("lg:col-span-3", !escondido)
}
if (this.hasBtnResumoLabelTarget)
this.btnResumoLabelTarget.textContent = escondido ? "Mostrar resumo" : "Ocultar resumo"
}
// ── Busca na listagem de notas (filtra os cards por NF/local/endereço/veículo) ──
filtrarBusca(event) {
const q = (event.currentTarget.value || "").trim().toLowerCase()
@@ -205,6 +218,23 @@ export default class extends Controller {
this.barraTarget.classList.toggle("bg-green-500", pct >= 100)
this.barraTarget.classList.toggle("bg-orange-500", pct < 100)
// Botão "Consolidar este veículo" (só existe quando UM veículo está
// selecionado e ainda não fechado): habilita quando o escopo chega a 100%.
if (this.hasConsolidarBtnTarget) {
const pronto = pct >= 100
this.consolidarBtnTarget.disabled = !pronto
this.consolidarBtnTarget.classList.toggle("bg-green-600", pronto)
this.consolidarBtnTarget.classList.toggle("hover:bg-green-500", pronto)
this.consolidarBtnTarget.classList.toggle("text-white", pronto)
this.consolidarBtnTarget.classList.toggle("cursor-pointer", pronto)
this.consolidarBtnTarget.classList.toggle("bg-[#2a2a2a]", !pronto)
this.consolidarBtnTarget.classList.toggle("text-gray-600", !pronto)
this.consolidarBtnTarget.classList.toggle("cursor-not-allowed", !pronto)
if (this.hasConsolidarHintTarget)
this.consolidarHintTarget.textContent =
pronto ? "Pronto para consolidar." : "Classifique 100% deste veículo para consolidar."
}
if (dados.veiculos_status) this.atualizarChips(dados.veiculos_status)
}
@@ -215,9 +245,12 @@ export default class extends Controller {
const porVeiculo = {}
status.forEach(v => { porVeiculo[v.vehicle] = v })
const VERDE = ["border-green-500", "text-green-400", "bg-green-500/10"]
const LARANJA = ["border-orange-500", "text-orange-400", "bg-orange-500/10"]
const NEUTRO = ["border-[#2a2a2a]", "text-gray-300"]
const LARANJA = ["border-orange-500", "text-orange-400", "bg-orange-500/10"]
const VERDE_FECHADO = ["border-green-500", "text-green-400", "bg-green-500/10"]
const VERDE_PRONTO = ["border-green-600/50", "text-green-400"]
const ARQUIVADO = ["border-dashed", "border-[#3a3a3a]", "text-gray-500"]
const NEUTRO = ["border-[#2a2a2a]", "text-gray-300"]
const TODAS = [...LARANJA, ...VERDE_FECHADO, ...VERDE_PRONTO, ...ARQUIVADO, ...NEUTRO]
this.chipVeiculoTargets.forEach(chip => {
const dados = porVeiculo[chip.dataset.vehicle]
@@ -225,17 +258,23 @@ export default class extends Controller {
const feito = chip.querySelector("[data-chip-feito]")
const total = chip.querySelector("[data-chip-total]")
const check = chip.querySelector("[data-chip-check]")
const lock = chip.querySelector("[data-chip-lock]")
if (feito) feito.textContent = dados.classificadas
if (total) total.textContent = dados.eligible
if (check) check.textContent = dados.fechado ? "✓" : ""
if (check) check.textContent = dados.completo ? "✓" : ""
if (lock) lock.classList.toggle("hidden", !dados.fechado)
// Estado de cor determinístico: laranja (selecionado) > verde (fechado) > neutro.
// Seleção tem prioridade para o usuário sempre enxergar quais carros somam.
chip.classList.remove(...VERDE, ...LARANJA, ...NEUTRO)
// Cor determinística: selecionado(laranja) > consolidado(verde+🔒) >
// pronto(verde) > arquivado(cinza) > neutro.
chip.classList.remove(...TODAS)
if (this.veiculosValue.includes(chip.dataset.vehicle)) {
chip.classList.add(...LARANJA)
} else if (dados.fechado) {
chip.classList.add(...VERDE)
chip.classList.add(...VERDE_FECHADO)
} else if (dados.completo) {
chip.classList.add(...VERDE_PRONTO)
} else if (dados.arquivado) {
chip.classList.add(...ARQUIVADO)
} else {
chip.classList.add(...NEUTRO)
}

View File

@@ -8,6 +8,11 @@ class Consolidacao < ApplicationRecord
has_many :consolidacao_motoristas, dependent: :destroy
has_many :consolidacao_entregas, dependent: :destroy
has_many :consolidacao_veiculos, dependent: :destroy
# Bucket dos lançamentos sem veículo (termo/apontamento manual legado). Mesmo
# sentinel do controller e de ConsolidacaoVeiculo.
SEM_VEICULO = ConsolidacaoVeiculo::SEM_VEICULO
# ── Enums ───────────────────────────────────────────────────
enum status: { rascunho: 0, finalizada: 1, arquivada: 2 }
@@ -111,21 +116,34 @@ class Consolidacao < ApplicationRecord
def finalizar!(user)
return false unless todas_entregas_classificadas?
update!(
status: :finalizada,
finalizado_por: user.id,
finalizado_em: Time.current
)
transaction do
# Finalizar a consolidação fecha (consolida) os veículos que ainda estavam
# abertos — mantém o estado por veículo coerente com o todo finalizado.
consolidacao_veiculos.ativos.abertos.update_all(
fechado_em: Time.current, fechado_por: user.id, updated_at: Time.current
)
update!(
status: :finalizada,
finalizado_por: user.id,
finalizado_em: Time.current
)
end
end
# Só está completa quando CADA motorista tem todas as suas entregas elegíveis
# classificadas. (Antes era uma soma agregada com >=, que mascarava motoristas
# pendentes quando outro tinha entregas a mais.)
# Só está completa quando CADA motorista ativo está completo (nada por
# classificar, descontados os veículos arquivados). Fechar veículo é um passo
# independente; ao finalizar, os veículos abertos são consolidados juntos.
def todas_entregas_classificadas?
consolidacao_motoristas.ativos.all? do |cm|
total = entregas_elegiveis_count(cm.motorista_nome)
total.zero? || classificadas_count(cm.motorista_nome) >= total
end
consolidacao_motoristas.ativos.all? { |cm| motorista_completo?(cm.motorista_nome) }
end
# Completude de UM motorista: não sobra entrega elegível por classificar. Os
# veículos ARQUIVADOS saem da conta (suas entregas deixam de ser exigidas),
# permitindo finalizar deixando um carro problemático de fora. Fechar um veículo
# é um passo independente (lock) — a finalização em si fecha os que restarem.
def motorista_completo?(motorista)
total = entregas_elegiveis_count(motorista) - eligible_veiculos_arquivados(motorista)
total.zero? || classificadas_count(motorista) >= total
end
def percentual_classificado
@@ -153,6 +171,28 @@ class Consolidacao < ApplicationRecord
)
end
# Elegíveis de UM veículo específico do motorista (respeita filtros da
# consolidação). O bucket "(sem veículo)" casa as entregas com vehicle NULL/vazio.
def entregas_elegiveis_count_veiculo(motorista, vehicle)
rel = Entrega.atendidas.da_conta_gade
.no_periodo_checkout(data_inicio, data_fim)
.do_motorista(motorista)
rel = rel.da_veiculo(vehicle_ids) if vehicle_ids.present?
rel = rel.da_operacoes(operacoes) if operacoes.present?
rel = vehicle == SEM_VEICULO ? rel.where(vehicle: [nil, '']) : rel.where(vehicle: vehicle)
rel.count
end
# Total de elegíveis dos veículos ARQUIVADOS do motorista — descontado do gate
# para que arquivar um carro problemático libere a finalização. Só toca a base
# externa quando existe algum veículo arquivado (mantém os testes isolados).
def eligible_veiculos_arquivados(motorista)
arquivados = consolidacao_veiculos.arquivados.where(motorista_nome: motorista)
return 0 if arquivados.empty?
arquivados.sum { |cv| entregas_elegiveis_count_veiculo(motorista, cv.vehicle) }
end
# Rótulos amigáveis das operações vinculadas (para exibição).
def operacoes_label
Array(operacoes).map { |t| Operacao.label(t) }
@@ -168,6 +208,54 @@ class Consolidacao < ApplicationRecord
.distinct.count(:tracking_id)
end
# Status por veículo do motorista: array de { vehicle, eligible, classificadas,
# completo, fechado, arquivado }. `completo` = 100% classificado (pode consolidar);
# `fechado` = veículo consolidado (🔒); `arquivado` = fora do gate. Une os veículos
# com entregas elegíveis (rastreio) com os que só têm linha em consolidacao_veiculos
# (arquivados / buckets manuais). Fonte única do filtro de carros e do wizard.
def veiculos_do_motorista(motorista)
return [] unless motorista.present?
rel = Entrega.atendidas.da_conta_gade
.no_periodo_checkout(data_inicio, data_fim)
.do_motorista(motorista)
rel = rel.da_veiculo(vehicle_ids) if vehicle_ids.present?
rel = rel.da_operacoes(operacoes) if operacoes.present?
por_veiculo = rel.pluck(:tracking_id, :vehicle).group_by { |(_id, v)| v.presence || SEM_VEICULO }
classificadas = consolidacao_entregas.where(motorista_nome: motorista, manual: false)
.distinct.pluck(:tracking_id).to_set
estados = consolidacao_veiculos.where(motorista_nome: motorista).index_by(&:vehicle)
(por_veiculo.keys + estados.keys).uniq.map do |veiculo|
ids = (por_veiculo[veiculo] || []).map(&:first)
feito = ids.count { |id| classificadas.include?(id) }
cv = estados[veiculo]
{ vehicle: veiculo, eligible: ids.size, classificadas: feito,
completo: ids.size.positive? && feito >= ids.size,
fechado: cv&.fechado? || false,
arquivado: cv&.arquivado? || false }
end.sort_by { |v| v[:vehicle] }
end
# Arquiva os veículos PENDENTES (com entregas elegíveis mas não 100% classificadas)
# de todos os motoristas ativos, deixando-os de fora da finalização. Usado pelo
# atalho "arquivar pendentes e finalizar" — os prontos/consolidados seguem no todo.
# Retorna a lista "Motorista · Veículo" arquivada.
def arquivar_veiculos_pendentes!(user)
arquivados = []
consolidacao_motoristas.ativos.each do |cm|
veiculos_do_motorista(cm.motorista_nome).each do |v|
next if v[:fechado] || v[:arquivado] || v[:completo] || v[:eligible].zero?
cv = consolidacao_veiculos.find_or_create_by!(motorista_nome: cm.motorista_nome, vehicle: v[:vehicle])
cv.arquivar!(user)
arquivados << "#{cm.motorista_nome} · #{v[:vehicle]}"
end
end
arquivados
end
# ── Apontamento manual ──────────────────────────────────────
# Adiciona uma entrega "por fora" (NF que chegou depois) com um ou mais pilares
# (tipos). O motorista vem da própria entrega e é incluído na consolidação se
@@ -190,6 +278,7 @@ class Consolidacao < ApplicationRecord
motorista_nome: motorista,
valor_aplicado: ConsolidacaoEntrega.valor_para(tipo),
manual: true,
vehicle: entrega.try(:vehicle).presence, # snapshot do veículo da rastreio
created_by: user&.id
)
ce.save!
@@ -208,7 +297,7 @@ class Consolidacao < ApplicationRecord
# O motorista vem digitado (não de uma Entrega) e é incluído se ainda não
# estiver. tracking_id é sintético (não há Entrega), e a NF/observação ficam
# guardadas em nf_manual/obs_manual.
def adicionar_apontamento_manual!(motorista:, tipos:, user:, nf: nil, obs: nil, valor_extra: nil)
def adicionar_apontamento_manual!(motorista:, tipos:, user:, nf: nil, obs: nil, valor_extra: nil, vehicle: nil)
tipos_validos = Array(tipos).map(&:to_s).select { |t| ConsolidacaoEntrega.tipos.key?(t) }.uniq
raise ArgumentError, 'Selecione ao menos uma classificação.' if tipos_validos.empty?
@@ -232,6 +321,7 @@ class Consolidacao < ApplicationRecord
motorista_nome: nome,
valor_aplicado: valor,
manual: true,
vehicle: vehicle.presence, # veículo escolhido no lançamento (Q3)
nf_manual: nf.presence,
obs_manual: obs.presence,
created_by: user&.id
@@ -249,7 +339,7 @@ class Consolidacao < ApplicationRecord
# para atrelar (só motorista + quantidade, a um preço fixo configurável). Fica
# numa única linha (manual: true): quantidade guarda o N e valor_aplicado guarda
# o total do lote, então recalcular_motorista! e demais somas seguem inalteradas.
def adicionar_termos!(motorista:, quantidade:, user:)
def adicionar_termos!(motorista:, quantidade:, user:, vehicle: nil)
nome = motorista.to_s.strip
raise ArgumentError, 'Informe o motorista do termo.' if nome.blank?
@@ -266,6 +356,7 @@ class Consolidacao < ApplicationRecord
quantidade: qtd,
valor_aplicado: qtd * ConsolidacaoEntrega.valor_para('termo'), # total do lote
manual: true,
vehicle: vehicle.presence, # veículo escolhido no lançamento (Q3)
created_by: user&.id
)
@@ -286,6 +377,27 @@ class Consolidacao < ApplicationRecord
end
cm.update_column(:valor_total, total)
update_column(:valor_total, consolidacao_motoristas.ativos.sum(:valor_total))
sincronizar_veiculos!(nome)
end
# Mantém as linhas de consolidacao_veiculos do motorista em dia a partir dos
# pilares já classificados (100% DB — não toca a base externa). Cria/atualiza uma
# linha por veículo presente nas classificações e remove as que ficaram sem
# pilares e ainda não foram fechadas/arquivadas. Veículos elegíveis mas 100% sem
# classificar não geram linha aqui — o gate já os pega por classificadas < total.
def sincronizar_veiculos!(nome)
entregas = consolidacao_entregas.where(motorista_nome: nome)
presentes = entregas.pluck(:vehicle).map { |v| v.presence || SEM_VEICULO }.uniq
presentes.each do |veic|
cv = consolidacao_veiculos.find_or_initialize_by(motorista_nome: nome, vehicle: veic)
cv.save! # before_save :recalcular_valor atualiza o valor_total do veículo
end
consolidacao_veiculos.where(motorista_nome: nome)
.where(fechado_em: nil, arquivado_em: nil)
.where.not(vehicle: presentes)
.destroy_all
end
private

View File

@@ -0,0 +1,71 @@
# app/models/consolidacao_veiculo.rb
# Unidade de FECHAMENTO por veículo dentro de uma consolidação. Espelha os padrões
# de ConsolidacaoMotorista (arquivamento reversível + valor_total recalculado dos
# pilares), mas o que ela controla é o estado "fechado/consolidado" de cada carro.
# O PAGAMENTO continua no motorista (ConsolidacaoMotorista) — ver [[consolidacao]].
class ConsolidacaoVeiculo < ApplicationRecord
# Nome da tabela fixado (mesmo motivo dos outros: inflector pt-BR).
self.table_name = 'consolidacao_veiculos'
# Bucket dos lançamentos sem veículo (termo/apontamento manual legado). Igual ao
# sentinel usado no controller e na migração — NULL quebraria o índice único.
SEM_VEICULO = '(sem veículo)'
belongs_to :consolidacao
belongs_to :fechador, class_name: 'User', foreign_key: :fechado_por, optional: true
belongs_to :arquivador, class_name: 'User', foreign_key: :arquivado_por, optional: true
validates :motorista_nome, presence: true
validates :vehicle, presence: true
validates :vehicle, uniqueness: { scope: %i[consolidacao_id motorista_nome] }
before_save :recalcular_valor
# ── Fechamento (consolidação do veículo, reversível) ────────
scope :fechados, -> { where.not(fechado_em: nil) }
scope :abertos, -> { where(fechado_em: nil) }
# ── Arquivamento (reversível) — sai do gate, mantém os dados ─
scope :ativos, -> { where(arquivado_em: nil) }
scope :arquivados, -> { where.not(arquivado_em: nil) }
def fechado?
fechado_em.present?
end
def arquivado?
arquivado_em.present?
end
# update_columns: não dispara before_save (o valor já está correto no fechamento).
def fechar!(user)
update_columns(fechado_em: Time.current, fechado_por: user&.id, updated_at: Time.current)
end
def reabrir!(_user)
update_columns(fechado_em: nil, fechado_por: nil, updated_at: Time.current)
end
def arquivar!(user)
update_columns(arquivado_em: Time.current, arquivado_por: user&.id, updated_at: Time.current)
end
def desarquivar!(_user)
update_columns(arquivado_em: nil, arquivado_por: nil, updated_at: Time.current)
end
# Soma dos pilares deste (motorista + veículo). Mesma regra do ConsolidacaoMotorista:
# o valor_aplicado capturado na classificação é a fonte da verdade e o desconto
# subtrai. O bucket "(sem veículo)" casa os pilares com vehicle NULL/vazio.
def recalcular_valor
self.valor_total = escopo_entregas
.sum { |e| e.desconto? ? -e.valor_aplicado : e.valor_aplicado }
end
private
def escopo_entregas
rel = consolidacao.consolidacao_entregas.where(motorista_nome: motorista_nome)
vehicle == SEM_VEICULO ? rel.where(vehicle: [nil, '']) : rel.where(vehicle: vehicle)
end
end

View File

@@ -37,17 +37,28 @@ class Operacao
nomes_validos.include?(nome.to_s)
end
# Rótulo amigável: tira o prefixo e formata.
# Rótulo amigável: tira o prefixo e formata em CAIXA ALTA (para não parecer o
# nome cru da tabela do banco).
def label(tabela)
tabela.to_s.delete_prefix(PREFIXO).tr('_', ' ').strip.titleize
tabela.to_s.delete_prefix(PREFIXO).tr('_', ' ').strip.upcase
end
# Abreviações de mês usadas nos nomes das tabelas (gade_entregas_emad_fev_2026).
# Meses usados nos nomes das tabelas (gade_entregas_emad_fev_2026). Aceita a
# abreviação (fev) E o nome por extenso (fevereiro) — o cliente usa os dois
# (ex.: "..._ubs_norte_maio_2026"), então ambos precisam cair no mês certo.
MESES = {
'jan' => [1, 'Janeiro'], 'fev' => [2, 'Fevereiro'], 'mar' => [3, 'Março'],
'abr' => [4, 'Abril'], 'mai' => [5, 'Maio'], 'jun' => [6, 'Junho'],
'jul' => [7, 'Julho'], 'ago' => [8, 'Agosto'], 'set' => [9, 'Setembro'],
'out' => [10, 'Outubro'], 'nov' => [11, 'Novembro'], 'dez' => [12, 'Dezembro']
'jan' => [1, 'Janeiro'], 'janeiro' => [1, 'Janeiro'],
'fev' => [2, 'Fevereiro'], 'fevereiro' => [2, 'Fevereiro'],
'mar' => [3, 'Março'], 'marco' => [3, 'Março'], 'março' => [3, 'Março'],
'abr' => [4, 'Abril'], 'abril' => [4, 'Abril'],
'mai' => [5, 'Maio'], 'maio' => [5, 'Maio'],
'jun' => [6, 'Junho'], 'junho' => [6, 'Junho'],
'jul' => [7, 'Julho'], 'julho' => [7, 'Julho'],
'ago' => [8, 'Agosto'], 'agosto' => [8, 'Agosto'],
'set' => [9, 'Setembro'], 'setembro' => [9, 'Setembro'],
'out' => [10, 'Outubro'], 'outubro' => [10, 'Outubro'],
'nov' => [11, 'Novembro'], 'novembro' => [11, 'Novembro'],
'dez' => [12, 'Dezembro'], 'dezembro' => [12, 'Dezembro']
}.freeze
# Agrupa as operações por mês/ano (extraído do nome da tabela), do mais recente
@@ -99,7 +110,7 @@ class Operacao
resto = (tokens[0...idx] + tokens[(ano ? idx + 2 : idx + 1)..]).join(' ').strip
{
titulo: ano ? "#{nome} #{ano}" : nome,
item: resto.presence&.titleize || nome,
item: (resto.presence || nome).to_s.upcase,
# mais recente primeiro; sem ano vai pro fim do bloco do mês
ordem: [-(ano || 0), -num]
}

View File

@@ -24,11 +24,12 @@ module Analytics
# filtros: Hash de cross-filter (coluna => valor) aplicado em memória — clicar
# numa célula da tabela (motorista/STS/status/observação) refiltra TODO o
# dashboard por aquele valor. Ex.: { 'driver' => 'Carlos' }.
def initialize(tabelas:, inicio: nil, fim: nil, filtros: {})
def initialize(tabelas:, inicio: nil, fim: nil, filtros: {}, data: nil)
@tabelas = Operacao.sanitizar(Array(tabelas))
@inicio = inicio&.to_date
@fim = fim&.to_date
@filtros = (filtros || {}).reject { |_, v| v.to_s.strip.empty? }
@data = data&.to_date # filtro por DIA (clique no gráfico "Entregas por Dia")
end
def operacoes_label
@@ -37,11 +38,11 @@ module Analytics
# Linhas após o cross-filter — base de TODAS as agregações/KPIs.
def linhas
@linhas ||= if @filtros.empty?
registros
else
registros.select { |r| @filtros.all? { |col, val| r[col].to_s == val.to_s } }
end
@linhas ||= begin
base = @filtros.empty? ? registros
: registros.select { |r| @filtros.all? { |col, val| r[col].to_s == val.to_s } }
@data ? base.select { |r| data_de(r['checkout']) == @data } : base
end
end
# Taxas (úteis no comparativo).

View File

@@ -46,13 +46,15 @@
</div>
</div>
<%# Fechar por veículo — filtra a tela por carro e mostra quais já estão fechados.
<%# Fechar por veículo — filtra a tela por carro, mostra o estado (pronto/
consolidado/arquivado) e permite CONSOLIDAR o veículo selecionado.
Só aparece quando o motorista usou mais de um veículo no período. %>
<% pode_fechar_veiculo = !(@consolidacao.finalizada? && current_user.operador?) %>
<% if @veiculos_status.size > 1 %>
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-3 mb-3">
<div class="flex items-center gap-2 mb-2">
<div class="flex items-center gap-2 mb-2 flex-wrap">
<span class="text-gray-400 text-sm font-semibold">🚗 Fechar por veículo:</span>
<span class="text-gray-600 text-xs">clique para filtrar/somar um ou vários carros (clique de novo p/ tirar)</span>
<span class="text-gray-600 text-xs">clique no nome para filtrar; consolide o carro quando estiver 100% (🔒)</span>
</div>
<div class="flex flex-wrap gap-2">
<%# Chip "Todos" — limpa a seleção (mostra todos os carros) %>
@@ -66,18 +68,68 @@
<% selecionado = @veiculos_sel.include?(v[:vehicle]) %>
<%# Alterna este veículo dentro da seleção atual (multi-seleção) %>
<% nova_sel = selecionado ? (@veiculos_sel - [v[:vehicle]]) : (@veiculos_sel + [v[:vehicle]]) %>
<%# Cor determinística: selecionado(laranja) > consolidado(verde+🔒) >
pronto(verde) > arquivado(cinza tracejado) > neutro. %>
<% cor = if selecionado then 'border-orange-500 text-orange-400 bg-orange-500/10'
elsif v[:fechado] then 'border-green-500 text-green-400 bg-green-500/10'
elsif v[:completo] then 'border-green-600/50 text-green-400'
elsif v[:arquivado] then 'border-dashed border-[#3a3a3a] text-gray-500'
else 'border-[#2a2a2a] text-gray-300 hover:border-orange-500' end %>
<%= link_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista, vehicle: nova_sel.presence),
data: { 'validacao-target': 'chipVeiculo', vehicle: v[:vehicle], eligible: v[:eligible] },
class: "px-3 py-2 rounded-lg text-sm font-semibold border min-h-[40px] flex items-center gap-2 transition-colors
#{selecionado ? 'border-orange-500 text-orange-400 bg-orange-500/10' : (v[:fechado] ? 'border-green-500 text-green-400 bg-green-500/10' : 'border-[#2a2a2a] text-gray-300 hover:border-orange-500')}" do %>
class: "px-3 py-2 rounded-lg text-sm font-semibold border min-h-[40px] flex items-center gap-2 transition-colors #{cor}" do %>
<span data-chip-lock class="<%= 'hidden' unless v[:fechado] %>">🔒</span>
<span><%= v[:vehicle] %></span>
<span class="text-xs <%= v[:fechado] ? 'text-green-400' : 'text-gray-500' %>">
<span class="text-xs <%= v[:completo] ? 'text-green-400' : 'text-gray-500' %>">
<span data-chip-feito><%= v[:classificadas] %></span>/<span data-chip-total><%= v[:eligible] %></span>
<span data-chip-check><%= '✓' if v[:fechado] %></span>
<span data-chip-check><%= '✓' if v[:completo] %></span>
</span>
<% end %>
<% end %>
</div>
<%# Ação de consolidação — opera sobre o veículo único selecionado. %>
<% if pode_fechar_veiculo %>
<% sel_unico = @veiculos_sel.size == 1 ? @veiculos_sel.first : nil %>
<% status_unico = sel_unico && @veiculos_status.find { |v| v[:vehicle] == sel_unico } %>
<div class="mt-3 pt-3 border-t border-[#2a2a2a]">
<% if status_unico.nil? %>
<p class="text-gray-500 text-xs">Selecione <strong>um</strong> veículo (clique no nome) para consolidá-lo separadamente.</p>
<% elsif status_unico[:fechado] %>
<div class="flex items-center gap-2 flex-wrap">
<span class="text-green-400 text-sm font-semibold">🔒 <%= sel_unico %> consolidado.</span>
<%= button_to '🔓 Reabrir veículo',
reabrir_veiculo_consolidacao_consolidacao_entregas_path(@consolidacao),
method: :post,
params: { motorista: @motorista, vehicle: sel_unico, vehicle_sel: @veiculos_sel },
data: { turbo_confirm: "Reabrir o veículo #{sel_unico} para edição?" },
class: 'text-gray-400 hover:text-white border border-[#2a2a2a] hover:border-orange-500 px-3 py-2 rounded-lg text-xs min-h-[40px]' %>
</div>
<% else %>
<div class="flex items-center gap-2 flex-wrap">
<%# Habilita só quando o carro está 100% classificado; o JS liga/desliga
conforme a barra do escopo selecionado chega (ou não) a 100%. %>
<%= button_to '🔒 Consolidar este veículo',
fechar_veiculo_consolidacao_consolidacao_entregas_path(@consolidacao),
method: :post,
params: { motorista: @motorista, vehicle: sel_unico, vehicle_sel: @veiculos_sel },
disabled: !status_unico[:completo],
data: { 'validacao-target': 'consolidarBtn',
turbo_confirm: "Consolidar o veículo #{sel_unico}? Você poderá reabrir depois." },
class: "font-bold px-4 py-2 rounded-lg text-xs min-h-[40px] #{status_unico[:completo] ? 'bg-green-600 hover:bg-green-500 text-white cursor-pointer' : 'bg-[#2a2a2a] text-gray-600 cursor-not-allowed'}" %>
<%= button_to '🗄️ Arquivar veículo',
arquivar_veiculo_consolidacao_consolidacao_entregas_path(@consolidacao),
method: :post,
params: { motorista: @motorista, vehicle: sel_unico, vehicle_sel: @veiculos_sel },
data: { turbo_confirm: "Arquivar o veículo #{sel_unico}? Ele sai da finalização (as entregas já classificadas continuam no pagamento)." },
class: 'text-gray-400 hover:text-white border border-[#2a2a2a] hover:border-red-500/50 px-3 py-2 rounded-lg text-xs min-h-[40px]' %>
<span class="text-gray-500 text-xs" data-validacao-target="consolidarHint">
<%= status_unico[:completo] ? 'Pronto para consolidar.' : 'Classifique 100% deste veículo para consolidar.' %>
</span>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
@@ -104,7 +156,12 @@
class="bg-purple-600 text-white font-bold px-3 py-2 rounded-lg text-xs min-h-[44px]">Extra</button>
</div>
<div class="ml-auto">
<div class="ml-auto flex items-center gap-2">
<%# Oculta/mostra o resumo lateral (mais largura para a lista) %>
<button type="button" data-action="validacao#toggleResumo"
class="text-gray-400 hover:text-white text-sm border border-[#2a2a2a] hover:border-orange-500 px-3 py-2 rounded-lg whitespace-nowrap">
📊 <span data-validacao-target="btnResumoLabel">Ocultar resumo</span>
</button>
<%= link_to params[:apenas_pendentes] == '1' ? 'Mostrar todas' : 'Apenas não classificadas',
validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista, vehicle: @veiculos_sel.presence,
apenas_pendentes: params[:apenas_pendentes] == '1' ? nil : '1'),
@@ -167,6 +224,16 @@
<% end %>
</datalist>
<%# Veículo (Q3) — a qual carro este lançamento pertence (soma no balde dele) %>
<label class="block text-gray-400 text-sm mb-1">Veículo <span class="text-gray-600">(opcional)</span></label>
<select data-apontamento-manual-target="veiculo"
class="w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-purple-500 focus:outline-none mb-3">
<option value="">— sem veículo —</option>
<% @veiculos_status.map { |v| v[:vehicle] }.reject { |x| x == '(sem veículo)' }.each do |veic| %>
<option value="<%= veic %>"><%= veic %></option>
<% end %>
</select>
<%# NF + observação (texto livre) %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<div>
@@ -237,6 +304,16 @@
</div>
</div>
<%# Veículo (Q3) — a qual carro estes termos pertencem %>
<label class="block text-gray-400 text-sm mb-1">Veículo <span class="text-gray-600">(opcional)</span></label>
<select data-apontamento-termo-target="veiculo"
class="w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-blue-500 focus:outline-none mb-4">
<option value="">— sem veículo —</option>
<% @veiculos_status.map { |v| v[:vehicle] }.reject { |x| x == '(sem veículo)' }.each do |veic| %>
<option value="<%= veic %>"><%= veic %></option>
<% end %>
</select>
<%# Stepper de quantidade %>
<label class="block text-gray-400 text-sm mb-2">Quantidade de termos</label>
<div class="flex items-center justify-center gap-3 mb-5">
@@ -262,7 +339,7 @@
<div class="grid grid-cols-1 lg:grid-cols-4 gap-3 lg:flex-1 lg:min-h-0">
<%# ── Lista de entregas (3 colunas) — área rolável ──── %>
<div class="lg:col-span-3 space-y-2 lg:overflow-y-auto lg:pr-1">
<div class="lg:col-span-3 space-y-2 lg:overflow-y-auto lg:pr-1" data-validacao-target="listaCol">
<%# 🔍 Busca na listagem — filtra os cards por NF, local, endereço ou veículo %>
<div class="sticky top-0 z-20 bg-[#0a0a0a] pb-2">
<div class="relative">
@@ -331,7 +408,7 @@
</div>
<%# ── Resumo lateral (1 coluna) — rola junto se precisar ─────── %>
<div class="lg:col-span-1 lg:overflow-y-auto">
<div class="lg:col-span-1 lg:overflow-y-auto" data-validacao-target="resumo">
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5 space-y-4">
<h3 class="text-white font-bold">📊 Resumo</h3>

View File

@@ -91,6 +91,18 @@
<% end %>
</div>
<p class="text-orange-500 font-black text-xl"><%= moeda(cm.valor_total) %></p>
<%# Breakdown por veículo (consolidados 🔒 / abertos). Informativo — o
pagamento continua sendo do motorista inteiro. %>
<% veiculos = @veiculos_por_motorista[cm.motorista_nome] %>
<% if veiculos.present? %>
<div class="flex flex-wrap gap-1.5 mt-1">
<% veiculos.each do |cv| %>
<span class="text-xs px-2 py-0.5 rounded border <%= cv.fechado? ? 'border-green-500/50 text-green-400' : 'border-[#2a2a2a] text-gray-400' %>">
<%= cv.fechado? ? '🔒' : '○' %> <%= cv.vehicle %> · <%= moeda(cv.valor_total) %>
</span>
<% end %>
</div>
<% end %>
<% if cm.pago? %>
<p class="text-green-400 text-xs mt-1">
💰 Pago em <%= l cm.pago_em, format: :short %>

View File

@@ -187,6 +187,16 @@
· <span class="text-orange-400"> <%= m[:apontamentos] %> apontamento<%= 's' if m[:apontamentos] > 1 %></span>
<% end %>
</p>
<% if m[:veiculos_total] > 0 %>
<p class="text-xs mt-0.5">
<span class="<%= m[:veiculos_fechados] == m[:veiculos_total] ? 'text-green-400' : 'text-gray-400' %>">
🚗 <%= m[:veiculos_fechados] %>/<%= m[:veiculos_total] %> veículo<%= 's' if m[:veiculos_total] > 1 %> consolidado<%= 's' if m[:veiculos_fechados] != 1 %>
</span>
<% if m[:veiculos_pendentes].any? %>
<span class="text-orange-400">· pendente<%= 's' if m[:veiculos_pendentes].size > 1 %>: <%= m[:veiculos_pendentes].join(', ') %></span>
<% end %>
</p>
<% end %>
</div>
</div>
<div class="text-right <%= 'pr-10' if pode_arquivar_motorista %>">
@@ -248,23 +258,39 @@
</div>
<% end %>
<%# Finalizar — habilita se tudo classificado %>
<% tudo_completo = @motoristas.all? { |m| m[:completo] } && @motoristas.any? %>
<div class="mt-8 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5 flex items-center justify-between">
<div>
<p class="text-white font-bold">Total da consolidação</p>
<p class="text-orange-500 font-black text-3xl"><%= moeda(@consolidacao.valor_total) %></p>
<%# Finalizar — habilita quando tudo classificado. Se sobram veículos pendentes
mas já há veículos consolidados, oferece "arquivar pendentes e finalizar". %>
<% tudo_completo = @motoristas.all? { |m| m[:completo] } && @motoristas.any? %>
<% tem_consolidados = @motoristas.any? { |m| m[:veiculos_fechados] > 0 || m[:veiculos_prontos] > 0 } %>
<% tem_pendentes = @motoristas.any? { |m| m[:veiculos_pendentes].any? } %>
<div class="mt-8 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div>
<p class="text-white font-bold">Total da consolidação</p>
<p class="text-orange-500 font-black text-3xl"><%= moeda(@consolidacao.valor_total) %></p>
</div>
<% if @consolidacao.rascunho? %>
<div class="flex flex-col sm:flex-row gap-2">
<% if !tudo_completo && tem_consolidados && tem_pendentes %>
<%= button_to '🔒 Arquivar pendentes e finalizar',
finalizar_parcial_consolidacao_path(@consolidacao),
method: :post,
data: { turbo_confirm: 'Arquivar TODOS os veículos ainda não classificados 100% e finalizar? Eles ficam de fora desta consolidação (você resolve depois). Os veículos consolidados são pagos normalmente.' },
class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3.5 rounded-lg min-h-[48px] flex items-center justify-center text-center' %>
<% end %>
<%= button_to 'Finalizar consolidação ✓', finalizar_consolidacao_path(@consolidacao),
method: :post,
disabled: !tudo_completo,
data: { turbo_confirm: 'Finalizar? Após finalizada, a consolidação não poderá ser editada por operadores.' },
class: "font-bold px-6 py-3.5 rounded-lg min-h-[48px] transition-colors
#{tudo_completo ? 'bg-green-600 hover:bg-green-500 text-white cursor-pointer' : 'bg-[#2a2a2a] text-gray-600 cursor-not-allowed'}" %>
</div>
<% end %>
</div>
<% if @consolidacao.rascunho? %>
<%= button_to 'Finalizar consolidação ✓', finalizar_consolidacao_path(@consolidacao),
method: :post,
disabled: !tudo_completo,
data: { turbo_confirm: 'Finalizar? Após finalizada, a consolidação não poderá ser editada por operadores.' },
class: "font-bold px-6 py-3.5 rounded-lg min-h-[48px] transition-colors
#{tudo_completo ? 'bg-green-600 hover:bg-green-500 text-white cursor-pointer' : 'bg-[#2a2a2a] text-gray-600 cursor-not-allowed'}" %>
<% end %>
</div>
<% unless tudo_completo %>
<p class="text-gray-500 text-sm mt-2 text-center">⚠️ Classifique todas as entregas de todos os motoristas para poder finalizar.</p>
<p class="text-gray-500 text-sm mt-2 text-center">
⚠️ Classifique todas as entregas de todos os motoristas para finalizar tudo<% if tem_consolidados && tem_pendentes %> — ou use <strong>“Arquivar pendentes e finalizar”</strong> para fechar só os veículos já consolidados.<% end %>
</p>
<% end %>
</div>

View File

@@ -71,6 +71,21 @@
</div>
</aside>
<%# Botão flutuante (desktop) — esconde/mostra a sidebar do site inteiro.
Em repouso mostra uma ALÇA fina laranja (indicador de que dá p/ recolher);
ao chegar perto com o mouse, ela expande no botão com a seta. %>
<div id="sidebar-edge" class="hidden md:flex group fixed top-1/2 -translate-y-1/2 z-40 h-32 w-12 items-center">
<button id="sidebar-toggle-btn" type="button" onclick="toggleSidebarCollapse()"
class="flex items-center justify-center rounded-r-lg shadow-lg transition-all duration-200
w-1.5 h-14 bg-orange-500/60 text-transparent
group-hover:w-7 group-hover:h-16 group-hover:bg-[#1a1a1a] group-hover:text-orange-500
group-hover:border group-hover:border-orange-500/70
focus:w-7 focus:h-16 focus:bg-[#1a1a1a] focus:text-orange-500"
title="Esconder/mostrar menu" aria-label="Esconder ou mostrar o menu">
<span id="sidebar-toggle-icon" class="text-lg leading-none">◀</span>
</button>
</div>
<%# Topbar mobile %>
<header class="md:hidden fixed top-0 left-0 right-0 z-30 bg-[#111111] border-b border-[#2a2a2a] px-4 py-3 flex items-center justify-between">
<button onclick="toggleSidebar()"
@@ -86,10 +101,24 @@
<div class="h-14 md:hidden"></div>
<script>
// Mobile: abre/fecha a sidebar por cima do conteúdo (hamburguer).
function toggleSidebar() {
const s = document.getElementById('sidebar');
const o = document.getElementById('sidebar-overlay');
s.classList.toggle('-translate-x-full');
o.classList.toggle('hidden');
}
// Desktop: recolhe/expande a sidebar (estado salvo p/ o site inteiro).
function toggleSidebarCollapse() {
const collapsed = document.documentElement.classList.toggle('sidebar-collapsed');
try { localStorage.setItem('sidebarCollapsed', collapsed ? '1' : '0'); } catch (e) {}
syncSidebarToggleIcon();
}
function syncSidebarToggleIcon() {
const icon = document.getElementById('sidebar-toggle-icon');
if (icon) icon.textContent =
document.documentElement.classList.contains('sidebar-collapsed') ? '▶' : '◀';
}
syncSidebarToggleIcon();
</script>

View File

@@ -55,10 +55,29 @@
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Sidebar recolhível no desktop (botão flutuante esconde/mostra o menu).
O estado fica na classe .sidebar-collapsed do <html>. */
@media (min-width: 768px) {
main, footer { transition: margin-left .3s ease; }
#sidebar-edge { left: 16rem; transition: left .3s ease; }
.sidebar-collapsed #sidebar { transform: translateX(-100%); }
.sidebar-collapsed main, .sidebar-collapsed footer { margin-left: 0; }
.sidebar-collapsed #sidebar-edge { left: 0; }
}
</style>
<%= javascript_importmap_tags %>
<%= yield :head %>
<%# Aplica o estado recolhido da sidebar antes de pintar (evita "piscar") %>
<script>
(function () {
try {
if (localStorage.getItem('sidebarCollapsed') === '1')
document.documentElement.classList.add('sidebar-collapsed');
} catch (e) {}
})();
</script>
</head>
<body class="bg-[#0a0a0a] text-white min-h-screen">

View File

@@ -91,7 +91,7 @@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-4">Notas fiscais por Motorista</p>
<div class="max-h-80 overflow-y-auto">
<div class="max-h-80 overflow-y-auto pr-2">
<%= render 'tabela_simples',
cabecalhos: ['Motorista', 'Notas fiscais'],
linhas: metricas.por_motorista.map { |h| [h[:nome], h[:total]] },
@@ -103,7 +103,7 @@
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-4">Entregas por STS (Unidade)</p>
<div class="max-h-80 overflow-y-auto">
<div class="max-h-80 overflow-y-auto pr-2">
<%= render 'tabela_simples',
cabecalhos: ['Unidade', 'NFs entregues'],
linhas: metricas.por_sts.map { |h| [h[:nome], h[:total]] },

View File

@@ -110,7 +110,7 @@
<span class="text-xs text-gray-500 uppercase tracking-wider">Filtros ativos:</span>
<% @chips.each do |chip| %>
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-300 text-xs">
<%= chip[:rotulo] %>: <strong class="text-white"><%= chip[:valor] %></strong>
<%= chip[:rotulo] %>: <strong class="text-white"><%= chip[:display] || chip[:valor] %></strong>
<%= link_to '✕', operacoes_dashboard_path(request.query_parameters.except(chip[:param])),
data: { turbo: false }, class: 'hover:text-white font-bold', title: 'Remover filtro' %>
</span>
@@ -269,6 +269,19 @@
const COR_A = '#f97316'; // comparativo: A = laranja da marca
const COR_B = '#3b82f6'; // comparativo: B = azul (contraste)
// Cross-filter por clique nos gráficos: recarrega a página com o filtro,
// preservando os demais parâmetros (mesma lógica dos links das tabelas).
function aplicarFiltroDash(novos) {
const url = new URL(window.location.href);
Object.keys(novos).forEach(function (k) {
const v = novos[k];
if (v === null || v === '') url.searchParams.delete(k);
else url.searchParams.set(k, v);
});
window.location.assign(url.toString());
}
function cursorPonteiro(e, els) { e.native.target.style.cursor = els.length ? 'pointer' : 'default'; }
// ── Charts (donut + barras + comparativo) ──
function initCharts() {
if (typeof Chart === 'undefined') return;
@@ -285,7 +298,15 @@
borderColor: '#1a1a1a', borderWidth: 2
}]
},
options: { responsive: true, maintainAspectRatio: false, cutout: '62%', plugins: { legend: { display: false } } }
options: {
responsive: true, maintainAspectRatio: false, cutout: '62%',
plugins: { legend: { display: false } },
onHover: cursorPonteiro,
onClick: function (e, els) {
if (!els.length) return;
aplicarFiltroDash({ f_resultado: els[0].index === 0 ? 'completed' : 'failed' });
}
}
});
});
@@ -302,6 +323,13 @@
},
options: {
responsive: true, maintainAspectRatio: false,
onHover: cursorPonteiro,
onClick: function (e, els) {
if (!els.length) return;
const el = els[0];
const iso = String(this.data.labels[el.index]).split('/').reverse().join('-');
aplicarFiltroDash({ f_data: iso, f_resultado: el.datasetIndex === 0 ? 'completed' : 'failed' });
},
scales: {
x: { stacked: true, ticks: { color: '#6b7280', maxTicksLimit: 12 }, grid: { color: 'rgba(255,255,255,0.04)' } },
y: { stacked: true, beginAtZero: true, ticks: { color: '#6b7280' }, grid: { color: 'rgba(255,255,255,0.04)' } }

View File

@@ -41,6 +41,7 @@ Rails.application.routes.draw do
member do
get :wizard # Passo 1 — selecionar motorista
post :finalizar
post :finalizar_parcial # arquiva veículos pendentes e finaliza (só os consolidados)
post :arquivar
post :reativar # Restaura uma consolidação arquivada
post :registrar_pagamento # Marca 1 motorista (ou todos) como pago
@@ -61,6 +62,10 @@ Rails.application.routes.draw do
post :classificar # toggle individual (JSON)
post :classificar_em_massa # ações em massa (JSON)
get :revisar # Passo 3 — revisão do motorista
post :fechar_veiculo # consolida (fecha) um veículo do motorista
post :reabrir_veiculo # reabre um veículo já consolidado
post :arquivar_veiculo # tira um veículo do gate (reversível)
post :desarquivar_veiculo # restaura um veículo arquivado
get :buscar_nf # JSON — busca a NF na SimpleRoute (apontamento manual)
post :apontar # cria apontamento manual a partir da NF
post :apontar_manual # apontamento 100% manual (sem rastreio)

View File

@@ -0,0 +1,38 @@
# Snapshot do veículo em cada pilar da consolidação. O veículo vive só na tabela
# externa de rastreio (db_reem_simplerout_2026), acessível via tracking_id. Guardar
# uma cópia aqui permite fechar/consolidar POR VEÍCULO sem N+1 e mantém o histórico
# estável mesmo que a base externa mude depois. Lançamentos sintéticos (apontamento
# 100% manual "MAN-*" e entrega de termo "TERMO-*") não têm veículo na rastreio e
# ficam NULL — o agrupamento trata NULL como "(sem veículo)".
class AddVehicleToConsolidacaoEntregas < ActiveRecord::Migration[7.1]
def up
unless column_exists?(:consolidacao_entregas, :vehicle)
add_column :consolidacao_entregas, :vehicle, :string
add_index :consolidacao_entregas, %i[consolidacao_id motorista_nome vehicle],
name: 'idx_consolidacao_entrega_veiculo'
end
# Backfill dos lançamentos já existentes a partir da rastreio. Só roda onde a
# tabela externa existe (dev/prod) — no banco de teste ela é isolada, então
# pulamos para não quebrar o db:migrate.
return unless connection.table_exists?('db_reem_simplerout_2026')
say_with_time 'backfill vehicle em consolidacao_entregas' do
execute(<<~SQL.squish)
UPDATE consolidacao_entregas ce
SET vehicle = e.vehicle
FROM db_reem_simplerout_2026 e
WHERE e.tracking_id = ce.tracking_id
AND ce.vehicle IS NULL
SQL
end
end
def down
if index_exists?(:consolidacao_entregas, %i[consolidacao_id motorista_nome vehicle],
name: 'idx_consolidacao_entrega_veiculo')
remove_index :consolidacao_entregas, name: 'idx_consolidacao_entrega_veiculo'
end
remove_column :consolidacao_entregas, :vehicle if column_exists?(:consolidacao_entregas, :vehicle)
end
end

View File

@@ -0,0 +1,61 @@
# Unidade de FECHAMENTO por veículo dentro de uma consolidação. Espelha o padrão de
# consolidacao_motoristas (arquivamento reversível + valor_total), mas o que ela
# controla é o estado "fechado/consolidado" de cada carro — o PAGAMENTO continua no
# motorista (consolidacao_motoristas). Assim dá para consolidar um veículo pronto
# sem esperar os outros. O bucket sem veículo (termo/apontamento manual legado) usa
# o sentinel '(sem veículo)' para que o índice único funcione (NULL quebraria).
class CreateConsolidacaoVeiculos < ActiveRecord::Migration[7.1]
SEM_VEICULO = '(sem veículo)'
def up
unless table_exists?(:consolidacao_veiculos)
create_table :consolidacao_veiculos do |t|
t.references :consolidacao, null: false, foreign_key: { to_table: :consolidacoes }
t.string :motorista_nome, null: false
t.string :vehicle, null: false
t.decimal :valor_total, precision: 10, scale: 2, default: 0
t.datetime :fechado_em # NULL = aberto (ainda não consolidado)
t.integer :fechado_por
t.datetime :arquivado_em # NULL = ativo
t.integer :arquivado_por
t.timestamps
end
add_index :consolidacao_veiculos, %i[consolidacao_id motorista_nome vehicle],
unique: true, name: 'idx_consolidacao_veiculo_unique'
add_index :consolidacao_veiculos, :arquivado_em
end
# Backfill: uma linha por (consolidação · motorista · veículo) já classificado,
# somando o valor (desconto = tipo 3 subtrai). Consolidações já finalizadas
# (status 1) nascem FECHADAS, herdando quem/quando finalizou — para não
# "reabrir" o que já foi pago.
say_with_time 'backfill consolidacao_veiculos a partir das entregas' do
execute(<<~SQL.squish)
INSERT INTO consolidacao_veiculos
(consolidacao_id, motorista_nome, vehicle, valor_total,
fechado_em, fechado_por, created_at, updated_at)
SELECT ce.consolidacao_id,
ce.motorista_nome,
COALESCE(NULLIF(ce.vehicle, ''), '#{SEM_VEICULO}') AS vehicle,
COALESCE(SUM(CASE WHEN ce.tipo = 3
THEN -ce.valor_aplicado
ELSE ce.valor_aplicado END), 0) AS valor_total,
CASE WHEN c.status = 1 THEN c.finalizado_em END AS fechado_em,
CASE WHEN c.status = 1 THEN c.finalizado_por END AS fechado_por,
NOW(), NOW()
FROM consolidacao_entregas ce
JOIN consolidacoes c ON c.id = ce.consolidacao_id
GROUP BY ce.consolidacao_id, ce.motorista_nome,
COALESCE(NULLIF(ce.vehicle, ''), '#{SEM_VEICULO}'),
c.status, c.finalizado_em, c.finalizado_por
SQL
end
end
def down
drop_table :consolidacao_veiculos if table_exists?(:consolidacao_veiculos)
end
end

View File

@@ -39,6 +39,12 @@ FactoryBot.define do
sequence(:motorista_nome) { |n| "Motorista #{n}" }
end
factory :consolidacao_veiculo do
association :consolidacao
sequence(:motorista_nome) { |n| "Motorista #{n}" }
sequence(:vehicle) { |n| "GADE_#{format('%03d', n)}" }
end
factory :consolidacao_entrega do
association :consolidacao
sequence(:tracking_id) { |n| "TRK-#{n}" }

View File

@@ -232,4 +232,108 @@ RSpec.describe Consolidacao, type: :model do
expect(Consolidacao.arquivadas).to include(arquivada)
end
end
describe '#sincronizar_veiculos! (via recalcular_motorista!)' do
let(:consolidacao) { create(:consolidacao) }
before { create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'A') }
it 'cria uma linha por veículo classificado, somando o valor do veículo' do
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A',
vehicle: 'GADE_1', tipo: :entrega_normal, valor_aplicado: 15)
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A',
vehicle: 'GADE_1', tipo: :bonus, valor_aplicado: 10)
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A',
vehicle: 'GADE_2', tipo: :entrega_normal, valor_aplicado: 20)
consolidacao.recalcular_motorista!('A')
veics = consolidacao.consolidacao_veiculos.where(motorista_nome: 'A').order(:vehicle)
expect(veics.map(&:vehicle)).to eq(%w[GADE_1 GADE_2])
expect(veics.find_by(vehicle: 'GADE_1').valor_total).to eq(25)
expect(veics.find_by(vehicle: 'GADE_2').valor_total).to eq(20)
end
it 'remove o veículo aberto que ficou sem pilares, mas preserva o fechado' do
e1 = create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A',
vehicle: 'GADE_1', valor_aplicado: 15)
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A',
vehicle: 'GADE_2', valor_aplicado: 20)
consolidacao.recalcular_motorista!('A')
consolidacao.consolidacao_veiculos.find_by(vehicle: 'GADE_2').fechar!(create(:gerente))
e1.destroy
consolidacao.recalcular_motorista!('A')
veics = consolidacao.consolidacao_veiculos.where(motorista_nome: 'A')
expect(veics.pluck(:vehicle)).to contain_exactly('GADE_2') # GADE_1 (aberto e vazio) removido
end
end
describe 'gate: veículo arquivado libera a finalização' do
let(:consolidacao) { create(:consolidacao) }
before { create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'A') }
it 'desconta as elegíveis do veículo arquivado' do
allow(consolidacao).to receive(:entregas_elegiveis_count).with('A').and_return(3)
allow(consolidacao).to receive(:entregas_elegiveis_count_veiculo).with('A', 'GADE_2').and_return(1)
# 2 classificadas (GADE_1). Falta 1 (GADE_2) => incompleto.
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A',
vehicle: 'GADE_1', tracking_id: 'T1')
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A',
vehicle: 'GADE_1', tracking_id: 'T2', tipo: :bonus)
expect(consolidacao.motorista_completo?('A')).to be false
# Arquiva GADE_2 (o carro sem classificar): total cai p/ 2 => completo.
cv = consolidacao.consolidacao_veiculos.create!(motorista_nome: 'A', vehicle: 'GADE_2')
cv.arquivar!(create(:gerente))
expect(consolidacao.motorista_completo?('A')).to be true
end
end
describe '#arquivar_veiculos_pendentes!' do
let(:consolidacao) { create(:consolidacao) }
let(:gerente) { create(:gerente) }
before { create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'A') }
it 'arquiva só os veículos pendentes (com elegíveis e < 100%)' do
allow(consolidacao).to receive(:veiculos_do_motorista).with('A').and_return([
{ vehicle: 'GADE_1', eligible: 5, classificadas: 5, completo: true, fechado: true, arquivado: false },
{ vehicle: 'GADE_2', eligible: 3, classificadas: 1, completo: false, fechado: false, arquivado: false },
{ vehicle: '(sem veículo)', eligible: 0, classificadas: 0, completo: false, fechado: false, arquivado: false }
])
arquivados = consolidacao.arquivar_veiculos_pendentes!(gerente)
expect(arquivados).to eq(['A · GADE_2'])
expect(consolidacao.consolidacao_veiculos.find_by(motorista_nome: 'A', vehicle: 'GADE_2')).to be_arquivado
# não mexe no consolidado (GADE_1) nem no bucket sem elegíveis
expect(consolidacao.consolidacao_veiculos.where(vehicle: 'GADE_1')).not_to exist
expect(consolidacao.consolidacao_veiculos.where(vehicle: '(sem veículo)')).not_to exist
end
end
describe '#finalizar! fecha os veículos ainda abertos' do
let(:consolidacao) { create(:consolidacao) }
let(:gerente) { create(:gerente) }
before { create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'A') }
it 'consolida os veículos abertos ao finalizar' do
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A',
vehicle: 'GADE_1', valor_aplicado: 15)
consolidacao.recalcular_motorista!('A')
cv = consolidacao.consolidacao_veiculos.find_by(vehicle: 'GADE_1')
expect(cv).not_to be_fechado
allow(consolidacao).to receive(:todas_entregas_classificadas?).and_return(true)
consolidacao.finalizar!(gerente)
expect(cv.reload).to be_fechado
expect(cv.fechado_por).to eq(gerente.id)
end
end
end

View File

@@ -0,0 +1,74 @@
require 'rails_helper'
RSpec.describe ConsolidacaoVeiculo, type: :model do
let(:consolidacao) { create(:consolidacao) }
describe 'validações' do
it 'exige motorista_nome e vehicle' do
expect(build(:consolidacao_veiculo, motorista_nome: nil)).not_to be_valid
expect(build(:consolidacao_veiculo, vehicle: nil)).not_to be_valid
end
it 'não permite o mesmo veículo duas vezes para o mesmo motorista' do
create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'A', vehicle: 'GADE_1')
dup = build(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'A', vehicle: 'GADE_1')
expect(dup).not_to be_valid
end
end
describe '#recalcular_valor (before_save)' do
before do
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'Joao',
vehicle: 'GADE_1', tipo: :entrega_normal, valor_aplicado: 15)
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'Joao',
vehicle: 'GADE_1', tipo: :desconto, valor_aplicado: 5)
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'Joao',
vehicle: 'GADE_2', tipo: :entrega_normal, valor_aplicado: 99)
end
it 'soma só os pilares do veículo e trata desconto como negativo' do
cv = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'Joao', vehicle: 'GADE_1')
expect(cv.valor_total).to eq(10) # 15 - 5 (GADE_2 fica de fora)
end
it 'o bucket "(sem veículo)" casa pilares com vehicle NULL/vazio' do
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'Joao',
vehicle: nil, tipo: :bonus, valor_aplicado: 7)
cv = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'Joao',
vehicle: ConsolidacaoVeiculo::SEM_VEICULO)
expect(cv.valor_total).to eq(7)
end
end
describe 'fechamento e arquivamento' do
let(:gerente) { create(:gerente) }
it 'fecha e reabre registrando quem/quando' do
cv = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'A', vehicle: 'GADE_1')
expect(cv).not_to be_fechado
cv.fechar!(gerente)
cv.reload
expect(cv).to be_fechado
expect(cv.fechado_por).to eq(gerente.id)
expect(cv.fechado_em).to be_present
cv.reabrir!(gerente)
expect(cv.reload).not_to be_fechado
end
it 'arquiva e desarquiva, e os escopos separam os estados' do
aberto = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'A', vehicle: 'GADE_1')
fechado = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'B', vehicle: 'GADE_2')
arquivado = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'C', vehicle: 'GADE_3')
fechado.fechar!(gerente)
arquivado.arquivar!(gerente)
expect(ConsolidacaoVeiculo.fechados).to include(fechado)
expect(ConsolidacaoVeiculo.fechados).not_to include(aberto)
expect(ConsolidacaoVeiculo.abertos).to include(aberto)
expect(ConsolidacaoVeiculo.ativos).not_to include(arquivado)
expect(ConsolidacaoVeiculo.arquivados).to contain_exactly(arquivado)
end
end
end

View File

@@ -0,0 +1,31 @@
require 'rails_helper'
# label e grupo_de são só parsing do nome da tabela (sem tocar o banco).
RSpec.describe Operacao do
describe '.label (caixa alta, sem parecer o nome do banco)' do
it 'tira o prefixo e deixa em CAIXA ALTA' do
expect(Operacao.label('gade_entregas_ubs_norte_maio_2026')).to eq('UBS NORTE MAIO 2026')
expect(Operacao.label('gade_entregas_emad_fev_2026')).to eq('EMAD FEV 2026')
end
end
describe '.grupo_de (agrupamento por mês)' do
it 'reconhece o mês por EXTENSO (maio) e agrupa no mês certo, item em caixa alta' do
g = Operacao.send(:grupo_de, 'gade_entregas_ubs_norte_maio_2026')
expect(g[:titulo]).to eq('Maio 2026')
expect(g[:item]).to eq('UBS NORTE')
end
it 'continua reconhecendo a abreviação (fev)' do
g = Operacao.send(:grupo_de, 'gade_entregas_emad_fev_2026')
expect(g[:titulo]).to eq('Fevereiro 2026')
expect(g[:item]).to eq('EMAD')
end
it 'cai em "Outras" quando não há mês reconhecível' do
g = Operacao.send(:grupo_de, 'gade_entregas_oeste_centro_remanescentes')
expect(g[:titulo]).to eq('Outras')
expect(g[:item]).to eq('OESTE CENTRO REMANESCENTES')
end
end
end