627 lines
30 KiB
Plaintext
627 lines
30 KiB
Plaintext
<%# 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 %>
|
||
|
||
<%# Atualiza os dados sozinho a cada 5 min (data-auto-refresh-interval-value em
|
||
ms), sem o operador apertar F5. Ver auto_refresh_controller.js. %>
|
||
<div class="space-y-6" data-controller="auto-refresh" data-auto-refresh-interval-value="300000">
|
||
|
||
<%# ── 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 <%= ultima_atualizacao_label %>
|
||
</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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|