diff --git a/app/controllers/admin/romaneios_controller.rb b/app/controllers/admin/romaneios_controller.rb index cd0dfbb..ad7d82b 100644 --- a/app/controllers/admin/romaneios_controller.rb +++ b/app/controllers/admin/romaneios_controller.rb @@ -17,6 +17,9 @@ class Admin::RomaneiosController < ApplicationController @romaneios = Romaneio.recentes.includes(:romaneio_veiculos).limit(50) @operacoes_agrupadas = Operacao.agrupadas_por_mes @data_padrao = Date.current + # Nome ORIGINAL do logo escolhido (a descricao), para a tela mostrar o mesmo + # que o operador enviou; nil = ainda usando o logo da GADE do repositório. + @logo_atual = Configuracao.find_by(chave: 'romaneio_logo')&.descricao end def show @@ -61,6 +64,47 @@ class Admin::RomaneiosController < ApplicationController redirect_to admin_romaneios_path, alert: e.message end + # ── Logo do cabeçalho ────────────────────────────────────────────────────── + # O romaneio é documento do CLIENTE, não da Reem: quando a operação muda de + # contratante, trocar o logo não pode exigir deploy. + # + # PNG/JPG apenas porque é o que o Prawn embute — um SVG aceito aqui sairia como + # folha SEM logo, sem erro nenhum, e ninguém descobriria antes de imprimir. + LOGO_TIPOS = { 'image/png' => '.png', 'image/jpeg' => '.jpg' }.freeze + LOGO_TAMANHO_MAX = 2.megabytes + + def atualizar_logo + authorize Romaneio + + arquivo = params[:logo] + return redirect_to(admin_romaneios_path, alert: 'Escolha o arquivo do logo (PNG ou JPG).') if arquivo.blank? + + extensao = LOGO_TIPOS[arquivo.content_type.to_s] + if extensao.nil? + return redirect_to(admin_romaneios_path, + alert: 'O logo precisa ser PNG ou JPG — é o que o PDF consegue embutir.') + end + if arquivo.size > LOGO_TAMANHO_MAX + return redirect_to(admin_romaneios_path, + alert: 'Logo acima de 2 MB. Reduza a imagem antes de enviar.') + end + + # Nome GERADO por nós: o nome que vem do navegador é entrada do usuário e não + # entra em caminho de arquivo. O original fica só na descrição, para a tela + # mostrar "Logo: LOGOGADE.png" como o operador reconhece. + nome = "logo-#{Time.current.strftime('%Y%m%d%H%M%S')}#{extensao}" + destino = Pdf::RomaneioPdf.pasta_logos + FileUtils.mkdir_p(destino) + File.binwrite(destino.join(nome), arquivo.read) + + config = Configuracao.find_or_initialize_by(chave: 'romaneio_logo') + config.update!(valor: nome, descricao: arquivo.original_filename.to_s.first(120), + updated_by: current_user&.id) + + redirect_to admin_romaneios_path, + notice: "Logo atualizado: #{arquivo.original_filename}. Vale para os próximos PDFs." + end + # Rebusca o plano e reconcilia com o que já está editado (a regra está em # Romaneios::Importador). def reimportar diff --git a/app/javascript/controllers/romaneio_controller.js b/app/javascript/controllers/romaneio_controller.js index d8b723f..27b30a9 100644 --- a/app/javascript/controllers/romaneio_controller.js +++ b/app/javascript/controllers/romaneio_controller.js @@ -10,7 +10,7 @@ import { Controller } from "@hotwired/stimulus" // plano inteiro. A regra de reconciliação (Romaneios::Importador) só funciona com // escrita por campo. export default class extends Controller { - static targets = ["celula", "preview", "veiculo", "motorista", "rotulo", "status", "linha"] + static targets = ["celula", "preview", "itemVeiculo", "motorista", "rotulo", "status", "linha", "busca", "contador"] static values = { url: String, pdfUrl: String, veiculo: String } // Recarregar o PDF a cada tecla travaria a tela; 600ms depois do último save é o @@ -19,6 +19,10 @@ export default class extends Controller { connect() { this.timerPreview = null + // Com 72 carros a lista rola; sem isto, ao trocar de veículo o selecionado + // pode nascer fora da área visível e parece que a seleção não pegou. + const atual = this.itemVeiculoTargets.find((a) => a.dataset.atual === "1") + if (atual) atual.scrollIntoView({ block: "nearest", inline: "nearest" }) } disconnect() { @@ -74,26 +78,50 @@ export default class extends Controller { } // ── Navegação e filtro ────────────────────────────────────────────────── - trocarVeiculo() { - window.location.search = `?veiculo=${encodeURIComponent(this.veiculoTarget.value)}` - } - + // A lista de veículos são LINKS de verdade (o veículo vive na query string), então + // clicar já navega sem JS. As setas só precisam achar o vizinho na mesma lista — + // é o que o operador faz o dia inteiro: percorrer carro a carro. anterior() { this.mover(-1) } proximo() { this.mover(1) } mover(passo) { - const select = this.veiculoTarget - const destino = select.selectedIndex + passo - if (destino < 0 || destino >= select.options.length) return - select.selectedIndex = destino - this.trocarVeiculo() + const itens = this.itemVeiculoTargets + if (itens.length === 0) return + + const atual = itens.findIndex((a) => a.dataset.atual === "1") + const destino = (atual === -1 ? 0 : atual) + passo + // Sem dar a volta de propósito: chegar na ponta e voltar ao primeiro carro faz + // o operador reimprimir folha que já separou, sem perceber. + if (destino < 0 || destino >= itens.length) return + + window.location.href = itens[destino].href } - filtrar(event) { - const termo = event.currentTarget.value.trim().toLowerCase() + filtrar() { + const termo = this.hasBuscaTarget ? this.buscaTarget.value.trim().toLowerCase() : "" + let visiveis = 0 + this.linhaTargets.forEach((linha) => { - linha.hidden = termo !== "" && !linha.dataset.busca.includes(termo) + const casa = termo === "" || linha.dataset.busca.includes(termo) + linha.hidden = !casa + if (casa) visiveis += 1 }) + + // Sem o contador, uma busca que não casa com nada deixa a tabela vazia e + // parece plano vazio — o operador acha que perdeu o romaneio. + if (!this.hasContadorTarget) return + const total = this.linhaTargets.length + const plural = visiveis === 1 ? "parada" : "paradas" + this.contadorTarget.textContent = termo === "" + ? `${total} ${total === 1 ? "parada" : "paradas"} nesta folha` + : `${visiveis} de ${total} ${plural} — filtrando por "${termo}"` + } + + limparBusca() { + if (!this.hasBuscaTarget) return + this.buscaTarget.value = "" + this.filtrar() + this.buscaTarget.focus() } atualizarPreview() { diff --git a/app/models/configuracao.rb b/app/models/configuracao.rb index 2143321..d3a3fa8 100644 --- a/app/models/configuracao.rb +++ b/app/models/configuracao.rb @@ -19,6 +19,10 @@ class Configuracao < ApplicationRecord notificacao_email empresa_nome empresa_logo + # Logo impresso no romaneio (documento do CLIENTE — hoje a GADE, por isso é + # separado do `empresa_logo`, que é a marca da Reem). Guarda só o NOME do + # arquivo; o binário vive em storage/logos/. Vazio = usa public/logo-gade.png. + romaneio_logo ].freeze CHAVES_MOEDA = %w[ diff --git a/app/policies/romaneio_policy.rb b/app/policies/romaneio_policy.rb index 59faf07..b80c234 100644 --- a/app/policies/romaneio_policy.rb +++ b/app/policies/romaneio_policy.rb @@ -16,4 +16,5 @@ class RomaneioPolicy < ApplicationPolicy def atualizar_linha? = index? def atualizar_veiculo? = index? def atualizar_rotulo? = index? + def atualizar_logo? = index? end diff --git a/app/services/pdf/romaneio_pdf.rb b/app/services/pdf/romaneio_pdf.rb index 7fb43e4..17d6be0 100644 --- a/app/services/pdf/romaneio_pdf.rb +++ b/app/services/pdf/romaneio_pdf.rb @@ -105,12 +105,36 @@ module Pdf @max_linhas_folha = (sobra / @linha_h).floor end + # O logo é ESCOLHÍVEL na tela (Configuracao 'romaneio_logo') porque o romaneio é + # documento do CLIENTE: quando a operação muda de contratante, trocar o + # cabeçalho não pode exigir deploy. Sem escolha, cai no logo da GADE que está + # no repositório. def self.logo_padrao - if defined?(Rails) && Rails.respond_to?(:root) && Rails.root - Rails.root.join('public', 'logo-gade.png').to_s - else - File.expand_path('../../../public/logo-gade.png', __dir__) - end + return File.expand_path('../../../public/logo-gade.png', __dir__) unless dentro_do_rails? + + logo_escolhido || Rails.root.join('public', 'logo-gade.png').to_s + end + + # Pasta dos logos enviados pela tela. Fora de public/ de propósito: o que o + # operador sobe não vira arquivo servido pela web sem passar por autorização. + def self.pasta_logos + Rails.root.join('storage', 'logos') + end + + def self.dentro_do_rails? + defined?(Rails) && Rails.respond_to?(:root) && !Rails.root.nil? + end + + def self.logo_escolhido + nome = Configuracao.valor('romaneio_logo').to_s + return nil if nome.strip.empty? + + caminho = pasta_logos.join(nome).to_s + File.exist?(caminho) ? caminho : nil + rescue StandardError + # Sem banco (a conferência de layout roda num container Ruby pelado) ou + # tabela ainda não migrada: o PDF continua saindo, com o logo padrão. + nil end def render diff --git a/app/views/admin/romaneios/_controles.html.erb b/app/views/admin/romaneios/_controles.html.erb index 1186724..69e9305 100644 --- a/app/views/admin/romaneios/_controles.html.erb +++ b/app/views/admin/romaneios/_controles.html.erb @@ -1,55 +1,42 @@ -<%# Seletor de veículo + os dois campos de cabeçalho da folha. %> +<%# Zona "Filtros" — busca e o rótulo do cabeçalho do PDF. + O seletor de veículo saiu daqui para o _veiculos.html.erb, que mostra a lista + inteira; aqui fica só o que age sobre a FOLHA em edição. %>
-
- -
- <%# ‹ › ao lado do select: com 72 veículos, uma lista lateral vira armadilha - de scroll, mas percorrer o plano carro a carro é o que o operador faz. %> - - - +
+
+ +
+ + <%# Rótulo que descreve o que o controle faz (diretriz 2): limpa a BUSCA, + não o romaneio. %> + +
-
-
-
- - <% atual = @veiculos.find { |v| v.veiculo == @veiculo } %> - -
-
- - -
+ <%# Contador: no programa original ele fica no rodapé da tabela ("0 registros") + e é como se percebe que a busca escondeu tudo — sem ele, a tabela vazia + parece plano vazio. %> + <%# Sem um segundo alvo "status" aqui: ele já existe no _tabela.html.erb + ("Alterações salvam sozinhas") e o Stimulus devolveria só o primeiro. %> +

+ <%= @linhas.size %> <%= @linhas.size == 1 ? 'parada' : 'paradas' %> nesta folha +

diff --git a/app/views/admin/romaneios/_tabela.html.erb b/app/views/admin/romaneios/_tabela.html.erb index 815470d..8446ace 100644 --- a/app/views/admin/romaneios/_tabela.html.erb +++ b/app/views/admin/romaneios/_tabela.html.erb @@ -7,7 +7,10 @@ traz HTML junto. %>
-

<%= linhas.size %> paradas

+ <%# Sem repetir a contagem aqui: ela agora é VIVA no bloco de filtros (muda + conforme a busca esconde linhas). Dois números para a mesma coisa, um + atualizando e outro não, é a divergência que a diretriz 1 proíbe. %> +

Paradas de <%= @veiculo %>

Alterações salvam sozinhas
diff --git a/app/views/admin/romaneios/_veiculos.html.erb b/app/views/admin/romaneios/_veiculos.html.erb new file mode 100644 index 0000000..1af99cb --- /dev/null +++ b/app/views/admin/romaneios/_veiculos.html.erb @@ -0,0 +1,78 @@ +<%# Coluna "Veículos" — a lista INTEIRA visível, como no programa original. + Antes era um +

+ Não sai impresso — a linha do rodapé é assinada à mão na retirada. +

+
+ +
+ <%= link_to pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo), + target: '_blank', rel: 'noopener', + class: 'w-full min-h-[48px] flex items-center justify-center gap-2 bg-[#1a1a1a] + hover:bg-[#242424] text-white border border-white/10 rounded-xl' do %> + <%= icone :visualizar, cor: nil %> PDF deste veículo + <% end %> + <%= link_to pdf_admin_romaneio_path(@romaneio), + class: 'w-full min-h-[48px] flex items-center justify-center gap-2 bg-[#f97316] + hover:bg-orange-500 text-white font-semibold rounded-xl' do %> + <%= icone :baixar, cor: nil %> PDF de TODOS os veículos + <% end %> +
+
diff --git a/app/views/admin/romaneios/index.html.erb b/app/views/admin/romaneios/index.html.erb index 5f1b918..1ef9c8a 100644 --- a/app/views/admin/romaneios/index.html.erb +++ b/app/views/admin/romaneios/index.html.erb @@ -20,6 +20,37 @@

+ <%# Logo do cabeçalho — zona "Arquivos" do programa original. + Fica ANTES da importação porque é configuração que vale para todos os + romaneios, não escolha por plano: quem troca de contratante troca uma vez. %> +
+ <%= form_with url: atualizar_logo_admin_romaneios_path, method: :post, multipart: true, + data: { turbo: false }, + class: 'flex flex-col sm:flex-row sm:items-end gap-3' do %> +
+ +

+ Em uso: + <%= @logo_atual.presence || 'logo-gade.png (padrão)' %> +

+ +

+ PNG ou JPG, até 2 MB — é o que o PDF consegue embutir. Vale para os próximos PDFs gerados. +

+
+ + <% end %> +
+ <%# Importação %>

Importar plano

diff --git a/app/views/admin/romaneios/show.html.erb b/app/views/admin/romaneios/show.html.erb index 5ad792e..d10aa6b 100644 --- a/app/views/admin/romaneios/show.html.erb +++ b/app/views/admin/romaneios/show.html.erb @@ -51,8 +51,16 @@ <% else %> <%= render 'avisos' %> -
-
+ <%# Três zonas, como no programa que a operação já usava: VEÍCULOS à esquerda, + edição no meio, PDF de verdade à direita. Em telas médias a prévia desce + para baixo (duas colunas); no celular vira uma coluna só. %> +
+
+ <%= render 'veiculos' %> +
+ +
<%= render 'controles' %> <%= render 'tabela', linhas: @linhas %> <%= render 'removidas', linhas: @removidas %> @@ -60,8 +68,8 @@ <%# Prévia — no celular vira um bloco recolhido: 6 colunas em tela pequena exigiriam zoom, e o que importa ali é editar, não conferir o layout. %> -
-
+
+
<%= icone :visualizar %> Ver prévia do PDF @@ -70,7 +78,7 @@ class: 'block mt-3 text-orange-400 hover:text-orange-300', target: '_blank', rel: 'noopener' %>
-