Relatórios (Excel)
Geração de planilhas .xlsx via Lambda excel-processor.
Dois modos — completo e executivo — montados a partir do JSON já calculado pelo
Neurocalc, com banner azul nas seções e atributos ordenados alfabeticamente.
Fluxo em dois saltos
O excel-processor não calcula nada — ele consome o JSON
já produzido pelo Neurocalc (via dashboard-processor) e
materializa em planilha. Por isso o frontend faz dois requests em sequência.
| # | Passo | Endpoint |
|---|---|---|
| 1 | Frontend busca o JSON do Neurocalc para a pesquisa. | NEXT_PUBLIC_DASHBOARD_PROCESSOR_URL |
| 2 | Frontend envia esse JSON + nome do arquivo + tipo de relatório para o gerador. | NEXT_PUBLIC_EXCEL_PROCESSOR_URL |
| 3 | Lambda monta o .xlsx, faz upload no bucket relatorios e devolve signed URL. |
Supabase Storage |
| 4 | Frontend faz fetch da signed URL, cria ObjectURL e força o download no navegador. |
— |
Se NEXT_PUBLIC_EXCEL_PROCESSOR_URL não estiver setada,
report.service.ts faz fallback para a Edge Function legada. Em
produção, a URL do Lambda deve estar sempre presente — o caminho antigo é só
para ambientes locais sem AWS.
Handler HTTP
Lambda Function URL, POST direto. CORS resolvido pela própria
configuração da Function URL (não tem header CORS no código — comentário do
index.ts avisa para não duplicar).
Payload de entrada
- json
- O resultado do Neurocalc no formato
{ result: { question_1, survey_info, overall_rating, ... } }. Não é validado a fundo — o gerador espera as chaves padrão. - nome_arquivo
- Nome base. Passa pelo
sanitizeFileName()antes de virar nome do blob. - reportType
'full'(default) ou'executive'.
Resposta
{
preUploaded: false,
signedUrl: "https://<project>.supabase.co/storage/v1/object/sign/relatorios/<file>.xlsx?token=...",
fileName: "pesquisa_empresa_2026.xlsx"
}
Erros
| Status | Quando |
|---|---|
| 405 | Método ≠ POST. |
| 400 | JSON inválido, faltam campos obrigatórios, reportType fora de {full, executive}. |
| 500 | Falha no generateExcel ou no upload — mensagem descritiva no body. |
Completo vs. Executivo
Os dois modos compartilham a aba Geral. As abas Pergunta 1..5
são reconstruídas em formato diferente conforme o tipo.
| Aspecto | Completo (full) |
Executivo (executive) |
|---|---|---|
| Público | Admin / surveyor | Leitura executiva, geralmente compartilhada externamente |
| Por pergunta | Relações gerais + relações por grupo (todas as categorias, com percentuais e contagens) | Resumo, ranking, relações internas por grupo, outliers |
| Colunas máximas | A–T+ (depende do número de atributos) | A–J |
| Linhas típicas | 1500+ em pesquisas médias | ~500 |
A escolha é feita em ReportTypeDialog antes do disparo —
SurveysCard chama startReportGeneration() com o tipo
selecionado. Modal de loading fica em pé durante toda a geração (pode chegar a
~30s em pesquisas grandes).
Aba Geral
Primeira aba do workbook em ambos os modos. Resume a pesquisa, taxa de resposta, e o breakdown por atributo.
| Linhas | Conteúdo |
|---|---|
| 3 | Título "INFORMAÇÕES GERAIS" (banner principal, fonte 14, branco sobre azul-marinho). |
| 5–6 | Nome da pesquisa + período (data de início e fim). |
| 9 | Header da tabela: Grupo · Pessoas · % do total. |
| 11–12 | Responderam / Não responderam. |
| 15 | Linha "TOTAL GERAL". |
| 17+ | Para cada atributo: subtítulo "ANÁLISE POR GRUPOS [atributo]", lista de grupos com contagem e percentual. |
| fim | Se houver, lista de "NÃO RESPONDENTES" com nome, ID e atributos 1–7. |
Ordenação alfabética dos atributos
Commit 1a2c53d trocou a ordem dos grupos de "por aparição no JSON"
para "alfabética por nome do grupo" usando localeCompare com locale
pt-BR e sensitivity: 'base' — então acentos e
capitalização não atrapalham.
function sortAttributeEntries(attributeData: any): [string, any][] {
return Object.entries(attributeData).sort(([keyA, itemA], [keyB, itemB]) => {
const labelA = String((itemA && itemA.key) || keyA);
const labelB = String((itemB && itemB.key) || keyB);
return labelA.localeCompare(labelB, 'pt-BR', { sensitivity: 'base' });
});
}
Aba "Pergunta N" — modo completo
Uma aba por pergunta. Pulada se a pergunta tem 100% de conexões nulas — a planilha nunca expõe pergunta vazia.
Estrutura
-
Título:
"Pergunta N: {{texto da pergunta}}"em banner azul. -
RELAÇÕES GERAIS — três subseções:
- Sincronias: alta, média, baixa.
- Assincronias: alta/baixa, média/nulo, alta/nulo.
- Conexões Nulas (PCR): total de nulos.
-
RELAÇÕES INTERNAS POR GRUPO — itera atributos 1–6 (em ordem alfabética).
Para cada atributo, monta um sub-bloco com:
- Linha de header com 4 colunas-chave (Total possíveis, Síncronas, Assíncronas, Nulas).
- Uma linha por valor de atributo (grupo) — contagens e percentuais por categoria.
- Linha de totais agregados ao final do bloco.
Esquema de cores por categoria
| Faixa | Colunas | Fill |
|---|---|---|
| Síncronas | E–G | Verde claro |
| Assíncronas | H–K | Laranja claro |
| Nulas (PCR) | L–Q | Vermelho claro |
Aba "Pergunta N" — modo executivo
Mais limpo, com banners azuis nas seções (commit 1a2c53d) e estrutura
achatada para leitura rápida.
| Linha | Conteúdo |
|---|---|
| 3 | Banner: "Pergunta N: ..." (merge B3:J3, fundo #2F5597, texto branco). |
| 5 | Banner: "Relações gerais". |
| 7–10 | Síncronas / Assíncronas / PCR com label e percentual. |
| 13 | Banner: "Relações internas por grupos". |
| 15+ | Por atributo (ordem alfabética): nome do grupo, tamanho, contagens por categoria. |
{
fill: { type: 'pattern', pattern: 'solid', fgColor: { rgb: '2F5597' } },
font: { bold: true, size: 10, color: { rgb: 'FFFFFF' } },
alignment: { horizontal: 'center', vertical: 'center' },
border: { top: thin, bottom: thin, left: thin, right: thin, color: { rgb: '2F5597' } }
}
Catálogo de estilos
Os estilos são funções em excel.ts que retornam objetos consumidos
pela xlsx. Não há tema centralizado — se precisar mudar cor, faz em
todas elas.
| Função | Uso | Cor |
|---|---|---|
getMainTitleStyle() | Título "INFORMAÇÕES GERAIS" | Azul-marinho |
getSubtitleStyle() | Subseção dentro da aba Geral | Cinza claro |
getSectionBannerStyle() | Banner azul (modo executivo, commit recente) | #2F5597 |
getHeaderStyle() | Headers de colunas | Cinza claro |
getDataCellStyle() | Células de dados (linhas ímpares) | Branco |
getAlternateRowStyle() | Células de dados (linhas pares) | #FAFBFC |
getSummaryStyle() | Linhas de total | Azul claro |
getConditionalStyle() | Intensidade da conexão | Verde/laranja/vermelho |
Larguras de coluna
- A: 3px — margem decorativa (recebe bullets como
●,■). - B: 40–45px — labels longos, nomes de grupo.
- C–G, H–I: 18px — dados numéricos.
- H, J, L (no modo full): 55px — textos descritivos.
- Demais: 14–25px.
Sanitização do filename & upload
Sanitização
export function sanitizeFileName(fileName: string) {
return fileName.normalize('NFD') // decomposição Unicode
.replace(/[̀-ͯ]/g, '') // remove diacríticos (acentos)
.replace(/[^a-zA-Z0-9\s\-_]/g, '') // só alfanum + espaço + - + _
.replace(/\s+/g, '_') // espaços → underscore
.toLowerCase();
}
"Pesquisa Empresa 2026" vira "pesquisa_empresa_2026";
o handler adiciona .xlsx ao final.
Upload no Supabase Storage
- Bucket:
relatorios(hardcoded). - MIME:
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet. - Upsert:
true— sobrescreve se já existir mesmo nome. - Signed URL TTL: 3600s (1 hora). Suficiente para o usuário baixar; expira sozinho.
Existe um workaround histórico em fetchSurveyFile() no frontend que
tenta dois nomes de arquivo: survey_123.xlsx e
survey_123 .xlsx (com espaço antes da extensão). É vestígio de uma
inconsistência antiga; não remova sem confirmar que nenhum bucket legado tem
arquivos com espaço.
Secrets & deploy
Variáveis de ambiente do Lambda
| Variável | Default |
|---|---|
SUPABASE_URL | URL do projeto |
SUPABASE_SECRET_NAME | supabase-service-key |
AWS_REGION | us-east-1 |
Cache do secret (5min)
SupabaseService faz GetSecretValue no Secrets Manager e
cacheia em memória por CACHE_TTL_MS = 5 * 60 * 1000. Cold start paga
uma chamada AWS; invocações subsequentes na mesma execução environment
reaproveitam.
Build
npm run build # esbuild --bundle --target=node20 --external:@aws-sdk/client-secrets-manager
npm run zip # gera function.zip pronto para upload
Frontend — report.service.ts
Antes de mandar pro Lambda, o frontend remove campos de debug do JSON do dashboard e empacota no formato esperado.
function transformToExcelFormat(dashboardResponse: SurveyHandlerResponse, fileName: string) {
const { _debug, ...rest } = dashboardResponse;
return {
json: { result: rest },
nome_arquivo: fileName,
};
}
export async function generateSurveyReport(surveyId: number, reportType: 'full' | 'executive') {
const dashboard = await fetchDashboardData(surveyId);
const payload = { ...transformToExcelFormat(dashboard, fileNameFor(dashboard)), reportType };
const { signedUrl, fileName } = await postToExcelLambda(payload);
return { downloadUrl: signedUrl, fileName };
}
function triggerDownload(url: string, fileName: string) {
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
}
Permissão por papel
- ADMIN: completo + executivo.
- SURVEYOR: completo + executivo (sua empresa).
- VIEWER: completo + executivo (sua empresa, somente leitura).
O ReportTypeDialog só aparece após o gate de papel.
Edge cases & armadilhas
- Pergunta 100% nula
- Aba inteira é pulada — o relatório omite a pergunta em vez de mostrar uma planilha vazia.
- Atributo com chave que parece data
- Heurística filtra strings em formato de data (ex.:
2025-09-01) para evitar agrupamento espúrio. Se houver atributo legítimo nesse formato, ele será ignorado — mover o valor para outro formato resolve. - NaN / Infinity
setCellValue()converte para0. Strings vazias são preservadas (não viram zero).- Datas ausentes
- "Data não informada" no lugar. Fallback ordem:
start_date→end_date→created_at. - Nome da empresa
-
Heurística de extração tenta, em ordem: regex em
survey_info.titlepor "pesquisa (.+)", depoisrespondents[0].atributo_6, depois primeiro valor não-palavra-chave emoverall_rating.attribute_*. Default: "Empresa". - Pesquisas gigantes
- Memória da Lambda: 1024MB. Timeout: 120s. Comporta ~5000 respondentes na prática. Acima disso, considerar streaming ou paginação por aba.
- Caracteres especiais no nome
- Os nomes no conteúdo da planilha mantêm acentos (UTF-8). Só o filename é sanitizado — necessário porque alguns navegadores quebram download com caracteres não-ASCII.
Histórico relevante
Commits que mudaram comportamento — referência rápida para entender por que algo está do jeito que está.
- 1a2c53d
-
banner azul nos títulos de seção e ordenação alfabética por atributo.
Introduziu
getSectionBannerStyle()esortAttributeEntries(). Aplicou nos três sheet builders (Geral, full, executivo). - 4f6579d
- Renomeou "Summary" para "Executivo" e reformulou a estrutura da aba executiva.
- 5a743db
- Gate de papel no
ReportTypeDialog— permissões por role para escolher tipo.