533 lines
28 KiB
Plaintext
533 lines
28 KiB
Plaintext
<%# app/views/admin/edicao_lancamentos/show.html.erb %>
|
|
<%# Card estilizado com foto da fachada, edição inline (click-to-edit, salva por
|
|
campo direto na API do SimpliRoute) e histórico duplo (nosso AuditoriaLog +
|
|
/history/ da API). Os dados do card chegam via fetch (endpoint buscar). %>
|
|
<div class="space-y-6" id="edicao-app"
|
|
data-buscar-url="<%= buscar_admin_edicao_lancamento_path %>"
|
|
data-atualizar-url="<%= atualizar_admin_edicao_lancamento_path %>"
|
|
data-historico-url="<%= historico_admin_edicao_lancamento_path %>"
|
|
data-csrf="<%= form_authenticity_token %>"
|
|
data-motivos="<%= @motivos.to_json %>">
|
|
|
|
<%# Header %>
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-white">Editar Lançamento</h1>
|
|
<p class="text-gray-400 text-sm mt-0.5">Corrige status, motivo e dados de checkout de uma entrega direto no SimpliRoute</p>
|
|
</div>
|
|
|
|
<%# Flash %>
|
|
<%= render 'shared/flash' %>
|
|
|
|
<%# Aviso de sincronização %>
|
|
<div class="p-4 bg-amber-900/20 border border-amber-500/40 rounded-xl text-amber-200/90 text-sm flex items-start gap-2">
|
|
<%= icone :alerta, cor: nil, tamanho: 'w-5 h-5 mt-0.5', espaco: false %>
|
|
<p>As alterações são gravadas no <strong>SimpliRoute na hora</strong> (cada campo salva ao confirmar <%= icone :confirmar, cor: nil, tamanho: 'w-3.5 h-3.5' %>),
|
|
mas o painel e as consolidações só refletem <strong>após a próxima sincronização</strong> da base de
|
|
rastreio. Toda alteração fica registrada na auditoria.</p>
|
|
</div>
|
|
|
|
<%# Busca por NF %>
|
|
<div class="bg-[#111] border border-white/5 rounded-2xl p-5">
|
|
<label class="block text-sm font-medium text-gray-300 mb-2">Buscar entrega pela NF</label>
|
|
<div class="flex gap-2">
|
|
<input id="campo-nf" type="text" inputmode="numeric" placeholder="Ex: 79774"
|
|
class="flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl px-4 py-3 text-white
|
|
placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]">
|
|
<button id="btn-buscar" type="button"
|
|
class="px-6 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold rounded-xl
|
|
transition-colors min-h-[48px] whitespace-nowrap">
|
|
Buscar
|
|
</button>
|
|
</div>
|
|
<p id="busca-erro" class="hidden mt-2 text-sm text-red-400"></p>
|
|
<p id="busca-loading" class="hidden mt-2 text-sm text-gray-400">Consultando SimpliRoute…</p>
|
|
</div>
|
|
|
|
<%# Card do lançamento (preenchido via JS após a busca) %>
|
|
<div id="card" class="hidden bg-[#111] border border-white/5 rounded-2xl overflow-hidden">
|
|
|
|
<%# Header do card %>
|
|
<div class="p-5 border-b border-white/5 flex flex-wrap items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<div class="flex items-center gap-3 flex-wrap">
|
|
<h2 id="c-titulo" class="text-white font-bold text-lg truncate">—</h2>
|
|
<span id="c-badge" class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold bg-gray-800 text-gray-400">—</span>
|
|
</div>
|
|
<p id="c-endereco" class="text-gray-400 text-sm mt-1">—</p>
|
|
<div class="flex flex-wrap gap-2 mt-2 text-xs text-gray-500">
|
|
<span class="px-2 py-0.5 rounded-full bg-white/5">NF <span id="c-nf">—</span></span>
|
|
<span class="px-2 py-0.5 rounded-full bg-white/5">Visita <span id="c-id">—</span></span>
|
|
<span class="px-2 py-0.5 rounded-full bg-white/5"><%= icone :caminhao %> <span id="c-motorista">—</span></span>
|
|
<span class="px-2 py-0.5 rounded-full bg-white/5"><%= icone :veiculo %> <span id="c-veiculo">—</span></span>
|
|
<span class="px-2 py-0.5 rounded-full bg-white/5"><%= icone :telefone %> <span id="c-telefone">—</span></span>
|
|
</div>
|
|
</div>
|
|
<button id="btn-historico" type="button"
|
|
class="px-4 py-2.5 bg-[#1a1a1a] hover:bg-[#252525] border border-white/10 text-gray-200
|
|
text-sm font-semibold rounded-xl transition-colors whitespace-nowrap">
|
|
<%= icone :historico %> Histórico
|
|
</button>
|
|
</div>
|
|
|
|
<div class="grid lg:grid-cols-3 gap-0">
|
|
|
|
<%# 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">
|
|
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 %>
|
|
<div class="lg:col-span-2 p-5">
|
|
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">
|
|
Dados do lançamento <span class="normal-case font-normal text-gray-600">— clique num valor para editar</span>
|
|
</p>
|
|
<div id="c-campos" class="grid sm:grid-cols-2 gap-3"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%# Modal de histórico %>
|
|
<div id="modal-hist" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div id="modal-hist-overlay" class="absolute inset-0 bg-black/70"></div>
|
|
<div class="relative bg-[#151515] border border-white/10 rounded-2xl w-full max-w-2xl max-h-[85vh] flex flex-col">
|
|
<div class="flex items-center justify-between p-4 border-b border-white/5">
|
|
<h3 class="text-white font-bold flex items-center gap-2"><%= icone :historico, espaco: false %> Histórico de alterações</h3>
|
|
<button id="modal-hist-fechar" type="button"
|
|
class="w-9 h-9 rounded-lg bg-white/5 hover:bg-white/10 text-gray-300" aria-label="Fechar"><%= icone :fechar, cor: nil, tamanho: 'w-5 h-5', espaco: false %></button>
|
|
</div>
|
|
<div id="modal-hist-corpo" class="p-4 overflow-y-auto space-y-5 text-sm">
|
|
<p class="text-gray-400">Carregando…</p>
|
|
</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>
|
|
(function () {
|
|
const app = document.getElementById('edicao-app');
|
|
const URLS = {
|
|
buscar: app.dataset.buscarUrl,
|
|
atualizar: app.dataset.atualizarUrl,
|
|
historico: app.dataset.historicoUrl
|
|
};
|
|
const CSRF = app.dataset.csrf;
|
|
const MOTIVOS = JSON.parse(app.dataset.motivos || '[]');
|
|
|
|
let visita = null; // estado atual do lançamento carregado
|
|
|
|
// ── Config dos campos editáveis ─────────────────────────────
|
|
// Ícone do sprite (public/icons.svg). fill=currentColor -> herda a cor do badge.
|
|
function ico(id, cls) {
|
|
return '<svg class="' + (cls || 'w-3.5 h-3.5') + '" fill="currentColor" ' +
|
|
'style="display:inline-block;vertical-align:-2px"><use href="/icons.svg#' + id + '"></use></svg>';
|
|
}
|
|
|
|
const STATUS = {
|
|
completed: { label: 'Completa', cls: 'bg-green-600 text-white', icone: 'check-circle-fill' },
|
|
failed: { label: 'Falha', cls: 'bg-red-600 text-white', icone: 'x-circle-fill' },
|
|
pending: { label: 'Pendente', cls: 'bg-yellow-500 text-black', icone: 'clock-fill' },
|
|
partial: { label: 'Parcial', cls: 'bg-blue-600 text-white', icone: 'hourglass-split' },
|
|
canceled: { label: 'Cancelada', cls: 'bg-gray-700 text-gray-300', icone: 'slash-circle' }
|
|
};
|
|
const CAMPOS = [
|
|
{ campo: 'status', label: 'Status', tipo: 'status' },
|
|
{ campo: 'checkout_observation', label: 'Motivo (insucesso)', tipo: 'motivo' },
|
|
{ campo: 'checkout_comment', label: 'Comentário do checkout', tipo: 'textarea', col2: true },
|
|
{ campo: 'notes', label: 'Observações (notes)', tipo: 'textarea', col2: true },
|
|
{ campo: 'checkout_time', label: 'Data/hora do checkout', tipo: 'datetime' },
|
|
{ campo: 'checkout_latitude', label: 'Latitude', tipo: 'texto' },
|
|
{ campo: 'checkout_longitude', label: 'Longitude', tipo: 'texto' }
|
|
];
|
|
|
|
const esc = s => String(s ?? '').replace(/[&<>"']/g,
|
|
m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
|
|
|
|
const motivoLabel = uuid => (MOTIVOS.find(m => m.id === uuid) || {}).label || null;
|
|
|
|
// ── Busca por NF ────────────────────────────────────────────
|
|
const campoNf = document.getElementById('campo-nf');
|
|
const erroEl = document.getElementById('busca-erro');
|
|
const loadEl = document.getElementById('busca-loading');
|
|
const cardEl = document.getElementById('card');
|
|
|
|
async function buscar() {
|
|
const nf = campoNf.value.trim();
|
|
erroEl.classList.add('hidden');
|
|
if (!nf) { mostrarErroBusca('Informe o número da NF.'); return; }
|
|
|
|
loadEl.classList.remove('hidden');
|
|
cardEl.classList.add('hidden');
|
|
try {
|
|
const resp = await fetch(URLS.buscar + '?nf=' + encodeURIComponent(nf), { headers: { 'Accept': 'application/json' } });
|
|
const data = await resp.json();
|
|
if (!data.ok) { mostrarErroBusca(data.erro || 'Não foi possível localizar a entrega.'); return; }
|
|
visita = data.visita;
|
|
renderCard();
|
|
cardEl.classList.remove('hidden');
|
|
} catch (e) {
|
|
mostrarErroBusca('Erro de conexão ao buscar a NF.');
|
|
} finally {
|
|
loadEl.classList.add('hidden');
|
|
}
|
|
}
|
|
function mostrarErroBusca(msg) { erroEl.textContent = msg; erroEl.classList.remove('hidden'); }
|
|
|
|
document.getElementById('btn-buscar').addEventListener('click', buscar);
|
|
campoNf.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); buscar(); } });
|
|
|
|
// ── Card ────────────────────────────────────────────────────
|
|
function renderCard() {
|
|
const v = visita;
|
|
document.getElementById('c-titulo').textContent = v.titulo || '—';
|
|
document.getElementById('c-endereco').textContent = v.endereco || '—';
|
|
document.getElementById('c-nf').textContent = v.nf ?? '—';
|
|
document.getElementById('c-id').textContent = v.id ?? '—';
|
|
document.getElementById('c-motorista').textContent = v.motorista || '—';
|
|
document.getElementById('c-veiculo').textContent = v.veiculo || '—';
|
|
document.getElementById('c-telefone').textContent = v.telefone || '—';
|
|
renderBadge();
|
|
renderFotos();
|
|
renderCampos();
|
|
}
|
|
|
|
function renderBadge() {
|
|
const cfg = STATUS[visita.status] || { label: visita.status || '—', cls: 'bg-gray-800 text-gray-400', icone: 'question-circle' };
|
|
const b = document.getElementById('c-badge');
|
|
b.className = 'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold ' + cfg.cls;
|
|
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.
|
|
// Cor da etiqueta por CATEGORIA (cat vem do servidor). O texto do rótulo
|
|
// também vem pronto do servidor (f.rotulo), porque os campos de foto do
|
|
// SimpliRoute variam por conta. `cls` = etiqueta translúcida (foto grande em
|
|
// destaque); `txt` = só a cor do texto (miniatura, sobre fundo preto sólido —
|
|
// a versão translúcida some sobre foto clara).
|
|
const COR_CAT = {
|
|
fachada: { cls: 'bg-orange-500/20 text-orange-300 border-orange-500/40', txt: 'text-orange-300' },
|
|
prova: { cls: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40', txt: 'text-emerald-300' },
|
|
nf: { cls: 'bg-blue-500/20 text-blue-300 border-blue-500/40', txt: 'text-blue-300' },
|
|
termo: { cls: 'bg-amber-500/20 text-amber-300 border-amber-500/40', txt: 'text-amber-300' },
|
|
relatorio: { cls: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/40', txt: 'text-cyan-300' },
|
|
assinatura: { cls: 'bg-purple-500/20 text-purple-300 border-purple-500/40', txt: 'text-purple-300' },
|
|
entrega: { cls: 'bg-sky-500/20 text-sky-300 border-sky-500/40', txt: 'text-sky-300' },
|
|
extra: { cls: 'bg-white/10 text-gray-300 border-white/20', txt: 'text-gray-300' }
|
|
};
|
|
const COR_PADRAO = COR_CAT.extra;
|
|
|
|
function renderFotos() {
|
|
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') + ' Nenhuma foto registrada neste lançamento</div>';
|
|
return;
|
|
}
|
|
|
|
// 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 c = COR_CAT[f.cat] || COR_PADRAO;
|
|
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 ' + c.cls + '">' + esc(f.rotulo) + '</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 ' + c.txt + '">' + esc(f.rotulo) + '</figcaption>';
|
|
|
|
return '<figure class="relative group cursor-zoom-in" data-foto-idx="' + i + '" ' +
|
|
'title="' + esc(f.rotulo + ' · ' + f.origem) + ' — clique para ampliar">' +
|
|
'<img src="' + esc(f.url) + '" alt="' + esc(f.rotulo) + '" 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;
|
|
document.getElementById('modal-foto-img').src = f.url;
|
|
document.getElementById('modal-foto-titulo').textContent =
|
|
f.rotulo + ' · ' + f.origem + ' (' + (fotoAtual + 1) + '/' + fotos.length + ')';
|
|
document.getElementById('modal-foto-abrir').href = f.url;
|
|
}
|
|
|
|
// ── Campos click-to-edit ────────────────────────────────────
|
|
function valorExibicao(def) {
|
|
const v = visita[def.campo];
|
|
if (def.tipo === 'status') return (STATUS[v] || {}).label || v || '—';
|
|
if (def.tipo === 'motivo') return v ? (motivoLabel(v) || v) : '— sem motivo —';
|
|
if (def.tipo === 'datetime') {
|
|
if (!v) return '—';
|
|
const s = String(v);
|
|
return s.slice(8, 10) + '/' + s.slice(5, 7) + '/' + s.slice(0, 4) + ' ' + s.slice(11, 16);
|
|
}
|
|
return (v === null || v === undefined || v === '') ? '—' : String(v);
|
|
}
|
|
|
|
function renderCampos() {
|
|
const grid = document.getElementById('c-campos');
|
|
grid.innerHTML = '';
|
|
CAMPOS.forEach(def => {
|
|
const div = document.createElement('div');
|
|
div.className = 'campo group bg-[#1a1a1a] border border-white/5 rounded-xl px-4 py-3 cursor-pointer ' +
|
|
'hover:border-orange-500/50 transition-colors' + (def.col2 ? ' sm:col-span-2' : '');
|
|
div.dataset.campo = def.campo;
|
|
pintarExibicao(div, def);
|
|
grid.appendChild(div);
|
|
});
|
|
}
|
|
|
|
function pintarExibicao(div, def, flash) {
|
|
div.innerHTML =
|
|
'<p class="text-xs text-gray-500 mb-0.5">' + esc(def.label) + '</p>' +
|
|
'<div class="flex items-start justify-between gap-2">' +
|
|
'<p class="texto-valor text-sm text-white break-words min-h-[20px]">' + esc(valorExibicao(def)) + '</p>' +
|
|
'<span class="text-gray-600 group-hover:text-orange-400" title="Editar">' + ico('pencil-fill') + '</span>' +
|
|
'</div>';
|
|
div.onclick = () => abrirEdicao(div, def);
|
|
if (flash) {
|
|
div.classList.add('ring-2', 'ring-green-500/70');
|
|
setTimeout(() => div.classList.remove('ring-2', 'ring-green-500/70'), 1200);
|
|
}
|
|
}
|
|
|
|
function abrirEdicao(div, def) {
|
|
div.onclick = null;
|
|
div.classList.remove('cursor-pointer');
|
|
const atual = visita[def.campo];
|
|
|
|
let editorHtml;
|
|
const base = 'w-full bg-[#111] border border-orange-500/60 rounded-lg px-2.5 py-2 text-sm text-white focus:outline-none';
|
|
if (def.tipo === 'status') {
|
|
editorHtml = '<select class="ed ' + base + '">' +
|
|
Object.entries(STATUS).map(([v, c]) =>
|
|
'<option value="' + v + '"' + (v === atual ? ' selected' : '') + '>' + c.label + '</option>').join('') +
|
|
'</select>';
|
|
} else if (def.tipo === 'motivo') {
|
|
editorHtml = '<select class="ed ' + base + '">' +
|
|
'<option value="">— sem motivo —</option>' +
|
|
MOTIVOS.map(m =>
|
|
'<option value="' + esc(m.id) + '"' + (m.id === atual ? ' selected' : '') + '>' + esc(m.label) + '</option>').join('') +
|
|
'</select>';
|
|
} else if (def.tipo === 'textarea') {
|
|
editorHtml = '<textarea rows="2" class="ed ' + base + '">' + esc(atual ?? '') + '</textarea>';
|
|
} else if (def.tipo === 'datetime') {
|
|
const valor = atual ? String(atual).slice(0, 16) : '';
|
|
editorHtml = '<input type="datetime-local" value="' + esc(valor) + '" class="ed ' + base + '">';
|
|
} else {
|
|
editorHtml = '<input type="text" value="' + esc(atual ?? '') + '" class="ed ' + base + '">';
|
|
}
|
|
|
|
div.innerHTML =
|
|
'<p class="text-xs text-orange-400 mb-1">' + esc(def.label) + '</p>' +
|
|
editorHtml +
|
|
'<div class="flex items-center gap-2 mt-2">' +
|
|
'<button type="button" class="btn-ok px-3 py-1.5 bg-green-600 hover:bg-green-500 text-white text-xs font-bold rounded-lg">' + ico('check-lg', 'w-3 h-3') + ' Salvar</button>' +
|
|
'<button type="button" class="btn-cancelar px-3 py-1.5 bg-white/5 hover:bg-white/10 text-gray-300 text-xs rounded-lg">' + ico('x-lg', 'w-3 h-3') + ' Cancelar</button>' +
|
|
'<span class="msg-salvando hidden text-xs text-gray-400">Salvando…</span>' +
|
|
'</div>' +
|
|
'<p class="msg-erro hidden mt-1.5 text-xs text-red-400"></p>';
|
|
|
|
const ed = div.querySelector('.ed');
|
|
ed.focus();
|
|
|
|
const cancelar = () => pintarExibicao(div, def) || div.classList.add('cursor-pointer');
|
|
div.querySelector('.btn-cancelar').onclick = e => { e.stopPropagation(); cancelar(); };
|
|
div.querySelector('.btn-ok').onclick = e => { e.stopPropagation(); salvar(div, def, ed.value); };
|
|
ed.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') cancelar();
|
|
if (e.key === 'Enter' && def.tipo !== 'textarea') { e.preventDefault(); salvar(div, def, ed.value); }
|
|
});
|
|
}
|
|
|
|
async function salvar(div, def, valor) {
|
|
const msgErro = div.querySelector('.msg-erro');
|
|
const msgSalvando = div.querySelector('.msg-salvando');
|
|
msgErro.classList.add('hidden');
|
|
msgSalvando.classList.remove('hidden');
|
|
try {
|
|
const body = { visit_id: visita.id };
|
|
body[def.campo] = valor;
|
|
const resp = await fetch(URLS.atualizar, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'X-CSRF-Token': CSRF
|
|
},
|
|
body: JSON.stringify(body)
|
|
});
|
|
const data = await resp.json();
|
|
if (!data.ok) {
|
|
msgErro.textContent = data.erro || 'Erro ao salvar.';
|
|
msgErro.classList.remove('hidden');
|
|
return;
|
|
}
|
|
// Atualiza o estado local com o que a API confirmou (ou mantém o atual
|
|
// quando o valor não mudou — campos: []).
|
|
if (data.campos && data.campos.length) Object.assign(visita, data.visita);
|
|
pintarExibicao(div, def, data.campos && data.campos.length > 0);
|
|
div.classList.add('cursor-pointer');
|
|
if (def.campo === 'status') renderBadge();
|
|
} catch (e) {
|
|
msgErro.textContent = 'Erro de conexão ao salvar.';
|
|
msgErro.classList.remove('hidden');
|
|
} finally {
|
|
msgSalvando.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// ── Histórico (modal) ───────────────────────────────────────
|
|
const modal = document.getElementById('modal-hist');
|
|
const corpo = document.getElementById('modal-hist-corpo');
|
|
|
|
function fecharModal() { modal.classList.add('hidden'); }
|
|
document.getElementById('modal-hist-fechar').onclick = fecharModal;
|
|
document.getElementById('modal-hist-overlay').onclick = 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;
|
|
modal.classList.remove('hidden');
|
|
corpo.innerHTML = '<p class="text-gray-400">Carregando…</p>';
|
|
try {
|
|
const resp = await fetch(URLS.historico + '?visit_id=' + encodeURIComponent(visita.id),
|
|
{ headers: { 'Accept': 'application/json' } });
|
|
const data = await resp.json();
|
|
corpo.innerHTML = htmlHistorico(data);
|
|
} catch (e) {
|
|
corpo.innerHTML = '<p class="text-red-400">Erro ao carregar o histórico.</p>';
|
|
}
|
|
});
|
|
|
|
function nomeCampo(campo) {
|
|
const def = CAMPOS.find(c => c.campo === campo);
|
|
return def ? def.label : campo;
|
|
}
|
|
function fmtValor(campo, valor) {
|
|
if (valor === null || valor === undefined || valor === '') return '—';
|
|
if (campo === 'checkout_observation') return motivoLabel(valor) || valor;
|
|
if (campo === 'status') return (STATUS[valor] || {}).label || valor;
|
|
return String(valor);
|
|
}
|
|
|
|
function htmlHistorico(data) {
|
|
let html = '<div>' +
|
|
'<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">' + ico('journal-text') + ' Nosso sistema (auditoria)</p>';
|
|
const interno = (data.interno || []);
|
|
if (!interno.length) {
|
|
html += '<p class="text-gray-500">Nenhuma alteração registrada pelo sistema.</p>';
|
|
} else {
|
|
html += interno.map(l => {
|
|
const mudancas = Object.keys(l.para || {}).map(campo =>
|
|
'<li class="text-gray-300"><span class="text-gray-500">' + esc(nomeCampo(campo)) + ':</span> ' +
|
|
'<span class="text-red-300/80 line-through">' + esc(fmtValor(campo, (l.de || {})[campo])) + '</span>' +
|
|
' → <span class="text-green-300">' + esc(fmtValor(campo, l.para[campo])) + '</span></li>').join('');
|
|
return '<div class="bg-[#1a1a1a] border border-white/5 rounded-xl p-3 mb-2">' +
|
|
'<div class="flex items-center justify-between text-xs text-gray-500 mb-1.5">' +
|
|
'<span>' + ico('person-fill') + ' ' + esc(l.usuario) + '</span><span>' + esc(l.quando) + '</span>' +
|
|
'</div>' +
|
|
'<ul class="space-y-0.5 text-xs">' + (mudancas || '<li class="text-gray-500">—</li>') + '</ul>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
html += '</div><div>' +
|
|
'<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">' + ico('globe2') + ' SimpliRoute (API)</p>';
|
|
const api = (data.api || []);
|
|
if (!api.length) {
|
|
html += '<p class="text-gray-500">Sem registros de histórico na API do SimpliRoute.</p>';
|
|
} else {
|
|
html += '<pre class="bg-[#1a1a1a] border border-white/5 rounded-xl p-3 text-xs text-gray-300 overflow-x-auto">' +
|
|
esc(JSON.stringify(api, null, 2)) + '</pre>';
|
|
}
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
})();
|
|
</script>
|