Adição de Mata com os pontos da onde foi entreque e corrige o filtro ao vivo

This commit is contained in:
2026-06-29 21:06:08 -03:00
parent 6b899ebafa
commit 7d6c79f878
5 changed files with 144 additions and 27 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

View File

@@ -162,6 +162,30 @@ module Analytics
pontos_mapa.first || SP_CENTRO
end
# Limite de marcadores no modo "Pontos" (Leaflet fica pesado com milhares).
MAX_PONTOS = 2000
# Pontos detalhados (modo "Pontos" do mapa) — um por entrega CONCLUÍDA com
# coordenada de CHECKOUT (onde a entrega foi registrada). Inclui dados para o
# popup (NF, destinatário, endereço, unidade, motorista, data).
def pontos_detalhados
@pontos_detalhados ||= linhas.filter_map do |r|
next unless r['status'] == 'completed'
lat = num(r['checkout_latitude']) || num(r['latitude'])
lng = num(r['checkout_longitude']) || num(r['longitude'])
next if lat.nil? || lng.nil? || (lat.zero? && lng.zero?)
{
lat: lat, lng: lng,
nf: r['reference_id'],
nome: r['nome_completo'].presence || r['contact_name'].presence || '—',
endereco: r['endereco_completo'].presence || r['address'].presence,
unidade: r['contact_name'],
motorista: r['driver'],
checkout: data_de(r['checkout'])&.strftime('%d/%m/%Y')
}
end.first(MAX_PONTOS)
end
private
def datas_checkout
@@ -200,16 +224,15 @@ module Analytics
conn = ActiveRecord::Base.connection
unions = @tabelas.map do |tabela|
gade = conn.quote_table_name(tabela)
label = conn.quote(Operacao.label(tabela))
status = coluna_status_gade(conn, tabela)
gade = conn.quote_table_name(tabela)
label = conn.quote(Operacao.label(tabela))
<<~SQL.strip
SELECT #{label} AS operacao,
r.driver, r.status, r.observation, r.contact_name,
r.reference_id, r.driver, r.status, r.observation, r.contact_name, r.address,
r.checkout, r.planned_date,
r.latitude, r.longitude,
r.checkout_latitude, r.checkout_longitude,
#{status} AS status_gade
#{selects_gade(conn, tabela)}
FROM ultimo r
INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal
WHERE r.rn = 1#{filtro_periodo(conn)}
@@ -231,12 +254,19 @@ module Analytics
conn.select_all(sql).to_a
end
# g.status existe em ALGUMAS tabelas de operação (ex.: ubs_norte). Quando a
# coluna não existe, devolve NULL — o painel "Por Status" cai no rótulo "—".
# (A única coluna garantida em toda tabela gade é nota_fiscal.)
def coluna_status_gade(conn, tabela)
# Colunas extras da tabela gade que só existem em ALGUMAS operações (ex.:
# ubs_norte). Quando a coluna não existe, devolve NULL com o mesmo alias — assim
# o UNION entre operações continua consistente. (A única coluna garantida em
# toda tabela gade é nota_fiscal.) As chaves são uma whitelist fixa (não entram
# dados do usuário no SQL).
GADE_OPCIONAIS = { 'status' => 'status_gade', 'nome_completo' => 'nome_completo',
'endereco_completo' => 'endereco_completo' }.freeze
def selects_gade(conn, tabela)
colunas = conn.columns(tabela).map(&:name)
colunas.include?('status') ? 'g.status' : 'CAST(NULL AS text)'
GADE_OPCIONAIS.map do |origem, apelido|
colunas.include?(origem) ? "g.#{origem} AS #{apelido}" : "CAST(NULL AS text) AS #{apelido}"
end.join(", ")
end
# Filtro de período OPCIONAL — usado só na visão Global (inicio/fim presentes).

View File

@@ -1,15 +1,31 @@
<%# locals: metricas (Analytics::OperacaoMetricas) %>
<% pontos = metricas.pontos_mapa %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<div class="flex items-center justify-between mb-4">
<p class="text-gray-400 text-sm font-medium">Mapa de Calor de Entregas</p>
<span class="text-xs text-gray-500"><%= pontos.size %> pontos</span>
<% heat = metricas.pontos_mapa %>
<% pontos = metricas.pontos_detalhados %>
<div class="mapa-box bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<p class="text-gray-400 text-sm font-medium">Mapa de Entregas</p>
<div class="flex items-center gap-2">
<div class="map-toggle inline-flex rounded-lg overflow-hidden border border-white/10 text-xs">
<button type="button" data-view="heat"
class="px-3 py-1.5 bg-orange-500 text-black font-semibold">🔥 Calor</button>
<button type="button" data-view="pontos"
class="px-3 py-1.5 text-gray-300 hover:text-white">📍 Pontos (<%= pontos.size %>)</button>
</div>
<button type="button" class="map-fullscreen px-3 py-1.5 rounded-lg border border-white/10 text-gray-300 hover:text-white text-xs" title="Ver em tela cheia">
⛶ Tela cheia
</button>
</div>
</div>
<% if pontos.empty? %>
<% if heat.empty? && pontos.empty? %>
<p class="text-center py-8 text-gray-500 text-sm">Sem coordenadas para exibir no período.</p>
<% else %>
<div class="mapa-calor h-96 rounded-xl overflow-hidden"
<div class="mapa-calor w-full h-[70vh] min-h-[480px] rounded-xl overflow-hidden bg-[#1a1a1a]"
data-heat="<%= heat.to_json %>"
data-pontos="<%= pontos.to_json %>"
data-centro="<%= metricas.centro_mapa.to_json %>"></div>
<p class="text-xs text-gray-600 mt-2">
🔥 Calor mostra a densidade · 📍 Pontos mostra cada entrega (clique no marcador para detalhes; coordenada de check-out).
</p>
<% end %>
</div>

View File

@@ -25,11 +25,13 @@
<% celulas.each_with_index do |cel, i| %>
<td class="py-2 align-top <%= i.zero? ? 'pr-3 text-gray-300' : 'pl-4 text-right whitespace-nowrap font-semibold text-white' %>">
<% if i.zero? && filtro %>
<%= link_to cel,
operacoes_dashboard_path(base.merge(filtro => cel.to_s)),
<% destino = ativo ? base.except(filtro) : base.merge(filtro => cel.to_s) %>
<%= link_to operacoes_dashboard_path(destino),
data: { turbo: false },
title: "Filtrar por #{cel}",
class: "inline-flex items-center gap-1 hover:text-orange-400 #{'text-orange-400 font-semibold' if ativo}" %>
title: (ativo ? 'Clique para remover o filtro' : "Filtrar por #{cel}"),
class: "inline-flex items-center gap-1 hover:text-orange-400 #{'text-orange-400 font-semibold' if ativo}" do %>
<%= cel %><% if ativo %> <span aria-hidden="true">✕</span><% end %>
<% end %>
<% else %>
<%= cel %>
<% end %>

View File

@@ -333,19 +333,88 @@
}
// ── 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>') : '';
return '' +
'<div style="min-width:200px">' +
'<div style="font-weight:700;color:#f97316">NF ' + escapeHtml(p.nf) + '</div>' +
'<div style="font-weight:600;margin-bottom:4px">' + escapeHtml(p.nome) + '</div>' +
'<div style="color:#16a34a;font-size:11px;margin-bottom:6px">✔ Entrega realizada</div>' +
linha('📍', p.endereco) +
linha('🏥', p.unidade) +
linha('🚚', p.motorista) +
linha('🕒', p.checkout) +
'</div>';
}
function initMapas() {
if (typeof L === 'undefined') return;
document.querySelectorAll('.mapa-calor').forEach(function (el) {
if (el._leaflet_id) return;
const pontos = JSON.parse(el.dataset.pontos || '[]');
const centro = JSON.parse(el.dataset.centro || '[-23.55,-46.63]');
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]');
const mapa = L.map(el).setView(centro, 11);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap', maxZoom: 19
}).addTo(mapa);
if (pontos.length && L.heatLayer) {
L.heatLayer(pontos, { radius: 18, blur: 22, maxZoom: 14 }).addTo(mapa);
// Camada de calor
const heatLayer = (heat.length && L.heatLayer)
? L.heatLayer(heat, { radius: 18, blur: 22, maxZoom: 14 }) : null;
// Camada de pontos (marcadores com popup)
const marcadores = L.layerGroup();
pontos.forEach(function (p) {
L.circleMarker([p.lat, p.lng], {
radius: 6, color: '#0a0a0a', weight: 1,
fillColor: '#f97316', fillOpacity: 0.85
}).bindPopup(popupHtml(p)).addTo(marcadores);
});
// Começa no Calor (cai para Pontos se não houver calor)
let atual = heatLayer || marcadores;
atual.addTo(mapa);
function mostrar(view) {
mapa.removeLayer(heatLayer || marcadores);
if (marcadores) mapa.removeLayer(marcadores);
if (heatLayer) mapa.removeLayer(heatLayer);
if (view === 'pontos') { marcadores.addTo(mapa); }
else { (heatLayer || marcadores).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 () { mostrar(b.dataset.view); });
});
// 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);
});
}