diff --git a/src/controllers/dashboard-controller.js b/src/controllers/dashboard-controller.js new file mode 100644 index 00000000..7aca54b3 --- /dev/null +++ b/src/controllers/dashboard-controller.js @@ -0,0 +1,221 @@ +import { Op, Sequelize } from 'sequelize'; + +import models from '../models/index.js'; + +const { Tombo, Especie, Coletor, Cidade, Familia, Genero, Herbario, TomboFoto } = models; + +const calcularPorcentagem = (atual, passado) => { + if (passado > 0) return parseFloat((((atual - passado) / passado) * 100).toFixed(1)); + if (atual > 0) return 100.0; + return 0.0; +}; + +const formatarRanking = (dadosQuery, aliasTabela) => { + return dadosQuery.map(item => { + const info = item[aliasTabela] || item[aliasTabela.charAt(0).toUpperCase() + aliasTabela.slice(1)]; + + return { + nome: info?.nome || info?.sigla || 'N/A', + total: parseInt(item.get('quantidade'), 10) || 0, + }; + }); +}; + +const formatarAno = array => { + const meses = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro']; + return array.map((total, index) => ({ mes: meses[index], total })); +}; + +export const tomboInfo = async (request, response, next) => { + try { + const ID_HCF = 2; + + const condicaoBase = { + rascunho: false, + ativo: true, + }; + + const [ + totaisGerais, + distintos, + rankEspecies, + rankFamilias, + rankGeneros, + rankMunicipios, + rankColetores, + rankHerbarios, + totalImagens, + ] = await Promise.all([ + Tombo.findOne({ + attributes: [ + [Sequelize.literal('COUNT(*)'), 'total'], + [Sequelize.literal(`COUNT(*) FILTER (WHERE entidade_id IS NULL OR entidade_id = ${ID_HCF})`), 'tombos_internos'], + [Sequelize.literal(`COUNT(*) FILTER (WHERE entidade_id IS NOT NULL AND entidade_id != ${ID_HCF})`), 'tombos_externos'], + ], + raw: true, + }), // totaisGerais + Tombo.findOne({ + where: condicaoBase, + attributes: [ + [Sequelize.literal('COUNT(DISTINCT especie_id)'), 'especies'], + [Sequelize.literal('COUNT(DISTINCT familia_id)'), 'familias'], + [Sequelize.literal('COUNT(DISTINCT genero_id)'), 'generos'], + [Sequelize.literal('COUNT(DISTINCT cidade_id)'), 'municipios'], + [Sequelize.literal('COUNT(DISTINCT coletor_id)'), 'coletores'], + [Sequelize.literal('COUNT(DISTINCT entidade_id)'), 'herbarios'], + ], + raw: true, + }), // distintos + Tombo.findAll({ + where: { ...condicaoBase, especie_id: { [Op.not]: null } }, + attributes: ['especie_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], + include: [{ model: Especie, as: 'especie', attributes: ['nome'] }], + group: ['especie_id', 'especie.id'], + order: [[Sequelize.literal('quantidade'), 'DESC']], + limit: 5, + }), // rankEspecies + Tombo.findAll({ + where: { ...condicaoBase, familia_id: { [Op.not]: null } }, + attributes: ['familia_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], + include: [{ model: Familia, as: 'familia', attributes: ['nome'] }], + group: ['familia_id', 'familia.id', 'familia.nome'], + order: [[Sequelize.literal('quantidade'), 'DESC']], + limit: 5, + }), // rankFamilias + Tombo.findAll({ + where: { ...condicaoBase, genero_id: { [Op.not]: null } }, + attributes: ['genero_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], + include: [{ model: Genero, as: 'genero', attributes: ['nome'] }], + group: ['genero_id', 'genero.id', 'genero.nome'], + order: [[Sequelize.literal('quantidade'), 'DESC']], + limit: 5, + }), // rankGeneros + Tombo.findAll({ + where: { ...condicaoBase, cidade_id: { [Op.not]: null } }, + attributes: ['cidade_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], + include: [{ model: Cidade, attributes: ['nome'] }], + group: ['cidade_id', 'cidade.id', 'cidade.nome'], + order: [[Sequelize.literal('quantidade'), 'DESC']], + limit: 5, + }), // rankMunicipios + Tombo.findAll({ + where: { ...condicaoBase, coletor_id: { [Op.not]: null } }, + attributes: ['coletor_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], + include: [{ model: Coletor, as: 'coletor', attributes: ['nome'] }], + group: ['coletor_id', 'coletor.id', 'coletor.nome'], + order: [[Sequelize.literal('quantidade'), 'DESC']], + limit: 5, + }), // rankColetores + Tombo.findAll({ + where: { ...condicaoBase, entidade_id: { [Op.not]: null } }, + attributes: ['entidade_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], + include: [{ model: Herbario, attributes: ['nome', 'sigla'] }], + group: ['entidade_id', 'herbario.id', 'herbario.nome', 'herbario.sigla'], + order: [[Sequelize.literal('quantidade'), 'DESC']], + limit: 5, + }), // rankHerbarios + TomboFoto.count(), // totalImagens + ]); + + return response.status(200).json({ + dados: { + tombos: { + total: parseInt(totaisGerais.total, 10) || 0, + internos: parseInt(totaisGerais.tombos_internos, 10) || 0, + externos: parseInt(totaisGerais.tombos_externos, 10) || 0, + fotos: totalImagens, + }, + taxonomia: { + familias: { total: parseInt(distintos.familias, 10) || 0, ranking: formatarRanking(rankFamilias, 'familia') }, + generos: { total: parseInt(distintos.generos, 10) || 0, ranking: formatarRanking(rankGeneros, 'genero') }, + especies: { total: parseInt(distintos.especies, 10) || 0, ranking: formatarRanking(rankEspecies, 'especie') }, + }, + municipios: { + total: parseInt(distintos.municipios, 10) || 0, + ranking: formatarRanking(rankMunicipios, 'cidade'), + }, + coletores: { + total: parseInt(distintos.coletores, 10) || 0, + ranking: formatarRanking(rankColetores, 'coletor'), + }, + herbarios: { + total: parseInt(distintos.herbarios, 10) || 0, + ranking: formatarRanking(rankHerbarios, 'herbario'), + }, + }, + }); + + } catch (error) { + next(error); + } +}; + +export const tomboSerieTemporal = async (request, response, next) => { + try { + const anoBase = parseInt(request.query.ano, 10) || new Date().getFullYear(); + const anoAnterior = anoBase - 1; + + const inicioRange = new Date(anoAnterior, 0, 1); + const fimRange = new Date(anoBase, 11, 31, 23, 59, 59, 999); + + const queryResult = await Tombo.findAll({ + where: { + rascunho: false, + ativo: true, + data_tombo: { [Op.between]: [inicioRange, fimRange] }, + }, + attributes: [ + [Sequelize.fn('EXTRACT', Sequelize.literal('YEAR FROM data_tombo')), 'ano'], + [Sequelize.fn('EXTRACT', Sequelize.literal('MONTH FROM data_tombo')), 'mes'], + [Sequelize.literal('COUNT(*)'), 'total'], + ], + group: [ + Sequelize.fn('EXTRACT', Sequelize.literal('YEAR FROM data_tombo')), + Sequelize.fn('EXTRACT', Sequelize.literal('MONTH FROM data_tombo')), + ], + raw: true, + }); + + const arrayAtual = Array(12).fill(0); + const arrayPassado = Array(12).fill(0); + let totalAtual = 0; + let totalPassado = 0; + + queryResult.forEach(item => { + const ano = parseInt(item.ano, 10); + const idx = parseInt(item.mes, 10) - 1; + const qtd = parseInt(item.total, 10) || 0; + + if (idx >= 0 && idx < 12) { + if (ano === anoBase) { + arrayAtual[idx] = qtd; + totalAtual += qtd; + } else if (ano === anoAnterior) { + arrayPassado[idx] = qtd; + totalPassado += qtd; + } + } + }); + + return response.status(200).json({ + meta: { + ano_referencia: anoBase, + ano_comparacao: anoAnterior, + }, + serie_temporal: { + dados: { + atual: formatarAno(arrayAtual), + passado: formatarAno(arrayPassado), + }, + totais: { + atual: totalAtual, + passado: totalPassado, + porcentagem: calcularPorcentagem(totalAtual, totalPassado), + }, + }, + }); + + } catch (error) { + next(error); + } +}; diff --git a/src/models/Tombo.js b/src/models/Tombo.js index 29156fb2..da00a7e7 100644 --- a/src/models/Tombo.js +++ b/src/models/Tombo.js @@ -62,6 +62,7 @@ function associate(modelos) { }); Tombo.belongsTo(Coletor, { + as: 'coletor', foreignKey: 'coletor_id', }); diff --git a/src/routes/dashboard.js b/src/routes/dashboard.js new file mode 100644 index 00000000..5e56dba4 --- /dev/null +++ b/src/routes/dashboard.js @@ -0,0 +1,262 @@ +import * as controller from '../controllers/dashboard-controller'; + +/** + * @swagger + * tags: + * name: Dashboard + * description: Rotas para o dashboard do painel + */ +export default app => { + /** + * @swagger + * /analise/tombo: + * get: + * summary: Retorna indicadores gerais dos tombos + * tags: [Dashboard] + * description: Retorna métricas consolidadas, totais e rankings utilizados no dashboard. + * responses: + * 200: + * description: Indicadores retornados com sucesso + * content: + * application/json: + * schema: + * type: object + * properties: + * dados: + * type: object + * properties: + * tombos: + * type: object + * properties: + * total: + * type: integer + * internos: + * type: integer + * externos: + * type: integer + * fotos: + * type: integer + * taxonomia: + * type: object + * properties: + * familias: + * type: object + * properties: + * total: + * type: integer + * ranking: + * type: array + * items: + * type: object + * properties: + * nome: + * type: string + * total: + * type: integer + * generos: + * type: object + * properties: + * total: + * type: integer + * ranking: + * type: array + * items: + * type: object + * properties: + * nome: + * type: string + * total: + * type: integer + * especies: + * type: object + * properties: + * total: + * type: integer + * ranking: + * type: array + * items: + * type: object + * properties: + * nome: + * type: string + * total: + * type: integer + * municipios: + * type: object + * properties: + * total: + * type: integer + * ranking: + * type: array + * items: + * type: object + * properties: + * nome: + * type: string + * total: + * type: integer + * coletores: + * type: object + * properties: + * total: + * type: integer + * ranking: + * type: array + * items: + * type: object + * properties: + * nome: + * type: string + * total: + * type: integer + * herbarios: + * type: object + * properties: + * total: + * type: integer + * ranking: + * type: array + * items: + * type: object + * properties: + * nome: + * type: string + * total: + * type: integer + * example: + * dados: + * tombos: + * total: 15423 + * internos: 13211 + * externos: 2212 + * fotos: 48721 + * taxonomia: + * familias: + * total: 120 + * ranking: + * - nome: Asteraceae + * total: 2500 + * generos: + * total: 540 + * ranking: + * - nome: Solanum + * total: 800 + * especies: + * total: 1800 + * ranking: + * - nome: amalago + * total: 120 + * municipios: + * total: 150 + * ranking: + * - nome: Campo Mourao + * total: 392 + * coletores: + * total: 75 + * ranking: + * - nome: Coletor #1 + * total: 123 + * herbarios: + * total: 18 + * ranking: + * - nome: Herbario da Universidade Tecnologica Federal do Parana Campus Campo Mourao + * total: 567 + * '500': + * $ref: '#/components/responses/InternalServerError' + */ + app.route('/analise/tombo') + .get([ + controller.tomboInfo, + ]); + + /** + * @swagger + * /analise/temporal: + * get: + * summary: Retorna série temporal de tombos + * tags: [Dashboard] + * description: Retorna dados mensais comparando o ano informado com o ano anterior. + * parameters: + * - in: query + * name: ano + * required: false + * schema: + * type: integer + * description: Ano de referência para comparação. Quando não informado utiliza o ano atual. + * responses: + * 200: + * description: Série temporal retornada com sucesso + * content: + * application/json: + * schema: + * type: object + * properties: + * meta: + * type: object + * properties: + * ano_referencia: + * type: integer + * ano_comparacao: + * type: integer + * serie_temporal: + * type: object + * properties: + * dados: + * type: object + * properties: + * atual: + * type: array + * items: + * type: object + * properties: + * mes: + * type: string + * total: + * type: integer + * passado: + * type: array + * items: + * type: object + * properties: + * mes: + * type: string + * total: + * type: integer + * totais: + * type: object + * properties: + * atual: + * type: integer + * passado: + * type: integer + * porcentagem: + * type: number + * format: float + * example: + * meta: + * ano_referencia: 2026 + * ano_comparacao: 2025 + * serie_temporal: + * dados: + * atual: + * - mes: Janeiro + * total: 120 + * - mes: Fevereiro + * total: 95 + * passado: + * - mes: Janeiro + * total: 100 + * - mes: Fevereiro + * total: 90 + * totais: + * atual: 1250 + * passado: 980 + * porcentagem: 27.6 + * '500': + * $ref: '#/components/responses/InternalServerError' + */ + app.route('/analise/temporal') + .get([ + controller.tomboSerieTemporal, + ]); +};