diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb index 8545a6d..c3778c5 100644 --- a/app/controllers/consolidacoes_controller.rb +++ b/app/controllers/consolidacoes_controller.rb @@ -4,8 +4,12 @@ class ConsolidacoesController < ApplicationController before_action :set_consolidacao, only: %i[show edit update destroy finalizar arquivar reativar wizard + registrar_pagamento cancelar_pagamento preview_holerite gerar_pdf_relatorio gerar_pdf_holerite] + # Formas de pagamento aceitas (o select da UI usa esta mesma lista). + FORMAS_PAGAMENTO = %w[pix transferencia dinheiro].freeze + # GET /consolidacoes — lista com filtros def index authorize Consolidacao @@ -27,6 +31,10 @@ class ConsolidacoesController < ApplicationController if params[:vehicle].present? @consolidacoes = @consolidacoes.where('vehicle_ids @> ?', [params[:vehicle]].to_json) end + + if %w[pago pendente parcial].include?(params[:pagamento]) + @consolidacoes = @consolidacoes.public_send("pagamento_#{params[:pagamento]}") + end end # GET /consolidacoes/new @@ -144,6 +152,37 @@ class ConsolidacoesController < ApplicationController redirect_to arquivadas_consolidacoes_path, notice: 'Consolidação excluída permanentemente.' end + # POST /consolidacoes/:id/registrar_pagamento + # Marca 1 motorista (param consolidacao_motorista_id) ou TODOS os pendentes. + def registrar_pagamento + authorize @consolidacao, :registrar_pagamento? + return pagamento_indisponivel unless @consolidacao.finalizada? + + forma = params[:forma_pagamento].presence_in(FORMAS_PAGAMENTO) || 'pix' + alvos = motoristas_alvo.select { |cm| !cm.pago? } + + alvos.each do |cm| + cm.marcar_pago!(current_user, forma: forma) + NotificacaoService.notificar_pagamento(@consolidacao, cm) + end + + auditar!(:registrar_pagamento, @consolidacao, + dados_novos: { motoristas: alvos.map(&:motorista_nome), forma: forma }) + redirect_to @consolidacao, notice: pagamento_notice(alvos.size, :pago) + end + + # POST /consolidacoes/:id/cancelar_pagamento — estorna 1 motorista ou TODOS. + def cancelar_pagamento + authorize @consolidacao, :cancelar_pagamento? + + alvos = motoristas_alvo.select(&:pago?) + alvos.each { |cm| cm.cancelar_pagamento!(current_user) } + + auditar!(:cancelar_pagamento, @consolidacao, + dados_novos: { motoristas: alvos.map(&:motorista_nome) }) + redirect_to @consolidacao, notice: pagamento_notice(alvos.size, :estornado) + end + # ── FASE 7 — PDFs ──────────────────────────────────────────── # GET /consolidacoes/:id/gerar_pdf_relatorio?motorista=X @@ -166,9 +205,11 @@ class ConsolidacoesController < ApplicationController motorista = params[:motorista] user_mot = User.motorista.find_by('LOWER(nome) = ?', motorista.to_s.downcase) user_mot&.regenerar_login_token! if user_mot&.login_token.blank? + cm = @consolidacao.consolidacao_motoristas.find_by(motorista_nome: motorista) pdf = Pdf::HoleritePdf.new(consolidacao: @consolidacao, motorista: motorista, - user_motorista: user_mot, app_host: ENV['APP_HOST']) + user_motorista: user_mot, app_host: ENV['APP_HOST'], + pago_em: cm&.pago_em, forma_pagamento: cm&.forma_pagamento) auditar!(:editar, @consolidacao, dados_novos: { pdf: 'holerite', motorista: motorista }) send_data pdf.render, @@ -204,6 +245,26 @@ class ConsolidacoesController < ApplicationController @consolidacao = Consolidacao.find(params[:id]) end + # Um motorista específico (consolidacao_motorista_id) ou todos da consolidação. + def motoristas_alvo + if params[:consolidacao_motorista_id].present? + [@consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])] + else + @consolidacao.consolidacao_motoristas.to_a + end + end + + def pagamento_indisponivel + redirect_to @consolidacao, alert: 'Só é possível registrar pagamento de uma consolidação finalizada.' + end + + def pagamento_notice(qtd, acao) + return 'Nenhuma alteração: motoristas já estavam no estado solicitado.' if qtd.zero? + + verbo = acao == :pago ? 'marcado(s) como pago(s)' : 'com pagamento estornado' + "#{qtd} motorista(s) #{verbo}." + end + def preparar_form_new @motoristas = Entrega.motoristas_ativos @veiculos = Entrega.veiculos_unicos diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 0821656..6e91a95 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -79,10 +79,28 @@ class DashboardController < ApplicationController @consolidacoes_abertas = @consolidacoes_mes.where(status: :rascunho).count @consolidacoes_fechadas = @consolidacoes_mes.where(status: :finalizada).count + # Pagamentos: pago vs pendente entre as consolidações FINALIZADAS do período + # (medido por motorista — fonte: consolidacao_motoristas). + carregar_dados_pagamentos + # Histórico estimado mais recente @historico_recente = HistoricoEstimado.order(created_at: :desc).limit(5) end + def carregar_dados_pagamentos + finalizadas = @consolidacoes_mes.where(status: :finalizada) + cms = ConsolidacaoMotorista.where(consolidacao_id: finalizadas.select(:id)) + + @pag_pago_qtd = cms.pagos.count + @pag_pend_qtd = cms.pendentes.count + @pag_pago_valor = cms.pagos.sum(:valor_total) + @pag_pend_valor = cms.pendentes.sum(:valor_total) + + @tabela_pagamentos = finalizadas.recentes.map do |c| + { consolidacao: c, status: c.status_pagamento, pago: c.valor_pago, pendente: c.valor_pendente } + end + end + def build_grafico_diario(entregas, preco_entrega) dias = (@periodo_inicio..@periodo_fim).to_a diff --git a/app/controllers/motorista/dashboard_controller.rb b/app/controllers/motorista/dashboard_controller.rb index e98dd50..5a155e5 100644 --- a/app/controllers/motorista/dashboard_controller.rb +++ b/app/controllers/motorista/dashboard_controller.rb @@ -27,6 +27,17 @@ module Motorista .where(motorista_nome: nome, consolidacoes: { status: :finalizada, deleted_at: nil }) .sum(:valor_total) + + # Aviso in-app: pagamentos confirmados nos últimos 7 dias (notificação na + # própria página do motorista, além de WhatsApp/email). + @pagamentos_recentes = ConsolidacaoMotorista + .joins(:consolidacao) + .where(motorista_nome: nome, + consolidacoes: { status: :finalizada, deleted_at: nil }) + .where.not(pago_em: nil) + .where('pago_em >= ?', 7.days.ago) + .order(pago_em: :desc) + .includes(:consolidacao) end private diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 4eb3bd5..39c6034 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -36,6 +36,20 @@ module ApplicationHelper end end + # Badge de status de pagamento de uma consolidação (derivado dos motoristas) + def badge_pagamento(status) + cfg = case status.to_s + when 'pago' then { cor: 'bg-green-600 text-white', icone: '💰', label: 'Pago' } + when 'parcial' then { cor: 'bg-yellow-500 text-black', icone: '⏳', label: 'Parcial' } + when 'pendente' then { cor: 'bg-gray-700 text-gray-300', icone: '🕗', label: 'Pendente' } + else { cor: 'bg-gray-800 text-gray-400', icone: '❓', label: status.to_s.humanize } + end + + tag.span class: "inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold #{cfg[:cor]}" do + "#{cfg[:icone]} #{cfg[:label]}" + end + end + # Badge de papel do usuário (admin/gerente/operador/motorista) def badge_role(role) cfg = case role.to_s diff --git a/app/mailers/consolidacao_mailer.rb b/app/mailers/consolidacao_mailer.rb index 3fbe98a..6b07434 100644 --- a/app/mailers/consolidacao_mailer.rb +++ b/app/mailers/consolidacao_mailer.rb @@ -11,4 +11,15 @@ class ConsolidacaoMailer < ApplicationMailer mail to: user.email, subject: "💰 Seu pagamento foi fechado — #{consolidacao.nome}" end + + def pagamento_efetuado(user, consolidacao, consolidacao_motorista) + @user = user + @consolidacao = consolidacao + @valor = consolidacao_motorista.valor_total + @forma = consolidacao_motorista.forma_pagamento + @link_painel = "https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista" + + mail to: user.email, + subject: "✅ Seu pagamento foi efetuado — #{consolidacao.nome}" + end end diff --git a/app/models/consolidacao.rb b/app/models/consolidacao.rb index f2946c1..cdf350f 100644 --- a/app/models/consolidacao.rb +++ b/app/models/consolidacao.rb @@ -30,6 +30,24 @@ class Consolidacao < ApplicationRecord scope :recentes, -> { order(created_at: :desc) } scope :no_periodo, ->(i, f) { where('data_inicio >= ? AND data_fim <= ?', i, f) } + # ── Filtro por status de pagamento (derivado dos motoristas) ─ + # pago_em NÃO-nulo conta como pago; COUNT(pago_em) ignora os NULL. + scope :pagamento_pago, -> { + where(id: ConsolidacaoMotorista.group(:consolidacao_id) + .having('COUNT(*) > 0 AND COUNT(*) = COUNT(pago_em)') + .select(:consolidacao_id)) + } + scope :pagamento_pendente, -> { + where(id: ConsolidacaoMotorista.group(:consolidacao_id) + .having('COUNT(pago_em) = 0') + .select(:consolidacao_id)) + } + scope :pagamento_parcial, -> { + where(id: ConsolidacaoMotorista.group(:consolidacao_id) + .having('COUNT(pago_em) > 0 AND COUNT(pago_em) < COUNT(*)') + .select(:consolidacao_id)) + } + # ── Métodos ───────────────────────────────────────────────── def arquivar!(user) @@ -53,6 +71,26 @@ class Consolidacao < ApplicationRecord ) end + # ── Pagamento (status derivado dos motoristas da consolidação) ── + # :pendente (nenhum pago) · :pago (todos pagos) · :parcial (alguns). + def status_pagamento + total = consolidacao_motoristas.count + return :pendente if total.zero? + + pagos = consolidacao_motoristas.pagos.count + return :pendente if pagos.zero? + + pagos >= total ? :pago : :parcial + end + + def valor_pago + consolidacao_motoristas.pagos.sum(:valor_total) + end + + def valor_pendente + consolidacao_motoristas.pendentes.sum(:valor_total) + end + def finalizar!(user) return false unless todas_entregas_classificadas? diff --git a/app/models/consolidacao_motorista.rb b/app/models/consolidacao_motorista.rb index 9ddf405..54bf5c4 100644 --- a/app/models/consolidacao_motorista.rb +++ b/app/models/consolidacao_motorista.rb @@ -5,6 +5,7 @@ class ConsolidacaoMotorista < ApplicationRecord self.table_name = 'consolidacao_motoristas' belongs_to :consolidacao + belongs_to :pagador, class_name: 'User', foreign_key: :pago_por, optional: true has_many :consolidacao_entregas, ->(cm) { where(motorista_nome: cm.motorista_nome) }, foreign_key: :consolidacao_id, @@ -15,6 +16,22 @@ class ConsolidacaoMotorista < ApplicationRecord before_save :recalcular_valor + # ── Pagamento ─────────────────────────────────────────────── + scope :pagos, -> { where.not(pago_em: nil) } + scope :pendentes, -> { where(pago_em: nil) } + + def pago? + pago_em.present? + end + + def marcar_pago!(user, forma:) + update!(pago_em: Time.current, pago_por: user&.id, forma_pagamento: forma) + end + + def cancelar_pagamento!(_user) + update!(pago_em: nil, pago_por: nil, forma_pagamento: nil) + end + def recalcular_valor # Fonte única de verdade: o valor_aplicado capturado no momento da # classificação (igual ao ConsolidacaoEntregasController#recalcular_motorista). diff --git a/app/policies/consolidacao_policy.rb b/app/policies/consolidacao_policy.rb index cbbca0f..5104f43 100644 --- a/app/policies/consolidacao_policy.rb +++ b/app/policies/consolidacao_policy.rb @@ -7,6 +7,10 @@ class ConsolidacaoPolicy < ApplicationPolicy def update? = user.pode_consolidar? && !record_finalizada_para_operador? def destroy? = user.admin? || user.gerente? + # Registrar/estornar pagamento: mesma régua de arquivar/excluir. + def registrar_pagamento? = user.admin? || user.gerente? + def cancelar_pagamento? = registrar_pagamento? + private # Operador não mexe em consolidação finalizada diff --git a/app/services/notificacao_service.rb b/app/services/notificacao_service.rb index 35391dc..051c031 100644 --- a/app/services/notificacao_service.rb +++ b/app/services/notificacao_service.rb @@ -9,6 +9,11 @@ class NotificacaoService new(consolidacao).notificar_todos end + # Avisa UM motorista de que o pagamento dele foi efetuado. + def self.notificar_pagamento(consolidacao, consolidacao_motorista) + new(consolidacao).enviar_aviso_pagamento(consolidacao_motorista) + end + def initialize(consolidacao) @consolidacao = consolidacao end @@ -23,6 +28,14 @@ class NotificacaoService end end + def enviar_aviso_pagamento(cm) + user = User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase) + return unless user + + enviar_whatsapp_pagamento(user, cm) if whatsapp_ativo? + enviar_email_pagamento(user, cm) if email_ativo? && user.email.present? + end + private def whatsapp_ativo? @@ -65,4 +78,37 @@ class NotificacaoService rescue => e Rails.logger.error("[Notificacao] Falha email #{user.email}: #{e.message}") end + + # ── Aviso de pagamento efetuado ───────────────────────────── + def mensagem_pagamento(user, cm) + valor = "R$ #{format('%.2f', cm.valor_total)}".gsub('.', ',') + "Olá #{user.nome.split.first}! ✅\n" \ + "Seu pagamento foi efetuado.\n" \ + "📋 #{@consolidacao.nome}\n" \ + "💰 Valor: #{valor}\n" \ + "Acesse seu painel para conferir: " \ + "https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista" + end + + def enviar_whatsapp_pagamento(user, cm) + return if user.telefone.blank? + + require 'twilio-ruby' + cliente = Twilio::REST::Client.new(ENV['TWILIO_ACCOUNT_SID'], ENV['TWILIO_AUTH_TOKEN']) + cliente.messages.create( + from: ENV['TWILIO_WHATSAPP_FROM'], + to: "whatsapp:#{user.telefone}", + body: mensagem_pagamento(user, cm) + ) + Rails.logger.info("[Notificacao] WhatsApp pagamento enviado para #{user.nome}") + rescue => e + Rails.logger.error("[Notificacao] Falha WhatsApp pagamento #{user.nome}: #{e.message}") + end + + def enviar_email_pagamento(user, cm) + ConsolidacaoMailer.pagamento_efetuado(user, @consolidacao, cm).deliver_later + Rails.logger.info("[Notificacao] Email pagamento agendado para #{user.email}") + rescue => e + Rails.logger.error("[Notificacao] Falha email pagamento #{user.email}: #{e.message}") + end end diff --git a/app/services/pdf/holerite_pdf.rb b/app/services/pdf/holerite_pdf.rb index ccc343a..5c400c2 100644 --- a/app/services/pdf/holerite_pdf.rb +++ b/app/services/pdf/holerite_pdf.rb @@ -7,13 +7,19 @@ require 'rqrcode' module Pdf class HoleritePdf < BasePdf - def initialize(consolidacao:, motorista:, user_motorista: nil, app_host: nil) + VERDE = '22C55E' + CINZA_BG = 'E5E7EB' + + def initialize(consolidacao:, motorista:, user_motorista: nil, app_host: nil, + pago_em: nil, forma_pagamento: nil) super() - @consolidacao = consolidacao - @motorista = motorista - @user_motorista = user_motorista - @app_host = app_host || ENV.fetch('APP_HOST', 'localhost:3000') - @entregas = consolidacao.consolidacao_entregas.where(motorista_nome: motorista) + @consolidacao = consolidacao + @motorista = motorista + @user_motorista = user_motorista + @app_host = app_host || ENV.fetch('APP_HOST', 'localhost:3000') + @pago_em = pago_em + @forma_pagamento = forma_pagamento + @entregas = consolidacao.consolidacao_entregas.where(motorista_nome: motorista) end private @@ -34,6 +40,8 @@ module Pdf t.columns(0).width = 120 end + desenhar_selo_pagamento + secao "PROVENTOS E DESCONTOS" resumo = @entregas.group(:tipo).count @@ -84,6 +92,26 @@ module Pdf assinaturas(motorista: @motorista) end + # Selo de pagamento: faixa verde "PAGO" (com data/forma) ou cinza "PENDENTE". + def desenhar_selo_pagamento + @pdf.move_down 10 + pago = @pago_em.present? + cor = pago ? VERDE : CINZA_BG + texto = if pago + via = @forma_pagamento.present? ? " · #{@forma_pagamento.to_s.capitalize}" : '' + "✓ PAGO em #{@pago_em.strftime('%d/%m/%Y')}#{via}" + else + "PAGAMENTO PENDENTE" + end + + @pdf.fill_color cor + @pdf.fill_rounded_rectangle [0, @pdf.cursor], 250, 26, 5 + @pdf.fill_color(pago ? 'FFFFFF' : '374151') + @pdf.text_box texto, at: [12, @pdf.cursor - 8], width: 238, size: 10, style: :bold + @pdf.fill_color '000000' + @pdf.move_down 34 + end + # Monta a URL respeitando o esquema do APP_HOST. Se vier sem http(s):// # (ex.: "100.75.222.23:3000") assume http — produção em IP:porta normalmente # não tem TLS. Defina APP_HOST com "https://" se o servidor usar HTTPS. diff --git a/app/views/consolidacao_mailer/pagamento_efetuado.html.erb b/app/views/consolidacao_mailer/pagamento_efetuado.html.erb new file mode 100644 index 0000000..b54028f --- /dev/null +++ b/app/views/consolidacao_mailer/pagamento_efetuado.html.erb @@ -0,0 +1,47 @@ +<%# app/views/consolidacao_mailer/pagamento_efetuado.html.erb %> +
diff --git a/app/views/consolidacoes/index.html.erb b/app/views/consolidacoes/index.html.erb index b334499..e052590 100644 --- a/app/views/consolidacoes/index.html.erb +++ b/app/views/consolidacoes/index.html.erb @@ -13,7 +13,7 @@ <%# ── 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-5 gap-3' do |f| %> + class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-6 grid grid-cols-1 md:grid-cols-6 gap-3' do |f| %> <%= f.text_field :nome, value: params[:nome], placeholder: 'Buscar por nome…', class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5 focus:border-orange-500 focus:outline-none' %> @@ -21,6 +21,10 @@ options_for_select([['Todos os status', ''], ['📝 Rascunho', 'rascunho'], ['✅ Finalizada', 'finalizada']], params[:status]), {}, class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + <%= f.select :pagamento, + options_for_select([['💵 Pagamento (todos)', ''], ['💰 Pagas', 'pago'], ['⏳ Parciais', 'parcial'], ['🕗 Pendentes', 'pendente']], params[:pagamento]), + {}, class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + <%= f.date_field :inicio, value: params[:inicio], class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> @@ -44,6 +48,7 @@📅 <%= l c.data_inicio, format: :short %> → <%= l c.data_fim, format: :short %> diff --git a/app/views/consolidacoes/show.html.erb b/app/views/consolidacoes/show.html.erb index 843e4a7..873c2e1 100644 --- a/app/views/consolidacoes/show.html.erb +++ b/app/views/consolidacoes/show.html.erb @@ -1,5 +1,7 @@ <%# app/views/consolidacoes/show.html.erb %> <% content_for :title, @consolidacao.nome %> +<% formas_pagamento = [['PIX', 'pix'], ['Transferência', 'transferencia'], ['Dinheiro', 'dinheiro']] %> +<% pode_pagar = @consolidacao.finalizada? && policy(@consolidacao).registrar_pagamento? %>
📅 <%= l @consolidacao.data_inicio, format: :short %> → <%= l @consolidacao.data_fim, format: :short %> @@ -31,6 +34,28 @@ <% end %>
<%= cm.motorista_nome %>
+<%= cm.motorista_nome %>
+ <% if @consolidacao.finalizada? %> + <%= badge_pagamento(cm.pago? ? :pago : :pendente) %> + <% end %> +<%= moeda(cm.valor_total) %>
+ <% if cm.pago? %> ++ 💰 Pago em <%= l cm.pago_em, format: :short %> + <% if cm.forma_pagamento.present? %>via <%= cm.forma_pagamento.humanize %><% end %> + <% if cm.pagador.present? %>por <%= cm.pagador.nome_display %><% end %> +
+ <% end %> + + <% if pode_pagar %> +Consolidações finalizadas no período
+ + <% if (@pag_pago_qtd + @pag_pend_qtd).zero? %> +Nenhuma consolidação finalizada no período
+| Consolidação | +Status | +Pago | +A pagar | +
|---|---|---|---|
| + <%= link_to linha[:consolidacao].nome, consolidacao_path(linha[:consolidacao]), + class: 'text-white hover:text-orange-500 font-medium' %> + | +<%= badge_pagamento(linha[:status]) %> | +<%= moeda(linha[:pago]) %> | +<%= moeda(linha[:pendente]) %> | +
Nenhuma consolidação finalizada no período
+Aqui estão seus valores de entregas.
+ <%# ── Aviso de pagamento recém-confirmado ───────────────── %> + <% if @pagamentos_recentes.any? %> +✅ Pagamento confirmado!
++ Você recebeu <%= moeda(cm.valor_total) %> + pela consolidação <%= cm.consolidacao.nome %> +
++ 💰 <%= l cm.pago_em.to_date, format: :short %> + <% if cm.forma_pagamento.present? %>· <%= cm.forma_pagamento.humanize %><% end %> +
+<%= c.nome %>
<%= badge_status(c.status) %> + <% if c.finalizada? %> + <%= badge_pagamento(cm&.pago? ? :pago : :pendente) %> + <% end %>📅 <%= l c.data_inicio, format: :short %> a <%= l c.data_fim, format: :short %>
<%= moeda(cm&.valor_total || 0) %>
+ <% if cm&.pago? %> ++ 💰 Pago em <%= l cm.pago_em.to_date, format: :short %> + <% if cm.forma_pagamento.present? %>via <%= cm.forma_pagamento.humanize %><% end %> +
+ <% end %>