diff --git a/Imagens para implantação /Imagem colada (8).png b/Imagens para implantação /Imagem colada (8).png new file mode 100644 index 0000000..ef2166a Binary files /dev/null and b/Imagens para implantação /Imagem colada (8).png differ diff --git a/app/services/analytics/operacao_metricas.rb b/app/services/analytics/operacao_metricas.rb index 35a126b..744a6ed 100644 --- a/app/services/analytics/operacao_metricas.rb +++ b/app/services/analytics/operacao_metricas.rb @@ -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 só 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). diff --git a/app/views/operacoes_dashboard/_mapa.html.erb b/app/views/operacoes_dashboard/_mapa.html.erb index 0e196ea..027bdea 100644 --- a/app/views/operacoes_dashboard/_mapa.html.erb +++ b/app/views/operacoes_dashboard/_mapa.html.erb @@ -1,15 +1,31 @@ <%# locals: metricas (Analytics::OperacaoMetricas) %> -<% pontos = metricas.pontos_mapa %> -
-
-

Mapa de Calor de Entregas

- <%= pontos.size %> pontos +<% heat = metricas.pontos_mapa %> +<% pontos = metricas.pontos_detalhados %> +
+
+

Mapa de Entregas

+
+
+ + +
+ +
- <% if pontos.empty? %> + + <% if heat.empty? && pontos.empty? %>

Sem coordenadas para exibir no período.

<% else %> -
+

+ 🔥 Calor mostra a densidade · 📍 Pontos mostra cada entrega (clique no marcador para detalhes; coordenada de check-out). +

<% end %>
diff --git a/app/views/operacoes_dashboard/_tabela_simples.html.erb b/app/views/operacoes_dashboard/_tabela_simples.html.erb index 28338d9..985e542 100644 --- a/app/views/operacoes_dashboard/_tabela_simples.html.erb +++ b/app/views/operacoes_dashboard/_tabela_simples.html.erb @@ -25,11 +25,13 @@ <% celulas.each_with_index do |cel, i| %> <% 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 %> <% end %> + <% end %> <% else %> <%= cel %> <% end %> diff --git a/app/views/operacoes_dashboard/index.html.erb b/app/views/operacoes_dashboard/index.html.erb index be0285b..c6cc1d4 100644 --- a/app/views/operacoes_dashboard/index.html.erb +++ b/app/views/operacoes_dashboard/index.html.erb @@ -333,19 +333,88 @@ } // ── 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 ? ('
' + icone + ' ' + escapeHtml(val) + '
') : ''; + return '' + + '
' + + '
NF ' + escapeHtml(p.nf) + '
' + + '
' + escapeHtml(p.nome) + '
' + + '
✔ Entrega realizada
' + + linha('📍', p.endereco) + + linha('🏥', p.unidade) + + linha('🚚', p.motorista) + + linha('🕒', p.checkout) + + '
'; + } + 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); }); }