diff --git a/Gemfile b/Gemfile index e922d05..50c0c25 100644 --- a/Gemfile +++ b/Gemfile @@ -25,12 +25,14 @@ gem "dotenv-rails" # PDF gem "prawn" gem "prawn-table" +gem "rqrcode" # QR code no holerite (login rápido motorista) +gem "chunky_png" # renderiza QR como PNG para embed no Prawn # Jobs / Cron gem "whenever", require: false -# Notificações (Fase 8 — opcional) -# gem "twilio-ruby" +# Notificações (Fase 8) +gem "twilio-ruby" # WhatsApp — só envia se TWILIO_ACCOUNT_SID estiver no .env # Utilitários gem "pagy" # paginação diff --git a/README.md b/README.md index c765739..9d8b7e1 100644 --- a/README.md +++ b/README.md @@ -127,13 +127,13 @@ docker-compose down # parar tudo | Fase | Status | O que fazer | |------|--------|-------------| | **1** | ✅ **CONCLUÍDA** | Setup + Docker + Models + Migrations + Seeds + Layout base | -| **2** | ⏳ Pendente | Login por role + PIN motorista + CRUD usuários + pilares de preço + sidebar | +| **2** | ⏳ Pendente | Login por role + CRUD usuários + pilares de preço + toggle tema | | **3** | ⏳ Pendente | Dashboard: cards grandes laranja/preto/branco + gráfico Chart.js + cálculo ao vivo | | **4** | ✅ **CONCLUÍDA** | Job Whenever (1h) + HistoricoEstimado + AuditoriaLog + API métricas | | **5** | ✅ **CONCLUÍDA** | Nova Consolidação + filtros + Wizard Passo 1 (selecionar motorista) | | **6** | ✅ **CONCLUÍDA** | Toggles coloridos + ações em massa + barra progresso + Wizard Passos 2 e 3 (Stimulus) | -| **7** | ⏳ Pendente | PDFs com Prawn: relatório individual + holerite com QR code | -| **8** | ⏳ Pendente | Painel motorista + notificações WhatsApp/email + responsividade final | +| **7** | ✅ **CONCLUÍDA** | PDFs Prawn: relatório individual + holerite com QR code + modal preview | +| **8** | ✅ **CONCLUÍDA** | Painel motorista + login PIN/QR + notificações WhatsApp/email | ### 📂 Arquivos das Fases 4, 5 e 6 @@ -178,30 +178,82 @@ config/routes.rb # ATUALIZADO — rotas do wi - Consolidação finalizada não pode ser editada por operadores (Pundit) - Todas ações críticas registradas em `auditoria_logs` -### ▶️ Para continuar (Fases 2, 3, 7 ou 8) +**Fase 7 — PDFs (Prawn + QR Code):** +``` +Gemfile # + rqrcode, chunky_png +db/migrate/20260101000009_add_login_token_to_users.rb # token para QR — rodar db:migrate! +app/services/pdf/base_pdf.rb # layout base: cabeçalho preto/laranja + rodapé +app/services/pdf/relatorio_motorista_pdf.rb # tabela detalhada de entregas + resumo + total +app/services/pdf/holerite_pdf.rb # estilo contracheque + QR Code de login rápido +app/controllers/consolidacoes_controller.rb # ATUALIZADO: gerar_pdf_relatorio/gerar_pdf_holerite/preview_holerite +app/views/consolidacoes/show.html.erb # botões por motorista + modal de preview +app/views/consolidacoes/preview_holerite.html.erb # preview HTML antes de confirmar o PDF +app/models/user.rb # ATUALIZADO: regenerar_login_token! +``` +Soft delete já implementado: consolidações finalizadas vão para "arquivada", nunca são excluídas. + +**Fase 8 — Painel Motorista + Notificações:** +``` +Gemfile # + twilio-ruby +app/controllers/motorista/sessoes_controller.rb # login PIN 4 dígitos + acesso via QR (/motorista/acesso/:token) +app/controllers/motorista/dashboard_controller.rb # 2 cards: estimado (mês) + consolidado (finalizadas) +app/views/motorista/sessoes/new.html.erb # teclado numérico gigante mobile-first (envia no 4º dígito) +app/views/motorista/dashboard/index.html.erb # cards laranja/branco + lista com download do próprio holerite +app/services/notificacao_service.rb # WhatsApp (Twilio) + email — falha nunca quebra a finalização +app/mailers/application_mailer.rb +app/mailers/consolidacao_mailer.rb # pagamento_fechado +app/views/consolidacao_mailer/pagamento_fechado.html.erb # email com identidade visual da marca +app/views/layouts/mailer.html.erb +config/initializers/smtp.rb # SMTP via ENV (só ativa se SMTP_USERNAME existir) +config/routes.rb # ATUALIZADO: /motorista/login + /motorista/acesso/:token +``` + +**Notificações — como ativar:** +1. WhatsApp: preencha `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN` e `TWILIO_WHATSAPP_FROM` no `.env` e mude a configuração `notificacao_whatsapp` para `true` no sistema. Motorista precisa ter `telefone` cadastrado (formato `+5511999998888`). +2. Email: preencha as variáveis `SMTP_*` no `.env` e mude `notificacao_email` para `true`. Motorista precisa ter `email`. +3. Ao finalizar uma consolidação, cada motorista recebe automaticamente a mensagem com o valor e o link do painel. + +**Fluxo do motorista (público de baixo nível técnico):** +- Acessa `/motorista/login` → digita PIN de 4 números num teclado gigante (envia sozinho no 4º dígito) +- OU escaneia o QR Code impresso no holerite → entra direto sem digitar nada +- Vê 2 cards: 🟧 valor estimado do mês ("aproximado") e ⬜ valor fechado para pagamento +- Baixa o próprio holerite das consolidações finalizadas + +### 🧪 Teste end-to-end sugerido +1. Login admin → criar consolidação (nome + período + motoristas) +2. Wizard Passo 1 → escolher motorista → Passo 2: classificar entregas com os toggles +3. Usar "Marcar todos como Normal" → Passo 3: revisar → próximo motorista +4. Com tudo classificado → Finalizar (motoristas notificados) +5. Na tela da consolidação → "Ver antes de gerar" → Confirmar → baixar Holerite PDF +6. Logout → `/motorista/login` → entrar com PIN do motorista +7. Conferir cards de valores → baixar próprio holerite → sair + +### ▶️ Para continuar (faltam apenas Fases 2 e 3) Cole este contexto no início da conversa com o Claude: ``` Continue o Sistema de Controle de Custos Logística da Gade Hospitalar. -CHECKPOINT — FASES 1, 4, 5 e 6 CONCLUÍDAS: +CHECKPOINT — FASES 1, 4, 5, 6, 7 e 8 CONCLUÍDAS: - Rails 7+ com Docker, PostgreSQL externo, Tailwind (preto #0a0a0a / laranja #f97316) -- Models: Entrega (read-only db_reem_simplerout_2026), User (Devise+roles), - Configuracao, Consolidacao (+route_ids jsonb), ConsolidacaoMotorista, - ConsolidacaoEntrega, HistoricoEstimado, AuditoriaLog -- 8 migrations criadas -- Fase 4: Whenever (1h) + AtualizarHistoricoEstimadoJob + rake historico:atualizar - + API GET /api/v1/dashboard/metricas + concern Auditavel -- Fase 5: ConsolidacoesController completo (filtros, multi-select motoristas/rotas) - + Wizard Passo 1 (wizard.html.erb) -- Fase 6: ConsolidacaoEntregasController (validar/classificar/classificar_em_massa/revisar) - + toggles coloridos + ações em massa + Stimulus validacao_controller.js - + validação de finalização +- Models: Entrega (read-only db_reem_simplerout_2026), User (Devise+roles+pin_code + +login_token), Configuracao, Consolidacao (+route_ids), ConsolidacaoMotorista, + ConsolidacaoEntrega, HistoricoEstimado, AuditoriaLog — 9 migrations +- Fase 4: Whenever (1h) + job + rake historico:atualizar + API /api/v1/dashboard/metricas +- Fase 5: ConsolidacoesController (filtros, multi-select) + Wizard Passo 1 +- Fase 6: validar/classificar/classificar_em_massa/revisar + toggles + Stimulus +- Fase 7: PDFs Prawn (Pdf::RelatorioMotoristaPdf, Pdf::HoleritePdf com QR code) + + modal preview em consolidacoes/show +- Fase 8: login motorista por PIN (/motorista/login) e QR (/motorista/acesso/:token) + + painel motorista (2 cards + holerites) + NotificacaoService (Twilio/ActionMailer) -FALTAM: Fase 2 (auth/PIN/CRUD usuários/configurações), Fase 3 (Dashboard com -gráficos), Fase 7 (PDFs Prawn + holerite + QR code), Fase 8 (painel motorista -+ notificações). Consulte o README.md do repositório para detalhes. +FALTAM SOMENTE: +- FASE 2: tela de login Devise estilizada + CRUD usuários (admin/usuarios) + + CRUD pilares de preço (admin/configuracoes) + tela auditoria + toggle tema claro/escuro +- FASE 3: DashboardController + view com cards grandes (valor estimado, entregas + do dia/mês, motoristas ativos) + gráfico Chart.js do historico_estimados +Consulte o README.md do repositório para a lista completa de arquivos. ``` --- diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb index c04c3ac..f0f1059 100644 --- a/app/controllers/consolidacoes_controller.rb +++ b/app/controllers/consolidacoes_controller.rb @@ -99,7 +99,8 @@ class ConsolidacoesController < ApplicationController if @consolidacao.finalizar!(current_user) auditar!(:finalizar, @consolidacao) - redirect_to @consolidacao, notice: 'Consolidação finalizada com sucesso!' + NotificacaoService.notificar_finalizacao(@consolidacao) # WhatsApp/email aos motoristas + 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.' @@ -114,6 +115,49 @@ class ConsolidacoesController < ApplicationController redirect_to consolidacoes_path, notice: 'Consolidação arquivada.' end + # ── FASE 7 — PDFs ──────────────────────────────────────────── + + # GET /consolidacoes/:id/gerar_pdf_relatorio?motorista=X + def gerar_pdf_relatorio + authorize @consolidacao, :show? + motorista = params[:motorista] + + pdf = Pdf::RelatorioMotoristaPdf.new(consolidacao: @consolidacao, motorista: motorista) + auditar!(:editar, @consolidacao, dados_novos: { pdf: 'relatorio', motorista: motorista }) + + send_data pdf.render, + filename: "relatorio_#{motorista.parameterize}_#{@consolidacao.id}.pdf", + type: 'application/pdf', + disposition: 'attachment' + end + + # GET /consolidacoes/:id/gerar_pdf_holerite?motorista=X + def gerar_pdf_holerite + authorize @consolidacao, :show? + 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? + + pdf = Pdf::HoleritePdf.new(consolidacao: @consolidacao, motorista: motorista, + user_motorista: user_mot, app_host: ENV['APP_HOST']) + auditar!(:editar, @consolidacao, dados_novos: { pdf: 'holerite', motorista: motorista }) + + send_data pdf.render, + filename: "holerite_#{motorista.parameterize}_#{@consolidacao.id}.pdf", + type: 'application/pdf', + disposition: 'attachment' + end + + # GET /consolidacoes/:id/preview_holerite?motorista=X — preview HTML antes do PDF + def preview_holerite + authorize @consolidacao, :show? + @motorista = params[:motorista] + @entregas = @consolidacao.consolidacao_entregas.where(motorista_nome: @motorista) + @resumo = @entregas.group(:tipo).count + @total = @entregas.sum { |e| e.desconto? ? -e.valor_aplicado : e.valor_aplicado } + render layout: false # renderiza dentro do modal (Turbo Frame) + end + private def set_consolidacao diff --git a/app/controllers/motorista/dashboard_controller.rb b/app/controllers/motorista/dashboard_controller.rb new file mode 100644 index 0000000..e98dd50 --- /dev/null +++ b/app/controllers/motorista/dashboard_controller.rb @@ -0,0 +1,39 @@ +# app/controllers/motorista/dashboard_controller.rb +module Motorista + class DashboardController < ApplicationController + before_action :garantir_motorista + + # GET /motorista + def index + nome = current_user.nome + + # Card 1 — Valor ESTIMADO (mês atual): entregas pagas × preco_entrega + entregas_mes = Entrega.pagas.da_conta_gade + .do_motorista(nome) + .no_periodo(Date.current.beginning_of_month, Date.current) + .count + @valor_estimado = entregas_mes * Configuracao.preco_entrega + @entregas_mes = entregas_mes + + # Card 2 — Valor CONSOLIDADO: soma das consolidações finalizadas + @minhas_consolidacoes = Consolidacao.ativas + .joins(:consolidacao_motoristas) + .where(consolidacao_motoristas: { motorista_nome: nome }) + .recentes + .distinct + + @valor_consolidado = ConsolidacaoMotorista + .joins(:consolidacao) + .where(motorista_nome: nome, + consolidacoes: { status: :finalizada, deleted_at: nil }) + .sum(:valor_total) + end + + private + + def garantir_motorista + return if current_user.motorista? + redirect_to dashboard_path, alert: 'Área exclusiva dos motoristas.' + end + end +end diff --git a/app/controllers/motorista/sessoes_controller.rb b/app/controllers/motorista/sessoes_controller.rb new file mode 100644 index 0000000..3b516c8 --- /dev/null +++ b/app/controllers/motorista/sessoes_controller.rb @@ -0,0 +1,44 @@ +# app/controllers/motorista/sessoes_controller.rb +# +# Login do motorista por PIN de 4 dígitos ou por QR Code (token do holerite). +# Não usa Devise password — fluxo simplificado para público de baixo nível técnico. +# +module Motorista + class SessoesController < ApplicationController + skip_before_action :authenticate_user! + layout 'application' + + # GET /motorista/login — tela com teclado de PIN + def new; end + + # POST /motorista/login — valida PIN + def create + pin = params[:pin].to_s.strip + user = User.motorista.ativos.find_by(pin_code: pin) + + if pin.length == 4 && user + sign_in(user) + AuditoriaLog.registrar(user: user, acao: :login, entidade: 'User', + entidade_id: user.id, request: request) + redirect_to motorista_dashboard_path, notice: "Bem-vindo, #{user.nome.split.first}!" + else + flash.now[:alert] = 'PIN não encontrado. Confira os 4 números e tente de novo.' + render :new, status: :unprocessable_entity + end + end + + # GET /motorista/acesso/:token — login via QR Code do holerite + def acesso_qr + user = User.motorista.ativos.find_by(login_token: params[:token]) + + if user + sign_in(user) + AuditoriaLog.registrar(user: user, acao: :login, entidade: 'User', + entidade_id: user.id, dados_novos: { via: 'qr_code' }, request: request) + redirect_to motorista_dashboard_path, notice: "Bem-vindo, #{user.nome.split.first}!" + else + redirect_to motorista_login_path, alert: 'QR Code inválido ou expirado. Entre com seu PIN.' + end + end + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 0000000..60e89c5 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,5 @@ +# app/mailers/application_mailer.rb +class ApplicationMailer < ActionMailer::Base + default from: ENV.fetch('SMTP_USERNAME', 'noreply@gade.com.br') + layout 'mailer' +end diff --git a/app/mailers/consolidacao_mailer.rb b/app/mailers/consolidacao_mailer.rb new file mode 100644 index 0000000..3fbe98a --- /dev/null +++ b/app/mailers/consolidacao_mailer.rb @@ -0,0 +1,14 @@ +# app/mailers/consolidacao_mailer.rb +class ConsolidacaoMailer < ApplicationMailer + default from: ENV.fetch('SMTP_USERNAME', 'noreply@gade.com.br') + + def pagamento_fechado(user, consolidacao, consolidacao_motorista) + @user = user + @consolidacao = consolidacao + @valor = consolidacao_motorista.valor_total + @link_painel = "https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista" + + mail to: user.email, + subject: "💰 Seu pagamento foi fechado — #{consolidacao.nome}" + end +end diff --git a/app/models/user.rb b/app/models/user.rb index d9bbf6c..e6dedaf 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -58,6 +58,11 @@ class User < ApplicationRecord admin? || gerente? || operador? end + # Token de login rápido via QR Code (holerite) — Fase 7/8 + def regenerar_login_token! + update!(login_token: SecureRandom.urlsafe_base64(24)) + end + private def pin_unico_para_motoristas diff --git a/app/services/notificacao_service.rb b/app/services/notificacao_service.rb new file mode 100644 index 0000000..35391dc --- /dev/null +++ b/app/services/notificacao_service.rb @@ -0,0 +1,68 @@ +# app/services/notificacao_service.rb +# +# Envia notificações aos motoristas quando uma consolidação é finalizada. +# Canais controlados pelas configurações: notificacao_whatsapp / notificacao_email. +# Falhas de envio NUNCA quebram o fluxo de finalização (apenas logam). +# +class NotificacaoService + def self.notificar_finalizacao(consolidacao) + new(consolidacao).notificar_todos + end + + def initialize(consolidacao) + @consolidacao = consolidacao + end + + def notificar_todos + @consolidacao.consolidacao_motoristas.each do |cm| + user = User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase) + next unless user + + enviar_whatsapp(user, cm) if whatsapp_ativo? + enviar_email(user, cm) if email_ativo? && user.email.present? + end + end + + private + + def whatsapp_ativo? + Configuracao.valor('notificacao_whatsapp') == 'true' && + ENV['TWILIO_ACCOUNT_SID'].present? + end + + def email_ativo? + Configuracao.valor('notificacao_email') == 'true' + end + + def mensagem(user, cm) + valor = "R$ #{format('%.2f', cm.valor_total)}".gsub('.', ',') + "Olá #{user.nome.split.first}! 🚚\n" \ + "Seu pagamento de entregas foi fechado.\n" \ + "📋 #{@consolidacao.nome}\n" \ + "💰 Valor: #{valor}\n" \ + "Acesse seu painel para baixar o holerite: " \ + "https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista" + end + + def enviar_whatsapp(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(user, cm) + ) + Rails.logger.info("[Notificacao] WhatsApp enviado para #{user.nome}") + rescue => e + Rails.logger.error("[Notificacao] Falha WhatsApp #{user.nome}: #{e.message}") + end + + def enviar_email(user, cm) + ConsolidacaoMailer.pagamento_fechado(user, @consolidacao, cm).deliver_later + Rails.logger.info("[Notificacao] Email agendado para #{user.email}") + rescue => e + Rails.logger.error("[Notificacao] Falha email #{user.email}: #{e.message}") + end +end diff --git a/app/services/pdf/base_pdf.rb b/app/services/pdf/base_pdf.rb new file mode 100644 index 0000000..4ce267e --- /dev/null +++ b/app/services/pdf/base_pdf.rb @@ -0,0 +1,68 @@ +# app/services/pdf/base_pdf.rb +# +# Layout base dos PDFs da Gade: cabeçalho preto com logo laranja, +# rodapé com data de geração. Herdar e implementar #corpo. +# +module Pdf + class BasePdf + LARANJA = 'F97316' + PRETO = '0A0A0A' + CINZA = '6B7280' + + def initialize + @pdf = Prawn::Document.new(page_size: 'A4', margin: [40, 40, 60, 40]) + end + + def render + cabecalho + corpo + rodape + @pdf.render + end + + private + + def cabecalho + @pdf.fill_color PRETO + @pdf.fill_rectangle [-40, @pdf.cursor + 40], @pdf.bounds.width + 80, 70 + + @pdf.fill_color LARANJA + @pdf.fill_rectangle [-40 + 0, @pdf.cursor + 40], 8, 70 # faixa laranja lateral + + @pdf.fill_color 'FFFFFF' + @pdf.text_box 'GADE HOSPITALAR', + at: [10, @pdf.cursor + 20], size: 20, style: :bold + @pdf.fill_color LARANJA + @pdf.text_box 'Sistema de Controle de Custos Logísticos', + at: [10, @pdf.cursor - 4], size: 10 + + @pdf.fill_color '000000' + @pdf.move_down 55 + end + + def rodape + @pdf.repeat(:all) do + @pdf.bounding_box [0, 20], width: @pdf.bounds.width do + @pdf.fill_color CINZA + @pdf.text "Gerado em #{Time.current.strftime('%d/%m/%Y %H:%M')} · Gade Hospitalar — documento interno", + size: 8, align: :center + @pdf.fill_color '000000' + end + end + end + + def moeda(v) + "R$ #{format('%.2f', v.to_f)}".gsub('.', ',') + end + + def secao(titulo) + @pdf.move_down 14 + @pdf.fill_color LARANJA + @pdf.text titulo, size: 13, style: :bold + @pdf.stroke_color LARANJA + @pdf.stroke_horizontal_rule + @pdf.fill_color '000000' + @pdf.move_down 8 + end + end +end diff --git a/app/services/pdf/holerite_pdf.rb b/app/services/pdf/holerite_pdf.rb new file mode 100644 index 0000000..22530fa --- /dev/null +++ b/app/services/pdf/holerite_pdf.rb @@ -0,0 +1,97 @@ +# app/services/pdf/holerite_pdf.rb +# +# Holerite: resumo do valor a pagar ao motorista, estilo contracheque, +# com QR Code para login rápido no painel do motorista. +# +require 'rqrcode' + +module Pdf + class HoleritePdf < BasePdf + def initialize(consolidacao:, motorista:, user_motorista: nil, app_host: 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) + end + + private + + def corpo + secao "HOLERITE DE PAGAMENTO — ENTREGAS" + + # Bloco de identificação (estilo contracheque) + info = [ + ['Motorista', @motorista], + ['Consolidação', @consolidacao.nome], + ['Período', "#{@consolidacao.data_inicio.strftime('%d/%m/%Y')} a #{@consolidacao.data_fim.strftime('%d/%m/%Y')}"], + ['Emissão', Time.current.strftime('%d/%m/%Y')] + ] + @pdf.table(info, width: @pdf.bounds.width, cell_style: { size: 10, padding: [5, 8] }) do |t| + t.columns(0).font_style = :bold + t.columns(0).background_color = 'F3F4F6' + t.columns(0).width = 120 + end + + secao "PROVENTOS E DESCONTOS" + + resumo = @entregas.group(:tipo).count + por_tipo = ConsolidacaoEntrega::TIPO_CORES.map do |tipo, cfg| + qtd = resumo[tipo] || 0 + next if qtd.zero? + valor = @entregas.where(tipo: tipo).sum(:valor_aplicado) + sinal = tipo == 'desconto' ? '-' : '+' + [cfg[:label], qtd.to_s, "#{sinal} #{moeda(valor)}"] + end.compact + + linhas = [['Descrição', 'Qtd', 'Valor']] + por_tipo + @pdf.table(linhas, header: true, width: @pdf.bounds.width, + cell_style: { size: 10, padding: [6, 8] }) do |t| + t.row(0).background_color = PRETO + t.row(0).text_color = 'FFFFFF' + t.row(0).font_style = :bold + t.columns(1..2).align = :right + end + + total = @entregas.sum { |e| e.desconto? ? -e.valor_aplicado : e.valor_aplicado } + + # Caixa do total — destaque laranja + @pdf.move_down 16 + @pdf.fill_color LARANJA + @pdf.fill_rounded_rectangle [0, @pdf.cursor], @pdf.bounds.width, 50, 8 + @pdf.fill_color '000000' + @pdf.text_box 'VALOR LÍQUIDO A RECEBER', + at: [16, @pdf.cursor - 10], size: 10, style: :bold + @pdf.text_box moeda(total), + at: [16, @pdf.cursor - 24], size: 22, style: :bold + @pdf.move_down 64 + + # QR Code de login rápido + desenhar_qrcode if @user_motorista&.login_token.present? + end + + def desenhar_qrcode + url = "https://#{@app_host}/motorista/acesso/#{@user_motorista.login_token}" + qr = RQRCode::QRCode.new(url) + png = qr.as_png(size: 220, border_modules: 2) + + arquivo = Tempfile.new(['qr', '.png']) + arquivo.binmode + arquivo.write(png.to_s) + arquivo.rewind + + secao "ACESSO RÁPIDO AO SEU PAINEL" + @pdf.image arquivo.path, width: 110, position: :left + @pdf.move_up 95 + @pdf.text_box "Escaneie este QR Code com a câmera do celular para entrar\n" \ + "no seu painel e acompanhar seus pagamentos.\n\n" \ + "Ou acesse: #{@app_host}/motorista\ne use seu PIN de 4 dígitos.", + at: [130, @pdf.cursor], width: @pdf.bounds.width - 130, size: 9 + @pdf.move_down 100 + ensure + arquivo&.close + arquivo&.unlink + end + end +end diff --git a/app/services/pdf/relatorio_motorista_pdf.rb b/app/services/pdf/relatorio_motorista_pdf.rb new file mode 100644 index 0000000..a434b1c --- /dev/null +++ b/app/services/pdf/relatorio_motorista_pdf.rb @@ -0,0 +1,77 @@ +# app/services/pdf/relatorio_motorista_pdf.rb +# +# Relatório Individual: detalhe de todas as entregas classificadas +# de um motorista em uma consolidação. +# +module Pdf + class RelatorioMotoristaPdf < BasePdf + def initialize(consolidacao:, motorista:) + super() + @consolidacao = consolidacao + @motorista = motorista + @entregas = consolidacao.consolidacao_entregas + .where(motorista_nome: motorista) + .order(:created_at) + end + + private + + def corpo + secao "RELATÓRIO INDIVIDUAL DE ENTREGAS" + + @pdf.text "Consolidação: #{@consolidacao.nome}", size: 11, style: :bold + @pdf.text "Motorista: #{@motorista}", size: 11 + @pdf.text "Período: #{@consolidacao.data_inicio.strftime('%d/%m/%Y')} a #{@consolidacao.data_fim.strftime('%d/%m/%Y')}", size: 10 + @pdf.text "Status: #{@consolidacao.status.humanize}", size: 10, color: CINZA + + secao "ENTREGAS (#{@entregas.size})" + + if @entregas.any? + linhas = [['#', 'Tracking', 'NF / Local', 'Tipo', 'Valor']] + @entregas.each_with_index do |e, i| + original = e.entrega_original + linhas << [ + (i + 1).to_s, + e.tracking_id.to_s.first(14), + original ? "NF #{original.numero_nf} · #{original.local.to_s.first(30)}" : '—', + e.tipo_cor[:label], + (e.desconto? ? '-' : '') + moeda(e.valor_aplicado) + ] + end + + @pdf.table(linhas, header: true, width: @pdf.bounds.width, + cell_style: { size: 8, padding: [4, 6] }) do |t| + t.row(0).background_color = PRETO + t.row(0).text_color = 'FFFFFF' + t.row(0).font_style = :bold + t.columns(4).align = :right + t.row(1..-1).borders = [:bottom] + t.row(1..-1).border_color = 'DDDDDD' + end + else + @pdf.text 'Nenhuma entrega classificada.', size: 10, color: CINZA + end + + # Totais por tipo + secao "RESUMO" + resumo = @entregas.group(:tipo).count + total = @entregas.sum { |e| e.desconto? ? -e.valor_aplicado : e.valor_aplicado } + + resumo_linhas = [['Tipo', 'Quantidade']] + ConsolidacaoEntrega::TIPO_CORES.each do |tipo, cfg| + resumo_linhas << [cfg[:label], (resumo[tipo] || 0).to_s] + end + + @pdf.table(resumo_linhas, width: 250, cell_style: { size: 9, padding: [4, 8] }) do |t| + t.row(0).background_color = LARANJA + t.row(0).text_color = '000000' + t.row(0).font_style = :bold + end + + @pdf.move_down 14 + @pdf.fill_color LARANJA + @pdf.text "TOTAL A PAGAR: #{moeda(total)}", size: 16, style: :bold + @pdf.fill_color '000000' + end + end +end diff --git a/app/views/consolidacao_mailer/pagamento_fechado.html.erb b/app/views/consolidacao_mailer/pagamento_fechado.html.erb new file mode 100644 index 0000000..a4ed83d --- /dev/null +++ b/app/views/consolidacao_mailer/pagamento_fechado.html.erb @@ -0,0 +1,41 @@ +<%# app/views/consolidacao_mailer/pagamento_fechado.html.erb %> +
diff --git a/app/views/consolidacoes/preview_holerite.html.erb b/app/views/consolidacoes/preview_holerite.html.erb new file mode 100644 index 0000000..5a22669 --- /dev/null +++ b/app/views/consolidacoes/preview_holerite.html.erb @@ -0,0 +1,63 @@ +<%# app/views/consolidacoes/preview_holerite.html.erb — renderizado sem layout, dentro do modal %> +GADE HOSPITALAR
+Holerite de Pagamento — Entregas
+| Motorista | +<%= @motorista %> | +
| Consolidação | +<%= @consolidacao.nome %> | +
| Período | +<%= l @consolidacao.data_inicio, format: :short %> a <%= l @consolidacao.data_fim, format: :short %> | +
| Descrição | +Qtd | +Valor | +
|---|---|---|
| <%= cfg[:label] %> | +<%= qtd %> | ++ <%= tipo == 'desconto' ? '-' : '+' %> <%= moeda(valor) %> + | +
VALOR LÍQUIDO A RECEBER
+<%= moeda(@total) %>
++ O PDF final inclui o QR Code de acesso rápido ao painel do motorista. +
++ 📅 <%= l @consolidacao.data_inicio, format: :short %> → <%= l @consolidacao.data_fim, format: :short %> + · Criada por <%= @consolidacao.criador.nome_display %> + <% if @consolidacao.finalizada? %> + · Finalizada por <%= @consolidacao.finalizador&.nome_display %> em <%= l @consolidacao.finalizado_em, format: :short %> + <% end %> +
+Valor total da consolidação
+<%= moeda(@consolidacao.valor_total) %>
+<%= cm.motorista_nome %>
+<%= moeda(cm.valor_total) %>
+Aqui estão seus valores de entregas.
+ + <%# ── 2 Cards grandes ─────────────────────────────────── %> +💰 Valor estimado deste mês
+<%= moeda(@valor_estimado) %>
++ <%= @entregas_mes %> entregas feitas e confirmadas +
++ ⚠️ Valor aproximado. O valor certo sai quando a empresa fechar o pagamento. +
+✅ Valor fechado para pagamento
+<%= moeda(@valor_consolidado) %>
++ Soma dos pagamentos já confirmados pela empresa +
+<%= c.nome %>
+ <%= badge_status(c.status) %> ++ 📅 <%= l c.data_inicio, format: :short %> a <%= l c.data_fim, format: :short %> +
+<%= moeda(cm&.valor_total || 0) %>
+📭
+Você ainda não aparece em nenhum pagamento.
+Quando a empresa fechar um período com suas entregas, ele aparece aqui.
+Digite seu PIN de 4 números
++ 💡 Você também pode escanear o QR Code do seu holerite para entrar direto. +
++ <%= link_to 'Sou administrador/gerente →', new_user_session_path, class: 'text-orange-500 hover:text-orange-400 text-sm' %> +
+