Romaneio: lista de veículos visível, busca com contador e logo escolhível

Traz para a tela as funções do programa original (Romaneiro PDF), na linha de
design do projeto:
- Veículos deixam de ser um <select> e viram lista visível com a contagem de
  paradas de cada carro — que é a conferência feita antes de imprimir e ficava
  invisível mostrando um veículo por vez. Cada item é link (?veiculo=), então
  sobrevive ao 'voltar' e ao F5. No celular vira faixa horizontal.
- Na mesma coluna: motorista do veículo, 'PDF deste veículo' e 'PDF de TODOS'.
- Busca ganha 'Limpar busca' e contador vivo ('4 de 31 paradas'). A contagem
  saiu do cabeçalho da tabela: dois números para a mesma coisa, um atualizando e
  o outro não, é a divergência que a diretriz 1 proíbe.
- Logo do cabeçalho escolhível (Configuracao 'romaneio_logo', separado do
  empresa_logo — o romaneio é documento do cliente). PNG/JPG até 2MB, gravado em
  storage/logos/ e não em public/, com nome gerado pelo sistema.
This commit is contained in:
2026-08-27 19:20:49 -03:00
parent 7fa2336b35
commit b3464e3acd
11 changed files with 274 additions and 65 deletions

View File

@@ -17,6 +17,9 @@ class Admin::RomaneiosController < ApplicationController
@romaneios = Romaneio.recentes.includes(:romaneio_veiculos).limit(50) @romaneios = Romaneio.recentes.includes(:romaneio_veiculos).limit(50)
@operacoes_agrupadas = Operacao.agrupadas_por_mes @operacoes_agrupadas = Operacao.agrupadas_por_mes
@data_padrao = Date.current @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 end
def show def show
@@ -61,6 +64,47 @@ class Admin::RomaneiosController < ApplicationController
redirect_to admin_romaneios_path, alert: e.message redirect_to admin_romaneios_path, alert: e.message
end 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 # Rebusca o plano e reconcilia com o que já está editado (a regra está em
# Romaneios::Importador). # Romaneios::Importador).
def reimportar def reimportar

View File

@@ -10,7 +10,7 @@ import { Controller } from "@hotwired/stimulus"
// plano inteiro. A regra de reconciliação (Romaneios::Importador) só funciona com // plano inteiro. A regra de reconciliação (Romaneios::Importador) só funciona com
// escrita por campo. // escrita por campo.
export default class extends Controller { 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 } static values = { url: String, pdfUrl: String, veiculo: String }
// Recarregar o PDF a cada tecla travaria a tela; 600ms depois do último save é o // 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() { connect() {
this.timerPreview = null 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() { disconnect() {
@@ -74,26 +78,50 @@ export default class extends Controller {
} }
// ── Navegação e filtro ────────────────────────────────────────────────── // ── Navegação e filtro ──────────────────────────────────────────────────
trocarVeiculo() { // A lista de veículos são LINKS de verdade (o veículo vive na query string), então
window.location.search = `?veiculo=${encodeURIComponent(this.veiculoTarget.value)}` // 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) } anterior() { this.mover(-1) }
proximo() { this.mover(1) } proximo() { this.mover(1) }
mover(passo) { mover(passo) {
const select = this.veiculoTarget const itens = this.itemVeiculoTargets
const destino = select.selectedIndex + passo if (itens.length === 0) return
if (destino < 0 || destino >= select.options.length) return
select.selectedIndex = destino const atual = itens.findIndex((a) => a.dataset.atual === "1")
this.trocarVeiculo() 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) { filtrar() {
const termo = event.currentTarget.value.trim().toLowerCase() const termo = this.hasBuscaTarget ? this.buscaTarget.value.trim().toLowerCase() : ""
let visiveis = 0
this.linhaTargets.forEach((linha) => { 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() { atualizarPreview() {

View File

@@ -19,6 +19,10 @@ class Configuracao < ApplicationRecord
notificacao_email notificacao_email
empresa_nome empresa_nome
empresa_logo 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 ].freeze
CHAVES_MOEDA = %w[ CHAVES_MOEDA = %w[

View File

@@ -16,4 +16,5 @@ class RomaneioPolicy < ApplicationPolicy
def atualizar_linha? = index? def atualizar_linha? = index?
def atualizar_veiculo? = index? def atualizar_veiculo? = index?
def atualizar_rotulo? = index? def atualizar_rotulo? = index?
def atualizar_logo? = index?
end end

View File

@@ -105,12 +105,36 @@ module Pdf
@max_linhas_folha = (sobra / @linha_h).floor @max_linhas_folha = (sobra / @linha_h).floor
end 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 def self.logo_padrao
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root return File.expand_path('../../../public/logo-gade.png', __dir__) unless dentro_do_rails?
Rails.root.join('public', 'logo-gade.png').to_s
else logo_escolhido || Rails.root.join('public', 'logo-gade.png').to_s
File.expand_path('../../../public/logo-gade.png', __dir__)
end 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 end
def render def render

View File

@@ -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. %>
<div class="bg-[#111] border border-white/5 rounded-2xl p-4 space-y-4"> <div class="bg-[#111] border border-white/5 rounded-2xl p-4 space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_minmax(0,280px)] gap-3">
<div> <div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">Veículo</label> <label class="block text-sm font-medium text-gray-300 mb-1.5">Buscar nesta folha</label>
<div class="flex gap-2"> <div class="flex gap-2">
<%# ao lado do select: com 72 veículos, uma lista lateral vira armadilha <input type="search" placeholder="NF, nome ou bairro/rua…"
de scroll, mas percorrer o plano carro a carro é o que o operador faz. %> data-romaneio-target="busca" data-action="input->romaneio#filtrar"
<button type="button" data-action="romaneio#anterior" aria-label="Veículo anterior"
class="px-3 min-w-[48px] min-h-[48px] bg-[#1a1a1a] border border-white/10 rounded-xl
text-white hover:bg-[#242424]"></button>
<select data-romaneio-target="veiculo" data-action="romaneio#trocarVeiculo"
class="flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white class="flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
focus:outline-none focus:border-orange-500 min-h-[48px]"> placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]">
<% @veiculos.each do |v| %> <%# Rótulo que descreve o que o controle faz (diretriz 2): limpa a BUSCA,
<option value="<%= v.veiculo %>" <%= 'selected' if v.veiculo == @veiculo %>> não o romaneio. %>
<%= v.veiculo %> (<%= @romaneio.contagem_por_veiculo[v.veiculo].to_i %>) <button type="button" data-action="romaneio#limparBusca"
</option> class="px-4 min-h-[48px] bg-[#1a1a1a] border border-white/10 rounded-xl
<% end %> text-gray-300 hover:text-white hover:bg-[#242424] whitespace-nowrap">
</select> Limpar busca
<button type="button" data-action="romaneio#proximo" aria-label="Próximo veículo" </button>
class="px-3 min-w-[48px] min-h-[48px] bg-[#1a1a1a] border border-white/10 rounded-xl
text-white hover:bg-[#242424]"></button>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<label class="block text-sm font-medium text-gray-300 mb-1.5"> <label class="block text-sm font-medium text-gray-300 mb-1.5">
PLANO <span class="text-gray-500 font-normal">— texto do canto direito do PDF</span> PLANO <span class="text-gray-500 font-normal">— canto direito do PDF</span>
</label> </label>
<input type="text" value="<%= @romaneio.rotulo_plano %>" <input type="text" value="<%= @romaneio.rotulo_plano %>"
data-romaneio-target="rotulo" data-action="change->romaneio#salvarRotulo" data-romaneio-target="rotulo" data-action="change->romaneio#salvarRotulo"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
focus:outline-none focus:border-orange-500 min-h-[48px]"> focus:outline-none focus:border-orange-500 min-h-[48px]">
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
Motorista <span class="text-gray-500 font-normal">— só na tela, não sai impresso</span>
</label>
<% atual = @veiculos.find { |v| v.veiculo == @veiculo } %>
<input type="text" value="<%= atual&.motorista %>" placeholder="Quem leva este carro"
data-romaneio-target="motorista" data-action="change->romaneio#salvarMotorista"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]">
</div>
</div> </div>
<div> <%# Contador: no programa original ele fica no rodapé da tabela ("0 registros")
<label class="block text-sm font-medium text-gray-300 mb-1.5">Buscar nesta folha</label> e é como se percebe que a busca escondeu tudo — sem ele, a tabela vazia
<input type="search" placeholder="NF, nome ou endereço…" parece plano vazio. %>
data-action="input->romaneio#filtrar" <%# Sem um segundo alvo "status" aqui: ele já existe no _tabela.html.erb
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white ("Alterações salvam sozinhas") e o Stimulus devolveria só o primeiro. %>
placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]"> <p class="text-xs text-gray-500" data-romaneio-target="contador">
</div> <%= @linhas.size %> <%= @linhas.size == 1 ? 'parada' : 'paradas' %> nesta folha
</p>
</div> </div>

View File

@@ -7,7 +7,10 @@
traz HTML junto. %> traz HTML junto. %>
<div class="bg-[#111] border border-white/5 rounded-2xl overflow-hidden"> <div class="bg-[#111] border border-white/5 rounded-2xl overflow-hidden">
<div class="px-4 py-3 border-b border-white/5 flex items-center justify-between"> <div class="px-4 py-3 border-b border-white/5 flex items-center justify-between">
<h2 class="text-white font-semibold text-sm"><%= linhas.size %> paradas</h2> <%# 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. %>
<h2 class="text-white font-semibold text-sm">Paradas de <%= @veiculo %></h2>
<span class="text-xs text-gray-500" data-romaneio-target="status">Alterações salvam sozinhas</span> <span class="text-xs text-gray-500" data-romaneio-target="status">Alterações salvam sozinhas</span>
</div> </div>

View File

@@ -0,0 +1,78 @@
<%# Coluna "Veículos" — a lista INTEIRA visível, como no programa original.
Antes era um <select> com : resolvia o scroll de 72 carros, mas o operador
não conseguia VER o plano — via um carro por vez e perdia a noção de quantas
paradas cada um tem, que é justamente a conferência que ele faz antes de
mandar imprimir.
Adapta ao dispositivo (diretriz 3): no celular vira faixa horizontal, porque
72 itens empilhados empurrariam a tabela para fora da primeira tela; no
desktop é coluna com rolagem própria, do lado da tabela. %>
<div class="bg-[#111] border border-white/5 rounded-2xl p-4 space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-white font-semibold text-sm">Veículos</h2>
<span class="text-xs text-gray-500"><%= @veiculos.size %> no plano</span>
</div>
<div class="flex lg:flex-col gap-2 overflow-x-auto lg:overflow-x-visible
lg:overflow-y-auto lg:max-h-[46vh] pb-1 lg:pb-0
lg:pr-1 [scrollbar-width:thin]">
<% @veiculos.each do |v| %>
<% atual = v.veiculo == @veiculo %>
<%# Link de verdade (não botão com JS): o veículo vive na query string, então
ele é endereçável, volta com o "voltar" do navegador e sobrevive a um F5. %>
<a href="<%= admin_romaneio_path(@romaneio, veiculo: v.veiculo) %>"
data-romaneio-target="itemVeiculo" data-veiculo="<%= v.veiculo %>"
<%= 'data-atual="1"'.html_safe if atual %>
class="shrink-0 lg:shrink flex items-center justify-between gap-3 min-h-[48px]
px-3 rounded-xl border transition-colors
<%= atual ? 'bg-[#f97316] border-orange-400 text-white font-semibold'
: 'bg-[#1a1a1a] border-white/10 text-gray-300 hover:border-orange-500 hover:text-white' %>">
<span class="whitespace-nowrap"><%= v.veiculo %></span>
<%# A contagem é o que o operador confere: carro sem parada ou com parada
demais é erro de plano, e some quando só um veículo aparece por vez. %>
<span class="text-xs px-2 py-0.5 rounded-lg
<%= atual ? 'bg-black/25' : 'bg-white/5 text-gray-400' %>">
<%= @romaneio.contagem_por_veiculo[v.veiculo].to_i %>
</span>
</a>
<% end %>
</div>
<div class="flex gap-2">
<button type="button" data-action="romaneio#anterior" aria-label="Veículo anterior"
class="flex-1 min-h-[48px] bg-[#1a1a1a] border border-white/10 rounded-xl
text-white hover:bg-[#242424]"></button>
<button type="button" data-action="romaneio#proximo" aria-label="Próximo veículo"
class="flex-1 min-h-[48px] bg-[#1a1a1a] border border-white/10 rounded-xl
text-white hover:bg-[#242424]"></button>
</div>
<div class="pt-1 border-t border-white/5">
<label class="block text-sm font-medium text-gray-300 mb-1.5 mt-3">
Motorista deste veículo
<span class="text-gray-500 font-normal">— só na tela</span>
</label>
<% atual = @veiculos.find { |v| v.veiculo == @veiculo } %>
<input type="text" value="<%= atual&.motorista %>" placeholder="Quem leva este carro"
data-romaneio-target="motorista" data-action="change->romaneio#salvarMotorista"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]">
<p class="text-xs text-gray-500 mt-1.5">
Não sai impresso — a linha do rodapé é assinada à mão na retirada.
</p>
</div>
<div class="space-y-2 pt-1">
<%= 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 %>
</div>
</div>

View File

@@ -20,6 +20,37 @@
</p> </p>
</div> </div>
<%# 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. %>
<div class="bg-[#111] border border-white/5 rounded-2xl p-5">
<%= 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 %>
<div class="flex-1">
<label class="block text-sm font-medium text-gray-300 mb-1.5">
Logo do romaneio
<span class="text-gray-500 font-normal">— canto esquerdo da folha</span>
</label>
<p class="text-sm text-gray-400 mb-2">
Em uso:
<strong class="text-white"><%= @logo_atual.presence || 'logo-gade.png (padrão)' %></strong>
</p>
<input type="file" name="logo" accept="image/png,image/jpeg"
class="w-full text-sm text-gray-300 file:mr-3 file:py-2.5 file:px-4 file:rounded-lg
file:border-0 file:bg-[#1a1a1a] file:text-gray-200 file:cursor-pointer">
<p class="text-xs text-gray-500 mt-2">
PNG ou JPG, até 2 MB — é o que o PDF consegue embutir. Vale para os próximos PDFs gerados.
</p>
</div>
<button type="submit"
class="px-5 py-3 bg-[#1a1a1a] hover:bg-[#242424] text-white border border-white/10
rounded-xl min-h-[48px] whitespace-nowrap">
Trocar logo
</button>
<% end %>
</div>
<%# Importação %> <%# Importação %>
<div class="bg-[#111] border border-white/5 rounded-2xl p-5 space-y-4"> <div class="bg-[#111] border border-white/5 rounded-2xl p-5 space-y-4">
<h2 class="text-white font-semibold">Importar plano</h2> <h2 class="text-white font-semibold">Importar plano</h2>

View File

@@ -51,8 +51,16 @@
<% else %> <% else %>
<%= render 'avisos' %> <%= render 'avisos' %>
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,560px)] gap-5 items-start"> <%# Três zonas, como no programa que a operação já usava: VEÍCULOS à esquerda,
<div class="space-y-4"> 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ó. %>
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,260px)_minmax(0,1fr)]
xl:grid-cols-[minmax(0,260px)_minmax(0,1fr)_minmax(0,520px)] gap-5 items-start">
<div class="lg:sticky lg:top-4">
<%= render 'veiculos' %>
</div>
<div class="space-y-4 lg:col-span-1">
<%= render 'controles' %> <%= render 'controles' %>
<%= render 'tabela', linhas: @linhas %> <%= render 'tabela', linhas: @linhas %>
<%= render 'removidas', linhas: @removidas %> <%= render 'removidas', linhas: @removidas %>
@@ -60,8 +68,8 @@
<%# Prévia — no celular vira um bloco recolhido: 6 colunas em tela pequena <%# 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. %> exigiriam zoom, e o que importa ali é editar, não conferir o layout. %>
<div class="lg:sticky lg:top-4"> <div class="lg:col-span-2 xl:col-span-1 xl:sticky xl:top-4">
<details class="lg:hidden bg-[#111] border border-white/5 rounded-2xl p-4 mb-4"> <details class="xl:hidden bg-[#111] border border-white/5 rounded-2xl p-4 mb-4">
<summary class="cursor-pointer text-white font-medium select-none min-h-[44px] flex items-center"> <summary class="cursor-pointer text-white font-medium select-none min-h-[44px] flex items-center">
<%= icone :visualizar %> Ver prévia do PDF <%= icone :visualizar %> Ver prévia do PDF
</summary> </summary>
@@ -70,7 +78,7 @@
class: 'block mt-3 text-orange-400 hover:text-orange-300', target: '_blank', rel: 'noopener' %> class: 'block mt-3 text-orange-400 hover:text-orange-300', target: '_blank', rel: 'noopener' %>
</details> </details>
<div class="hidden lg:block bg-[#111] border border-white/5 rounded-2xl p-4"> <div class="hidden xl:block bg-[#111] border border-white/5 rounded-2xl p-4">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<h2 class="text-white font-semibold text-sm">Prévia — <%= @veiculo %></h2> <h2 class="text-white font-semibold text-sm">Prévia — <%= @veiculo %></h2>
<button type="button" data-action="romaneio#atualizarPreview" <button type="button" data-action="romaneio#atualizarPreview"

View File

@@ -138,6 +138,7 @@ Rails.application.routes.draw do
resources :romaneios, only: %i[index show create destroy] do resources :romaneios, only: %i[index show create destroy] do
collection do collection do
post :importar_planilha # caminho B: upload do .xlsx do plano post :importar_planilha # caminho B: upload do .xlsx do plano
post :atualizar_logo # troca o logo impresso no cabeçalho
end end
member do member do
# ?veiculo=GADE_001 -> inline (é a prévia do editor); sem veiculo -> download # ?veiculo=GADE_001 -> inline (é a prévia do editor); sem veiculo -> download