← Projetos
BI2026

Dashboard de Recursos Humanos

Como medir o ciclo inteiro de um colaborador — da abertura da vaga ao desligamento — sem que somar doze meses de quadro devolva uma empresa nove vezes maior do que ela é.

  • Power BI
  • DAX
  • TMDL
  • Modelagem Dimensional
  • ETL
Tela do projeto Dashboard de Recursos Humanos
01

O problema

RH é a área que mais produz dado desconectado. O headcount vive num snapshot mensal, a vaga vive no ATS, a nota de desempenho vive no ciclo de avaliação, o acidente vive na CAT e o desligamento vive numa planilha de motivos. Cada assunto tem seu próprio grão e sua própria data de negócio — e quase nenhum conversa com os outros.

O efeito prático é que perguntas óbvias não têm resposta. “Quanto custou o turnover do time comercial no semestre” cruza desligamento, salário, encargos e recrutamento. “O alto desempenho está saindo mais que a média” cruza avaliação com status do colaborador. Sem modelo, cada uma dessas vira uma extração nova feita à mão.

Havia ainda uma armadilha específica de RH que eu queria resolver por construção, não por disciplina: metade dos indicadores é retrato (quadro, salário médio, diversidade) e a outra metade é fluxo (admissões, custo, horas de treinamento). Tratar os dois grupos igual é o erro que faz uma empresa de 880 pessoas aparecer com 10.600 no total do ano.

02

As decisões

O fato central é um snapshot mensal, não o cadastro do colaborador

Fato_Headcount_Mensal tem grão de uma linha por colaborador por mês — 36 snapshots de histórico. É isso que permite perguntar “qual era o quadro em março” sem reconstruir a resposta a partir de datas de admissão e desligamento. Todo KPI de retrato lê o último snapshot do contexto, via MAX(Data_Referencia), nunca a soma dos snapshots.

O que eu descartei

O caminho curto seria usar Dim_Colaborador como fato, com uma linha por pessoa e o status atual. Funciona para a foto de hoje e perde toda a história: qualquer pergunta sobre o passado passaria a ser recalculada a partir de datas, e quem mudou de área no meio do ano apareceria sempre na área nova.

Turnover se divide por headcount médio — e o denominador é medida de primeira classe

# Headcount Médio existe como medida própria, não como cálculo enterrado dentro do turnover. Ela lê o primeiro e o último snapshot do contexto e tira a média. Todos os onze indicadores de rotatividade a usam como denominador, então a definição é uma só e muda num lugar só.

O que eu descartei

Dividir pelo headcount final é o erro nº 1 em dashboard de RH. Em período de crescimento ele subestima a rotatividade, e em período de corte superestima — nos dois casos devolve um número plausível, que é o pior tipo de erro porque ninguém desconfia.

Dim_Calendario é o hub das oito fatos, cada uma pela sua data de negócio

As oito fatos se ligam ao mesmo calendário, cada uma pela data que faz sentido para ela: Data_Referencia no headcount, Data_Abertura no recrutamento, Data_Ocorrencia na segurança, Data_Resposta no engajamento. É isso que viabiliza medida cross-fact — horas de treinamento sobre headcount médio, acidentes sobre homens-hora trabalhadas, respondentes de pesquisa sobre quadro elegível. Um filtro de período move os oito assuntos juntos.

O que eu descartei

Deixar cada fato filtrando pela própria coluna de data isolaria os assuntos. A partir daí, qualquer indicador que cruze duas fatos vira uma medida com CALCULATE reescrevendo filtro na mão — e cada uma com sua interpretação de “no mesmo período”.

Quatro relacionamentos inativados de propósito

Fato_Headcount_Mensal se liga a Cargo, Departamento e Local, e Fato_Recrutamento se liga a Dim_Colaborador pelo contratado. Os quatro estão inativos. A hierarquia organizacional é resolvida por caminho único: via Dim_Colaborador quando a pergunta é sobre a situação atual, ou pelas colunas _Snapshot da própria fato quando é sobre a situação na época. As colunas _Snapshot são LOOKUPVALUE gravado no momento do snapshot.

O que eu descartei

Deixar tudo ativo criaria caminho ambíguo entre fato e dimensão — o modelo nem carrega, e a saída seria distribuir USERELATIONSHIP pelas medidas. Aí a resposta de “qual departamento” passaria a depender de qual medida o usuário arrastou, o que é impossível de explicar para quem lê o relatório.

Um Calculation Group no lugar de 500 medidas de time intelligence

CG_TimeIntelligence aplica Atual, YTD, YoY, YoY %, MoM, MoM % e MAT sobre qualquer medida do modelo com SELECTEDMEASURE(). São sete itens de cálculo cobrindo os 73 KPIs. O MAT entrou porque o turnover desta base tem pico sazonal em dezembro e janeiro — sem janela móvel de 12 meses, todo dezembro parece uma crise.

O que eu descartei

A alternativa é escrever a variação de cada KPI à mão: 73 × 7 daria mais de 500 medidas para manter sincronizadas. Corrigir a definição de turnover passaria a significar caçar sete cópias dela.

Cada KPI publicado tem uma medida gêmea que o recalcula por outro caminho

Os 73 KPIs têm uma gêmea TST oculta, na subpasta _Testes de cada bloco, que chega ao mesmo número por um caminho DAX deliberadamente diferente: onde a publicada usa SUM dentro de CALCULATE, a gêmea usa COUNTROWS com FILTER; onde usa AVERAGE, a gêmea usa SUMX ÷ COUNTROWS; onde usa TREATAS da fato para a dimensão, a gêmea itera a dimensão para a fato por transição de contexto. Delta diferente de zero significa erro de lógica ou inconsistência na origem.

O que eu descartei

Conferir no olho, comparando o visual com a planilha. Foi justamente o mecanismo que pegou um bug real: em % Turnover de Alto Desempenho, a primeira versão fazia MAX(Dim_Colaborador[Status]) a partir da fato — como filtro não flui de fato para dimensão, o MAX varria a dimensão inteira, devolvia "Desligado" sempre e cravava 100%. Um número redondo que passaria batido numa conferência visual.

03

O modelo

FATOSDIMENSÕESData_ReferenciaID_ColaboradorFato_Headcount_Mensal23 colunasFato_Movimentacao13 colunasFato_Recrutamento26 colunasFato_Treinamento14 colunasFato_Desempenho13 colunasFato_Engajamento16 colunasFato_Absenteismo11 colunasFato_Seguranca11 colunasDim_Calendario14 colunasDim_Colaborador30 colunasDim_Cargo8 colunasDim_Departamento4 colunasDim_Local6 colunasDim_Curso7 colunasDim_Motivo_Desligamento5 colunas_KPIs (6 tabelas)tabela de medidas

Fato_Headcount_Mensal

tabela fato

Grão: uma linha por colaborador por mês (snapshot)

tabela central do modelo · 36 snapshots · as 3 colunas _Snapshot são LOOKUPVALUE, e guardam a lotação na época contra a lotação atual

  • ID_Snapshot
  • Ano_Mes
  • Data_Referencia
  • ID_Colaborador
  • ID_Cargo
  • ID_Departamento
  • ID_Local
  • FTE
  • Flag_Ativo_Fim_Mes
  • Flag_Admitido_Mes
  • Flag_Desligado_Mes
  • Tempo_Casa_Meses
  • Salario_Base
  • Horas_Extras_Qtd
  • Horas_Extras_Valor
  • Encargos
  • Beneficios
  • Provisoes_Ferias_13
  • Custo_Total_Mes
  • Headcount
  • Departamento_Snapshot
  • Cargo_Snapshot
  • Local_Snapshot

clique numa tabela para ver o grão, a origem e as colunas · lido do TMDL do projeto · o diagrama desenha os 19 relacionamentos ativos de fato para dimensão

O DAX que sustenta o modelo

# Headcount

# Headcount =
VAR _UltimoSnapshot = MAX ( Fato_Headcount_Mensal[Data_Referencia] )
RETURN
    CALCULATE (
        SUM ( Fato_Headcount_Mensal[Flag_Ativo_Fim_Mes] ),
        Fato_Headcount_Mensal[Data_Referencia] = _UltimoSnapshot
    )

Por que assim

É a medida que define a regra do modelo inteiro: retrato lê o último snapshot do contexto, não a soma dos snapshots. Sem o CALCULATE prendendo a data de referência, um filtro de doze meses somaria doze fotos do mesmo quadro e devolveria ~10.600 pessoas numa empresa de ~880. O mesmo padrão se repete em # FTE, salário médio e todos os indicadores de diversidade.

# Headcount Médio

# Headcount Médio =
VAR _PrimeiroSnapshot = MIN ( Fato_Headcount_Mensal[Data_Referencia] )
VAR _UltimoSnapshot   = MAX ( Fato_Headcount_Mensal[Data_Referencia] )
VAR _HCInicial =
    CALCULATE (
        SUM ( Fato_Headcount_Mensal[Flag_Ativo_Fim_Mes] ),
        Fato_Headcount_Mensal[Data_Referencia] = _PrimeiroSnapshot
    )
VAR _HCFinal =
    CALCULATE (
        SUM ( Fato_Headcount_Mensal[Flag_Ativo_Fim_Mes] ),
        Fato_Headcount_Mensal[Data_Referencia] = _UltimoSnapshot
    )
RETURN
    DIVIDE ( _HCInicial + _HCFinal, 2 )

Por que assim

É o denominador obrigatório dos onze indicadores de turnover, e existe como medida própria justamente para não ser reescrito onze vezes. Ele acompanha o contexto: filtrar um trimestre move o primeiro e o último snapshot junto, então a média é sempre a do período que a pessoa está olhando — e não a do ano inteiro.

% Turnover de Alto Desempenho

% Turnover de Alto Desempenho =
VAR _TopIDs =
    CALCULATETABLE (
        VALUES ( Fato_Desempenho[ID_Colaborador] ),
        Fato_Desempenho[Quadrante_9Box] IN { "8 - Alto Desempenho", "9 - Estrela" },
        Fato_Desempenho[Status_Avaliacao] = "Concluída",
        REMOVEFILTERS ( Dim_Calendario )
    )
VAR _ColabTop =
    CALCULATETABLE (
        Dim_Colaborador,
        TREATAS ( _TopIDs, Dim_Colaborador[ID_Colaborador] ),
        REMOVEFILTERS ( Dim_Calendario )
    )
VAR _TotalTop      = COUNTROWS ( _ColabTop )
VAR _TopDesligados =
    COUNTROWS ( FILTER ( _ColabTop, Dim_Colaborador[Status] = "Desligado" ) )
RETURN
    DIVIDE ( _TopDesligados, _TotalTop )

Por que assim

É a medida onde o modelo mais tenta enganar. O 9-Box mora em Fato_Desempenho e o status de desligamento mora em Dim_Colaborador — e filtro não flui de fato para dimensão. O TREATAS é o que empurra a lista de IDs do alto desempenho para dentro da dimensão, materializando o subconjunto de pessoas antes de contar quantas saíram. A primeira versão tentava o atalho, MAX(Dim_Colaborador[Status]) a partir da fato: o MAX varria a dimensão inteira, achava "Desligado" e devolvia 100% fixo. Foi a medida gêmea que denunciou. Os dois REMOVEFILTERS existem porque a pergunta é sobre a pessoa, não sobre o mês — sem eles, filtrar um período recortaria quem foi avaliado nele.

04

O resultado

73
KPIs em DAX

organizados em 11 blocos

15
tabelas no modelo

8 fatos · 7 dimensões

73
medidas gêmeas de teste

1 bug real capturado

69
visuais

em 7 páginas navegáveis

O que eu faria diferente

Duas coisas eu faria diferente. A primeira é o caminho da fonte: as consultas M apontam para o .xlsx por caminho absoluto, então quem clona o repositório precisa reapontar na mão. Um parâmetro pCaminhoBase resolveria em cinco minutos, e eu deixei para depois porque o projeto é single-user — que é exatamente o argumento que todo mundo usa antes de o projeto deixar de ser single-user. A segunda é a ordem do trabalho: escrevi as 73 medidas gêmeas depois das publicadas, e foi assim que descobri um bug já com o relatório montado. Se as gêmeas tivessem vindo junto, bloco a bloco, o erro teria aparecido antes de eu desenhar visual em cima dele. Fica também o hábito que veio da base ser fictícia: # Dias sem Acidentes usa o fim da base como referência, não TODAY(), porque com dado inventado TODAY() faz o streak crescer sozinho sem nenhum fato novo entrar — e a premissa ficou escrita dentro da medida, não na minha cabeça.