Implantação da criação de romaneios
This commit is contained in:
@@ -234,6 +234,24 @@ class Admin::RomaneiosController < ApplicationController
|
||||
return nil
|
||||
end
|
||||
|
||||
# Sem operação escolhida, tenta deduzi-la do NOME DO PLANO ("ENTREGAS EMAD
|
||||
# 09.2026" → gade_entregas_emad_set_2026).
|
||||
#
|
||||
# POR QUE ISSO É IMPORTANTE E NÃO CONVENIÊNCIA: é a operação que diz quem é
|
||||
# paciente NOVO. Importar sem ela produz um romaneio de aparência normal com a
|
||||
# coluna APARELHO vazia para TODOS — o erro só aparece no papel, com aparelho
|
||||
# não entregue. A operação não é campo opcional na prática; era só difícil de
|
||||
# acertar, porque o nome do plano e o nome da tabela nunca são iguais.
|
||||
#
|
||||
# Empate/nenhum casamento devolve nil e segue sem operação: vincular a errada
|
||||
# preencheria APARELHO e TELEFONE com dados de OUTRO mês, sem ninguém
|
||||
# desconfiar. A tela avisa quando ficou sem.
|
||||
@operacao_deduzida = nil
|
||||
if tabela.blank? && params[:rotulo_plano].present?
|
||||
tabela = Romaneios::CasadorDeOperacao.casar(params[:rotulo_plano]).to_s
|
||||
@operacao_deduzida = tabela.presence
|
||||
end
|
||||
|
||||
data = Date.parse(data)
|
||||
Romaneio.find_or_create_by!(planned_date: data, operacao_tabela: tabela) do |r|
|
||||
r.rotulo_plano = params[:rotulo_plano].presence || Romaneio.rotulo_sugerido(tabela, data)
|
||||
@@ -247,8 +265,18 @@ class Admin::RomaneiosController < ApplicationController
|
||||
def importar(linhas, origem:)
|
||||
resultado = aplicar(linhas, origem: origem)
|
||||
auditar!(:criar, @romaneio, dados_novos: resultado.to_h)
|
||||
|
||||
# Dizer que a operação foi deduzida, e qual: dedução silenciosa que acerta é
|
||||
# invisível, mas dedução silenciosa que erra é indefensável — o operador
|
||||
# precisa ver de qual mês vieram os dados de APARELHO.
|
||||
aviso = if @operacao_deduzida
|
||||
" Operação #{Operacao.label(@operacao_deduzida)} vinculada pelo nome do plano."
|
||||
else
|
||||
''
|
||||
end
|
||||
|
||||
redirect_to admin_romaneio_path(@romaneio),
|
||||
notice: "Plano importado — #{resultado.resumo}."
|
||||
notice: "Plano importado — #{resultado.resumo}.#{aviso}"
|
||||
end
|
||||
|
||||
def aplicar(linhas, origem:)
|
||||
|
||||
@@ -10,7 +10,8 @@ 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", "itemVeiculo", "motorista", "rotulo", "status", "linha", "busca", "contador"]
|
||||
static targets = ["celula", "preview", "itemVeiculo", "motorista", "rotulo", "status", "linha", "busca",
|
||||
"contador", "overlay", "caixaPreview", "tituloPreview"]
|
||||
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 +20,10 @@ export default class extends Controller {
|
||||
|
||||
connect() {
|
||||
this.timerPreview = null
|
||||
// A prévia nasce fechada e DESATUALIZADA: o PDF só é gerado na primeira vez
|
||||
// que alguém pede para ver. Antes o iframe era montado a cada carregamento da
|
||||
// tela e gerava um PDF que muitas vezes ninguém olhava.
|
||||
this.previewDesatualizado = true
|
||||
// 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")
|
||||
@@ -124,12 +129,49 @@ export default class extends Controller {
|
||||
this.buscaTarget.focus()
|
||||
}
|
||||
|
||||
atualizarPreview() {
|
||||
// ── Prévia (sobreposição) ───────────────────────────────────────────────
|
||||
abrirPreview() {
|
||||
if (!this.hasOverlayTarget) return
|
||||
|
||||
this.overlayTarget.hidden = false
|
||||
// Trava o scroll do fundo: rolar a lista atrás de um modal aberto faz o
|
||||
// operador perder o lugar quando fecha.
|
||||
document.body.style.overflow = "hidden"
|
||||
if (this.previewDesatualizado) this.carregarPreview()
|
||||
}
|
||||
|
||||
fecharPreview() {
|
||||
if (!this.hasOverlayTarget || this.overlayTarget.hidden) return
|
||||
|
||||
this.overlayTarget.hidden = true
|
||||
document.body.style.overflow = ""
|
||||
}
|
||||
|
||||
// Clique no fundo escuro fecha; clique DENTRO da caixa não — senão mexer no
|
||||
// PDF fecharia a prévia sem querer.
|
||||
fecharPorFora(event) {
|
||||
if (this.hasCaixaPreviewTarget && this.caixaPreviewTarget.contains(event.target)) return
|
||||
this.fecharPreview()
|
||||
}
|
||||
|
||||
carregarPreview() {
|
||||
if (!this.hasPreviewTarget) return
|
||||
|
||||
// Cache-bust em vez de reload(): reload reinicializa o visualizador de PDF do
|
||||
// navegador e a tela pisca a cada salvamento.
|
||||
this.previewTarget.src =
|
||||
`${this.pdfUrlValue}?veiculo=${encodeURIComponent(this.veiculoValue)}&t=${Date.now()}`
|
||||
this.previewDesatualizado = false
|
||||
}
|
||||
|
||||
// Chamada depois de cada gravação. Com a prévia FECHADA não gera PDF nenhum —
|
||||
// só marca que o próximo "Ver prévia" precisa buscar de novo.
|
||||
atualizarPreview() {
|
||||
if (!this.hasOverlayTarget || this.overlayTarget.hidden) {
|
||||
this.previewDesatualizado = true
|
||||
return
|
||||
}
|
||||
this.carregarPreview()
|
||||
}
|
||||
|
||||
// ── Infra ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -121,6 +121,14 @@ class Operacao
|
||||
}
|
||||
end
|
||||
|
||||
# Número do mês a partir de um token do nome ("ago", "agosto", "AGOSTO").
|
||||
# Público de propósito: Romaneios::CasadorDeOperacao precisa da MESMA tabela de
|
||||
# meses para casar o nome do plano do SimpliRoute com a tabela da operação, e
|
||||
# duplicar esse mapa seria criar uma segunda verdade que sai de sincronia.
|
||||
def mes_numero(token)
|
||||
MESES[token.to_s.downcase]&.first
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Extrai { titulo:, item:, ordem: } do nome da tabela.
|
||||
|
||||
@@ -45,6 +45,23 @@ class Romaneio < ApplicationRecord
|
||||
@contagem_por_veiculo ||= linhas_vigentes.reorder(nil).group(:veiculo).count
|
||||
end
|
||||
|
||||
# { novos: 184, recorrentes: 1863 } — quantos pacientes recebem APARELHO.
|
||||
#
|
||||
# POR QUE APARECE NA TELA: é a conferência que o programa antigo fazia com uma
|
||||
# caixa de diálogo ("184 entrega(s) marcada(s) como NOVO") logo depois de
|
||||
# carregar o status. Sem esse número, um romaneio importado SEM operação
|
||||
# vinculada — em que ninguém é marcado como novo — parece idêntico a um
|
||||
# importado certo, e o erro só aparece no papel, com aparelho não entregue.
|
||||
# "0 novos" na tela é a pergunta que o operador precisa se fazer.
|
||||
def contagem_aparelho
|
||||
@contagem_aparelho ||= begin
|
||||
grupos = linhas_vigentes.reorder(nil).group(:aparelho).count
|
||||
total = grupos.values.sum
|
||||
novos = grupos.sum { |valor, qtd| valor.to_s.strip.casecmp('SIM').zero? ? qtd : 0 }
|
||||
{ novos: novos, recorrentes: total - novos }
|
||||
end
|
||||
end
|
||||
|
||||
def folhas_de(veiculo)
|
||||
total = contagem_por_veiculo[veiculo].to_i
|
||||
return 1 if total.zero?
|
||||
|
||||
130
app/services/romaneios/casador_de_operacao.rb
Normal file
130
app/services/romaneios/casador_de_operacao.rb
Normal file
@@ -0,0 +1,130 @@
|
||||
# app/services/romaneios/casador_de_operacao.rb
|
||||
#
|
||||
# Casa o NOME DO PLANO do SimpliRoute com a tabela da operação do mês.
|
||||
#
|
||||
# ── POR QUE EXISTE ──────────────────────────────────────────────────────────
|
||||
# A operação não trabalha por data — ela identifica o plano pelo nome ("ENTREGAS
|
||||
# EMAD 09.2026", "UBS SUL E LESTE AGOSTO 2026"). E é a operação do mês que diz
|
||||
# quem é paciente NOVO: sem ela vinculada, `Romaneios::EnriquecimentoGade` nem
|
||||
# roda e a coluna APARELHO sai vazia para todos — um romaneio que parece certo e
|
||||
# só revela o erro no papel, com aparelho não entregue.
|
||||
#
|
||||
# A API pública do SimpliRoute NÃO tem endpoint que liste planos (a documentação
|
||||
# só expõe criação, /v1/plans/{data}/vehicles/ e /v1/plans/routes/{id}/visits/);
|
||||
# a lista que aparece na tela deles é da interface web. Então o nome chega aqui
|
||||
# DIGITADO ou COLADO pelo operador, e o casamento tem que aguentar variação.
|
||||
#
|
||||
# ── AS DIFERENÇAS SÃO SISTEMÁTICAS, NÃO ALEATÓRIAS ──────────────────────────
|
||||
# plano "UBS SUL E LESTE AGOSTO 2026" x tabela ubs_sul_leste_ago_2026
|
||||
# ↑ conectivo "E" ↑ mês abreviado
|
||||
# plano "ENTREGAS EMAD 09.2026" x tabela emad_set_2026
|
||||
# ↑ prefixo genérico ↑ mês NUMÉRICO
|
||||
# Por isso a comparação é por CONJUNTO DE PALAVRAS + (mês, ano), e não por
|
||||
# igualdade de texto — que erraria em todos os casos reais acima.
|
||||
module Romaneios
|
||||
class CasadorDeOperacao
|
||||
# Palavras que aparecem no nome do plano e nunca no nome da tabela. "ENTREGAS"
|
||||
# entra aqui porque é o prefixo genérico que o cliente usa nos planos; o
|
||||
# prefixo das tabelas (`gade_entregas_`) já é removido antes.
|
||||
RUIDO = %w[entregas entrega de da do das dos e o a as os plano planos].freeze
|
||||
|
||||
Resultado = Struct.new(:tabela, :confianca, :motivo, keyword_init: true)
|
||||
|
||||
def self.casar(nome, tabelas: nil)
|
||||
new(nome, tabelas: tabelas).casar
|
||||
end
|
||||
|
||||
def initialize(nome, tabelas: nil)
|
||||
@nome = nome.to_s
|
||||
@tabelas = tabelas
|
||||
end
|
||||
|
||||
# Devolve o nome da tabela, ou nil quando não dá para ter certeza.
|
||||
#
|
||||
# ⚠️ Empate devolve NIL de propósito. Vincular a operação errada é pior do que
|
||||
# não vincular: com a errada, APARELHO e TELEFONE saem preenchidos com dados
|
||||
# de OUTRO mês e ninguém desconfia; sem nenhuma, a tela avisa que está sem
|
||||
# operação e o operador escolhe.
|
||||
def casar
|
||||
resultado.tabela
|
||||
end
|
||||
|
||||
def resultado
|
||||
@resultado ||= calcular
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def calcular
|
||||
alvo = decompor(@nome)
|
||||
return Resultado.new(confianca: :nenhuma, motivo: 'nome vazio') if alvo[:palavras].empty?
|
||||
|
||||
candidatas = (@tabelas || Operacao.nomes_validos).map do |tabela|
|
||||
[tabela, pontuar(alvo, decompor(Operacao.label(tabela)))]
|
||||
end.reject { |_, ponto| ponto.nil? }
|
||||
|
||||
return Resultado.new(confianca: :nenhuma, motivo: 'nenhuma operação com o mesmo mês/ano') if candidatas.empty?
|
||||
|
||||
melhor = candidatas.max_by(&:last)
|
||||
empate = candidatas.count { |_, ponto| ponto == melhor.last }
|
||||
if empate > 1
|
||||
return Resultado.new(confianca: :ambigua,
|
||||
motivo: "#{empate} operações batem igualmente — escolha na lista")
|
||||
end
|
||||
|
||||
Resultado.new(tabela: melhor.first, confianca: :alta, motivo: 'mês, ano e palavras batem')
|
||||
end
|
||||
|
||||
# "UBS SUL E LESTE AGOSTO 2026" -> { palavras: #{ubs sul leste}, mes: 8, ano: 2026 }
|
||||
def decompor(texto)
|
||||
tokens = normalizar(texto)
|
||||
ano = tokens.find { |t| t.match?(/\A(19|20)\d{2}\z/) }
|
||||
mes = mes_de(tokens, ano)
|
||||
|
||||
palavras = tokens - [ano, mes[:token]].compact
|
||||
palavras = palavras.reject { |t| RUIDO.include?(t) }
|
||||
|
||||
{ palavras: palavras.to_set, mes: mes[:numero], ano: ano&.to_i }
|
||||
end
|
||||
|
||||
# Aceita "agosto", "ago" e o mês NUMÉRICO de "09.2026" — este último só quando
|
||||
# vem colado ao ano, senão o "3" de "AVULSAS 3 EMAD" viraria março.
|
||||
def mes_de(tokens, ano)
|
||||
por_nome = tokens.find { |t| Operacao.mes_numero(t) }
|
||||
return { token: por_nome, numero: Operacao.mes_numero(por_nome) } if por_nome
|
||||
|
||||
return { token: nil, numero: nil } unless ano
|
||||
|
||||
idx = tokens.index(ano)
|
||||
anterior = idx&.positive? ? tokens[idx - 1] : nil
|
||||
if anterior&.match?(/\A\d{1,2}\z/) && (1..12).cover?(anterior.to_i)
|
||||
{ token: anterior, numero: anterior.to_i }
|
||||
else
|
||||
{ token: nil, numero: nil }
|
||||
end
|
||||
end
|
||||
|
||||
def normalizar(texto)
|
||||
ActiveSupport::Inflector.transliterate(texto.to_s)
|
||||
.downcase
|
||||
.split(/[^a-z0-9]+/) # "09.2026" e "EMAD/SUDESTE" quebram aqui
|
||||
.reject(&:empty?)
|
||||
end
|
||||
|
||||
# nil = descartada. Mês e ano são ELIMINATÓRIOS: é o que impede "EMAD 09.2026"
|
||||
# de casar com a tabela de agosto, que tem exatamente as mesmas palavras.
|
||||
def pontuar(alvo, candidata)
|
||||
return nil if alvo[:mes] && candidata[:mes] && alvo[:mes] != candidata[:mes]
|
||||
return nil if alvo[:ano] && candidata[:ano] && alvo[:ano] != candidata[:ano]
|
||||
return nil if alvo[:palavras].empty? || candidata[:palavras].empty?
|
||||
|
||||
comuns = (alvo[:palavras] & candidata[:palavras]).size
|
||||
return nil if comuns.zero?
|
||||
|
||||
sobrando = (alvo[:palavras] ^ candidata[:palavras]).size
|
||||
# Palavra em comum soma 2; palavra que só um lado tem tira 1. "UBS SUL LESTE"
|
||||
# contra "UBS SUL" ainda casa, mas perde para "UBS SUL LESTE" exato.
|
||||
(comuns * 2) - sobrando
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -63,11 +63,17 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 pt-1">
|
||||
<%# Prévia sob demanda: o PDF só é gerado quando alguém pede para ver. %>
|
||||
<button type="button" data-action="romaneio#abrirPreview"
|
||||
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">
|
||||
<%= icone :visualizar, cor: nil %> Ver prévia do PDF
|
||||
</button>
|
||||
<%= 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
|
||||
<%= icone :baixar, 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]
|
||||
|
||||
@@ -73,7 +73,10 @@
|
||||
<select name="operacao_tabela"
|
||||
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]">
|
||||
<option value="">Sem operação — só o que o plano trouxer</option>
|
||||
<%# "Deixe em branco" e não "Sem operação": deixar vazio agora tenta
|
||||
deduzir a operação pelo nome do PLANO, e é esse o caminho normal.
|
||||
Escolher aqui é o override para quando a dedução não acerta. %>
|
||||
<option value="">Deduzir pelo nome do plano</option>
|
||||
<% @operacoes_agrupadas.each do |titulo, ops| %>
|
||||
<optgroup label="<%= titulo %>">
|
||||
<% ops.each do |op| %>
|
||||
@@ -88,9 +91,13 @@
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
PLANO <span class="text-gray-500 font-normal">(canto direito do PDF)</span>
|
||||
</label>
|
||||
<input type="text" name="rotulo_plano" placeholder="Ex.: EMAD 09.2026"
|
||||
<input type="text" name="rotulo_plano" placeholder="Ex.: ENTREGAS EMAD 09.2026"
|
||||
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">
|
||||
Cole o nome do plano como está no SimpliRoute — é por ele que a
|
||||
operação do mês (quem é <strong>NOVO</strong>) é encontrada.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<%# app/views/admin/romaneios/show.html.erb
|
||||
Editor do romaneio: tabela editável à esquerda, o PDF REAL à direita.
|
||||
Editor do romaneio: veículos à esquerda, tabela editável à direita, e o PDF
|
||||
REAL numa sobreposição aberta sob demanda.
|
||||
|
||||
A prévia é um <iframe> de mesma origem, e isso não é preferência: o CSP deste
|
||||
app está em modo enforcing com `object_src :none` (config/initializers/
|
||||
@@ -12,7 +13,7 @@
|
||||
data-romaneio-pdf-url-value="<%= pdf_admin_romaneio_path(@romaneio) %>"
|
||||
data-romaneio-veiculo-value="<%= @veiculo %>">
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div class="flex flex-col sm:flex-row sm:flex-wrap sm:items-start sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">
|
||||
Romaneio de <%= l @romaneio.planned_date, format: '%d/%m/%Y' %>
|
||||
@@ -25,6 +26,22 @@
|
||||
<%= "em #{l @romaneio.importado_em, format: '%d/%m %H:%M'}" if @romaneio.importado_em %>
|
||||
</p>
|
||||
</div>
|
||||
<%# NOVOS x RECORRENTES: o mesmo aviso que o programa antigo dava numa caixa
|
||||
de diálogo depois de aplicar o status. Fica ao lado do título porque é a
|
||||
primeira coisa a conferir — "0 novos" quase sempre significa importação
|
||||
sem operação vinculada, e o aviso azul abaixo explica o que fazer. %>
|
||||
<% contagem = @romaneio.contagem_aparelho %>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="px-3 py-1.5 rounded-xl border
|
||||
<%= contagem[:novos].positive? ? 'bg-green-600/15 border-green-500/40 text-green-300'
|
||||
: 'bg-amber-900/20 border-amber-500/40 text-amber-200' %>">
|
||||
<strong><%= contagem[:novos] %></strong> com aparelho (NOVOS)
|
||||
</span>
|
||||
<span class="px-3 py-1.5 rounded-xl bg-white/5 border border-white/10 text-gray-300">
|
||||
<strong><%= contagem[:recorrentes] %></strong> recorrentes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<%= link_to rotulo(:baixar, 'Baixar todos'),
|
||||
pdf_admin_romaneio_path(@romaneio),
|
||||
@@ -51,46 +68,55 @@
|
||||
<% 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ó. %>
|
||||
<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">
|
||||
<%# Duas zonas na tela: VEÍCULOS e a edição. A prévia saiu do fluxo e virou
|
||||
sobreposição sob demanda (botão "Ver prévia") — antes o <iframe> era montado
|
||||
em TODO carregamento, e cada montagem gerava um PDF no servidor só para
|
||||
ficar num canto que o operador nem sempre olha. Agora o PDF só é gerado
|
||||
quando alguém pede para ver. %>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,260px)_minmax(0,1fr)] gap-5 items-start">
|
||||
<div class="lg:sticky lg:top-4">
|
||||
<%= render 'veiculos' %>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 lg:col-span-1">
|
||||
<div class="space-y-4">
|
||||
<%= render 'controles' %>
|
||||
<%= render 'tabela', linhas: @linhas %>
|
||||
<%= render 'removidas', linhas: @removidas %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# 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. %>
|
||||
<div class="lg:col-span-2 xl:col-span-1 xl:sticky xl:top-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">
|
||||
<%= icone :visualizar %> Ver prévia do PDF
|
||||
</summary>
|
||||
<%= link_to 'Abrir o PDF deste veículo',
|
||||
pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo),
|
||||
class: 'block mt-3 text-orange-400 hover:text-orange-300', target: '_blank', rel: 'noopener' %>
|
||||
</details>
|
||||
<%# ── Prévia em sobreposição ──────────────────────────────────────────────
|
||||
Fica FORA da grade, ocupando a tela inteira quando aberta. `hidden` no
|
||||
markup e o src só preenchido na abertura: um iframe com src carrega
|
||||
mesmo escondido, o que anularia a economia.
|
||||
|
||||
<div class="hidden xl:block bg-[#111] border border-white/5 rounded-2xl p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-white font-semibold text-sm">Prévia — <%= @veiculo %></h2>
|
||||
<button type="button" data-action="romaneio#atualizarPreview"
|
||||
class="text-xs text-gray-400 hover:text-white px-3 py-2 rounded-lg hover:bg-white/5">
|
||||
<%= icone :restaurar, tamanho: 'w-4 h-4' %> Atualizar prévia
|
||||
</button>
|
||||
<iframe> e não <embed>/<object>: o CSP está em enforcing com
|
||||
`object_src :none`, e sem `frame_src` ele cai em `default_src :self` —
|
||||
o iframe de mesma origem passa, os outros dois não. %>
|
||||
<div data-romaneio-target="overlay" hidden
|
||||
data-action="click->romaneio#fecharPorFora keydown.esc@window->romaneio#fecharPreview"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6
|
||||
bg-black/70 backdrop-blur-sm">
|
||||
<div class="w-full max-w-6xl h-[92vh] flex flex-col gap-3"
|
||||
data-romaneio-target="caixaPreview">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="text-white font-semibold">
|
||||
Prévia — <span data-romaneio-target="tituloPreview"><%= @veiculo %></span>
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= link_to 'Abrir em outra aba',
|
||||
pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo),
|
||||
target: '_blank', rel: 'noopener',
|
||||
class: 'text-sm text-gray-300 hover:text-white px-3 py-2 rounded-lg hover:bg-white/10' %>
|
||||
<button type="button" data-action="romaneio#fecharPreview"
|
||||
aria-label="Fechar prévia"
|
||||
class="min-w-[48px] min-h-[48px] rounded-xl bg-[#1a1a1a] border border-white/10
|
||||
text-white hover:bg-[#242424]">✕</button>
|
||||
</div>
|
||||
<iframe data-romaneio-target="preview"
|
||||
src="<%= pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo) %>"
|
||||
class="w-full h-[78vh] rounded-xl border border-white/10 bg-white"
|
||||
title="Pré-visualização do romaneio do veículo <%= @veiculo %>"></iframe>
|
||||
</div>
|
||||
<iframe data-romaneio-target="preview"
|
||||
class="flex-1 w-full rounded-xl border border-white/10 bg-white"
|
||||
title="Pré-visualização do romaneio"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
Reference in New Issue
Block a user