Implantação da função de controle de consolidaçoes pagas e pendentes

This commit is contained in:
2026-06-17 15:49:24 -03:00
parent 292ac31c66
commit e9def7e21a
17 changed files with 526 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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).

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
<%# app/views/consolidacao_mailer/pagamento_efetuado.html.erb %>
<div style="font-family: Arial, sans-serif; max-width: 520px; margin: 0 auto; background: #0a0a0a; border-radius: 12px; overflow: hidden;">
<div style="background: #0a0a0a; padding: 24px; border-left: 6px solid #f97316;">
<h1 style="color: #ffffff; margin: 0; font-size: 20px;">REEM TRANSPORTE</h1>
<p style="color: #f97316; margin: 4px 0 0; font-size: 13px;">Sistema de Logística</p>
</div>
<div style="background: #ffffff; padding: 28px;">
<h2 style="color: #0a0a0a; margin-top: 0;">Olá, <%= @user.nome.split.first %>! ✅</h2>
<p style="color: #374151; font-size: 15px; line-height: 1.6;">
Seu pagamento de entregas foi <strong>efetuado</strong> pela empresa.
</p>
<table style="width: 100%; margin: 20px 0; border-collapse: collapse;">
<tr>
<td style="background: #f3f4f6; padding: 10px 14px; font-weight: bold; color: #374151;">Período</td>
<td style="padding: 10px 14px; color: #0a0a0a;"><%= @consolidacao.nome %></td>
</tr>
<% if @forma.present? %>
<tr>
<td style="background: #f3f4f6; padding: 10px 14px; font-weight: bold; color: #374151;">Forma</td>
<td style="padding: 10px 14px; color: #0a0a0a;"><%= @forma.to_s.humanize %></td>
</tr>
<% end %>
</table>
<div style="background: #22c55e; border-radius: 10px; padding: 20px; text-align: center; margin: 20px 0;">
<p style="color: rgba(0,0,0,0.6); margin: 0; font-size: 12px; font-weight: bold;">VALOR PAGO</p>
<p style="color: #0a0a0a; margin: 6px 0 0; font-size: 32px; font-weight: 900;">
R$ <%= format('%.2f', @valor).gsub('.', ',') %>
</p>
</div>
<p style="text-align: center; margin: 24px 0;">
<a href="<%= @link_painel %>"
style="background: #0a0a0a; color: #f97316; text-decoration: none; padding: 14px 28px; border-radius: 8px; font-weight: bold; display: inline-block;">
Ver meu painel e baixar holerite →
</a>
</p>
<p style="color: #9ca3af; font-size: 12px; text-align: center;">
Entre com seu PIN de 4 números ou escaneie o QR Code do seu holerite.
</p>
</div>
</div>

View File

@@ -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 @@
<div class="flex items-center gap-3 mb-1">
<h3 class="text-white font-bold text-lg"><%= c.nome %></h3>
<%= badge_status(c.status) %>
<% if c.finalizada? %><%= badge_pagamento(c.status_pagamento) %><% end %>
</div>
<p class="text-gray-400 text-sm">
📅 <%= l c.data_inicio, format: :short %> → <%= l c.data_fim, format: :short %>

View File

@@ -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? %>
<div class="max-w-4xl mx-auto">
@@ -7,9 +9,10 @@
<%= link_to '← Consolidações', consolidacoes_path, class: 'text-gray-500 hover:text-orange-500 text-sm' %>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mt-2">
<div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold text-white"><%= @consolidacao.nome %></h1>
<%= badge_status(@consolidacao.status) %>
<% if @consolidacao.finalizada? %><%= badge_pagamento(@consolidacao.status_pagamento) %><% end %>
</div>
<p class="text-gray-400 text-sm mt-1">
📅 <%= l @consolidacao.data_inicio, format: :short %> → <%= l @consolidacao.data_fim, format: :short %>
@@ -31,6 +34,28 @@
<% end %>
</div>
</div>
<%# ── Pagamento da operação inteira ─────────────────────── %>
<% if pode_pagar %>
<div class="flex flex-col sm:flex-row sm:items-center gap-2 mt-4 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4">
<span class="text-gray-400 text-sm font-medium mr-1">💵 Operação inteira:</span>
<% if @consolidacao.status_pagamento != :pago %>
<%= form_with url: registrar_pagamento_consolidacao_path(@consolidacao), method: :post,
data: { turbo_confirm: 'Marcar TODOS os motoristas pendentes desta consolidação como PAGOS?' },
class: 'flex items-center gap-2' do |f| %>
<%= f.select :forma_pagamento, options_for_select(formas_pagamento, 'pix'),
{}, class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5 text-sm' %>
<%= f.submit '💰 Marcar tudo como pago',
class: 'bg-green-600 hover:bg-green-700 text-white font-bold px-4 py-2.5 rounded-lg text-sm cursor-pointer min-h-[44px]' %>
<% end %>
<% end %>
<% if @consolidacao.status_pagamento != :pendente %>
<%= button_to '↩ Estornar tudo', cancelar_pagamento_consolidacao_path(@consolidacao), method: :post,
data: { turbo_confirm: 'Estornar o pagamento de TODOS os motoristas?' },
class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-2.5 rounded-lg text-sm min-h-[44px]' %>
<% end %>
</div>
<% end %>
</div>
<%# Total geral %>
@@ -46,8 +71,39 @@
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<p class="text-white font-bold text-lg"><%= cm.motorista_nome %></p>
<div class="flex items-center gap-2 flex-wrap">
<p class="text-white font-bold text-lg"><%= cm.motorista_nome %></p>
<% if @consolidacao.finalizada? %>
<%= badge_pagamento(cm.pago? ? :pago : :pendente) %>
<% end %>
</div>
<p class="text-orange-500 font-black text-xl"><%= moeda(cm.valor_total) %></p>
<% if cm.pago? %>
<p class="text-green-400 text-xs mt-1">
💰 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 %>
</p>
<% end %>
<% if pode_pagar %>
<div class="mt-2">
<% if cm.pago? %>
<%= button_to '↩ Estornar pagamento', cancelar_pagamento_consolidacao_path(@consolidacao, consolidacao_motorista_id: cm.id), method: :post,
data: { turbo_confirm: "Estornar o pagamento de #{cm.motorista_nome}?" },
class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-3 py-2 rounded-lg text-xs min-h-[40px]' %>
<% else %>
<%= form_with url: registrar_pagamento_consolidacao_path(@consolidacao, consolidacao_motorista_id: cm.id), method: :post,
data: { turbo_confirm: "Marcar #{cm.motorista_nome} como pago?" },
class: 'flex items-center gap-2' do |f| %>
<%= f.select :forma_pagamento, options_for_select(formas_pagamento, 'pix'),
{}, class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-2 py-2 text-xs' %>
<%= f.submit '💰 Marcar pago',
class: 'bg-green-600 hover:bg-green-700 text-white font-bold px-3 py-2 rounded-lg text-xs cursor-pointer min-h-[40px]' %>
<% end %>
<% end %>
</div>
<% end %>
</div>
<div class="flex flex-wrap gap-2">
<%# Preview abre modal %>

View File

@@ -138,6 +138,79 @@
</div>
</div>
<%# ── PAGAMENTOS (pago x pendente) ──────────────────────── %>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
<%# Rosca pago vs a pagar (por valor) %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<h2 class="text-lg font-semibold text-white mb-1">💵 Pagamentos</h2>
<p class="text-gray-400 text-sm mb-4">Consolidações finalizadas no período</p>
<% if (@pag_pago_qtd + @pag_pend_qtd).zero? %>
<div class="text-center py-8 text-gray-500">
<div class="text-3xl mb-2">🧾</div>
<p class="text-sm">Nenhuma consolidação finalizada no período</p>
</div>
<% else %>
<div class="relative h-44 mb-4">
<canvas id="grafico-pagamentos"></canvas>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-green-400">● Pago</span>
<span class="text-white font-semibold">
<%= moeda(@pag_pago_valor) %> · <%= @pag_pago_qtd %> motorista(s)
</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-400">● A pagar</span>
<span class="text-white font-semibold">
<%= moeda(@pag_pend_valor) %> · <%= @pag_pend_qtd %> motorista(s)
</span>
</div>
</div>
<% end %>
</div>
<%# Tabela de consolidações com status de pagamento %>
<div class="xl:col-span-2 bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<h2 class="text-lg font-semibold text-white mb-4">📋 Status de pagamento por consolidação</h2>
<% if @tabela_pagamentos.any? %>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-gray-500 text-left border-b border-white/5">
<th class="py-2 pr-3 font-medium">Consolidação</th>
<th class="py-2 px-3 font-medium">Status</th>
<th class="py-2 px-3 font-medium text-right">Pago</th>
<th class="py-2 pl-3 font-medium text-right">A pagar</th>
</tr>
</thead>
<tbody>
<% @tabela_pagamentos.each do |linha| %>
<tr class="border-b border-white/5">
<td class="py-2.5 pr-3">
<%= link_to linha[:consolidacao].nome, consolidacao_path(linha[:consolidacao]),
class: 'text-white hover:text-orange-500 font-medium' %>
</td>
<td class="py-2.5 px-3"><%= badge_pagamento(linha[:status]) %></td>
<td class="py-2.5 px-3 text-right text-green-400"><%= moeda(linha[:pago]) %></td>
<td class="py-2.5 pl-3 text-right text-gray-400"><%= moeda(linha[:pendente]) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<div class="text-center py-8 text-gray-500">
<div class="text-3xl mb-2">📋</div>
<p class="text-sm">Nenhuma consolidação finalizada no período</p>
</div>
<% end %>
</div>
</div>
<%# Por operação %>
<% if @por_operacao.any? %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
@@ -271,3 +344,47 @@
});
})();
</script>
<%# Rosca de pagamentos (pago x a pagar, por valor) %>
<script>
(function () {
const ctx = document.getElementById('grafico-pagamentos');
if (!ctx || typeof Chart === 'undefined') return;
Chart.getChart(ctx)?.destroy();
const pago = <%= @pag_pago_valor.to_f %>;
const aPagar = <%= @pag_pend_valor.to_f %>;
new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Pago', 'A pagar'],
datasets: [{
data: [pago, aPagar],
backgroundColor: ['#22c55e', '#374151'],
borderColor: '#1a1a1a',
borderWidth: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '62%',
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#0a0a0a',
borderColor: '#22c55e',
borderWidth: 1,
titleColor: '#9ca3af',
bodyColor: '#ffffff',
padding: 12,
callbacks: {
label: (c) => ` ${c.label}: R$ ${c.parsed.toFixed(2).replace('.', ',')}`
}
}
}
}
});
})();
</script>

View File

@@ -6,6 +6,27 @@
<h1 class="text-2xl font-bold text-white mb-1">Olá, <%= current_user.nome.split.first %>! 👋</h1>
<p class="text-gray-400 mb-6">Aqui estão seus valores de entregas.</p>
<%# ── Aviso de pagamento recém-confirmado ───────────────── %>
<% if @pagamentos_recentes.any? %>
<div class="bg-green-600/15 border border-green-600/40 rounded-2xl p-5 mb-6">
<p class="text-green-400 font-bold text-sm uppercase tracking-wide mb-2">✅ Pagamento confirmado!</p>
<div class="space-y-2">
<% @pagamentos_recentes.each do |cm| %>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1">
<p class="text-white text-sm">
Você recebeu <span class="font-black text-green-400"><%= moeda(cm.valor_total) %></span>
pela consolidação <span class="font-semibold"><%= cm.consolidacao.nome %></span>
</p>
<p class="text-gray-400 text-xs">
💰 <%= l cm.pago_em.to_date, format: :short %>
<% if cm.forma_pagamento.present? %>· <%= cm.forma_pagamento.humanize %><% end %>
</p>
</div>
<% end %>
</div>
</div>
<% end %>
<%# ── 2 Cards grandes ─────────────────────────────────── %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
@@ -41,14 +62,23 @@
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 flex-wrap">
<p class="text-white font-bold"><%= c.nome %></p>
<%= badge_status(c.status) %>
<% if c.finalizada? %>
<%= badge_pagamento(cm&.pago? ? :pago : :pendente) %>
<% end %>
</div>
<p class="text-gray-400 text-sm mt-1">
📅 <%= l c.data_inicio, format: :short %> a <%= l c.data_fim, format: :short %>
</p>
<p class="text-orange-500 font-black text-xl mt-1"><%= moeda(cm&.valor_total || 0) %></p>
<% if cm&.pago? %>
<p class="text-green-400 text-xs mt-1">
💰 Pago em <%= l cm.pago_em.to_date, format: :short %>
<% if cm.forma_pagamento.present? %>via <%= cm.forma_pagamento.humanize %><% end %>
</p>
<% end %>
</div>
<% if c.finalizada? %>
<%= link_to '⬇️ Baixar meu holerite',

View File

@@ -34,7 +34,9 @@ Rails.application.routes.draw do
get :wizard # Passo 1 — selecionar motorista
post :finalizar
post :arquivar
post :reativar # Restaura uma consolidação arquivada
post :reativar # Restaura uma consolidação arquivada
post :registrar_pagamento # Marca 1 motorista (ou todos) como pago
post :cancelar_pagamento # Estorna o pagamento de 1 motorista (ou todos)
get :preview_holerite
get :gerar_pdf_relatorio
get :gerar_pdf_holerite

View File

@@ -0,0 +1,9 @@
# Registra o pagamento de cada motorista dentro de uma consolidação:
# QUEM pagou (pago_por), QUANDO (pago_em, NULL = pendente) e a FORMA (PIX etc.).
class AddPagamentoToConsolidacaoMotoristas < ActiveRecord::Migration[7.1]
def change
add_column :consolidacao_motoristas, :pago_em, :datetime unless column_exists?(:consolidacao_motoristas, :pago_em)
add_column :consolidacao_motoristas, :pago_por, :integer unless column_exists?(:consolidacao_motoristas, :pago_por)
add_column :consolidacao_motoristas, :forma_pagamento, :string unless column_exists?(:consolidacao_motoristas, :forma_pagamento)
end
end