SIMPE × SAGE/MGDI

Documentação da integração — piloto Saúde Bucal

Visão Geral

Contexto, atores, arquitetura e estado atual da integração SIMPE × SAGE/MGDI.

O problema

O SIMPE precisa exibir indicadores oficiais (números de equipes de saúde, valores repassados, cobertura, etc.) dentro do plano estratégico. Esses dados já existem na SAGE. Em vez de duplicar a coleta, integramos via API do MGDI.

Escopo do piloto

16

Cards do mockup

7

Indicadores cobertos

~44%

Cobertura efetiva

35/35

Endpoints OK em 27/04

Saúde Bucal, 16 cards de indicadores nos mockups. Cobertura técnica atual: 7 indicadores com endpoint funcional. O restante depende de mapeamento ou novas fontes a confirmar com o DEMAS. Próximo passo: validar o piloto e expandir pras demais secretarias (SAES, SVSA, SGTES, etc.).

Arquitetura

SIMPE Front Next.js SIMPE Backend Next.js BFF + cache APISIX gateway apisix.demas... MGDI API Fastify ⚠ Rede interna do MS — APISIX e MGDI só resolvem dentro da VPN Backend-for-Frontend é obrigatório em produção

Stack do SIMPE

  • Frontend e BFF: Next.js + TypeScript
  • Backend principal: Node.js
  • Banco: Supabase (Postgres)
  • Cache: unstable_cache do Next 15 ou tabela dedicada no Supabase
Status atual — 27/04/2026

Testes contra rede interna do MS validaram 35/35 endpoints OK. Identificadas 3 ressalvas que precisam ser tratadas no front: ano corrente parcial nos financeiros, agrupamento necessário no resultset MN, e inconsistência entre titulo e tituloCompleto nos metadados. Detalhes em ⚠️ Ressalvas.

Cobertura

Cruzamento entre os 16 cards dos mockups e os endpoints disponíveis na API MGDI.

4 elementos de cada card

RequisitoEndpointValidado
(1) Título descritivoGET /indicador/{codigo}tituloCompleto
(2) Evolução BR (5 anos)GET /indicador/{codigo}/resultset?tipo=BR&anodata=-5
(3) Mapa por municípioGET /indicador/{codigo}/resultset?tipo=MN&anodata=-1
(4) Ficha (i)GET /ficha-qualificacao/{codigo}
Auxiliar (sync)GET /indicador/{codigo}/carga

Fonte: _source/01-cobertura-api-simpe-saude-bucal.md §1

Cards × código MGDI × status

MockupCardCódigo MGDIStatusObservação
4.1CEO cofinanciadosCEOIMP✅ cobertoSérie anual limpa, mapa ~1.1 MB
4.1Valor repassado CEOCEOVLR⚠️ ano parcialInclui 202602 misturado com anos fechados
4.2Novas UOM entregues❌ sem códigoPode ser derivado de SBUOMP — DEMAS
4.2UOM cofinanciadasSBUOMP✅ cobertoSérie anual limpa
4.2Valor repassado UOMSBRCUOM⚠️ ano parcialInclui 202602 parcial
4.3Equipes Saúde Bucal 40hSBESB40H✅ cobertoSérie anual limpa
4.3Carga Horária DiferenciadaSBESB - SBESB40H⚠️ derivado20h + 30h. Validar definição com Mariana
4.4eSB cofinanciadas?⚠️ ambíguoPode ser SBESB com outra leitura
4.4Valor repassado equipesSBVRFAF⚠️ ano parcialInclui 202602 parcial
4.5Sesb cofinanciadas❌ sem códigoMariana buscando nos painéis
4.5Equipamentos odontológicos❌ sem código
4.5Impressoras 3D e scanners❌ sem código
4.5Profissionais qualificados❌ sem código
4.5Municípios qualificados❌ sem código
4.5Mun. c/ LRPD cofinanciados❌ sem código
4.6Curso de gerentes CEO/SESB❌ sem códigoFora do piloto até definição
4.6Oferta técnica em saúde bucal❌ sem código
4.6Oferta pós-técnica saúde bucal❌ sem código

Fonte: _source/01-cobertura-api-simpe-saude-bucal.md §2

Resumo quantitativo

16

Totais

6

Cobertos diretos

1

Derivado

1

Ambíguo

8

Sem código

~44%

Cobertura efetiva

A API entrega 100% dos elementos visuais exigidos (título, evolução, mapa, ficha) para os indicadores que ela cobre. O gap é de cobertura de indicadores, não de funcionalidade da API.

Endpoints

Catálogo dos 5 tipos de endpoint usados no piloto. Base: http://apisix.demas.saude.gov/api-dev (rede interna do MS).

Tipos de chamada

Os 7 indicadores cobertos (CEOIMP, CEOVLR, SBESB, SBESB40H, SBRCUOM, SBUOMP, SBVRFAF) usam os mesmos 5 padrões de URL. Tamanhos e tempos de resposta vêm dos testes reais executados em 27/04/2026 (_source/reports/saps-bucal-report.csv).

1. GET /indicador/{codigo}

Metadados completos do indicador: títulos, descrição, fórmula, fonte, periodicidade, unidade de medida, polaridade, responsáveis e indicadores relacionados.

Parâmetros: nenhum Tamanho típico: ~25 KB (6–31 KB) Tempo médio: ~0.3 s TTL recomendado: 24 h

// Trecho representativo de _source/samples/metadado-ceoimp.json

{
  "id": 1185,
  "codigo": "CEOIMP",
  "titulo": "Número de Centros de Especialidades Odontológicas implantado e custeado",
  "tituloCompleto": "Número de Centros de Especialidades Odontológicas pago no período",
  "descricao": "Quantidade de CEO - Centro de Especialidades Odontológicas, tipos I, II e III...",
  "fonte_dados": "<p>SISAB</p>",
  "ativo": true,
  "acumulativo": false,
  "cache_ttl": 43200,
  "co_unidade_medida": 2,
  "Tags": [
    { "codigo": 120, "descricao": "Política Nacional de Saúde Bucal (PNSB)",
      "TagCategoria": { "codigo": 1, "descricao": "Ações e Programas" } }
  ],
  "UnidadeMedida":   { "codigo": 2, "descricao": "Número", "fl_fator": 1 },
  "Polaridade":      { "codigo": 1, "descricao": "Maior - melhor" },
  "Granularidade":   { "codigo": 4, "sigla": "MN", "descricao": "Município" },
  "ParametroFonte":  null,
  "ResponsavelTecnico":   [{ "codigo": 17494, "sigla": "CGSB", "nome": "COORDENAÇÃO-GERAL DE SAÚDE BUCAL" }],
  "ResponsavelGerencial": [{ "codigo": 19174, "sigla": "SAPS", "nome": "SECRETARIA DE ATENÇÃO PRIMÁRIA À SAÚDE" }],
  "VisualizacaoIndicador": [{ "codigo": 5, "nome": "Gráfico de linha" }]
}
⚠️ Ressalva — campos titulo × tituloCompleto

Não seguem padrão consistente entre indicadores. Recomenda-se dicionário externo. Detalhes em Ressalva #3.

2. GET /indicador/{codigo}/carga

Lista de competências disponíveis (sincronismo). Retorna array de { co_anomes } no formato YYYYMM.

Parâmetros: nenhum Tamanho típico: ~850 B Tempo médio: ~0.2 s TTL recomendado: 6 h

// Trecho de _source/samples/carga-financeiro.json (CEOVLR)

[
  { "co_anomes": 201612 },
  { "co_anomes": 201712 },
  { "co_anomes": 201812 },
  { "co_anomes": 202112 },
  { "co_anomes": 202212 },
  { "co_anomes": 202301 },
  { "co_anomes": 202302 },
  ... // mensal a partir de 202301
  { "co_anomes": 202408 },
  { "co_anomes": 202409 },
  // ⚠️ falta 202410 — pendência aberta com DEMAS
  { "co_anomes": 202411 },
  { "co_anomes": 202412 },
  ...
  { "co_anomes": 202601 },
  { "co_anomes": 202602 }
]
⚠️ Anomalia confirmada

O CEOVLR pula a competência 202410 (vai de 202409 direto para 202411). Pode ser indisponibilidade real ou falha de carga. Pendência aberta — ver ✅ Pendências.

3. GET /indicador/{codigo}/resultset?tipo=BR&anodata=-5

Evolução agregada Brasil. Retorna array com anomes + campo de valor cujo nome é o próprio código do indicador (ex: CEOIMP).

Parâmetros: tipo=BR, anodata=-5 Tamanho típico: ~1.2 KB (não-financeiros) · ~150–200 B (financeiros) Tempo médio: ~0.7 s TTL recomendado: 6 h

// Trecho de _source/samples/resultset-br-ceoimp.json — não-financeiro, mensal

[
  { "anomes": 202601, "CEOIMP": 1154 },
  { "anomes": 202602, "CEOIMP": 1175 },
  { "anomes": 202501, "CEOIMP": 1209 },
  { "anomes": 202502, "CEOIMP": 1209 },
  { "anomes": 202503, "CEOIMP": 1210 },
  { "anomes": 202504, "CEOIMP": 1210 },
  { "anomes": 202505, "CEOIMP": 1211 },
  // ... 39 pontos mensais cobrindo 5 anos calendário
  { "anomes": 202412, "CEOIMP": 1209 },
  { "anomes": 202312, "CEOIMP": 1192 },
  { "anomes": 202212, "CEOIMP": 1158 }
]
⚠️ Ressalva — ano corrente parcial nos financeiros

Nos indicadores CEOVLR, SBRCUOM e SBVRFAF a série mistura anos fechados (dezembro) com o ano corrente parcial. Detalhes em Ressalva #1.

// _source/samples/resultset-br-ceovlr.json — financeiro com ano parcial visível

[
  { "anomes": 202602, "CEOVLR":  89421018.12 },  // PARCIAL: jan+fev/2026
  { "anomes": 202512, "CEOVLR": 506377601.91 },  // 2025 fechado
  { "anomes": 202412, "CEOVLR": 502460676.17 },  // 2024 fechado
  { "anomes": 202312, "CEOVLR": 282328339.49 },  // 2023 fechado
  { "anomes": 202212, "CEOVLR": 226805471.18 }   // 2022 fechado
]

4. GET /indicador/{codigo}/resultset?tipo=MN&anodata=-1

Distribuição por município (mapa). codigogeo é o código IBGE de 6 dígitos sem dígito verificador — casa com qualquer GeoJSON do Brasil. Campos uf, regiao e local já vêm preenchidos.

Parâmetros: tipo=MN, anodata=-1 Tamanho típico: ~1.1 MB (não-financeiros) · ~560 KB (financeiros) Tempo médio: 3–5 s (não-financeiros) · 1.5–2 s (financeiros) TTL recomendado: 24 h

// Trecho de _source/samples/resultset-mn-trecho.json (CEOIMP)

[
  { "anomes": 202601, "uf": "AC", "regiao": "Norte", "local": "Acrelândia",  "codigogeo": 120001, "CEOIMP": 0 },
  { "anomes": 202602, "uf": "AC", "regiao": "Norte", "local": "Acrelândia",  "codigogeo": 120001, "CEOIMP": 0 },
  { "anomes": 202601, "uf": "AC", "regiao": "Norte", "local": "Assis Brasil","codigogeo": 120005, "CEOIMP": 0 },
  { "anomes": 202602, "uf": "AC", "regiao": "Norte", "local": "Assis Brasil","codigogeo": 120005, "CEOIMP": 0 },
  // ... ~5500 municípios, cada um com múltiplas competências do ano corrente
]
⚠️ Ressalva — múltiplas competências por município

Acrelândia (codigogeo 120001) aparece duas vezes: anomes 202601 e 202602. É obrigatório agrupar por codigogeo mantendo o item com anomes máximo antes de renderizar — senão o mapa renderiza com valor errado de janeiro. Detalhes em Ressalva #2.

5. GET /ficha-qualificacao/{codigo}

Ficha de qualificação do indicador (formato HTML/JSON). Usada no ícone (i) ao lado do título do card.

Parâmetros: nenhum Tamanho típico: ~3.3 KB (2.3–4.3 KB) Tempo médio: ~0.3 s TTL recomendado: 7 dias

Conteúdo: texto descritivo (objetivo, conceituação, interpretação, fonte, limitações, notas) — derivado dos mesmos campos do metadado mas formatado como documento. Ver _source/samples/ficha-qualificacao-ceoimp.html.

Bônus — listagem paginada

GET /indicador?limit=N&ativos=true

Listagem paginada de indicadores. Útil para descoberta. Envelope: { count, rows }.

Total no catálogo: 1002 indicadores Resposta: array reduzido por indicador (sem o blob HTML dos campos descritivos)

// Trecho de _source/samples/listagem-paginada.json (limit=3)

{
  "count": 1002,
  "rows": [
    {
      "id": 2290,
      "codigo": "AIDSNCN",
      "titulo": "Número de casos de aids identificados no período",
      "tituloCompleto": "Número de casos de aids identificados no período",
      "ativo": true,
      "UnidadeMedida":   { "codigo": 2, "descricao": "Número" },
      "Granularidade":   { "codigo": 4, "descricao": "Município" },
      "ResponsavelTecnico": [{ "codigo": 7060, "sigla": "SVSA", "nome": "SECRETARIA DE VIGILÂNCIA EM SAÚDE E AMBIENTE" }]
    }
  ]
}

Tabela de testes (27/04/2026)

IndicadorMetadadoCargaResultset BRResultset MNFicha
CEOIMP 31 KB / 0.40 s904 B / 0.18 s1.2 KB / 0.97 s1.1 MB / 5.07 s2.9 KB / 0.24 s
CEOVLR 31 KB / 0.30 s904 B / 0.26 s200 B / 0.77 s562 KB / 1.91 s3.9 KB / 0.18 s
SBESB 31 KB / 0.30 s862 B / 0.26 s1.2 KB / 0.19 s1.1 MB / 3.96 s3.9 KB / 0.21 s
SBESB40H21 KB / 0.30 s946 B / 0.18 s1.4 KB / 0.81 s1.1 MB / 3.33 s3.9 KB / 0.49 s
SBRCUOM 18 KB / 0.30 s799 B / 0.19 s147 B / 0.73 s563 KB / 1.56 s4.3 KB / 0.19 s
SBUOMP 6 KB / 0.24 s 799 B / 0.18 s1.2 KB / 0.81 s1.1 MB / 3.20 s2.4 KB / 0.19 s
SBVRFAF 30 KB / 0.33 s946 B / 0.18 s197 B / 0.82 s583 KB / 1.57 s3.2 KB / 0.51 s

Fonte: _source/reports/saps-bucal-report.csv

Tipos TypeScript

Definições derivadas dos samples reais em _source/samples/. Campos sempre null nos dois metadados estão tipados como T | null.

IndicadorMetadado

// Fonte: _source/samples/metadado-ceoimp.json + metadado-ceovlr.json
export interface IndicadorMetadado {
  id: number;
  codigo: string;
  titulo: string;
  tituloCompleto: string;
  descricao: string;
  data_atualizacao_dado: string | null;     // ISO 8601 ou null
  ultima_atualizacao: string | null;         // sempre null nos samples
  diretrizNacional: string | null;           // sempre null nos samples
  objetivoRelevancia: string;                // HTML
  metodo_calculo: string;
  formula_calculo: string;
  referencia_consulta: string;
  referencia_consulta_denominador: string | null; // sempre null nos samples
  procedimento_operacional: string | null;   // sempre null nos samples
  fonte_dados: string;                       // HTML
  conceituacao: string;                      // HTML
  interpretacao: string;                     // HTML
  usos: string;                              // HTML
  analise: string;
  limitacoes: string;
  notas: string;
  observacoes: string | null;                // sempre null nos samples
  referencia_bibliografica: string | null;   // sempre null nos samples
  doi: string | null;                        // sempre null nos samples
  ativo: boolean;
  acumulativo: boolean;
  privado: boolean;
  universal: boolean;
  indice_referencia: number | null;          // sempre null nos samples
  ano_referencia: number | null;             // sempre null nos samples
  descritivo_unidade_medida: string;         // pode vir vazio
  prefixo: string | null;
  cache_ttl: number;                         // segundos (CEOIMP: 43200)
  dt_inclusao: string;                       // ISO 8601
  dt_atualizacao: string;                    // ISO 8601
  co_unidade_medida: number;                 // 1 = Moeda, 2 = Número
  co_polaridade: number;
  co_granularidade: number;

  Tags: Array<{
    codigo: number;
    descricao: string;
    TagCategoria: { codigo: number; descricao: string };
  }>;

  IndicadoresRelacionados: IndicadorMetadado[];   // recursivo (parcial)
  Politicas: Politica[];

  ResponsavelTecnico:   ResponsavelUnidade[];
  ResponsavelGerencial: ResponsavelUnidade[];

  Granularidade: { codigo: number; sigla: string; descricao: string };
  CriterioAgregacao: { codigo: number; descricao: string };
  UnidadeMedida: { codigo: number; descricao: 'Número' | 'Moeda' | string; fl_fator: number };
  Polaridade: { codigo: number; descricao: string };
  PeriodicidadeAtualizacao:    { codigo: number; descricao: string };
  PeriodicidadeAvaliacao:      { codigo: number; descricao: string };
  PeriodicidadeMonitoramento:  { codigo: number; descricao: string };
  ClassificacaoIndicador:      { codigo: number; descricao: string };
  Classificacao6sIndicador:    { codigo: number; descricao: string };
  TipoConsulta:                { codigo: number; descricao: string };
  VisualizacaoIndicador: Array<{ codigo: number; nome: string }>;

  ParametroFonte: { codigo: number; sigla: string } | null;  // null em CEOIMP, presente em CEOVLR
  UnidadeEspacialAnalise: unknown | null;
  BancoDados: { codigo: number; descricao: string; url: string; co_tipo_banco_dados: number | null };

  Fonte: unknown[];
  CategoriasAnalise: unknown[];
  InstrumentosPlanejamento: unknown[];
}

interface ResponsavelUnidade {
  codigo: number;
  sigla: string;
  nome: string;
  unidadePai?: number;
  ativa?: 'S' | 'N';
  tipo?: number;
}

interface Politica {
  codigo: number;
  sigla: string;
  nome: string;
  ano: number;
  ativo: boolean;
  legislacao_nome: string;
  legislacao_link: string;
  publico: boolean;
  // ... demais campos administrativos
}

ListagemPaginada

// Fonte: _source/samples/listagem-paginada.json (envelope { count, rows })
export interface ListagemPaginada<T> {
  count: number;
  rows: T[];
}

Carga

// Fonte: _source/samples/carga-financeiro.json + carga-anual.json
// Formato: YYYYMM (ex: 202602 = fevereiro de 2026)
export interface Carga {
  co_anomes: number;
}

ResultsetBR<TCodigo>

// Fonte: _source/samples/resultset-br-ceoimp.json + resultset-br-ceovlr.json
// O nome da chave de valor é o próprio código do indicador em maiúscula.
// Template literal type permite inferir a chave dinâmica.
export type ResultsetBR<TCodigo extends string> = Array<
  { anomes: number } & { [K in TCodigo]: number }
>;

// Uso:
// type EvolucaoCEOIMP = ResultsetBR<'CEOIMP'>;
// const r: EvolucaoCEOIMP = [{ anomes: 202602, CEOIMP: 1175 }];

ResultsetMN<TCodigo>

// Fonte: _source/samples/resultset-mn-trecho.json
export type ResultsetMN<TCodigo extends string> = Array<
  {
    anomes: number;
    uf: string;          // sigla, ex: "AC"
    regiao: string;      // nome, ex: "Norte"
    local: string;       // nome do município
    codigogeo: number;   // código IBGE 6 dígitos sem dígito verificador
  } & { [K in TCodigo]: number }
>;

CodigoIndicadorSAPS

// Os 7 indicadores SAPS Bucal cobertos pelo piloto.
// Fonte: _source/endpoints-saps-bucal.txt
export type CodigoIndicadorSAPS =
  | 'CEOIMP'
  | 'CEOVLR'
  | 'SBESB'
  | 'SBESB40H'
  | 'SBRCUOM'
  | 'SBUOMP'
  | 'SBVRFAF';

// Subset dos que vêm com cargas mensais e ano corrente parcial:
export type CodigoIndicadorFinanceiro = 'CEOVLR' | 'SBRCUOM' | 'SBVRFAF';

Type guard de financeiro

// Mais robusto que `co_unidade_medida === 1` porque o campo
// `descritivo_unidade_medida` é vazio em CEOIMP e SBVRFAF e em SBRCUOM
// vem só "valor" — `UnidadeMedida.descricao` é o único confiável.
// Fonte: comparação metadado-ceoimp.json (Número) × metadado-ceovlr.json (Moeda).
export function isIndicadorFinanceiro(meta: IndicadorMetadado): boolean {
  return meta.UnidadeMedida.descricao === 'Moeda';
}

Guia de Implementação

Receita prática pra renderizar um card de indicador. Cada passo abaixo é código TypeScript executável, exceto a parte de UI (chart/mapa) que fica como pseudocódigo.

1. Buscar metadado

import { unstable_cache } from 'next/cache';

const API_BASE = process.env.MGDI_API_BASE!; // http://apisix.demas.saude.gov/api-dev

export const fetchMetadado = unstable_cache(
  async (codigo: CodigoIndicadorSAPS): Promise<IndicadorMetadado> => {
    const res = await fetch(`${API_BASE}/indicador/${codigo}`, {
      signal: AbortSignal.timeout(30_000),
    });
    if (!res.ok) throw new Error(`MGDI ${codigo}: HTTP ${res.status}`);
    return res.json();
  },
  ['mgdi-metadado'],
  { revalidate: 60 * 60 * 24 } // 24h
);

2. Buscar evolução BR + tratar ano parcial

// Fonte da regra: _source/01-cobertura-api-simpe-saude-bucal.md §4.1
// Indicadores financeiros (CEOVLR, SBRCUOM, SBVRFAF) vêm com cargas mensais.
// O parâmetro anodata=-5 retorna a competência mais recente de cada ano calendário,
// então pros financeiros vem 202602 (parcial) misturado com 202512, 202412, etc.
// Não filtramos — marcamos. A decisão visual fica a cargo do componente.

type PontoBR<C extends string> = { anomes: number } & { [K in C]: number };
type PontoBRMarcado<C extends string> = PontoBR<C> & { parcial: boolean };

export function marcarAnoParcial<C extends string>(
  rs: ResultsetBR<C>,
  anoCorrente: number = new Date().getFullYear(),
): PontoBRMarcado<C>[] {
  return rs.map((p) => {
    const ano = Math.floor(p.anomes / 100);
    const mes = p.anomes % 100;
    return { ...p, parcial: ano === anoCorrente && mes !== 12 };
  });
}

export const fetchEvolucaoBR = unstable_cache(
  async <C extends CodigoIndicadorSAPS>(codigo: C) => {
    const url = `${API_BASE}/indicador/${codigo}/resultset?tipo=BR&anodata=-5`;
    const rs = (await fetch(url).then((r) => r.json())) as ResultsetBR<C>;
    return marcarAnoParcial(rs);
  },
  ['mgdi-resultset-br'],
  { revalidate: 60 * 60 * 6 } // 6h
);

3. Buscar mapa MN + agrupar por município

// Fonte da regra: _source/01-cobertura-api-simpe-saude-bucal.md §4.3
// O endpoint MN com anodata=-1 retorna TODAS as cargas do último ano calendário,
// não 1 ponto por município. É obrigatório agrupar por codigogeo mantendo
// o item com anomes máximo, senão o mapa renderiza com valor errado.

export function ultimaPorMunicipio<C extends string>(
  rs: ResultsetMN<C>,
): ResultsetMN<C> {
  const acc = new Map<number, ResultsetMN<C>[number]>();
  for (const row of rs) {
    const atual = acc.get(row.codigogeo);
    if (!atual || row.anomes > atual.anomes) acc.set(row.codigogeo, row);
  }
  return Array.from(acc.values());
}

export const fetchMapaMN = unstable_cache(
  async <C extends CodigoIndicadorSAPS>(codigo: C) => {
    const url = `${API_BASE}/indicador/${codigo}/resultset?tipo=MN&anodata=-1`;
    const rs = (await fetch(url).then((r) => r.json())) as ResultsetMN<C>;
    return ultimaPorMunicipio(rs); // cache guarda já agrupado
  },
  ['mgdi-resultset-mn'],
  { revalidate: 60 * 60 * 24 } // 24h
);

4. Decidir título — dicionário primeiro, tituloCompleto como fallback

// Fonte da regra: _source/01-cobertura-api-simpe-saude-bucal.md §4.2 + §6
// Os campos `titulo` e `tituloCompleto` da API NÃO são consistentes entre
// indicadores. A Mariana monta o dicionário oficial; aqui ficam placeholders.

interface TituloCard {
  cardTitulo: string;
  cardSubtitulo: string;
  graficoEvolucao: string;
  mapa: string;
}

// TODO(Mariana): preencher os 7 indicadores com títulos do mockup.
const TITULOS_INDICADORES: Partial<Record<CodigoIndicadorSAPS, TituloCard>> = {
  CEOIMP: {
    cardTitulo: 'CEO cofinanciados',
    cardSubtitulo: 'Nº CEO cofinanciados',
    graficoEvolucao: 'Evolução anual do número de CEO, Brasil',
    mapa: 'Distribuição do número de CEO por município',
  },
  // ...
};

export function resolverTitulo(
  codigo: CodigoIndicadorSAPS,
  meta: IndicadorMetadado,
): TituloCard {
  const dict = TITULOS_INDICADORES[codigo];
  if (dict) return dict;
  // Fallback: usa o tituloCompleto pra todos os 4 slots.
  return {
    cardTitulo: meta.tituloCompleto,
    cardSubtitulo: meta.tituloCompleto,
    graficoEvolucao: meta.tituloCompleto,
    mapa: meta.tituloCompleto,
  };
}

5. Formatar valor

const fmtMoeda  = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 });
const fmtNumero = new Intl.NumberFormat('pt-BR', { style: 'decimal' });

export function formatarValor(meta: IndicadorMetadado, valor: number): string {
  return isIndicadorFinanceiro(meta) ? fmtMoeda.format(valor) : fmtNumero.format(valor);
}

6. Skeleton de componente React

// Componente server-side. Lib de gráfico/mapa fica como pseudocódigo.
import { LineChart } from '@/lib/charts';   // pseudocódigo
import { MapaBR }    from '@/lib/mapa';     // pseudocódigo
import { FichaInfo } from './FichaInfo';

interface CardIndicadorProps { codigo: CodigoIndicadorSAPS; }

export async function CardIndicador({ codigo }: CardIndicadorProps) {
  const [meta, evolucao, mapa] = await Promise.all([
    fetchMetadado(codigo),
    fetchEvolucaoBR(codigo),
    fetchMapaMN(codigo),
  ]);

  const titulos = resolverTitulo(codigo, meta);

  return (
    <article className="card-indicador">
      <header>
        <h3>{titulos.cardTitulo}</h3>
        <FichaInfo codigo={codigo} />          {/* (4) ícone (i) */}
      </header>

      <LineChart                                 /* (2) evolução BR */
        title={titulos.graficoEvolucao}
        data={evolucao.map((p) => ({
          x: p.anomes,
          y: (p as any)[codigo] as number,
          parcial: p.parcial,
        }))}
        formatY={(v) => formatarValor(meta, v)}
      />

      <MapaBR                                    /* (3) mapa por município */
        title={titulos.mapa}
        data={mapa.map((m) => ({
          codigogeo: m.codigogeo,
          valor: (m as any)[codigo] as number,
          local: m.local,
        }))}
        formatV={(v) => formatarValor(meta, v)}
      />
    </article>
  );
}

Ressalvas

Três comportamentos da API que precisam ser tratados no front. Ignorá-los gera bugs silenciosos — gráficos renderizam, mas com valor errado.

#1 — Ano corrente parcial nos indicadores financeiros

Afeta: CEOVLR, SBRCUOM, SBVRFAF (mockups 4.1, 4.2, 4.4).

A API tem cargas mensais pra esses 3 indicadores e cargas anuais pros outros 4. O parâmetro anodata=-5 retorna a competência mais recente de cada ano calendário, então pros financeiros vem 202602 (fev/2026, parcial) misturado com 202512, 202412, etc. (anos fechados em dezembro).

Exemplo real do CEOVLR (de _source/samples/resultset-br-ceovlr.json):

anomesValor (R$)Status
202212226.805.471ano fechado
202312282.328.339ano fechado
202412502.460.676ano fechado
202512506.377.601ano fechado
20260289.421.018apenas jan+fev/2026 — PARCIAL

Plotar direto vai mostrar 2026 como queda de 82%, o que é factualmente errado.

Solução técnica: usar marcarAnoParcial() (guia 5, passo 2) — não filtra, só marca cada ponto com flag parcial: true que o componente de gráfico usa pra aplicar tratamento visual (rótulo "fev/2026", padrão listrado, etc.).

Decisão visual pendente com Luísa — ver ✅ Pendências.

#2 — Resultset MN retorna múltiplas competências por município

O parâmetro anodata=-1 no endpoint de mapa não retorna 1 ponto por município: retorna todas as cargas do último ano calendário. Em janeiro/2026, vieram 202601 E 202602 pro mesmo município de Acrelândia. Isso explica o tamanho do response (1.1 MB pros não-financeiros, 562 KB pros financeiros).

Exemplo real (de _source/samples/resultset-mn-trecho.json):

[
  { "anomes": 202601, "uf": "AC", "local": "Acrelândia", "codigogeo": 120001, "CEOIMP": 0 },
  { "anomes": 202602, "uf": "AC", "local": "Acrelândia", "codigogeo": 120001, "CEOIMP": 0 }
]

Sem agrupamento, um data.find(m => m.codigogeo === 120001) ingênuo vai pegar a primeira ocorrência (geralmente janeiro), não a mais recente. Bug silencioso clássico — o gráfico renderiza, mas com valor errado.

Solução técnica: agrupar por codigogeo mantendo o item com anomes máximo, e o cache deve guardar já a versão agrupada (não o response cru) — senão cada render do front faz o agrupamento de novo, desperdiçando CPU.

function ultimaPorMunicipio<C extends string>(rs: ResultsetMN<C>): ResultsetMN<C> {
  const acc = new Map<number, ResultsetMN<C>[number]>();
  for (const row of rs) {
    const atual = acc.get(row.codigogeo);
    if (!atual || row.anomes > atual.anomes) acc.set(row.codigogeo, row);
  }
  return Array.from(acc.values());
}

#3 — titulo × tituloCompleto inconsistentes

Após inspeção real da API (27/04/2026), confirmou-se que os campos titulo e tituloCompleto não seguem padrão consistente entre indicadores. Em alguns o titulo é a versão informativa e o tituloCompleto é uma definição técnica longa. Em outros é o oposto. E o SBESB chega a contradizer o próprio campo curto.

Comparação real dos 7 indicadores SAPS (de _source/01-cobertura-api-simpe-saude-bucal.md):

CódigotitulotituloCompleto
CEOIMPNúmero de Centros de Especialidades Odontológicas implantado e custeadoNúmero de Centros de Especialidades Odontológicas pago no período
CEOVLRValor repassado para custeio de Centros de Especialidades Odontológicas - CEOQuantidade de recursos (em real) do tipo custeio destinado aos municípios, pelo Ministério da Saúde, para o cofinanciamento dos Centros de Especialidades Odontológicas CEO.
SBESBNúmero de Equipes de Saúde Bucal (40h + carga horária diferenciada)Número de Equipes de Saúde Bucal – carga horária de 20, 30 e 40 horas ⚠️
SBESB40HNúmero de Equipes de Saúde Bucal 40hNúmero de Equipes de Saúde Bucal 40h custeadas pelo MS
SBRCUOMValor repassado para custeio UOMValor recurso de custeio destinado ao cofinanciamento das Unidades Odontológicas Móveis (UOM)
SBUOMPNúmero de Unidades Odontológicas Móveis - UOM pagasNúmero de Unidades Odontológicas Móveis - UOM pagas (idêntico)
SBVRFAFValor repassado para custeio das Equipes de Saúde BucalValor repassado referente ao custeio das Equipes de Saúde Bucal no mês de referência

Solução recomendada: não usar nenhum dos dois campos como fonte direta dos títulos do mockup. Mariana monta um dicionário de títulos por código MGDI alinhado com os títulos dos mockups. O front busca primeiro no dicionário e usa tituloCompleto como fallback. Implementação na guia 5, passo 4.

Pendências

Itens acionáveis até o piloto rodar em produção. Bloqueadores travam o lançamento; não-bloqueadores afetam qualidade ou completude visual.

Bloqueadores do piloto

  • 8 indicadores sem código MGDI
    Dono: DEMAS · Próxima ação: mapear oficialmente os indicadores dos mockups 4.5 e 4.6 + Novas UOM (4.2) + eSB cofinanciadas (4.4). Painéis das áreas têm endpoint público?
  • Acesso externo à API em produção
    Dono: DEMAS · Próxima ação: definir estratégia (VPN persistente, proxy reverso ou endpoint público dedicado). Hoje apisix.demas.saude.gov só resolve em rede interna do MS.

Não-bloqueadores

  • Tratamento visual do ano corrente parcial
    Dono: Luísa · Próxima ação: definir entre filtrar o ponto, mostrar com hash/listrado e label "fev/2026" (recomendado, replica mockup SAGE), ou substituir anodata=-5 por chamada explícita por competências.
  • Validar mapeamento "Carga Horária Diferenciada" = SBESB - SBESB40H
    Dono: Mariana · Próxima ação: confirmar se "diferenciada" = "20h + 30h" no contexto operacional da Saúde Bucal (subtração já confirmada na API: SBESB cobre 20h+30h+40h, SBESB40H só 40h).
  • Construir dicionário de títulos por código MGDI
    Dono: Mariana · Próxima ação: preencher 4 slots por indicador (cardTitulo, cardSubtitulo, graficoEvolucao, mapa) alinhados aos mockups. Estrutura no guia 5, passo 4.
  • Confirmar política de rate-limit no APISIX
    Dono: DEMAS · Próxima ação: qual o limite efetivo por consumer? Cadastrar SIMPE com cota dedicada? Hoje não há cabeçalhos X-RateLimit-*.
  • Investigar gap de carga 202410 no CEOVLR
    Dono: DEMAS · Próxima ação: indisponibilidade real ou falha de carga? Afeta totalizador anual de 2024? (também aparece gap em 202509).