# Changelog — Dashboard Talento Humano
**Proyecto:** BIA Faranda Hotels — Dashboard de Empleados
**Stack:** HTML + ECharts 5.4.3 · Python http.server · PostgreSQL

Todos los cambios relevantes del proyecto se documentan en este archivo.
Formato basado en [Keep a Changelog](https://keepachangelog.com/es/).

---

## [1.26.2] — 2026-10-20

### Datos (BD, sin cambio de código)

- **FC Zona G (FCZN) — septiembre 2026 cargado manualmente** (sin API; el
  cierre automático la deja sin empleados):
  - `historico_empleados` (2026-09-01): 28 activos, desde el reporte
    Relación de Empleados de Zeus; nacimiento/sexo cruzados con
    `Historico de empleados.xlsx`. Planta total sept: 1.034 → 1.062.
  - `historico_ir` (2026-09): 2 ingresos y 3 retiros (causa "Voluntario"),
    origen `manual`. Cuadra: 29 (ago) + 2 − 3 = 28.
  - `historico_nomina_pyg` (NIT 999999001): 69 filas (29 empleados, 33
    conceptos). Devengos 90.190.534, deducciones 8.947.817, neto
    81.242.717, provisiones 17.046.489 (Excel corregido por el usuario;
    la primera carga traía 7.895.582), gasto total de personal 120.887.823.

---

## [1.26.1] — 2026-10-16

### Corregido

- **`servidor.py` — `log_message` ya no falla** cuando `args[0]` no es texto
  (errores HTTP donde el primer argumento es un código). *(Solo en el
  worktree de desarrollo/8081; pendiente de `/merge` a main.)*

### Datos (BD, sin cambio de código)

- **Cierre de septiembre 2026 completado en los 18 hoteles:** nómina,
  planta (`historico_empleados`) e ingresos/retiros (`historico_ir`).
  Recargas de Puerta del Sol (jul–sep) y Aloft (nómina sep, IR ago/sep);
  correcciones de planta en GHS, BIAN, Aloft y retirados de fin de mes
  que quedaban como activos (jun/ago).
- **CLUB/FEB (Faranda_Bogota):** la API `/rrhh/ingresos-retiros` omite
  este hotel desde jun-2026 (evidencia en
  `API_ingresos_retiros_Faranda_Bogota.md`, pendiente de enviar al equipo
  del Server 217). Retiros jun–sep: causa provisional "Voluntario"
  (Montiel = "Cesión de contrato", traslado FEB→CLUB) y fechas reales de
  retiro tomadas de `/nomina/reporte` (15 de 16; falta López López, FEB jul).

---

## [1.26.0] — 2026-09-16

### Agregado

- **Nueva propiedad: FC Zona G (FCZN).** Sin API de nómina/P&G con
  NominaApp — histórico cargado manualmente. Dada de alta en `empresas`
  y `hotel_unificacion` (Excel `Unificacion_Hoteles.xlsx` +
  `/proxy/admin/sync-hoteles`), sacada de las listas de exclusión
  `EXCL`/`EXCL_H` de `servidor.py`.
- **Histórico completo de FC Zona G cargado (ene-ago 2026):**
  - `historico_empleados`: 224 filas (51 personas), fuente: 8 Excel
    mensuales de Zeus en 2 formatos distintos.
  - `historico_ir`: 43 movimientos, cruzados y corregidos contra el
    archivo de nómina (que trae fecha de ingreso/retiro confirmada).
  - `historico_nomina_pyg`: 234 filas empleado-mes (503 filas totales
    con los 4 tipos de registro), desde un Excel de 20 hojas con layout
    de columnas variable por mes — mapeo por nombre de columna.
    Verificado: panel "Nómina histórica" de la hoja de vida funciona
    completo para esta propiedad.
- **Nueva columna "Salario" en la tabla del tab Empleados** — ordenable,
  visible también en tarjetas móviles. *(Solo en el worktree de
  desarrollo por ahora.)*

### Corregido

- **Payroll mostraba "sin NIT configurado" para hoteles sin API en vivo**
  aunque tuvieran NIT y datos históricos cargados (caso FC Zona G).
  `/proxy/nomina/hoteles-nit` exigía `hotel_name_api IS NOT NULL` — un
  campo que el frontend no usa en ningún lado. Se quitó del filtro.

---

## [1.25.2] — 2026-09-03

### Corregido

- **Ingresos/Retiros de Belvedere y FC Bogotá salían en 0 y no cuadraban
  con "Activos Fin".** El endpoint `/rrhh/ingresos-retiros` de NominaApp
  dejó de devolver el NIT 900345450 (bucket `Faranda_Bogota` = CLUB +
  FEB) a partir de junio 2026 — los datos existen en Zeus y en
  `empleados-activos-detalle`, pero no en ese endpoint.
  - `cierre_mes.py`: nueva `completar_ir_desde_snapshot()` — para las
    razones sin ninguna fila IR del período (`IR_FALLBACK_SNAPSHOT_RAZONES
    = {CLUB, FEB}`), reconstruye ingresos/retiros comparando el snapshot
    de `historico_empleados` mes vs. mes anterior. Filas marcadas
    `origen='snapshot'` (columna nueva en `historico_ir`). Si NominaApp
    corrige su API, los datos reales vuelven a mandar.
  - `backfill_ir_snapshot.py`: script nuevo que aplica lo mismo a meses
    ya cerrados. Ejecutado para jun–ago 2026: 32 movimientos, coinciden
    con el reporte de Zeus. Belvedere y FC Bogotá ya cuadran en los 3
    meses.
  - Limitación conocida: un traslado entre dos razones del mismo grupo se
    ve como retiro + ingreso, no como transferencia.
- **FC Cartagena y Servihoteles descuadraban por 1 en agosto 2026** — un
  empleado de Servihoteles (Cantillo Urueta, cédula 1143363867) cambió de
  centro de costo a "Faranda Collection Cartagena" sin alta ni baja real;
  `activos-count` lo reclasifica a FC Cartagena pero ningún movimiento de
  IR lo refleja (+1 en FC Cartagena, −1 en Servihoteles). Ajuste de datos:
  dos filas manuales en `historico_ir` (`origen='ajuste'`, período
  2026-08) — un ingreso para CCAR y un retiro para SERV. Sin cambio de
  código; un re-cierre de agosto las borraría.

### Cambiado

- **Carga histórica de nómina/P&G — el log de error ahora dice el motivo
  real.** `post_json()` en `cargar_historico_nomina_pyg.py` lee el cuerpo
  del `HTTPError` y expone el campo `detail` de la API. Antes, dos casos
  muy distintos se veían idénticos como `HTTP Error 400: Bad Request`:
  - *base de nómina caída del proveedor* — `Cannot open database
    "NominaEmp" ... login failed (4060)` (caso PSOL / NIT 800157788):
    hay que reportarlo, no se resuelve esperando.
  - *nómina del mes aún sin cerrar* — `No hay periodos cerrados para
    YYYY-MM` (caso DORA/Aloft en meses recientes): se resuelve sola en
    el reintento de los días 5/7.
  Sin cambio de comportamiento: sigue saltando ese NIT y continuando.

### Operación

- **Cierres mensuales consolidados en el Mac mini.** Los 6 LaunchAgents
  de cierre (`com.bia.cierre-mes-dia1/5/7`, `com.bia.nomina-pyg-dia1/5/7`)
  estaban corriendo a la vez en el Mac mini y en la Mac local (el
  `launchctl unload` de agosto no era permanente). Se desactivaron en la
  Mac local (`bootout` + `.plist` movidos fuera de `~/Library/LaunchAgents`);
  el Mac mini queda como único ejecutor. En la Mac local siguen activos
  el dashboard (8080/8081) y el backup de PostgreSQL.

---

## [1.25.0] — 2026-08-27

### Agregado

- **Nómina histórica en el panel "hoja de vida":** nuevo botón "📋 Ver
  nómina histórica" (junto al de evolución salarial) abre una tarjeta
  flotante con selector de período y el desglose completo — devengos,
  deducciones, provisiones, aportes patronales, neto pagado. Fuente:
  `historico_nomina_pyg` (columna `raw_data`), que ya tenía este detalle
  por empleado desde ene-2024 sin que ningún endpoint lo expusiera.
  Nuevo endpoint `GET /proxy/historico/nomina-empleado`.
- **Detección de conceptos de nómina sin clasificar (Payroll):** banner
  amarillo cuando la nómina de un período trae un concepto que nunca se
  vio antes (no está en `nomina_conceptos`, ni siquiera como "ignorar")
  — antes se descartaba en silencio y no aparecía en ningún lado.
- **Backup automático diario de PostgreSQL** — `pg_dump` a las 4:00 AM
  vía LaunchAgent (`com.bia.backup-postgres`), 30 días de retención.
  No existía ningún backup de la base histórica hasta ahora.

### Corregido

- **Filtro de Operación (En Operación/Socio) no funcionaba en Ingresos y
  Retiros del mes en vivo** — la API externa de IR no trae ese dato por
  fila. Nuevo endpoint `GET /proxy/historico/socios` expone la tabla
  `socios` para cruzar por cédula (cubre también a quien ya se retiró
  ese mes, a diferencia de cruzar contra empleados activos).
- **Etiquetas de "Distribución por Rango de Edad" y "Distribución por
  Antigüedad"** ahora muestran cantidad + porcentaje, igual que las
  demás gráficas (antes solo el número).
- **298 empleados con `codigo_empleado='NaN'`** en `historico_empleados`
  (ene-2023 a sep-2024, 4 entidades con la cédula vacía en la fuente
  durante un rango de meses) — 1.271 filas recuperadas cruzando por
  nombre; 688 quedan sin dato recuperable en ningún lado. De paso,
  normalizado un sufijo `".0"` en 1.024 filas de `codigo_empleado`
  (artefacto de conversión a float en la carga).

### Cambiado

- **4 fallos silenciosos** al consultar nómina/P&G en vivo por hotel
  (`except: pass`) ahora dejan un aviso en el log con el hotel/NIT y el
  error — antes ese hotel quedaba en 0 sin ningún rastro.
- **9 funciones JS sin ningún uso** eliminadas (`ausHasDetailFilters`,
  `estSerieInit`, `estSerieRenderAll`, `fmtMoney`, `prApplyCecoCargoFilter`,
  `prVarPct`, `syncIR`, `toSentence`, `refreshHotelDependentTabs`).
- Documentadas en código las 3 formas distintas de calcular "Antigüedad"
  (tabla/panel, gráfica de distribución, historial por empresa) con
  referencias cruzadas, para que no se "corrija" una sin saber que
  rompe consistencia con las otras dos.

---

## [1.24.1] — 2026-08-25

### Cambiado

- **"Evolución salarial" ya no tapa la hoja de vida:** antes era un panel
  que se apilaba encima (o, en un intento anterior, pestañas dentro del
  mismo panel). Ahora se muestra como una tarjeta flotante dentro del
  área difuminada a la izquierda de la hoja de vida — ambas vistas quedan
  visibles al mismo tiempo, sin taparse. Cargos normalizados
  (mayúsculas/minúsculas) para que no se vean bandas duplicadas por
  diferencias de capitalización entre meses.
- **Tarjetas de género (Masculino/Femenino):** el % ya no salta a 100%/0%
  al hacer click para filtrar — se mantiene el valor real, y la tarjeta
  activa se marca con un badge "✓ Filtrado".

---

## [1.24.0] — 2026-08-25

### Agregado

- **Filtro por género:** click en las tarjetas Masculino/Femenino del
  header filtra todo el dashboard (tabla, gráficas, KPIs) por sexo — igual
  que el resto de filtros. Click de nuevo (o "Limpiar filtros") lo quita.
- **Tarjetas de género con tooltip:** ahora muestran nominal + porcentaje
  al pasar el cursor (antes solo el porcentaje).
- **Desglose Masculino/Femenino en tooltips** de "Empleados por Hotel",
  "Distribución Tipo de Contrato", "Empleados por Centro de Costo" (y su
  drill-down de cargos), "Distribución por Rango de Edad", "Distribución
  por Antigüedad" y "Distribución % de CECO" (tab Salarios).
- **Etiquetas de los donuts** "Empleados por Hotel" y "Distribución Tipo
  de Contrato" ahora muestran cantidad + porcentaje (antes solo %).
- **Click en el logo** recarga todo el dashboard (filtros, período, datos).
- **Ingresos/Retiros del header ahora se pueden filtrar por género:** se
  agregó `codigo_empleado` a `historico_ir` (la API de IR ya traía la
  cédula en el campo "contrato", solo no se capturaba) — permite cruzar
  movimientos con el sexo del empleado. Incluye backfill histórico
  (80.4% de cobertura general, 88.4% desde 2024-01).

### Corregido

- **Nombres/cargos con ñ o tildes** mostraban una mayúscula extra justo
  después de la letra (ej. "MUÑOZ" → "MuñOz") — `toTitle()` usaba una
  regex que no reconocía esos caracteres como letra.
- **KPIs "Activos Inicio/Fin Mes"** no reflejaban el filtro de género
  (se calculan con una llamada aparte al servidor, no desde los datos
  ya filtrados en el navegador) — corregido.
- **"Historial por empresa" con fechas invertidas y bloques duplicados**
  en casos donde `historico_empleados` tiene dos filas para el mismo
  empleado y mes con cargo/empresa distintos (dato inconsistente en la
  fuente) — el endpoint ahora se queda con un solo registro por mes.

---

## [1.23.0] — 2026-08-24

### Agregado

- **Panel "hoja de vida" en el tab Empleados:** click en una fila (desktop)
  o tarjeta (mobile) abre un panel deslizante desde la derecha con toda la
  información disponible del empleado — datos personales, laborales,
  compensación y un botón para ver la evolución salarial completa
  (reutiliza el gráfico de trayectoria ya existente).
- **"Historial por empresa" dentro del panel:** línea de tiempo vertical
  (nodos conectados cronológicamente, actual resaltado en verde) con cada
  bloque de empresa y cargo por el que pasó el empleado, con fechas de
  ingreso/salida y antigüedad de cada bloque, más el total acumulado en
  el grupo al final.
- **Nuevo endpoint `/proxy/historico/ingreso-real-map`:** calcula por
  empleado la fecha real de inicio en su empresa actual (ignorando los
  reinicios de `fecha_ingreso` que ocurren ante cambios de cargo o de
  contrato dentro de la misma empresa), con protección contra huecos
  reales de empleo (salió y fue recontratado meses/años después).

### Cambiado

- **Antigüedad ya no se reinicia por cambios de cargo dentro de la MISMA
  empresa** — tanto en la tabla principal de Empleados como en el campo
  resumen del panel de detalle. Antes solo se veía la antigüedad real en
  el nuevo "Historial por empresa"; ahora el número también es correcto.
  Los cambios de razón social (empresa distinta) siguen sin sumarse en
  estos campos — eso sigue siendo exclusivo del historial del panel, por
  decisión de negocio.

### Corregido

- **Edad y Antigüedad congeladas desde abril 2026:** la fecha base `HOY`
  estaba hardcodeada desde la creación del dashboard y nunca se actualizó
  — todos los cálculos de edad/antigüedad quedaron ~4 meses atrasados.
  Cambiado a `new Date()`.
- **Cargo duplicado en "Historial por empresa" por mismatch de
  mayúsculas/minúsculas:** un mismo cargo real (ej. "Analista Business
  Intelligence") aparecía como 2-3 bloques distintos en el historial
  porque `historico_empleados.cargo` llega con capitalización mixta entre
  meses y el mapeo de unificación de cargos (`CARGO_MAP`) hacía un lookup
  exacto (case-sensitive). Normalizado a mayúsculas, igual que ya hacía
  el backend.

---

## [1.22.5] — 2026-08-21

### Corregido

- **Cierre de julio 2026 reconstruido:** el endpoint masivo
  `empleados-activos-detalle` (usado por el cierre automático día 1) omitía
  por completo dos hoteles — Belvedere/Club House (Faranda_Bogota, NIT
  900345450) y PSOL (Puerta del Sol) — sin devolver error. Se validó
  (`validar_hoteles_recibidos()` en `cierre_mes.py`) y se recargó julio con
  el método clásico por-NIT, que sí trae Belvedere/Club House. PSOL sigue
  sin datos: falla en todos los endpoints de nómina por un problema del
  proveedor (login a base de datos SQL Server), sin solución posible desde
  el dashboard.
- **KPI "Activos Fin/Inicio Mes" mostraba el valor anterior cuando el
  resultado real era 0** — un `0 || null` convertía un resultado legítimo
  de cero en `null`, y la animación de la tarjeta ignora `null` dejando el
  valor previo en pantalla (parecía que el filtro de Operación no
  aplicaba). Cambiado a `?? null`.
- **Doble conteo entre Servihoteles y hoteles reclasificados:** algunos
  empleados de Servihoteles (NIT compartido, razón social genérica `SERV`)
  en realidad trabajan para un hotel con NIT propio, identificable por su
  centro de costo (ej. 3 personas de FC Cartagena). Ahora se reclasifican
  correctamente — cuentan una sola vez, no en ambos lados — tanto en la
  tabla/gráficos de Empleados como en las tarjetas KPI de Análisis.
  Aplica únicamente a **FC Cartagena**; se revirtió para GHS y Hotel
  Caribe por decisión explícita, ya que esos centros de costo corresponden
  a operaciones sin relación real con esos hoteles (ventas de
  multipropiedad y otro caso), pese a que los cargos parecían de hotel.

### Optimizado

- **Lag al cambiar de filtro de hotel:** cada cambio de filtro precargaba
  en segundo plano Salarios CECO e Ingresos/Retiros aunque no fueran la
  pestaña activa, disparando hasta 8 llamadas a la API externa de nómina
  (~500ms c/u) por cada clic en el mes en vivo. Se removió la precarga
  redundante — esos tabs ya recargan datos frescos al activarse. Medido:
  34→12 peticiones de red por cambio de filtro, 8→4 llamadas externas.

---

## [1.22.4] — 2026-08-18

### Agregado

- **Despliegue de cierres en Mac mini dedicado:** se agrega el paquete
  `deploy/mac-mini/` (`INSTALACION_MAC_MINI.md`, `install-macmini-launchd.sh`,
  plantillas `launchagents/*.plist`) como alternativa a
  `deploy/INSTALACION_JOB_SERVIDOR_API.md` (servidor Linux + cron) para correr
  los cierres mensuales fuera de la máquina de trabajo diaria, usando `launchd`
  — el mismo mecanismo que ya operaba localmente.

### Cambiado

- **Cierres mensuales — ejecución movida a Mac mini:** `cierre_mes.py` y
  `cargar_historico_nomina_pyg.py` dejan de correr vía LaunchAgents en la Mac
  de trabajo y pasan a un Mac mini dedicado, apuntando a la misma base remota
  (`172.19.0.160`, `Base de Datos Historicos Talento Humano`). Los 6
  LaunchAgents locales (`com.bia.cierre-mes-dia1/5/7`,
  `com.bia.nomina-pyg-dia1/5/7`) quedan desactivados en la Mac de trabajo; solo
  siguen activos los del dashboard (`com.bia.dashboard-talento` /
  `-dev`), que no se ven afectados.

---

## [1.22.3] — 2026-07-31

### Cambiado

- **Empleados — tabla:** se agrega columna **Edad** antes de **Antigüedad** en
  la tabla principal y en las tarjetas móviles.
- **Ausencias — ranking por hotel:** se reemplaza el ranking histórico de días
  no laborados estimados por un ranking de **días por conceptos de nómina** del
  período actual. El gráfico ahora usa incapacidades, licencias y permisos
  clasificados, respeta filtros principales y muestra costo, empleados afectados
  y tasa por conceptos en tooltip.
- **Ausencias — tendencia mensual:** se reemplaza la tasa estimada histórica por
  **tasa por conceptos** de los últimos 6 meses, calculada desde nómina y
  alineada con los KPIs del tab.
- **Ausencias — mapa de calor:** se reemplaza el heatmap estimado por hotel por
  un mapa de calor de **tasa por conceptos** por hotel y mes, con tooltip de
  días, costo y empleados afectados.
- **Ausencias — contraste heatmap:** la escala de color usa el máximo real de
  tasas por conceptos para mantener contraste incluso cuando los porcentajes son
  bajos, leyendo explícitamente la dimensión de tasa para evitar celdas lavadas.

---

## [1.22.2] — 2026-07-30

### Cambiado

- **Ausencias — detalle móvil:** el detalle de conceptos usa una lista nativa
  de fichas en mobile, con concepto, tipo y métricas separadas para mejorar
  lectura sin depender del layout de tabla ni generar scroll horizontal.

---

## [1.22.1] — 2026-07-29

### Cambiado

- **Ausencias — KPI de días:** `Días No Laborados Est.` pasa a
  **Días por Conceptos** y se calcula con la suma de `cantidad` en conceptos de
  incapacidades, licencias y permisos. Ya no usa `empleados × días calendario -
  días reales de nómina` para ese KPI.
- **Ausencias — KPI de tasa:** `Tasa Estimada` pasa a **Tasa por Conceptos** y
  se calcula como `días por conceptos / (días reportados de nómina + días por
  conceptos)`, evitando inflar el indicador por empleados que no laboran todos
  los días calendario.
- **Ausencias — Mix interactivo:** al seleccionar un tipo de ausencia en el
  gráfico de dona se muestra un panel lateral con personas, días, valor y
  conceptos asociados.

### Documentado

- El cálculo por calendario se conserva únicamente como referencia estimada en
  vistas históricas agregadas por hotel. Para KPIs operativos del período se
  priorizan cantidades reportadas por nómina para evitar sobreestimar
  ausentismo en empleados que no trabajan todos los días calendario.

---

## [1.22.0] — 2026-07-28

### Agregado

- **Nuevo tab Ausencias e Incapacidades:** se incorpora después de
  **Ingresos y Retiros** con KPIs, tendencia mensual, mapa de calor por hotel,
  ranking histórico, mix por tipo y detalle de conceptos de ausencia.
- **Clasificación de ausencias desde nómina:** el tab identifica incapacidades,
  accidente de trabajo, maternidad/paternidad, licencias/permisos y vacaciones
  usando conceptos de nómina.
- **Consolidación ejecutiva de conceptos:** el detalle agrupa por concepto de
  negocio y tipo, conservando códigos consolidados en tooltip para auditoría.

### Cambiado

- **Vacaciones separadas del ausentismo:** vacaciones, provisiones, primas y
  compensadas se muestran como categoría separada para no inflar el ausentismo
  principal.
- **Servihoteles excluido de históricos de ausencias:** se omite del heatmap y
  ranking histórico porque funciona como bolsa de outsourcing y su remanente no
  es comparable con hoteles operativos en días promedio.
- **Filtros principales en Ausencias:** el tab respeta Razón Social, CECO,
  Cargo, Empleado, Operación y Período. Las vistas históricas por hotel muestran
  aviso cuando un filtro detallado no puede aplicarse a ese agregado.
- **Heatmap de ausencias:** mejora de legibilidad en porcentajes, paleta,
  márgenes y contraste.

### Documentado

- **Memoria operativa — Ausencias e Incapacidades:** el indicador de tasa es
  estimado desde días esperados menos días reales de nómina. Los costos y
  empleados afectados se calculan desde conceptos clasificados de nómina.
  No debe tratarse como bitácora médica/evento por evento hasta contar con una
  fuente formal de novedades con fecha inicio/fin y causa.

---

## Memoria Operativa — Job en el servidor de la API

- El destino oficial de los cierres es PostgreSQL `172.19.0.160:5432`, base
  `Base de Datos Historicos Talento Humano`, schema `public`.
- La opcion operativa elegida es ejecutar los jobs en el servidor original de
  la API con un usuario tecnico dedicado y cron.
- El servidor de la API ejecuta los cierres; la Mac deja de ser el scheduler
  principal.
- La implementacion actual sigue llamando NominaApp y P&G durante las fechas de
  cierre. No es todavia una integracion directa base-a-base.
- Para meses cerrados, el dashboard debe leer PostgreSQL. Los datos del periodo
  vivo pueden seguir usando API hasta quedar almacenados como historicos.
- El job de cierres debe usar un usuario PostgreSQL escritor separado del
  usuario de solo lectura del dashboard.
- Antes de la primera ejecucion en servidor se deben pausar los LaunchAgents
  locales para evitar dos escrituras concurrentes. Se conservan temporalmente
  como fallback manual.
- No ejecutar `--empleados-activos-detalle` a mitad de mes: toma la fotografia
  del dia de ejecucion y la etiqueta como cierre del mes anterior.
- El procedimiento completo y los criterios de certificacion estan en
  `deploy/INSTALACION_JOB_SERVIDOR_API.md`.

---

## [1.21.0] — 2026-07-24

### Agregado

- **Guia servidor API:** nuevo
  `deploy/INSTALACION_JOB_SERVIDOR_API.md` con arquitectura, fuentes,
  programacion, requisitos, red, seguridad, roles PostgreSQL, instalacion,
  pruebas, cron, corte desde la Mac, certificacion, operacion, recuperacion y
  reversa.
- **Memoria de arquitectura:** se registra que el servidor original de la API
  sera el ejecutor permanente y PostgreSQL `172.19.0.160` sera el repositorio
  historico consumido por el dashboard.

### Documentado

- Diferencia entre reducir las consultas del dashboard y eliminar totalmente
  el consumo de API.
- Uso de `rrhhjobs`, `.env` con permisos `600` y rol
  `th_cierre_writer` con privilegios limitados.
- Validacion obligatoria de zona horaria `America/Bogota`.
- Corte controlado de LaunchAgents para impedir ejecuciones concurrentes.
- Consultas agregadas de certificacion sin exponer informacion personal.

### Cambiado

- **Permisos minimos reales:** `cargar_historico_nomina_pyg.py` solo ejecuta
  DDL cuando recibe `--init-db`. Las cargas normales verifican que las tablas
  requeridas existan y pueden ejecutarse con el rol escritor sin permiso
  `CREATE`.

---

## Memoria Operativa — Fuente de Datos Nómina/P&G

- **Corte oficial de datos:** hasta **marzo 2026 inclusive** se usa **PostgreSQL histórico**.
- **Desde abril 2026 en adelante** se usa la **API externa**.
- Este criterio aplica tanto para **Nómina** como para **P&G**.
- Los tabs **Payroll** y **Estadísticas Hoteles** deben respetar el mismo corte para que `Gasto de personal` y `Costo Nómina` cuadren entre tabs.
- No cambiar a “API primero” para períodos históricos; la API solo debe usarse como fuente principal desde abril 2026.

---

## Memoria Operativa — Cards Payroll

- Las cards KPI del tab **Payroll** no deben usar minigráficos.
- El espacio derecho de cada card debe mostrar dos pills grandes:
  - pill de variación porcentual.
  - pill de variación nominal.
- Ambos pills usan el mismo formato visual y el mismo color/estado según el resultado: verde para aumento, rojo para disminución, gris si no hay LY o está plano.
- La variación nominal debe mostrarse abreviada dentro de la card, pero con tooltip/título usando el valor completo.
- Este patrón aplica a **Empleados**, **Outsourcing de Servicios**, **Días Trabajados** y **Gasto Total Personal**.

---

## Memoria Operativa — Cierres Mensuales Talento Humano

- **Empleados activos día 1:** `cierre_mes.py --empleados-activos-detalle` toma la foto viva desde `GET /rrhh/empleados-activos-detalle` y la guarda como cierre del mes anterior en `historico_empleados`.
- **Empleados activos día 5/7:** `cierre_mes.py --solo-empleados-si-vacio` conserva el cierre de empleados si ya existen registros para el periodo; solo inserta empleados si el día 1 falló o dejó el periodo vacío.
- **Ingresos y retiros:** `historico_ir` no cambia de lógica; días 1, 5 y 7 se refresca con `DELETE + INSERT` desde `POST /rrhh/ingresos-retiros` usando `tipo_nomina_id` 4 y 5.
- **Nómina + P&G:** `cargar_historico_nomina_pyg.py --mes-anterior --fuente ambas` corre los días 1, 5 y 7 a las 5:30 AM para actualizar `historico_nomina_pyg`.
- **Programación local:** los LaunchAgents de empleados/IR corren a las 5:00 AM y los de Nómina/P&G a las 5:30 AM.

---

## [1.20.0] — 2026-07-23

### Agregado

- **Configuración segura:** nuevo `app_config.py` para cargar `.env`/variables de entorno y centralizar PostgreSQL, NominaApp, P&G y SSL sin secretos en código.
- **Plantilla de ambiente:** nuevo `.env.example` con el contrato de variables para local y nube.
- **Protección admin:** endpoints `/proxy/admin/*` y `/proxy/historico/cecos-sync` requieren `X-Admin-Key` cuando `APP_REQUIRE_ADMIN_AUTH=true`.
- **Plan nube:** `PENDIENTES.md` documenta la migración segura de Talento Humano a PostgreSQL remoto.
- **Base remota TH:** soporte para `DATABASE_URL`/`PG_SCHEMA`, permitiendo apuntar a `Base de Datos Historicos Talento Humano` sin cambiar SQL del dashboard.
- **Paquete cloud:** `deploy/README-cloud.md`, `deploy/install-cloud-cron.sh` y `deploy/crontab-talento-humano.example` documentan e instalan cierres fuera de la Mac.
- **Pendiente Opción 1:** `PENDIENTES.md` detalla el plan servidor Faranda + cron, con requisitos, variables, instalación y criterios de certificación.

### Cambiado

- **Secretos fuera del repo:** `servidor.py`, `cierre_mes.py` y `cargar_historico_nomina_pyg.py` ya no contienen passwords ni API keys reales.
- **CORS configurable:** `CORS_ALLOWED_ORIGINS` reemplaza respuestas abiertas por defecto.
- **Errores seguros:** las respuestas HTTP ya no devuelven traceback salvo que `APP_DEBUG_ERRORS=true`.
- **SSL seguro por defecto:** `APP_VERIFY_SSL=true` mantiene verificación de certificados; `false` queda solo para diagnóstico local controlado.

### Verificado

- Auditoría local read-only de tablas, columnas e índices de Talento Humano.
- Migración remota de Talento Humano en la base `Base de Datos Historicos Talento Humano`: 10 tablas creadas en `public` y datos certificados por conteos y hashes agregados.
- Pruebas remotas sin escritura: `cierre_mes.py --test --solo-empleados-si-vacio` y `cargar_historico_nomina_pyg.py --mes-anterior --fuente ambas --dry-run`.

---

## [1.19.0] — 2026-07-23

### Agregado

- **Automatización Nómina + P&G:** `cargar_historico_nomina_pyg.py` ahora soporta `--mes-anterior` para cargar automáticamente el periodo anterior y `--dry-run` para validar sin escribir.
- **LaunchAgents Nómina + P&G:** nuevos agentes `com.bia.nomina-pyg-dia1`, `com.bia.nomina-pyg-dia5` y `com.bia.nomina-pyg-dia7`, programados a las 5:30 AM.
- **Fuente viva para cierre día 1:** `cierre_mes.py` incorpora `--empleados-activos-detalle` para poblar `historico_empleados` desde `GET /rrhh/empleados-activos-detalle`.
- **Modo conservador día 5/7:** `cierre_mes.py` incorpora `--solo-empleados-si-vacio` para no reemplazar empleados si el cierre del día 1 ya dejó datos.

### Cambiado

- **Cierre de empleados activos:** el día 1 queda como foto inicial del mes anterior usando empleados activos vivos; días 5 y 7 ya no reemplazan empleados existentes.
- **Documentación operativa:** `README.md` documenta la nueva programación de cierres, argumentos y horarios.

### Verificado

- `GET /rrhh/empleados-activos-detalle` trae los campos requeridos para `historico_empleados`: identificación, nombre, fechas, sexo, CECO, cargo, sueldo, contrato, NIT y BU.
- Validado `--test --empleados-activos-detalle`, `--test --solo-empleados-si-vacio`, `--dry-run` de Nómina/P&G, `py_compile` y carga de LaunchAgents.

---

## [1.18.0] — 2026-06-18

### Agregado — Gráfica Trayectoria Empleado (Tab Empleados)

- **`emp-tl-card`**: nueva card debajo de la tabla de empleados con gráfico de trayectoria. Se muestra al hacer clic en cualquier fila
- **`empTlLoad(nombre, razonSocial)`**: carga historial completo del empleado desde PostgreSQL sin filtro de fecha. Construye step-line ECharts con:
  - Bandas de color por cargo (`markArea`) con label del cargo en cada banda
  - Líneas verticales en cada cambio de salario con porcentaje de incremento (`markLine`)
  - Tooltip detallado: fecha · cargo · salario · CECO · tipo contrato
  - `dataZoom` slider si el empleado tiene >24 meses de historia
  - Fechas en español: `Ene 23`, `Feb 23`, etc.
- **`empTlClose()`**: oculta la card de trayectoria
- **`_empTlChart`**: variable global ECharts para la gráfica de trayectoria
- **Endpoint `GET /proxy/historico/trayectoria`**: retorna todos los snapshots del empleado sin filtro de fecha ni razon_social. Búsqueda accent-insensitive con `norm_name()` + SQL `translate()`

#### servidor.py
- **`norm_name(s)`**: nueva función helper que quita tildes (unicodedata NFD → ASCII), colapsa espacios y uppercase. Usada en endpoint trayectoria para match robusto entre nombres del API live (posibles tildes) y historico_empleados
- **SQL translate()**: query trayectoria normaliza `nombre_completo` en BD con `translate('ÁÉÍÓÚÜÑá...', 'AEIOUUNa...')` para coincidir con `norm_name()` del input

### Agregado — Tab Estadísticas

- **Línea promedio en "Evolución por Hotel"**: `markLine` naranja punteada con badge `x̄ X.X%` en el extremo. Aplica tanto en vista individual como en vista agregada
- **Lollipop "% Nómina vs Ingresos"**: reemplaza scatter. Barra delgada gris + dot de color (verde <25%, naranja 25–35%, rojo >35%) + `markLine` referencia en 30%
- **Sort default Estadísticas**: tabla ordena por Ingresos P&G descendente al cargar

### Corregido

#### P&G Abril/Mayo 2026 — datos faltantes
- Los meses Abr/May 2026 en `historico_nomina_pyg` solo tienen registros `respuesta` (JSON árbol crudo), no registros `concepto` extraídos
- Fix en `/proxy/pyg/reporte`: cuando no hay `concepto` rows, extrae desde `respuesta` con `flat_short` indexado por último segmento del path (`k.split("/")[-1]`) para manejar IDs con prefijo (`3-gasto_personal/sueldos_y_salarios_del_personal`)
- Fix en `resumen-hoteles` (sección "6b"): mismo fallback para llenar `pyg_conceptos_map`
- Resultado: Estadísticas y Payroll muestran datos P&G correctos para todos los meses ≤ May 2026

#### GHS — empleados contados incorrectamente (10 vs 124)
- `hotel_unificacion` tenía dos filas para GHS (Local + TC) y `DISTINCT ON` elegía la fila TC que tiene BU diferente
- Fix: CTE `hotel_bus` agrupa `all_bus = ARRAY_AGG(bu_codigo)` por `nombre_personalizado`. Lógica de suma: si `len(all_bus) > 1` → suma empleados de todos los BUs

#### Barras redondas en charts Estadísticas
- `borderRadius: [3,3,0,0]` aplicado en series de barras en `estSerieRender` y `estSerieRenderAgregado`

#### Servihoteles excluido del ranking "Costo Nómina por Empleado"
- Filtro `r.label !== 'Servihoteles'` en el array de datos del chart

#### onclick tabla empleados — robusto a caracteres especiales
- Reemplazado `onclick="empTlLoad('${e.nombre}',..."` (rompía con comillas y tildes) por `data-nombre="${esc}"` + `onclick="empTlLoad(this.dataset.nombre, ...)"`. Cubre `e.nombre || e.nombre_completo` para modo live API vs histórico

---

## [1.17.0] — 2026-06-10

### Agregado — Tab Estadísticas Hoteles: KPIs + paleta azules

- **Paleta azules** (`EST_COLORS`): 5 tonos azul Faranda para las cards KPI de estadísticas
- **`Días Laborados Mes`**: (empleados directos + outsourcing) × días del mes
- **`Días Promedio Trab`**: `SUM(dias nómina) / total_emps` (entero, desde nómina)
- **Cutoff `_is_periodo_historico` actualizado a Mayo 2026**: `(2026, 5)` en worktree dev

---

## [1.16.0] — 2026-06-04

### Rendimiento — servidor multi-hilo + connection pool

#### servidor.py
- **`ThreadingHTTPServer`**: reemplaza `http.server.HTTPServer` single-threaded. El dashboard dispara múltiples `fetch()` en paralelo (`Promise.all`); el servidor anterior los ponía en cola causando que cards como "Activos Inicio" se quedasen en `--` hasta que terminaba la cola
- **`ThreadedConnectionPool(minconn=2, maxconn=10)`**: elimina ~150ms de overhead por request (antes cada petición abría y cerraba una conexión PostgreSQL nueva)
- **`try/finally DB_POOL.putconn(conn)`**: patrón aplicado en `_historico()` y `_stats()` — la conexión siempre se devuelve al pool incluso en errores. `_stats()` usa flag `_conn_returned` para rutas que liberan la conexión antes de llamar a la API externa P&G

### Rendimiento — debounce en actualizaciones JS

#### renderAll() — 3 niveles de prioridad
- **Inmediato**: tabla de empleados + KPIs numéricos (lo que el usuario ve primero)
- **180ms debounce** (`_renderChartsTimer`): 6 gráficas ECharts — evita re-renders en ráfaga al cambiar varios filtros seguidos
- **350ms debounce** (`_headerKpiTimer`): `loadHeaderKPIs()` + `loadSalarios()`/`loadIR()` si son el tab activo — evita burst de peticiones HTTP
- Durante `_periodChanging`: sin debounce (respuesta inmediata al cambiar período)

#### _scheduleAllTabsUpdate() — nuevo (reemplaza refreshHotelDependentTabs)
- **400ms debounce** (`_allTabsTimer`): actualiza todos los tabs al cambiar cualquier filtro
- Salarios/IR: precarga en background si NO son el tab activo
- Payroll: `prLoad()` o `prApplyFilters()` si ES el tab activo
- Estadísticas: `estLoad()` si ES el tab activo
- **Conectado a TODOS los filtros**: f-hotel, f-centro, f-cargo, f-empleado, f-operacion, mSearchInput, mSearchClear, clearFilters (antes solo f-hotel llamaba refreshHotelDependentTabs)

### Corregido

#### loadHeaderKPIs() — carga desincronizada
- **2 bloques secuenciales**: activos-count (PostgreSQL rápido) se muestra de inmediato; `fetchIRData()` (puede ser API externa lenta para mes actual) se carga aparte
- **Removido indicador `…`**: los cards mantienen el valor anterior en silencio hasta que llega el dato nuevo

#### Filtros — bugs varios
- **Tab Estadísticas razón social**: condición `if (activeTab !== 'estadisticas')` estaba invertida → nunca recargaba al filtrar. Corregido a `estLoad()` incondicional
- **Filtro período visible en todos los tabs**: `hdr-sal-controls` siempre `display:''` (antes se ocultaba en algunos tabs)
- **Donut Payroll sin datos al abrir tab**: `chart.resize()` al activar tab payroll (ECharts se inicializaba con dimensiones 0 porque el panel era `display:none`)

### Agregado

#### animCountKpi(id, target)
- Nueva función de animación de conteo para los 4 KPI cards del header
- Parsea el valor actual con `.replace(/\./g,'')` para soportar formato es-CO (puntos de miles)
- 22 pasos × 16ms ≈ 350ms de duración
- Aplicada a: `k-inicio`, `k-ingresos`, `k-retiros`, `k-fin`

#### Pills Payroll — 2 decimales
- `prMiniSpark()`: `toFixed(1)` → `toFixed(2)` en el pill de variación porcentual

#### Card Outsourcing de Servicios
- Número grande + nombre de hotel inline (sin número duplicado pequeño debajo)
- `pr-k-serv-lines` muestra solo el nombre del hotel, no el conteo

---

## [1.15.0] — 2026-05-26

### Corregido — P&G por BU, NIT CCUC, clasificaciones

#### P&G por Business Unit (fix crítico)
- **`servidor.py` BU hardcoded**: el endpoint `_pyg_reporte()` leía siempre `raw["pnl"]["local"]["tree"]` → Belvedere y FC Cucuta siempre mostraban 0 conceptos P&G. Corregido: itera `bu_codigos` y lee `raw["pnl"][bu_key]["tree"]` de cada uno; fallback a "local" solo si todos retornan vacío
- **`bu_codigos` normalizados a lowercase**: `servidor.py` normaliza `[b.lower() for b in bu_codigos]` antes de iterar el P&G response — la API devuelve keys lowercase ("local", "cu", "be", "tc")
- **`bu_codigo` lowercase en `hotel_unificacion`**: CCUC='cu', FEB='be', COLH='fb', TCLUB='tc' (todos lowercase para consistencia con P&G API)

#### P&G por company_id (arquitectura correcta)
- **Endpoint `/proxy/pyg/reporte`**: acepta `company_id` directamente (antes solo `nit`); si no viene, hace lookup `pyg_company_id` desde `empresas WHERE nit=?`
- **`hoteles-nit` query**: devuelve columna `pyg_company_id` en el JSON
- **`pygCompanyMap` (JS)**: reemplaza `pygBuMap` — agrupa hoteles por `pyg_company_id`. Una llamada P&G por empresa (no por NIT). `pygCompanyMap[cid] = [bu_codigos[]]`
- **`prLoad()`**: `pygCompanyIds = Object.keys(pygCompanyMap).map(Number)` → `Promise.all` con un fetch por `company_id`

#### NIT CCUC corregido
- **`empresas SET nit='900361237' WHERE codigo='CCUC'`** (era 890930668 = NIT Hotel Bolívar, incorrecto)
- FC Cucuta y Casa Canabal comparten NIT 900361237 (mismo holding)
- **`empresas SET pyg_company_id=4 WHERE codigo='CCUC'`** (era 20 = Hotel Bolívar Cúcuta)

#### BD empresas — nuevos registros
- `GHS`: `pyg_company_id = 21` (era NULL)
- `TCLUB`: INSERT `(codigo='TCLUB', nit='900657316', pyg_company_id=21)`

#### `prApplyBUFilter` case-insensitive
- DB guarda `bu_codigo` lowercase ('cu','be','tc'); nómina API devuelve uppercase ('CU','BE','Local')
- Comparación corregida: `(e.bu_codigo||'').toLowerCase() === buLower`

#### Conceptos LY-only — sin doble conteo
- **Sección 2 (LY-only) ejecuta ANTES de sección 1d** (reconciliación) en `prRenderConceptos()`. Antes: conceptos con val=0 y ly>0 no estaban en `poolMap` cuando corría la reconciliación → `lyNominaSum` los excluía → `lyDiff` los incluía → sección 2 los volvía a agregar = doble conteo visible como "Ajuste Contable P&G $0 / $X"
- Ahora: `poolMap` ya tiene todos los conceptos LY-only cuando corre la reconciliación → `lyNominaSum` correcto → `lyDiff` correcto → sin ajuste fantasma

#### Ajuste Contable P&G — LY source of truth
- `ly = lyDiff` en la fila `ADJ_{id}` (era `0`). P&G es la fuente de verdad para el total LY de la tabla
- Esto garantiza que columna LY cuadre: `sum(poolMap.ly) === P&G.ly` siempre

#### Outsourcing Servihoteles — The Club
- `PR_HOTEL_SERV_PATTERNS['The Club']` incluye `'THE CLUB CTG'` (era bajo clave `'GHS'`)
- Los empleados de Servihoteles con CC `THE CLUB CTG` aparecen correctamente en la card "The Club"

### Base de datos
```sql
-- NIT y company_id CCUC
UPDATE empresas SET nit='900361237', pyg_company_id=4 WHERE codigo='CCUC';
-- GHS company_id
UPDATE empresas SET pyg_company_id=21 WHERE codigo='GHS';
-- TCLUB nuevo
INSERT INTO empresas(codigo,nit,pyg_company_id) VALUES('TCLUB','900657316',21);
-- bu_codigos lowercase
UPDATE hotel_unificacion SET bu_codigo='cu'  WHERE razon_social='CCUC';
UPDATE hotel_unificacion SET bu_codigo='be'  WHERE razon_social='FEB';
UPDATE hotel_unificacion SET bu_codigo='fb'  WHERE razon_social='COLH';
UPDATE hotel_unificacion SET bu_codigo='tc'  WHERE razon_social='TCLUB';
-- nombre_personalizado The Club
UPDATE hotel_unificacion SET nombre_personalizado='The Club' WHERE razon_social='TCLUB';
```

---

## [1.14.0] — 2026-05-25

### Agregado — Tab Payroll: P&G, SS Patronal, Reconciliación, UX

#### Seguridad Social Patronal
- `ss_vista:"ambos"` en fetch nómina → cada empleado devuelve `seguridad_social[]{aporte_empleador}`
- `_prBuildSsPatronal(emps)`: suma `aporte_empleador > 0` por código con prefijo `SS_` (SS_DX02, SS_DX03, SS_DX05, SS_RIESGO, SS_SENA, SS_ICBF, SS_CAJ)
- 7 conceptos SS insertados en `nomina_conceptos` con `grupo='Seguridad Social'`, `subgrupo='Aportes Patronales'`
- Fix `prMergeData` clone case: construye `ss_patronal` desde `empleados[]` (raw API no lo trae)

#### Integración API P&G
- `empresas.pyg_company_id` INTEGER: 17 hoteles mapeados (IDs 1-20, sin 5/12/16)
- Endpoint `POST /proxy/pyg/reporte`: lookup `pyg_company_id` por NIT → llama AppsFaranda → retorna conceptos planos `{id, nombre, grupo, subgrupo, val, ly, nomina_backed}`
- `CONCEPTOS` list en `_pyg_reporte()`: 18 entradas (9 nómina-backed + 9 P&G-only) incluyendo `gastos_medicos_y_medicamentos` y `salud_ocupacional_y_examenes_d`
- `prLoad()`: fetch paralelo nómina + P&G via `Promise.all`. Merge P&G por `id` sumando `val` y `ly` entre NITs → `_prPygData`
- Pool build: P&G-only items → `poolMap['PYG_'+id]`; nómina-backed se usan solo para reconciliación

#### Reconciliación P&G vs Nómina
- Flag `nomina_backed` en JSON response del endpoint P&G
- En `prRenderConceptos()`: si sin filtro CECO/cargo, para cada subgrupo nómina-backed → `diff = pygItem.val - nominaSum`; si `|diff| > 1` → inserta `ADJ_{id}` = "⚠ Ajuste contable P&G" (itálica, opacidad 0.75) con `⚠` prefix
- Ejemplo verificado: Auxilios Jan 2025 LY → P&G $14,303,129 vs Nómina $13,956,463 = $346,666 (asiento manual CC ACTIVIDADES)

#### UX & Layout
- **Loading animation**: overlay `#pr-loading-overlay` con shimmer CSS (`@keyframes pr-shimmer`), skeleton de 4 KPI cards + tabla + donut, bouncing dots. `prLoadingShow()`/`prLoadingHide()` en todos los puntos de salida de `prLoad()`
- **Tabla empleados eliminada**: `prRenderEmpleados()` reemplazada por stub vacío
- **`kpi-bar` global**: `display:none` al entrar a tab Payroll, restaurada al salir
- **KPI cards**: reducidos de 4 a 3 (elimina Total Devengos). `pr-k-gasto` actualizado desde `grandTotal` del pool en `prRenderConceptos()` — siempre igual al Total de la tabla
- **Layout tabla+donut**: `grid-template-columns: 55% 1fr` (antes `1fr 340px`)
- **Donut**: `height:420px`, etiquetas externas `{name}\n{pct}%` con `rich`, `labelLine` suave, leyenda scroll derecha, TOP 8, `radius:['38%','62%']`, `center:['40%','50%']`
- **Tabla conceptos**: `table-layout:fixed`; col Concepto 40% con `word-break:break-word`; cols numéricas 16%/9%/16%/9%

### Corregido
- `prMergeData` clone case: raw API no tiene `ss_patronal` → se construye desde `empleados[]` vía `_prBuildSsPatronal()`
- Conceptos P&G nómina-backed no se agregan a `poolMap` directamente (evita doble conteo con nómina + ajuste)

---

## [1.13.0] — 2026-05-14

### Agregado — Tab 💼 Payroll (Nómina)
- **Tab Payroll completo**: KPI cards (Empleados, Días, Devengos, Gasto Total) con comparativa año anterior y %VarLY; tabla árbol Conceptos (Grupo → Subgrupo → Concepto); gráfico donut distribución devengos; tabla detalle empleados
- **API nómina integrada**: `POST /api/v1/nomina/reporte` con `{hotel_nit, ano, mes, incluir_empleados:true}` — Auth `X-API-Key`
- **18 NITs cargados** en tabla `empresas.nit` (PostgreSQL) desde Excel `Nit Hoteles Faranda.xlsx`
- **Endpoint `GET /proxy/nomina/hoteles-nit`**: JOIN `hotel_unificacion + empresas` filtrando `nit IS NOT NULL AND hotel_name_api IS NOT NULL`
- **Tabla `nomina_conceptos`** (PostgreSQL): PK `codigo VARCHAR(40)`, campos `nombre, tipo, grupo, subgrupo, nombre_display, incluir bool`
- **Endpoint `GET /proxy/nomina/conceptos-map`**: devuelve todos los conceptos con `codigo_api` calculado via `regexp_replace(codigo, '_v[0-9]+$', '')`
- **Endpoint `POST /proxy/admin/sync-conceptos`**: lee `Conceptos_Nomina_Mapeo.xlsx` (10 cols), upsert con COALESCE para preservar grupo/subgrupo existentes
- **Script `generar_conceptos_excel.py`**: 18 NITs × 29 meses (Ene 2024–May 2026) = 522 peticiones en paralelo; detecta combinaciones únicas `(codigo, nombre)` por hotel; genera filas con sufijo `_v2/_v3` para el mismo código con distinto nombre
- **Sistema `_v2/_v3`**: mismo código API con nombre diferente en otro hotel recibe clave BD `codigo_v2`. Excel muestra col "Código BD" y "Código API". Filas en amarillo
- **Índice doble `_prConceptos`**: `_prConceptos[codigo_db]` + `_prConceptosByApi[codigo_api|nombre.upper()]`. Función `_prLookup(codigo, nombre)` busca primero por API+nombre antes de fallback por código
- **Filtro BU Belvedere / FC Bogotá**: mismo NIT (900345450), filtrado por `bu_codigo` (`BE` / `Local`). Reconstr uye conceptos, resumen y provisiones desde empleados filtrados
- **Filtro CECO/Cargo** conectado a filtros principales del header: `getCentroFilter()` + `getCargoFilter()` + normalización via `cleanCentro()`/`cleanCargo()`. Re-filtra sin re-fetch cuando solo cambia CECO/cargo; re-fetch al cambiar hotel
- **Pool LY**: conceptos que solo tienen valor en año anterior (val=0, ly>0) también aparecen en la tabla con `—` en Año Actual

### Corregido
- **DOMContentLoaded no dispara** en body tardío: inicialización Payroll movida al handler de clicks de tabs principal
- **`e.devengos` es float, no array**: reconstrucción de conceptos usa `e.conceptos[]` iterando `{codigo, nombre, cantidad, valor}`, prefijo `DX` indica deducción
- **Conceptos duplicados en dropdown** (Servihoteles ×5): query `hoteles-nit` filtra `hotel_name_api IS NOT NULL AND hotel_name_api <> ''`
- **Sync conceptos con 10 columnas**: handler detecta formato nuevo (≥8 cols) vs viejo (7 cols) por posición

### Skills creados
- `/sync-conceptos` — copia Excel al worktree y ejecuta sync endpoint
- `/regenerar-conceptos` — ejecuta `generar_conceptos_excel.py` para descubrir nuevos conceptos

---

## [1.12.0] — 2026-05-13

### Agregado
- **Endpoint `POST /proxy/admin/sync-cecos`**: lee `Unificacion_CECOs.xlsx` y actualiza `ceco_unificado` en `ceco_unificacion`. Mismo patrón que sync-hoteles/cargos. 184 mappings cargados, 0 sin mapear
- **Botón 🔄 sync CECOs**: junto al label "Centro de Costo" en el header; llama `syncCecosXlsx()` que sincroniza el Excel, recarga `CECO_MAP` y repinta los filtros
- **`syncCecosXlsx()` (JS)**: fetch POST a `/proxy/admin/sync-cecos`, luego `loadCecoMap()` + `populateFilters()` + `applyFilters()`

### Corregido
- **Gráfico Antigüedad — mes actual**: `renderAntiguedad()` detecta si es mes API (`!MODO_HISTORICO && selYear === API_MONTH.year && selMonth === API_MONTH.month`) y calcula bins directamente desde `filteredEmps._meses` en memoria, evitando la llamada a `historico_empleados` que no tiene datos del mes corriente
- **Tabla Empleados descuadrada**: `display:flex` en `<td class="td-nombre">` rompía `table-layout:fixed` desalineando header. Movido a `<div class="td-nombre-inner">` interno. Anchos de columnas corregidos: desktop 104% → 100%, tablet 103% → 100%
- **Period picker mobile — posición incorrecta**: el dropdown aparecía muy abajo porque `transform` en ancestro rompía `position:fixed`. Ahora `togglePeriodPicker()` calcula `getBoundingClientRect()` del botón trigger y aplica `top = rect.bottom + 6px` via estilos inline. `drop.style.cssText = ''` al cerrar
- **CECOs y Cargos en Title Case**: `cleanCentro()` y `cleanCargo()` aplican `toTitle()` al valor devuelto. Consistente en filtros, gráficas, tabla, Salarios CECO y drill-down

### Datos
- **CECOs mappings**: `Unificacion_CECOs.xlsx` completado con 184 mappings (A&B → Alimentos Y Bebidas, Alojamiento → Recepcion Y Reservas, etc.). Sincronizados a `ceco_unificacion` en PostgreSQL

---

## [1.11.0] — 2026-05-12

### Agregado
- **Tab Análisis — Drill-down CECOs**: click en cualquier barra de "Empleados por Centro de Costo" → desglose de cargos de ese CECO con gradiente de color + etiquetas (N y %). Botón "← Volver" restaura la vista principal. Título dinámico en el card. Hint `🔍 Clic para ver cargos` en la esquina del gráfico
- **Tab I&R — Variación Neta Mensual**: gráfico combo barras (ingresos ↑verde / retiros ↓rojo) + línea azul de variación neta. Filtra desde '2024-01'
- **Tab I&R — Variación YoY por Hotel**: barras horizontales divergentes por hotel, verde positivo / rojo negativo
- **Tab I&R — Tendencia de Retiros con Banda de Alerta**: línea de retiros + media (verde dashed) + umbral media+σ (rojo dashed) + markArea roja cuando supera umbral
- **Tab I&R — Análisis de Cohorte**: retención por cohorte trimestral; eje X = Mes 1,2,3,6,9,12; paleta azul. Endpoint `/proxy/historico/cohorte-retencion` — "nuevos" definido como NOT EXISTS en snapshot mes anterior
- **Tab I&R — Toggle Ingresos / Retiros**: una sola card con pill toggle reemplaza las dos tablas apiladas. Estado `_irTblMode`, función `irTblToggle()`, paneles `ir-panel-ing` / `ir-panel-ret`
- **Endpoint `/proxy/historico/ir-mensual`**: agrupa `historico_ir` por mes, devuelve `{mes, ingresos, retiros}`
- **Endpoint `/proxy/historico/cohorte-retencion`**: cohortes desde `historico_empleados` con NOT EXISTS, LEFT JOINs a N meses posteriores, devuelve `pct_1`…`pct_12` filtrando cohortes con `total >= 8`

### Cambiado
- **Tab Análisis — Top 15 Cargos más Frecuentes eliminado**: card, section-divider, función `renderCargos()` y CSS `#ch-cargos` removidos por completo
- **Tab Resumen — orden de gráficas**: "Empleados por Hotel" sube a la primera fila (junto a CECOs). "Distribución Tipo de Contrato" baja a la sección "Distribución por Hotel" (junto a Pirámide Poblacional)
- **Tab Análisis — CECOs — degradado por rango**: RANK_BLUES (14 tonos azul oscuro → claro); el bar más alto recibe `#001A5A`, el más bajo `#CCE0F7`
- **Tab Análisis — CECOs — más barras**: `maxBars` de 14 → 20 en desktop
- **Tab Análisis — CECOs — altura dinámica**: `26px × n` barras reales (mín 280px); elimina espacio en blanco al fondo
- **Tab Análisis — CECOs — `barMaxWidth` eliminado**: las barras se dimensionan automáticamente; `barCategoryGap` reducido a `20%`
- **Tab Resumen — Distribución Tipo de Contrato**: `#card-contrato` flex-column + `#ch-contrato` flex:1/height:auto — el donut llena todo el alto del card sin espacio sobrante. `c.resize()` con setTimeout 50ms tras render
- **Tab I&R — layout**: tabla (toggle) PRIMERO, luego 3 filas de gráficos (rotación+causas / neta+yoy / tendencia+cohorte)
- **Tab I&R — tabla max-height**: `380px → 330px`

### Corregido
- **`RANK_BLUES` typo**: `'99C2E8'` corregido a `'#99C2E8'`
- **Leyendas Top Cargos sin color correcto**: series ECharts → `itemStyle.color` a nivel de serie (no de dato) para que la leyenda tome el color del grupo funcional
- **`ir-mensual` devolvía fechas históricas (2005, 2016)**: filtrado client-side `rows.filter(r => r.mes >= '2024-01')`
- **`rotacion-heatmap` — `%` en ILIKE rompía psycopg2**: corregido a `ILIKE '%%retiro%%'`
- **`cohorte-retencion` — `codigo_empleado` no existe en `historico_ir`**: rediseñado para usar solo `historico_empleados` con NOT EXISTS

---

## [1.10.0] — 2026-05-11

### Agregado
- **MODO_HISTORICO**: flag `const MODO_HISTORICO = true` que desconecta completamente la API externa. Cuando está activo: `loadData()` llama `loadHistoricoEmps()`, `onPeriodChange`/`loadSalarios`/`irUsaApi` nunca tocan la API, `API_MONTH` apunta a `IR_HIST_MAX`. Para reconectar: cambiar a `false`
- **KPI Activos Fin Mes — LY**: añadido `id="k-fin-sub"` y actualización con `sub(activosFin, activosFinLY)` — misma comparativa que los demás KPIs del header
- **Buscador tabla Empleados — botón ✕**: botón circular MD3 que aparece al escribir; `tblSearchClear()` limpia, oculta X y devuelve foco; `tblSearchToggleClear()` controla visibilidad

### Mejorado — Material Design 3
- **Tipografía**: fuente Roboto (Google Fonts), `letter-spacing: 0.01em`, `-webkit-font-smoothing: antialiased`
- **Cards**: `border-radius 16px`, sin borde, shadow doble capa MD3; hover con elevación mayor
- **card-title**: sin `border-bottom` — solo acento izquierdo (`border-radius: 4px`)
- **KPI cards / Gender card**: `border-radius 16px`, sin borde, elevation shadow; contenido centrado con ícono lateral
- **Stat cards**: eliminada barra de gradiente superior (`::before`), shadow MD3
- **Mini-KPI**: `border-radius 16px`, sin borde, shadow
- **Filtros** (cs-trigger, period-trigger, filter-select-native): pill `border-radius: 20px`
- **Botones** (retry, sync-ir, clear-sm, sal-y/m-btn): pill 20px, `font-weight: 500`
- **Tabs**: `padding 13px 24px`, `font-weight 500` inactivo / `700` activo, `letter-spacing`
- **Search input tabla**: pill 20px, focus ring `box-shadow: 0 0 0 3px rgba(0,51,160,0.08)`
- **tbl-wrap / ceco-table-wrap**: `border-radius 12px`, sin borde externo, shadow
- **thead th / ceco th**: `padding 11px`, `font-weight 500`, `letter-spacing 0.04em`
- **tbody td / ceco td**: `padding 9px` (filas más altas)
- **vbadge**: pill 20px, `font-weight 500`
- **sal-controls**: `border-radius 16px`, sin borde, shadow
- **Scrollbar**: `6px`, `border-radius 10px`, hover color

---

## [1.9.0] — 2026-05-08

### Mejorado
- **Tab Salarios CECO — Tab Salarios CECO desktop**: `ceco-table-wrap` con `max-height: 480px` + `overflow-y: auto` y `thead th` sticky — la tabla árbol ya no crece sin límite
- **Tab Salarios CECO — Leyenda combo chart**: `legend type:'scroll'` + `grid.top: 46` — evita que la leyenda haga wrap sobre el gráfico
- **Tab Ingresos y Retiros — Sort por columnas**: tablas de Ingresos y Retiros ahora ordenables por cualquier columna (click en encabezado); estado de sort con indicador ▲/▼; cache `_irIngCache`/`_irRetCache` para re-ordenar sin refetch
- **Tab Ingresos y Retiros — toTitle() en nombres**: nombres de empleados en Title Case en ambas tablas
- **Tab Ingresos y Retiros — `ir-table-wrap` altura**: ampliada a `max-height: 380px` desktop / 320px tablet / 260px mobile
- **Tab Ingresos y Retiros — botón sync**: botón 🔄 Actualizar histórico con clase `btn-sync-ir`

### Corregido
- **Tab Análisis — donut Distribución Tipo de Contrato espacio en blanco**: `#ch-contrato { height: 330px }` override + `radius: ['32%','74%']` + `center: ['40%','52%']` — elimina espacio muerto
- **Tab Análisis — barras Distribución por Rango de Edad espacio en blanco**: `#ch-edad { height: 320px }` + `grid.top: 24` + `barMaxWidth` y `barCategoryGap` ajustados
- **Tab Salarios CECO — donut Distribución % CECO**: `radius: ['30%','62%']`, etiquetas externas con `labelLine` (igual a Tipo de Contrato), rich text nombre+%, umbral ≥ 5% por ítem para ocultar label y labelLine en slices pequeños
- **Tab Análisis — donut Empleados por Hotel**: etiquetas y líneas solo para hoteles ≥ 5% del total — elimina solapamiento en 22 hoteles
- **Tab Análisis — tooltip Distribución por Género y Hotel**: muestra Masculino N (X%) / Femenino N (X%) / Total N empleados — porcentaje relativo al total del hotel
- **IR_DESEADA_NORM**: eliminado duplicado `'vencimiento de contrato'`

### Mejorado — Matriz Detalle por Centro de Costo
- **toTitle()** en nombres de CECO (L1, L1b) y Cargo (L2) — Title Case consistente
- **Acento visual**: `border-left: 3px` en filas CECO (ocean-blue), sub-CECOs de Otros (sky-blue), fila Otros (gris)
- **L1b** background `#f0f5fc → #f7f9fe` — mayor contraste con L1
- **Toggle button** `border-radius: 4px → 50%` — botón circular moderno
- **Padding empleados (L3)**: `46px → 54px`, L3b `62px → 70px`

---

## [1.8.0] — 2026-05-08

### Corregido
- **Tab Empleados — Nombres en ALL CAPS**: añadido helper `toTitle(str)` que aplica Title Case a los nombres de empleados. Se usa en la tabla desktop, las tarjetas mobile y el dropdown del filtro Empleado
- **Sort por Cargo inconsistente con lo mostrado**: `getV(e, 3)` usaba `e.cargo` raw para ordenar, pero la columna mostraba `cleanCargo(e.cargo)`. Corregido para ordenar también por el valor limpio
- **Tab Salarios CECO — Nombres en ALL CAPS en la matriz y el bottom sheet**: `p.nombre` en `salRenderMatriz` y `cecoSheetOpen` ahora usa `toTitle(p.nombre)`

### Mejorado
- **Tab Empleados — Badge de sexo (M/F) inline en columna Nombre (desktop)**: pequeño badge de color junto al nombre de cada empleado — consistente con las tarjetas mobile
- **Tab Empleados — `title` por celda**: las columnas Nombre, Hotel, Centro de Costo y Cargo muestran el texto completo al hover, no solo el `<tr>` título general
- **Tab Empleados — barra de búsqueda más ancha**: `max-width: min(420px, 40%)` en lugar de 300px fijo para pantallas grandes

---


## [1.7.0] — 2026-05-08

### Corregido
- **KPI "Activos Inicio Mes" con filtro CECO mobile devolvía conteo incorrecto**: `getMQServerParams` ignoraba `_mSearchType` y usaba heurístico de cargos primero — al buscar "BUSINESS INTELLIGENCE", el cargo unificado coincidía y se enviaba `cargo=` en vez de `centro=`, devolviendo 4 en vez de 3 (incluía al Director de otro CECO). Ahora la función respeta el tipo de sugerencia seleccionado; para texto libre el nuevo orden de prioridad es CECO → cargo → nombre
- **`_where_cargo` — comparación case-sensitive en subquery rompía filtros sobre datos con capitalización mixta**: la cláusula `cargo IN (SELECT cargo_original ...)` fallaba para datos de Abril 2026 que llegaron con `'Analista Business Inteligence'` (mixto) cuando `cargo_unificacion.cargo_original` almacena `'ANALISTA BUSINESS INTELIGENCE'` (mayúsculas). Corregido a `UPPER(cargo) IN (SELECT UPPER(cargo_original) ...)` — insensible a capitalización en ambos lados
- **Sugerencias mobile: hotel añadido como tipo de búsqueda** (`type: 'hotel'`, tag naranja). Al elegir un hotel desde el dropdown, `_mSearchType = 'hotel'` y el filtro de cards aplica solo sobre `e._hotel`
- **`applyFilters` mobile — filtro exacto por campo**: cuando el usuario elige una sugerencia tipificada (ceco/cargo/hotel/emp), el filtro de cards aplica únicamente al campo correspondiente, no con OR sobre todos los campos
- **`mBuildSuggest` usa `filteredEmps`** (no `allEmps`) para construir sugerencias, respetando los filtros de header activos
- **Barra de búsqueda duplicada en mobile**: el segundo buscador (`.tbl-controls`) quedaba visible por error de orden en el CSS; movido al bloque `@media (max-width: 640px)` correcto
- **Desbordamiento de texto en emp-cards**: agregado `min-width: 0` en `.emp-card-field` para que el grid pueda comprimir; fuentes con `clamp()` adaptativas; badges y valores con `word-break: break-word`

### Agregado
- **Responsive mobile — Tab Análisis**: gráficos redimensionados (alturas específicas por gráfico), `stat-col` en grilla 2 columnas, separadores de sección, Top 10 Cargos visibles en mobile
- **Responsive mobile — Tab Empleados**: cards con borde de color por tipo de contrato (indefinido/fijo/SENA), sort-bar mobile, campo F.Vence condicional (solo contratos no indefinidos), tabla y buscador desktop ocultos en mobile
- **LaunchAgent dev (puerto 8081)**: `com.bia.dashboard-talento-dev.plist` — inicia automáticamente el servidor del worktree al encender el Mac

---

## [1.6.0] — 2026-05-07

### Agregado
- **Multi-select Razón Social, Centro de Costo y Cargo**: los tres filtros aceptan selección múltiple con checkboxes. Muestra "N seleccionados" en el trigger; "Todas" limpia la selección. Aplica en todos los tabs (Análisis, Salarios CECO, Ingresos y Retiros)
- **`MultiSelect`**: nueva clase JS independiente de `CustomSelect`, con `getValues()`, `isEmpty()`, `reset()` y `refresh()`. Usada en `f-hotel`, `f-centro` y `f-cargo`
- **`getHotelFilter()`**: helper que devuelve array de hoteles seleccionados (vacío = Todos)
- **`getCentroFilter()`**: helper que devuelve array de centros de costo seleccionados
- **`getCargoFilter()`**: helper que devuelve array de cargos seleccionados
- **`getHotelRazonesMulti()`**: helper que consolida las `razon_social` de todos los hoteles seleccionados para los endpoints históricos
- **servidor.py — `_parse_list()`**: parsea parámetro CSV en query string a lista de valores
- **servidor.py — `_where_in()`**: genera cláusula `UPPER(col) IN (...)` dinámica con N placeholders
- **servidor.py — `_where_cargo()`**: genera cláusula que busca cargo original o unificado en múltiples valores

### Cambiado
- **Tab Análisis — Empleados por Hotel**: reemplaza barras horizontales por **donut** con leyenda scrollable a la derecha (desktop) y **barras horizontales** descendentes en mobile
- **Tab Análisis — Top 15 Cargos más Frecuentes**: reemplaza barras horizontales por **embudo** con gradiente azul oscuro → claro y etiquetas blancas internas (nombre + cantidad por franja)
- **Tab Análisis — Empleados por Centro de Costo**: ancho del eje Y calculado dinámicamente según el nombre más largo del dataset — elimina truncaciones
- **Tab Salarios CECO — gráfico combo**: escala eje Y parte desde `minBase × 0.4` (no desde 0) — elimina el efecto de líneas pegadas al suelo
- **`fmtM()`**: tooltip dinámico — muestra M (millones), K (miles) o valor raw según magnitud
- **Filtro Razón Social por defecto**: arranca en "Todas" en lugar de "Hotel Caribe"
- **`rebuildCentros()`**: migrada a multi-select; preserva selecciones válidas en cascada hotel → centro → cargo → empleado actualizando `_selected` directamente antes de `refresh()`
- **`applyFilters()`**: filtra por arrays (`centros.length`, `cargos.length`) en lugar de valores únicos
- **`irFilterRows()`**: usa `getCargoFilter()` array
- **`irFetchHistorico()`**: pasa cargos como lista separada por coma
- **`fetchIRData()`**: cache key incluye `getCentroFilter()` y `getCargoFilter()`; hotel usa `getHotelFilter()` en lugar de `.value`
- **`loadHeaderKPIs()`**, **`loadIR()`**, **`loadSalarios()`**: pasan `centro` y `cargo` como listas CSV a los endpoints históricos
- **Endpoints `salarios`, `matriz`, `activos-count`, `ir`**: aceptan listas CSV para `centro` y `cargo` — generan cláusulas `IN (...)` dinámicas via `_parse_list` + `_where_in` / `_where_cargo`
- **`clearFilters()`**: usa `csMap[id].reset()` para todos los filtros multi-select

---

## [1.5.0] — 2026-05-04

### Agregado
- **Carga histórica Abril 2026 — `historico_empleados`**: 1.021 registros insertados. Tabla pasa de 38.891 (Mar 2026) a 39.912 registros (52 meses, Ene 2022–Abr 2026). Nota: `fecha_nacimiento` viene en formato string largo en este mes ("Friday, March 12, 2004") — se parsea con `pd.to_datetime` al cargar
- **Carga histórica Abril 2026 — `historico_ir`**: 109 movimientos insertados (ingresos + retiros). Tabla pasa de 4.864 a 4.929 registros

### Cambiado
- **`API_MONTH` y `SAL_MAX` dinámicos**: reemplazados los valores hardcodeados (`month: 5`) por `new Date()` en JS. El selector de período se actualiza automáticamente cada mes sin intervención manual
- **`selYear` y `selMonth`** inicializan desde `API_MONTH` detectado automáticamente

---

## [1.4.0] — 2026-04-30

### Agregado
- **BU_HOTEL_MAP**: nuevo mapa JS `"hotel_name_api|bu_nombre" → nombre_personalizado` para split correcto de hoteles que comparten el mismo `hotel_name_api` en la API
  - `Faranda_Bogota | CLUB HOUSE` → FC Bogota
  - `Faranda_Bogota | BELVEDERE` → Belvedere
  - `Hotel_Canabal | Local` → Casa Canabal
  - `Hotel_Canabal | Faranda Cucuta` → FC Cucuta
  - `GHS | Local` → GHS
  - `GHS | The Club` → The Club
- **LaunchAgent macOS**: servidor `servidor.py` configurado para iniciar automáticamente al encender el Mac (`~/Library/LaunchAgents/com.bia.dashboard-talento.plist`, `KeepAlive: true`)
- **Filtros globales en todos los tabs**: los filtros de Cargo, Empleado y Centro de Costo ahora aplican en todos los tabs del dashboard (Resumen, Análisis, Empleados, Salarios CECO, Ingresos y Retiros)
- **CSS responsive completo**: breakpoints implementados en todos los tabs
  - `≤1024px` (tablet): columnas colapsadas, gráficos redimensionados
  - `≤640px` (mobile): KPI bar vertical, tablas con scroll horizontal
  - `≤380px` (small mobile): reducción adicional de padding y fuentes
- **irFilterRows()**: función JS centralizada para filtrar filas de Ingresos/Retiros por hotel, cargo y empleado en el cliente
- **Repositorio Git**: inicialización del control de versiones del proyecto

### Cambiado
- **Lógica Activos Inicio/Fin**: corregida para usar snapshot del mes anterior como "Inicio" y snapshot del mes seleccionado como "Fin" (antes tomaba el mismo mes para ambos)
- **BU_HOTEL_MAP en loadData()**: ambos bloques de carga de empleados (API mes actual e histórico) usan `BU_HOTEL_MAP` con prioridad sobre `HOTEL_MAP`
- **NOMBRE_RAZONES_MAP en dropdown**: el selector de hotel siempre muestra todos los hoteles definidos en el mapeo, independientemente de si hay empleados cargados en el período seleccionado (fix para FC Bogota que no aparecía)
- **populateFilters()**: dropdown de hotel combina hoteles de `allEmps` + todos los keys de `NOMBRE_RAZONES_MAP` para garantizar visibilidad completa
- **activos-count endpoint**: acepta nuevos params `centro`, `cargo`, `nombre` para filtrado granular
- **historico/ir endpoint**: acepta nuevos params `cargo`, `nombre`
- **historico/salarios endpoint**: acepta nuevos params `centro`, `cargo`, `nombre`
- **historico/matriz endpoint**: acepta nuevos params `centro`, `cargo`, `nombre`

### Corregido
- FC Bogota no aparecía en el dropdown de Razón Social por conflicto de prioridad en `HOTEL_MAP` (Belvedere tenía más activos_api y sobreescribía el mapeo de Faranda_Bogota)
- Filtros de cargo y empleado no filtraban el tab de Ingresos y Retiros ni los endpoints históricos
- `loadHeaderKPIs` y `loadIR` usaban fechas inconsistentes para calcular activos de inicio de mes

---

## [1.3.0] — 2026-04-27

### Agregado
- **Tab Ingresos y Retiros**: nuevo tab completo con:
  - Banner KPI: Activos Inicio, Ingresos, Retiros, Activos Fin, % Rotación Deseada y % Rotación Indeseada — todos con LY y variación
  - Tabla de Ingresos: Nombre · Hotel · Cargo · Tipo Contrato · Fecha Ingreso · Fecha Término
  - Gráfico de Rotación: barras Deseada vs Indeseada con % anualizado
  - Gráfico de Causas: donut con distribución por causa de retiro
  - Tabla de Retiros: Nombre · Hotel · Cargo · Tipo Contrato · Fecha Retiro · Razón · Tiempo de servicio
- **Clasificación de rotación**:
  - Deseada: Vencimiento de Contrato, Justa Causa, Term. Aprendizaje
  - Indeseada: Voluntario, Sin Justa Causa, Injusta Causa
- **Fórmula anualizada**: `(retiros_tipo / activos_inicio) × 12 × 100`
- **Endpoint `/proxy/historico/ir`**: ingresos y retiros desde `historico_ir` con soporte LY
- **Endpoint `/proxy/historico/activos-count`**: conteo de activos por snapshot de fecha con filtros
- **`irFlattenRows()`**: aplana estructura `hoteles[] → unidades_negocio[] → rows[]` de la API externa
- **Caché de IR por período**: evita doble carga al cambiar filtros sin cambiar período

### Cambiado
- Período global (`selYear`/`selMonth`) controla todos los tabs simultáneamente
- Filtros de header en cascada: Razón Social → Centro de Costo → Cargo → Empleado → Operación
- Flag `_periodChanging` para evitar cargas concurrentes al cambiar período

---

## [1.2.0] — 2026-04-25

### Agregado
- **Tab Salarios CECO**: visualización de salarios por centro de costo con comparativo LY
  - Gráfico combo: barras de salario actual vs LY + línea de variación %
  - Donut: distribución de masa salarial por CECO
  - Tabla detalle: CECO · Unificado · Empleados · Salario Actual · LY · Variación
- **Endpoint `/proxy/historico/salarios`**: salarios por CECO desde `historico_empleados` con LY
- **Endpoint `/proxy/historico/matriz`**: árbol CECO → Cargo → Empleado
- **Endpoint `/proxy/historico/cecos-map`**: tabla `ceco_unificacion` completa
- **Endpoint `/proxy/historico/cargos-map`**: tabla `cargo_unificacion` completa
- **Endpoint `/proxy/historico/hoteles-map`**: tabla `hotel_unificacion` completa con `bu_nombre`
- **Tabla `ceco_unificacion`**: mapeo de CECOs originales a nombres unificados
- **Tabla `cargo_unificacion`**: mapeo de cargos originales a cargos normalizados (`CARGO_MAP` en JS)
- **Tabla `hotel_unificacion`**: mapeo completo con `bu_nombre`, `bu_codigo`, `nombre_personalizado`, `activos_api`
- **Sync desde Excel**: endpoints `POST /proxy/admin/sync-hoteles` y `POST /proxy/admin/sync-cargos`
- **`cleanCargo()`**: normalización de nombres de cargo usando `CARGO_MAP`
- **SOCIOS_IDS**: Set de identificaciones marcadas como Socio para clasificación de operación

### Cambiado
- `HOTEL_MAP` prioriza entrada con mayor `activos_api` para evitar sobrescritura por hoteles inactivos
- `RAZON_NOMBRE_MAP` y `NOMBRE_RAZONES_MAP` construidos desde `hotel_unificacion` en lugar de hardcode

---

## [1.1.0] — 2026-04-24

### Agregado
- **PostgreSQL — Base de Datos Talento Humano**: carga de 38,891 registros históricos (Enero 2022 — Marzo 2026)
  - Tabla `historico_empleados`: snapshot mensual de empleados activos (51 meses, 61 archivos Excel consolidados)
  - Tabla `empresas`: 27 códigos de razón social
- **Endpoint `/proxy/historico/empleados`**: empleados históricos por fecha con `COALESCE` de `fecha_nacimiento` e `INITCAP` de sexo
- **Endpoint `POST /proxy/historico/cecos-sync`**: inserción automática de CECOs nuevos detectados en la API
- **Proxy `razon_where()`**: helper para construir cláusula `IN (...)` dinámica desde razones sociales separadas por coma
- **`getHotelRazones()`**: devuelve lista de `razon_social` para un `nombre_personalizado` dado
- **Paleta cromática Faranda Hotels**: variables CSS basadas en Brand Guidelines Marzo 2024
  - `--deep-blue: #001A5A` · `--ocean-blue: #0033A0` · `--sky-blue: #87BAE6` · `--gold: #D59F00`

### Cambiado
- Período Abr 2026 en adelante = fuente API externa; meses anteriores = PostgreSQL histórico
- `calcAge()` y `calcMeses()` calculados desde `fecha_nacimiento` y `fecha_ingreso` respectivamente
- `tipoContrato()` infiere tipo de contrato desde fechas cuando la API no lo provee

---

## [1.0.0] — 2026-04-22

### Agregado
- **Dashboard base** (`dashboard-empleados.html`): interfaz HTML puro con ECharts 5.4.3
- **Servidor proxy** (`servidor.py`): Python `http.server` en puerto 8080, resuelve CORS para API externa
- **Autenticación API**: header `X-API-Key` hacia `https://api.faranda...`
- **Endpoints proxy base**:
  - `GET /proxy/rrhh/...` — proxy transparente a API externa
  - `POST /proxy/rrhh/ingresos-retiros` — proxy POST con body a API de movimientos
- **Tab Resumen**: KPIs principales de empleados activos (total, género, promedio edad, antigüedad, salario)
- **Tab Análisis**: distribución por cargo, centro de costo, tipo de contrato, antigüedad
- **Tab Empleados**: tabla paginada con detalle de cada empleado activo
- **Filtros header**: Razón Social, Centro de Costo, Cargo, Empleado, Operación, Período
- **Mapas de hoteles base**:
  - `HOTEL_MAP` (hotel_name_api → nombre_personalizado)
  - `HOTEL_EXCLUIDOS` (ELMA, FNS marcados como "No mostrar")
  - `RAZON_NOMBRE_MAP` (razon_social → nombre_personalizado)
- **Operación default**: "En Operacion" al cargar el dashboard
- **`cleanHotel()` y `cleanCentro()`**: normalización de nombres de hotel y CECO

---

*Mantenido por BIA — Business Intelligence Faranda Hotels*
