# Plan de Actualización — lab_saas
# SaaS multi-tenant · Stack moderno · Rediseño mobile-first · Nuevas funcionalidades

> **Estado (2026-07-15):** las fases SaaS (0–7) siguen en planeación, pero ya hay trabajo
> implementado fuera de ese orden — ver **Bitácora de avances** al final:
> 1. **Auditoría de BD**: soft delete + responsables (`*_by`) en las 14 tablas, bitácora
>    `auditoria`, `BaseAuditModel`, primeras migraciones CI4, revisión de FKs.
> 2. **Rediseño Modernize** (adelanto de Fase 5): login + 3 layouts con topbar/menú
>    horizontal, partials compartidos, tokens en `public/css/app.css`; usuarios de prueba
>    `test_admin`/`test_empleado`/`test_lab`.
> 3. **UX DataTables + Select2**: jQuery/DataTables 2/Select2 por CDN, `public/js/app.js`,
>    filtrado en vivo en todos los listados, autocompletes como Select2 remoto.
>
> **Documento vivo:** aquí se itera el plan, se adjuntan referencias de inspiración y se
> registra cada avance en la bitácora. Convenciones de trabajo: ver `CLAUDE.md` (raíz).
> Carpeta de docs: `docs/` — índice en `docs/README.md`, análisis en `docs/ANALISIS_CONTEXTO.md`.

---

## Contexto

El proyecto es el LIMS **"labSoft"**, ya modernizado a CodeIgniter 4.6.5 / PHP 8.1+
(plan de 20 días completado, ver `PLAN_MODERNIZACION.md`). Hoy es **single-tenant** a pesar de
llamarse `lab_saas`: no existe aislamiento por inquilino — la separación actual es solo por
sucursal y por rol (0=admin, 1=laboratorista, 2=empleado).

**Punto de partida verificado (2026-07-15, antes de los avances de la bitácora):**
- CI 4.6.5, rutas explícitas, `AuthFilter` por sesión, CSRF activo.
- 10 modelos, 1 servicio (`VentaService`), FPDF en `app/ThirdParty/fpdf`.
- BD MySQLi con 14 tablas en `database/schema.sql` — **sin migraciones CI4** (todo SQL crudo).
- Frontend: Bootstrap 5.3.3 + Bootstrap Icons por CDN, JS vanilla con fetch, sin build tooling,
  `public/` sin assets propios (CSS/JS viven inline en los 3 layouts).
- Infra local nueva sin commitear: `Dockerfile` (FrankenPHP/PHP 8.3), `docker-compose.yml`,
  `Caddyfile`, `Dampfile` (dominio `labsaas.test`, BD `labsaas_db`).
- Deuda técnica documentada en `INSTALACION.md`: CRUDs sin validación server-side,
  carrito en sesión se pierde al expirar.

## Objetivo

Convertir el sistema en un **SaaS para múltiples laboratorios** y de paso:
modernizar el stack, rediseñar la UI (móviles, tabletas y computadoras) y agregar funcionalidades.

**Decisiones ya tomadas:**

| Tema | Decisión |
|------|----------|
| Multi-tenancy | Una sola BD con columna `tenant_id` en las tablas |
| Alta de tenants | Panel de **super-admin** (rol nuevo), sin auto-registro público |
| Frontend | CI4 + Bootstrap 5 renovado, mobile-first, **sin SPA ni build tooling** |
| Features nuevas | Dashboard con gráficas · Portal público de resultados para pacientes · Links WhatsApp (`wa.me`) para enviar resultados · Cierre de deuda técnica |

---

## Inspiración de diseño (para el rediseño UI)

> **Referencia elegida (2026-07-15):** [Modernize — Tailwind Next.js Horizontal](https://modernize-tailwind-nextjs-horizontal.vercel.app/)
> Tokens extraídos del CSS compilado real del demo (no aproximados).

### Dirección visual: replicar Modernize Horizontal

**Layout (cambia la decisión previa de sidebar):**
- **Navegación horizontal de dos niveles**: topbar blanca (logo, buscador, notificaciones,
  avatar con dropdown) + barra de menú horizontal debajo con dropdowns por módulo.
  En móvil (< lg) el menú horizontal colapsa a offcanvas con hamburguesa.
- Contenido sobre fondo gris azulado muy claro, contenedor centrado (~1300px máx),
  todo en tarjetas blancas sin borde con sombra suave.

**Paleta (tema BLUE, el default del demo):**

| Token | Valor | Uso |
|-------|-------|-----|
| primary | `#5D87FF` | Botones, links, menú activo |
| secondary | `#49BEFF` | Acentos secundarios |
| success | `#13DEB9` | Estados OK, badges "capturado/pagado" |
| warning | `#F6B51E` | Pendientes, saldos |
| error/danger | `#EF4444` | Errores, eliminar |
| info | `#8754EC` | Informativo |
| Texto títulos | `#2A3547` | Headings, texto fuerte |
| Texto cuerpo | `#5A6A85` | Párrafos, celdas |
| Fondo página | `#EFF4FA` / `#F6F9FC` | Body del área de contenido |
| Bordes | `#E5E5E5` | Divisores, inputs |
| Dark mode | bg `#1C2536`, surface `#1A2537`, muted `#2A3851`, border `#333F55`, texto `#7C8FAC` | Fase posterior (opcional) |

- **Variantes "light" al 12%**: cada color tiene su versión pastel
  (`color-mix(color 12%, transparent)`) para badges, chips e iconos con fondo —
  badge con fondo pastel + texto del color pleno, nunca badge sólido.
- Modernize trae **temas de acento intercambiables** (purple `#763EBD`, aqua `#0074BA`,
  green `#0A7EA4`, cyan `#01C0C8`) vía `data-color-theme` — patrón perfecto para
  **personalización por tenant** en la fase SaaS (un CSS custom property por tenant).

**Tipografía:** DM Sans (Google Fonts, pesos variables 400–700). Escala: xs .75rem,
sm .875rem, base 1rem, lg 1.125rem, xl 1.25rem, 2xl 1.5rem; números grandes de KPI 40px.

**Superficies y profundidad:**
- Radius base **10px** en tarjetas y botones (sm .25, md .375, lg .5, xl .75, 2xl 1rem).
- Sombra de tarjeta: `0 0 2px rgba(145,158,171,.3), 0 12px 24px -4px rgba(145,158,171,.12)`
  (la clásica "md" de Modernize). Botones: `0 9px 17.5px rgba(0,0,0,.05)`.
- Tarjetas sin borde; la sombra es la única separación con el fondo.

**Componentes a replicar:**
- Tarjetas KPI con icono en círculo pastel + número grande + variación.
- Tablas limpias: sin bordes verticales, encabezado en texto muted, avatares con
  iniciales, badges pastel de estado/prioridad — patrón exacto para pendientes de
  lab, listado de clientes y corte de caja.
- Gráficas de área/dona suaves (Chart.js con estos colores) para el dashboard Fase 6.

**Traducción a nuestro stack (Bootstrap 5.3, sin Tailwind):**
- Sobrescribir CSS custom properties de Bootstrap en `public/css/app.css`:
  `--bs-primary: #5D87FF`, `--bs-body-bg: #EFF4FA`, `--bs-body-color: #5A6A85`,
  `--bs-heading-color: #2A3547`, `--bs-border-radius: 10px`, `--bs-font-sans-serif: 'DM Sans', ...`
  + clases utilitarias propias `.bg-light-primary`, `.bg-light-success`, etc. (12%).
- DM Sans por CDN (Google Fonts/fontsource-jsdelivr), como ya se hace con Bootstrap.
- El modo oscuro de Bootstrap 5.3 (`data-bs-theme="dark"`) mapea 1:1 con los tokens dark.
- Navbar horizontal = `navbar-expand-lg` + dropdowns nativos; no requiere JS extra.

**Lineamientos base ya acordados (siguen vigentes):**
- Mobile-first real; menú horizontal → offcanvas con hamburguesa en móvil.
- Tablas adaptivas: `.table-responsive` + patrón tarjeta en móvil para listados clave.
- Más interacciones fetch/AJAX sin recarga de página.
- CSS/JS propios extraídos a `public/css/app.css` y `public/js/app.js` (hoy inline en layouts).

---

## Decisiones de diseño técnico

1. **`tenant_id` en TODAS las tablas de negocio** (defensa en profundidad). Los modelos usan
   mucho SQL crudo con joins; con `tenant_id` denormalizado cada query se cierra con un solo
   `AND x.tenant_id = ?`. Tablas: sucursal, empleado, usuario, cliente, analisis, precios,
   consecutivo, doctores, muestra, resultado_detalle, ventas, venta_muestra, abonar, maximos
   + nuevas `tenant` y `carrito_item`.
   - `maximos`: pasa de 1 fila global a 1 fila por tenant (`UNIQUE(tenant_id)`).
   - `doctores`: PK natural `correodoctor` → PK compuesta o surrogate.
   - `usuario.login`: se mantiene único **global**.
2. **Scoping automático vía `App\Models\BaseTenantModel`**: sobreescribe
   find/findAll/first/paginate/countAllResults/update/delete añadiendo `where(tenant_id)`;
   `beforeInsert`/`beforeUpdate` inyectan tenant_id; flag `$tenantScoped` desactivable.
   Helper `tenant_id(): int` en `lab_helper.php` que lee `session('tenant_id')` y
   **lanza excepción si falta** (fail-closed).
3. **Login global único** (sin selector de tenant ni subdominios). *(Actualizado 2026-07-16,
   Shield)*: la carga de `tenant_id`/`tenant_nombre` a sesión (validando `tenant.activo`) va en
   `poblar_sesion_usuario()` (`lab_helper`), que corre en el evento `login` de Shield
   (`app/Config/Events.php`) — ya no en `Auth::login`.
4. **Super-admin = grupo Shield `superadmin`, `users.tenant_id = NULL`.** *(Actualizado
   2026-07-16: reemplaza el diseño `accesslevel 9`.)* El grupo ya existe en
   `app/Config/AuthGroups.php`; el gating será `group:superadmin` en las rutas y
   `poblar_sesion_usuario()` ya mapea el grupo a `accesslevel 9` por compatibilidad.
5. **CDB sin cambios**: `{consecutivo}-{idusuario}-{idsucursal}-{idcliente}-{idmuestra}`
   ya es globalmente único (ids AUTO_INCREMENT de BD compartida).
6. **Portal pacientes: token aleatorio persistido por venta**
   (`ventas.public_token CHAR(32) UNIQUE`, `bin2hex(random_bytes(16))`), revocable y tecleable;
   expiración 90 días validada en controlador. Solo muestras `status=1`.
7. **Migraciones: baseline idempotente** — `0001_Baseline` porta `schema.sql` a Forge con guard
   `if ($this->db->tableExists('usuario')) return;` para que `spark migrate` funcione igual
   en BD existente y vacía.

---

## Fases

### Fase 0 — Infraestructura
- Commit de `Dockerfile`, `docker-compose.yml`, `Caddyfile`, `Dampfile`.
- `composer.json`: `"php": "^8.3"` + `composer update` en contenedor.

### Fase 1 — Migraciones CI4 + Seeders
- `app/Database/Migrations/...Baseline.php` — `schema.sql` completo en Forge con guard.
- `app/Database/Seeds/AnalisisSeeder.php` — 87 análisis + precios, **parametrizable por tenant**.
- `app/Database/Seeds/InstallSeeder.php` — sucursal + consecutivo + admin + maximos.
- Marcar `database/*.sql` como referencia deprecada en `INSTALACION.md`.

### Fase 2 — Núcleo multi-tenant (fase crítica)
- Migraciones `CreateTenant` (+ INSERT tenant 1 "labSoft") y `AddTenantId`
  (ALTER a las 14 tablas con `DEFAULT 1` como backfill, FK e índice; `usuario.tenant_id` NULLable).
- Nuevo `app/Models/BaseTenantModel.php` + helper `tenant_id()`.
- Los 10 modelos extienden BaseTenantModel; revisar **método por método el SQL crudo** (~15 sitios):
  `VentaModel` (6 métodos), `MuestraModel` (6), `PrecioModel` (2), `UsuarioModel` (3).
- `VentaService`: tenant_id en inserts, scoping en `consecutivo` (líneas 55-59) y `maximos` (línea 157).
- `poblar_sesion_usuario()` (evento `login` de Shield): cargar tenant a sesión, rechazar tenant
  inactivo. La columna `tenant_id` va en `users` (Shield), NULLable para superadmin.

### Fase 3 — Panel super-admin + provisión
- Seed usuario super (accesslevel 9, tenant NULL).
- `app/Services/TenantProvisioner.php`: transacción crea tenant → sucursal + consecutivo →
  maximos → usuario admin del tenant → catálogo (reusa `AnalisisSeeder`).
- `Super/Tenants.php` + `Super/Dashboard.php`, vistas `app/Views/super/*`, layout `layouts/super.php`.
- `Routes.php`: grupo `super` con filtro `group:superadmin` + guards de tenant NULL.
- `TenantModel.php` (extiende `Model` normal, NO BaseTenantModel).

### Fase 4 — Deuda técnica
- Validación server-side: `$validationRules` en modelos + `$this->validate()` en controladores
  Admin con `withInput()` y errores en vistas (`old()`).
- Carrito persistente: migración `carrito_item`, `CarritoModel`, reescribir `Empleado/Carrito.php`
  y ajustar `Cotizacion`/`Venta`; `VentaService` limpia carrito en BD (hoy línea 115, sesión).

### Fase 5 — Rediseño frontend mobile-first (sin build)
- Extraer CSS/JS inline de los 3 layouts a `public/css/app.css` + `public/js/app.js`
  (incluye helper fetch con manejo de `csrf_hash`).
- Partials: `topbar.php` (logo, usuario, sucursal), `navmenu.php` (menú horizontal con
  items por rol, `navbar-expand-lg`; offcanvas con hamburguesa en móvil), `flash.php`.
- Tablas responsive + patrón tarjeta en móvil; formularios en grid; acciones AJAX en CRUDs.
- **Aplicar la dirección visual Modernize definida en "Inspiración de diseño"**
  (tokens exactos ya extraídos: paleta, DM Sans, radius 10px, sombras, badges pastel).

### Fase 6 — Dashboard con gráficas
- Métodos tenant-scoped: `ventasPorDia(30)`, `ventasPorSucursal`, `topAnalisis(10)`,
  `pendientesCount`, `saldosPorCobrar`.
- `Admin/Dashboard.php`: endpoint `data()` JSON; vista con tarjetas KPI + **Chart.js 4 por CDN**.

### Fase 7 — Portal público de pacientes + WhatsApp
- Migración: `ventas.public_token` + `token_creado`; `VentaService` genera token al vender.
- `Portal.php`: `index()` (form de código), `ver($token)`, `pdf($token)` — sin sesión,
  solo muestras status=1, reutiliza `PdfResultado`; extraer lógica común a `ResultadoService`.
- Rutas públicas `GET /r/(:segment)`, `/r/(:segment)/pdf`, + form de código en `/portal`
  (**ojo:** el diseño original decía `/resultados`, pero con las rutas planas de 2026-07-16
  esa URL ya pertenece a los resultados del empleado) + `ThrottleFilter`.
- Helper `whatsapp_link(string $tel, string $texto): ?string`
  (`https://wa.me/52{digitos}?text=` con `rawurlencode`); botón en vistas de resultados
  del empleado con el link del portal en el mensaje.

### Fase 8 — Interpretación asistida por IA (SLM) sobre resultados

Va **después** de la Fase 7: necesita histórico de pacientes y el portal ya consolidados.
Análisis completo de viabilidad y requisitos en la bitácora
(«2026-07-27 — Diseño: interpretación asistida por IA»). Resumen de la fase:

- **8.0 — Capa determinista (prerequisito, sin IA).** Persistir la bandera
  alto/bajo/normal por campo en `resultado_detalle`; *delta check* contra estudios
  previos del mismo `idcliente`; mostrar ambos en captura, HTML y PDF.
  Esto ya da valor solo y construye el payload que el modelo necesitará.
- **8.1 — Datos faltantes.** Catálogo cerrado de unidades; código LOINC por campo en
  `campos_resultado`; campo de contexto clínico en la orden (motivo, médico, medicación,
  embarazo); auditar el % real de `cliente.sexo` / `cliente.fnacimiento` capturados.
- **8.2 — Servicio.** `app/Services/InterpretacionService.php` (patrón `VentaService`)
  detrás de una interfaz de proveedor; tabla `interpretacion` (`tenant_id`, `idmuestra`,
  `modelo`, `version_prompt`, `payload_hash`, `salida` JSON, `estado`, `revisado_por`);
  ejecución **asíncrona** (tabla de pendientes + `spark` command por cron), nunca
  bloqueando el guardado de captura.
- **8.3 — UI.** Borrador visible para laboratorista/químico, marcado como generado por IA,
  editable y con firma humana obligatoria antes de pasar a `PdfResultado` o al portal.
- **8.4 — Evaluación.** *Golden set* de 50–100 casos reales con la interpretación que
  escribiría un químico clínico; se corre ante cada cambio de prompt o de modelo.
- **8.5 — SaaS.** Opt-in por tenant, cuota de uso por tenant, prompt ajustable por
  laboratorio (candidato a plan de precio superior).

---

## Verificación

- Por fase: `docker compose up -d` + `php spark migrate` en contenedor + `vendor/bin/phpunit`
  (existen `tests/unit/HealthTest.php` y `PdfSmokeTest.php`).
- Tests nuevos: `BaseTenantModelTest` (insert inyecta tenant; find cross-tenant → null),
  `WhatsappHelperTest`.
- **Prueba de aislamiento (cierre Fase 2/3):** dos tenants en dos navegadores en paralelo —
  catálogo, clientes, pendientes, reportes y abonos no se mezclan.
  Auditoría `grep -rn "db->query\|db->table" app/` confirmando filtro tenant en cada sitio.
- Flujo completo por tenant: catálogo → carrito → venta → ticket → captura lab → resultados →
  abono → portal público → link WhatsApp.
- Responsive: prueba manual a 375/768/1280px de cada pantalla por rol.

## Fuera de alcance (esta actualización)

- Auto-registro público de tenants / suscripciones de pago.
- Subdominios por tenant (el diseño no lo bloquea a futuro).
- Notificaciones por email, lector de huella, app móvil, backup automático.

---

## Bitácora de avances

### 2026-07-15 — Auditoría de BD: soft delete + seguimiento de responsabilidades

Trabajo adelantado fuera del orden de fases (a petición explícita): auditoría completa de
`labsaas_db` con campos de "quién hizo qué y cuándo", soft delete y revisión de llaves foráneas.

**Hallazgos de la auditoría:**
- Las 14 tablas (InnoDB, utf8mb4_unicode_ci) no tenían timestamps, responsables ni soft delete.
  Las banderas `analisis.activo`, `cliente.status` y `usuario.activo` significan "inactivo",
  no "eliminado" — se conservan con ese significado de negocio.
- Las 15 FKs existentes están correctas (todas NO ACTION = RESTRICT, coherente con el fix del
  commit `5d9121a`). Faltaban: unicidad en `venta_muestra` e índices de consulta.
- `ventas.idconsecutivo` NO es FK: guarda el número de folio vigente al vender (lo llena
  `VentaService`). El nombre confunde; renombrarlo a `folio` queda para la Fase 2.

**Migraciones creadas y aplicadas** (`app/Database/Migrations/`, primeras del proyecto;
la BD viva se gestiona desde ahora con `php spark migrate`, `schema.sql` queda como base histórica):
1. `2026-07-15-120000_AddAuditFields.php` — a las 14 tablas: `created_at` (DEFAULT
   CURRENT_TIMESTAMP en BD), `updated_at` (ON UPDATE automático), `created_by`, `updated_by`;
   y en las 12 tablas de negocio además `deleted_at`/`deleted_by` (soft delete). `consecutivo`
   y `maximos` no llevan soft delete (contadores/config que nunca se eliminan). Los DEFAULT a
   nivel de BD cubren también las escrituras con query builder crudo. Los campos `*_by` van
   **sin FK a `usuario` a propósito**: son rastro histórico y no deben bloquear escrituras.
2. `2026-07-15-120100_CreateAuditoria.php` — tabla `auditoria` (id, idusuario, login, accion,
   tabla, idregistro, datos JSON, ip, created_at) con FK a `usuario` ON DELETE SET NULL e
   índices por usuario, (tabla, idregistro) y fecha. Los `*_by` guardan el ÚLTIMO responsable;
   `auditoria` guarda el HISTORIAL completo (insert/update/soft_delete/delete/login/logout).
3. `2026-07-15-120200_FixForeignKeys.php` — `UNIQUE uk_venta_muestra(idventa, idmuestra)`,
   índice `idx_muestra_status_fecha` (pantalla de pendientes del lab) e `idx_ventas_fecha`
   (corte de caja/reportes). Todas las migraciones son idempotentes (guards con
   `fieldExists`/`tableExists`/información de índices) y tienen `down()`.

**Código nuevo/modificado:**
- `app/Models/BaseAuditModel.php` (NUEVO) — base con `$useTimestamps`, `$useSoftDeletes`,
  estampado de `created_by/updated_by/deleted_by` desde `session('idusuario')` (callbacks
  before*, incluidos batch), override de `delete()` para estampar `deleted_by`, y bitácora
  automática en `auditoria` en cada insert/update/delete (redacta `password_hash`; nunca
  lanza — un fallo de bitácora no tumba la operación de negocio).
- Los 10 modelos (`Abono`, `Analisis`, `Cliente`, `Empleado`, `Muestra`, `Precio`,
  `ResultadoDetalle`, `Sucursal`, `Usuario`, `Venta`) ahora extienden `BaseAuditModel`.
- Consultas SQL crudas blindadas con `deleted_at IS NULL`: `VentaModel` (6 métodos, incluidos
  los JOIN a `abonar` para que los saldos ignoren abonos eliminados), `MuestraModel` (4),
  `PrecioModel::getTabla`, `AbonoModel::getTotalAbonado`, `AnalisisModel` (2).
- `app/Helpers/lab_helper.php` — helper nuevo `auditoria_log()` para eventos fuera de modelos.
- `app/Controllers/Auth.php` — registra `login`/`logout` en bitácora y excluye usuarios
  soft-deleted del login.
- `app/Services/VentaService.php`, `Admin/Sucursal.php`, `Admin/Config.php` — estampan
  responsable en sus escrituras crudas (`consecutivo`, `venta_muestra`, `maximos`).
- `INSTALACION.md` — instalaciones nuevas: `schema.sql` + `seed.sql` y después `php spark migrate`.

**Verificación realizada:**
- `docker exec labsaas php spark migrate` OK; esquema confirmado contra `information_schema`.
- Smoke test funcional (comando spark temporal, ya eliminado): insert → update → soft delete
  en `cliente` dejó timestamps correctos, `find()` deja de ver el registro, `withDeleted()`
  sí lo ve, y la bitácora registró las 3 acciones.
- PHPUnit 7/7 OK; `/login` responde 200.

**Fix colateral:** el `.env` apuntaba a `damp_db`/`lab_saas_db` (hosts/BD inexistentes);
corregido a `damp-db`/`labsaas_db`. En CLI el `.env` tiene precedencia sobre las variables
del contenedor — con los valores viejos `spark migrate` no conectaba.

**Limitaciones conocidas (documentadas en el código):**
- En deletes por `where()` sin id (p. ej. `ResultadoDetalleModel::guardarCampos` al recapturar
  resultados) `deleted_by` queda NULL; la bitácora sí registra quién ejecutó la acción.
- Un `login` de usuario soft-deleted no puede reutilizarse (`uk_login` sigue único global);
  liberar el login requiere hard delete o renombrar el registro anterior.
- En CLI (seeds/comandos) los `*_by` quedan NULL y la bitácora registra `login='cli'`.

### 2026-07-15 — Rediseño Modernize aplicado (adelanto de Fase 5)

- `public/css/app.css` (NUEVO): tokens Modernize mapeados a variables Bootstrap 5.3 +
  estilos de topbar, menú horizontal, tablas, badges pastel globales (los `.badge.bg-*`
  sólidos se ven pastel sin tocar las vistas) y página de login.
- `app/Views/auth/login.php` rediseñado (DM Sans, tarjeta con sombra Modernize, blobs
  decorativos, toggle de contraseña).
- Los 3 layouts (`layouts/admin|empleado|lab.php`) reescritos con el patrón horizontal
  de dos niveles usando partials compartidos nuevos: `partials/topbar.php` (marca + chip
  de rol pastel + dropdown de usuario con avatar de iniciales), `partials/navmenu.php`
  (menú horizontal `offcanvas-lg`: barra en escritorio, offcanvas con hamburguesa en
  móvil, item activo con `url_is()`), `partials/flash.php` (alertas pastel).
  La sidebar oscura del admin y las navbars azul/verde desaparecen; los roles se
  distinguen por chip (Admin rojo, Empleado azul, Laboratorio verde pastel).
- Usuarios de prueba creados: `test_admin`/`Prueba2026admin` (0), `test_empleado`/
  `Prueba2026emp` (2, con empleado en Sucursal Principal), `test_lab`/`Prueba2026lab`
  (1, ídem). Los 3 verificados por HTTP con login CSRF completo.
- Verificación: las 30 rutas GET de los 3 módulos (incluidas ediciones con id real,
  captura y resultados) responden 200 con `app.css` presente.
- Pendiente de Fase 5: extraer JS a `public/js/app.js` (helper fetch/CSRF), patrón
  tarjeta en móvil para tablas clave, pulir vistas interiores (KPI cards del dashboard).

### 2026-07-15 — UX: DataTables 2 + Select2 + filtrado en vivo

- **Stack**: se agregó jQuery 3.7.1 por CDN (decisión explícita del usuario: requisito de
  DataTables/Select2; convive con el fetch vanilla existente). Nuevos CDNs en los 3 layouts:
  `datatables.net-bs5@2.3.2`, `select2@4.1.0-rc.0` + tema `select2-bootstrap-5-theme@1.3.0`.
  Los layouts ganaron `renderSection('scripts')` al final del body (antes NO existía y los
  scripts de vista corrían antes de las librerías).
- **`public/js/app.js` (NUEVO)**: i18n español inline, auto-init de DataTables en
  `table.js-datatable` (pageLength 25, `order: []`, `th.dt-no-sort` no ordenable,
  `data-dt-search="false"` oculta el buscador integrado) y de Select2 en todos los
  `select.form-select` (excluye `.dt-input` de DataTables y `.js-select2-manual`;
  `minimumResultsForSearch: 8` oculta el buscador en selects cortos). Helpers:
  `LB.getTable`, `LB.filtroColumna` (regex exacta por columna), `LB.select2Remoto`
  (Select2 ajax contra endpoints `?q=`, `mapResult` devuelve `{id, text, ...registro}`).
- **DataTables aplicado a**: empleados, sucursales, usuarios (modales movidos fuera del
  tbody — eran HTML inválido), capturadas de lab, análisis, clientes, pendientes y precios.
- **Filtrado en vivo**: Análisis y Clientes perdieron su `paginate(20)` + filtros GET
  server-side (controllers devuelven `findAll()`; la rama JSON de `Cliente::index` por
  `Accept: application/json` se conservó); sus inputs de búsqueda filtran con `dt.search()`
  al teclear y los selects de categoría/tipo filtran por columna. Lab/pendientes filtra
  categoría client-side; se eliminó `Pendientes::filtrar` y su ruta (404 ahora).
- **Precios (edición inline preservada)**: los `<form id="precio-X">` salieron de la tabla
  (eran hijos inválidos del td) y viven después de `</table>` con su CSRF DENTRO; los inputs
  y el botón por fila se asocian por atributo `form=`. Así DataTables pagina/filtra sin
  romper el guardado (verificado por HTTP: POST con CSRF del form externo → 303 + BD actualizada).
- **Autocompletes → Select2 remoto** (backend intacto): cotización (con `tags: true` para
  cliente nuevo + hidden `nombre` + validación en submit; el autollenado usa
  `$('#sexo').val().trigger('change')` porque ahora es Select2), abonos, monedero y
  resultados. Rewire de listeners nativos → jQuery en `#tipoClienteSelect` del catálogo
  (Select2 solo dispara eventos jQuery).
- **CSS**: bloque DataTables/Select2 en `app.css` con los tokens Modernize (focus, radius,
  paginación con primary, dropdown con sombra de tarjeta).
- **Verificado**: 18 rutas GET con los 3 roles → 200 con marcadores correctos; guardado
  inline de precios; endpoint JSON de clientes (mín. 2 caracteres); carrito agregar/listar
  con rotación CSRF; PHPUnit 7/7.
- **Regla nueva para vistas**: los scripts de vista van en `section('scripts')` (no inline
  en content) para tener jQuery/DataTables/Select2/app.js disponibles.

**Impacto sobre las fases planeadas:**
- Fase 1 (baseline de `schema.sql` + seeders) sigue pendiente; si se crea el baseline, debe
  ordenarse ANTES de estas tres migraciones o incluir sus guards.
- La migración `AddTenantId` de la Fase 2 debe considerar los nuevos campos: los `*_by`
  seguirán siendo ids globales de `usuario`, y la tabla `auditoria` deberá ganar `tenant_id`
  (o filtrarse vía el join a `usuario`) para el panel por tenant.

### 2026-07-16 — Shield (auth) + rutas planas

**Fase A — CodeIgniter Shield 1.3 reemplaza el auth casero:**
- `composer require codeigniter4/shield` (+ `codeigniter4/settings`). Configs publicadas:
  `app/Config/Auth.php` (login por `username`, regex propio que admite `_` y 45 chars,
  sin registro público ni magic links, `userProvider` = `App\Models\UserModel`) y
  `app/Config/AuthGroups.php` (grupos `superadmin` —reservado SaaS—, `admin`,
  `laboratorista`, `empleado`). Helpers `auth`/`setting` autoloaded.
  `Config\Security::$csrfProtection` pasó a `'session'` (requerido por Shield).
- **Migración `2026-07-16-210000_MigrarUsuarioAShield`**: `usuario` → `users` (+
  `auth_identities` tipo `email_password` con email placeholder `login@labbello.local` y el
  hash bcrypt tal cual; `auth_groups_users` según accesslevel) **preservando IDs**
  (`users.id = idusuario`, obligatorio por FKs y CDB). `users.username` ampliado a 45,
  columna `idempleado` añadida a `users` (FK a empleado). FKs repuntadas a `users(id)` con
  columnas a INT UNSIGNED: `fk_vta_usuario`, `fk_mta_usr`, `fk_aud_usuario`. `usuario`
  eliminada; `down()` la reconstruye. Los `created_by/updated_by` históricos de la propia
  tabla usuario no se portaron (la bitácora conserva la historia).
- **Sesión de dominio**: `poblar_sesion_usuario()` en `lab_helper` (JOIN empleado→sucursal +
  mapa grupo→accesslevel) corre en el evento `login` de Shield (`app/Config/Events.php`) y
  como red de seguridad en `AuthFilter` — así `BaseAuditModel`, la bitácora y los ~14
  archivos que leen `session('idusuario'/'accesslevel'/'idsucursal')` no se tocaron.
- `Auth.php` slim (`auth()->attempt()` / `auth()->logout()` + destroy); login/logout siguen
  en bitácora (tabla `users`). "Desactivar" usuario = `active=0` **+ `status='banned'`**
  (con Shield sin activación por email, `active=0` solo NO bloquea el login; el ban sí).
- `App\Models\UserModel` (extiende el de Shield): `idempleado` en allowedFields,
  `findByIdEmpleado`, `getAllConEmpleado`, `existeLogin` (withDeleted: UNIQUE en BD) y
  `contarAdminsActivos` (JOIN a `auth_groups_users`). `UsuarioModel` eliminado.
  `Admin/Usuario.php` y el alta de usuario en `Admin/Empleado.php` reescritos sobre Shield
  (grupo en vez de accesslevel; el CRUD de users audita manual con `auditoria_log()` porque
  el UserModel de Shield no extiende `BaseAuditModel`). `EmpleadoModel::getAllConSucursal`
  ahora joinea `users`.

**Fase B — rutas planas (adiós prefijos /admin, /empleado, /lab):**
- `Routes.php`: 3 grupos con prefijo `''` y filtros `['auth', 'group:...']` (el `auth` propio
  = login + red de seguridad de sesión; el `group:` de Shield gatea el rol; wrong-role →
  redirect a `/`). Homes con path propio: `/dashboard` (admin), `/catalogo` (empleado),
  `/pendientes` (lab). `GET /` = `Home::index` dispatcher por grupo. **Lab renombrado:**
  `lab/resultados*` → `/capturas*` (chocaba con los resultados del empleado).
- Barrido: ~59 `base_url()` en vistas (incl. URLs AJAX en `section('scripts')`), arreglos
  `items`/`match`/`homeUrl` de los 3 layouts, fetch del carrito del layout empleado y ~40
  redirects en controllers. Sin redirects de compatibilidad (corte limpio, app interna).
- `AuthFilter` ya no recibe niveles como argumento (eso es del filtro `group:`).

**Verificado**: PHPUnit 12/12 (nuevo `tests/database/ShieldAuthTest.php`: login por username,
sesión de dominio, accesslevel por grupo, rechazo de password malo, ban bloquea, regla del
último admin — corre en SQLite migrando solo los namespaces Shield/Settings). Por HTTP con
los 3 usuarios de prueba: login → home correcto, 16 rutas planas 200, cruces de rol
redirigen a su home, `/` dispatcher (anónimo → /login), carrito agregar/listar/quitar con
rotación CSRF de sesión, bitácora registrando login/logout/CRUD.

**Impacto sobre las fases planeadas** (ya reflejado arriba en Decisiones/Fases 2-3/7):
super-admin = grupo Shield; tenant a sesión en el evento `login`; `users.tenant_id`
(no `usuario`); el portal público NO puede usar `/resultados`.

### 2026-07-21 — Carpeta `docs/` + análisis de contexto

- Se creó `docs/` y se movieron aquí los documentos de plan/operación generados
  por sesiones de IA: `PLAN_ACTUALIZACION.md`, `PLAN_MODERNIZACION.md`,
  `INSTALACION.md`.
- Nuevos: `docs/README.md` (índice) y `docs/ANALISIS_CONTEXTO.md` (lectura
  consolidada + implicaciones para un POS en `/dashboard`).
- `CLAUDE.md` (raíz) actualizado para apuntar a `docs/*`.
- Siguiente paso acordado con el usuario: **planear** el punto de venta en la
  ruta `dashboard` (aún sin implementación).

### 2026-07-21 — Unificación de vistas: alta de clientes en modal (AJAX)

Primera pieza del patrón “listado + modal” (sin salir de la vista):

- **Clientes**: botón «Nuevo cliente» abre modal Bootstrap en `/clientes`; POST
  AJAX a `/clientes/nuevo`; respuesta JSON con `cliente` + `csrfHash`; la fila
  se agrega al DataTable sin recargar.
- Partial reutilizable `app/Views/admin/clientes/_form_fields.php` (también lo
  usa `form.php` de edición / fallback GET).
- Helpers globales en `public/js/app.js`: `LB.initCsrf` / `LB.setCsrfHash`,
  `LB.postForm`, `LB.toast` (estilo flash Modernize). Meta `csrf-token` /
  `csrf-name` en los 3 layouts. CSS `.lb-toasts` en `app.css`.
- `Admin\Cliente::nuevo` dual-mode (AJAX → JSON; no-AJAX → redirect) +
  validación mínima (nombre, mail, tipo, sexo).

### 2026-07-21 — Clientes: edición en el mismo modal

- Modal único `#modalCliente` para alta y edición (título, action y CTA dinámicos).
- **Editar**: botón en listado → `GET /clientes/editar/{id}` Accept JSON rellena
  el form; `POST` AJAX guarda y **reemplaza la fila** en DataTables (`data-idcliente`).
- `Admin\Cliente::editar` dual-mode (GET JSON / POST JSON / vista full-page fallback).
- Payload de cliente ampliado (campos de form + puntos) para relleno y listado.
- Rutas GET/POST de página completa se conservan por compatibilidad.

### 2026-07-21 — Empleados: modal alta + edición (mismo patrón)

- `/empleados`: modal único `#modalEmpleado` (crear/editar sin salir del listado).
- Partial `admin/empleados/_form_fields.php` (login opcional solo en alta).
- `Admin\Empleado` dual-mode AJAX; `EmpleadoModel::findConSucursal`.
- Alta con login opcional: toast incluye contraseña temporal; edición muestra
  info de acceso (link a Usuarios / crear acceso).
- DataTables: fila con `data-idempleado`, upsert in-place.

### 2026-07-21 — Sucursales + Usuarios: modal alta + edición

**Sucursales** (`/sucursales`): mismo molde que clientes (form corto). Al crear se
sigue insertando fila en `consecutivo`. Partial `_form_fields`, dual-mode AJAX.

**Usuarios** (`/usuarios`):
- Modal único alta/edición (password solo en alta; reglas login único y último admin).
- Modal único de contraseña (AJAX) reutilizable; desactivar por AJAX actualiza fila.
- `?idempleado=` abre el modal de alta con empleado preseleccionado.
- `UserModel::findConEmpleado`; partial `admin/usuario/_form_fields.php`.

### 2026-07-22 — POS unificado en `/cotizacion` (iteración 1)

Reemplazo del flujo multi-página catálogo → carrito → cotización por una sola
pantalla tipo caja (8 col proceso + 4 col cobro), admin + empleado.

**Decisiones de producto:**
- Ruta canónica: `/cotizacion` (menú «Venta»). `/catalogo` redirige al POS.
- Tipo de precio: selector manual (General / GeneralD / Especial / EspecialD).
- Descuento % editable por ítem; abono inicial; métodos de pago solo UI (sin BD).
- Cierre: Venta + Solo cotizar vía `VentaService` intacto.
- Un análisis = una línea (sin cantidad N).

**Backend extendido (sin migración de esquema):**
- `Carrito`: `descuento`, `tipo` (recalcula precios), `ver`/`agregar`/`quitar`
  devuelven `items` + subtotal/descuentoTotal/totalPagar + csrfHash.
- `Catalogo::buscar` (`GET /catalogo/buscar?q=`) para Select2 de análisis.
- `POST /clientes/nuevo` movido al grupo `admin,empleado` (alta rápida del POS).
- Modal cliente extraído a `admin/clientes/_modal.php` (listado + POS).

**UI:**
- Vista POS en `empleado/cotizacion/index.php` con layout dual admin/empleado.
- Select2 paciente + botón alta modal; Select2 análisis; cards de carrito;
  panel cobro sticky con previa en vivo.
- Menú empleado: un solo ítem «Venta»; home → `/cotizacion`. Admin: link «Venta».
- Ticket con link «Nueva venta» y layout según rol.

**Fuera de alcance (iteraciones posteriores):** método de pago en BD, carrito
persistente, cantidad N, auto-tipo de precio del paciente, KPIs en dashboard.

### 2026-07-22 — Documento de contexto del POS

- Creado **`docs/PLAN_POS.md`**: estado de la iteración 1, mapa de endpoints/archivos,
  checklist de aceptación y **backlog priorizado** (POS-01…POS-34).
- Actualizados `docs/README.md`, `docs/ANALISIS_CONTEXTO.md` (§ POS) y `CLAUDE.md`
  para apuntar al nuevo doc.

### 2026-07-22 — POS: UI cards blancas + sin tipo de precio

- Cards del proceso, carrito e ítem de método de pago: fondo blanco (`pos-card-surface`).
- Eliminado selector «Tipo de precio» del POS y el endpoint `POST /carrito/tipo`.
- Carrito/catálogo buscan precio siempre en columna `General` (sin `session('tipo_cliente')`).
- Alta de cliente en venta: `tipo_cliente` fijo `'General'`.
- CRUD de clientes y tabla `precios` (GeneralD/Especial…) se conservan para admin;
  descuentos/fidelidad del POS quedan como pendiente (POS-11 en `PLAN_POS.md`).

### 2026-07-22 — POS en dashboard del admin

- `/dashboard`: cards de acceso (Análisis, Clientes, Reportes) + **punto de venta** embebido.
- UI/JS del POS extraídos a `_pos.php` y `_pos_scripts.php` (reuso en `/cotizacion` y dashboard).
- Error al procesar venta: admin vuelve a `/dashboard`, empleado a `/cotizacion`.

### 2026-07-22 — Docs de contexto al día (POS)

- Reescrito **`docs/PLAN_POS.md`** al estado actual: POS en `/dashboard` + `/cotizacion`,
  partials, sin tipo de precio, checklist y backlog (incl. POS-17 KPIs con POS).
- Actualizados **`docs/ANALISIS_CONTEXTO.md`**, `docs/README.md` y `CLAUDE.md`.

### 2026-07-27 — POS: precio del análisis no se reflejaba en venta

**Causa:** el form de análisis al **editar** solo actualizaba `analisis.precio`; el POS
y el carrito leen `precios.General`. Al alta sí se sincronizaba; al editar no.
Datos históricos desfasados (p. ej. base $350, General $0 → carrito en $0).

**Fix:**
- `Analisis::editar` / alta llaman `PrecioModel::sincronizarDesdeAnalisis()`.
- `actualizarPrecios()` mantiene `analisis.precio = General` (menú Precios → listados).
- `getPrecioParaCliente('General')` hace fallback a `analisis.precio` si General=0 o no hay fila.
- Migración `2026-07-27-120000_SyncAnalisisPrecios` alinea datos existentes y crea filas faltantes.
- Formulario: label «Precio de lista» + nota de que es el General del POS.

### 2026-07-27 — Tablero Kanban de laboratorio (`/lab`)

- Órdenes = ventas cobradas; subtareas = muestras (análisis).
- Estados: pendiente → en_proceso → en_revisión → completada.
- Asignación manual + auto al pasar a en_proceso; comentarios en hilo.
- Captura reutilizada; al guardar → `lab_status=en_revision`. Completar exige resultados.
- Cotizar **no** genera muestras (solo venta comercial).
- Migración `LabKanbanMuestra` + tabla `lab_comentario`.
- Home laboratorista → `/lab`; menú admin **Lab**.

### 2026-07-27 — Personal unificado (empleados + usuarios en UI)

- Nuevo módulo **`/personal`**: un listado y un modal (ficha + acceso + rol).
- Tablas **sin cambios**: sigue `empleado` + `users` (Shield) + `idempleado`.
- Acciones: crear/editar ficha, crear o actualizar login/rol, contraseña, desactivar/reactivar.
- Menú admin: **Personal** reemplaza Empleados y Usuarios; GET `/empleados` y `/usuarios` redirigen.
- Cuentas sin ficha se listan al pie (informativo).

### 2026-07-27 — Análisis: modal alta + edición (mismo patrón que clientes)

- `/analisis`: modal único `#modalAnalisis` (crear/editar sin salir del listado).
- Partials `_form_fields.php` + `_modal.php`; `form.php` reutiliza el partial (fallback).
- `Admin\Analisis` dual-mode AJAX (GET relleno, POST JSON con payload + `csrfHash`).
- Precio de lista sigue sincronizando `precios.General` al guardar.
- Eliminar por AJAX quita la fila (o desactiva si hay muestras).

### 2026-07-27 — Ticket de laboratorio (`/venta/ticket/{id}`)

Comprobante de lab con datos de negocio, desglose, pago y descuentos.

**Persistencia (migración `TicketVentaCampos`):**
- `ventas`: subtotal, descuento_items, descuento_puntos, metodo_pago, recibido, cambio
- `venta_muestra`: precio_lista, descuento_pct, precio_final (snapshot al cobrar)
- `abonar.metodo_pago`

**POS:** método de pago se guarda; en efectivo hay «Recibido» + cambio en vivo.
**Vista + PDF:** header sucursal, paciente, líneas con CDB/importes, totales,
promoción monedero, método/abonado/cambio/saldo; estilos `@media print`.

### 2026-07-28 — MT-0 núcleo multi-tenant (datos + sesión)

- Migraciones `CreateTenant` + `AddTenantId`: tabla `tenant` (id=1 labSoft, onboarding
  completado), `tenant_id` en tablas de negocio, `users.tenant_id` nullable,
  `force_password_reset`, `auditoria.tenant_id`, `maximos` UNIQUE(tenant_id) + AUTO_INCREMENT.
- `BaseTenantModel` (scope fail-closed, inject, `forTenant` / `withoutTenant`).
- Helpers `tenant_id()` / `tenant_id_nullable()`; `poblar_sesion_usuario` carga tenant y
  marca `tenant_bloqueado` si inactivo; `AuthFilter` cierra sesión en ese caso.
- SQL crudo de modelos de negocio + `VentaService` / Config / Monedero scoped.
- Stub `/super` + `layouts/super` (home del superadmin; CRUD tenants = MT-1).
- Tests: `TenantHelperTest`, `BaseTenantModelTest` (aislamiento cross-tenant).
- Onboarding de dueño **no** implementado aún — plan en `PLAN_ONBOARDING.md`.

### 2026-07-28 — MT-1 backoffice superadmin + provisión de laboratorios

- `TenantProvisioner`: transacción tenant + sucursal + consecutivo + maximos +
  empleado + admin Shield + catálogo clonado desde tenant 1 (precios $0).
- `CatalogoPlantilla::clonarDesdeTenant`.
- Rutas `/super/tenants*`: listado, alta, pantalla credenciales una vez, detalle,
  editar metadatos, suspender/reactivar, reset admin.
- Dashboard `/super` con KPI y altas recientes; menú Laboratorios.
- Tests `TenantProvisionerTest` (provisión + aislamiento).
- Onboarding dueño y force-password UI: siguen pendientes (`PLAN_ONBOARDING.md`).

### 2026-07-28 — Landing pública + registro trial (LR-0 + LR-1)

- `/` es landing de producto para visitantes; autenticados van al home de rol.
- CTAs: **Entrar** (`/login`) y **Probar gratis** (`/registro`).
- Registro público: `TenantProvisioner` con `plan=trial`, catálogo precios 0,
  `force_password_reset=0`, origen `public`; honeypot + captcha suma + throttle 5/h.
- Auto-login al admin creado → `/dashboard`. Email de bienvenida fail-open.
- Layout `layouts/public.php`, vistas `public/landing` y `public/registro`.
- Superadmin sigue creando labs en `/super/tenants/nuevo`.

### 2026-07-28 — Animaciones GSAP en landing

- CDN GSAP 3 + ScrollTrigger solo en `public/landing.php`.
- `public/js/landing.js`: hero timeline, reveals al scroll (stats, features, steps,
  quotes, trial, CTA), parallax suave de blobs/preview; respeta `prefers-reduced-motion`.
- Clases hook `gs-*` en markup; fallback sin CDN/JS (`.gsap-ready` + `<noscript>`).
- Test: `LandingRegistroTest::testLandingCargaGsap`.

### 2026-07-28 — Fix registro: maximos.PRIMARY duplicate

- Prod: `Duplicate entry '1' for key 'maximos.PRIMARY'` al provisionar tenant
  (tabla legacy sin AUTO_INCREMENT; AI de AddTenantId falló en silencio).
- Migración `2026-07-28-200000_MaximosAutoIncrement` fuerza AI + contador.
- `TenantProvisioner`: si id no es AI, asigna `MAX(id)+1` explícito.
- Logs de fallo de registro más verbosos (clase, DB, stack).

### 2026-07-28 — CreateCoreBusinessTables (BD vacía)

- Prod fallaba migrate: `Table '…analisis' doesn't exist` (solo migraciones, sin
  `schema.sql`).
- Migración `2026-07-14-100000_CreateCoreBusinessTables`: CREATE IF NOT EXISTS
  de tablas de negocio base.
- Harden: AddAuditFields, SyncAnalisisPrecios, FixForeignKeys, Ticket/Referencia/
  Sucursal/Kanban, EnsureMT (`safeFieldExists`).
- Docs `INSTALACION.md`: flujo BD vacía con `migrate --all`.

### 2026-07-28 — Ensure multi-tenant schema (catch-up)

- Auditoría: no todas las tablas/columnas quedaban cubiertas si alguna migración
  fallaba en silencio en prod (maximos AI, doctores PK global, columnas producto).
- Nueva migración idempotente `2026-07-28-210000_EnsureMultiTenantSchema`:
  - `tenant_id` en tablas de negocio + users/auditoria/feedback
  - audit/soft-delete en tablas nuevas
  - maximos AI, columnas producto (idcategoria, public_token, sucursal, muestra…)
  - doctores: PK `id` AI + unique `(tenant_id, correodoctor)`; suelta FK ventas
  - crea tablas faltantes (categoria, quimica, feedback, lab_comentario, tenant)
- Sin tenant_id a propósito: `auth_*`, `settings`, `migrations`, `tenant`,
  `feedback_adjunto` (hijo de feedback).

### 2026-07-28 — Rewrite copy landing (auditoría conversión)

- Pricing honesto: **$0 en fase de desarrollo**; precios del software **por determinarse**
  (hero, stats, card acceso, FAQ, CTA final, footer, OG en `Home::index`).
- Secciones nuevas: PAS/problema, para quién/no para quién, FAQ (7), escenarios por rol
  (no reseñas falsas). Features en beneficio-first; CTAs específicos.
- Quitado lenguaje de suscripción madura (“Cancela cuando quieras”, badge “Recomendado”).
- Nav: Acceso $0 · FAQ · Empezar gratis. GSAP ampliado (FAQ, multi-split/CTA).
- Docs: `MANUAL_IDENTIDAD_PUBLICA`, `PLAN_LANDING_REGISTRO` §7/§11, `ANALISIS_CONTEXTO`,
  `CLAUDE.md`, `docs/README`.
- Tests: `testLandingPricingHonestoEnDesarrollo` + contratos de secciones.

### 2026-07-28 — Rangos de referencia (min/max) por campo de resultado

- Helpers: `resolver_referencia`, `valor_fuera_de_rango`, `formatear_referencia`,
  `marcar_valor_rango`, `parse_numero_lab` (sexo M/F/A + edad en años).
- Admin › Análisis: al editar, UI de unidad + filas de rangos por campo de plantilla;
  se guardan en `campos_resultado.referencias[]`.
- Captura: muestra ref del paciente y resalta fuera de rango (no bloquea).
- Resultados HTML/PDF: columna Referencia y marca ↑/↓ si aplica.
- Tests: `RangoReferenciaTest`.

### 2026-07-28 — CRUD Categorías de análisis

- Tabla `analisis_categoria` (tenant_id, nombre, orden, activo) + `analisis.idcategoria`.
- Migración con backfill desde `descripcion` + defaults clásicos.
- Admin › **Categorías**: listado + modal crear/editar/eliminar (bloqueo si hay análisis).
- Form de análisis: select por idcategoria; sync de `descripcion` al guardar.
- `CatalogoPlantilla` clona categorías y mapea FK al provisionar tenants.

### 2026-07-28 — Módulo Químicas / perfiles (paquetes de reactivos)

- Tablas `quimica` + `quimica_item` (tenant-scoped).
- Admin › **Químicas**: crear nombre, nº de reactivos, precio fijo; buscar y agregar análisis.
- POS: búsqueda unificada análisis + químicas; carrito con `tipo=paquete`.
- Venta: expande a una muestra por reactivo; precio del paquete en el primer ítem.

### 2026-07-27 — Diseño: interpretación asistida por IA (SLM) — análisis previo

Evaluación de qué haría falta para que un modelo de lenguaje ayude a interpretar los
resultados. **No se implementó nada**; queda planteado como Fase 8 (arriba). Este es el
razonamiento que sustenta esa fase.

**Decisión de fondo: separar el cálculo de la redacción.**
Decidir si un valor está alto, bajo o normal es aritmética contra el rango de referencia y
ya está resuelto en `resolver_referencia()`. Un modelo de lenguaje **nunca** debe hacer esa
comparación: alucina umbrales, se equivoca con unidades y no es auditable. El SLM entra
únicamente *después*, sobre banderas ya calculadas, para lo que sí es lenguaje:
- correlacionar analitos entre sí (patrón ferropénico, colestásico vs. citolítico,
  disociación albúmina/globulina),
- redactar un comentario legible,
- sugerir pruebas de confirmación,
- traducir a lenguaje llano para el paciente (portal, Fase 7).

Fuera de alcance del modelo, siempre: diagnosticar y recomendar tratamiento.

**Brechas de datos detectadas (bloquean la calidad, no la implementación):**
- `cliente.sexo` y `cliente.fnacimiento` son `DEFAULT NULL`. Si en producción se capturan
  vacíos, `resolver_referencia()` devuelve `null` y el modelo se queda sin el insumo básico.
  Medir el % real antes de nada.
- `unidad` es texto libre por campo (`mg/dl` vs `mg/dL` vs `MG/DL`): imposible validar ni
  comparar entre tenants. Hace falta catálogo cerrado.
- La identidad de un analito es hoy el `nombre` del campo dentro de `campos_resultado`,
  definido libremente por cada laboratorio. `HB` / `Hgb` / `hemoglobina_total` son el mismo
  analito y el modelo no siempre lo infiere. Mapear a **LOINC** es el trabajo aburrido que
  hace funcionar todo lo demás (y sirve para interoperabilidad futura).
- No hay *delta check*. Comparar contra estudios previos del mismo `idcliente` es SQL puro
  sobre `muestra` + `resultado_detalle`, y probablemente es el mayor generador de valor
  clínico de toda la fase — sin IA de por medio.
- No existe contexto clínico (motivo del estudio, médico solicitante, medicación, embarazo).
  Sin él la interpretación se queda en describir lo obvio.

**Arquitectura acordada.** Captura en `Lab/Captura` → banderas deterministas → job encolado →
servicio arma payload JSON canónico (paciente anonimizado + analitos con valor, unidad, rango
aplicado, bandera e histórico) → el modelo devuelve **JSON estructurado validado contra
esquema**, nunca prosa suelta → se persiste en `interpretacion` con `modelo` y
`version_prompt` para poder reproducir una interpretación cuestionada meses después
(`auditoria_log()` cubre la otra mitad de la trazabilidad).

**Hosting — el tema legal manda sobre el técnico.** Son datos personales sensibles de salud
bajo la LFPDPPP: enviarlos a una API externa exige aviso de privacidad y consentimiento, y en
multi-tenant eso significa que **cada laboratorio cliente** debe aceptarlo, no solo nosotros.
Además la NOM-007-SSA3-2011 (confirmar con responsable sanitario) exige que los resultados los
libere un profesional autorizado.
- *API gestionada* (p. ej. Claude Haiku 4.5): cero infraestructura, calidad muy superior a
  cualquier modelo de 7B, costo por token bajo al volumen de un laboratorio; los datos salen
  del servidor.
- *Self-hosted* (Ollama/vLLM con Qwen o Llama 7-8B, o MedGemma según licencia): los datos no
  salen y es argumento comercial fuerte; el costo real no es la GPU sino el andamiaje extra
  (prompts rígidos, ejemplos, validación) y errores de razonamiento que un modelo grande no
  comete.
- **Secuencia recomendada:** prototipar contra API con datos sintéticos o anonimizados, medir
  calidad, y solo entonces decidir si un modelo local aguanta esa vara. Comprar GPU primero es
  la forma más común de gastar meses y descubrir que la interpretación no era suficientemente
  buena.

**Seguridad clínica (innegociable).** La interpretación se guarda siempre como *borrador*, se
muestra marcada visiblemente como generada por IA, y no llega a `PdfResultado` ni al portal del
paciente hasta que un humano la revisa, edita y firma. Sin un *golden set* de evaluación se
estaría ajustando a ciegas, sin forma de saber si una "mejora" empeoró algo.

**Siguiente paso concreto (no requiere modelo):** ejecutar la sub-fase 8.0 — persistir la
bandera por campo y el delta check, y mostrarlos en captura/HTML/PDF.

### 2026-07-27 — Módulo de ayuda / documentación (FAQ por rol)

Centro de ayuda en formato **preguntas y respuestas**, filtrado por rol de usuario.

**Rutas:** `GET /ayuda` y alias `GET /documentacion` (filtro `auth`, todos los roles).

**Archivos:**
- `app/Config/DocumentacionFaq.php` — categorías + ítems FAQ (roles, tags, HTML de respuesta)
- `app/Controllers/Documentacion.php` — filtro por rol, categoría y búsqueda `?q=`
- `app/Views/documentacion/index.php` — UI acordeón Modernize + sidebar de categorías
- Menú **Ayuda** en layouts `admin`, `empleado`, `lab` y `super`
- Estilos `.docs-*` en `public/css/app.css`

**Visibilidad:**
- `empleado` → general, acceso, POS, abonos, monedero, resultados
- `laboratorista` → general, acceso, laboratorio (+ resultados compartidos)
- `admin` → unión operativa (admin + empleado + lab); opción de ver también temas de plataforma
- `superadmin` → general/acceso globales + plataforma SaaS (tenants, provisión, trial)

**Contenido:** ~40 Q&A cubriendo flujo caja→lab→entrega, CDB/folio, puntos, personal,
categorías/químicas/rangos, corte de caja y backoffice multi-tenant.

### 2026-07-27 — Módulo de feedback (widget + bandeja superadmin)

Canal para que **cualquier usuario autenticado** envíe reportes/sugerencias; la **bandeja
de gestión es solo superadmin**.

**Envío (todos los roles con sesión):**
- Botón flotante inferior derecho + modal (`partials/feedback_widget.php` en layouts
  admin / empleado / lab / super).
- Campos: tipo (bug, falla, feature, recomendación, mejora UX, pregunta, otro), severidad
  opcional, título, descripción, capturas (hasta 5 imágenes, 3 MB, PNG/JPG/WEBP/GIF).
- Contexto auto: URL de la página, user-agent, usuario, rol, `tenant_id`.
- `POST /feedback` → JSON + CSRF; archivos en `public/uploads/feedback/{id}/`.

**Bandeja (solo superadmin):**
- `GET /super/feedback` listado con filtros estado/tipo/q + KPI nuevos/revisión.
- `GET /super/feedback/{id}` detalle, capturas, contexto; actualizar estado y notas internas.
- Menú **Feedback** en `layouts/super`; KPI «Feedback nuevos» en dashboard plataforma.

**BD:** migración `CreateFeedback` → tablas `feedback` + `feedback_adjunto`
(modelos `FeedbackModel`, `FeedbackAdjuntoModel`).

### 2026-07-27 — Reportes: dashboard con KPIs y gráficas

Reemplaza el listado plano de corte de caja por un dashboard de insights.

**UI (`/reportes`):**
- Filtros sucursal + rango + presets Hoy / 7 días / 30 días / Mes.
- Cards KPI: **ingresos netos (cobrado)**, vendido, por cobrar, ticket promedio
  (+ estudios y descuentos).
- Chart.js (CDN): ingresos día a día (vendido vs cobrado), donut métodos de pago,
  barras horizontales top 10 análisis y top 10 químicas.
- Tablas de ranking + desglose por sucursal (link a detalle existente).

**Backend:** `app/Services/ReporteService.php` — agregados con abonos en subconsulta
(evita duplicar `SUM(precio)` al hacer JOIN a `abonar`). Solo ventas `status=1`.

**Químicas en reportes:** migración `VentaMuestraIdQuimica` (`venta_muestra.idquimica`);
`VentaService` graba el id del paquete en cada línea expandida. Ventas históricas de
paquetes sin `idquimica` no entran al ranking de químicas (sí los análisis sueltos).

### 2026-07-27 — Portal público de resultados + verificación de identidad

Enlace seguro para pacientes (WhatsApp/correo) en lugar del PDF autenticado.

**Flujo:**
1. Venta cobrada genera `ventas.public_token` (32 hex) + `token_creado` (también lazy
   vía `VentaModel::asegurarPublicToken` / `urlPortalPublico`).
2. Rutas públicas: `GET /r/{token}`, `POST /r/{token}/verificar`, `GET /r/{token}/pdf`.
3. Antes de ver resultados, el paciente debe verificar:
   - **Últimos 4 dígitos** del celular (o teléfono) registrado, o
   - **Correo** exacto si no hay teléfono con ≥4 dígitos.
4. Pista enmascarada (`anonimizarTelefono` / `anonimizarCorreo`).
5. Sesión de desbloqueo 2 h; máx. 8 intentos y bloqueo 15 min.
6. Sin contacto registrado → pantalla de error (acudir a sucursal).
7. WhatsApp/email del módulo Resultados usan el link `/r/{token}` y mencionan la
   verificación.

**Archivos:** migración `VentaPublicToken`, `Portal.php`, vistas `public/portal_*`,
layout `layouts/portal.php`.

### 2026-07-27 — Landing pública estilo SaasSpace (solo marketing)

Rediseño de la **página pública** (no backoffice) tomando la **estructura** del demo
[SaasSpace / Tailgrids](https://saasspace.demos.tailgrids.com/) y manteniendo los
**colores Modernize** ya en uso (`--lb-*` en `app.css`).

**Secciones home:** nav anchors · hero centrado + CTAs · preview mock del producto ·
stats · features · split flujo caja→lab→entrega · 3 pasos · trial $0 · quotes ·
CTA banner · footer multi-columna.

**Archivos:**
- `app/Views/layouts/public.php` — nav sticky glass + footer columnas
- `app/Views/public/landing.php` — secciones marketing
- `app/Views/public/registro.php` — form alineado a la misma identidad
- `public/css/app.css` — bloque scoped `.public-site` / clases `public-*`
- `docs/MANUAL_IDENTIDAD_PUBLICA.md` — manual para futuras secciones
- Tests: `LandingRegistroTest` (secciones, layout, manual)

**Regla:** copiar divs/secciones del patrón SaaS; **no** copiar la paleta del demo.
Backoffice (admin/empleado/lab/super) sin cambios de UI.
