<%@ page language=“java” contentType=“text/html; charset=UTF-8” pageEncoding=“UTF-8” %>
<%@ taglib uri=“Oracle Java Technologies | Oracle” prefix=“c” %>
<%@ taglib prefix=“snk” uri=“/WEB-INF/tld/sankhyaUtil.tld” %>
<%@ taglib prefix=“fmt” uri=“Oracle Java Technologies | Oracle” %>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/ol.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: #333;
}
.container {
max-width: 1400px;
margin: 20px auto;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 30px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.header {
text-align: center;
margin-bottom: 30px;
}
.title {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 10px;
}
.subtitle {
color: #666;
font-size: 1.1rem;
margin-bottom: 20px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin: 30px 0;
}
.stat-card {
background: linear-gradient(135deg, #ffffff, #f8f9fa);
border-radius: 12px;
padding: 25px;
text-align: center;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
border-top: 3px solid #667eea;
transition: transform 0.3s ease;
}
.stat-card:hover {
transform: translateY(-5px);
}
.stat-icon {
font-size: 2.5rem;
color: #667eea;
margin-bottom: 15px;
}
.stat-number {
font-size: 2.5rem;
font-weight: 700;
color: #333;
margin: 10px 0;
}
.stat-label {
color: #666;
font-size: 0.9rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-section {
background: #f8f9fa;
border-radius: 12px;
padding: 20px;
margin: 20px 0;
border-left: 4px solid #667eea;
}
#map {
width: 100%;
height: 600px;
border-radius: 12px;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
border: 3px solid rgba(255, 255, 255, 0.3);
margin: 20px 0;
display: none;
}
.loading, .error {
text-align: center;
padding: 40px;
border-radius: 12px;
margin: 20px 0;
}
.loading {
background: linear-gradient(135deg, #e3f2fd, #bbdefb);
color: #1976d2;
border: 2px solid rgba(25, 118, 210, 0.2);
}
.error {
background: linear-gradient(135deg, #ffebee, #ffcdd2);
color: #d32f2f;
border: 2px solid rgba(211, 47, 47, 0.2);
display: none;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
margin: 5px;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
@media (max-width: 768px) {
.container {
margin: 10px;
padding: 20px;
}
.title {
font-size: 2rem;
}
#map {
height: 400px;
}
}
</style>
<c:set var="DTINI" value="${dadosPeriodo.rows[0].DATA_INICIAL}" />
<c:set var="DTFIN" value="${dadosPeriodo.rows[0].DATA_FINAL}" />
<fmt:parseDate value="${DTINI}" var="dataIni" pattern="yyyy-MM-dd HH:mm:ss.S"/>
<fmt:parseDate value="${DTFIN}" var="dataFin" pattern="yyyy-MM-dd HH:mm:ss.S"/>
<snk:query var="vendasCidades">
SELECT
UPPER(TRIM(CID.NOMECID)) AS CIDADE,
UPPER(TRIM(UFS.UF)) AS ESTADO,
COUNT(CAB.NUNOTA) AS QTD_VENDAS,
COALESCE(SUM(CAB.VLRNOTA), 0) AS VALOR_TOTAL,
COALESCE(ROUND(AVG(CAB.VLRNOTA), 2), 0) AS TICKET_MEDIO
FROM TGFCAB CAB
INNER JOIN TGFPAR PAR ON PAR.CODPARC = CAB.CODPARC
INNER JOIN TSICID CID ON CID.CODCID = PAR.CODCID
INNER JOIN TSIUFS UFS ON UFS.CODUF = CID.UF
INNER JOIN TGFNAT NAT ON NAT.CODNAT = CAB.CODNAT
WHERE (:DTINI IS NULL OR CAB.DTFATUR >= :DTINI)
AND (:DTFIN IS NULL OR CAB.DTFATUR <= :DTFIN)
AND NAT.CODNAT IN (1010104, 1010103, 1010101, 1010102, 1010200)
AND CID.NOMECID IS NOT NULL
AND UFS.UF IS NOT NULL
GROUP BY CID.NOMECID, UFS.UF
HAVING COUNT(CAB.NUNOTA) > 0
ORDER BY SUM(CAB.VLRNOTA) DESC
</snk:query>
<snk:query var="estatisticas">
SELECT
COUNT(DISTINCT CID.CODCID) AS TOTAL_CIDADES,
COUNT(DISTINCT UFS.CODUF) AS TOTAL_ESTADOS,
COUNT(CAB.NUNOTA) AS TOTAL_VENDAS,
COALESCE(SUM(CAB.VLRNOTA), 0) AS VALOR_TOTAL_GERAL
FROM TGFCAB CAB
INNER JOIN TGFPAR PAR ON PAR.CODPARC = CAB.CODPARC
INNER JOIN TSICID CID ON CID.CODCID = PAR.CODCID
INNER JOIN TSIUFS UFS ON UFS.CODUF = CID.UF
INNER JOIN TGFNAT NAT ON NAT.CODNAT = CAB.CODNAT
WHERE (:DTINI IS NULL OR CAB.DTFATUR >= :DTINI)
AND (:DTFIN IS NULL OR CAB.DTFATUR <= :DTFIN)
AND NAT.CODNAT IN (1010104, 1010103, 1010101, 1010102, 1010200)
</snk:query>
<div class="container">
<header class="header">
<h1 class="title">
<i class="fas fa-map-marked-alt"></i>
Dashboard Geográfico de Vendas
</h1>
<p class="subtitle">
<i class="fas fa-chart-line"></i>
Visualização interativa das vendas por localização
</p>
</header>
<div class="info-section">
<h3><i class="fas fa-calendar-alt"></i> Período de Análise</h3>
<p>
<strong>De:</strong> <fmt:formatDate value="${dataIni}" pattern="dd/MM/yyyy"/>
<strong>até:</strong> <fmt:formatDate value="${dataFin}" pattern="dd/MM/yyyy"/>
</p>
<p><strong>Total de cidades com vendas:</strong> ${vendasCidades.rowCount}</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon"><i class="fas fa-city"></i></div>
<div class="stat-number">${estatisticas.rows[0].TOTAL_CIDADES}</div>
<div class="stat-label">Cidades com Vendas</div>
</div>
<div class="stat-card">
<div class="stat-icon"><i class="fas fa-map-marked-alt"></i></div>
<div class="stat-number">${estatisticas.rows[0].TOTAL_ESTADOS}</div>
<div class="stat-label">Estados Atendidos</div>
</div>
<div class="stat-card">
<div class="stat-icon"><i class="fas fa-shopping-cart"></i></div>
<div class="stat-number">
<fmt:formatNumber value="${estatisticas.rows[0].TOTAL_VENDAS}" type="number"/>
</div>
<div class="stat-label">Total de Vendas</div>
</div>
<div class="stat-card">
<div class="stat-icon"><i class="fas fa-dollar-sign"></i></div>
<div class="stat-number">
<fmt:formatNumber value="${estatisticas.rows[0].VALOR_TOTAL_GERAL}"
type="currency" currencySymbol="R$ " minFractionDigits="0" maxFractionDigits="0"/>
</div>
<div class="stat-label">Valor Total</div>
</div>
</div>
<div id="loading-status" class="loading">
<div class="spinner"></div>
<p><i class="fas fa-sync fa-spin"></i> Carregando mapa e processando dados geográficos...</p>
<small>Aguarde enquanto localizamos as cidades no mapa</small>
</div>
<div id="error-status" class="error">
<p><i class="fas fa-exclamation-triangle"></i> <strong>Erro ao carregar o mapa</strong></p>
<p id="error-details">Detalhes do erro aparecerão aqui</p>
<button class="btn" onclick="location.reload()">
<i class="fas fa-redo"></i> Tentar Novamente
</button>
</div>
<div id="map"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/ol.js"></script>
<script>
'use strict';
// Configuração global
const CONFIG = {
center: [-47.93, -15.78], // Centro do Brasil
zoom: 5,
maxZoom: 15,
minZoom: 3
};
// Variáveis globais
let map = null;
let vectorSource = null;
let cidadesBrasil = [];
// Dados das vendas do Sankhya (usando escape HTML)
const dadosVendas = [
<c:forEach items="${vendasCidades.rows}" var="row" varStatus="status">
{
cidade: '<c:out value="${row.CIDADE}"/>',
estado: '<c:out value="${row.ESTADO}"/>',
qtdVendas: ${row.QTD_VENDAS},
valorTotal: ${row.VALOR_TOTAL},
ticketMedio: ${row.TICKET_MEDIO}
}<c:if test="${!status.last}">,</c:if>
</c:forEach>
];
// Funções auxiliares
function log(message) {
console.log('🗺️ Dashboard:', message);
}
function showError(message) {
document.getElementById('error-details').textContent = message;
document.getElementById('error-status').style.display = 'block';
document.getElementById('loading-status').style.display = 'none';
log('ERRO: ' + message);
}
function showSuccess() {
document.getElementById('map').style.display = 'block';
document.getElementById('loading-status').style.display = 'none';
log('Mapa carregado com sucesso!');
}
// Normalizar string para comparação
function normalizeString(str) {
if (!str) return '';
return str.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
.trim()
.replace(/\s+/g, ' ');
}
// Carregar dados das cidades
async function carregarCidadesBrasil() {
log('Carregando dados geográficos...');
try {
const response = await fetch('cidades_br.json', {
method: 'GET',
headers: {
'Accept': 'application/json',
'Cache-Control': 'no-cache'
}
});
if (!response.ok) {
throw new Error('Erro HTTP ' + response.status + ': ' + response.statusText);
}
const data = await response.json();
if (!Array.isArray(data) || data.length === 0) {
throw new Error('Arquivo de cidades inválido ou vazio');
}
cidadesBrasil = data;
log(data.length + ' cidades brasileiras carregadas');
return true;
} catch (error) {
log('Erro ao carregar cidades: ' + error.message);
throw new Error('Não foi possível carregar os dados geográficos: ' + error.message);
}
}
// Encontrar coordenadas de uma cidade
function encontrarCoordenadas(nomeCidade, uf) {
if (!cidadesBrasil || cidadesBrasil.length === 0) {
return null;
}
const cidadeNorm = normalizeString(nomeCidade);
const estadoNorm = normalizeString(uf);
// Busca exata
let cidade = cidadesBrasil.find(function(c) {
return normalizeString(c.nome) === cidadeNorm && normalizeString(c.uf) === estadoNorm;
});
// Se não encontrou, tentar busca parcial
if (!cidade) {
cidade = cidadesBrasil.find(function(c) {
return normalizeString(c.nome).indexOf(cidadeNorm) >= 0 && normalizeString(c.uf) === estadoNorm;
});
}
if (cidade) {
return {
latitude: parseFloat(cidade.lat),
longitude: parseFloat(cidade.lon)
};
}
return null;
}
// Inicializar mapa OpenLayers
function inicializarMapa() {
log('Inicializando mapa OpenLayers...');
try {
if (typeof ol === 'undefined') {
throw new Error('Biblioteca OpenLayers não foi carregada');
}
// Criar fonte de vetores
vectorSource = new ol.source.Vector();
// Criar mapa
map = new ol.Map({
target: 'map',
layers: [
new ol.layer.Tile({
source: new ol.source.OSM({
attributions: [
ol.source.OSM.ATTRIBUTION,
' | Dashboard Sankhya ERP'
]
})
}),
new ol.layer.Vector({
source: vectorSource
})
],
view: new ol.View({
center: ol.proj.fromLonLat(CONFIG.center),
zoom: CONFIG.zoom,
maxZoom: CONFIG.maxZoom,
minZoom: CONFIG.minZoom
})
});
log('Mapa OpenLayers inicializado');
return true;
} catch (error) {
log('Erro ao inicializar mapa: ' + error.message);
throw error;
}
}
// Processar dados e criar marcadores
function processarDados() {
log('Processando dados de vendas...');
try {
if (!dadosVendas || dadosVendas.length === 0) {
throw new Error('Nenhum dado de vendas disponível');
}
vectorSource.clear();
const valorMaximo = Math.max.apply(Math, dadosVendas.map(function(item) {
return parseFloat(item.valorTotal) || 0;
}));
let marcadoresCriados = 0;
let marcadoresIgnorados = 0;
// Processar cada cidade
for (let i = 0; i < dadosVendas.length; i++) {
const venda = dadosVendas[i];
try {
const coords = encontrarCoordenadas(venda.cidade, venda.estado);
if (coords && isFinite(coords.latitude) && isFinite(coords.longitude)) {
const marcador = criarMarcador(venda, coords, valorMaximo);
if (marcador) {
vectorSource.addFeature(marcador);
marcadoresCriados++;
}
} else {
marcadoresIgnorados++;
log('Coordenadas não encontradas: ' + venda.cidade + '-' + venda.estado);
}
} catch (error) {
marcadoresIgnorados++;
log('Erro ao processar ' + venda.cidade + ': ' + error.message);
}
}
if (marcadoresCriados === 0) {
throw new Error('Nenhum marcador pôde ser criado no mapa');
}
// Ajustar visualização
ajustarVisualizacao();
log('Processamento concluído: ' + marcadoresCriados + ' marcadores criados, ' + marcadoresIgnorados + ' ignorados');
} catch (error) {
log('Erro no processamento: ' + error.message);
throw error;
}
}
// Criar marcador individual
function criarMarcador(dadosVenda, coordenadas, valorMaximo) {
try {
const posicao = ol.proj.fromLonLat([coordenadas.longitude, coordenadas.latitude]);
if (!isFinite(posicao[0]) || !isFinite(posicao[1])) {
return null;
}
const feature = new ol.Feature({
geometry: new ol.geom.Point(posicao),
cidade: dadosVenda.cidade,
estado: dadosVenda.estado,
qtdVendas: dadosVenda.qtdVendas,
valorTotal: dadosVenda.valorTotal,
ticketMedio: dadosVenda.ticketMedio
});
// Calcular tamanho e cor baseado no valor
const valorNormalizado = parseFloat(dadosVenda.valorTotal) || 0;
const percentual = valorMaximo > 0 ? valorNormalizado / valorMaximo : 0;
let cor = '#3498db'; // Azul para valores baixos
if (percentual > 0.7) {
cor = '#e74c3c'; // Vermelho para valores altos
} else if (percentual > 0.3) {
cor = '#f39c12'; // Laranja para valores médios
}
const tamanho = 8 + (percentual * 16); // Tamanho de 8 a 24
feature.setStyle(new ol.style.Style({
image: new ol.style.Circle({
radius: tamanho,
fill: new ol.style.Fill({ color: cor }),
stroke: new ol.style.Stroke({ color: '#ffffff', width: 2 })
})
}));
return feature;
} catch (error) {
log('Erro ao criar marcador: ' + error.message);
return null;
}
}
// Ajustar visualização do mapa
function ajustarVisualizacao() {
if (vectorSource.getFeatures().length > 0) {
const extent = vectorSource.getExtent();
map.getView().fit(extent, {
padding: [50, 50, 50, 50],
maxZoom: CONFIG.maxZoom - 2,
duration: 800
});
}
}
// Configurar interações do mapa
function configurarInteracoes() {
// Click nos marcadores
map.on('singleclick', function(event) {
const feature = map.forEachFeatureAtPixel(event.pixel, function(feature) {
return feature;
});
if (feature) {
const dados = {
cidade: feature.get('cidade'),
estado: feature.get('estado'),
qtdVendas: feature.get('qtdVendas'),
valorTotal: feature.get('valorTotal'),
ticketMedio: feature.get('ticketMedio')
};
const valorFormatado = new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL'
}).format(dados.valorTotal);
const ticketFormatado = new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL'
}).format(dados.ticketMedio);
alert(
dados.cidade + ' - ' + dados.estado + '\n\n' +
'Vendas: ' + dados.qtdVendas + '\n' +
'Valor Total: ' + valorFormatado + '\n' +
'Ticket Médio: ' + ticketFormatado
);
}
});
// Cursor pointer nos marcadores
map.on('pointermove', function(event) {
const feature = map.forEachFeatureAtPixel(event.pixel, function(feature) {
return feature;
});
map.getTargetElement().style.cursor = feature ? 'pointer' : '';
});
}
// Função principal de inicialização
async function inicializar() {
log('Iniciando Dashboard Geográfico Sankhya...');
log(dadosVendas.length + ' registros de vendas carregados');
try {
await carregarCidadesBrasil();
inicializarMapa();
processarDados();
configurarInteracoes();
showSuccess();
setTimeout(function() {
if (map) {
map.updateSize();
}
}, 100);
} catch (error) {
showError(error.message);
}
}
// Inicialização quando o DOM estiver carregado
document.addEventListener('DOMContentLoaded', function() {
log('DOM carregado, iniciando sistema...');
inicializar();
});
</script>
fica carregando conforme a imagem
