diff --git a/Imagens para correção /Imagem colada (12).png b/Imagens para correção /Imagem colada (12).png new file mode 100644 index 0000000..d5fd9e6 Binary files /dev/null and b/Imagens para correção /Imagem colada (12).png differ diff --git a/app/controllers/consolidacao_entregas_controller.rb b/app/controllers/consolidacao_entregas_controller.rb index 24ddcce..11e9288 100644 --- a/app/controllers/consolidacao_entregas_controller.rb +++ b/app/controllers/consolidacao_entregas_controller.rb @@ -177,44 +177,22 @@ class ConsolidacaoEntregasController < ApplicationController if entregas.empty? render json: { ok: false, erro: "Nota #{nf} não encontrada na base de rastreio." } else - render json: { ok: true, nf: nf, entregas: entregas.map { |e| entrega_json(e) } } + render json: { ok: true, nf: nf, entregas: entregas.map(&:resumo_apontamento) } end end # POST /consolidacoes/:consolidacao_id/consolidacao_entregas/apontar # Cria um apontamento manual a partir de um tracking_id encontrado pela NF. + # Aceita um ou vários pilares (tipos[]) na mesma entrega. def apontar authorize @consolidacao, :update? - tipo = params[:tipo].to_s - return render_apontamento_erro('Tipo inválido.') unless ConsolidacaoEntrega.tipos.key?(tipo) - entrega = Entrega.da_conta_gade.find_by(tracking_id: params[:tracking_id]) return render_apontamento_erro('Entrega não encontrada na base de rastreio.') unless entrega - motorista = entrega.driver.to_s.strip - return render_apontamento_erro('Entrega sem motorista — não é possível apontar.') if motorista.blank? - - # Garante o motorista na consolidação (pode não estar, já que a NF veio por fora). - @consolidacao.consolidacao_motoristas.find_or_create_by!(motorista_nome: motorista) - - ce = @consolidacao.consolidacao_entregas - .find_or_initialize_by(tracking_id: entrega.tracking_id, tipo: tipo) - if ce.persisted? - return render_apontamento_erro("Essa nota já está classificada como #{ConsolidacaoEntrega::TIPO_CORES[tipo][:label]} para #{motorista}.") - end - - ce.assign_attributes( - motorista_nome: motorista, - valor_aplicado: valor_para_tipo(tipo), - manual: true, - created_by: current_user.id - ) - ce.save! - - recalcular_motorista(motorista) + motorista = @consolidacao.adicionar_apontamento!(entrega, tipos_param, current_user) auditar!(:editar, @consolidacao, - dados_novos: { apontamento_manual: entrega.numero_nf, tipo: tipo, motorista: motorista }) + dados_novos: { apontamento_manual: entrega.numero_nf, tipos: tipos_param, motorista: motorista }) respond_to do |format| format.json { render json: resumo_json(motorista).merge(motorista: motorista) } @@ -223,29 +201,35 @@ class ConsolidacaoEntregasController < ApplicationController notice: "Apontamento da nota #{entrega.numero_nf} adicionado para #{motorista}." end end + rescue ArgumentError => e + render_apontamento_erro(e.message) end - # DELETE /consolidacoes/:consolidacao_id/consolidacao_entregas/remover_apontamento?id=1 - # Remove um apontamento manual. (Não passa pelo guard de elegibilidade, pois o - # tracking está fora do período — por isso não reusa o toggle classificar.) + # DELETE /consolidacoes/:consolidacao_id/consolidacao_entregas/remover_apontamento + # Remove um apontamento manual — por ?id= (um pilar) ou por ?tracking_id=&motorista= + # (todos os pilares daquela nota). Não passa pelo guard de elegibilidade, pois o + # tracking está fora do período (por isso não reusa o toggle classificar). def remover_apontamento authorize @consolidacao, :update? - ce = @consolidacao.consolidacao_entregas.apontamentos.find_by(id: params[:id]) - return render_apontamento_erro('Apontamento não encontrado.') unless ce + scope = @consolidacao.consolidacao_entregas.apontamentos + registros = if params[:id].present? + Array(scope.find_by(id: params[:id])) + else + scope.where(tracking_id: params[:tracking_id], motorista_nome: params[:motorista]).to_a + end + return render_apontamento_erro('Apontamento não encontrado.') if registros.empty? - motorista = ce.motorista_nome - ce.destroy - recalcular_motorista(motorista) + motorista = registros.first.motorista_nome + tracking = registros.first.tracking_id + registros.each(&:destroy) + @consolidacao.recalcular_motorista!(motorista) auditar!(:editar, @consolidacao, - dados_novos: { remover_apontamento_manual: ce.tracking_id, motorista: motorista }) + dados_novos: { remover_apontamento_manual: tracking, motorista: motorista }) respond_to do |format| format.json { render json: resumo_json(motorista).merge(motorista: motorista) } - format.html do - redirect_to revisar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: motorista), - notice: 'Apontamento removido.' - end + format.html { redirect_back fallback_location: consolidacao_path(@consolidacao), notice: 'Apontamento removido.' } end end @@ -285,18 +269,10 @@ class ConsolidacaoEntregasController < ApplicationController tracking_ids_elegiveis(motorista).include?(tracking_id) end - # Dados da entrega para o modal de apontamento (conferência antes de salvar). - def entrega_json(e) - { - tracking_id: e.tracking_id, - nf: e.numero_nf, - motorista: e.driver, - veiculo: e.vehicle, - local: e.local, - status: e.status, - pago: e.elegivel_pagamento?, - data: (e.checkout || e.planned_date)&.to_s - } + # Pilares (tipos) selecionados no modal — aceita tipos[] (vários) ou tipo (um). + def tipos_param + tipos = Array(params[:tipos]).presence || Array(params[:tipo]) + tipos.reject(&:blank?) end def render_apontamento_erro(msg) @@ -317,24 +293,11 @@ class ConsolidacaoEntregasController < ApplicationController end def valor_para_tipo(tipo) - case tipo.to_s - when 'entrega_normal' then Configuracao.preco_entrega - when 'retirada' then Configuracao.preco_retirada - when 'bonus' then Configuracao.preco_bonus - when 'desconto' then Configuracao.preco_desconto - else 0 - end + ConsolidacaoEntrega.valor_para(tipo) end def recalcular_motorista(nome) - cm = @consolidacao.consolidacao_motoristas.find_by(motorista_nome: nome) - return unless cm - - total = @consolidacao.consolidacao_entregas.where(motorista_nome: nome).sum do |e| - e.tipo == 'desconto' ? -e.valor_aplicado : e.valor_aplicado - end - cm.update_column(:valor_total, total) - @consolidacao.update_column(:valor_total, @consolidacao.consolidacao_motoristas.sum(:valor_total)) + @consolidacao.recalcular_motorista!(nome) end def resumo_json(motorista) diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb index f22491a..9f28dca 100644 --- a/app/controllers/consolidacoes_controller.rb +++ b/app/controllers/consolidacoes_controller.rb @@ -101,10 +101,60 @@ class ConsolidacoesController < ApplicationController render json: Operacao.dados(params[:operacoes]) end + # GET /consolidacoes/buscar_nf?nf=123 — busca a NF na SimpleRoute (nota avulsa). + def buscar_nf + authorize Consolidacao, :create? + + nf = params[:nf].to_s.strip + return render(json: { ok: false, erro: 'Informe o número da nota.' }, status: :unprocessable_entity) if nf.blank? + + entregas = Entrega.da_conta_gade.por_nf(nf).order(:checkout) + if entregas.empty? + render json: { ok: false, erro: "Nota #{nf} não encontrada na base de rastreio." } + else + render json: { ok: true, nf: nf, entregas: entregas.map(&:resumo_apontamento) } + end + end + + # POST /consolidacoes/avulsa — cria uma consolidação JÁ a partir de uma NF + # avulsa (entrega que chegou por fora) e adiciona o apontamento ao motorista. + def avulsa + authorize Consolidacao, :create? + + entrega = Entrega.da_conta_gade.find_by(tracking_id: params[:tracking_id]) + return redirect_to(consolidacoes_path, alert: 'Entrega não encontrada na base de rastreio.') unless entrega + + data_ref = (entrega.checkout || entrega.planned_date)&.to_date || Date.current + + consolidacao = nil + ActiveRecord::Base.transaction do + consolidacao = Consolidacao.create!( + nome: "Avulsa nf#{entrega.numero_nf}", + data_inicio: data_ref, + data_fim: data_ref.end_of_month, + created_by: current_user.id + ) + consolidacao.adicionar_apontamento!(entrega, Array(params[:tipos]).reject(&:blank?), current_user) + end + + auditar!(:criar, consolidacao, + dados_novos: { avulsa_nf: entrega.numero_nf, motorista: entrega.driver, tipos: params[:tipos] }) + redirect_to consolidacao_path(consolidacao), + notice: "Consolidação avulsa criada para #{entrega.driver} (NF #{entrega.numero_nf})." + rescue ArgumentError => e + redirect_to consolidacoes_path, alert: e.message + rescue ActiveRecord::RecordInvalid => e + redirect_to consolidacoes_path, alert: "Não foi possível criar a consolidação avulsa: #{e.message}" + end + # GET /consolidacoes/:id — visão geral def show authorize @consolidacao @motoristas = @consolidacao.consolidacao_motoristas.order(:motorista_nome) + # Apontamentos manuais agrupados por nota (tracking) p/ exibição e remoção. + @apontamentos = @consolidacao.consolidacao_entregas.apontamentos + .order(:motorista_nome, :tracking_id) + .group_by { |ce| [ce.motorista_nome, ce.tracking_id] } end # GET /consolidacoes/:id/wizard — PASSO 1: selecionar motorista diff --git a/app/javascript/controllers/apontamento_controller.js b/app/javascript/controllers/apontamento_controller.js index 4e3176b..2ef709f 100644 --- a/app/javascript/controllers/apontamento_controller.js +++ b/app/javascript/controllers/apontamento_controller.js @@ -6,7 +6,7 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { - static targets = ["modal", "nf", "erro", "resultados", "confirmar", "tipo"] + static targets = ["modal", "nf", "erro", "resultados", "confirmar", "tipoCheck"] static values = { buscarUrl: String, apontarUrl: String } // ── Modal ────────────────────────────────────────────────── @@ -85,6 +85,9 @@ export default class extends Controller { const selecionado = this.resultadosTarget.querySelector('input[name="apontamento_entrega"]:checked') if (!selecionado) { this.mostrarErro("Selecione uma entrega."); return } + const tipos = this.tipoCheckTargets.filter(c => c.checked).map(c => c.value) + if (!tipos.length) { this.mostrarErro("Selecione ao menos uma classificação."); return } + const btn = event.currentTarget btn.disabled = true try { @@ -95,7 +98,7 @@ export default class extends Controller { "Accept": "application/json", "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content }, - body: JSON.stringify({ tracking_id: selecionado.value, tipo: this.tipoTarget.value }) + body: JSON.stringify({ tracking_id: selecionado.value, tipos: tipos }) }) const data = await resp.json() if (!resp.ok || data.ok === false) { diff --git a/app/javascript/controllers/nota_avulsa_controller.js b/app/javascript/controllers/nota_avulsa_controller.js new file mode 100644 index 0000000..fb36a13 --- /dev/null +++ b/app/javascript/controllers/nota_avulsa_controller.js @@ -0,0 +1,92 @@ +// app/javascript/controllers/nota_avulsa_controller.js +// Stimulus controller — "Nota avulsa": busca a NF na base de rastreio e, ao +// prosseguir, o formulário cria JÁ uma consolidação avulsa para o motorista. + +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["modal", "nf", "erro", "resultados", "confirmar"] + static values = { buscarUrl: String } + + abrir() { + this.reset() + this.modalTarget.classList.remove("hidden") + this.nfTarget.focus() + } + + fechar() { + this.modalTarget.classList.add("hidden") + } + + fundo(event) { + if (event.target === this.modalTarget) this.fechar() + } + + reset() { + this.nfTarget.value = "" + this.erroTarget.classList.add("hidden") + this.resultadosTarget.innerHTML = "" + this.confirmarTarget.classList.add("hidden") + } + + // ── Busca da NF (Enter ou botão) ─────────────────────────── + async buscar(event) { + event?.preventDefault() + const nf = this.nfTarget.value.trim() + if (!nf) { this.mostrarErro("Informe o número da nota."); return } + + this.erroTarget.classList.add("hidden") + this.resultadosTarget.innerHTML = `

Buscando…

` + this.confirmarTarget.classList.add("hidden") + + try { + const url = `${this.buscarUrlValue}?nf=${encodeURIComponent(nf)}` + const resp = await fetch(url, { headers: { "Accept": "application/json" } }) + const data = await resp.json() + + if (!data.ok) { + this.resultadosTarget.innerHTML = "" + this.mostrarErro(data.erro || "Nota não encontrada.") + return + } + this.renderResultados(data.entregas) + } catch (e) { + this.resultadosTarget.innerHTML = "" + this.mostrarErro("Erro ao buscar a nota. Tente novamente.") + } + } + + renderResultados(entregas) { + const cards = entregas.map((e, i) => { + const pago = e.pago + ? `paga` + : `${e.status || "pendente"}` + return ` + ` + }).join("") + + this.resultadosTarget.innerHTML = cards + this.confirmarTarget.classList.remove("hidden") + } + + // Garante que há uma entrega selecionada antes de enviar o formulário. + validar(event) { + const selecionado = this.resultadosTarget.querySelector('input[name="tracking_id"]:checked') + if (!selecionado) { + event.preventDefault() + this.mostrarErro("Busque e selecione uma nota antes de criar.") + } + } + + mostrarErro(msg) { + this.erroTarget.textContent = msg + this.erroTarget.classList.remove("hidden") + } +} diff --git a/app/models/consolidacao.rb b/app/models/consolidacao.rb index 6e773ab..4152a71 100644 --- a/app/models/consolidacao.rb +++ b/app/models/consolidacao.rb @@ -160,6 +160,52 @@ class Consolidacao < ApplicationRecord .distinct.count(:tracking_id) 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 + # ainda não estiver. Retorna o nome do motorista. Idempotente por (tracking, tipo). + def adicionar_apontamento!(entrega, tipos, user) + 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? + + motorista = entrega.driver.to_s.strip + raise ArgumentError, 'Entrega sem motorista — não é possível apontar.' if motorista.blank? + + transaction do + consolidacao_motoristas.find_or_create_by!(motorista_nome: motorista) + + tipos_validos.each do |tipo| + ce = consolidacao_entregas.find_or_initialize_by(tracking_id: entrega.tracking_id, tipo: tipo) + next if ce.persisted? # pilar já existe — mantém + + ce.assign_attributes( + motorista_nome: motorista, + valor_aplicado: ConsolidacaoEntrega.valor_para(tipo), + manual: true, + created_by: user&.id + ) + ce.save! + end + + recalcular_motorista!(motorista) + end + + motorista + end + + # Recalcula o valor de um motorista (soma dos pilares, desconto subtrai) e o + # total da consolidação. Fonte única usada pelo wizard e pelos apontamentos. + def recalcular_motorista!(nome) + cm = consolidacao_motoristas.find_by(motorista_nome: nome) + return unless cm + + total = consolidacao_entregas.where(motorista_nome: nome).sum do |e| + e.desconto? ? -e.valor_aplicado : e.valor_aplicado + end + cm.update_column(:valor_total, total) + update_column(:valor_total, consolidacao_motoristas.sum(:valor_total)) + end + private def periodo_valido diff --git a/app/models/consolidacao_entrega.rb b/app/models/consolidacao_entrega.rb index cc3e8a5..45eeecb 100644 --- a/app/models/consolidacao_entrega.rb +++ b/app/models/consolidacao_entrega.rb @@ -38,4 +38,16 @@ class ConsolidacaoEntrega < ApplicationRecord def tipo_cor TIPO_CORES[tipo] || TIPO_CORES['entrega_normal'] end + + # Preço configurado para um pilar (tipo). Fonte única usada na classificação + # (controller) e no apontamento manual (Consolidacao#adicionar_apontamento!). + def self.valor_para(tipo) + case tipo.to_s + when 'entrega_normal' then Configuracao.preco_entrega + when 'retirada' then Configuracao.preco_retirada + when 'bonus' then Configuracao.preco_bonus + when 'desconto' then Configuracao.preco_desconto + else 0 + end + end end diff --git a/app/models/entrega.rb b/app/models/entrega.rb index abf47fd..69e898c 100644 --- a/app/models/entrega.rb +++ b/app/models/entrega.rb @@ -129,6 +129,21 @@ class Entrega < ApplicationRecord reference_id end + # Resumo para o apontamento manual (modal de busca de NF). Fonte única usada + # pelos dois fluxos: apontar em consolidação existente e criar "nota avulsa". + def resumo_apontamento + { + tracking_id: tracking_id, + nf: numero_nf, + motorista: driver, + veiculo: vehicle, + local: local, + status: status, + pago: elegivel_pagamento?, + data: (checkout || planned_date)&.to_s + } + end + def local contact_name.presence || address end diff --git a/app/views/consolidacoes/index.html.erb b/app/views/consolidacoes/index.html.erb index 49b0270..df558b6 100644 --- a/app/views/consolidacoes/index.html.erb +++ b/app/views/consolidacoes/index.html.erb @@ -6,11 +6,69 @@
<%= link_to '📁 Arquivadas', arquivadas_consolidacoes_path, class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %> + <% if policy(Consolidacao).create? %> + + <% end %> <%= link_to '+ Nova Consolidação', new_consolidacao_path, class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %>
+<%# ── Modal "Nota avulsa" — cria a consolidação direto a partir da NF ── %> +<% if policy(Consolidacao).create? %> +
+ +
+<% end %> + <%# ── Filtros ──────────────────────────────────────────── %> <%= form_with url: consolidacoes_path, method: :get, class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-6 grid grid-cols-1 md:grid-cols-6 gap-3' do |f| %> diff --git a/app/views/consolidacoes/show.html.erb b/app/views/consolidacoes/show.html.erb index 0a451d7..63b349e 100644 --- a/app/views/consolidacoes/show.html.erb +++ b/app/views/consolidacoes/show.html.erb @@ -132,6 +132,53 @@ <% end %> + + <%# ── Apontamentos manuais (notas que chegaram por fora) ─────────────── %> + <% if @apontamentos.present? %> + <% pode_editar = !(@consolidacao.finalizada? && current_user.operador?) %> +

➕ Apontamentos manuais

+
+ + + + + + + <% if pode_editar %><% end %> + + + + <% @apontamentos.each do |(motorista, _tracking), pilares| %> + <% entrega = pilares.first.entrega_original %> + <% total = pilares.sum { |p| p.desconto? ? -p.valor_aplicado : p.valor_aplicado } %> + + + + + <% if pode_editar %> + + <% end %> + + <% end %> + +
Nota / MotoristaClassificaçõesValor
+

NF <%= entrega&.numero_nf || '—' %>

+

<%= motorista %><% if entrega&.vehicle.present? %> · <%= entrega.vehicle %><% end %>

+
+
+ <% pilares.each do |p| %> + <%= p.tipo_cor[:label] %> + <% end %> +
+
<%= moeda(total) %> + <%= button_to '✕ Remover', + remover_apontamento_consolidacao_consolidacao_entregas_path(@consolidacao, tracking_id: pilares.first.tracking_id, motorista: motorista), + method: :delete, + form: { data: { turbo_confirm: "Remover o apontamento da NF #{entrega&.numero_nf} de #{motorista}?" } }, + class: 'text-gray-400 hover:text-red-400 border border-[#2a2a2a] hover:border-red-500/50 px-3 py-2 rounded-lg text-xs min-h-[40px]' %> +
+
+ <% end %> <%# ── Modal de Preview ─────────────────────────────────── %> diff --git a/app/views/consolidacoes/wizard.html.erb b/app/views/consolidacoes/wizard.html.erb index e94d9c5..977e0ea 100644 --- a/app/views/consolidacoes/wizard.html.erb +++ b/app/views/consolidacoes/wizard.html.erb @@ -68,16 +68,22 @@ <%# Resultados (preenchidos via JS) %>
- <%# Confirmação — tipo + adicionar (oculto até a busca retornar) %> + <%# Confirmação — pilares (múltiplos) + adicionar (oculto até a busca retornar) %>