# Servidor RinoTrack

## Acceso

- **IP:** 18.214.40.71
- **Usuario SSH:** bitnami
- **Llave:** `rinotrack.pem` (en la raíz del proyecto)
- **Stack:** Bitnami (Apache + MariaDB), PHP servido directo por Apache (sin build ni reinicio)

Conexión:

```bash
ssh -o StrictHostKeyChecking=no -i rinotrack.pem bitnami@18.214.40.71
```

## Rutas importantes en el servidor

- **App en producción (la que sirve Apache):** `/opt/bitnami/apache/htdocs/desarrollo/rinotrack`
  - Es un directorio plano, **NO es repositorio git**. Se despliega copiando archivos.
  - El docroot público es `desarrollo/rinotrack/public`.
- Existen varias copias/backups en `htdocs` (desarrollobkabdiel, alfa, etc.) y en `/home/bitnami/deploy/rinotrack`. La única productiva es `desarrollo/rinotrack`.

## Base de datos

- **Motor:** MariaDB 11.8 (cliente `mysql`/`mariadb`)
- **Host:** localhost (solo accesible desde el propio servidor)
- **Usuario:** root
- **Password:** `/VFwtcC6Xj18`
- **Base:** rinotrack

Consulta rápida desde la terminal local (a través de SSH, usando un archivo `.sql`):

```bash
# 1) Escribe tu consulta en un archivo local tmp_query.sql
# 2) Ejecútala en el servidor:
ssh -o StrictHostKeyChecking=no -i rinotrack.pem bitnami@18.214.40.71 \
  "mysql -uroot -p'/VFwtcC6Xj18' rinotrack -t" < tmp_query.sql 2>&1 | grep -v Deprecated
```

Notas:
- Se usa `-t` para salida en tabla y se filtra la advertencia "Deprecated program name" con `grep -v Deprecated`.
- Enviar el SQL por archivo (redirección `<`) evita los problemas de escapado de comillas al usar `-e "..."` a través de SSH.
- Los nombres de tabla usan mayúsculas iniciales: `Users`, `Tasks`, `Subtasks`, `Projects`, `Clans`, `Clan_Members`, `User_Active_Clan`, etc.

## Forma de despliegue (deploy)

Como producción no es un repo git, el despliegue se hace **copiando el/los archivos modificados por SCP**, siempre respaldando primero.

1. Validar sintaxis del archivo localmente:

```bash
php -l app/controllers/ClanLeaderController.php
```

2. Respaldar el archivo en el servidor (con fecha en el nombre):

```bash
ssh -o StrictHostKeyChecking=no -i rinotrack.pem bitnami@18.214.40.71 \
  "cp /opt/bitnami/apache/htdocs/desarrollo/rinotrack/app/controllers/ClanLeaderController.php \
      /opt/bitnami/apache/htdocs/desarrollo/rinotrack/app/controllers/ClanLeaderController.php.bak_YYYYMMDD"
```

3. Subir el archivo corregido:

```bash
scp -o StrictHostKeyChecking=no -i rinotrack.pem \
  app/controllers/ClanLeaderController.php \
  bitnami@18.214.40.71:/opt/bitnami/apache/htdocs/desarrollo/rinotrack/app/controllers/ClanLeaderController.php
```

4. Verificar en el servidor (md5 debe coincidir con el local, sintaxis OK):

```bash
# md5 local (macOS):
md5 -q app/controllers/ClanLeaderController.php

# md5 + lint en el servidor:
ssh -o StrictHostKeyChecking=no -i rinotrack.pem bitnami@18.214.40.71 \
  "cd /opt/bitnami/apache/htdocs/desarrollo/rinotrack && \
   md5sum app/controllers/ClanLeaderController.php && \
   php -l app/controllers/ClanLeaderController.php"
```

5. No hace falta reiniciar Apache: PHP se interpreta en cada request. El cambio queda activo de inmediato.

Rollback: restaurar el `.bak_YYYYMMDD` sobre el archivo original con `cp`.

## Sistema de diseño (tema ClickUp)

Desde el 2026-08-30 la interfaz usa un sistema de tokens único, sin build step.

Hojas nuevas, en este orden de carga (siempre **al final del `<head>`**):

1. `public/assets/css/clickup-tokens.css` — paleta clara y oscura + alias que reapuntan
   las familias heredadas (`--monday-*`, `--admin-*`, `--cm-*`, `--ticket-*`, `--sidebar-*`).
2. `public/assets/css/clickup-theme.css` — componentes: botones, formularios, tarjetas,
   tablas, modales, badges, kanban, pestañas, sidebar y scrollbars.
3. `public/assets/css/clickup-screens.css` — ajustes por pantalla.
4. `public/assets/css/clickup-dark-fixes.css` — neutraliza los `style=""` que fijan blanco.

Reglas para no romper el tema al editar CSS:

- Los alias usan el selector `html[data-theme]`, no `:root`. Es intencional: varias vistas
  cargan `monday-theme.css` **dentro del `<body>`**, o sea después del `<head>`, y con
  `:root` ganarían por orden de carga. `html[data-theme]` tiene mayor especificidad y gana
  siempre, sin `!important`.
- En `clickup-screens.css` cada selector va precedido de `html[data-theme]` por el mismo motivo.
- Nunca sustituir literales de color que consuma JavaScript (Chart.js y similares):
  esas librerías no resuelven variables CSS.

Modo claro/oscuro:

- El tema vive en `data-theme` sobre `<html>` (valores `light` o `dark`).
- Se persiste en `localStorage` con la clave `rinotrack-theme`.
- El parcial `app/views/components/theme-head.php` incluye el script anti-parpadeo que fija
  el tema antes del primer pintado; `theme-scripts.php` carga el conmutador.
- El botón está en el pie del sidebar. **No** lleva la clase `.sidebar-link` a propósito,
  porque `sidebar.js` cierra el menú móvil al pulsar cualquier `.sidebar-link`.

Para añadir una pantalla nueva con HTML propio, incluir los dos parciales:

```php
<?php require __DIR__ . '/../components/theme-head.php'; ?>   <!-- al final del <head> -->
<?php require __DIR__ . '/../components/theme-scripts.php'; ?> <!-- antes de </body> -->
```

## Módulo de Encuestas

Desde el 2026-09-01 Polaris tiene un módulo de Encuestas en el menú lateral. **Los
datos no viven en `rinotrack`**, sino en la base `notification_system`, que se
comparte con RinoMailer (la app de `/notificaciones`).

### Por qué se comparte la base

RinoMailer es una aplicación aparte, con su propio router y su propio login
(tabla `admins`). Sus encuestas ya estaban en uso —94 encuestas y más de 2,400
respuestas— y los enlaces para responder ya estaban repartidos. Migrar los datos
habría roto esos enlaces, así que Polaris se conecta a la misma base:

- **Polaris** crea, edita y analiza las encuestas.
- **RinoMailer** sigue sirviendo el formulario público en `/notificaciones/survey/{id}`.

```
Polaris  ──> notification_system.surveys <── RinoMailer (enlace público)
   │                     │
   └── rinotrack.Users ──┘  (JOIN entre esquemas, por owner_user_id)
```

### Segunda conexión de base de datos

`Database::getSurveyConnection()` en `config/database.php` devuelve un PDO
apuntando a `SURVEY_DB_NAME` (`notification_system`). Desde esa conexión, las
tablas de Polaris se alcanzan con el prefijo `DB_NAME`; ambos esquemas están en
el mismo servidor MariaDB, así que el JOIN entre ellos funciona sin más. Si la
conexión falla, el método devuelve `null` y el resto de Polaris sigue vivo.

`SURVEY_PUBLIC_URL` en `config/app.php` arma la raíz del enlace público. Cuelga
del host, no de la ruta de Polaris, porque lo sirve la otra aplicación.

### Quién ve qué

| Quién | Alcance |
|---|---|
| Super administrador y administrador | Todo, incluidas las **históricas** |
| Usuario con el módulo activado | Las suyas y las de sus compañeros de clan |
| Cualquier otro | Nada; el menú no aparece y las rutas devuelven 403 |

Los clanes salen de `User::getUserClans()`, que los lee de `Clan_Members`. Un
usuario de Marketing nunca ve la encuesta de comedor de Recursos Humanos.

**Encuestas históricas:** las 94 creadas en RinoMailer antes de la integración no
tienen propietario (`owner_user_id` nulo, `source = 'rinomailer'`). Solo las ve
el super administrador, con una etiqueta que las identifica. Las que se sigan
creando desde RinoMailer caerán en la misma categoría.

### Activación por usuario

La concede **solo el super administrador**, desde `admin/users`: una casilla en el
modal de edición y un botón de encendido rápido en cada fila. Se guarda en
`Users.surveys_enabled`. El endpoint `admin/toggle-surveys-access` valida el rol
de forma explícita e ignora el campo si lo manda cualquier otro.

### Vigencia programada

`surveys.opens_at` y `surveys.closes_at` abren y cierran la encuesta sola. La
validación vive en el `SurveyController` de RinoMailer, tanto en `publicView()`
como en `submit()`, porque alguien puede dejar el formulario abierto y enviarlo
después del cierre. Sin fechas, la encuesta queda siempre abierta, que es como se
comportaba antes.

### Archivos

| Archivo | Rol |
|---|---|
| `app/models/Survey.php` | Consultas, alcance por clan, estadísticas |
| `app/controllers/SurveyController.php` | Acciones, permisos, exportación |
| `app/views/surveys/` | Listado, constructor, resultados, reporte, aviso |
| `app/views/components/surveys_menu.php` | Grupo desplegable del sidebar |
| `public/assets/css/surveys.css` | Estilos sobre los tokens ClickUp |
| `notificaciones/controllers/SurveyController.php` | Copia versionada del archivo de RinoMailer que se modificó |
| `migrations/2026_09_01_surveys_module.sql` | Las dos migraciones |
| `migrations/2026_09_03_survey_meals.sql` | Encuestas de comedor |

Al editar una encuesta que ya tiene respuestas, el constructor se bloquea: guardar
el cuestionario borra y reinserta las preguntas, y el borrado en cascada se
llevaría las respuestas. Solo se editan los datos generales.

### Encuestas de comedor

Un segundo tipo de encuesta (`surveys.survey_kind = 'comedor'`) donde cada
respuesta afirmativa se convierte en una deducción de nómina.

```
RH crea la encuesta         Empleado responde          RH calcula el período
(congela la tarifa)   -->   (con sesión, en Polaris)   -->  (líneas automáticas)
```

Tres decisiones sostienen el diseño:

- **El formulario vive en Polaris** (`?route=surveys/respond&id=`), no en el
  enlace público de RinoMailer. Ese enlace es anónimo y no puede identificar al
  empleado, y sin identificarlo no hay a quién cobrarle. Las encuestas marcadas
  con `requires_login` se rechazan en `publicView()` y `submit()` de RinoMailer,
  que muestra un botón hacia Polaris.
- **Las opciones las escribe el sistema** (`Sí, lo quiero` / `No, gracias`) y el
  tipo queda fijo en opción única. RH solo redacta el día y el platillo. Si RH
  pudiera escribir las opciones, no habría manera inequívoca de contar consumos.
- **La tarifa se congela al crear la encuesta** en `meal_cost_desayuno` y
  `meal_cost_comida`. El motor de nómina nunca lee la tarifa vigente, así que
  subir el precio del comedor no cambia lo que ya se cobró.

El período destino es el que contiene `surveys.created_at`, es decir la semana en
que se lanzó. Puede no existir todavía: `calculatePeriod()` busca las encuestas
por rango de fechas, así que el cobro entra solo cuando RH cree el período.

Las líneas `DESC_DESAYUNO` y `DESC_COMIDA` se insertan con `origen = 'automatico'`
dentro del motor, no como conceptos manuales: así se regeneran en cada recálculo
(si alguien cambia su pedido) y respetan el bloqueo de períodos cerrados.

El índice único `(survey_id, respondent_user_id)` hace que el empleado corrija su
pedido en lugar de acumular respuestas, que es lo que duplicaría el cobro.

Las tarifas se editan en `?route=surveys/meal-settings` pero se guardan en
`Payroll_Settings`, porque son política de nómina; la pantalla exige
`payroll_enabled` además del acceso a Encuestas.

#### Horario de comida

Toda encuesta que cobre **comida** lleva al final una pregunta obligatoria de
horario que agrega el sistema. Las de desayuno no la llevan: fue una regla
explícita del cliente.

Se distingue por `survey_questions.meal_service = 'horario'`, que no es un
servicio sino una marca de "informativa, no se cobra". Por eso
`Survey::servicioDePregunta()` devuelve NULL para ella aunque la encuesta tenga
un solo servicio: sin esa salida temprana, el fallback que hace heredar el
servicio único la habría contado como un consumo más.

Los horarios salen de `Payroll_Settings.horarios_comida` (lista separada por
comas) y se copian a las opciones de la pregunta al crear la encuesta, así que
quedan congelados igual que el precio.

El empleado elige **un horario para toda la semana**, no uno por día, y solo se
le exige si pidió al menos un día de comida.

### Menú desplegable del sidebar

Es el primer grupo tipo acordeón del sidebar. El disparador es un `<button>` **sin
la clase `.sidebar-link`**, porque `sidebar.js` cierra el menú móvil al pulsar
cualquier elemento con esa clase. Con el sidebar colapsado el submenú se oculta y
el botón navega directo al listado.

## Módulo de Nómina

Pre-nómina semanal para RH. **No timbra**: calcula el bruto y exporta el
resultado para que el despacho contable emita el CFDI. Los recibos que genera
son documentos de control interno y lo dicen en el pie.

### Reglas de negocio acordadas

| Regla | Valor | Dónde se cambia |
|---|---|---|
| Periodicidad | Semanal | Al crear el período |
| Días laborales | 6 (lunes a sábado) | Por empleado en su ficha |
| Falta injustificada | Descuenta 1 día | Fija en el motor |
| Retardos | 3 = 1 falta, dentro del mismo período | `Payroll_Settings.retardos_por_falta` |
| Vacaciones aprobadas | Se pagan como día trabajado | `Payroll_Settings.pagar_vacaciones` |
| Empresa que paga | La de la ficha laboral | Ficha del empleado |

### La asistencia no se captura aquí

Se decidió el modelo "por excepción": no hay reloj checador ni registro de
horas. El motor **lee las faltas y los retardos que RH ya captura** en el panel
de incidencias (`Reprimands`, tipos `absence` y `delay`) y los días de
`Vacation_Requests` con `status = 'approved'`. Eso eliminó la parte más cara del
proyecto: el flujo de captura ya existía y no se duplicó.

**`Reprimands` ganó `incident_date`.** Antes solo guardaba `created_at`, que es
cuándo RH capturó la incidencia, no cuándo ocurrió. Si RH registra el miércoles
la falta del lunes, la nómina la descontaría en la semana equivocada. El
histórico se rellenó con `DATE(created_at)` y las consultas usan
`COALESCE(incident_date, DATE(created_at))`.

### El salario se guarda diario, no semanal

Toda la aritmética del módulo descuenta días completos (una falta = un día), así
que el diario es la unidad natural. El sueldo semanal sale de multiplicarlo por
`dias_laborales`.

### Ficha laboral en tabla aparte

`Payroll_Employee_Profiles` no son columnas en `Users` a propósito: hay varios
`SELECT u.*` en el sistema (`getAllWithRoles`, `search`) que expondrían el
sueldo de todos sin querer.

### Quién ve qué

Acceso solo para super administrador o usuario con `payroll_enabled = 1`.
**No se hereda del clan de Recursos Humanos** como el resto de RH: esa detección
funciona buscando clanes cuyo nombre contenga "recursos humanos" y es frágil
para datos salariales. El permiso se enciende desde `admin/users` con el
endpoint `admin/toggle-payroll-access`, igual que encuestas.

Los empleados **no ven sus recibos**: fue decisión del cliente. RH los
distribuye con el botón de imprimir del recibo.

### Estados del período

`borrador` → `calculada` → `cerrada`. Al cerrar, los recibos quedan congelados
con snapshot del nombre, puesto y salario, para que sigan siendo fieles aunque
después cambie la ficha del empleado. Solo el super administrador puede reabrir.

**El recálculo respeta lo capturado a mano.** Borra únicamente las líneas con
`origen = 'automatico'` (sueldo y descuentos por incidencias) y regenera esas;
los bonos y descuentos que RH agregó sobreviven.

### Archivos

| Archivo | Papel |
|---|---|
| `migrations/2026_09_02_payroll_module.sql` | 6 tablas, catálogo sembrado, `payroll_enabled`, `incident_date` |
| `app/models/Payroll.php` | Fichas, conceptos, períodos, motor de cálculo y recibos |
| `app/controllers/PayrollController.php` | Rutas del módulo y control de acceso |
| `app/views/payroll/` | `index` (períodos), `period` (cálculo), `employees`, `receipt`, `concepts`, `message` |
| `app/views/components/payroll_menu.php` | Grupo desplegable del sidebar |
| `app/views/components/payroll_assets.php` | Hoja y script del módulo |
| `public/assets/css/payroll.css` | Estilos sobre los tokens `--cu-*` |
| `public/assets/js/payroll-ui.js` | Toasts, confirmaciones, modales y POST al backend |

### Exportaciones

Los dos CSV originales siguen ahí, con BOM UTF-8 para que Excel respete los
acentos sin librerías. `payroll/export-csv` lleva el desglose completo para el
contador; `payroll/export-bank` solo trae beneficiario, banco, CLABE e importe
de quienes cobran por transferencia.

A partir del 2026-09-02 se suman XLSX real y PDF imprimible (ver más abajo).

## Reportería y exportación

### Por qué no hay librerías nuevas

El servidor no tiene composer ni `vendor/`, y meter PhpSpreadsheet o TCPDF
implicaría mantener dependencias en un despliegue que se hace copiando archivos
por SCP. En su lugar:

- **XLSX**: se escribe el XML del paquete Office Open XML y se comprime con
  `ZipArchive`, que ya viene con PHP. Es la misma técnica que usaba
  `AdminController::buildUsersXlsx()`, extraída a una clase reutilizable.
- **PDF**: se entrega HTML con hoja de impresión tamaño carta y el usuario usa
  *Imprimir → Guardar como PDF*. Es lo que ya hacía Encuestas.

Verificado en producción: `ZipArchive` está disponible.

### `app/models/XlsxWriter.php`

Generador de libros de varias hojas. Se carga desde `public/index.php` junto al
resto de modelos.

```php
$xlsx = new XlsxWriter();
$xlsx->addSheet('Resumen', ['Empleado', 'Neto'], $filas, [
    'types'  => ['text', 'money'],   // text, number, integer, money, percent, date
    'widths' => [32, 16],
    'title'  => 'Nómina semanal',
    'subtitle' => 'Del 01 al 06 de septiembre',
    'meta'   => ['Empresa' => 'Todas'],
    'totals' => ['TOTAL', 10800.75],
]);
$xlsx->download('nomina.xlsx');
```

Detalles que importan:

- Los tipos numéricos se escriben como número real (`<v>`), no como texto, para
  que Excel pueda sumarlos. El formato de moneda es `numFmtId 164`.
- Una fila `['__section' => 'Texto']` dibuja un subencabezado dentro de la hoja.
- Los nombres de pestaña se recortan a 31 caracteres y se limpian los caracteres
  que Excel rechaza (`\ / ? * [ ] :`).

### `app/views/components/print_styles.php`

Hoja de impresión compartida por los documentos imprimibles de ambos módulos.
Va embebida y no enlazada: estas vistas se abren fuera del layout y en pestaña
nueva, así que un CSS que no cargue produce un documento roto sin que se note.
Prefijo de clases `rp-`.

### Reportes de Nómina

Ruta principal: `payroll/reports`. Filtra por rango de fechas y empresa; el
rango se compara contra `Payroll_Periods.fecha_fin`, es decir un período
pertenece al mes en que se paga, que es como lo cuadra el contador.

| Ruta | Qué entrega |
|---|---|
| `payroll/reports` | Centro de reportes: tarjetas, gráficas Chart.js, comparativo, incidencias y acumulado |
| `payroll/export-range-xlsx` | Excel de 6 hojas: Resumen, Comparativo, Por empleado, Por empresa y clan, Incidencias, Conceptos |
| `payroll/report-print` | Reporte del rango imprimible, con espacio para firmas |
| `payroll/export-period-xlsx` | Excel de un período: recibos y detalle línea por línea |
| `payroll/receipt-print` | Recibo individual formal, con importe en letra |
| `payroll/employee-report` | Historial de un empleado; con `&format=xlsx` entrega el Excel |

Consultas nuevas en `Payroll.php`: `getRangeSummary`, `getPeriodComparison`,
`getEmployeeTotals`, `getEmployeeHistory`, `getCompanyBreakdown`,
`getClanBreakdown`, `getIncidentsReport`, `getConceptBreakdown`, `getDataRange`.

Dos decisiones que no son obvias:

- **El costo de las incidencias no se recalcula**, se lee de las líneas
  `DESC_FALTA` y `DESC_RETARDO` del recibo. Así el reporte cuadra exactamente
  con lo que se pagó, aunque después cambien las políticas.
- **El clan sale de `User_Active_Clan.active_clan_id`** y, si no hay, del primer
  `Clan_Members`. Un usuario puede estar en varios clanes y sin esto su neto se
  contaría dos veces. Ojo: la columna se llama `active_clan_id`, no `clan_id`.

### Reportes de Encuestas

| Ruta | Qué entrega |
|---|---|
| `surveys/reports` | Centro de reportes: elige encuestas y las compara |
| `surveys/participation` | Tasa de respuesta, completitud por pregunta y quiénes faltan |
| `surveys/export-xlsx` | Excel de 4 hojas: Resumen, Resultados, Respuestas (matriz), Participación |
| `surveys/executive-report` | Reporte ejecutivo imprimible con lectura automática del resultado |
| `surveys/consolidated-print` | Consolidado comparativo imprimible |
| `surveys/export-consolidated-xlsx` | Excel del consolidado más una hoja por encuesta |

Consultas nuevas en `Survey.php`: `getParticipation`, `getConsolidated`,
`getResponsesTimeline`.

Sobre la participación: los enlaces de encuesta son públicos, así que no existe
una lista de invitados. La audiencia se estima con la plantilla activa del clan
dueño (`owner_clan_id`) y el cruce se hace por correo, único dato que comparten
`survey_responses` y `Users`. Si la encuesta no tiene clan, la tasa sale nula y
la interfaz lo dice en lugar de inventar un número.

En el consolidado, lo único comparable entre cuestionarios distintos es el
promedio de las preguntas tipo `rating`; por eso es la métrica del comparativo.

### Los IDs siempre se filtran por alcance

`SurveyController::requestedSurveyIds()` recorta lo que llega por GET contra
`listForUser()`. Sin eso, cualquiera con el módulo activo podría leer encuestas
de otro departamento cambiando el parámetro a mano.

## Módulo de Asistente IA

Chat tipo ChatGPT dentro de Polaris, en `?route=assistant`. Además de conversar,
entrega documentos de oficina reales: Excel, Word, PowerPoint y PDF.

**Proveedor:** MiniMax, modelo `MiniMax-M3` (el más capaz de la familia: 1M de
contexto, uso de herramientas y razonamiento). El endpoint es compatible con el
formato de OpenAI, así que se llama con el mismo cuerpo de `chat/completions`.
La llave y la URL viven en `config/app.php` (`MINIMAX_API_KEY`,
`MINIMAX_BASE_URL`).

### El modelo no escribe los archivos

Esto es lo central del diseño. Un LLM no puede producir un ZIP de Office válido:
si se le pide el binario, devuelve algo que no abre. Lo que hace es llamar a una
herramienta (`crear_excel`, `crear_word`, `crear_powerpoint`, `crear_pdf`) con la
**estructura** del documento en JSON, y es PHP quien arma el archivo con los
escritores de OOXML. Así el documento siempre abre.

`MinimaxClient::chat()` corre el bucle: manda el historial, si la respuesta trae
`tool_calls` ejecuta cada una, devuelve el resultado al modelo y vuelve a
preguntar. El tope es 6 vueltas para que no se cicle (consulta de datos + documento).

### Datos reales (solo lectura)

Además de generar archivos, el modelo puede llamar tools acotadas al perfil:

| Tool | Fuente |
|---|---|
| `consultar_tareas` / `detalle_tarea` | `AssistantData` + `Task` (clan si líder, asignadas si miembro) |
| `generar_doc_desde_tareas` | Carga tareas visibles y arma xlsx/docx/pptx/pdf |
| `consultar_comedor` | `Survey::listUserMealHistory` |
| `consultar_tickets` | `Ticket::getByUser` (admins: cola con `gestion`) |
| `guardar_preferencias` | Tabla `AI_User_Preferences` |

Cada ítem trae `url` navegable (`clan_leader/task-details`, `clan_member/task-details`,
`surveys/my-orders`, `tickets/view`). No muta tareas, pedidos ni tickets.

### Image-to-image

`crear_imagen` acepta `referencia_adjunto_id` o `usar_ultima_imagen`. MiniMax recibe
`subject_reference` tipo `character` con la foto adjunta.

### Biblioteca, plantillas y regenerar

- `?route=assistant/biblioteca` — todos los `AI_Artifacts` del usuario con búsqueda.
- Chips Minuta / Acta / Presupuesto / Banner + sugerencias de datos reales.
- Botón **Otra versión** → `assistant/regenerar` reedita desde `spec_json`.
- Preferencias en `assistant/preferencias` (tono, empresa, formato).

### Escritores de Office

Siguen la técnica que ya usaba `XlsxWriter`: se escribe el XML del paquete y se
comprime con `ZipArchive`. **No se instaló ninguna librería**, por lo mismo de
siempre: el servidor no tiene composer y el despliegue es por SCP.

| Archivo | Genera |
|---|---|
| `app/models/XlsxWriter.php` | XLSX (ya existía) |
| `app/models/DocxWriter.php` | DOCX: títulos, encabezados, párrafos, viñetas, listas numeradas, tablas y saltos de página |
| `app/models/PptxWriter.php` | PPTX 16:9: portada, diapositivas con título y viñetas, y notas del presentador |

Detalles que cuestan tiempo si se descubren tarde:

- **El PPTX dibuja cuadros de texto con coordenadas explícitas** en lugar de
  heredar marcadores del `slideMaster`. La herencia obliga a mantener un patrón
  completo y coherente; si algo no cuadra, PowerPoint abre la presentación con
  los textos descolocados o vacíos. Con coordenadas propias, lo que se ve es lo
  que se escribió.
- **Las notas del presentador arrastran cinco partes extra** (`notesMaster1.xml`,
  su tema `theme2.xml`, y por diapositiva el `notesSlide` con sus relaciones),
  más la entrada `notesMasterIdLst` en `presentation.xml`. Falta una y el archivo
  no abre.
- **En OOXML el orden de los hijos importa.** `a:pPr` exige `lnSpc`, `spcBef`,
  `buFont` y `buChar` en ese orden; `fmtScheme` exige exactamente tres entradas
  en cada uno de sus cuatro listados.
- **Un `\n` dentro de `<w:t>` se ignora al abrir el DOCX:** el salto de línea hay
  que declararlo como `<w:br/>`. Lo mismo en PPTX con `<a:br/>`.
- **Los caracteres de control rompen el XML** y Office se niega a abrir el
  archivo sin decir por qué. Los tres escritores los filtran antes de escapar.

El PDF es la excepción: no se guarda en disco. Se registra la estructura en
`spec_json` y se vuelve a dibujar como HTML de impresión
(`app/views/assistant/document_print.php` + `print_styles.php`), que es el patrón
que ya usan Nómina y Encuestas. El navegador es quien produce el PDF.

### Lo que devuelve el modelo se normaliza siempre

`AssistantController` pasa todo por `secciones()`, `matriz()` y `listaDeTextos()`
antes de tocar los escritores. El modelo respeta el esquema casi siempre, pero
no siempre: manda números donde se pidió texto, una cadena donde se pidió
arreglo, o filas más cortas que las columnas. Sin esa capa, un desliz suyo
reventaría la generación del archivo. Las filas se recortan o se rellenan para
cuadrar con las columnas en vez de descuadrar la tabla.

### Almacenamiento y descarga

Los archivos se guardan en `public/uploads/assistant/<user_id>/` con nombre
único, pero **la descarga nunca sale por enlace directo**: pasa por
`?route=assistant/download&id=N`, que valida que el artefacto sea del usuario que
lo pide. `Assistant::rutaAbsoluta()` además comprueba con `realpath()` que la
ruta no se salga de la carpeta del módulo.

> La carpeta debe ser escribible por Apache, que corre como `daemon`. Si se crea
> por SSH queda como `bitnami:bitnami` y el módulo falla al guardar. Debe quedar
> `bitnami:daemon` con 775, igual que `public/uploads`.

### Historial en tablas propias

`AI_Conversations`, `AI_Messages` y `AI_Artifacts`, no `Chat_History`. Esa tabla
es del chat viejo de Polaris: guarda una fila por par pregunta/respuesta y no
tiene noción de hilo, así que no sirve para conversar con contexto. Al modelo se
le mandan los últimos 20 turnos; más allá encarece sin aportar.

### Acceso

Interruptor por usuario `Users.assistant_enabled`, con el mismo patrón que
`surveys_enabled` y `payroll_enabled`, porque **cada consulta consume tokens de
pago** y no tiene caso abrirlo a toda la plantilla. Los administradores entran
por rol. Se enciende desde `admin/users` con el botón de la varita.

### El chat formatea Markdown

El modelo contesta en Markdown. `public/assets/js/assistant-ui.js` lo convierte
a HTML **escapando primero**: lo que llega es texto de un modelo y nunca debe
interpretarse como etiquetas. Es el único lugar donde se interpreta, así que el
historial del servidor viaja crudo en `data-ai-raw` para no formatearse dos veces.

## Auditoría visual (Playwright)

La carpeta `.ux-audit/` (ignorada por git) contiene una herramienta local para
navegar la app con un navegador headless, capturar pantallas y medir contraste.
Requiere Node y se instala con `npm i playwright && npx playwright install chromium`.

| Script | Para qué sirve |
|---|---|
| `shoot.js <usuario> <pass>` | Captura las pantallas principales en tema claro y oscuro |
| `contrast.js <usuario> <pass>` | Mide el contraste real de cada texto y lista lo que baja de 4.5:1 |
| `recheck.js <usuario> <pass>` | Igual que el anterior, pero sirviendo las hojas locales sin desplegar |
| `verify-all.js <usuario> <pass>` | Verifica cambios de CSS locales contra el sitio en vivo |
| `test-encuestas.js <usuario> <pass>` | Flujo completo de Encuestas: menú, alta y enlace público |
| `test-aislamiento.js <usuario> <pass> <id> <etiqueta>` | Que una encuesta de otro clan no se vea ni se pueda abrir |
| `test-resultados.js <usuario> <pass> <id>` | Métricas, gráficas, modales y reporte con datos reales |
| `verify-encuestas.js <usuario> <pass> <id>` | Acordeón del sidebar, errores de JS y contraste en oscuro |
| `preview-tokens.html` + `shot-tokens.js` | Renderiza texto, píldoras, tabla y formulario con las hojas locales y captura claro y oscuro. No necesita sesión, así que sirve para validar la paleta sin credenciales |
| `morado-a-azul.js [--aplicar]` | Mapa explícito de la paleta morada a la azul. Sin argumentos solo reporta; documenta qué tono va a cada azul y por qué |

**`recheck.js` no interceptaba `clickup-tokens.css`.** Solo sustituía las otras tres
hojas, de modo que una auditoría de un cambio de paleta se hacía contra los tokens
de producción y salía limpia siempre. Ya está en la lista.

**Si el reporte solo señala el botón "Iniciar Sesión", el login falló** y se está
midiendo la pantalla de acceso, no la aplicación: revisa las credenciales antes de
dar por buenos los resultados.

Truco útil: `page.route()` intercepta las peticiones de `clickup-*.css` y las
sirve desde el repositorio local. Permite validar un cambio de estilos contra
producción **sin desplegar nada**.

Notas aprendidas:

- El `zoom: 0.9` del body desplaza el marco de referencia de los hijos
  `position: fixed`, y de forma distinta en cada eje. Evita calcular
  coordenadas a mano para menús flotantes: usa posicionamiento CSS relativo.
- **Las páginas de clan_leader se renderizan DOS VECES.** La vista termina con
  `require_once __DIR__ . '/../layout.php'` y además `loadView()` del controlador
  vuelve a envolverla con el mismo layout. En el HTML final aparecen dos `<html>`
  y todos los CSS y JS se cargan por duplicado. Cualquier script que registre
  listeners globales debe protegerse contra la doble ejecución (ver
  `theme-toggle.js`). Corregir la doble envoltura de raíz sigue pendiente.
- Al medir contraste hay que ignorar los elementos con `background-image`
  (degradados), porque `backgroundColor` sale transparente y da falsos positivos.

## Historial de cambios desplegados

- **2026-09-19** — **MIA: autoría de documentos con OpenAI + imágenes embebidas.**
  - MiniMax sigue siendo el chat; al generar Excel/Word/PPT/PDF el borrador pasa por GPT-5 mini / GPT-5 (fallback GPT-4.1) vía `DocumentAuthor` + `OpenAIClient`.
  - Imágenes sueltas y embebidas en DOCX/PPTX con `gpt-image-1` (fallback MiniMax `image-01` si no hay key).
  - Secretos movidos a `config/secrets.php` (gitignored). Desplegar ese archivo por scp, no por git.
  - Archivos: `OpenAIClient.php`, `DocumentAuthor.php`, `DocxWriter.php`, `PptxWriter.php`, `AssistantController.php`, `config/app.php`, `config/secrets.example.php`.
  - Backup: `~/backups_mia_openai_docs_YYYYMMDD/`.

- **2026-09-15 (2)** — **Entrenamiento MIA (solo `abdielc`).** Panel para revisar quién ha usado el asistente y abrir sus conversaciones en un modal, con el fin de detectar errores de razonamiento o de tools.
  - Acceso hardcodeado a `username === 'abdielc'` en controlador y sidebar; cualquier otro usuario recibe 403.
  - Lista de usuarios con actividad (`AI_Conversations` + mensajes), búsqueda, y dos modales: chats del usuario → detalle de mensajes.
  - Rutas: `assistant/entrenamiento`, `assistant/entrenamiento-conversaciones`, `assistant/entrenamiento-detalle`.
  - Backups: `~/backups_entrenamiento_mia_20260915/`.

- **2026-09-15** — **Clan member se veía con “zoom” más grande que clan leader.** Al pasar de un perfil al otro la interfaz no mantenía la misma escala.
  - **Causa:** `clan_member/projects.php` era un HTML standalone (DOCTYPE propio) que cargaba sidebar y monday-theme pero **no** `theme.css`. En `theme.css` está `body { zoom: 0.9 }`, así que el líder queda al 90% y member/proyectos al 100% (~11% más grande).
  - **Proyectos member** ahora es un fragmento como el del líder: `monday-layout projects-page` + `layout.php`, con el mismo padding `20px 40px`.
  - **Tareas y Agenda member** dejaron el nav duplicado `modern-dashboard` y usan `monday-main`, igual que el resto del sistema.
  - **`ClanMemberController::loadView`** captura la salida y, si alguna vista se escapa del layout, la envuelve; si ya imprimió el documento completo, no lo duplica.
  - Backups: `~/backups_layout_member_20260915/`.

- **2026-09-14 (2)** — **El asistente decía que no había pedidos de comedor cuando sí los había.** A un colaborador que preguntó por sus comidas de la semana le contestó que no tenía nada registrado, e inventó fechas y precios de pedidos que nunca existieron.
  - **La causa: la herramienta no mandaba ni una sola fecha de consumo.** De cada pedido solo viajaba `submitted_at` y el texto del platillo tal como lo escribe RH (`"LUNES 21 / Tostadas de tinga"`), sin mes ni año. El modelo tomaba la fecha de envío como si fuera el día que se come y, como **el menú es de la semana siguiente a la que se pide**, concluía que no había nada para la semana en curso. Caso real: la encuesta 118 se contestó el 14 de septiembre y su menú va del lunes 21 al viernes 25.
  - **Las fechas se resuelven en PHP, no en el modelo.** `Survey::fechaDePlatillo()` ancla el día que escribió RH a la semana que cubre la encuesta usando `fechaLanzamiento()` como referencia. Soporta `LUNES 21`, `Lunes 21 de septiembre`, `21 de septiembre` y acentos; si el texto no trae día reconocible devuelve NULL en vez de adivinar, porque un NULL explícito es preferible a una fecha inventada.
  - **El número del día se lee junto al nombre del día,** no de cualquier parte del texto: `"Burrito California 2x1"` habría dado el día 2. Y si el nombre y el número no concuerdan (`LUNES 22` cuando el 22 es martes) manda el número: el nombre del día es lo que RH copia y pega entre semanas.
  - **Cada pedido ahora lleva `periodo_inicio`/`periodo_fin` y `semana_relativa`** (`semana_en_curso`, `semana_siguiente`, `semana_pasada`), ya calculados. La aritmética de semanas la hace PHP porque el modelo la resolvía mal. `consultar_comedor` acepta además el parámetro `semana` para que ni siquiera tenga que filtrar.
  - **`listOpenMealSurveys()` distingue "no pediste" de "no hay menú".** Devuelve los menús vigentes con la marca `ya_pedido` y el enlace a `surveys/respond`, que es la diferencia que al colaborador le importa. No filtra por clan: el comedor es de toda la empresa y cualquiera con sesión puede pedir, igual que en `respond()`.
  - **El prompt da hoy con día de la semana y los tres rangos de semana ya calculados,** y prohíbe explícitamente deducir fechas del texto libre. Antes solo recibía `d/m/Y`.
  - Verificado: los 9 casos del parser (incluidos acentos, conflicto día/número y texto sin fecha), y contra la base real el pedido de la 118 queda como `2026-09-21 a 2026-09-25 / semana_siguiente` en lugar de atribuirse al día de envío.
  - **Nota:** PHP corre en `America/Mexico_City` y MariaDB en la hora del sistema (PDT), así que van una hora desfasados. No afecta a este flujo —los filtros SQL llevan un día de margen y `estado()` compara fechas que escribió el propio PHP— pero conviene tenerlo presente al depurar cortes por hora.
  - Backups remotos: `~/backups_asistente_comedor_20260914/` (`Survey.php`, `AssistantData.php`, `AssistantController.php`).

- **2026-09-14** — **El perfil de colaborador se alinea con el de líder: mismo ancho de página y los mismos modales.** El colaborador seguía viendo los diálogos grises del navegador (`confirm`/`alert`) mientras el líder ya usaba el modal del sistema, y sus pantallas no coincidían en ancho con las del líder.
  - **57 diálogos nativos sustituidos** en 7 vistas (`task_details` concentraba 36). Los `alert()` pasaron a `showAlertModal()` y los `confirm()` a `confirmDanger()` / `confirmAction()`.
  - **`confirm()` es síncrono y el modal no lo es**, así que `if (!confirm(...)) return;` no se podía cambiar en el sitio. Cada función se partió en dos: un wrapper que lanza el modal y `<nombre>Confirmed` con el cuerpo original intacto. Menos intrusivo que envolver y reindentar el cuerpo completo.
  - **Los `alert()` de éxito seguidos de `location.reload()` habrían parpadeado:** el alert nativo bloqueaba, el modal no. Esos tres casos pasan el reload como callback de cierre.
  - **Helpers nuevos en `script.js`:** `showAlertModal`, `confirmAction`, `confirmDanger`, `showSuccessModal`, `showErrorModal`, `showWarningModal`. El cuerpo del modal se inyecta con `innerHTML`, así que los helpers escapan el texto y convierten `\n` en `<br>`; `showConfirmationModal` mantiene su comportamiento para quien le pasa HTML a propósito.
  - **`script.js` se carga después del contenido,** así que sus definiciones globales pisaban las que la vista ya había declarado. `confirmDelete` y los tres `show*Modal` ahora se declaran solo si no existen. `minutes.php` define su propio `confirmDelete` y con esto deja de perderlo.
  - **Layout:** el dashboard del colaborador tenía un `<style>` que forzaba `padding` y `margin` sobre `.monday-main`; se quitó y usa el mismo `<main>` que el líder. `profile.php` adopta `monday-main profile-main` con las medidas del líder, y `tasks.php` y `availability.php` dejan de centrar su contenido a 1400px.
  - **`.main-content` de `theme.css` desplazaba el contenido dos veces:** repetía el offset del sidebar que `body` ya aplica en `sidebar.css`. Solo lo sufría `reprimands/admin.php`, la única vista que no redefine la clase; el resto (admin, dashboard) la pisa con su propia hoja. Se conservó el `max-width` para no cambiar el ancho de RH.
  - **`ClanMemberController::loadView()` carga `clan-member.css` por omisión**, como el líder hace con `clan-leader.css`. Respeta el `$additionalCSS` que la vista haya definido antes.
  - Verificado: sintaxis PHP de las 14 vistas, JS embebido extraído y validado con `node --check`, y md5 idéntico local/remoto en los 12 archivos.
  - Backups remotos: `~/backups_unificacion_member_20260914/` (misma estructura de carpetas). Rollback: `cp -r ~/backups_unificacion_member_20260914/* /opt/bitnami/apache/htdocs/desarrollo/rinotrack/`.

- **2026-09-07** — **Horario obligatorio en las encuestas de comida.** La cocina necesita escalonar el servicio, así que toda encuesta que cobre comida pregunta a qué hora pasa cada quien.
  - **Solo comida, no desayuno.** Regla explícita del cliente. `saveQuestions()` agrega la pregunta únicamente si `SERVICIO_COMIDA` está entre los servicios de la encuesta.
  - **La pregunta la genera el sistema, no RH,** por la misma razón que las opciones Sí/No: si RH la redactara podría olvidarla o escribirla distinto en cada encuesta, y el reporte que se le pasa a la cocina dejaría de ser comparable. En el constructor se filtra para que no aparezca ni se pueda borrar.
  - **`meal_service = 'horario'` en lugar de una columna nueva.** Es la misma columna que distingue desayuno de comida y ya existía; el valor no es un servicio sino una marca de "esta pregunta no se cobra". `servicioDePregunta()` devuelve NULL para ella **incluso cuando la encuesta tiene un solo servicio**, que es el caso donde el fallback de herencia la habría convertido en un consumo fantasma.
  - **Un horario por semana, no por día.** En la práctica la gente come a la misma hora, y preguntarlo cinco veces alarga el formulario sin aportar nada.
  - **Los horarios se congelan en la encuesta** igual que el precio: se copian a las opciones de la pregunta al crearla. Cambiar `horarios_comida` no altera las encuestas ya lanzadas. Al reeditar una encuesta se releen sus propios horarios, no los globales.
  - **El horario solo se exige a quien pidió al menos un día de comida.** Validado en cliente y en servidor; a quien no pidió nada no se le pregunta y la tarjeta se oculta sola.
  - **Reportes:** columna de horario por empleado y desglose "cuántos pasan a cada hora" en la pantalla, en el PDF y en una hoja propia del Excel, que es la que se le entrega a la cocina.
  - **Pendiente operativo:** la encuesta 115 (`COMEDOR`) se creó antes de este cambio y ya tenía 24 respuestas, así que no tiene la pregunta de horario. El constructor bloquea editar preguntas cuando hay respuestas, porque el borrado en cascada se las llevaría. Para dotarla de horario hay que insertar la fila a mano en `survey_questions`; las encuestas nuevas ya lo traen.
  - Backups remotos: `~/backups_horario_20260907/` (8 archivos).

- **2026-09-10 (2)** — **Ampliación del Asistente IA:** tools de lectura (tareas/comedor/tickets)
  con links, docs desde tareas, image-to-image, biblioteca, plantillas, regenerar y
  `AI_User_Preferences`. Migración `2026_09_10_assistant_preferences.sql`. Archivos clave:
  `AssistantData.php`, `AssistantController.php`, `MinimaxClient.php`, vistas
  `biblioteca`/`preferencias`, CSS/JS del chat.
- **2026-09-10** — **Módulo de Asistente IA con MiniMax** (ver sección propia arriba). Chat dentro de Polaris que además entrega Excel, Word, PowerPoint y PDF de verdad.
  - **El modelo no genera los archivos, genera la estructura.** Un LLM no puede producir un ZIP de Office válido. El modelo llama a una herramienta con el contenido en JSON y PHP arma el binario con los escritores de OOXML. Es la diferencia entre un archivo que abre y uno que no.
  - **No se instaló ninguna librería,** por lo mismo que en reportería: sin composer y con despliegue por SCP, PhpSpreadsheet o PhpPresentation salen caros. `DocxWriter` y `PptxWriter` siguen la técnica de `XlsxWriter`: XML del paquete más `ZipArchive`.
  - **El PPTX usa cuadros de texto con coordenadas explícitas** en vez de heredar marcadores del patrón. Heredar obliga a mantener un `slideMaster` completo y coherente, y cualquier descuadre abre la presentación con los textos fuera de lugar o vacíos.
  - **Las notas del presentador cuestan cinco partes extra** en el paquete (`notesMaster`, su tema, y por diapositiva el `notesSlide` con sus relaciones). Falta una y PowerPoint rechaza el archivo entero.
  - **Todo lo que devuelve el modelo se normaliza** antes de llegar a los escritores. Manda números donde se pidió texto y filas más cortas que las columnas; sin esa capa, un desliz suyo reventaría la generación.
  - **El historial va en tablas propias y no en `Chat_History`.** La tabla vieja guarda una fila por par pregunta/respuesta y no tiene noción de hilo, así que no permite conversar con contexto.
  - **La descarga pasa por el controlador,** nunca por enlace directo a `public/`: valida que el archivo sea del usuario y comprueba con `realpath()` que la ruta no se salga de la carpeta del módulo.
  - **Problema encontrado al desplegar:** `public/uploads/assistant` creada por SSH queda como `bitnami:bitnami`, y Apache corre como `daemon`, así que el módulo no habría podido guardar nada. Corregido a `bitnami:daemon` 775 y verificado escribiendo como `daemon`.
  - Verificado antes de desplegar: los paquetes generados pasan validación de XML, relaciones y `[Content_Types]`; el DOCX abre con el lector de Word de macOS mostrando encabezados, viñetas y tablas; y se pidieron los cuatro formatos a MiniMax de verdad, comprobando que llama a las herramientas y que lo que devuelve produce archivos válidos.
  - Verificado en producción: migración aplicada (3 tablas `AI_*` y `Users.assistant_enabled`), 19 archivos con md5 idéntico al local y sin errores de sintaxis, `zip`/`curl`/`mbstring` disponibles, la ruta `assistant` responde 302 a login y MiniMax contesta desde el propio servidor.
  - Backups remotos: los `.bak_20260910` de los 7 archivos modificados, junto a cada original. Rollback: restaurarlos y borrar `app/views/assistant/`, `app/models/{Assistant,DocxWriter,PptxWriter}.php`, `app/services/MinimaxClient.php`, `app/controllers/AssistantController.php`, `app/views/components/assistant_{menu,assets}.php` y `public/assets/{css/assistant.css,js/assistant-ui.js}`. Las tablas y la columna pueden quedarse: no estorban.

- **2026-09-03** — **Encuestas de comedor que se descuentan solas en la nómina.** RH lanza una encuesta con el menú de la semana, el empleado marca qué días quiere, y cada "sí" se convierte en una deducción en su recibo.
  - **El formulario de respuesta se construyó dentro de Polaris** (`surveys/respond`), no en RinoMailer. El enlace público de `/notificaciones` es anónimo: solo guarda el nombre y el correo que la persona escriba a mano, así que era imposible saber a quién cobrarle. La nueva pantalla exige sesión y guarda `respondent_user_id`.
  - **Las opciones de cada pregunta las pone el sistema** (`Sí, lo quiero` / `No, gracias`) y el tipo queda fijo en opción única. RH escribe solo el texto del día y el platillo. Si RH pudiera redactar las opciones, no habría forma inequívoca de saber qué respuesta significa "sí quiero", y de ese conteo depende cuánto se le descuenta a la gente.
  - **El costo se congela en la encuesta al crearla** (`meal_cost_desayuno` / `meal_cost_comida`). Leer la tarifa vigente al calcular la nómina haría que subir el precio del comedor cambiara retroactivamente lo que ya se le cobró a quien pidió la semana pasada.
  - **El cobro cae en el período que contiene `surveys.created_at`,** es decir la misma semana en que se lanza. Si ese período todavía no existe, la encuesta no se pierde: el motor lee por rango de fechas, así que entra sola cuando RH lo cree y lo calcule.
  - **Las líneas son automáticas, no manuales.** `DESC_DESAYUNO` y `DESC_COMIDA` se generan dentro de `calculatePeriod()` con `origen = 'automatico'`, así que sobreviven a los recálculos, se regeneran si alguien cambia su pedido y respetan el bloqueo de períodos cerrados. Si se hubieran insertado como conceptos manuales, un recálculo las habría duplicado.
  - **Índice único `(survey_id, respondent_user_id)`** en `survey_responses`: el empleado corrige su pedido en lugar de acumular respuestas, que es lo que duplicaría el cobro. Solo aplica cuando hay usuario, así que las respuestas anónimas históricas no estorban.
  - **RinoMailer rechaza las encuestas con `requires_login`** en `publicView()` y en `submit()`, y muestra un botón hacia Polaris. Sin ese candado, cualquiera podría responder de forma anónima por el enlace viejo y generar un consumo que después nadie puede cobrar.
  - **Las tarifas viven en `Payroll_Settings`** aunque se editen desde Encuestas, porque son política de nómina. La pantalla exige `payroll_enabled`: quien solo tiene Encuestas no debe mover cifras que mueven dinero.
  - **El reporte de nómina lee las líneas del recibo, no las encuestas.** Es el mismo criterio que ya se usaba con las incidencias: un reporte histórico debe reflejar lo que realmente se cobró aunque después cambien las tarifas o se borre la encuesta.
  - **Quien pidió sin ficha de nómina activa aparece marcado** en el reporte de consumo en lugar de desaparecer en silencio. RH necesita saber que esa persona pidió de comer aunque no se le pueda descontar.
  - Verificado en producción: migración aplicada en las dos bases, índice único creado, y las rutas `surveys/meal-settings`, `surveys/respond`, `surveys/meal-report` y `payroll/reports` responden 200.
  - Backups remotos: `~/backups_comedor_20260903/` (13 archivos de Polaris más `rinomailer_SurveyController.php.bak`). Rollback: restaurar esos archivos y borrar las vistas `app/views/surveys/{respond,meal_settings,meal_report,meal_print}.php`. Las columnas nuevas pueden quedarse: tienen valor por omisión y no estorban a RinoMailer.

- **2026-09-02 (5)** — **Reportería y exportación en Nómina y Encuestas** (ver sección "Reportería y exportación" arriba). Se agregaron XLSX real de varias hojas y documentos PDF imprimibles a los dos módulos.
  - **No se instaló ninguna librería.** El servidor no tiene composer, y mantener PhpSpreadsheet o TCPDF en un despliegue por SCP sale caro. El XLSX se arma escribiendo el XML de Office Open XML y comprimiéndolo con `ZipArchive`, que ya viene con PHP; el PDF es HTML con hoja de impresión carta que el navegador guarda. Verificado en producción: `ZipArchive` disponible.
  - **`AdminController::buildUsersXlsx()` era privado y solo servía para usuarios.** Se extrajo a `app/models/XlsxWriter.php` con soporte de varias hojas, tipos de celda y filas de total. Los números se escriben como número real y no como texto, que era la limitación grande del original: así Excel puede sumarlos.
  - **El costo de las incidencias se lee de las líneas del recibo,** no se recalcula. Si se recalculara con las políticas de hoy, un reporte de hace tres meses dejaría de cuadrar con lo que realmente se pagó.
  - **Bug encontrado en la prueba contra producción:** el reporte por clan asumía `User_Active_Clan.clan_id`, pero la columna se llama `active_clan_id`. Se detectó corriendo las consultas contra la base real antes de desplegar, no después.
  - **La tasa de participación de encuestas es una estimación, y la interfaz lo dice.** Los enlaces son públicos y no hay lista de invitados, así que la audiencia se aproxima con la plantilla del clan dueño cruzando por correo. Sin clan asignado, la tasa sale nula en lugar de mostrar un número inventado.
  - **Los IDs de encuesta que llegan por GET se filtran contra `listForUser()`.** Sin eso, cualquiera con el módulo activo leería encuestas de otro departamento cambiando el parámetro.
  - Verificado: 12 rutas nuevas responden 302 a login, todas las consultas corren contra la base de producción sin error, y los XLSX generados pasan validación de XML y los reconoce el sistema como Excel 2007+.
  - **OPcache despista:** justo después de copiar, las rutas nuevas devolvían 404 porque Apache seguía sirviendo el `index.php` en caché. No es un error de despliegue; conviene esperar o revalidar antes de diagnosticar.
  - Backups remotos: `~/backups_rinotrack_20260902_reportes/antes_de_reportes.tgz`.

- **2026-09-02 (4)** — **Módulo de Nómina dentro de RH** (ver sección propia arriba). Pre-nómina semanal que calcula el bruto y exporta; no timbra.
  - **Lo más barato del diseño fue no construir asistencia.** El cliente eligió el modelo por excepción, así que el motor lee las faltas y los retardos que RH ya captura en `Reprimands` en lugar de duplicar el flujo. Eso quitó la fase más cara del proyecto.
  - **`Reprimands.incident_date` es nueva y era necesaria:** la tabla solo tenía `created_at`, la fecha en que RH capturó la incidencia. Si se registra el miércoles la falta del lunes, la nómina descontaba en la semana equivocada. El histórico se rellenó con `DATE(created_at)`.
  - **Permiso propio, no heredado de RH:** el resto del módulo de RH detecta acceso buscando clanes cuyo nombre contenga "recursos humanos". Para sueldos eso es frágil, así que se usó `payroll_enabled` con el patrón de `surveys_enabled`. Súper administrador entra por rol.
  - **Ficha laboral en tabla aparte y no en `Users`,** porque varios `SELECT u.*` del sistema (`getAllWithRoles`, `search`) expondrían el sueldo de todos sin querer.
  - **El recálculo no pisa lo capturado a mano:** borra solo las líneas `origen = 'automatico'`. Un bug fácil aquí sería borrar todas y perder los bonos de la semana.
  - Migración aplicada en producción: 6 tablas `Payroll_*`, 11 conceptos sembrados, 3 ajustes, `Users.payroll_enabled` y `Reprimands.incident_date` (7 filas rellenadas). Verificado: tablas creadas, 15 archivos PHP sin errores de sintaxis, ruta `payroll` responde 302 a login y el log de Apache no muestra errores nuevos.
  - Backups remotos: `~/backups_rinotrack_20260902_nomina/`.

- **2026-09-02 (3)** — **El módulo de RH tardaba demasiado en cargar.** Se optimizó el panel de incidencias, que tardaba varios segundos.
  - **La causa dominante:** la vista instanciaba `new Vacation()` **dentro del bucle de filas**, y ese constructor ejecutaba `CREATE TABLE IF NOT EXISTS` más cuatro `ALTER TABLE`. Con 358 solicitudes eran unas 1,790 operaciones DDL por carga. Ahora el esquema se verifica una sola vez por request con una bandera estática, en `Vacation` y en `Reprimand`.
  - **Paginación real:** las vacaciones se paginan en servidor de 25 en 25 y el filtro por estado viaja en la URL. Antes se renderizaban las 358 filas y JavaScript solo ocultaba las que no cabían.
  - **N+1 eliminado:** el conteo de adjuntos de cada incidencia se resolvía con una consulta por fila; ahora sale de un `LEFT JOIN` agrupado.
  - **Usuarios bajo demanda:** los 119 usuarios se enviaban duplicados en cada carga (un `<select>` y otra vez como JSON). Ahora hay autocompletado contra `reprimands/get-users?q=`.
  - **Regresión introducida y corregida en el mismo día:** al limpiar el controlador se quitaron unas líneas de depuración que de paso definían `$user`, y el sidebar hace `return` sin esa variable, así que **el menú izquierdo desapareció** en RH. Se repuso en el controlador y se añadió el respaldo que ya usaba `my_reprimands.php`.
  - Backups remotos: `~/backups_rinotrack_20260902_rh_opt/`.

- **2026-09-02 (2)** — **El morado de marca pasa a azul** (75 archivos, solo presentación). Se sustituyó toda la familia morada/índigo por su equivalente azul, en las dos temáticas.
  - **Alcance real:** el sistema de tokens solo explicaba una parte. Había **38 tonos morados distintos en 1,116 apariciones** repartidas entre hojas heredadas (`kpi.css`, `okr.css`, `internal-chat.css`, `clan-leader.css`) y estilos en línea dentro de las vistas, que no siguen a `--cu-accent`. Se resolvieron con un mapa explícito en `.ux-audit/morado-a-azul.js`: 972 sustituciones.
  - **Por qué el mapa es explícito y no una rotación de matiz:** una conversión automática habría teñido también los grises azulados del tema oscuro (`#1e1e2e`, `#2a2a3e`) y los azules que ya estaban bien (`#1e3a8a`, `#1e40af`).
  - **Trampa de los degradados:** muchos combinan un índigo con un violeta (`#4f46e5 → #7c3aed`). Si ambos caen en el mismo azul, el degradado **se aplana a color sólido**. El mapa manda cada violeta un escalón más oscuro que el índigo de su familia. Quedaron 5 casos residuales corregidos a mano; se verifica con un chequeo de paradas consecutivas iguales. Hay 6 degradados planos que ya lo estaban antes y se dejaron intactos.
  - **Accesibilidad:** el azul `#2563eb` da **5.17:1** con texto blanco encima, así que cierra el pendiente que había dejado el ajuste de contraste (el morado daba 4.15:1 y el botón primario no llegaba a AA).
  - **`/notificaciones` se despliega aparte:** su `SurveyController.php` vive en `/opt/bitnami/apache/htdocs/notificaciones`, no bajo `desarrollo/rinotrack`.
  - Verificado: 0 morados restantes, 91 archivos PHP sin errores de sintaxis, toda la paleta cumple AA y capturas comparadas en claro y oscuro.
  - Backups remotos: `~/backups_rinotrack_20260902_azul/` (`app-previo.tgz` con los 74 archivos y el `SurveyController.php` de RinoMailer). Rollback: `cd /opt/bitnami/apache/htdocs/desarrollo/rinotrack && tar xzf ~/backups_rinotrack_20260902_azul/app-previo.tgz`.

- **2026-09-02** — **Más contraste en el tema claro** (solo presentación; nada de lógica ni de base de datos). Los usuarios reportaron que el tema claro "es muy claro". Se oscureció la paleta desde el bloque `:root` de `clickup-tokens.css`, sin tocar el morado de marca y dejando las tarjetas en blanco puro.
  - **Los tres culpables medidos:** `--cu-text-3` daba **2.9:1** sobre blanco y reprobaba AA pese a pintar casi todas las etiquetas y textos de ayuda; `--cu-border` daba **1.19:1**, así que las tarjetas no tenían borde visible; y `--cu-bg` contra `--cu-surface` daba **1.07:1**, por lo que página y tarjeta se leían como una sola mancha blanca.
  - **Por qué las tarjetas siguen blancas:** las vistas traen unos 380 `background: #fff` escritos a mano. Al oscurecer solo el fondo de página, la tarjeta resalta más y esos fondos fijos siguen siendo coherentes, sin tocar una sola vista.
  - **Colores de estado:** se usan como texto en unos 25 selectores (píldoras, alertas, iconos) y como relleno sólido en solo 3 botones, así que se eligieron legibles sobre blanco. Los rellenos también ganaron: llevan texto blanco encima y antes daban 2.0:1. Ojo con el efecto cruzado: al oscurecer también los fondos `-soft`, el texto de la píldora **pierde** contraste; hay que ajustar frente y fondo a la vez.
  - **`--cu-text-3` se fija contra `--cu-surface-3`,** no contra el blanco. Al oscurecer las superficies, un valor que cumplía sobre la tarjeta se quedaba en 3.8:1 sobre los paneles grises.
  - **Barrido:** ninguna vista usaba un gris fijo como fondo de página, pero sí en cabeceras de tabla y paneles internos con `#f9fafb` (1.03:1 sobre blanco: el bloque existe pero no se ve). Se reapuntaron a los tokens desde `clickup-screens.css` con el patrón `html[data-theme]`. Los que traen el color en el atributo `style` de la vista necesitan `!important`.
  - **El modo oscuro no cambió:** cada token modificado ya estaba redefinido en el bloque `[data-theme="dark"]`, que gana por especificidad. Verificado con captura antes y después.
  - **Pendiente conocido:** texto blanco sobre `--cu-accent` da **4.15:1** y no llega a AA. Es previo a este cambio y se dejó fuera a propósito para no alterar el morado de marca.
  - Backups remotos: `~/backups_rinotrack_20260902_contraste/`. Rollback: `cp ~/backups_rinotrack_20260902_contraste/*.css /opt/bitnami/apache/htdocs/desarrollo/rinotrack/public/assets/css/`.

- **2026-09-01** — **Módulo de Encuestas dentro de Polaris** (ver sección propia arriba). Se integró el motor de encuestas de RinoMailer como sección del menú lateral, compartiendo la base `notification_system` en lugar de migrar los datos, para no romper los enlaces públicos ya repartidos.
  - **Base de datos:** `notification_system.surveys` ganó `owner_user_id`, `owner_clan_id`, `owner_company_id`, `source`, `opens_at` y `closes_at`; `rinotrack.Users` ganó `surveys_enabled`. Sin llaves foráneas entre esquemas: son bases distintas y la referencia cruzada haría frágil el mantenimiento. Migración en `migrations/2026_09_01_surveys_module.sql`.
  - **Visibilidad por clan:** cada encuesta guarda quién y desde qué clan se creó. Un usuario ve las suyas y las de sus compañeros de clan; el super administrador ve todo. Verificado en vivo: desde otro clan el listado sale vacío y ver, editar, exportar y eliminar responden 403, incluso invocando el POST directo sin pasar por la interfaz.
  - **Activación por usuario:** casilla en el modal de `admin/users` más botón de encendido en cada fila, y el endpoint `admin/toggle-surveys-access`. Solo super administrador.
  - **Vigencia:** `opens_at` y `closes_at` abren y cierran la encuesta sola. La validación está en `publicView()` y en `submit()` de RinoMailer, porque el formulario puede enviarse después del cierre si quedó abierto en el navegador.
  - **Seguridad:** `createUser`, `updateUser`, `toggleUserStatus` y `deleteUser` de `AdminController` solo llamaban `requireAuth()`, así que cualquier usuario con sesión podía invocarlos por POST. Ahora pasan por `requireAdminJson()`.
  - **Nota de arquitectura:** las vistas del módulo usan el patrón `ob_start()` + `include layout.php` una sola vez, así que **no sufren el doble render** que afecta a las páginas de `clan_leader`. Verificado: un solo `<html>` y cero errores de JavaScript.
  - Las 94 encuestas históricas, sus 722 preguntas y sus más de 2,400 respuestas quedaron intactas, sin registros huérfanos. El sistema siguió recibiendo respuestas reales durante todo el despliegue.
  - Backups remotos: `~/backups_rinotrack_20260901_encuestas/` (9 archivos, incluido el `SurveyController.php` de RinoMailer). Rollback: `cp -r ~/backups_rinotrack_20260901_encuestas/{app,config,public} /opt/bitnami/apache/htdocs/desarrollo/rinotrack/`, restaurar el archivo de `notificaciones/` y borrar `app/views/surveys/`, `app/models/Survey.php`, `app/controllers/SurveyController.php`, `app/views/components/surveys_menu.php` y `public/assets/css/surveys.css`. Las columnas nuevas pueden quedarse: no estorban a RinoMailer.

- **2026-08-30 (2)** — Auditoría visual con navegador y correcciones derivadas:
  - **Kanban recortado:** `monday-theme.css` combinaba `justify-content: center` con `overflow-x: auto` en `.kanban-wrapper`. Al desbordar, el centrado deja el lado izquierdo inalcanzable por scroll y la columna "Vencidas" quedaba cortada 40px de forma permanente. Corregido con `flex-start` desde `clickup-screens.css`.
  - **Modo oscuro ilegible:** títulos de tareas (1.19:1), botón "Limpiar" (1.11:1), cifras de estadísticas (1.10:1) y panel de chat (1.22:1). Venían de hojas, no de estilos inline, por eso los selectores de atributo no los alcanzaban. Corregido en `clickup-dark-fixes.css`.
  - **Páginas públicas:** login y tickets de invitado son diseños oscuros autocontenidos; la capa genérica les aclaraba el fondo pero no el texto. Marcadas con `theme-exempt`.
  - **Acciones por fila:** `clan_leader/tasks.php` pasó de 5 botones de colores a 2 acciones neutras (ver, seguimiento) más un menú `···` con editar, clonar y eliminar. Los iconos se atenúan hasta que el cursor entra en la fila.
  - **Conmutador de tema que no cambiaba:** la página se renderiza dos veces (ver sección de auditoría), así que `theme-toggle.js` se cargaba dos veces y registraba dos listeners; cada clic alternaba el tema dos veces y lo dejaba igual. Resuelto con una guarda de inicialización en el propio script. Backup: `~/backups_rinotrack_20260830_toggle.js`.
  - **Botón "Ver tareas" invisible en Archivo:** la regla del conmutador lista/tarjetas de Proyectos (`.view-btn`) alcanzaba también al botón sólido de Archivo, que conserva `color: white !important`, dejándolo blanco sobre blanco. Resuelto acotando la regla con `:not(.action-btn)` y dando fondo de acento al botón sólido. Al añadir estilos genéricos por clase conviene comprobar que ese nombre no se reutilice con otro significado en otra vista.
  - **Unificación de color en Gestión de Tareas:** los puntos KPI pasaron de dorado a acento, el nombre del clan de verde a texto secundario, y la píldora del encabezado de degradado verde a superficie neutra. El rojo queda reservado para "Vencida". Los iconos de acción subieron de opacidad 0.55 a 0.9, porque a 0.55 resultaban casi invisibles.
  - **Dashboard KPI:** el encabezado pasó del degradado azul heredado al acento del sistema, y las tarjetas OKR del amarillo crema a superficie neutra.
  - Backups remotos: `~/backups_rinotrack_20260830_fix`, `_fix2`, `_acciones` y `_screens_v2.css`.
- **2026-08-30** — Rediseño visual tipo ClickUp (42 archivos, solo presentación; sin cambios de lógica ni de base de datos). Se añadieron 4 hojas de estilo y `theme-toggle.js`, se cableó el tema en los 9 documentos HTML (2 layouts + 7 vistas standalone), se unificó `--sidebar-width` (había 240px y 260px en conflicto), se homologó Font Awesome a 6.5.0 en 29 archivos y se corrigió el `[data-theme="dark"]` de `theme.css`, que definía fondos blancos. Backups remotos: `~/backups_rinotrack_20260830/` (35 archivos previos). Rollback: `cp -r ~/backups_rinotrack_20260830/* /opt/bitnami/apache/htdocs/desarrollo/rinotrack/` y borrar las hojas `clickup-*.css`.
- **2026-08-24** — `app/controllers/ClanLeaderController.php` y `app/models/Project.php`: el resumen de puntos OKR ya no cuenta proyectos archivados; al archivar un proyecto se limpian `is_okr`, `okr_points` y `okr_user_id`. Se corrigieron en BD 3 proyectos archivados que seguían ocupando presupuesto (1142 Edith Farfan, 946 y 947). Backups remotos: `ClanLeaderController.php.bak_20260824_okr_archived`, `Project.php.bak_20260824_okr_archived`.
- **2026-08-05** — `app/controllers/ClanLeaderController.php`: se agregó `isOwnPersonalTask()` y se relajó la validación de clan en `getTaskDataForEdit()` y `updateTaskFromModal()` para que el dueño de una tarea personal pueda editarla aunque su proyecto "Tareas Personales" esté en un clan distinto al activo (error "Tarea no pertenece a tu clan"). Backup remoto: `ClanLeaderController.php.bak_20260805_isownpersonaltask`.


## Despliegues recientes

- **2026-09-22 (widgets flotantes arrastrables)** — Chat interno, campanita de notificaciones MIA y FAB de MIA se pueden arrastrar (posición en `localStorage`). Componente unificado `floating_widgets.php` en layouts + páginas standalone (incidencias, gamificación). Archivos: `floating-widgets-drag.js/css`, layouts, `ChatInternal` en bootstrap de modelos.

- **2026-09-22 (descripción subtareas en detalle)** — La descripción de cada subtarea se muestra en la página de detalle (preview bajo el título + bloque completo al expandir), en líder y miembro. Archivos: `clan_leader/task_details.php`, `clan_member/task_details.php`, `task-details-monday.css`. Backup remoto: `~/backups_subtask_desc_20260922_2105/`.

- **2026-09-22 (entrenamiento MIA v1)** — Revisión de conversaciones reales (`AI_Conversations`/`AI_Messages`) y ajuste de prompt + permisos multi-clan.
  - Historial versionado (estadística / changelog): [`docs/mia_training/`](docs/mia_training/) — versión `2026-09-22_v1`.
  - Backup remoto código: `~/backups_mia_train_20260922/`.

- **2026-09-22** — Cierre pendientes MIA (briefing por URL + limpieza cron + pruebas de permisos).
  - Ruta pública `?route=cron/mia-briefing&token=…` en `public/index.php` (auth por `CRON_TOKEN` en `config/secrets.php`).
  - Fallback `CRON_TOKEN=''` en `config/app.php` y plantilla en `config/secrets.example.php`.
  - `public/cron_mia_briefing.php`: quitada consulta a `Clans.leader_user_id` (columna inexistente); links del correo usan `APP_PUBLIC_URL`; modo `--dry` / `?dry=1` sin enviar correos.
  - Cron CLI ya instalado: `0 7 * * * …/cron_mia_briefing.php`.
  - Pruebas en servidor: miembro (gaelh) deniega asignar/clonar/reporte fuera de alcance; seguimiento reversible OK; líder gestiona solo su clan; dry-run CLI/URL OK; sin token → 403.
  - Backup remoto: `~/backups_mia_cierre_20260922/`. Rollback: restaurar esos archivos y quitar `CRON_TOKEN` de `secrets.php` si se revirtió.

- **2026-09-15** — Alertas proactivas MIA (campanita in-app). Archivos: `InAppAlert.php`, `MiaAlertsService.php`, `AlertsController.php`, `mia_alerts.php`, `mia-alerts.css/js`, rutas `alerts/*` en `index.php`, layouts. Tabla `User_Alert_State`. Backup remoto: `~/backups_mia_alerts_20260915_101355/`.
