Files
Reem-Notas/app/views/operacoes_dashboard/index.html.erb

625 lines
30 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<%# app/views/operacoes_dashboard/index.html.erb %>
<%# Dashboard de análise de entregas por operação (KPIs, insucessos, STS, mapa). %>
<% content_for :head do %>
<meta name="turbo-cache-control" content="no-cache">
<%# Flatpickr (date range) — CSS no <head> %>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/themes/dark.css">
<%# Leaflet (mapa de calor) %>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
.flatpickr-calendar { background: #1a1a1a; border: 1px solid rgba(255,255,255,.1); box-shadow: 0 10px 30px rgba(0,0,0,.5); }
.flatpickr-months, .flatpickr-weekdays, .flatpickr-weekday { background: #1a1a1a; color: #9ca3af; }
.flatpickr-current-month, .flatpickr-monthDropdown-months, .numInput { color: #fff; }
.flatpickr-day { color: #e5e7eb; }
.flatpickr-day:hover { background: #2a2a2a; border-color: #2a2a2a; }
.flatpickr-day.today { border-color: #f97316; }
.flatpickr-day.selected, .flatpickr-day.startRange, .flatpickr-day.endRange,
.flatpickr-day.selected:hover, .flatpickr-day.startRange:hover, .flatpickr-day.endRange:hover {
background: #f97316; border-color: #f97316; color: #0a0a0a;
}
.flatpickr-day.inRange { background: rgba(249,115,22,.18); border-color: rgba(249,115,22,.18); box-shadow: -5px 0 0 rgba(249,115,22,.18), 5px 0 0 rgba(249,115,22,.18); }
.flatpickr-months .flatpickr-prev-month svg, .flatpickr-months .flatpickr-next-month svg { fill: #f97316; }
/* O mapa do Leaflet usa fundo claro — moldura escura ao redor */
.leaflet-container { background: #1a1a1a; }
/* Modo escuro = OpenStreetMap completo (detalhe igual ao claro) invertido,
para ruas e nomes ficarem bem visíveis sobre fundo escuro. */
.tiles-escuro-contraste { filter: invert(1) hue-rotate(180deg) brightness(0.95) contrast(1.05); }
</style>
<% end %>
<% hoje = Date.current %>
<% atalhos = {
'Hoje' => [hoje, hoje],
'7 dias' => [hoje - 6, hoje],
'Este mês' => [hoje.beginning_of_month, hoje],
'30 dias' => [hoje - 29, hoje]
} %>
<% ini_iso = @periodo_inicio.strftime('%Y-%m-%d') %>
<% fim_iso = @periodo_fim.strftime('%Y-%m-%d') %>
<%# Parâmetros de operação a preservar ao trocar de modo/período %>
<% ctx_ops = { operacao: @operacao, op_a: @op_a, op_b: @op_b }.compact %>
<div class="space-y-6">
<%# ── Cabeçalho ─────────────────────────────────────────── %>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-white">Dashboard de Operações</h1>
<p class="text-gray-400 text-sm mt-0.5">
<% if @modo == 'global' %>
🌐 Global · <%= @periodo_inicio.strftime('%d/%m/%Y') %> <%= @periodo_fim.strftime('%d/%m/%Y') %>
<% elsif @modo == 'comparar' %>
⚖️ Comparação entre operações (cada uma no seu próprio período)
<% elsif @metricas&.data_inicio %>
<%= @metricas.operacoes_label %> · <%= @metricas.data_inicio.strftime('%d/%m/%Y') %> <%= @metricas.data_fim.strftime('%d/%m/%Y') %>
<% elsif @operacao %>
<%= Operacao.label(@operacao) %>
<% end %>
· Atualizado às <%= Time.now.strftime('%H:%M') %>
</p>
</div>
<%# Filtro de período — só na visão Global (a faixa de data não se aplica a
operação única, que usa o período natural dos próprios dados). %>
<% if @modo == 'global' %>
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-orange-500">📅</span>
<input type="text" id="periodo-range" readonly placeholder="Selecione o período"
value="<%= @periodo_inicio.strftime('%d/%m/%Y') %> até <%= @periodo_fim.strftime('%d/%m/%Y') %>"
class="cursor-pointer pl-4 pr-10 py-2.5 bg-[#1a1a1a] border border-white/10 rounded-xl
text-white text-sm w-full sm:w-[240px] focus:outline-none focus:border-orange-500 placeholder-gray-500">
</div>
<div class="flex flex-wrap items-center gap-1">
<% atalhos.each do |label, (ini, fim)| %>
<% ativo = @periodo_inicio == ini && @periodo_fim == fim %>
<%= link_to label,
operacoes_dashboard_path(modo: 'global', inicio: ini.strftime('%Y-%m-%d'), fim: fim.strftime('%Y-%m-%d')),
data: { turbo: false },
class: "px-3 py-2.5 rounded-xl text-sm whitespace-nowrap border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %>
<% end %>
</div>
</div>
<% end %>
</div>
<%# ── Abas de modo ──────────────────────────────────────── %>
<% modos = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global', 'comparar' => '⚖️ Comparar' } %>
<div class="flex flex-wrap items-center gap-2 border-b border-white/5 pb-3">
<% modos.each do |m, label| %>
<% ativo = @modo == m %>
<%= link_to label,
operacoes_dashboard_path(ctx_ops.merge(modo: m, inicio: ini_iso, fim: fim_iso)),
data: { turbo: false },
class: "px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap #{ativo ? 'bg-orange-500 text-black' : 'bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500'}" %>
<% end %>
</div>
<% if @tabelas_validas.blank? %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-10 text-center text-gray-400">
Nenhuma operação encontrada no banco (tabelas <code class="text-orange-400">gade_entregas_*</code>).
</div>
<% else %>
<%# ── Filtros ativos (cross-filter por clique nas tabelas) ── %>
<% if @chips.any? && %w[operacao global].include?(@modo) %>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-gray-500 uppercase tracking-wider">Filtros ativos:</span>
<% @chips.each do |chip| %>
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-300 text-xs">
<%= chip[:rotulo] %>: <strong class="text-white"><%= chip[:display] || chip[:valor] %></strong>
<%= link_to '✕', operacoes_dashboard_path(request.query_parameters.except(chip[:param])),
data: { turbo: false }, class: 'hover:text-white font-bold', title: 'Remover filtro' %>
</span>
<% end %>
<%= link_to 'Limpar tudo',
operacoes_dashboard_path(request.query_parameters.except(*@chips.map { |c| c[:param] })),
data: { turbo: false }, class: 'text-xs text-gray-400 hover:text-orange-400 underline' %>
</div>
<% end %>
<%# ── Seletor de operação por modo ───────────────────── %>
<% if @modo == 'operacao' %>
<form method="get" action="<%= operacoes_dashboard_path %>" data-turbo="false" class="flex flex-wrap items-center gap-2">
<input type="hidden" name="modo" value="operacao">
<input type="hidden" name="inicio" value="<%= ini_iso %>">
<input type="hidden" name="fim" value="<%= fim_iso %>">
<label class="text-gray-400 text-sm">Operação:</label>
<select name="operacao" class="auto-submit bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500 min-w-[240px]">
<% @operacoes_agrupadas.each do |titulo, ops| %>
<optgroup label="<%= titulo %>">
<% ops.each do |op| %>
<option value="<%= op[:tabela] %>" <%= 'selected' if op[:tabela] == @operacao %>><%= op[:label] %></option>
<% end %>
</optgroup>
<% end %>
</select>
</form>
<% if @metricas %>
<%= render 'painel', metricas: @metricas, filtravel: true %>
<%= render 'mapa', metricas: @metricas %>
<% end %>
<% elsif @modo == 'global' %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4 text-sm text-gray-300">
🌐 Visão <strong class="text-white">Global</strong> — todas as
<%= @tabelas_validas.size %> operações agregadas no período.
</div>
<% if @metricas %>
<%= render 'painel', metricas: @metricas, filtravel: true %>
<%= render 'mapa', metricas: @metricas %>
<% end %>
<% else # comparar %>
<form method="get" action="<%= operacoes_dashboard_path %>" data-turbo="false" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<input type="hidden" name="modo" value="comparar">
<input type="hidden" name="inicio" value="<%= ini_iso %>">
<input type="hidden" name="fim" value="<%= fim_iso %>">
<div class="flex items-center gap-2">
<label class="text-orange-400 text-sm font-semibold">A:</label>
<select name="op_a" class="auto-submit flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500">
<% @operacoes_agrupadas.each do |titulo, ops| %>
<optgroup label="<%= titulo %>">
<% ops.each do |op| %>
<option value="<%= op[:tabela] %>" <%= 'selected' if op[:tabela] == @op_a %>><%= op[:label] %></option>
<% end %>
</optgroup>
<% end %>
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-blue-400 text-sm font-semibold">B:</label>
<select name="op_b" class="auto-submit flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-blue-500">
<% @operacoes_agrupadas.each do |titulo, ops| %>
<optgroup label="<%= titulo %>">
<% ops.each do |op| %>
<option value="<%= op[:tabela] %>" <%= 'selected' if op[:tabela] == @op_b %>><%= op[:label] %></option>
<% end %>
</optgroup>
<% end %>
</select>
</div>
</form>
<%# Comparativo direto A x B (tabela + barras agrupadas) %>
<% if @metricas_a && @metricas_b %>
<%= render 'comparativo', a: @metricas_a, b: @metricas_b,
label_a: @metricas_a.operacoes_label, label_b: @metricas_b.operacoes_label %>
<% end %>
<%# Detalhe completo de cada operação (recolhível) %>
<details class="bg-[#1a1a1a] rounded-2xl border border-white/5">
<summary class="cursor-pointer px-6 py-4 text-sm text-gray-300 hover:text-white select-none">
🔎 Ver detalhe completo de cada operação
</summary>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 p-4 pt-0">
<div>
<% if @metricas_a %>
<%= render 'painel', metricas: @metricas_a, titulo: "A · #{@metricas_a.operacoes_label}" %>
<% else %>
<p class="text-gray-500 text-sm">Selecione a operação A.</p>
<% end %>
</div>
<div>
<% if @metricas_b %>
<%= render 'painel', metricas: @metricas_b, titulo: "B · #{@metricas_b.operacoes_label}" %>
<% else %>
<p class="text-gray-500 text-sm">Selecione a operação B.</p>
<% end %>
</div>
</div>
</details>
<% end %>
<% end %>
</div>
<%# ── Scripts: flatpickr, Chart.js, Leaflet ───────────────── %>
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/pt.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script>
(function () {
const ISO = '<%= ini_iso %>';
const ISO_FIM = '<%= fim_iso %>';
// ── Flatpickr (período) ──
function initPeriodo() {
const input = document.getElementById('periodo-range');
if (!input || typeof flatpickr === 'undefined' || input._flatpickr) return;
const toISO = (d) =>
d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const parseISO = (s) => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); };
flatpickr(input, {
mode: 'range',
locale: Object.assign({}, flatpickr.l10ns.pt, { rangeSeparator: ' até ' }),
dateFormat: 'd/m/Y',
defaultDate: [parseISO(ISO), parseISO(ISO_FIM)],
maxDate: 'today',
onClose: function (selectedDates) {
if (selectedDates.length === 0) return;
const ini = toISO(selectedDates[0]);
const fim = toISO(selectedDates[selectedDates.length - 1]);
const url = new URL(window.location.href);
url.searchParams.set('inicio', ini);
url.searchParams.set('fim', fim);
window.location.href = url.toString();
}
});
}
// ── Selects que submetem o form ao mudar ──
function initAutoSubmit() {
document.querySelectorAll('select.auto-submit').forEach(function (s) {
if (s.dataset.bound) return;
s.dataset.bound = '1';
s.addEventListener('change', function () { s.form.requestSubmit(); });
});
}
// ── Cores da marca (laranja + azul p/ falha) ──
const COR_OK = '#f97316'; // laranja Reem (completas)
const COR_FAIL = '#3b82f6'; // azul (falhas), igual ao card de Recusas
const COR_A = '#f97316'; // comparativo: A = laranja da marca
const COR_B = '#3b82f6'; // comparativo: B = azul (contraste)
// Cross-filter por clique nos gráficos: recarrega a página com o filtro,
// preservando os demais parâmetros (mesma lógica dos links das tabelas).
function aplicarFiltroDash(novos) {
const url = new URL(window.location.href);
Object.keys(novos).forEach(function (k) {
const v = novos[k];
if (v === null || v === '') url.searchParams.delete(k);
else url.searchParams.set(k, v);
});
window.location.assign(url.toString());
}
function cursorPonteiro(e, els) { e.native.target.style.cursor = els.length ? 'pointer' : 'default'; }
// ── Charts (donut + barras + comparativo) ──
function initCharts() {
if (typeof Chart === 'undefined') return;
document.querySelectorAll('canvas.dash-donut').forEach(function (ctx) {
Chart.getChart(ctx)?.destroy();
new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Completas', 'Falhas'],
datasets: [{
data: [Number(ctx.dataset.completed), Number(ctx.dataset.failed)],
backgroundColor: [COR_OK, COR_FAIL],
borderColor: '#1a1a1a', borderWidth: 2
}]
},
options: {
responsive: true, maintainAspectRatio: false, cutout: '62%',
plugins: { legend: { display: false } },
onHover: cursorPonteiro,
onClick: function (e, els) {
if (!els.length) return;
aplicarFiltroDash({ f_resultado: els[0].index === 0 ? 'completed' : 'failed' });
}
}
});
});
// Plugin: desenha o valor centralizado DENTRO de cada segmento da barra
// (pula segmentos baixos demais e valores zero).
const barValueLabels = {
id: 'barValueLabels',
afterDatasetsDraw: function (chart) {
const g = chart.ctx;
g.save();
g.font = '600 11px system-ui, -apple-system, sans-serif';
g.fillStyle = '#ffffff';
g.textAlign = 'center';
g.textBaseline = 'middle';
chart.data.datasets.forEach(function (ds, i) {
const meta = chart.getDatasetMeta(i);
if (meta.hidden) return;
meta.data.forEach(function (bar, idx) {
const v = Number(ds.data[idx]) || 0;
if (!v) return;
const p = bar.getProps(['x', 'y', 'base'], true);
if (Math.abs(p.base - p.y) < 16) return; // segmento pequeno demais
g.fillText(String(v), p.x, (p.y + p.base) / 2);
});
});
g.restore();
}
};
// Total do dia (soma das séries) para o % no tooltip.
const totalDoDia = function (datasets, idx) {
return datasets.reduce(function (s, d) { return s + (Number(d.data[idx]) || 0); }, 0);
};
document.querySelectorAll('canvas.dash-bars').forEach(function (ctx) {
Chart.getChart(ctx)?.destroy();
new Chart(ctx, {
type: 'bar',
data: {
labels: JSON.parse(ctx.dataset.labels),
datasets: [
{ label: 'Completas', data: JSON.parse(ctx.dataset.completed), backgroundColor: COR_OK },
{ label: 'Falhas', data: JSON.parse(ctx.dataset.failed), backgroundColor: COR_FAIL }
]
},
plugins: [barValueLabels],
options: {
responsive: true, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
onHover: cursorPonteiro,
onClick: function (e, els) {
if (!els.length) return;
const el = els[0];
const iso = String(this.data.labels[el.index]).split('/').reverse().join('-');
aplicarFiltroDash({ f_data: iso, f_resultado: el.datasetIndex === 0 ? 'completed' : 'failed' });
},
scales: {
x: { stacked: true, ticks: { color: '#6b7280', maxTicksLimit: 12 }, grid: { color: 'rgba(255,255,255,0.04)' } },
y: { stacked: true, beginAtZero: true, ticks: { color: '#6b7280' }, grid: { color: 'rgba(255,255,255,0.04)' } }
},
plugins: {
legend: { labels: { color: '#9ca3af' } },
// Card de hover no tema escuro do app: data + Completas/Falhas (n e %) + Total.
tooltip: {
mode: 'index', intersect: false,
backgroundColor: '#1f1f1f',
borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1,
titleColor: '#ffffff', bodyColor: '#d1d5db', footerColor: '#9ca3af',
padding: 12, cornerRadius: 12, boxPadding: 4, usePointStyle: true,
titleFont: { weight: '700' }, footerFont: { weight: '600' },
callbacks: {
title: function (items) { return items.length ? items[0].label : ''; },
label: function (item) {
const total = totalDoDia(item.chart.data.datasets, item.dataIndex);
const v = Number(item.raw) || 0;
const pct = total ? (v * 100 / total).toFixed(2) : '0.00';
return ' ' + item.dataset.label + ': ' + v + ' (' + pct + '%)';
},
footer: function (items) {
if (!items.length) return '';
const total = totalDoDia(items[0].chart.data.datasets, items[0].dataIndex);
return 'Total: ' + total + ' (100%)';
}
}
}
}
}
});
});
// Comparativo A x B (barras agrupadas)
document.querySelectorAll('canvas.cmp-bars').forEach(function (ctx) {
Chart.getChart(ctx)?.destroy();
new Chart(ctx, {
type: 'bar',
data: {
labels: ['Total', 'Sucesso', 'Recusas', 'Pendentes'],
datasets: [
{ label: 'A · ' + (ctx.dataset.labelA || 'A'), data: JSON.parse(ctx.dataset.a), backgroundColor: COR_A },
{ label: 'B · ' + (ctx.dataset.labelB || 'B'), data: JSON.parse(ctx.dataset.b), backgroundColor: COR_B }
]
},
options: {
responsive: true, maintainAspectRatio: false,
scales: {
x: { ticks: { color: '#6b7280' }, grid: { color: 'rgba(255,255,255,0.04)' } },
y: { beginAtZero: true, ticks: { color: '#6b7280' }, grid: { color: 'rgba(255,255,255,0.04)' } }
},
plugins: { legend: { labels: { color: '#9ca3af' } } }
}
});
});
}
// ── Mapa de calor (Leaflet) ──
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function popupHtml(p) {
const linha = (icone, val) => val ? ('<div>' + icone + ' ' + escapeHtml(val) + '</div>') : '';
const foto = p.foto
? '<a href="' + escapeHtml(p.foto) + '" target="_blank" rel="noopener">' +
'<img src="' + escapeHtml(p.foto) + '" alt="Fachada" loading="lazy" ' +
'onerror="this.style.display=\'none\';this.parentNode.nextElementSibling.style.display=\'block\'" ' +
'style="margin-top:8px;width:100%;max-height:160px;object-fit:cover;border-radius:6px;display:block"></a>' +
'<div style="display:none;margin-top:6px;color:#9ca3af;font-size:11px">📷 Foto indisponível (link expirado/sem acesso)</div>'
: '<div style="margin-top:6px;color:#6b7280;font-size:11px">📷 Sem foto da fachada para esta entrega</div>';
const statusLinha = p.sucesso
? '<div style="color:#16a34a;font-size:11px;margin-bottom:6px">✔ Entrega realizada</div>'
: '<div style="color:#3b82f6;font-size:11px;margin-bottom:6px">✖ Insucesso' +
(p.motivo ? ' — ' + escapeHtml(p.motivo) : '') + '</div>';
return '' +
'<div style="min-width:210px;max-width:240px">' +
'<div style="font-weight:700;color:#f97316">NF ' + escapeHtml(p.nf) + '</div>' +
'<div style="font-weight:600;margin-bottom:4px">' + escapeHtml(p.nome) + '</div>' +
statusLinha +
linha('📍', p.endereco) +
linha('🏥', p.unidade) +
linha('🚚', p.motorista) +
linha('🚗', p.veiculo) +
linha('🕒', p.checkout) +
foto +
'</div>';
}
// Ícone de localização (balão) — sem depender de imagem externa.
// Cor por resultado: laranja = sucesso, azul = insucesso (falha/óbito).
function pinIcon(cor) {
return L.divIcon({
className: 'pin-entrega',
html: '<svg width="28" height="40" viewBox="0 0 28 40" xmlns="http://www.w3.org/2000/svg">' +
'<path d="M14 0C6.8 0 1 5.8 1 13c0 9.7 13 27 13 27s13-17.3 13-27C27 5.8 21.2 0 14 0z" ' +
'fill="' + cor + '" stroke="#0a0a0a" stroke-width="1.5"/>' +
'<circle cx="14" cy="13" r="5" fill="#0a0a0a"/></svg>',
iconSize: [28, 40],
iconAnchor: [14, 40],
popupAnchor: [0, -36]
});
}
function initMapas() {
if (typeof L === 'undefined') return;
const ICONE_OK = pinIcon('#f97316'); // sucesso — laranja
const ICONE_FALHA = pinIcon('#3b82f6'); // insucesso — azul
document.querySelectorAll('.mapa-box').forEach(function (box) {
const el = box.querySelector('.mapa-calor');
if (!el || el._leaflet_id) return;
const heat = JSON.parse(el.dataset.heat || '[]');
const pontos = JSON.parse(el.dataset.pontos || '[]');
const centro = JSON.parse(el.dataset.centro || '[-23.55,-46.63]');
// Camadas base (todas grátis, sem chave): escuro (padrão, combina com o
// tema), claro e satélite. O escuro usa o OpenStreetMap COMPLETO (mesmo
// detalhe do claro: ruas, nomes, POIs) e é escurecido por filtro CSS de
// inversão (.tiles-escuro-contraste).
const escuro = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap', maxZoom: 19,
className: 'tiles-escuro-contraste'
});
const claro = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap', maxZoom: 19
});
const satelite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles © Esri', maxZoom: 19
});
const mapa = L.map(el, { layers: [escuro] }).setView(centro, 11);
L.control.layers(
{ '🌙 Escuro': escuro, '🗺️ Claro': claro, '🛰️ Satélite': satelite },
{}, { position: 'topright', collapsed: true }
).addTo(mapa);
// Camada de calor (leve — entra por padrão)
const heatLayer = (heat.length && L.heatLayer)
? L.heatLayer(heat, { radius: 18, blur: 22, maxZoom: 14 }) : null;
// Camada de pontos: criada SOB DEMANDA (só na 1ª vez que abrir "Pontos").
// O popup é uma FUNÇÃO, então o HTML da foto só é montado/baixado quando
// o balão é clicado — nada de imagem é requisitado ao carregar o mapa.
let marcadores = null;
let entradas = []; // [{ marker, p }] — para a busca filtrar sem recriar
const resultadosBusca = L.layerGroup();
function construirMarcadores() {
if (marcadores) return marcadores;
marcadores = L.layerGroup();
pontos.forEach(function (p) {
const marker = L.marker([p.lat, p.lng], { icon: p.sucesso ? ICONE_OK : ICONE_FALHA })
.bindPopup(function () { return popupHtml(p); }, { maxWidth: 260 });
marker.addTo(marcadores);
entradas.push({ marker: marker, p: p });
});
return marcadores;
}
// Começa no Calor (cai para Pontos se não houver calor)
let vistaAtual = heatLayer ? 'heat' : 'pontos';
(heatLayer || construirMarcadores()).addTo(mapa);
function mostrar(view) {
vistaAtual = view;
if (heatLayer) mapa.removeLayer(heatLayer);
if (marcadores) mapa.removeLayer(marcadores);
if (view === 'pontos') { construirMarcadores().addTo(mapa); }
else { (heatLayer || construirMarcadores()).addTo(mapa); }
box.querySelectorAll('.map-toggle button').forEach(function (b) {
const on = b.dataset.view === view;
b.classList.toggle('bg-orange-500', on);
b.classList.toggle('text-black', on);
b.classList.toggle('font-semibold', on);
b.classList.toggle('text-gray-300', !on);
});
}
box.querySelectorAll('.map-toggle button').forEach(function (b) {
b.addEventListener('click', function () {
const inputBusca = box.querySelector('.mapa-busca');
if (inputBusca && inputBusca.value) { inputBusca.value = ''; }
limparBusca();
mostrar(b.dataset.view);
});
});
// ── Busca por NF / paciente / veículo ──
const inputBusca = box.querySelector('.mapa-busca');
const infoBusca = box.querySelector('.mapa-busca-info');
function limparBusca() {
mapa.removeLayer(resultadosBusca);
resultadosBusca.clearLayers();
if (infoBusca) infoBusca.textContent = '';
}
function aplicarBusca(termo) {
const q = String(termo || '').trim().toLowerCase();
if (!q) {
limparBusca();
mostrar(vistaAtual); // restaura a visão padrão (Calor ou Pontos)
return;
}
construirMarcadores();
if (heatLayer) mapa.removeLayer(heatLayer);
if (marcadores) mapa.removeLayer(marcadores);
resultadosBusca.clearLayers();
const casa = function (v) { return String(v == null ? '' : v).toLowerCase().indexOf(q) !== -1; };
const achados = entradas.filter(function (e) {
return casa(e.p.nf) || casa(e.p.nome) || casa(e.p.veiculo);
});
achados.forEach(function (e) { e.marker.addTo(resultadosBusca); });
resultadosBusca.addTo(mapa);
if (achados.length) {
const grupo = L.featureGroup(achados.map(function (e) { return e.marker; }));
mapa.fitBounds(grupo.getBounds(), { padding: [40, 40], maxZoom: 16 });
if (achados.length === 1) { achados[0].marker.openPopup(); }
if (infoBusca) infoBusca.textContent = achados.length + (achados.length === 1 ? ' resultado' : ' resultados');
} else {
if (infoBusca) infoBusca.textContent = 'Nenhum resultado';
}
}
if (inputBusca) {
let debounce = null;
inputBusca.addEventListener('input', function () {
clearTimeout(debounce);
const valor = inputBusca.value;
debounce = setTimeout(function () { aplicarBusca(valor); }, 200);
});
}
// Tela cheia (Fullscreen API sobre o container do mapa)
const btnFs = box.querySelector('.map-fullscreen');
if (btnFs) {
btnFs.addEventListener('click', function () {
if (document.fullscreenElement) { document.exitFullscreen(); }
else if (el.requestFullscreen) { el.requestFullscreen(); }
});
}
document.addEventListener('fullscreenchange', function () {
setTimeout(function () { mapa.invalidateSize(); }, 150);
});
setTimeout(function () { mapa.invalidateSize(); }, 200);
});
}
function initTudo() { initPeriodo(); initAutoSubmit(); initCharts(); initMapas(); }
initTudo();
document.addEventListener('turbo:load', initTudo);
})();
</script>