Alterações de fotos da simpleroute

This commit is contained in:
2026-07-20 17:35:21 -03:00
parent f6ae32f485
commit f6eda043a3
4 changed files with 319 additions and 38 deletions

View File

@@ -32,8 +32,10 @@ class Admin::EdicaoLancamentosController < ApplicationController
entrega = Entrega.por_nf(nf).first
return render_erro("NF #{nf} não encontrada na base de rastreio.") if entrega.nil?
id = client.resolver_id(entrega)
visita = client.visita(id)
id = client.resolver_id(entrega)
visita = client.visita(id)
# Segunda fonte de imagens (POD). Não é obrigatória: se falhar, vem {}.
detalhe = client.detalhe_visita(id)
render json: {
ok: true,
@@ -52,11 +54,9 @@ class Admin::EdicaoLancamentosController < ApplicationController
planned_date: visita['planned_date'],
contato: visita['contact_name'],
telefone: visita['contact_phone'],
# Fotos do card: fachada vem do rastreio (mesma foto do mapa de operações);
# pictures/assinatura vêm da API. Só URLs http(s) chegam ao <img>.
foto_fachada: url_imagem(entrega.try(:foto_da_fachada)),
pictures: Array(visita['pictures']).filter_map { |u| url_imagem(u) },
signature: url_imagem(visita['signature']),
# Galeria unificada — TODAS as fotos do lançamento, de todas as fontes,
# cada uma etiquetada para o ADM saber o que está conferindo.
fotos: fotos_do_lancamento(entrega, visita, detalhe),
motorista: entrega.driver,
veiculo: entrega.vehicle
},
@@ -218,6 +218,31 @@ class Admin::EdicaoLancamentosController < ApplicationController
url.match?(%r{\Ahttps?://}i) ? url : nil
end
# Reúne as imagens das TRÊS fontes numa lista única e etiquetada:
# 1. espelho de rastreio -> foto da fachada (mesma do mapa de operações)
# 2. visita da API -> pictures[] + signature
# 3. detalhe do POD -> pictures[] + signature (costuma ser mais completo)
#
# A mesma foto costuma aparecer em mais de uma fonte, então deduplica por URL
# mantendo a primeira etiqueta (a ordem abaixo é a mais informativa).
def fotos_do_lancamento(entrega, visita, detalhe)
brutas = []
brutas << [entrega.try(:foto_da_fachada), 'fachada', 'Rastreio']
[[visita, 'Visita'], [detalhe, 'Comprovante']].each do |fonte, origem|
Array(fonte['pictures']).each { |u| brutas << [u, 'entrega', origem] }
brutas << [fonte['signature'], 'assinatura', origem]
end
vistas = Set.new
brutas.filter_map do |valor, tipo, origem|
url = url_imagem(valor)
next if url.nil? || !vistas.add?(url)
{ url: url, tipo: tipo, origem: origem }
end
end
# Sem token não há o que fazer — avisa e volta.
def garantir_configurado
return if SimpliRoute.configurado?

View File

@@ -84,6 +84,17 @@ module SimpliRoute
[]
end
# Detalhe do comprovante de entrega (POD): traz foto, assinatura, hora e GPS.
# É uma fonte MAIS COMPLETA de imagens que `visita(id)` — a visita crua às
# vezes vem com `pictures` vazio mesmo havendo foto registrada no checkout.
# Best-effort: se o endpoint não existir/responder, devolve {} e a tela segue
# com o que a visita tiver.
def detalhe_visita(id)
get("/v1/plans/visits/#{id}/detail/") || {}
rescue Error
{}
end
private
def get(path)

View File

@@ -71,14 +71,12 @@
<div class="grid lg:grid-cols-3 gap-0">
<%# Coluna esquerda — fotos %>
<%# Coluna esquerda — galeria com TODAS as fotos do lançamento %>
<div class="p-5 border-b lg:border-b-0 lg:border-r border-white/5 space-y-3">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Foto da fachada</p>
<div id="c-foto-wrap"></div>
<div id="c-extras-wrap" class="hidden">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mt-4 mb-2">Fotos do SimpliRoute</p>
<div id="c-extras" class="grid grid-cols-3 gap-2"></div>
</div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Fotos do lançamento <span id="c-fotos-contador" class="normal-case font-normal text-gray-600"></span>
</p>
<div id="c-fotos" class="space-y-3"></div>
</div>
<%# Coluna direita — campos click-to-edit %>
@@ -105,6 +103,38 @@
</div>
</div>
</div>
<%# Visualizador de foto — amplia e navega entre todas as imagens do lançamento %>
<div id="modal-foto" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
<div id="modal-foto-overlay" class="absolute inset-0 bg-black/85"></div>
<div class="relative w-full max-w-3xl flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<p id="modal-foto-titulo" class="text-white font-semibold text-sm"></p>
<div class="flex items-center gap-2">
<a id="modal-foto-abrir" href="#" target="_blank" rel="noopener"
class="px-3 py-2 rounded-lg bg-white/5 hover:bg-white/10 text-gray-300 text-xs inline-flex items-center">
<%= icone :visualizar, cor: nil, tamanho: 'w-4 h-4' %> Tamanho real
</a>
<button id="modal-foto-fechar" type="button"
class="w-9 h-9 rounded-lg bg-white/5 hover:bg-white/10 text-gray-300 flex items-center justify-center"
aria-label="Fechar"><%= icone :fechar, cor: nil, tamanho: 'w-5 h-5', espaco: false %></button>
</div>
</div>
<div class="relative bg-[#151515] border border-white/10 rounded-2xl overflow-hidden">
<img id="modal-foto-img" src="" alt="" class="w-full max-h-[70vh] object-contain bg-black">
<button id="modal-foto-ant" type="button"
class="absolute left-2 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-black/70 hover:bg-black
text-white flex items-center justify-center" aria-label="Foto anterior">
<%= icone :voltar, cor: nil, tamanho: 'w-5 h-5', espaco: false %>
</button>
<button id="modal-foto-prox" type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-black/70 hover:bg-black
text-white flex items-center justify-center" aria-label="Próxima foto">
<%= icone :avancar, cor: nil, tamanho: 'w-5 h-5', espaco: false %>
</button>
</div>
</div>
</div>
</div>
<script>
@@ -202,34 +232,89 @@
b.innerHTML = ico(cfg.icone) + ' ' + esc(cfg.label);
}
// Etiqueta de cada tipo de imagem — o ADM precisa saber o que está olhando
// para julgar se a foto está errada.
// `cls` = etiqueta translúcida (foto em destaque, fundo escuro previsível).
// `txt` = só a cor do texto, usada na miniatura sobre fundo preto sólido —
// sobre foto clara a versão translúcida ficava ilegível.
const ROTULO_FOTO = {
fachada: { texto: 'Fachada', cls: 'bg-orange-500/20 text-orange-300 border-orange-500/40', txt: 'text-orange-300' },
entrega: { texto: 'Comprovante', cls: 'bg-blue-500/20 text-blue-300 border-blue-500/40', txt: 'text-blue-300' },
assinatura: { texto: 'Assinatura', cls: 'bg-purple-500/20 text-purple-300 border-purple-500/40', txt: 'text-purple-300' }
};
function renderFotos() {
const wrap = document.getElementById('c-foto-wrap');
if (visita.foto_fachada) {
wrap.innerHTML =
'<a href="' + esc(visita.foto_fachada) + '" target="_blank" rel="noopener" title="Abrir em tamanho real">' +
'<img src="' + esc(visita.foto_fachada) + '" alt="Fachada" loading="lazy" ' +
'class="w-full h-52 object-cover rounded-xl border border-white/10 hover:opacity-90 transition-opacity">' +
'</a>';
} else {
const fotos = visita.fotos || [];
const wrap = document.getElementById('c-fotos');
const cont = document.getElementById('c-fotos-contador');
cont.textContent = fotos.length ? '— ' + fotos.length + (fotos.length > 1 ? ' imagens' : ' imagem') : '';
if (!fotos.length) {
wrap.innerHTML =
'<div class="w-full h-52 rounded-xl border border-dashed border-white/10 flex items-center justify-center ' +
'text-gray-600 text-sm">' + ico('camera-fill') + ' Sem foto da fachada para esta entrega</div>';
'text-gray-600 text-sm">' + ico('camera-fill') + ' Nenhuma foto registrada neste lançamento</div>';
return;
}
const extras = [...(visita.pictures || [])];
if (visita.signature) extras.push(visita.signature);
const extrasWrap = document.getElementById('c-extras-wrap');
const grid = document.getElementById('c-extras');
if (extras.length) {
grid.innerHTML = extras.map(u =>
'<a href="' + esc(u) + '" target="_blank" rel="noopener">' +
'<img src="' + esc(u) + '" loading="lazy" class="w-full h-16 object-cover rounded-lg border border-white/10 hover:opacity-90">' +
'</a>').join('');
extrasWrap.classList.remove('hidden');
} else {
extrasWrap.classList.add('hidden');
grid.innerHTML = '';
}
// A 1ª em destaque; as demais numa grade de 3. Clicar abre o visualizador.
// Na miniatura a etiqueta é compacta e truncada (senão estoura a célula);
// o rótulo completo + origem aparecem no visualizador.
const figura = (f, i, destaque) => {
const r = ROTULO_FOTO[f.tipo] ||
{ texto: f.tipo, cls: 'bg-white/10 text-gray-300 border-white/20', txt: 'text-gray-300' };
const etiqueta = destaque
? '<figcaption class="absolute top-1.5 left-1.5 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase ' +
'tracking-wide border ' + r.cls + '">' + esc(r.texto) + '</figcaption>' +
'<span class="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 rounded bg-black/70 text-gray-300 text-[10px]">' +
esc(f.origem) + '</span>'
: '<figcaption class="absolute top-1 left-1 right-1 px-1 py-0.5 rounded text-[9px] font-bold uppercase ' +
'tracking-tight truncate bg-black/80 ' + r.txt + '">' + esc(r.texto) + '</figcaption>';
return '<figure class="relative group cursor-zoom-in" data-foto-idx="' + i + '" ' +
'title="' + esc(r.texto + ' · ' + f.origem) + ' — clique para ampliar">' +
'<img src="' + esc(f.url) + '" alt="' + esc(r.texto) + '" loading="lazy" ' +
'class="w-full ' + (destaque ? 'h-52' : 'h-24') + ' object-cover rounded-xl border border-white/10 ' +
'group-hover:opacity-90 transition-opacity">' +
etiqueta +
'</figure>';
};
const resto = fotos.slice(1).map((f, i) => figura(f, i + 1, false)).join('');
wrap.innerHTML = figura(fotos[0], 0, true) +
(resto ? '<div class="grid grid-cols-3 gap-2">' + resto + '</div>' : '');
wrap.querySelectorAll('[data-foto-idx]').forEach(el =>
el.addEventListener('click', () => abrirVisualizador(+el.dataset.fotoIdx)));
}
// ── Visualizador (lightbox) — amplia e navega entre as fotos ──────────
let fotoAtual = 0;
function abrirVisualizador(i) {
fotoAtual = i;
document.getElementById('modal-foto').classList.remove('hidden');
pintarVisualizador();
}
function fecharVisualizador() {
document.getElementById('modal-foto').classList.add('hidden');
}
function navegarFoto(passo) {
const fotos = visita.fotos || [];
if (!fotos.length) return;
fotoAtual = (fotoAtual + passo + fotos.length) % fotos.length;
pintarVisualizador();
}
function pintarVisualizador() {
const fotos = visita.fotos || [];
const f = fotos[fotoAtual];
if (!f) return;
const r = ROTULO_FOTO[f.tipo] || { texto: f.tipo };
document.getElementById('modal-foto-img').src = f.url;
document.getElementById('modal-foto-titulo').textContent =
r.texto + ' · ' + f.origem + ' (' + (fotoAtual + 1) + '/' + fotos.length + ')';
document.getElementById('modal-foto-abrir').href = f.url;
}
// ── Campos click-to-edit ────────────────────────────────────
@@ -365,7 +450,20 @@
function fecharModal() { modal.classList.add('hidden'); }
document.getElementById('modal-hist-fechar').onclick = fecharModal;
document.getElementById('modal-hist-overlay').onclick = fecharModal;
document.addEventListener('keydown', e => { if (e.key === 'Escape') fecharModal(); });
// ── Visualizador de foto ────────────────────────────────────
document.getElementById('modal-foto-fechar').onclick = fecharVisualizador;
document.getElementById('modal-foto-overlay').onclick = fecharVisualizador;
document.getElementById('modal-foto-ant').onclick = () => navegarFoto(-1);
document.getElementById('modal-foto-prox').onclick = () => navegarFoto(1);
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { fecharModal(); fecharVisualizador(); }
// Setas só navegam com o visualizador aberto, senão atrapalham os campos.
if (document.getElementById('modal-foto').classList.contains('hidden')) return;
if (e.key === 'ArrowLeft') navegarFoto(-1);
if (e.key === 'ArrowRight') navegarFoto(1);
});
document.getElementById('btn-historico').addEventListener('click', async () => {
if (!visita) return;