teste #3
BIN
Imagens para implantação /Imagem colada (8).png
Normal file
BIN
Imagens para implantação /Imagem colada (8).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 666 KiB |
@@ -162,6 +162,30 @@ module Analytics
|
|||||||
pontos_mapa.first || SP_CENTRO
|
pontos_mapa.first || SP_CENTRO
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def datas_checkout
|
def datas_checkout
|
||||||
@@ -200,16 +224,15 @@ module Analytics
|
|||||||
conn = ActiveRecord::Base.connection
|
conn = ActiveRecord::Base.connection
|
||||||
|
|
||||||
unions = @tabelas.map do |tabela|
|
unions = @tabelas.map do |tabela|
|
||||||
gade = conn.quote_table_name(tabela)
|
gade = conn.quote_table_name(tabela)
|
||||||
label = conn.quote(Operacao.label(tabela))
|
label = conn.quote(Operacao.label(tabela))
|
||||||
status = coluna_status_gade(conn, tabela)
|
|
||||||
<<~SQL.strip
|
<<~SQL.strip
|
||||||
SELECT #{label} AS operacao,
|
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.checkout, r.planned_date,
|
||||||
r.latitude, r.longitude,
|
r.latitude, r.longitude,
|
||||||
r.checkout_latitude, r.checkout_longitude,
|
r.checkout_latitude, r.checkout_longitude,
|
||||||
#{status} AS status_gade
|
#{selects_gade(conn, tabela)}
|
||||||
FROM ultimo r
|
FROM ultimo r
|
||||||
INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal
|
INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal
|
||||||
WHERE r.rn = 1#{filtro_periodo(conn)}
|
WHERE r.rn = 1#{filtro_periodo(conn)}
|
||||||
@@ -231,12 +254,19 @@ module Analytics
|
|||||||
conn.select_all(sql).to_a
|
conn.select_all(sql).to_a
|
||||||
end
|
end
|
||||||
|
|
||||||
# g.status existe só em ALGUMAS tabelas de operação (ex.: ubs_norte). Quando a
|
# Colunas extras da tabela gade que só existem em ALGUMAS operações (ex.:
|
||||||
# coluna não existe, devolve NULL — o painel "Por Status" cai no rótulo "—".
|
# ubs_norte). Quando a coluna não existe, devolve NULL com o mesmo alias — assim
|
||||||
# (A única coluna garantida em toda tabela gade é nota_fiscal.)
|
# o UNION entre operações continua consistente. (A única coluna garantida em
|
||||||
def coluna_status_gade(conn, tabela)
|
# 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 = 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
|
end
|
||||||
|
|
||||||
# Filtro de período OPCIONAL — usado só na visão Global (inicio/fim presentes).
|
# Filtro de período OPCIONAL — usado só na visão Global (inicio/fim presentes).
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
<%# locals: metricas (Analytics::OperacaoMetricas) %>
|
<%# locals: metricas (Analytics::OperacaoMetricas) %>
|
||||||
<% pontos = metricas.pontos_mapa %>
|
<% heat = metricas.pontos_mapa %>
|
||||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
<% pontos = metricas.pontos_detalhados %>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="mapa-box bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||||
<p class="text-gray-400 text-sm font-medium">Mapa de Calor de Entregas</p>
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||||
<span class="text-xs text-gray-500"><%= pontos.size %> pontos</span>
|
<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>
|
</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>
|
<p class="text-center py-8 text-gray-500 text-sm">Sem coordenadas para exibir no período.</p>
|
||||||
<% else %>
|
<% 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-pontos="<%= pontos.to_json %>"
|
||||||
data-centro="<%= metricas.centro_mapa.to_json %>"></div>
|
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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,11 +25,13 @@
|
|||||||
<% celulas.each_with_index do |cel, i| %>
|
<% 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' %>">
|
<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 %>
|
<% if i.zero? && filtro %>
|
||||||
<%= link_to cel,
|
<% destino = ativo ? base.except(filtro) : base.merge(filtro => cel.to_s) %>
|
||||||
operacoes_dashboard_path(base.merge(filtro => cel.to_s)),
|
<%= link_to operacoes_dashboard_path(destino),
|
||||||
data: { turbo: false },
|
data: { turbo: false },
|
||||||
title: "Filtrar por #{cel}",
|
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}" %>
|
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 %>
|
<% else %>
|
||||||
<%= cel %>
|
<%= cel %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -333,19 +333,88 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Mapa de calor (Leaflet) ──
|
// ── Mapa de calor (Leaflet) ──
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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() {
|
function initMapas() {
|
||||||
if (typeof L === 'undefined') return;
|
if (typeof L === 'undefined') return;
|
||||||
document.querySelectorAll('.mapa-calor').forEach(function (el) {
|
document.querySelectorAll('.mapa-box').forEach(function (box) {
|
||||||
if (el._leaflet_id) return;
|
const el = box.querySelector('.mapa-calor');
|
||||||
const pontos = JSON.parse(el.dataset.pontos || '[]');
|
if (!el || el._leaflet_id) return;
|
||||||
const centro = JSON.parse(el.dataset.centro || '[-23.55,-46.63]');
|
|
||||||
|
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);
|
const mapa = L.map(el).setView(centro, 11);
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '© OpenStreetMap', maxZoom: 19
|
attribution: '© OpenStreetMap', maxZoom: 19
|
||||||
}).addTo(mapa);
|
}).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);
|
setTimeout(function () { mapa.invalidateSize(); }, 200);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user