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

460 lines
22 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; }
</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[: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 + vermelho p/ falha) ──
const COR_OK = '#f97316'; // laranja Reem (completas)
const COR_FAIL = '#ef4444'; // vermelho (falhas), igual ao card de Recusas
const COR_A = '#f97316'; // comparativo: A = laranja da marca
const COR_B = '#3b82f6'; // comparativo: B = azul (contraste)
// ── 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 } } }
});
});
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 }
]
},
options: {
responsive: true, maintainAspectRatio: false,
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' } } }
}
});
});
// 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
? '<img src="' + escapeHtml(p.foto) + '" alt="Fachada" loading="lazy" ' +
'style="margin-top:8px;width:100%;max-height:150px;object-fit:cover;border-radius:6px">'
: '';
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>' +
'<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) +
foto +
'</div>';
}
// Ícone de localização (balão) — laranja da marca, sem depender de imagem externa.
function pinIcon() {
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="#f97316" 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 = pinIcon();
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.
const escuro = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap © CARTO', subdomains: 'abcd', maxZoom: 20
});
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
const heatLayer = (heat.length && L.heatLayer)
? L.heatLayer(heat, { radius: 18, blur: 22, maxZoom: 14 }) : null;
// Camada de pontos (marcadores em balão com popup)
const marcadores = L.layerGroup();
pontos.forEach(function (p) {
L.marker([p.lat, p.lng], { icon: ICONE })
.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);
});
}
function initTudo() { initPeriodo(); initAutoSubmit(); initCharts(); initMapas(); }
initTudo();
document.addEventListener('turbo:load', initTudo);
})();
</script>