UNPKG

synapsis-itp

Version:

Biblioteca de dashboard interativo com Apache ECharts e TanStack Table, com suporte a filtros dinâmicos

616 lines (567 loc) 27.5 kB
window.addEventListener('DOMContentLoaded', async () => { const config = window.dashboardConfig; const currentFilters = {}; const ultimosFiltrosClicados = {}; const chartInstances = {}; // Objeto para armazenar as instâncias dos gráficos para mais fluidez // =================================================================== // FUNÇÕES DE CÁLCULO E UTILIDADES // =================================================================== function calcularEstatisticas(valores) { if (!Array.isArray(valores) || valores.length === 0) return null; const isNumerico = valores.every(val => !isNaN(parseFloat(val)) && isFinite(val)); const resultados = { frequencias: {}, moda: [], total: valores.length }; let maxFreq = 0; valores.forEach(val => { const key = String(val); resultados.frequencias[key] = (resultados.frequencias[key] || 0) + 1; maxFreq = Math.max(maxFreq, resultados.frequencias[key]); }); resultados.moda = Object.keys(resultados.frequencias).filter(key => resultados.frequencias[key] === maxFreq); resultados.distintos = Object.keys(resultados.frequencias).length; if (isNumerico) { const nums = valores.map(Number).sort((a, b) => a - b); const soma = nums.reduce((acc, val) => acc + val, 0); const media = soma / nums.length; const meio = Math.floor(nums.length / 2); const mediana = nums.length % 2 === 0 ? (nums[meio - 1] + nums[meio]) / 2 : nums[meio]; const variancia = nums.reduce((acc, val) => acc + (val - media) ** 2, 0) / nums.length; const desvioPadrao = Math.sqrt(variancia); Object.assign(resultados, { soma, media, mediana, minimo: nums[0], maximo: nums[nums.length - 1], desvioPadrao }); } return resultados; } function adicionarBotaoExportacao(containerId, tabelaId) { const container = document.getElementById(containerId); if (!container) return; const botaoExistente = container.querySelector('button.export-button'); if(botaoExistente) botaoExistente.remove(); const botao = document.createElement('button'); botao.textContent = 'Exportar para XLSX'; botao.className = 'export-button'; botao.onclick = () => exportarParaXLSX(tabelaId, `${tabelaId}.xlsx`); container.appendChild(botao); } function exportarParaXLSX(tabelaId, nomeArquivo) { const tabela = document.getElementById(tabelaId)?.querySelector('table'); if (!tabela) { console.error('Tabela não encontrada para exportação:', tabelaId); return; } const workbook = XLSX.utils.table_to_book(tabela); XLSX.writeFile(workbook, nomeArquivo || 'dados.xlsx'); } function calcularContagemPorCampo(dados, campo) { if (!dados || dados.length === 0) return []; const contagem = dados.reduce((acc, item) => { const chave = item[campo]; if (chave) { acc[chave] = (acc[chave] || 0) + 1; } return acc; }, {}); return Object.entries(contagem) .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count); } // =================================================================== // FUNÇÕES DE RENDERIZAÇÃO (GRÁFICOS E TABELAS) // =================================================================== function gerarTabela(containerId, columnConfig, data) { const container = document.getElementById(containerId); if (!container) return; const tableWrapper = document.createElement('div'); tableWrapper.className = 'table-responsive-wrapper'; const table = document.createElement('table'); table.className = 'dashboard-table'; const thead = document.createElement('thead'); const tbody = document.createElement('tbody'); const headerRow = document.createElement('tr'); columnConfig.forEach(col => { const th = document.createElement('th'); th.textContent = col.header; headerRow.appendChild(th); }); thead.appendChild(headerRow); data.forEach(row => { const tr = document.createElement('tr'); columnConfig.forEach(col => { const td = document.createElement('td'); let cellValue = row[col.key]; if (col.format && typeof col.format === 'function') { td.textContent = col.format(cellValue); } else { td.textContent = cellValue !== undefined ? cellValue : ''; } tr.appendChild(td); }); tbody.appendChild(tr); }); table.appendChild(thead); table.appendChild(tbody); tableWrapper.appendChild(table); container.innerHTML = ''; container.appendChild(tableWrapper); } function gerarChartEstatisticas(estatisticas) { const container = document.getElementById(config.elements.estatistica); if (!container) return; let chart = chartInstances.estatistica; if (!chart) { chart = echarts.init(container); chartInstances.estatistica = chart; window.addEventListener('resize', () => chart.resize()); } const categorias = ['Média', 'Mediana', 'Moda', 'Mínimo', 'Máximo', 'Desvio Padrão']; const valores = [ estatisticas.media, estatisticas.mediana, parseFloat(estatisticas.moda[0] || 0), estatisticas.minimo, estatisticas.maximo, estatisticas.desvioPadrao ]; chart.setOption({ title: { text: '' }, tooltip: { trigger: 'axis', formatter: params => `<strong>${params[0].name}</strong>: ${params[0].value ? params[0].value.toFixed(2) : '0.00'} hrs` }, xAxis: { type: 'category', data: categorias }, yAxis: { type: 'value', name: 'Valor' }, series: [{ type: 'bar', data: valores, itemStyle: { color: '#4dc9f6' }, label: { show: true, position: 'top', formatter: p => p.value ? p.value.toFixed(2) : '0.00' } }], animationDurationUpdate: 300, animationEasingUpdate: 'cubicInOut' }); } function gerarChartTimeline(dados) { const container = document.getElementById(config.elements.timeline); if (!container) return; let chart = chartInstances.timeline; if (!chart) { chart = echarts.init(container); chartInstances.timeline = chart; adicionarInteratividadeGrafico(chart, container); window.addEventListener('resize', () => chart.resize()); } const xData = dados.map(i => i.codigo_atendimento); const inicioData = dados.map(i => i.tempo_inicio_hrs); const resolucaoData = dados.map(i => i.tempo_resolucao_hrs - i.tempo_inicio_hrs); chart.setOption({ title: { text: config.timeline.title }, tooltip: { trigger: 'axis', formatter: params => dados.length > 0 ? config.timeline.tooltipFormatter(dados[params[0].dataIndex]) : '' }, xAxis: { type: 'category', data: xData, axisLabel: { rotate: config.timeline.axisLabelRotate } }, yAxis: { type: 'value', name: config.timeline.yAxisName }, legend: { data: Object.values(config.timeline.seriesNames), left: config.timeline.legendPosition }, series: [ { name: config.timeline.seriesNames.inicio, type: 'bar', stack: 'total', emphasis: { disabled: true }, itemStyle: { color: config.timeline.colors.inicio }, data: inicioData }, { name: config.timeline.seriesNames.resolucao, type: 'bar', stack: 'total', emphasis: { disabled: true }, itemStyle: { color: config.timeline.colors.resolucao }, data: resolucaoData } ], animationDurationUpdate: 500, animationEasingUpdate: 'cubicInOut' }); } function gerarChartPerformance(data, labels) { const container = document.getElementById(config.elements.performance); if (!container) return; let chart = chartInstances.performance; if (!chart) { chart = echarts.init(container); chartInstances.performance = chart; adicionarInteratividadeGrafico(chart, container); window.addEventListener('resize', () => chart.resize()); } const valorSelecionado = ultimosFiltrosClicados.atendente; const seriesData = data.map((item, idx) => ({ value: item.value, itemStyle: { color: config.performance.colorScale(item.value), opacity: !valorSelecionado || valorSelecionado === labels[idx] ? 1 : 0.3, borderColor: valorSelecionado === labels[idx] ? '#000' : undefined, borderWidth: valorSelecionado === labels[idx] ? 2 : 0 } })); chart.setOption({ title: { text: config.chartTitles.performance }, tooltip: { formatter: p => data.length > 0 ? config.performance.tooltipFormatter(data[p.dataIndex]) : '' }, xAxis: { type: 'category', data: labels, axisLabel: { rotate: config.performance.axisLabelRotate } }, yAxis: { type: 'value', name: config.performance.yAxisName }, series: [{ type: 'bar', emphasis: { disabled: true }, label: config.performance.labelConfig, showBackground: true, data: seriesData }], animationDurationUpdate: 500, animationEasingUpdate: 'cubicInOut' }); } function gerarChartPriority(dados) { const container = document.getElementById(config.elements.priority); if (!container) return; let chart = chartInstances.priority; if (!chart) { chart = echarts.init(container); chartInstances.priority = chart; adicionarInteratividadeGrafico(chart, container); window.addEventListener('resize', () => chart.resize()); } const valorSelecionado = ultimosFiltrosClicados.prioridade; const chartData = config.priority.labels.map(p => ({ value: dados.filter(d => d.prioridade === p).length, name: p, itemStyle: { color: config.priority.colors[p], opacity: !valorSelecionado || p === valorSelecionado ? 1 : 0.3, borderColor: p === valorSelecionado ? '#000' : undefined, borderWidth: p === valorSelecionado ? 2 : 0 } })); const legendaPrevia = chart.getOption()?.legend?.[0]?.selected; chart.setOption({ title: { text: config.chartTitles.priority }, tooltip: { trigger: 'item' }, legend: { show: true, bottom: 0, selected: legendaPrevia || null }, series: [{ type: 'pie', radius: config.priority.radius, selectedMode: 'single', emphasis: { disabled: true }, label: config.priority.labelFormat, data: chartData }], animationDurationUpdate: 300, animationEasingUpdate: 'cubicInOut' }); } function gerarChartSatisfaction(dados) { const container = document.getElementById(config.elements.satisfaction); if (!container) return; let chart = chartInstances.satisfaction; if (!chart) { chart = echarts.init(container); chartInstances.satisfaction = chart; adicionarInteratividadeGrafico(chart, container); window.addEventListener('resize', () => chart.resize()); } const valorSelecionado = ultimosFiltrosClicados.nota; const chartData = config.satisfaction.notas.map(n => ({ value: dados.filter(d => d.nota === n).length, name: n, itemStyle: { color: config.satisfaction.colors[n], opacity: !valorSelecionado || n === valorSelecionado ? 1 : 0.3, borderColor: n === valorSelecionado ? '#000' : undefined, borderWidth: n === valorSelecionado ? 2 : 0 } })); const legendaPrevia = chart.getOption()?.legend?.[0]?.selected; chart.setOption({ title: { text: config.chartTitles.satisfaction }, tooltip: { trigger: 'item' }, legend: { show: true, bottom: 0, selected: legendaPrevia || null }, series: [{ type: 'pie', roseType: 'radius', radius: config.satisfaction.radius, selectedMode: 'single', emphasis: { disabled: true }, label: config.satisfaction.labelFormat, labelLine: config.satisfaction.labelLine, data: chartData }], animationDurationUpdate: 300, animationEasingUpdate: 'cubicInOut' }); } // =================================================================== // FUNÇÕES DE CONTROLE E PROCESSAMENTO DE DADOS // =================================================================== function exibirEstatisticas(dados) { const campoValor = config.campos?.valor; if (!campoValor) return; const valores = dados.map(d => parseFloat(d[campoValor] || 0)).filter(v => !isNaN(v)); const stats = (valores.length > 0) ? calcularEstatisticas(valores) : { media: 0, mediana: 0, moda: ['-'], minimo: 0, maximo: 0, desvioPadrao: 0 }; gerarChartEstatisticas(stats); const el = document.getElementById('estatisticas'); if (el) { if (dados.length > 0) { el.innerHTML = `<strong>Estatísticas:</strong><br>Média: ${stats.media?.toFixed(2)}<br>Mediana: ${stats.mediana?.toFixed(2)}<br>Moda: ${stats.moda.join(', ')}<br>Mínimo: ${stats.minimo}<br>Máximo: ${stats.maximo}<br>Desvio Padrão: ${stats.desvioPadrao?.toFixed(2)}<br>`; } else { el.innerHTML = '<strong>Estatísticas:</strong><br>Selecione um período para ver os dados.'; } } } async function aplicarFiltro(campo, valor, secoes) { ultimosFiltrosClicados[campo] = valor; currentFilters[campo] = valor; await atualizarAposFiltro(secoes); } async function limparFiltros(campoAlvo) { const secoesAfetadas = document.querySelector(`[data-campo="${campoAlvo}"]`)?.dataset.aplicaEm?.split(',').map(s => s.trim()); delete currentFilters[campoAlvo]; delete ultimosFiltrosClicados[campoAlvo]; await atualizarAposFiltro(secoesAfetadas); } async function atualizarAposFiltro(secoes) { const { dadosFiltrados, performance, grupos } = await processarDadosLeve(window.__dadosDashboard); if (secoes && Array.isArray(secoes)) { secoes.forEach(secao => atualizarSecao(secao, dadosFiltrados, performance, grupos)); } else { await atualizarDashboard(dadosFiltrados, performance, grupos); } } function atualizarSecao(secao, dadosFiltrados, performance, grupos) { switch (secao) { case 'estatisticas': exibirEstatisticas(dadosFiltrados); break; case 'timeline': gerarChartTimeline(dadosFiltrados); break; case 'performance': gerarChartPerformance(performance, grupos); break; case 'priority': gerarChartPriority(dadosFiltrados); break; case 'satisfaction': gerarChartSatisfaction(dadosFiltrados); break; default: const fontesDeDados = { dadosFiltrados, performance, estatisticas: calcularEstatisticas(dadosFiltrados.map(d => parseFloat(d[config.campos?.valor] || 0))) }; renderizarTabelas(fontesDeDados, secao); } } function processarDadosLeve(dados) { return new Promise(resolve => { const { grupo, valor, dataPrincipal } = config.campos || {}; if (!grupo || !valor || !dataPrincipal) { console.error("Configuração de campos (grupo, valor, dataPrincipal) está incompleta."); resolve({ dadosFiltrados: [], performance: [], grupos: [] }); return; } requestIdleCallback(() => { let dadosFiltrados = [...dados]; if (currentFilters.periodo && currentFilters.periodo.inicio && currentFilters.periodo.fim) { const dataInicioFiltro = new Date(currentFilters.periodo.inicio); const dataFimFiltro = new Date(currentFilters.periodo.fim); dataFimFiltro.setHours(23, 59, 59, 999); dadosFiltrados = dadosFiltrados.filter(d => { if (!d[dataPrincipal]) return false; const dataItem = new Date(d[dataPrincipal]); return dataItem >= dataInicioFiltro && dataItem <= dataFimFiltro; }); } else { resolve({ dadosFiltrados: [], performance: [], grupos: [] }); return; } for (const [campo, val] of Object.entries(currentFilters)) { if (val !== 'all' && val !== null && campo !== 'periodo') { dadosFiltrados = dadosFiltrados.filter(d => d[campo] === val); } } const gruposSet = new Set(); const somaPorGrupo = {}; const contagemPorGrupo = {}; for (const item of dadosFiltrados) { const chaveGrupo = item[grupo]; const valorNum = parseFloat(item[valor] || 0); if (!isNaN(valorNum)) { gruposSet.add(chaveGrupo); somaPorGrupo[chaveGrupo] = (somaPorGrupo[chaveGrupo] || 0) + valorNum; contagemPorGrupo[chaveGrupo] = (contagemPorGrupo[chaveGrupo] || 0) + 1; } } const grupos = Array.from(gruposSet).sort(); const performance = grupos.map(g => ({ name: g, value: somaPorGrupo[g] ? (somaPorGrupo[g] / contagemPorGrupo[g]) : 0, count: contagemPorGrupo[g] })); resolve({ dadosFiltrados, performance, grupos }); }); }); } function gerarFiltrosAutomaticos(dados) { const filtroContainer = document.getElementById('filtrosDinamicos'); if (!filtroContainer) return; const selects = filtroContainer.querySelectorAll('select[data-campo]'); selects.forEach(select => { const campo = select.dataset.campo; const valorAtual = select.value; select.innerHTML = ''; const optionTodos = document.createElement('option'); optionTodos.value = 'all'; optionTodos.textContent = `Todos (${campo})`; select.appendChild(optionTodos); const valoresUnicos = [...new Set(dados.map(d => d[campo]).filter(Boolean))].sort(); valoresUnicos.forEach(val => { const option = document.createElement('option'); option.value = val; option.textContent = val; select.appendChild(option); }); if (valoresUnicos.includes(valorAtual)){ select.value = valorAtual; } else { select.value = 'all'; } }); } function renderizarTabelas(fontesDeDados, chaveEspecifica = null) { const configsDeTabela = config.tabelas || {}; const chavesParaRenderizar = chaveEspecifica ? [chaveEspecifica] : Object.keys(configsDeTabela); chavesParaRenderizar.forEach(key => { const conf = configsDeTabela[key]; if (!conf) return; let dadosParaTabela = []; switch (conf.tipo) { case 'dados_brutos': dadosParaTabela = fontesDeDados.dadosFiltrados; gerarTabela(conf.containerId, conf.colunas, dadosParaTabela); if (dadosParaTabela.length > 0) { adicionarBotaoExportacao(conf.containerId, conf.containerId); } break; case 'performance': dadosParaTabela = fontesDeDados.performance; gerarTabela(conf.containerId, conf.colunas, dadosParaTabela); break; case 'estatisticas': const stats = fontesDeDados.estatisticas || { media: 0, mediana: 0, moda: ['-'], minimo: 0, maximo: 0, desvioPadrao: 0 }; const dataStats = [ { Indicador: 'Média', Valor: stats.media?.toFixed(2) || '0.00' }, { Indicador: 'Mediana', Valor: stats.mediana?.toFixed(2) || '0.00' }, { Indicador: 'Moda', Valor: stats.moda.join(', ') }, { Indicador: 'Mínimo', Valor: stats.minimo || 0 }, { Indicador: 'Máximo', Valor: stats.maximo || 0 }, { Indicador: 'Desvio Padrão', Valor: stats.desvioPadrao?.toFixed(2) || '0.00' } ]; gerarTabela(conf.containerId, conf.colunas, dataStats); break; case 'contagem': dadosParaTabela = calcularContagemPorCampo(fontesDeDados.dadosFiltrados, conf.campo); gerarTabela(conf.containerId, conf.colunas, dadosParaTabela); break; } }); } async function atualizarDashboard(dadosFiltrados, performance, grupos) { if(!dadosFiltrados) { const processados = await processarDadosLeve(window.__dadosDashboard); dadosFiltrados = processados.dadosFiltrados; performance = processados.performance; grupos = processados.grupos; } exibirEstatisticas(dadosFiltrados); gerarChartTimeline(dadosFiltrados); gerarChartPerformance(performance, grupos); gerarChartPriority(dadosFiltrados); gerarChartSatisfaction(dadosFiltrados); const estatisticas = calcularEstatisticas(dadosFiltrados.map(d => parseFloat(d[config.campos?.valor] || 0)).filter(v => !isNaN(v))); renderizarTabelas({ dadosFiltrados, performance, estatisticas }); let dadosParaFiltros; if (currentFilters.periodo) { const { dataPrincipal } = config.campos; const dataInicioFiltro = new Date(currentFilters.periodo.inicio); const dataFimFiltro = new Date(currentFilters.periodo.fim); dataFimFiltro.setHours(23, 59, 59, 999); dadosParaFiltros = window.__dadosDashboard.filter(d => { if (!d[dataPrincipal]) return false; const dataItem = new Date(d[dataPrincipal]); return dataItem >= dataInicioFiltro && dataItem <= dataFimFiltro; }); } else { dadosParaFiltros = []; } gerarFiltrosAutomaticos(dadosParaFiltros); } function adicionarInteratividadeGrafico(chart, container) { const campo = container.dataset.campo; const secoesAfetadas = container.dataset.aplicaEm?.split(',').map(s => s.trim()); if (!campo) return; const getValueFromParams = (p) => { if (!p) return null; if (p.componentType === 'series') { if (p.seriesType === 'bar') return p.name || chart.getOption().xAxis[0].data[p.dataIndex]; if (p.seriesType === 'pie') return p.name; } return null; }; chart.on('click', p => { if (!currentFilters.periodo) { alert("Por favor, defina um período antes de interagir com os gráficos."); return; } const valor = getValueFromParams(p); if (valor === undefined || valor === null) return; const isPie = chart.getOption().series[0].type === 'pie'; if (ultimosFiltrosClicados[campo] === valor) { if (isPie) chart.dispatchAction({ type: 'legendAllSelect' }); limparFiltros(campo); } else { if (isPie) { chart.dispatchAction({ type: 'legendSelect', name: valor }); chart.dispatchAction({ type: 'highlight', name: valor }); } aplicarFiltro(campo, valor, secoesAfetadas); } }); chart.getZr().on('dblclick', () => { if (ultimosFiltrosClicados[campo]) { if (chart.getOption().series[0].type === 'pie') { chart.dispatchAction({ type: 'legendAllSelect' }); } limparFiltros(campo); } }); } // =================================================================== // FUNÇÕES DE INICIALIZAÇÃO // =================================================================== function inicializarDashboardVazio() { Object.keys(currentFilters).forEach(key => delete currentFilters[key]); Object.keys(ultimosFiltrosClicados).forEach(key => delete ultimosFiltrosClicados[key]); exibirEstatisticas([]); gerarChartTimeline([]); gerarChartPerformance([], []); gerarChartPriority([]); gerarChartSatisfaction([]); renderizarTabelas({dadosFiltrados: [], performance: [], estatisticas: {}}); gerarFiltrosAutomaticos([]); const periodoEl = document.getElementById(config.elements.periodo); if (periodoEl) { periodoEl.textContent = 'Período: Por favor, selecione um intervalo de datas para começar.'; } } function configurarLimitesDoFiltroDePeriodo(dados) { const dataInicioEl = document.getElementById('filtro-data-inicio'); const dataFimEl = document.getElementById('filtro-data-fim'); const dataPrincipal = config.campos?.dataPrincipal; if (!dataInicioEl || !dataFimEl || !dataPrincipal) { console.error("Inputs de data ou 'dataPrincipal' na configuração não encontrados."); return; } const datas = dados.map(d => d[dataPrincipal]).filter(Boolean).sort((a, b) => new Date(a) - new Date(b)); if (datas.length > 0) { const primeiraData = datas[0].split(' ')[0]; const ultimaData = datas[datas.length - 1].split(' ')[0]; dataInicioEl.min = primeiraData; dataInicioEl.max = ultimaData; dataFimEl.min = primeiraData; dataFimEl.max = ultimaData; } } async function inicializarFiltroDePeriodo() { const dataInicioEl = document.getElementById('filtro-data-inicio'); const dataFimEl = document.getElementById('filtro-data-fim'); const btnAplicar = document.getElementById('btn-aplicar-periodo'); const btnLimpar = document.getElementById('btn-limpar-periodo'); if (!dataInicioEl || !dataFimEl || !btnAplicar || !btnLimpar) return; btnAplicar.addEventListener('click', async () => { const inicio = dataInicioEl.value; const fim = dataFimEl.value; if (!inicio || !fim) { alert('Por favor, selecione uma data de início e uma data de fim.'); return; } if (new Date(inicio) > new Date(fim)) { alert('A data de início não pode ser posterior à data de fim.'); return; } currentFilters.periodo = { inicio, fim }; const periodoEl = document.getElementById(config.elements.periodo); if(periodoEl) periodoEl.textContent = `Período: ${inicio} a ${fim}`; await atualizarDashboard(); }); btnLimpar.addEventListener('click', () => { delete currentFilters.periodo; dataInicioEl.value = ''; dataFimEl.value = ''; inicializarDashboardVazio(); }); } function inicializarFiltrosHTML() { const filtroContainer = document.getElementById('filtrosDinamicos'); if (!filtroContainer) return; filtroContainer.addEventListener('change', (e) => { if (e.target.matches('select[data-campo]')) { const select = e.target; const campo = select.dataset.campo; const valor = select.value; const secoes = select.dataset.aplicaEm?.split(',').map(s => s.trim()); if (!currentFilters.periodo) { alert("Por favor, defina um período antes de aplicar outros filtros."); select.value = 'all'; return; } aplicarFiltro(campo, valor, secoes); } }); } // =================================================================== // INICIALIZAÇÃO PRINCIPAL DO DASHBOARD // =================================================================== try { const response = await fetch(config.jsonPath); const dados = await response.json(); window.__dadosDashboard = dados; configurarLimitesDoFiltroDePeriodo(dados); inicializarDashboardVazio(); inicializarFiltrosHTML(); inicializarFiltroDePeriodo(); } catch (error) { console.error('Erro ao carregar ou inicializar o dashboard:', error); document.body.innerHTML = '<h1>Erro ao carregar o dashboard. Verifique o console para mais detalhes.</h1>'; } });