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
Stack do SIMPE
- Frontend e BFF: Next.js + TypeScript
- Backend principal: Node.js
- Banco: Supabase (Postgres)
- Cache:
unstable_cachedo Next 15 ou tabela dedicada no Supabase
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
| Requisito | Endpoint | Validado |
|---|---|---|
| (1) Título descritivo | GET /indicador/{codigo} → tituloCompleto | ✓ |
| (2) Evolução BR (5 anos) | GET /indicador/{codigo}/resultset?tipo=BR&anodata=-5 | ✓ |
| (3) Mapa por município | GET /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
| Mockup | Card | Código MGDI | Status | Observação |
|---|---|---|---|---|
| 4.1 | CEO cofinanciados | CEOIMP | ✅ coberto | Série anual limpa, mapa ~1.1 MB |
| 4.1 | Valor repassado CEO | CEOVLR | ⚠️ ano parcial | Inclui 202602 misturado com anos fechados |
| 4.2 | Novas UOM entregues | — | ❌ sem código | Pode ser derivado de SBUOMP — DEMAS |
| 4.2 | UOM cofinanciadas | SBUOMP | ✅ coberto | Série anual limpa |
| 4.2 | Valor repassado UOM | SBRCUOM | ⚠️ ano parcial | Inclui 202602 parcial |
| 4.3 | Equipes Saúde Bucal 40h | SBESB40H | ✅ coberto | Série anual limpa |
| 4.3 | Carga Horária Diferenciada | SBESB - SBESB40H | ⚠️ derivado | 20h + 30h. Validar definição com Mariana |
| 4.4 | eSB cofinanciadas | ? | ⚠️ ambíguo | Pode ser SBESB com outra leitura |
| 4.4 | Valor repassado equipes | SBVRFAF | ⚠️ ano parcial | Inclui 202602 parcial |
| 4.5 | Sesb cofinanciadas | — | ❌ sem código | Mariana buscando nos painéis |
| 4.5 | Equipamentos odontológicos | — | ❌ sem código | — |
| 4.5 | Impressoras 3D e scanners | — | ❌ sem código | — |
| 4.5 | Profissionais qualificados | — | ❌ sem código | — |
| 4.5 | Municípios qualificados | — | ❌ sem código | — |
| 4.5 | Mun. c/ LRPD cofinanciados | — | ❌ sem código | — |
| 4.6 | Curso de gerentes CEO/SESB | — | ❌ sem código | Fora do piloto até definição |
| 4.6 | Oferta técnica em saúde bucal | — | ❌ sem código | — |
| 4.6 | Oferta 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.
// 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" }]
}titulo × tituloCompletoNã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.
// 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 }
]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).
// 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 }
]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.
// 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
]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.
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 }.
// 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)
| Indicador | Metadado | Carga | Resultset BR | Resultset MN | Ficha |
|---|---|---|---|---|---|
CEOIMP | 31 KB / 0.40 s | 904 B / 0.18 s | 1.2 KB / 0.97 s | 1.1 MB / 5.07 s | 2.9 KB / 0.24 s |
CEOVLR | 31 KB / 0.30 s | 904 B / 0.26 s | 200 B / 0.77 s | 562 KB / 1.91 s | 3.9 KB / 0.18 s |
SBESB | 31 KB / 0.30 s | 862 B / 0.26 s | 1.2 KB / 0.19 s | 1.1 MB / 3.96 s | 3.9 KB / 0.21 s |
SBESB40H | 21 KB / 0.30 s | 946 B / 0.18 s | 1.4 KB / 0.81 s | 1.1 MB / 3.33 s | 3.9 KB / 0.49 s |
SBRCUOM | 18 KB / 0.30 s | 799 B / 0.19 s | 147 B / 0.73 s | 563 KB / 1.56 s | 4.3 KB / 0.19 s |
SBUOMP | 6 KB / 0.24 s | 799 B / 0.18 s | 1.2 KB / 0.81 s | 1.1 MB / 3.20 s | 2.4 KB / 0.19 s |
SBVRFAF | 30 KB / 0.33 s | 946 B / 0.18 s | 197 B / 0.82 s | 583 KB / 1.57 s | 3.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):
| anomes | Valor (R$) | Status |
|---|---|---|
| 202212 | 226.805.471 | ano fechado |
| 202312 | 282.328.339 | ano fechado |
| 202412 | 502.460.676 | ano fechado |
| 202512 | 506.377.601 | ano fechado |
| 202602 | 89.421.018 | apenas 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ódigo | titulo | tituloCompleto |
|---|---|---|
CEOIMP | Número de Centros de Especialidades Odontológicas implantado e custeado | Número de Centros de Especialidades Odontológicas pago no período |
CEOVLR | Valor repassado para custeio de Centros de Especialidades Odontológicas - CEO | Quantidade 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. |
SBESB | Nú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 ⚠️ |
SBESB40H | Número de Equipes de Saúde Bucal 40h | Número de Equipes de Saúde Bucal 40h custeadas pelo MS |
SBRCUOM | Valor repassado para custeio UOM | Valor recurso de custeio destinado ao cofinanciamento das Unidades Odontológicas Móveis (UOM) |
SBUOMP | Número de Unidades Odontológicas Móveis - UOM pagas | Número de Unidades Odontológicas Móveis - UOM pagas (idêntico) |
SBVRFAF | Valor repassado para custeio das Equipes de Saúde Bucal | Valor 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
-
Acesso externo à API em produção
Não-bloqueadores
-
Tratamento visual do ano corrente parcial
-
Validar mapeamento "Carga Horária Diferenciada" =
SBESB - SBESB40H -
Construir dicionário de títulos por código MGDI
-
Confirmar política de rate-limit no APISIX
-
Investigar gap de carga
202410no CEOVLR