From 6a1b37db3c68c601b94e8bdad8ea2acebca4610a Mon Sep 17 00:00:00 2001 From: Alisson Vieira Date: Thu, 4 Jun 2026 12:32:32 -0300 Subject: [PATCH 1/3] HCF-API-453 (#456) --- src/controllers/dashboard-controller.js | 221 ++++++++++++++++++++ src/models/Tombo.js | 1 + src/routes/dashboard.js | 262 ++++++++++++++++++++++++ 3 files changed, 484 insertions(+) create mode 100644 src/controllers/dashboard-controller.js create mode 100644 src/routes/dashboard.js 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, + ]); +}; From 220b35ff28d105a47f1054d3324a843d200117d8 Mon Sep 17 00:00:00 2001 From: Moran <105233020+feliperm17@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:51:38 -0300 Subject: [PATCH 2/3] Refinamento relatorio tombo por cidade (#455) --- src/controllers/relatorios-controller.js | 10 ++++++---- src/reports/templates/TombosPorCidade.tsx | 11 ++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/controllers/relatorios-controller.js b/src/controllers/relatorios-controller.js index 36db19c7..e9a956eb 100644 --- a/src/controllers/relatorios-controller.js +++ b/src/controllers/relatorios-controller.js @@ -675,13 +675,15 @@ export const obtemDadosDoRelatorioDeLocalDeColeta = async (req, res, next) => { export const obtemDadosDoRelatorioDeTombosPorCidade = async (req, res, next) => { const { paginacao } = req; const { limite, pagina, offset } = paginacao; - const { cidade, showCoord } = req.query; + const { cidade, showCoord, estado } = req.query; let whereCidade = {}; if (cidade) { - whereCidade = { - id: cidade, - }; + whereCidade.id = cidade; + } + if (estado) { + // se usuário informou estado, filtra cidades por estado + whereCidade.estado_id = estado; } try { diff --git a/src/reports/templates/TombosPorCidade.tsx b/src/reports/templates/TombosPorCidade.tsx index 09a99394..57679275 100644 --- a/src/reports/templates/TombosPorCidade.tsx +++ b/src/reports/templates/TombosPorCidade.tsx @@ -123,9 +123,14 @@ function RelacaoTombosPorCidade({ dados, total, textoFiltro, showCoord = false } {criaData(item)} {familia?.nome} -
{genero?.nome} {especy?.nome}
{item.autor} - {showCoord && {cordenadas.latitude}} - {showCoord && {cordenadas.longitude}} + +
+ {`${genero?.nome || ''} ${especy?.nome || ''}`.trim()} +
+
{item.autor || ''}
+ + {showCoord && {cordenadas.latitude}} + {showCoord && {cordenadas.longitude}} {item.hcf} ) From ca180410b87268f1073aff17058816eed3bccb18 Mon Sep 17 00:00:00 2001 From: Moran <105233020+feliperm17@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:52:06 -0300 Subject: [PATCH 3/3] Ajustes view_splinker (#454) --- .../migration/20260408200000_cria_view_splinker.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/database/migration/20260408200000_cria_view_splinker.ts b/src/database/migration/20260408200000_cria_view_splinker.ts index 9cc71646..8484c686 100644 --- a/src/database/migration/20260408200000_cria_view_splinker.ts +++ b/src/database/migration/20260408200000_cria_view_splinker.ts @@ -29,7 +29,7 @@ export async function run(knex: Knex): Promise { END AS "Family", COALESCE(g.nome, '') AS "Genus", COALESCE(e.nome, '') AS "Species", - '' AS "Subspecies", + COALESCE(se.nome, '') AS "Subspecies", COALESCE(a.nome, '') AS "ScientificNameAuthor", COALESCE(t.nomes_populares, '') AS "CommonName", '' AS "FieldNumber", @@ -53,10 +53,7 @@ export async function run(knex: Knex): Promise { COALESCE(lc.descricao, '') AS "Locality", t.latitude AS "VerbatimLatitude", t.longitude AS "VerbatimLongitude", - CASE - WHEN t.altitude IS NOT NULL THEN t.altitude::text || ' m' - ELSE '' - END AS "VerbatimElevation", + COALESCE(t.altitude::text, '') AS "VerbatimElevation", '' AS "VerbatimDepth", COALESCE(t.data_identificacao_dia::text, '') AS "DayIdentified", COALESCE(t.data_identificacao_mes::text, '') AS "MonthIdentified", @@ -83,6 +80,7 @@ export async function run(knex: Knex): Promise { LEFT JOIN reinos r ON f.reino_id = r.id LEFT JOIN generos g ON t.genero_id = g.id LEFT JOIN especies e ON t.especie_id = e.id + LEFT JOIN sub_especies se ON se.id = t.sub_especie_id LEFT JOIN autores a ON e.autor_id = a.id LEFT JOIN coletores col ON t.coletor_id = col.id LEFT JOIN coletores_complementares cc ON cc.hcf = t.hcf